본문으로 건너뛰기

일관성 경계와 Saga 패턴 - 디자인 패턴, 시스템 수준 편 ep.09

결제는 됐는데 주문은 안 됐다. 이것은 성공인가 실패인가.


이 경계는 무엇을 청구하는가

앞 편에서 같은 값의 사본들을 다뤘습니다. 이번 편은 다른 값들이 함께 맞아야 하는 문제입니다.

주문을 넣습니다. 재고가 줄고, 돈이 빠지고, 주문 기록이 생기고, 쿠폰이 사용 처리됩니다. 네 가지가 전부 일어나거나, 전부 일어나지 않아야 합니다.

한 데이터베이스 안이라면 트랜잭션이 이것을 보장합니다. BEGIN과 COMMIT 사이에서는 전부 아니면 전무입니다.

문제는 이 네 가지가 서로 다른 시스템에 있을 때입니다. 결제는 외부 PG사에, 재고는 우리 데이터베이스에, 쿠폰은 마케팅 서비스에 있을 때 트랜잭션은 이 경계를 넘지 못합니다.

이 경계가 청구하는 것은 중간 상태입니다. 트랜잭션이 닿지 않는 곳에서는 “결제는 됐는데 주문은 안 된” 상태가 실재합니다. 그 상태를 없앨 수는 없고, 다룰 수만 있습니다.

일관성 경계가 답하는 질문은 하나입니다. 어디까지가 하나의 사건인가.


이 경계에 붙어 있는 이름들

트랜잭션이 닿는 범위

먼저 확인할 것은 트랜잭션의 사정거리입니다.

범위원자성방법
같은 테이블보장됨기본 동작
같은 DB의 여러 테이블보장됨BEGIN ~ COMMIT
같은 DB의 여러 서비스보장 가능공유 트랜잭션
다른 DB어려움2PC: 실무에서 잘 안 씁니다
외부 API불가능보상으로 다룹니다

2단계 커밋(2PC) 이라는 방법이 있긴 합니다. 코디네이터가 참가자들에게 “준비됐나” 묻고, 전부 예라고 하면 “커밋해라”를 보냅니다. 이론적으로는 원자성이 보장됩니다.

실무에서는 잘 쓰지 않습니다. 코디네이터가 죽으면 참가자들이 잠금을 붙든 채 멈춥니다. 참가자 중 하나가 느리면 전체가 느려집니다. 그리고 외부 결제사가 우리의 2PC에 참여해 줄 리가 없습니다.

그래서 다른 접근이 필요합니다. 원자성을 포기하고, 대신 되돌리는 방법을 준비하는 것입니다.

트랜잭션이 닿는 범위와 닿지 않는 범위를 보여주는 다이어그램. 중앙에 하나의 데이터베이스가 있고 그 안에 주문 테이블과 재고 테이블이 들어 있으며, 두 테이블을 감싸는 트랜잭션 경계가 실선 박스로 그려져 있습니다. 이 경계 안에는 전부 아니면 전무라는 라벨이 붙어 있습니다. 경계 바깥에는 외부 결제사와 마케팅 서비스가 별도의 시스템으로 배치되어 있으며, 이들과 데이터베이스 사이에는 트랜잭션 경계가 그어지지 않고 네트워크 호출 화살표만 그려져 있습니다. 이 구간에는 중간 상태가 실재한다는 경고가 표시됩니다. 하단에는 트랜잭션 사정거리가 표로 정리되어 있어, 같은 테이블과 같은 데이터베이스 안에서는 원자성이 보장되고, 다른 데이터베이스는 2단계 커밋이 필요하며 실무에서 잘 쓰지 않고, 외부 API는 아예 불가능하다는 점이 단계별로 표시됩니다.

Saga

Saga(사가) 는 여러 로컬 트랜잭션을 이어 붙이고, 실패하면 보상 트랜잭션(compensating transaction) 으로 되돌리는 방식입니다. 1987년의 논문에서 온 이름이고, 마이크로서비스와 함께 다시 널리 쓰이게 되었습니다.

보상 트랜잭션은 “취소”가 아니라 “반대 방향의 새 작업” 입니다. 이미 커밋된 것은 되돌릴 수 없습니다. 대신 그것을 상쇄하는 작업을 새로 합니다.

// 각 단계와 그 보상
type SagaStep<T> = {
  name: string
  execute: (ctx: T) => Promise<void>
  compensate: (ctx: T) => Promise<void>
}

