Saga Pattern과 보상 트랜잭션 실전 설계

· 약 10분 읽기 · 조회 1 #분산 시스템#Saga 패턴#트랜잭션#마이크로서비스#아키텍처

주문·결제·재고가 서로 다른 서비스와 DB로 나뉘면 한 번의 커밋으로 전부를 묶을 수 없고, 중간 단계가 실패했을 때 앞 단계를 되돌릴 방법을 따로 만들어야 한다. 이 글은 로컬 트랜잭션을 차례로 실행하고 실패하면 보상 트랜잭션으로 취소하는 Saga 패턴을, 단계 순서·상태 테이블·멱등성·격리·타임아웃까지 실제 설계 순서대로 정리한 것이다.

눈 덮인 능선을 따라 텐트 캠프가 이어지고, 등반가들이 로프를 잡고 캠프 사이를 오가며 맨 앞 사람은 이전 캠프로 돌아가는 모습

한눈에 보기

설계 결정 핵심 질문 기본 선택
조율 방식 누가 다음 단계를 부르나 단계 4개 이상이면 오케스트레이션
보상 정의 각 단계를 의미상 어떻게 취소하나 상태 전이(취소·환불 기록), 행 삭제 금지
단계 순서 되돌릴 수 없는 단계는 어디에 두나 보상 가능 → 피벗 → 재시도 가능
상태 기록 지금 몇 번째 단계인가 saga 상태 테이블 + 단계 로그
실패 처리 보상·재시도가 또 실패하면 멱등 보상, 무한 재시도 + 경보
격리 중간 상태를 누가 보나 *_PENDING 같은 의미적 잠금
타임아웃 응답이 없으면 실패인가 상태 조회로 확인한 뒤 판단

주문 생성, 재고 예약, 결제 승인, 배송 접수 순서에서 피벗인 결제 승인이 거절되면 재고 해제와 주문 취소를 역순으로 실행하고, 결제 승인 뒤 배송 접수는 실패해도 재시도하는 흐름

왜 2PC가 아니라 Saga인가

2단계 커밋(2PC)은 모든 참여자가 준비를 마칠 때까지 잠금을 쥐고 기다린다. 참여자 하나가 느리면 전체가 느려지고, 코디네이터가 죽으면 잠금이 풀리지 않는다. 메시지 브로커나 외부 결제사처럼 XA를 지원하지 않는 참여자가 끼면 아예 쓸 수 없다.

Saga는 1987년 Hector Garcia-Molina와 Kenneth Salem의 논문 "Sagas"에서 나온 개념이다. 오래 걸리는 트랜잭션을 짧은 로컬 트랜잭션 T1…Tn으로 나누고, 각 Ti마다 그 효과를 취소하는 보상 트랜잭션 Ci를 짝지운다. Tk에서 실패하면 C(k-1)…C1을 역순으로 실행한다. 각 단계가 바로 커밋되므로 잠금을 오래 쥐지 않는다. 그 대가로 원자성은 "결국 전부 되거나 전부 취소된다"로 약해지고, 격리는 사라진다.

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

코레오그래피는 각 서비스가 이벤트를 듣고 다음 일을 스스로 한다. 주문 서비스가 OrderCreated를 내면 재고 서비스가 받아 예약하고 StockReserved를 낸다.

  • 장점: 중앙 조율자가 없어 단계 2~3개짜리 흐름은 빨리 만든다.
  • 단점: 흐름이 코드 여러 곳에 흩어져 "지금 이 주문이 어디까지 갔나"를 한곳에서 볼 수 없다. 서비스끼리 서로의 이벤트를 알아야 해 순환 의존이 생기기 쉽다.

오케스트레이션은 오케스트레이터 하나가 단계마다 명령을 보내고 응답을 받아 다음을 정한다.

  • 장점: 흐름과 보상 순서가 한 클래스(또는 워크플로 정의)에 모인다. 상태 테이블도 한곳에 있다.
  • 단점: 오케스트레이터에 업무 로직이 몰리기 쉽다. 오케스트레이터는 순서와 분기만 알고, 금액 계산 같은 규칙은 각 서비스에 둔다.

단계가 4개 이상이거나 보상 분기가 있으면 오케스트레이션을 기본으로 한다. Temporal 같은 워크플로 엔진을 쓰면 상태 저장과 재시도를 엔진이 맡는다.

보상은 롤백이 아니다

보상은 DB 롤백처럼 흔적 없이 지우는 일이 아니다. 이미 커밋되어 다른 사람이 봤을 수 있는 결과를 업무 의미로 취소하는 새 트랜잭션이다.

정방향 Ti 보상 Ci 하면 안 되는 보상
주문 생성(PENDING) 상태를 CANCELLED로 주문 행 DELETE
재고 예약 예약 해제(RELEASED) 재고 수량을 이전 값으로 덮어쓰기
포인트 차감 적립 이력 추가 차감 이력 삭제
안내 메일 발송 취소 안내 메일 (보낸 메일은 회수 불가)

