이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 분산 환경에서 실패를 단순 성공/실패가 아니라 어떤 모델로 나눠야 하는가?
  • 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 FailureDB 저장 성공, 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 minutes

Retry와 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_countreconciliation backlog를 본다.
duplicate_event_countbroker나 retry 중복 정도를 본다.
out_of_order_event_countversion 처리 필요성을 본다.
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이나 불변식 검증이 필요하기 때문이다.