const placeOrderSaga: SagaStep<OrderContext>[] = [
  {
    name: 'reserve-stock',
    execute: (ctx) => stockService.reserve(ctx.items, ctx.orderId),
    compensate: (ctx) => stockService.release(ctx.orderId),
  },
  {
    name: 'charge-payment',
    execute: async (ctx) => {
      ctx.paymentId = await paymentGateway.charge(ctx.amount, ctx.orderId)
    },
    // 결제 취소는 "없던 일"이 아니라 "환불"이라는 새 거래입니다
    compensate: (ctx) => paymentGateway.refund(ctx.paymentId),
  },
  {
    name: 'create-order',
    execute: (ctx) => orderService.create(ctx),
    compensate: (ctx) => orderService.markCancelled(ctx.orderId),
  },
]

실행 엔진은 단순합니다. 앞으로 가다 실패하면 뒤로 돌아옵니다.

async function runSaga<T>(steps: SagaStep<T>[], ctx: T) {
  const completed: SagaStep<T>[] = []

  try {
    for (const step of steps) {
      await step.execute(ctx)
      completed.push(step)
    }
  } catch (error) {
    // 성공한 것들을 역순으로 되돌립니다
    for (const step of completed.reverse()) {
      try {
        await step.compensate(ctx)
      } catch (compensateError) {
        // 보상마저 실패하면 사람이 개입해야 합니다
        await alerting.critical('saga compensation failed', {
          step: step.name, ctx, compensateError,
        })
      }
    }
    throw error
  }
}

보상도 실패할 수 있습니다. 그때는 자동으로 해결되지 않습니다. catch를 통해 사람이 볼 수 있는 곳에 남겨놔야 합니다.

보상은 대칭이 아닙니다

보상 트랜잭션은 원래 작업의 정확한 역이 아닙니다.

  • 재고를 10개 줄였다가 되돌리면 10개가 늘어납니다. 대칭입니다.
  • 결제를 취소하면 환불 거래가 생깁니다. 카드 명세서에는 결제와 환불이 둘 다 남습니다.
  • 확인 메일을 보냈다가 취소하면? 메일은 되돌릴 수 없습니다. “취소되었습니다” 메일을 한 통 더 보내는 것이 최선입니다.

되돌릴 수 없는 작업은 Saga의 마지막에 배치합니다. 메일 발송, 배송 시작, 외부 알림처럼 취소가 어려운 것들을 앞에 두면, 뒤에서 실패했을 때 정리할 방법이 없습니다.

순서 설계의 원칙을 이런 기준으로 만듭니다.

  1. 되돌리기 쉬운 것부터 (재고 예약, 임시 저장)
  2. 되돌리기 비싼 것 (결제)
  3. 되돌릴 수 없는 것 (메일, 배송 접수)

Saga의 보상 트랜잭션 흐름 다이어그램. 상단에는 정상 흐름이 그려져 있습니다. 재고 예약, 결제 승인, 주문 생성, 메일 발송의 네 단계가 좌에서 우로 순차 진행되며 모두 성공한 모습입니다. 하단에는 실패 흐름이 그려져 있습니다. 재고 예약과 결제 승인은 성공했으나 주문 생성에서 실패한 상황이며, 실패 지점에서 화살표가 반대 방향으로 꺾여 역순으로 보상이 진행됩니다. 결제 환불, 재고 해제 순서입니다. 각 보상 단계에는 그것이 원래 작업의 정확한 역이 아니라는 점이 표시됩니다. 결제 환불은 카드 명세서에 결제와 환불이 둘 다 남고, 메일은 아예 되돌릴 수 없습니다. 하단에는 순서 설계 원칙이 세 단계로 정리되어 있습니다. 되돌리기 쉬운 것을 앞에, 비싼 것을 중간에, 되돌릴 수 없는 것을 마지막에 배치한다는 내용입니다.

오케스트레이션과 코레오그래피

Saga를 조율하는 방식은 두 가지입니다.

오케스트레이션(orchestration): 지휘자가 있습니다. 하나의 조율자가 각 서비스를 순서대로 부르고, 실패하면 보상을 지시합니다. 흐름이 한 곳에 모여 있어 읽기 쉽고 디버깅하기 쉽습니다. 대신 조율자가 모든 서비스를 알아야 합니다.

코레오그래피(choreography): 지휘자가 없습니다. 각 서비스가 이벤트를 듣고 자기 일을 하고 다음 이벤트를 냅니다. 서비스 간 결합이 낮습니다. 대신 전체 흐름이 코드 어디에도 없어서, 무슨 일이 일어나는지 알려면 여섯 저장소를 열어야 합니다.

실무에서는 단계가 셋을 넘으면 오케스트레이션이 낫습니다. 흐름을 한 곳에서 볼 수 있다는 이점이 결합도 비용보다 큽니다.

Event Sourcing

Event Sourcing(이벤트 소싱) 은 현재 상태를 저장하는 대신 일어난 사건의 목록을 저장하는 방식입니다.

