CQRS 아키텍처와 DB 동기화 (Event Sourcing 연동)
핵심 인사이트 (3줄 요약)
- 본질: CQRS(명령 조회 책임 분리) 환경에서 DB 동기화는 쓰기 전용 DB(Command Model)에 발생한 상태 변경 내역을 읽기 전용 DB(Query Model, View DB)로 전파하여 두 물리적 데이터베이스 간의 데이터 무결성을 맞추는 비동기 아키텍처 매커니즘이다.
- 가치: 쓰기(RDBMS)와 읽기(NoSQL, Search Engine)에 가장 특화된 이기종 데이터베이스의 장점을 극대화하면서도, 두 시스템 간의 결합도를 끊어내어 어느 한쪽의 장애나 병목이 다른 쪽에 영향을 주지 않도록 시스템의 고가용성(High Availability)을 보장한다.
- 융합: 완벽한 DB 동기화를 위해서는 트랜잭션 도중 메시지 유실을 막는 아웃박스(Outbox) 패턴과, 데이터의 진실의 원천(Source of Truth)을 확보하는 **이벤트 소싱(Event Sourcing)**을 결합하여, **최종 일관성(Eventual Consistency)**을 100% 보장하는 파이프라인(Kafka 등)을 구축하는 것이 핵심이다.
Ⅰ. 개요 및 필요성 (Context & Necessity)
-
개념: CQRS 아키텍처는 쓰기(Write) DB와 읽기(Read) DB를 물리적으로 완전히 분리한다. 클라이언트가 데이터를 수정하면 Write DB만 업데이트된다. 이때 Read DB는 여전히 과거의 데이터를 가지고 있으므로, Write DB에서 변경된 사실을 Read DB 쪽으로 넘겨주어 화면에 뿌려질 모양(Materialized View)으로 즉각 반영(Projection)해야 한다. 이 일련의 비동기 흐름이 CQRS의 DB 동기화다.
-
필요성: 만약 동기화 파이프라인이 멈추거나 메시지가 중간에 유실되면(Message Loss), 사용자는 "방금 결제했는데 마이페이지에 결제 내역이 안 뜬다"는 심각한 정합성 장애를 겪게 된다. 분리된 두 DB 간에 2PC(Two-Phase Commit) 같은 분산 락(Lock)을 걸면 CQRS의 존재 이유인 '성능'이 죽어버린다. 따라서 락을 걸지 않고도(비동기) 반드시 1번 이상 메시지가 전달(At-least-once delivery)되어 언젠가는 데이터가 100% 일치하게 되는 최종 일관성의 아키텍처적 보증 수단이 필수적이다.
-
💡 비유: 요리사(Write DB)가 주방에서 맛있는 스테이크를 완성했습니다. 완성되었다는 사실을 밖의 웨이터(Read DB)에게 즉각 알려줘야 웨이터가 손님 메뉴판(화면)에 '스테이크 주문 가능'이라고 적을 수 있죠. 요리사가 종이에 "스테이크 1개 추가됨"이라고 적어서 알림통(Kafka)에 던져 넣으면, 웨이터가 그걸 꺼내 보고 자기 수첩에 업데이트하는 과정이 바로 DB 동기화입니다.
-
등장 배경 및 발전 과정:
- Dual-Write의 비극: 초기에는 로직에서 Write DB에
insert를 하고, 그 바로 아랫줄에 Read DB에insert를 날리는 이중 쓰기(Dual-Write)를 시도했다. 하지만 첫 줄은 성공하고 두 번째 줄에서 네트워크 에러가 나면 데이터가 영원히 틀어지는 참사가 발생했다. - CDC (Change Data Capture) 활용: RDBMS의 트랜잭션 로그(예: MySQL의 Binlog)를 긁어내서(Debezium 등) 타깃 DB로 쏴주는 인프라 레벨의 동기화가 유행했다.
- Event Sourcing과 Outbox의 결합: 비즈니스 맥락(Domain Event)을 잃어버리는 CDC의 한계를 극복하고자, 애플리케이션 레벨에서 도메인 이벤트를 발행하는 이벤트 소싱과 아웃박스 패턴이 현대 MSA-CQRS 동기화의 디팩토 표준(De facto standard)이 되었다.
- Dual-Write의 비극: 초기에는 로직에서 Write DB에
-
📢 섹션 요약 비유: 두 명의 화가(DB)가 등을 대고 앉아서 똑같은 그림을 그려야 할 때, 한 명이 먼저 그리고 "나 여기 빨간색 칠했어!"라고 소리치면(이벤트), 다른 한 명이 듣고 자기 캔버스에도 똑같이 빨간색을 칠해서 결국 똑같은 그림(최종 일관성)을 완성하는 과정과 같습니다.
Ⅱ. 아키텍처 및 핵심 원리 (Deep Dive)
CQRS DB 동기화의 3대 핵심 패턴
동기화 과정에서 가장 중요한 것은 **"Write DB 업데이트"**와 **"메시지(이벤트) 큐 발송"**이라는 2개의 행동을 **원자적(Atomic)**으로 처리하는 것이다.
1. 이중 쓰기 안티패턴 (Dual-Write Anti-Pattern)
// [최악의 설계]
@Transactional
public void createOrder(Order order) {
orderRepository.save(order); // 1. Write DB(PostgreSQL) 저장 (성공)
kafkaTemplate.send("order_events", order); // 2. Kafka 전송 (네트워크 장애로 실패!)
// 결과: DB에는 주문이 있는데, 읽기 DB에는 영원히 반영되지 않음 (고아 데이터 발생)
}
2. Transactional Outbox 패턴 (가장 권장됨)
비즈니스 테이블(Order)과 이벤트 발행 대기열 테이블(Outbox)을 같은 DB 안에 두고, 단일 로컬 트랜잭션으로 묶어서 저장한다.
┌───────────────────────────────────────────────────────────────┐
│ 아웃박스 (Transactional Outbox) 패턴 아키텍처 │
├───────────────────────────────────────────────────────────────┤
│ │
│ [ 1. 애플리케이션 (Write Model) ] │
│ │ │
│ ▼ (하나의 로컬 트랜잭션 안에서 2개 테이블에 동시 저장) │
│ ┌──────────────────────────────────────────────┐ │
│ │ Write Database (PostgreSQL) │ │
│ │ │ │
│ │ [Order Table] [Outbox Table] │ │
│ │ - OrderId: 1 - EventId: E_123 │ │
│ │ - Status: PENDING - Payload: OrderCreated│ │
│ │ - Status: UNPUBLISHED │ │
│ └──────┬───────────────────────┬───────────────┘ │
│ │ (CDC / Polling) │ │
│ ▼ │ │
│ [ 2. Message Relay (Debezium 등) ] │
│ - Outbox 테이블을 감시하다가 새 row가 생기면 읽어서 Kafka로 쏜다. │
│ - 발송 성공 시 Status를 PUBLISHED로 바꿈 (또는 삭제). │
│ │ │
│ ▼ │
│ [ 3. Message Broker (Kafka) ] │
│ │ │
│ ▼ │
│ [ 4. 애플리케이션 (Read Model / Projector) ] │
│ - Kafka 이벤트를 Consume하여 Read DB (Elasticsearch) 업데이트!│
└───────────────────────────────────────────────────────────────┘
[장점]: 데이터베이스 저장과 이벤트 생성이 100% 원자적(Atomic)으로 묶인다. Kafka가 잠시 죽어있어도 Outbox 테이블에 데이터가 안전하게 남아있으므로 유실이 절대 발생하지 않는다 (최소 1회 전달 보장).
3. 이벤트 소싱 (Event Sourcing) 결합
이벤트 소싱은 Outbox 패턴을 한 차원 더 극한으로 밀어붙인 형태다. "현재 상태 테이블(Order Table)" 자체가 없고, "오직 이벤트 저장소(Event Store)"만 존재한다. 상태를 변경하는 쓰기 행위 자체가 이벤트를 추가하는 것이므로, 상태 저장과 이벤트 발행을 동기화할 고민조차 필요 없다(둘이 같은 행위이기 때문).
동기화 과정에서의 데이터 멱등성 (Idempotency) 확보
Outbox 패턴이나 이벤트 소싱을 통해 이벤트를 읽기 DB로 넘길 때, 메시지 브로커(Kafka)의 특성상 네트워크 지연으로 인해 동일한 메시지가 2번 이상 전달될 수 있다 (At-least-once delivery). 따라서 읽기 모델의 프로젝션 로직(Consumer)은 똑같은 이벤트를 2번 받아도 Read DB가 꼬이지 않도록 **멱등성(Idempotency)**을 반드시 구현해야 한다.
- 구현 예:
UPDATE read_table SET ... WHERE version = 1처럼 버전 기반 동시성 제어(Optimistic Locking)를 하거나, 이미 처리한Event ID를 기록해 두고 중복이 오면 무시하는 로직을 방어 코드로 넣는다.
Ⅲ. 실무 적용 및 기술사적 판단
실무 시나리오
-
시나리오 — 조회(Read) DB의 데이터 오염 / 완전 재생(Replay): 신규 기능 배포 중 개발자의 실수로 Elasticsearch(Read DB)의 데이터 매핑 로직(Projector)에 버그가 들어가, 일주일치 상품 조회 데이터가 모두 엉망으로 꼬여버린 상황.
- 판단: CQRS 모델에서 Read DB는 일회용 소모품(Disposable)에 불과하다. 진정한 원본(Source of Truth)은 Write DB 또는 Event Store에 안전하게 보존되어 있다.
- 해결책: 오염된 Read DB 인덱스를 통째로 날려버린다(Drop). 그리고 Kafka의 Offset을 일주일 전으로 되돌리거나, Event Store의 모든 이벤트를 시퀀스 넘버(Seq=0)부터 처음부터 다시 재생(Replay / Rebuild)시켜 새로운 Read DB를 순식간에 재구축한다. 이것이 CQRS와 이벤트 소싱이 결합했을 때 얻는 궁극의 데이터 회원 탄력성(Resiliency)이다.
-
시나리오 — CQRS 최종 일관성 타임랙(Lag)으로 인한 UX 결함: 사용자가 "내 정보 수정"을 누르고 이름을 바꾼 뒤 리다이렉트되어 마이페이지로 돌아왔는데, 아직 Kafka를 거쳐 Read DB로 동기화가 되지 않아 (Lag 0.5초 발생) 옛날 이름이 그대로 보이는 상황. 사용자는 에러인 줄 알고 '수정' 버튼을 계속 연타하게 됨.
- 판단: 백엔드 아키텍처의 최종 일관성 지연시간(Replication Lag)을 화면 프론트엔드가 커버하지 못해 발생한 문제다.
- 해결책: 백엔드 동기화 속도를 0으로 만드는 것은 물리적으로 불가능하다. 프론트엔드 단에서 **낙관적 업데이트(Optimistic UI)**를 적용하여 로컬 캐시 데이터를 우선 변경해 화면에 보여주거나, 백엔드 API가 Write DB 수정 직후 "당신의 데이터는 N초 후에 변경될 예정입니다" 식의 버전 정보(ETag)를 주고 프론트엔드가 폴링(Polling)하도록 UX를 설계해야 한다.
도입 체크리스트
- 기술적: 이벤트 소비(Consume) 순서가 보장되는가? 주문 생성(
OrderCreated) 이벤트보다 주문 취소(OrderCanceled) 이벤트가 Read DB에 먼저 도착하면 심각한 논리 오류가 발생한다. Kafka 파티션 키(Partition Key)를OrderId로 설정하여 **순차적 처리(Ordering)**를 보장했는가? - 아키텍처적: CDC(Debezium)를 쓸 것인가, 아니면 애플리케이션에서 Outbox 도메인 이벤트를 직접 구울 것인가? (순수 데이터 복제가 목적이면 CDC가 낫고, 타 서비스에 비즈니스 의미를 전달하려면 Outbox 이벤트 발행이 필수적이다).
Ⅳ. 기대효과 및 결론
정량/정성 기대효과
| 구분 | Dual-Write (안티패턴) | Transactional Outbox + CQRS | 개선 효과 |
|---|---|---|---|
| 정량 (신뢰도) | 네트워크 불안정 시 Read DB 데이터 누락율 존재 | 최소 1회 전달 및 최종 일관성 100% 보장 | 읽기/쓰기 DB 간 데이터 유실률 0% 달성 |
| 정량 (복구력) | Read DB 깨지면 수동 스크립트 복구 (수 일) | Event Store 전체 Replay 버튼 클릭 (수 분) | 재난 복구(DR) 및 데이터 재생 시간 극적 단축 |
| 정성 (결합도) | Read DB 지연이 Write 트랜잭션 속도를 갉아먹음 | 비동기 큐잉으로 결합도 완전 분리 | 한쪽 DB가 죽어도 다른 쪽은 생존하는 Fault Tolerance |
CQRS 아키텍처의 진정한 승부처는 "명령과 조회를 분리했다"는 껍데기가 아니라, "분리된 두 세계를 어떻게 유실 없이, 그리고 비즈니스 순서에 맞게 동기화시킬 것인가" 하는 파이프라인의 견고함에 있다. 기술사는 단순히 Kafka를 도입하는 것을 넘어, Outbox 패턴을 통한 원자성 확보, 파티션 키 설계를 통한 이벤트 순서 보장, 그리고 재시도로 인한 중복을 방어하는 멱등성(Idempotency) 로직을 Consumer 쪽에 강제하는 디테일한 백엔드 거버넌스를 이끌어야 한다.
📌 관련 개념 맵 (Knowledge Graph)
| 개념 명칭 | 관계 및 시너지 설명 |
|---|---|
| CQRS (명령 조회 책임 분리) | 쓰기와 읽기 DB를 분리하는 모체 아키텍처로, 이 둘 사이의 강을 건너기 위해 동기화 파이프라인이 필수불가결하다. |
| 이벤트 소싱 (Event Sourcing) | 상태 대신 이벤트 자체를 영속화하여, 동기화를 위한 이벤트 발행 행위와 데이터 저장을 일치시키는 CQRS의 최상위 진화 형태다. |
| Outbox 패턴 (아웃박스 패턴) | RDBMS의 로컬 트랜잭션을 활용해 "데이터 저장"과 "메시지 발송 대기열"을 한 트랜잭션으로 묶어 유실을 원천 차단하는 설계다. |
| CDC (Change Data Capture) | 애플리케이션 코드를 수정하지 않고 DB의 트랜잭션 로그(Binlog)를 긁어서 타깃 DB로 스트리밍해주는 로우 레벨 데이터 동기화 기술이다. |
| 멱등성 (Idempotency) | 이벤트를 받아 Read DB를 업데이트하는 Consumer 로직이 동일한 이벤트를 N번 받아도 상태가 1번만 적용되도록 보장하는 방어 기법이다. |
👶 어린이를 위한 3줄 비유 설명
- 내가 일기장에 "오늘은 짜장면을 먹었다"라고 글(Write DB)을 썼어요. 그런데 거실에 있는 엄마(Read DB)도 내가 뭘 먹었는지 실시간으로 알아야 한대요.
- 만약 내가 직접 거실로 뛰어가서 알려주려다가 넘어지면(에러 발생), 엄마는 내가 뭘 먹었는지 영영 모르게 돼요. (이중 쓰기의 문제)
- 그래서 내가 일기장에 글을 씀과 동시에, 방문 앞 우체통(Outbox)에 똑같은 내용의 쪽지를 넣어두면 튼튼한 우체부 아저씨(Kafka)가 무조건 엄마에게 전달해 주는 안전한 방법을 'DB 동기화 아웃박스 패턴'이라고 해요!