- Published on
CQRS 읽기모델 최종일관성 버그 진단 7단계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
CQRS를 운영에 올리면 언젠가 한 번은 이런 제보를 받습니다.
- 방금 결제했는데 주문 상세에서 상태가 아직
PENDING으로 보인다 - 취소했는데 목록에서는 취소로 보이고 상세에서는 정상으로 보인다
- 특정 사용자만 “가끔” 데이터가 과거로 돌아간다
이런 현상은 단순 지연일 수도 있지만, 더 위험한 건 읽기모델이 영구적으로 잘못된 상태로 고착되는 경우입니다. 최종일관성은 “언젠가 맞아진다”가 아니라, 맞아지도록 설계하고 관측하고 복구할 수 있어야 합니다.
아래 7단계는 읽기모델 최종일관성 버그를 “감”이 아니라 증거 기반으로 좁혀가는 실전 진단 절차입니다.
1단계: 증상을 지연(latency)과 불일치(divergence)로 분리
먼저 같은 현상처럼 보이는 두 부류를 분리합니다.
- 지연: 쓰기모델은 맞고 읽기모델이 늦게 따라옴. 시간이 지나면 정상화됨.
- 불일치: 시간이 지나도 읽기모델이 맞아지지 않거나, 특정 조건에서 계속 틀림.
진단의 출발점은 “사용자가 본 시점” 기준이 아니라, 도메인 이벤트 기준으로 상태를 비교하는 것입니다.
체크리스트
- 이벤트가 발행되었는가
- 이벤트가 소비되었는가
- 소비되었는데 읽기모델 업데이트가 실패했는가
- 업데이트가 성공했는데도 조회가 다른 저장소/캐시를 보는가
지연인지 불일치인지부터 확정해야 다음 단계에서 관측 포인트가 분명해집니다.
2단계: 상관관계 ID로 쓰기부터 읽기까지 한 줄로 잇기
최종일관성 버그는 대부분 “중간이 비었다”에서 시작합니다. 쓰기 요청, 이벤트 발행, 브로커 전달, 컨슈머 처리, 읽기 저장소 반영, 캐시 무효화까지 하나의 상관관계 ID로 이어 붙여야 합니다.
권장되는 최소 필드:
correlationId(요청 단위)aggregateId(도메인 기준)eventId(중복/멱등 핵심)eventVersion또는sequenceoccurredAt(이벤트 발생 시간)
이벤트 모델 예시 (Java)
public record DomainEvent(
String eventId,
String correlationId,
String aggregateId,
long sequence,
Instant occurredAt,
String type,
String payloadJson
) {}
로그에 반드시 남길 것
- 프로듀서:
eventId,aggregateId,sequence, 브로커 오프셋(가능하면) - 컨슈머: 수신한
eventId, 처리 결과, 반영된 읽기모델 버전
이 단계가 안 되어 있으면 이후 단계는 “추측”이 됩니다.
3단계: 이벤트 발행 경로에서 원자성 깨짐(outbox 미적용) 확인
읽기모델이 영영 안 맞는 가장 흔한 원인은 쓰기 트랜잭션과 이벤트 발행의 원자성 붕괴입니다.
전형적인 안티패턴:
- DB 커밋 성공
- 그 다음 Kafka 발행
- 발행 실패 시 이벤트 유실
이 경우 읽기모델은 절대 따라올 수 없습니다.
Outbox 패턴 스키마 예시 (SQL)
create table outbox (
id varchar(36) primary key,
aggregate_id varchar(64) not null,
sequence bigint not null,
type varchar(128) not null,
payload_json json not null,
occurred_at timestamp not null,
published_at timestamp null,
unique (aggregate_id, sequence)
);
트랜잭션 내 outbox 저장 (의사코드)
@Transactional
public void handle(Command cmd) {
Aggregate agg = repo.load(cmd.aggregateId());
agg.apply(cmd);
repo.save(agg);
DomainEvent evt = agg.toEvent(cmd.correlationId());
outboxRepo.insert(evt);
}
별도 퍼블리셔가 published_at 이 null 인 outbox를 읽어 브로커로 내보내면, “커밋은 됐는데 이벤트는 없음” 상태를 크게 줄일 수 있습니다.
4단계: 컨슈머 멱등성(idempotency)과 중복 처리 경로 점검
브로커 기반 시스템에서 “정확히 한 번”은 기대하기 어렵습니다. 대부분은 최소 한 번(at-least-once)로 운영됩니다. 그러면 읽기모델은 다음 두 문제를 반드시 겪습니다.
- 중복 이벤트로 인해 동일 업데이트가 여러 번 적용됨
- 재처리로 인해 과거 이벤트가 다시 적용되어 상태가 되돌아감
해결은 멱등성입니다. 읽기모델 업데이트 시 eventId 또는 (aggregateId, sequence) 를 기준으로 “이미 처리했는지”를 저장해야 합니다.
처리 이력 테이블 예시
create table projection_offsets (
projection_name varchar(64) not null,
aggregate_id varchar(64) not null,
last_sequence bigint not null,
updated_at timestamp not null,
primary key (projection_name, aggregate_id)
);
멱등 적용 로직 (의사코드)
@Transactional
public void onEvent(DomainEvent e) {
long last = offsetsRepo.getLastSequence("orderProjection", e.aggregateId());
if (e.sequence() <= last) {
return; // 이미 처리했거나 더 과거 이벤트
}
projectionRepo.apply(e);
offsetsRepo.upsert("orderProjection", e.aggregateId(), e.sequence());
}
여기서 중요한 포인트는 projectionRepo.apply(e) 와 offset 업데이트가 같은 트랜잭션이어야 한다는 점입니다. 둘이 분리되면 “반영은 했는데 offset 저장 실패” 또는 그 반대가 발생해 불일치가 고착됩니다.
관련해서 분산 트랜잭션이나 보상 흐름에서 “중복 실행 방지”가 핵심이 되는 경우가 많습니다. 보상 트랜잭션 중복을 막는 패턴은 읽기모델 멱등성과도 결이 같습니다.
5단계: 순서 보장(ordering) 붕괴와 재정렬(out-of-order) 검증
최종일관성 버그에서 “가끔 과거로 돌아간다”는 제보는 대개 이벤트 순서가 깨지는 경우입니다.
대표 원인:
- 파티셔닝 키가
aggregateId가 아니라서 동일 aggregate 이벤트가 다른 파티션으로 감 - 컨슈머가 병렬 처리하면서 동일 aggregate를 동시에 처리
- 재시도/지연 큐로 인해 과거 이벤트가 나중에 도착
진단 방법
- 브로커 메시지 키가
aggregateId인지 확인 - 컨슈머 동시성 설정에서 동일 키가 동시에 처리될 여지가 있는지 확인
- 이벤트에
sequence가 있는지, 컨슈머가 이를 검증하는지 확인
Kafka 키 설정 예시 (의사코드)
producer.send(
topic,
/* key */ event.aggregateId(),
/* value */ event
);
그리고 4단계에서 소개한 sequence 기반 멱등 체크는 “중복 방지”뿐 아니라 “역순 도착 방지”에도 효과적입니다. 역순 이벤트는 무시하거나, 별도 보정 큐로 보내 수동/자동 복구 대상으로 분리하세요.
6단계: 읽기 경로의 캐시/리플리카/검색 인덱스 지연을 분리 측정
이벤트 소비는 정상인데도 사용자가 여전히 옛 데이터를 본다면, 읽기 경로에 다른 저장소가 끼어 있을 확률이 큽니다.
흔한 구성:
- 읽기 DB 리플리카 사용 (복제 지연)
- Redis 캐시 사용 (무효화 실패)
- Elasticsearch/OpenSearch 인덱스 (refresh 지연)
- CDN/Edge 캐시 (API 캐싱)
분리 측정 팁
- 같은 요청에서 “캐시를 우회한 원본 조회” 엔드포인트를 임시로 제공
- 응답에
readModelUpdatedAt,readModelVersion같은 메타를 포함 - 리플리카라면
seconds_behind_master같은 지표를 함께 대시보드로 확인
응답 메타 포함 예시 (JSON)
{
"orderId": "o-123",
"status": "PAID",
"readModelVersion": 42,
"readModelUpdatedAt": "2026-02-24T10:11:12Z"
}
이 메타가 있으면 고객센터 제보만으로도 “지연인지 불일치인지”가 다시 선명해집니다.
7단계: 재처리(replay)와 백필(backfill)로 복구 가능하게 만들기
진단이 끝나면 마지막은 복구입니다. 최종일관성을 운영한다는 건 “언젠가 맞는다”가 아니라 “언제든 다시 맞출 수 있다”입니다.
복구 전략은 보통 3가지 레벨로 준비합니다.
- 단일 aggregate 재처리: 특정
aggregateId만 이벤트 스트림을 다시 적용 - 기간 기반 백필: 특정 시간 범위 이벤트를 다시 적용
- 전체 리빌드: 읽기모델을 드롭하고 이벤트 스토어 또는 소스 DB에서 재구축
안전한 리플레이를 위한 조건
- 4단계의 멱등성(최소
sequence체크)이 구현되어 있어야 함 - 리플레이 작업이 프로덕션 트래픽과 경합하지 않도록 레이트 리밋/배치 크기 제한
- “현재 처리 중인 실시간 이벤트”와 “리플레이 이벤트”가 섞일 때의 규칙 정의
간단한 리플레이 워커 예시 (의사코드)
public void replayAggregate(String aggregateId, long fromSeq, long toSeq) {
List<DomainEvent> events = eventStore.load(aggregateId, fromSeq, toSeq);
for (DomainEvent e : events) {
projectionConsumer.onEvent(e); // 동일한 멱등 로직 재사용
}
}
리플레이가 가능해지면, 최종일관성 버그는 “장애”에서 “복구 가능한 데이터 드리프트”로 성격이 바뀝니다.
운영에서 바로 쓰는 최종 체크리스트
아래 항목을 티켓 템플릿처럼 고정해두면, 재현이 어려운 간헐적 버그도 속도가 붙습니다.
correlationId확보 여부- 해당
aggregateId의 이벤트sequence연속성(누락/중복) 여부 - outbox 적용 여부 및 outbox 적체/실패 지표
- 컨슈머 멱등성 키(
eventId또는(aggregateId, sequence)) 존재 여부 - 파티션 키가
aggregateId인지 - 읽기 경로 캐시/리플리카/인덱스 지연 분리 측정 결과
- 단일 aggregate 리플레이로 복구 가능한지
마무리: 최종일관성은 관측성과 복구성이 전부다
CQRS 읽기모델의 최종일관성은 “지연을 감수한다”가 아니라,
- 이벤트가 유실되지 않게 만들고
- 중복/역순을 견디게 만들고
- 어디서 늦는지 보이게 만들고
- 언제든 다시 맞출 수 있게 만드는
운영 능력의 문제입니다.
진단을 반복하다 보면, 시스템 장애 대응이 점점 systemd 나 쿠버네티스처럼 “단계별 체크리스트”로 굳어집니다. 그런 접근을 좋아한다면 아래 글의 진단 프레임도 참고가 됩니다.
읽기모델이 틀어졌을 때 “왜 그런지”를 설명할 수 있고 “어떻게 맞출지”가 준비되어 있다면, CQRS는 복잡도가 아니라 확장성과 운영 안정성을 주는 아키텍처가 됩니다.