수량을 "이전 값으로 덮어쓰기"하면 그사이 다른 주문이 바꾼 값까지 지운다. 보상은 항상 차이(+N, -N)나 상태 전이로 쓴다. 메일처럼 되돌릴 수 없는 부작용은 보상이 아니라 다음 절의 순서 배치로 다룬다.

단계 순서: 피벗을 어디에 둘까

단계는 세 종류로 나눈다.

  • 보상 가능 단계: 취소할 방법이 있다. 주문 생성, 재고 예약.
  • 피벗(pivot): 이 단계가 커밋되면 Saga는 끝까지 가야 한다. 외부 결제 승인처럼 되돌리기 어렵거나 비싼 단계다.
  • 재시도 가능 단계: 피벗 뒤에 오며 실패하지 않을 때까지 다시 보낸다. 배송 접수, 알림 발송.

순서는 보상 가능 → 피벗 → 재시도 가능이다. 피벗 앞에서 실패하면 역순으로 보상하고, 피벗 뒤에서 실패하면 보상하지 않고 재시도한다. 알림 메일을 결제 앞에 두면 결제가 거절됐을 때 "주문 완료" 메일이 이미 나간 상태가 된다. 되돌릴 수 없는 부작용은 피벗 뒤로 옮기는 것이 보상 로직을 짜는 것보다 싸다.

saga 상태 테이블

오케스트레이터는 매 단계 결과를 DB에 남긴다. 프로세스가 죽었다 살아나도 여기서 이어 간다.

CREATE TABLE saga_instance (
  saga_id      VARCHAR(36) PRIMARY KEY,
  saga_type    VARCHAR(50) NOT NULL,          -- ORDER_PLACEMENT
  business_key VARCHAR(50) NOT NULL,          -- 주문 번호
  status       VARCHAR(20) NOT NULL,          -- RUNNING, COMPENSATING, DONE, FAILED
  current_step INT NOT NULL,
  payload      TEXT NOT NULL,                 -- 보상에 필요한 값(예약 ID, 승인 번호)
  deadline_at  TIMESTAMP NOT NULL,
  version      INT NOT NULL DEFAULT 0,        -- 낙관적 잠금
  updated_at   TIMESTAMP NOT NULL
);

CREATE TABLE saga_step_log (
  saga_id   VARCHAR(36) NOT NULL,
  step      INT NOT NULL,
  direction VARCHAR(4) NOT NULL,              -- FWD, COMP
  result    VARCHAR(10) NOT NULL,             -- OK, FAIL, TIMEOUT
  at        TIMESTAMP NOT NULL,
  PRIMARY KEY (saga_id, step, direction)
);
  • payload에는 보상에 쓸 값을 정방향 응답에서 받아 저장한다. 결제 취소에는 승인 번호가 필요한데 나중에 다시 조회하려 하면 이미 늦다.
  • 명령 발행은 상태 갱신과 같은 트랜잭션에서 Outbox에 쓴다. 이 부분은 이전 글 Outbox·Inbox 패턴으로 연계 데이터 정합성 확보하기를 참고한다.
  • version으로 같은 Saga를 두 인스턴스가 동시에 진행하는 것을 막는다.

보상의 멱등성, 재시도, 타임아웃

보상도 실패한다. 네트워크가 끊기면 오케스트레이터는 같은 보상을 다시 보내고, 받는 쪽은 같은 요청을 두 번 받는다. 그래서 보상은 반드시 멱등해야 한다.

  • 같은 보상 두 번: 이미 RELEASED면 아무것도 하지 않고 성공을 돌려준다. 요청 ID 중복 처리는 이전 글 분산 환경에서 멱등 API와 중복 요청 방지 설계의 키 저장소 방식을 그대로 쓴다.
  • 정방향보다 먼저 도착한 보상: 예약 요청이 늦게 도착하고 해제 요청이 먼저 오면, 해제할 예약이 없다. 이때 "취소됨" 기록을 남기고 성공을 돌려준다. 나중에 도착한 예약 요청은 이 기록을 보고 거절한다. 기록을 남기지 않으면 늦게 온 예약이 그대로 살아남는다.
  • 보상이 계속 실패: 보상은 포기할 수 없다. 지수 백오프로 무한 재시도하되, 일정 횟수가 넘으면 FAILED로 표시하고 운영자에게 경보를 보낸다. 사람이 처리할 화면이나 절차가 있어야 한다.

타임아웃은 "응답 없음"이지 "실패"가 아니다. 결제사가 30초 동안 답이 없어도 실제로는 승인됐을 수 있다. 바로 보상으로 가면 돈은 빠졌는데 주문은 취소된다.

deadline 경과 → 상태 조회 API 호출
  ├─ 승인됨   → 정방향 계속
  ├─ 거절·없음 → 보상 시작 (취소됨 기록 포함)
  └─ 조회 불가 → 재조회, 일정 횟수 넘으면 FAILED + 경보

