이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 분산 환경에서 실패를 단순 성공/실패가 아니라 어떤 모델로 나눠야 하는가?
- timeout, partial failure, message delay, split brain은 일관성 선택을 어떻게 바꾸는가?
- 실패 모델을 API, retry, idempotency, reconciliation 정책으로 어떻게 연결하는가?
개요
분산 시스템에서 가장 어려운 실패는 명확한 500 응답이 아니다. 응답이 없고, 일부만 성공했고, 메시지가 늦게 도착하고, 두 노드가 서로를 죽었다고 판단하는 상황이다. 이런 실패를 예외로 취급하면 설계가 항상 정상 흐름만 설명한다. 실무에서는 timeout, 중복, 순서 뒤바뀜, clock skew, partition을 정상 입력으로 두어야 한다. 일관성은 장애가 없을 때의 규칙이 아니라 장애 중에도 데이터가 망가지지 않게 하는 규칙이다.
이 문서가 바꾸는 설계 축
| 축 | 바뀌는 내용 |
|---|---|
| 성능 | timeout과 retry budget을 정해 느린 의존성에 무한히 묶이지 않는다. |
| 정합성 | unknown, duplicate, out-of-order, conflict 상태를 명시적으로 처리한다. |
| 장애 격리 | 부분 장애가 전체 요청 경로를 잠식하지 않게 fallback과 bulkhead를 둔다. |
| 운영 복잡도 | reconciliation, DLQ, replay, clock drift monitoring이 필요해진다. |
실패 모델 분류
| 실패 모델 | 예시 | 설계 대응 |
|---|---|---|
| Timeout | 결제 API 응답 없음 | unknown 상태, idempotency, 조회 확인 |
| Partial Failure | DB 저장 성공, event publish 실패 | outbox, 재발행 작업 |
| Message Delay | 배송 이벤트가 10분 늦게 도착 | version, occurredAt, stale 처리 |
| Duplicate Delivery | 같은 이벤트가 두 번 소비됨 | eventId dedup, idempotent consumer |
| Out-of-order | 배송중 후 결제완료 이벤트 도착 | version check, 상태 전이 검증 |
| Split Brain | 두 노드가 leader라고 판단 | quorum, fencing token |
| Clock Skew | 서버 시간 차이로 TTL/순서 오류 | logical version, NTP monitoring |
대표 시나리오
결제 요청이 timeout됐다고 하자. 사용자 화면에는 “결제 실패”처럼 보일 수 있다. 하지만 실제로는 결제사에서 승인됐을 수도 있다.
T1: 우리 서버가 결제 승인 요청 전송
T2: 결제사가 승인 처리
T3: 네트워크 지연으로 응답 미수신
T4: 우리 서버 timeout이 상태를 단순 실패로 보고 사용자가 다시 결제하게 하면 중복 승인이 된다.
따라서 UNKNOWN 또는 PENDING_CONFIRMATION 상태가 필요하다.
Unknown 상태를 둔다
성공/실패 이외의 상태를 도메인에 넣는다.
public enum PaymentStatus {
REQUESTED,
APPROVED,
FAILED,
UNKNOWN
}unknown은 부끄러운 상태가 아니다. 분산 환경에서 사실을 확정할 수 없다는 정확한 표현이다. unknown 상태에는 reconciliation owner와 만료 정책이 붙어야 한다.
unknown_payment_max_age = 15 minutes
reconciliation_interval = 1 minute
manual_review_after = 30 minutesRetry와 idempotency
timeout 후 retry는 필요하지만 위험하다. retry 가능한 경계에는 idempotency key가 있어야 한다.
POST /payments
Idempotency-Key: order-42-payment서버는 같은 key로 들어온 요청을 같은 결과로 돌려줘야 한다.
CREATE UNIQUE INDEX uq_payment_idempotency
ON payments (idempotency_key);idempotency key가 없으면 retry가 장애 복구가 아니라 장애 증폭이 된다.
Partial failure와 outbox
DB 저장은 성공했지만 event publish가 실패할 수 있다.
order row insert: success
OrderCreated event publish: timeout이 경우 order는 있는데 다른 서비스는 모른다. outbox는 이 간극을 줄인다. DB transaction 안에 event를 함께 저장하고, 별도 publisher가 재시도한다. 그래도 consumer는 중복 이벤트를 받을 수 있으므로 dedup이 필요하다. outbox는 publish 유실을 줄이지만 exactly-once를 보장하지 않는다.
Message ordering
메시지는 늦게 오거나 순서가 뒤집힐 수 있다. 배송 상태 이벤트를 예로 든다.
{
"shipmentId": "ship-10",
"status": "IN_TRANSIT",
"version": 4
}consumer는 현재 version이 5인데 version 4 이벤트가 오면 무시해야 한다. 상태 전이는 단순 timestamp보다 version과 allowed transition으로 검증한다. clock skew가 있으면 timestamp는 절대 순서 기준이 되기 어렵다.
Split brain과 fencing
분산 lock이나 leader election을 쓸 때는 split brain을 고려한다. 두 worker가 모두 자신이 leader라고 믿으면 같은 작업을 두 번 실행할 수 있다. fencing token은 늦게 도착한 옛 leader의 write를 거부하는 장치다.
leader token = 18
storage accepts writes only if token >= current token분산 lock만 믿고 최종 저장소 검증이 없으면 lock 만료 후 늦은 write가 들어올 수 있다.
API 응답 정책
실패 모델은 API 응답에도 반영된다.
| 상황 | 응답 |
|---|---|
| 확실한 실패 | 400, 409, 422 등 재시도해도 같은 실패 |
| 일시 실패 | 503, Retry-After |
| 결과 불명 | 202 PENDING_CONFIRMATION |
| 중복 요청 | 기존 결과 반환 |
| 오래된 이벤트 | 200으로 소비하되 상태 변경 없음 |
| client에게 “다시 누르면 될지”를 명확히 알려야 한다. | |
| 그렇지 않으면 사용자가 retry 정책의 일부가 된다. |
운영 지표
| 지표 | 의미 |
|---|---|
| timeout_by_dependency | 어떤 의존성이 불확실성을 만드는지 본다. |
| unknown_state_count | reconciliation backlog를 본다. |
| duplicate_event_count | broker나 retry 중복 정도를 본다. |
| out_of_order_event_count | version 처리 필요성을 본다. |
| dlq_oldest_age | 실패 메시지가 얼마나 오래 방치됐는지 본다. |
| clock_drift | 시간 기반 TTL과 ordering 위험을 본다. |
개인 프로젝트 기준
- 외부 API timeout을 실패로 단정하지 않고 unknown 상태를 검토한다.
- 결제, 주문, 쿠폰 발급에는 idempotency key를 둔다.
- 메시지 consumer는 같은 event를 두 번 받아도 안전하게 만든다.
- 이벤트에 version이나 sequence를 넣어 오래된 이벤트를 거부한다.
- DLQ가 있다면 수동 재처리 기준을 문서화한다.
기업 운영 기준
- 의존성별 timeout, retry, circuit breaker, idempotency 정책을 표준화한다.
- unknown 상태 reconciliation job과 owner를 둔다.
- outbox publisher, DLQ, replay 권한을 운영 절차로 관리한다.
- split brain 가능성이 있는 lock/leader 구조에는 fencing 검토를 포함한다.
- clock drift와 message delay를 장애 분석 지표로 남긴다.
위험 신호
- timeout을 무조건 실패로 처리한다.
- retry는 있지만 idempotency key가 없다.
- consumer가 중복 메시지나 순서 뒤바뀜을 처리하지 못한다.
- outbox 없이 DB 변경과 event publish를 따로 한다.
- 분산 lock이 있으면 중복 실행이 완전히 막힌다고 믿는다.
- unknown 상태가 쌓여도 reconciliation owner가 없다.
확인 질문
확인 질문
- 확인 질문: 500 응답보다 timeout이 더 어려운 이유는 무엇인가?
- 답변: 상대가 처리했는지 알 수 없으므로 성공과 실패를 확정할 수 없고, retry가 중복 처리로 이어질 수 있기 때문이다.
- 확인 질문: Outbox가 필요한 대표 상황은 무엇인가?
- 답변: 원본 DB 변경과 이벤트 발행을 함께 해야 하지만 둘 중 하나만 성공할 수 있는 상황이다.
- 확인 질문: 분산 lock만으로 충분하지 않은 이유는 무엇인가?
- 답변: lock 만료, 네트워크 지연, split brain 때문에 늦은 write가 들어올 수 있어 최종 저장소의 fencing이나 불변식 검증이 필요하기 때문이다.