이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Strong consistency와 eventual consistency는 어떤 데이터에 각각 적합한가?
- 최종 일관성을 허용할 때 반드시 함께 설계해야 하는 보정 장치는 무엇인가?
- outbox, projection, conflict 처리로 일관성 비용을 어떻게 관리하는가?
개요
Strong consistency는 읽는 순간 항상 최신의 올바른 값을 보장하려는 선택이다. Eventual consistency는 쓰기와 읽기 사이의 지연을 허용하되, 나중에 수렴하도록 설계하는 선택이다. 둘 중 하나가 항상 더 성숙한 선택은 아니다. 주문 금액, 권한, 재고처럼 틀리면 안 되는 값은 강하게 다뤄야 한다. 좋아요 수, 추천 피드, 검색 색인처럼 지연을 설명할 수 있는 값은 eventual로 둘 수 있다.
이 문서가 바꾸는 설계 축
| 축 | Strong | Eventual |
|---|---|---|
| 성능 | primary, lock, transaction 때문에 느릴 수 있다. | 쓰기 응답은 빨라지고 읽기 모델은 분리된다. |
| 정합성 | 불변식을 즉시 검증하기 쉽다. | 중복, 순서, 지연, conflict 처리가 필요하다. |
| 장애 격리 | 확실하지 않으면 실패해 데이터 오류를 막는다. | 일부 컴포넌트 장애를 지연 처리로 흡수할 수 있다. |
| 운영 복잡도 | DB 병목과 lock을 관리한다. | event, DLQ, replay, projection lag를 운영한다. |
데이터별 선택
| 데이터 | 권장 일관성 | 이유 |
|---|---|---|
| 결제 승인 원장 | Strong | 중복 승인과 금액 오류가 치명적이다. |
| 재고 차감 | Strong 또는 reservation | 초과 판매를 막아야 한다. |
| 포인트 잔액 | Strong | 잘못 늘거나 줄면 금전성 보상이 틀어진다. |
| 월간 랭킹 | Eventual 가능 | 수 초 지연되어도 보상 기준과 분리할 수 있다. |
| 검색 색인 | Eventual 가능 | 원본 상세와 색인 반영 시점이 다를 수 있다. |
| 알림 읽음 카운트 | Eventual 가능 | 늦게 반영되어도 사용자 피해가 작다. |
| 사용자 권한 | Strong 또는 짧은 TTL | 권한 오남용이 위험하다. |
| 추천 세그먼트 | Eventual | 지연을 제품 경험으로 흡수할 수 있다. |
| 한 도메인 안에서도 데이터별 선택이 다르다. | ||
| 포인트 잔액은 강하게 지켜야 하지만 랭킹과 추천 세그먼트는 파생 데이터로 둘 수 있다. |
대표 시나리오
결제 후 포인트를 적립하고 랭킹에 반영한다고 하자.
| 데이터 | 사용자 기대 | 일관성 선택 |
|---|---|---|
| 포인트 적립 원장 | 결제 1건당 한 번만 기록되어야 한다. | Strong |
| 사용 가능 잔액 | 즉시 정확해야 한다. | Strong |
| 월간 랭킹 | 몇 초 늦게 올라가도 된다. | Eventual |
| 추천 세그먼트 | 다음 추천 배치에서 반영되어도 된다. | Eventual |
| 모든 파생 데이터를 strong으로 만들면 적립 API가 랭킹, 추천, 검색까지 기다린다. | ||
| 반대로 원장까지 eventual로 만들면 중복 적립과 잔액 오류를 보정해야 한다. | ||
| 일관성 선택은 원본 불변식과 파생 사용처를 나눠야 한다. |
Strong consistency 설계 비용
강한 일관성은 보통 하나의 owner와 transaction 경계를 요구한다.
@Transactional
public PointEarnResult earnPoints(EarnPointCommand command) {
PointAccount account = pointAccountRepository.findForUpdate(command.memberId());
PointLedger ledger = pointLedgerRepository.save(
PointLedger.earned(command.paymentId(), command.memberId(), command.amount())
);
account.credit(ledger.amount());
return PointEarnResult.recorded(ledger.id(), account.balance());
}이 방식은 중복 적립과 잔액 오류를 막는 데 좋다. 하지만 특정 회원이나 프로모션에 쓰기가 몰리면 lock wait가 생긴다. 트랜잭션 안에서 외부 결제를 호출하면 connection을 오래 점유한다. strong은 정합성을 높이지만 성능과 장애 격리 비용을 만든다.
Eventual consistency 설계 비용
최종 일관성은 event와 projection을 요구한다.
{
"eventId": "point-earned-100",
"type": "PointEarned",
"aggregateId": "member-100",
"version": 3
}consumer는 eventId와 version으로 중복과 순서를 처리해야 한다.
projection은 원본이 아니다.
CREATE TABLE monthly_point_rank_view (
member_id varchar(50) NOT NULL,
month varchar(7) NOT NULL,
total_points bigint NOT NULL,
version bigint NOT NULL,
projected_at timestamp NOT NULL,
PRIMARY KEY (month, member_id)
);랭킹 projection이 늦으면 사용자는 오래된 순위를 본다. 그래서 projection lag와 rebuild 절차가 필수다.
Outbox로 경계 고정
DB write와 event publish를 따로 처리하면 둘 중 하나만 성공할 수 있다. outbox는 원본 transaction 안에 event 발행 의도를 함께 저장한다.
CREATE TABLE outbox_event (
event_id varchar(80) PRIMARY KEY,
aggregate_id varchar(80) NOT NULL,
event_type varchar(80) NOT NULL,
payload jsonb NOT NULL,
created_at timestamp NOT NULL,
published_at timestamp
);outbox는 즉시 일관성을 event consumer까지 확장하지 않는다. 대신 원본 변경과 이벤트 발행 의도를 같은 transaction에 묶어 유실을 줄인다. consumer 쪽은 여전히 eventual이다.
Conflict를 설계한다
eventual consistency는 conflict가 없다는 뜻이 아니다. 동일한 프로필을 두 기기에서 동시에 수정할 수 있다.
PUT /profile
If-Match: version-17서버의 현재 version이 18이면 conflict를 반환한다.
409 Conflict
{
"code": "VERSION_CONFLICT",
"currentVersion": 18
}충돌을 마지막 쓰기 승리로 덮을지, 사용자에게 병합을 요구할지 정해야 한다. 이 결정은 기술보다 제품 경험에 가깝다.
선택 기준
| 질문 | Strong 쪽 | Eventual 쪽 |
|---|---|---|
| 틀린 값이 돈, 권한, 재고를 바꾸는가? | 예 | 아니오 |
| 지연을 사용자에게 설명할 수 있는가? | 어렵다 | 가능하다 |
| 재처리로 수렴할 수 있는가? | 필요 없음 | 필요함 |
| write TPS가 높고 fanout이 큰가? | 부담 큼 | 분리 가능 |
| 충돌을 자동 해결할 수 있는가? | 중요도 낮음 | 중요 |
개인 프로젝트 기준
- 각 테이블이나 read model이 원본인지 파생 상태인지 표시한다.
- 주문, 결제, 포인트, 권한은 strong 기준을 우선 검토한다.
- eventual을 쓰면 projection lag와 재처리 방법을 적는다.
- conflict가 날 수 있는 API는 version이나 idempotency key를 둔다.
- “나중에 맞는다”는 말 대신 몇 초 또는 어떤 작업 후 맞는지 적는다.
기업 운영 기준
- source of truth, projection, cache의 owner와 SLO를 분리한다.
- outbox, DLQ, replay, rebuild의 운영 owner를 둔다.
- projection lag가 SLO를 넘으면 사용자 영향과 완화 조치를 정의한다.
- conflict count와 merge 실패율을 지표로 본다.
- strong path는 lock wait, transaction time, primary load를 capacity review에 포함한다.
위험 신호
- 모든 데이터를 strong으로 두고 primary 부하와 lock wait를 보지 않는다.
- eventual consistency를 쓰면서 lag 한계와 사용자 안내가 없다.
- projection table을 원본처럼 수정한다.
- event에 eventId, version, occurredAt이 없다.
- outbox 없이 DB write와 event publish를 분리한다.
- conflict를 마지막 쓰기 승리로 덮으면서 사용자 피해를 검토하지 않는다.
확인 질문
확인 질문
- 확인 질문: Strong consistency가 필요한 대표 조건은 무엇인가? - 답변: 틀린 성공이나 오래된 값이 돈, 권한, 재고, 포인트 잔액을 잘못 바꾸는 경우다.
- 확인 질문: Eventual consistency를 허용하려면 무엇이 함께 있어야 하는가?
- 답변: event id, version, projection lag 지표, 재처리 절차, 사용자 안내가 필요하다.
- 확인 질문: Outbox는 모든 일관성 문제를 해결하는가?
- 답변: 아니다. 원본 변경과 event 발행 의도를 묶어 유실을 줄이지만 consumer와 read model은 여전히 지연과 중복을 처리해야 한다.