조회 API가 없는 외부 시스템이면 같은 요청 ID로 다시 보내 결과를 받는 방식(멱등 재요청)을 쓴다. 둘 다 안 되는 시스템은 피벗 자리에 두고 수작업 대사 절차를 붙인다.

격리 부족: 중간 상태가 보인다

Saga의 각 단계는 바로 커밋되므로, 다른 트랜잭션이 끝나지 않은 Saga의 중간 결과를 읽는다. 재고가 예약된 직후 다른 주문이 "남은 재고 0"을 보고 품절로 거절했는데, 첫 Saga가 결제 거절로 보상되면 팔 수 있던 재고를 놓친 셈이다. 반대로 취소될 주문을 보고 배송을 준비할 수도 있다.

대표 대책은 다음과 같다.

  • 의미적 잠금: 진행 중인 레코드에 APPROVAL_PENDING, RESERVED 같은 상태를 둔다. 다른 요청은 이 상태를 보고 기다리거나 거절하거나 "처리 중"으로 표시한다. 가장 흔히 쓴다.
  • 교환 가능한 갱신: stock = stock - 1처럼 순서가 바뀌어도 결과가 같은 연산만 쓴다. 덮어쓰기 대신 증감으로 쓰면 보상이 다른 갱신을 지우지 않는다.
  • 다시 읽기: 갱신 직전에 값을 다시 읽어 바뀌었으면 Saga를 중단하고 다시 시작한다. 낙관적 잠금과 같은 원리다.
  • 비관적 순서: 다른 사람이 읽으면 위험한 갱신을 재시도 가능 단계 쪽으로 옮겨, 보상될 가능성이 있는 동안 노출되지 않게 한다.

화면에도 반영한다. "주문 접수 중" 상태를 사용자에게 보여 주면 보상이 일어나도 "완료됐다가 취소됨"보다 덜 혼란스럽다.

구성요소를 왜 두는가

구성요소 누가 쓰나 없으면 생기는 일
saga 상태 테이블 오케스트레이터 재시작 뒤 어디서 이어 갈지 모른다
payload의 보상용 값 보상 단계 승인 번호가 없어 결제 취소를 못 한다
멱등 보상 + 취소됨 기록 각 서비스 늦게 온 정방향 요청이 살아남는다
의미적 잠금 상태 다른 요청·화면 취소될 데이터를 보고 다음 일을 한다
deadline + 상태 조회 오케스트레이터 승인된 결제를 실패로 보고 보상한다
FAILED 경보와 수작업 절차 운영자 보상이 멈춘 채 아무도 모른다

자주 하는 실수

보상을 DELETE로 구현한다. 감사 이력이 사라지고, 늦게 온 요청이 같은 키로 다시 만들어진다.

되돌릴 수 없는 부작용을 피벗 앞에 둔다. 메일·문자·외부 전송은 보상할 수 없으므로 피벗 뒤에서 재시도로 다룬다.

타임아웃을 실패로 처리한다. 실제로 성공한 정방향 위에 보상이 덮여 돈과 주문이 어긋난다.

보상에 필요한 값을 저장하지 않는다. 보상 시점에 원래 응답을 다시 구할 방법이 없는 경우가 많다.

코레오그래피로 시작해 단계가 늘어도 그대로 둔다. 단계 5개를 넘으면 흐름을 추적하려고 이벤트 로그를 서비스마다 뒤지게 된다.

점검표

  • 모든 정방향 단계에 짝이 되는 보상이 정의되어 있거나, 피벗·재시도 가능 단계로 분류되어 있다.
  • 보상은 상태 전이나 증감으로 구현되어 있고 DELETE·덮어쓰기가 없다.
  • 되돌릴 수 없는 부작용은 모두 피벗 뒤에 있다.
  • saga 상태 테이블에 현재 단계, 보상용 값, deadline, version이 있다.
  • 명령 발행이 상태 갱신과 같은 트랜잭션(Outbox)에서 일어난다.
  • 같은 보상을 두 번 받아도 결과가 같다는 테스트가 있다.
  • 정방향보다 보상이 먼저 도착하는 경우를 테스트했다.
  • 타임아웃 뒤 상태 조회로 결과를 확인하고 나서 보상한다.
  • 진행 중 레코드에 의미적 잠금 상태가 있고 화면에 "처리 중"으로 보인다.
  • FAILED Saga에 경보와 사람이 처리할 절차가 있다.

Saga는 분산 트랜잭션을 없애는 기술이 아니라, 실패를 정상 흐름의 일부로 설계하는 방법이다. 각 단계의 의미적 취소를 정하고, 되돌릴 수 없는 단계를 피벗 뒤로 보내고, 보상과 타임아웃을 멱등하게 만들면 서비스가 나뉘어도 주문 하나가 어중간한 상태로 남지 않는다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기