// 상태 저장 — 현재만 남습니다
{ orderId: 'o-1', status: 'shipped', total: 45000 }

// 이벤트 저장 — 과정이 남습니다
[
  { type: 'OrderPlaced',    at: '10:00', items: [...], total: 50000 },
  { type: 'CouponApplied',  at: '10:00', couponId: 'c-9', discount: 5000 },
  { type: 'PaymentCharged', at: '10:01', paymentId: 'p-3', amount: 45000 },
  { type: 'OrderShipped',   at: '14:20', trackingNo: '1234' },
]

현재 상태는 이벤트를 순서대로 겹쳐서 계산합니다.

function reduceOrder(events: OrderEvent[]): Order {
  return events.reduce(applyEvent, initialOrder)
}

얻는 것이 큽니다. 어떻게 이 상태가 되었는지가 데이터에 남습니다. “왜 이 주문의 금액이 45,000원이지”라는 질문에 답할 수 있습니다. 감사 로그가 부산물이 아니라 원본입니다.

비용도 큽니다. 이벤트가 쌓이면 매번 접는 비용이 커지므로 스냅샷이 필요합니다. 이벤트 스키마를 바꾸기 어렵습니다. 이미 저장된 이벤트는 수정할 수 없기 때문입니다.

전면 도입보다는 필요한 도메인에만 쓰는 편이 현실적입니다. 주문 상태 변경 이력, 결제 처리 과정, 권한 변경처럼 “왜 이렇게 되었는지”가 중요한 영역에 어울립니다.

앞선 시리즈의 State 패턴이 상태 전이를 객체 수준에서 다뤘다면, Event Sourcing은 같은 전이를 저장 수준으로 옮긴 것입니다.

최종 일관성

여러 서비스가 각자의 데이터를 갖고 이벤트로 동기화하면, 잠깐 어긋나는 구간이 생깁니다. 최종 일관성(eventual consistency) 은 “지금은 다를 수 있지만 결국 같아진다”는 약속입니다.

“결국”이 언제인지가 설계의 대상입니다. 100밀리초일 수도, 5분일 수도 있습니다. 그리고 그 시간이 사용자 경험을 결정합니다.

  • 주문 직후 목록에 안 보임 → 5초면 사용자가 눈치챕니다.
  • 프로필 사진 변경이 다른 화면에 반영 안 됨 → 5분이어도 대개 괜찮습니다.

어긋나 있어도 되는 시간을 데이터마다 정해 두는 것이 이 경계의 실무 작업입니다. ep.08 캐시 경계에서 TTL을 정한 것과 같은 종류의 결정입니다.

안티패턴

보상 없는 다단계 처리: 여러 서비스를 순서대로 부르면서 실패 처리가 보상 절차 없이 catch { log() }로 로그만 남길뿐이면, 중간 상태가 그대로 남습니다.

보상이 멱등하지 않음: 보상이 두 번 실행되면 두 번 환불될 수 있습니다. ep.06 시간의 경계의 멱등성이 보상에도 필요합니다.

Saga를 트랜잭션처럼 취급: Saga 중간에는 다른 사용자가 그 중간 상태를 볼 수 있습니다. 격리성이 없습니다. 재고를 예약했다가 푸는 사이에 다른 사람이 그 재고를 못 살 수 있습니다.

되돌릴 수 없는 작업을 앞에 배치: 확인 메일을 첫 단계에 보내면, 뒤에서 실패했을 때 “죄송합니다” 메일을 또 보내야 합니다.


이 경계가 화면에 드러나는 자리

이 경계는 화면에서 문장의 문제가 됩니다. 그리고 가장 쓰기 어려운 문장이 여기서 나옵니다.

부분 실패를 말하는 법

“결제는 됐는데 주문이 안 됐습니다”를 사용자에게 어떻게 전달할까요.

가장 나쁜 답은 “오류가 발생했습니다” 입니다. 사용자는 돈이 빠졌는지 안 빠졌는지 모릅니다. 카드 앱을 열어 확인하고, 다시 시도할지 고민하고, 두 번 결제될까 봐 두려워합니다.

좋은 부분 실패 메시지는 세 가지를 담습니다.

  1. 무엇이 되었는가: 결제는 정상 처리되었습니다.
  2. 무엇이 안 되었는가: 주문서 생성에 실패했습니다.
  3. 지금 무슨 일이 일어나고 있는가: 결제 금액은 자동으로 환불되며, 3영업일 내 반영됩니다.
주문을 완료하지 못했습니다

결제는 정상 처리되었으나 주문서 생성에 실패했습니다.
결제 금액 45,000원은 자동으로 환불 처리되며, 카드사에 따라
3영업일 내 반영됩니다.

