이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Projection read model은 어떤 원천 데이터에서 어떤 조회 모델을 만드는가?
- 이벤트 중복, 순서 역전, schema 변경, rebuild를 어떻게 설계해야 하는가?
- Projection은 성능, 정합성, 장애 격리, 운영 복잡도를 어떻게 바꾸는가?
개요
Projection read model은 쓰기 모델의 데이터를 조회 전용 형태로 투영한 결과다.
주문 원본은 orders, order_lines, payments, shipments처럼 정합성 중심으로 나뉘어 있을 수 있다.
하지만 주문 목록 화면은 주문 번호, 결제 금액, 배송 상태, 마지막 이벤트 시간, 고객 이름을 한 줄에 보여주고 싶다.
이때 projection은 여러 원천 이벤트나 테이블 변경을 읽어 order_summary_read_model 같은 조회 전용 모델을 만든다.
Projection의 목적은 조회 성능을 높이는 것이지만, 실제 설계의 핵심은 “깨져도 다시 만들 수 있는가”다.
Projection의 위치
Projection은 원본이 아니며, 원본은 command model이 지키는 write DB 또는 이벤트 로그다.
따라서 read model이 원본보다 먼저 갱신되거나, read model의 값을 기준으로 주문 상태를 판단하면 책임이 뒤집힌다.
Projection을 설계할 때 먼저 정해야 할 것은 다음이다.
- 원천 이벤트는 무엇인가?
- 이벤트 순서는 어떤 key에서 보장해야 하는가?
- 같은 이벤트를 두 번 처리해도 결과가 같은가?
- 오래된 이벤트가 늦게 도착하면 무시할 수 있는가?
- read model을 처음부터 다시 만들 수 있는가?
- schema 변경 시 old event와 new projector가 호환되는가?
이 선택은 읽기 성능을 개선하지만, 정합성 지연과 재처리 운영을 새로 만든다.
Read model 예시
주문 목록 조회를 위한 테이블은 화면 중심으로 설계한다.
CREATE TABLE order_summary_read_model (
order_id bigint PRIMARY KEY,
member_id bigint NOT NULL,
member_name varchar(100) NOT NULL,
total_amount numeric(12, 2) NOT NULL,
order_status varchar(30) NOT NULL,
payment_status varchar(30) NOT NULL,
shipment_status varchar(30) NOT NULL,
last_event_id uuid NOT NULL,
last_event_version bigint NOT NULL,
projected_at timestamp NOT NULL,
source_occurred_at timestamp NOT NULL
);
CREATE INDEX idx_order_summary_member_projected
ON order_summary_read_model (member_id, projected_at DESC);
CREATE INDEX idx_order_summary_status_projected
ON order_summary_read_model (order_status, projected_at DESC);이 테이블은 정규화보다 조회 조건과 정렬을 우선하지만, write model의 정합성 제약을 대신하지 않는다.
이벤트 계약 예시
Projection에 필요한 이벤트는 화면에 필요한 필드를 충분히 제공해야 한다.
{
"eventId": "018f6b91-7f3c-7bb0-b42f-07c6f6b41c21",
"eventType": "OrderPaid",
"aggregateId": "1001",
"aggregateVersion": 4,
"schemaVersion": 2,
"occurredAt": "2026-07-01T10:15:00+09:00",
"payload": {
"memberId": 77,
"memberName": "Kim",
"totalAmount": "42000.00",
"paymentStatus": "PAID"
}
}aggregateVersion은 오래된 이벤트를 걸러내는 기준이고, schemaVersion은 projector가 payload를 해석할 때 필요한 계약이다.
멱등 갱신
Projection worker는 같은 이벤트를 두 번 받을 수 있다고 가정한다.
따라서 eventId 처리 이력이나 aggregateVersion 비교가 필요하다.
@Transactional
public void project(OrderPaidEvent event) {
if (processedEventRepository.existsById(event.eventId())) {
return;
}
OrderSummary row = readRepository.findByOrderId(event.orderId())
.orElseGet(() -> OrderSummary.empty(event.orderId()));
if (row.lastEventVersion() >= event.aggregateVersion()) {
processedEventRepository.save(new ProcessedEvent(event.eventId()));
return;
}
row.applyPayment(
event.memberId(),
event.memberName(),
event.totalAmount(),
event.paymentStatus(),
event.aggregateVersion(),
event.occurredAt()
);
readRepository.save(row);
processedEventRepository.save(new ProcessedEvent(event.eventId()));
}핵심은 “insert or update”가 아니라 중복과 순서 역전을 견디는 것이다.
Rebuild 절차
Read model은 파생 데이터이므로 rebuild 절차가 있어야 한다.
운영 절차는 대략 다음 순서다.
- 새 read model table을 만든다.
- 특정 시점까지의 이벤트를 순서대로 replay한다.
- replay 중 lag와 실패 이벤트를 기록한다.
- 기존 read model과 count, checksum, 샘플 row를 비교한다.
- query API의 read target을 새 table로 전환한다.
- 문제가 있으면 이전 table로 되돌린다.
대량 rebuild는 운영 트래픽과 분리해 원래 보호하려던 쓰기 DB나 read DB를 다시 압박하지 않게 한다.
Projection lag
Projection lag는 “이벤트 발생 시각”과 “read model 반영 시각”의 차이다.
lag = now - max(source_occurred_at applied to read model)단순히 worker queue size만 보면 부족하다. 오래된 이벤트 하나가 막혀 있어도 사용자 화면은 오래된 상태일 수 있다.
따라서 다음 지표를 같이 본다.
projection.lag.secondsprojection.apply.durationprojection.failed.eventsprojection.replayed.eventsprojection.skipped_old_eventsread_model.row_count
이 지표가 있어야 성능 개선이 실제 정합성 비용으로 얼마나 바뀌었는지 볼 수 있다.
장애 격리
Projection worker가 실패해도 command API는 가능한 한 계속 동작해야 한다.
이를 위해 write DB와 read model 갱신을 동기 트랜잭션으로 묶지 않고, outbox나 이벤트 로그에 원천 기록을 남긴다.
이 구조는 쓰기 경로를 보호하지만 query 경로에는 stale data가 생긴다.
따라서 장애 격리는 항상 정합성 계약과 같이 설계해야 한다.
위험 신호
- 이벤트에 version이 없어 오래된 이벤트를 구분할 수 없다.
- 같은 이벤트를 두 번 처리하면 금액이나 count가 두 번 증가한다.
- read model을 수동 SQL로 고치고 원천 이벤트와 차이를 남긴다.
- rebuild 절차 없이 projection table만 운영한다.
- projection 실패가 무한 retry로 broker와 DB를 압박한다.
- schemaVersion 없이 payload 필드를 삭제하거나 의미를 바꾼다.
개인 프로젝트 기준
개인 프로젝트에서는 완전한 이벤트 플랫폼이 없어도 된다.
하지만 최소한 다음은 남겨야 한다.
- read model이 원본이 아니라 파생 데이터라는 설명
- 같은 이벤트를 두 번 처리해도 안전한 upsert
- 수동 rebuild 방법
- lag를 로그로라도 확인하는 방법
- stale data가 보이는 화면의 안내 문구
기업 운영 기준
- 누가 event schema 변경을 승인하는가?
- replay는 어느 시간대에 돌릴 수 있는가?
- DLQ의 이벤트를 누가 재처리하는가?
- read model 검증은 count, checksum, 샘플 중 무엇으로 하는가?
- lag가 SLO를 넘으면 query API는 계속 제공하는가?
이 질문에 답하지 못하면 read model은 장애 때 믿을 수 없는 사본이 된다.
확인 질문
확인 질문
- 확인 질문: Projection read model이 원본이 아닌 이유는 무엇인가?
- 답변: 조회 성능과 응답 형태를 위해 원천 데이터에서 만든 파생 데이터이며, 정합성의 최종 판단은 write model이나 이벤트 로그가 담당하기 때문이다.
- 확인 질문: Projection worker가 반드시 멱등해야 하는 이유는 무엇인가?
- 답변: 메시지 중복, retry, worker 재시작이 있을 수 있으므로 같은 이벤트를 다시 처리해도 결과가 바뀌지 않아야 하기 때문이다.
- 확인 질문: Projection이 바꾸는 설계 축은 무엇인가?
- 답변: 읽기 성능과 조회 모델 독립성은 좋아지지만, 정합성 지연, rebuild, schema version, 장애 재처리 운영 복잡도가 늘어난다.