Published on

CQRS 읽기모델 최종일관성 버그 진단 7단계

Authors

CQRS를 운영에 올리면 언젠가 한 번은 이런 제보를 받습니다.

  • 방금 결제했는데 주문 상세에서 상태가 아직 PENDING 으로 보인다
  • 취소했는데 목록에서는 취소로 보이고 상세에서는 정상으로 보인다
  • 특정 사용자만 “가끔” 데이터가 과거로 돌아간다

이런 현상은 단순 지연일 수도 있지만, 더 위험한 건 읽기모델이 영구적으로 잘못된 상태로 고착되는 경우입니다. 최종일관성은 “언젠가 맞아진다”가 아니라, 맞아지도록 설계하고 관측하고 복구할 수 있어야 합니다.

아래 7단계는 읽기모델 최종일관성 버그를 “감”이 아니라 증거 기반으로 좁혀가는 실전 진단 절차입니다.

1단계: 증상을 지연(latency)과 불일치(divergence)로 분리

먼저 같은 현상처럼 보이는 두 부류를 분리합니다.

  • 지연: 쓰기모델은 맞고 읽기모델이 늦게 따라옴. 시간이 지나면 정상화됨.
  • 불일치: 시간이 지나도 읽기모델이 맞아지지 않거나, 특정 조건에서 계속 틀림.

진단의 출발점은 “사용자가 본 시점” 기준이 아니라, 도메인 이벤트 기준으로 상태를 비교하는 것입니다.

체크리스트

  • 이벤트가 발행되었는가
  • 이벤트가 소비되었는가
  • 소비되었는데 읽기모델 업데이트가 실패했는가
  • 업데이트가 성공했는데도 조회가 다른 저장소/캐시를 보는가

지연인지 불일치인지부터 확정해야 다음 단계에서 관측 포인트가 분명해집니다.

2단계: 상관관계 ID로 쓰기부터 읽기까지 한 줄로 잇기

최종일관성 버그는 대부분 “중간이 비었다”에서 시작합니다. 쓰기 요청, 이벤트 발행, 브로커 전달, 컨슈머 처리, 읽기 저장소 반영, 캐시 무효화까지 하나의 상관관계 ID로 이어 붙여야 합니다.

권장되는 최소 필드:

  • correlationId (요청 단위)
  • aggregateId (도메인 기준)
  • eventId (중복/멱등 핵심)
  • eventVersion 또는 sequence
  • occurredAt (이벤트 발생 시간)

이벤트 모델 예시 (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_atnull 인 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가지 레벨로 준비합니다.

  1. 단일 aggregate 재처리: 특정 aggregateId 만 이벤트 스트림을 다시 적용
  2. 기간 기반 백필: 특정 시간 범위 이벤트를 다시 적용
  3. 전체 리빌드: 읽기모델을 드롭하고 이벤트 스토어 또는 소스 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는 복잡도가 아니라 확장성과 운영 안정성을 주는 아키텍처가 됩니다.