환불 확인번호: RF-8842-01

[다시 주문하기]   [환불 내역 확인]

확인번호가 중요합니다. 사용자가 나중에 문의할 때 구체적으로 어떤 건인지 알려줄 포인트가 있어야 합니다. 확인번호는 ep.13 관측 경계에서 다룰 에러 ID와 같은 역할입니다.

보상이 진행 중일 때

보상은 즉시 끝나지 않습니다. 환불은 몇 초에서 며칠까지 걸립니다. 그 사이에도 사용자는 상태를 알아야 합니다.

진행 상태화면 문구
보상 시작 전주문 처리 중 문제가 발생했습니다. 확인 중입니다.
보상 진행 중환불을 처리하고 있습니다.
보상 완료환불이 완료되었습니다. (확인번호 포함)
보상 실패자동 환불에 실패했습니다. 고객센터에서 확인 중이며 24시간 내 연락드리겠습니다.

마지막 줄이 특히 중요합니다. 자동 복구에 실패했을 때 “사람이 보고 있다”는 사실을 알리는 것이 사용자를 가장 안심시킵니다.

부분 실패 상황의 화면 설계 비교. 좌측은 나쁜 예로, '오류가 발생했습니다'라는 문장 하나와 확인 버튼만 있는 화면이 그려져 있습니다. 그 아래에는 이 화면을 본 사용자가 하게 되는 행동이 나열됩니다. 카드 앱을 열어 확인하고, 다시 시도할지 고민하고, 두 번 결제될까 두려워한다는 내용입니다. 우측은 좋은 예로, 무엇이 되었고 무엇이 안 되었으며 지금 무슨 일이 일어나고 있는지가 세 문단으로 나뉜 화면이 그려져 있습니다. 결제는 정상 처리되었고 주문서 생성에 실패했으며 금액은 자동 환불된다는 내용과 함께 환불 확인번호가 표시되고, 다시 주문하기와 환불 내역 확인 두 개의 버튼이 있습니다. 하단에는 보상 진행 단계별 문구가 표로 정리되어 있습니다. 보상 시작 전, 진행 중, 완료, 실패 네 단계에 각각 대응하는 문장이 적혀 있으며, 보상 실패 시에는 사람이 확인 중이라는 사실을 알리는 것이 가장 중요하다는 설명이 붙어 있습니다.

되돌릴 지점을 어디에 둘 것인가

일관성 경계는 “여기까지는 되돌릴 수 있다” 는 선을 화면에 만듭니다. 이 선을 사용자에게 보여주는 것이 좋은 설계입니다.

  • 장바구니 → 언제든 비울 수 있습니다.
  • 주문서 작성 → 뒤로 갈 수 있습니다.
  • 결제 버튼 → 여기가 마지노 선입니다.
  • 결제 완료 → 되돌리려면 취소·환불이라는 새 절차가 필요합니다.

이 선 앞에 마찰을 두는 것이 확인 화면입니다. “정말 결제하시겠습니까”는 귀찮게 하려는 것이 아니라, 되돌릴 수 없는 구간에 들어간다는 신호입니다.

반대로, 되돌릴 수 있는 곳에 확인 화면을 두면 소음이 됩니다. 장바구니에 담을 때마다 “담으시겠습니까”를 물으면 사용자는 갈수록 이런 메시지를 읽지 않게 되고, 나중에 결제 확인도 같이 무시당합니다.

확인 화면의 개수는 되돌릴 수 없는 지점의 개수와 같아야 합니다.

진행 중 상태를 화면에 남기기

Saga는 시간이 걸립니다. 그 사이 사용자가 화면을 떠날 수 있습니다.

그래서 주문 목록에 “처리 중” 상태가 필요합니다. 성공과 실패만 있는 목록은 Saga를 표현하지 못합니다.

type OrderStatus =
  | 'processing'      // Saga 진행 중
  | 'confirmed'       // 전부 성공
  | 'cancelling'      // 보상 진행 중
  | 'cancelled'       // 보상 완료
  | 'needs_attention' // 보상 실패 — 사람이 봐야 함

마지막 needs_attention을 타입에 넣어 자동으로 해결되지 않는 상태가 존재한다는 사실을 인정해야 합니다. 이 상태를 만들어 두지 않으면, 주문이 어중간한 상태로 목록에 남아 아무도 처리하지 않게 됩니다.


참고 자료


다음 편 예고

ep.10 - 신뢰 경계와 검증의 위치

데이터가 흐르는 마지막 질문이 남았습니다. 이 입력을 믿어도 되는가. 클라이언트 검증은 UX이고 서버 검증은 보안입니다. 둘을 헷갈리면 사고가 납니다.