Table of contents
Open Table of contents
블로그 이전
구현과 배포
Astro 기반 개인 블로그를 세팅하고 배포했다. 티스토리 블로그를 사용했었는데 글 게시까지 과정에서 너무 피곤함이 느껴져 블로그 이전을 결심하게 됐다.
찾아보니 astro + github + cloudflare 조합이 간단하고 퀄리티도 좋아보였다. 그리고 도메인도 cloudflare에서 구매해둔 것이 있어 딱이라고 생각했다. 예상보다 쉽게 배포까지 설정이 가능했는데 cloudflare에 등록만 하면 정말 간단하게 배포가 진행된다.
블로그 템플릿은 공유되고 있는 템플릿 중 마음에 드는 것을 골라서 내 입맛에 맞게 수정했다. 원하는대로 결과가 잘 나온 것 같아 마음에 든다. 익숙하지 않은 프론트의 영역이라 AI를 통해서 수정을 진행했는데 큰 문제없이 수월하게 마무리 했다.
완성하고 나니 문득 개발자의 미래에 대한 생각이 잠깐 스쳤다. 밝은 미래일지 어두운 미래일지 상상이 잘 안된다.
특강 정리
캐시(Redis)만으로 못 푸는 조회 성능 문제가 있을 때, 아키텍처를 바꿔 조회를 개선하는 방법 중 하나가 CQRS다.
단일 모델의 한계
user 테이블과 User 클래스 하나로 CRUD를 처리하는 구조는 트래픽이 적을 때는 잘 동작한다. 하지만 데이터가 백만, 천만 단위로 커지면 읽기와 쓰기가 같은 모델, 테이블을 두고 경쟁하면서 병목이 생기기 시작한다.
| 원인 | 내용 |
|---|---|
| 읽기/쓰기 요구 불일치 | 이커머스: 주문 쓰기 후 주문 내역, 배송, 결제 조회가 훨씬 많다. (일 조회 약 5천만 건 → 약 580 TPS) |
| 조인, 집계 증가 | 주문+상품+회원+배송 조회가 늘면 Disk I/O로 응답이 5~10배 느려질 수 있음 |
| 자원 경합 (PostgreSQL) | 쓰기 증가 → dead tuple → autovacuum이 I/O 점유, 잠금 경합 |
| 스키마 변경 비용 | 예: points_used 컬럼 추가 → 수억 row backfill 시 락, 성능 저하 |
MVCC, 락 튜닝, READ 레플리카 등은 같은 모델 안에서의 한계 완화이지 읽기/쓰기 모델 분리가 아니다. 구조적 한계에서 CQRS를 생각해볼 수 있다.
CQRS란
- Greg Young(2010 제안) → Martin Fowler(2011 블로그 정리)
데이터 변경(Command)과 조회(Query)의 책임, 흐름, 모델을 분리해 각 목적에 맞게 최적화하는 패턴
| Command | Query | |
|---|---|---|
| 의도 | 시스템 상태 변경 | 정보 제공 |
| 부수 효과 | 있음 | 없어야 함 |
| 책임 | 도메인 규칙 검증 | 응답 형태, 성능 최적화 |
| CRUD | C/U/D | R |
| 예시 | 주문 생성/취소, 회원 수정, 판매 상태 변경, 구독 해지 | 주문 목록/상세, 검색, 상태 확인 |
분리 효과
- 쓰기는 정확성, 읽기는 성능, 응답 형태
- 읽기 변경이 쓰기 로직에 미치는 영향 감소
- 각각 독립 변경, 최적화
적용 4단계
- 단계를 올릴수록 원복 비용이 커진다. 1, 2, 3 순 점진적으로 도입
1단계. 모델만 분리
- 같은 RDBMS, 같은 트랜잭션
OrderCommandService/OrderQueryService분리- 강한 일관성, 운영 부담 상대적 적음
- 도메인 로직 풍부, 트래픽 아직 크지 않은 사내/초기 시스템에 적합
- DB 하나로도 가능
2단계. Primary / Replica로 부하 분리
- 데이터 모델은 하나, DB를 Primary(쓰기)와 Replica(읽기)로 나눔
- PostgreSQL 스트리밍 리플리케이션, Aurora/RDS 관리형 리드 레플리카 등
- 조회 성능, 확장성 증가
- 복제 지연으로 읽기 일관성 약화 가능
3단계. 저장소 분리 + 메시지 동기화
- 쓰기 → PostgreSQL, 읽기 → MongoDB / Redis / Elasticsearch
- Kafka, RabbitMQ 등으로 쓰기 → 읽기 동기화
- 저장소별 최적화 가능 / 결과적 일관성, 구현, 운영 난이도 크게 상승
- 실무는 보통 1, 2, 3 순으로 진행. 처음부터 3단계는 드묾
- 이커머스, 소셜 피드, 대규모 서비스에서 3단계 이상 흔함
4단계. Event Sourcing 결합
- 쓰기 쪽을 이벤트 스토어로 사용
- 현재 상태가 아니라 상태 변경 시퀀스(이벤트) 저장
- 학습 곡선, 운영 비용 높음. 1~3을 거친 뒤 검토
- CQRS와 자주 같이 쓰이지만 동일 패턴은 아님
읽기 모델에서 NoSQL을 고려하는 이유
| 유형 | 특징 | 대표 | CQRS 읽기 관련 |
|---|---|---|---|
| Document | JSON 문서, 화면 단위 비정규화 저장, 조인 없이 조회 | MongoDB, Couchbase, Elasticsearch, OpenSearch | 상품/게시글/SNS 피드 등 가장 많이 사용. MongoDB, ES를 우선 알아둘 것 |
| Key-Value | Key-Value, 인메모리 등 초저지연 | Redis | 캐시, 세션, 단순 키 조회 |
| Wide Column | 로우에 많은 컬럼, 시계열/로그 분석 | Cassandra | 특강 범위에서 실사용 빈도 낮음 |
| Graph | 관계 중심 | Neo4j | 소셜 그래프 등 |
읽기에 NoSQL을 두는 세 가지 이유
- 스키마 유연성: RDB 컬럼 추가+백필 부담 대신, 코드로 새 필드 채우기 시작 가능
- 수평 확장: 읽기 트래픽이 클 때 다중 노드 분산이 자연스러움
- 비정규화: Document에 한 화면 JSON을 담아 조인 제거
쓰기는 정규화(정합성, RDBMS), 읽기는 비정규화(조회 최적화, NoSQL)
결과적 일관성
쓰기 DB와 읽기 DB는 한 트랜잭션에서 동시에 갱신되지 않는다.
이벤트로 순차 동기화 → 쓰기는 됐는데 읽기에는 아직 안 보이는 구간이 생김 → 시간이 지나면 같아짐 = 결과적 일관성(eventual consistency)
- NoSQL만의 특성이라기보다, 읽기/쓰기 DB를 나눠 이벤트로 동기화할 때 이해해야 하는 특성
- 폴링 주기 단축, CDC 등 동기화 개선, 또는 “처리 중입니다” 같은 안내 메시지를 통해 처리
flow
- 사용자 쓰기 요청 → Write DB(RDBMS) 저장
- 변경 사실을 이벤트로 발행 → Kafka / RabbitMQ
- Projector(컨슈머) 가 이벤트를 받아 Read DB(Mongo, ES, Redis 등) 갱신
Write DB가 먼저 바뀌고, 이벤트 전달 후 Read DB가 바뀐다. 그 시간 차가 일관성 갭이다.
쓰기 측 책임
정합성 영역(RDBMS): 도메인 규칙 검증, ACID 트랜잭션, 도메인 이벤트 신뢰성 있는 발행
위험한 코드: 같은 흐름에서 orderRepo.save() 직후 트랜잭션 커밋 전후로 Kafka 직접 발행
| 시나리오 | 결과 |
|---|---|
| DB 성공, Kafka 실패 | 주문은 됐는데 읽기 측이 영구히 모름 |
| Kafka 성공, 비즈니스 롤백 | 없는 주문이 이벤트로 흐름 |
| 트랜잭션 안 외부 호출 | 트랜잭션 시간, 락 증가 |
Outbox 패턴
- 비즈니스 데이터와 발행할 이벤트를 Outbox 테이블에 같은 트랜잭션으로 저장
- 별도 워커가 Outbox를 읽어 Kafka 발행, 성공 후 상태 갱신
핵심은 브로커에 직접 보내지 말고 먼저 DB에 넣고 나중에 전송하는 것.
동기화(Outbox → Kafka) 흐름
| 방식 | 내용 | 장단 |
|---|---|---|
| 폴링 + 발행 | @Scheduled 등으로 Outbox 주기 조회 | 구현 단순 / 주기만큼 지연 |
| CDC (Change Data Capture) | 트랜잭션 로그 감지 | 지연 짧음 / 인프라, 운영 복잡도 상승 |
- 발행 성공 후 상태 갱신 전 워커 장애 시 재전송 가능
읽기 측 책임
도메인 규칙/정규화보다 화면을 빠르게 구성(조회 최적화)
- 구체화된 뷰(Materialized View): 조회 시점에 계산하지 않고, 미리 가공한 사본을 저장. 사용자는 원본이 아니라 이 뷰를 조회
- CQRS 읽기 모델은 쓰기와 다른 DB(NoSQL) 를 써도 됨
Event Sourcing과 CQRS
| CQRS | Event Sourcing | |
|---|---|---|
| 무엇 | Command / Query 책임 분리 | 상태를 저장하는 방식을 바꿈 |
| 원본 | (단계에 따라) 쓰기 DB 등 | 이벤트 스트림이 원본 |
| 현재 상태 | 쓰기 모델 등 | 이벤트를 누적 재생한 결과(사본) |
장바구니에 현재 수량만 있으면 사용자 분석이 어렵다. 이벤트(생성, 추가, 제거, 수량 변경 등)를 쌓으면 구매 유도, 쿠폰, 분석 등이 가능하다.
- 재수화(rehydration): 이벤트를 순서대로 재생해 현재 상태 재구성
- 정합성 불일치 시 이벤트를 원본으로 보고 상태를 다시 만듦
장점
- 시점별 상태 재구성(특정 일자 잔액 등), 감사 로그, 운영 사고 디버깅
단점
- 과거 이벤트와 스키마 비일치 시 호환성 처리 비용 부담
- 스냅샷 전략 처리 비용 부담
- 이벤트 스토어 DB/라이브러리 의존
CQRS와 잘 맞는 이유 이벤트 스토어를 화면용으로 직접 조회하기 부담 → Read DB(NoSQL)에 여러 읽기 모델 사용 (장바구니 화면 뷰, 상품 분석 뷰, 쿠폰 뷰, 운영 통계 뷰 등)