이번 시간에는 여러 서비스에 걸친 주문을 Saga 패턴으로 처리하는 과정을 직접 만들어 보며 배워가는 시간입니다.
예를 들어서 주문 서비스, 결제 서비스, 재고 서비스가 각자 자기 DB를 쓴다고 해볼게요. 고객이 상품을 주문하면 결제 서비스가 카드 결제를 저장하고, 이어서 재고 서비스가 재고를 차감해요.
그런데 결제가 끝난 뒤에 재고가 0이라는 사실이 드러나면 어떻게 해야 할까요? 하나의 DB였다면 트랜잭션을 롤백하면 끝나지만, 결제 행은 이미 다른 DB에 커밋됐어요.
이 문제를 푸는 방법이 Saga 패턴이에요. Saga는 여러 서비스에 걸친 작업을 서비스마다 따로 커밋하는 단계로 나누고, 중간 단계가 실패하면 앞 단계를 되돌리는 보상 동작을 실행하는 방식입니다.
Saga를 이어 가는 방식은 두 가지예요. 코레오그래피(choreography)는 흐름을 정하는 중앙 서비스 없이, 각 서비스가 받은 이벤트에 반응해 자기 일을 하고 다음 이벤트를 내는 방식이에요. 오케스트레이션(orchestration)은 오케스트레이터라는 한 프로세스가 Saga의 상태를 쥐고, 각 서비스에 다음에 할 일을 명령으로 보내는 방식이에요.
이 글에서는 2PC가 왜 부담스러운지 먼저 확인하고, 오케스트레이션으로 보상과 보상 실패의 재시도를 재현해 볼게요.
실습 준비
이 글의 명령은 모두 실습 코드의 msa-saga/ 디렉터리에서 실행합니다.
compose.yaml: Kafka, PostgreSQL, 앱 컨테이너init.sql: 서비스별 계정, 데이터베이스, 테이블과 초기 재고common.py: 메시지를 읽어 처리 함수에 넘기는 공통 루프와 발행·로그 함수(서비스들이 불러 씀)svc.py: 주문·결제·재고 서비스(코레오그래피 3개, 명령 처리 2개)place.py: 주문을 넣는 주문 API 역할 스크립트orch.py: 오케스트레이터
그 밖의 파일은 데이터·보조 스크립트입니다.
compose.yaml은 apache/kafka:4.3.1(768MB), postgres:17-alpine(256MB), python:3.12-slim 앱 컨테이너(512MB)를 띄우고, PostgreSQL이 처음 뜰 때 init.sql을 실행해요.
init.sql은 서비스별 계정과 데이터베이스를 만들고, 다른 계정은 접속하지 못하게 CONNECT 권한을 막아요. 테이블은 이렇게 구성되어 있어요.
- orders DB:
orders(주문, 상태는PENDING,CONFIRMED,CANCELLED),sent_emails(메일 발송 기록), 오케스트레이션에서 쓰는saga_state와saga_log - payments DB:
payments(결제),refunds(환불,order_id가 기본 키),fault_injection(환불 실패를 일부러 일으키는 횟수) - inventory DB:
stock(상품 코드인 SKU별 재고, SKUA는 10개, SKUB는 0개),reservations(재고 예약)
먼저 명령을 짧게 쓰기 위한 셸 함수를 만들고, 컨테이너를 띄운 뒤 Python 패키지를 설치하고 토픽을 만들게요. svc는 앱 컨테이너 안에서 서비스 프로세스를 백그라운드로 띄우는 함수예요.
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
export COMPOSE_PROJECT_NAME=saga
kafka() { docker compose exec -T kafka /opt/kafka/bin/"$@"; }
psql_as() { docker exec -i saga-db-1 psql -U "$1" -d "$2" "${@:3}"; }
app() { docker exec -i saga-app-1 "$@"; }
svc() { docker exec -d saga-app-1 python "$@"; }
mkdir -p logs
docker compose up -d
app pip install -q --root-user-action ignore confluent-kafka==2.15.1 "psycopg[binary]==3.2.10"
for t in order-events payment-events inventory-events saga-commands-payment saga-commands-inventory saga-replies; do
kafka kafka-topics.sh --bootstrap-server kafka:9092 --create --topic $t --partitions 3
done
Created topic order-events.
Created topic payment-events.
Created topic inventory-events.
Created topic saga-commands-payment.
Created topic saga-commands-inventory.
Created topic saga-replies.
앞의 세 토픽은 코레오그래피에서, 뒤의 세 토픽은 오케스트레이션에서 써요. 메시지 키는 모두 order_id예요. 코레오그래피용 세 토픽과 svc.py의 코레오그래피 서비스 3개는 이 글에서는 쓰지 않아요.
PostgreSQL은 같은 서버 안이라도 다른 데이터베이스의 테이블을 쿼리에서 참조하지 못해요. 그러니까 이 실습의 세 서비스는 물리적으로 같은 서버를 쓰지만, 서로의 데이터를 볼 방법은 Kafka 메시지뿐인 거죠.
publish는 이벤트를 JSON으로 만들어 order_id를 키로 보내요. 서비스는 처리 결과를 /app/logs/<서비스>.log에 시간, 서비스, 주문 ID, 내용 순서로 한 줄씩 남깁니다.
실습 1: 2PC를 쓰지 않는 이유
서로 다른 DB의 쓰기를 한 번에 커밋하는 전통적인 방법은 2단계 커밋(2PC, two-phase commit)이에요.
코디네이터라고 부르는 프로그램이 참여하는 DB마다 먼저 "커밋할 준비가 됐는지" 묻고(1단계), 모두 준비됐다고 답하면 그때 커밋을 지시해요(2단계).
준비 단계를 통과한 트랜잭션은 커밋이 보장되도록 디스크에 기록되고, 커밋이나 롤백 지시가 올 때까지 잠금을 그대로 쥐고 있어요.
PostgreSQL은 이 준비 단계를 PREPARE TRANSACTION으로 제공해요. 이 기능은 max_prepared_transactions 설정의 기본값이 0이라 기본 상태에서는 꺼져 있고, 0인 서버에서 PREPARE TRANSACTION을 실행하면 오류가 나요. 실습에서는 이 값을 10으로 올려 띄웠어요.
그럼 코디네이터 역할을 손으로 해 볼게요. order-900 주문에 대해 payments DB에는 결제 행을 넣고, inventory DB에서는 SKU A의 재고를 하나 줄인 다음, 두 트랜잭션을 모두 준비 상태로 만들어요.
그리고 코디네이터가 커밋을 지시하기 전에 사라졌다고 가정하고 아무것도 하지 않을게요. psql 세션은 각 명령이 끝나면 바로 종료됩니다.
psql_as payment_svc payments <<'SQL'
BEGIN;
INSERT INTO payments VALUES ('order-900', 50000, 'PAID');
PREPARE TRANSACTION 'order-900-payments';
SQL
psql_as inventory_svc inventory <<'SQL'
BEGIN;
UPDATE stock SET available = available - 1 WHERE sku = 'A';
PREPARE TRANSACTION 'order-900-inventory';
SQL
psql_as postgres postgres -c "SELECT gid, prepared, owner, database FROM pg_prepared_xacts"
BEGIN
INSERT 0 1
PREPARE TRANSACTION
BEGIN
UPDATE 1
PREPARE TRANSACTION
gid | prepared | owner | database
---------------------+-------------------------------+---------------+-----------
order-900-payments | 2026-10-01 05:04:27.148717+00 | payment_svc | payments
order-900-inventory | 2026-10-01 05:04:27.624263+00 | inventory_svc | inventory
(2 rows)
세션은 끝났지만 pg_prepared_xacts 뷰에는 두 트랜잭션이 남아 있어요. gid는 준비할 때 붙인 이름이고, owner와 database는 누가 어느 DB에서 준비했는지 알려 줘요.
DB를 재시작한 뒤, 다른 주문을 처리하는 재고 서비스가 SKU A의 행을 잠그려고 한다고 해볼게요. 3초만 기다리도록 lock_timeout을 걸었어요.
docker compose restart db
psql_as postgres postgres -c "SELECT gid, prepared, owner, database FROM pg_prepared_xacts"
psql_as postgres inventory -c "SELECT x.gid, l.relation::regclass, l.mode FROM pg_locks l JOIN pg_prepared_xacts x ON l.virtualtransaction = '-1/' || x.transaction WHERE l.locktype = 'relation' AND x.database = 'inventory'"
psql_as inventory_svc inventory -c "SET lock_timeout = '3s'" -c "SELECT available FROM stock WHERE sku = 'A' FOR UPDATE"
gid | prepared | owner | database
---------------------+-------------------------------+---------------+-----------
order-900-payments | 2026-10-01 05:04:27.148717+00 | payment_svc | payments
order-900-inventory | 2026-10-01 05:04:27.624263+00 | inventory_svc | inventory
(2 rows)
gid | relation | mode
---------------------+------------+------------------
order-900-inventory | stock_pkey | RowExclusiveLock
order-900-inventory | stock | RowExclusiveLock
(2 rows)
SET
ERROR: canceling statement due to lock timeout
CONTEXT: while locking tuple (0,1) in relation "stock"
재시작 뒤에도 두 트랜잭션이 그대로 있어요. pg_locks를 보면 order-900-inventory가 stock 테이블과 기본 키 인덱스에 건 잠금도 남아 있죠.
행 잠금은 이 결과에 나오지 않아요. PostgreSQL은 행 잠금을 pg_locks가 아니라 행 자체에 기록하기 때문이에요. 대신 다시 lock timeout으로 끝난 마지막 명령이 행 잠금이 남아 있다는 증거입니다.
왜 재시작으로도 안 풀릴까요? 준비된 트랜잭션은 커밋을 보장해야 하니까 재시작으로 사라지지 않아요.
PostgreSQL 문서도 준비된 트랜잭션을 오래 두지 말라고 경고해요. 잠금을 계속 쥘 뿐 아니라 VACUUM이 오래된 행 버전을 정리하지 못하게 막기 때문이에요.
결국 누군가 결제사와 실제 결제 여부를 확인하고 결정을 내려 줘야 해요. 여기서는 주문을 취소하기로 하고, 각 DB에서 ROLLBACK PREPARED를 실행해 볼게요.
psql_as payment_svc payments -c "ROLLBACK PREPARED 'order-900-payments'"
psql_as inventory_svc inventory -c "ROLLBACK PREPARED 'order-900-inventory'"
psql_as inventory_svc inventory -c "SET lock_timeout = '3s'" -c "SELECT available FROM stock WHERE sku = 'A' FOR UPDATE"
ROLLBACK PREPARED
ROLLBACK PREPARED
SET
available
-----------
10
(1 row)
잠금이 풀리자 바로 행을 읽어요. 정리하면 2PC는 원자성을 주는 대신, 코디네이터가 멈추면 참여한 모든 DB가 잠금을 쥔 채 결정을 기다려요.
서비스마다 DB를 나눈 구조에서는 코디네이터 하나가 멈추면 여러 서비스의 주문 처리가 함께 멈춰요. 게다가 Kafka 같은 메시지 브로커나 외부 결제사 API는 애초에 PostgreSQL의 2PC에 참여할 수도 없어요. 그래서 서비스 사이에서는 보통 2PC 대신 Saga를 씁니다.
실습 2: 오케스트레이션으로 결제 되돌리기
이 실습에서는 오케스트레이터를 주문 서비스 안에 둘게요. 오케스트레이터는 orders DB의 saga_state에 주문마다 지금 단계와 상태를 저장하고, 단계를 지날 때마다 saga_log에 한 줄씩 기록해요.
명령은 saga-commands-payment, saga-commands-inventory 토픽으로 보내고, 응답은 두 서비스가 모두 saga-replies 토픽으로 보내요.
orch.py가 오케스트레이터예요. next_step은 saga_state.steps에 적힌 순서에서 다음 단계를 찾아 명령을 보내고, 단계가 더 없으면 주문을 CONFIRMED로 바꿔요. record는 saga_log에 한 줄을 넣고 로그 파일에도 씁니다.
결제 서비스와 재고 서비스 쪽은 svc.py payment-cmd, svc.py inventory-cmd로 띄워요. 명령을 받아 처리하고 saga-replies로 PaymentCompleted, InventoryFailed 같은 응답을 보내는 처리 함수만 다릅니다.
오케스트레이터와 명령을 처리하는 두 서비스를 띄운 뒤, 재고가 있는 SKU A와 재고가 0인 SKU B를 하나씩 주문해 볼게요. 기본 단계 순서는 결제, 재고, 메일이에요.
place.py <choreo|orch> <주문 ID> <SKU> <수량> <금액> [단계 순서]는 주문 서비스의 주문 API 역할을 해요. orch는 오케스트레이터에 Saga 시작을 맡깁니다.
svc orch.py; svc svc.py payment-cmd; svc svc.py inventory-cmd
app python place.py orch order-11 A 1 30000
app python place.py orch order-12 B 1 20000
psql_as order_svc orders -c "SELECT step, result, to_char(at, 'HH24:MI:SS.MS') AS at FROM saga_log WHERE order_id = 'order-12' ORDER BY id"
step | result | at
-----------+--------------------------------------------+--------------
start | 주문 PENDING, 순서 payment,inventory,email | 05:05:19.906
payment | ProcessPayment 명령 | 05:05:19.943
payment | 결제 성공 | 05:05:20.031
inventory | ReserveInventory 명령 | 05:05:20.046
inventory | 재고 부족으로 실패, 보상 시작 | 05:05:20.146
payment | RefundPayment 명령 (시도 1) | 05:05:20.183
payment | 환불 완료 | 05:05:20.274
done | 주문 CANCELLED | 05:05:20.278
(8 rows)
실패한 order-12의 전체 흐름이 테이블 하나에 순서대로 있어요. 결제 성공, 재고 실패, 환불 명령, 환불 완료, 주문 취소까지 SQL 한 번으로 보이죠.
두 주문의 최종 상태도 확인해 볼게요.
psql_as order_svc orders -c "SELECT order_id, step, status, attempts FROM saga_state" -c "SELECT order_id, status FROM orders WHERE order_id IN ('order-11', 'order-12')"
psql_as payment_svc payments -c "SELECT p.order_id, p.status, r.amount AS refunded FROM payments p LEFT JOIN refunds r USING (order_id) WHERE order_id IN ('order-11', 'order-12')"
order_id | step | status | attempts
----------+------+-----------+----------
order-11 | done | COMPLETED | 0
order-12 | done | CANCELLED | 1
(2 rows)
order_id | status
----------+-----------
order-11 | CONFIRMED
order-12 | CANCELLED
(2 rows)
order_id | status | refunded
----------+----------+----------
order-11 | PAID |
order-12 | REFUNDED | 20000
(2 rows)
여기서 눈여겨볼 건 order-12의 결제 행이에요. 지워지지 않고 REFUNDED로 남았고, refunds에 20000원짜리 환불 행이 새로 생겼어요.
DB 롤백이었다면 결제 행 자체가 없었을 텐데, 보상은 "결제했다가 환불했다"는 기록을 남긴 거예요.
실습 3: 보상이 실패하면 재시도
보상 트랜잭션도 실패할 수 있어요. 예를 들어서 환불을 요청했는데 결제사가 응답하지 않는 경우를 생각해 볼게요.
실습에서는 fault_injection 테이블의 remaining 값만큼 환불이 실패하게 했어요. 결제 서비스(svc.py payment-cmd)는 RefundPayment 명령을 받으면 먼저 remaining이 0보다 큰지 보고 1을 줄여요.
줄였으면 환불하지 않고 RefundFailed를 응답하고, 아니면 refunds에 환불 행을 넣은 뒤(이미 있으면 건너뜀) RefundCompleted를 응답합니다.
오케스트레이터는 RefundFailed를 받으면 기다렸다가 환불 명령을 다시 보내요. 기다리는 시간은 1초, 2초, 4초, 8초처럼 실패할 때마다 두 배로 늘려요.
이를 지수 백오프(exponential backoff)라고 해요. 장애가 난 상대에게 재시도 요청이 몰리지 않게 하려는 거예요. 다섯 번째 시도까지 실패하면 더 보내지 않고 상태를 COMPENSATION_FAILED로 바꿉니다.
환불이 두 번 실패하도록 설정하고 SKU B를 주문해 볼게요. 출력은 order-21의 줄만 보인 발췌예요(앞 주문의 로그 줄도 같은 파일에 남아 있어요).
psql_as payment_svc payments -c "UPDATE fault_injection SET remaining = 2 WHERE name = 'refund'"
app python place.py orch order-21 B 1 20000
cat logs/*.log | sort
UPDATE 1
05:05:26.783 orchestrator order-21 [start] 주문 PENDING, 순서 payment,inventory,email
05:05:26.808 orchestrator order-21 [payment] ProcessPayment 명령
05:05:26.825 payment order-21 ProcessPayment 처리, 결제 PAID
05:05:26.851 orchestrator order-21 [payment] 결제 성공
05:05:26.867 orchestrator order-21 [inventory] ReserveInventory 명령
05:05:26.903 inventory order-21 ReserveInventory 처리, 재고 부족
05:05:26.997 orchestrator order-21 [inventory] 재고 부족으로 실패, 보상 시작
05:05:27.063 orchestrator order-21 [payment] RefundPayment 명령 (시도 1)
05:05:27.100 payment order-21 RefundPayment 처리 실패(결제사 응답 없음)
05:05:27.175 orchestrator order-21 [payment] 환불 실패, 1초 뒤 재시도
05:05:28.192 orchestrator order-21 [payment] RefundPayment 명령 (시도 2)
05:05:28.199 payment order-21 RefundPayment 처리 실패(결제사 응답 없음)
05:05:28.205 orchestrator order-21 [payment] 환불 실패, 2초 뒤 재시도
05:05:30.211 orchestrator order-21 [payment] RefundPayment 명령 (시도 3)
05:05:30.221 payment order-21 RefundPayment 처리, 환불 저장
05:05:30.228 orchestrator order-21 [payment] 환불 완료
05:05:30.235 orchestrator order-21 [done] 주문 CANCELLED
1초, 2초를 기다린 뒤 세 번째 시도에서 환불이 저장됐고 주문은 취소됐어요.
정리
범위: Kafka 4.3(apache/kafka:4.3.1) 단일 노드, PostgreSQL 17(max_prepared_transactions=10), Python 주문·결제·재고 서비스, Saga 세 단계.
PREPARE TRANSACTION까지 하고 코디네이터가 멈추자 준비된 트랜잭션이 DB 재시작 뒤에도 남아 행 잠금을 쥐었고, 다른 주문의FOR UPDATE는 lock timeout으로 실패했어요. 서비스 사이의 쓰기는 2PC로 묶지 말고 Saga로 나눠 주세요.- 재고가 없어 보상된 주문의 결제 행은 지워지지 않고
REFUNDED로 남았고,refunds에 환불 행이 새로 생겼어요. 보상은 지난 기록을 지우지 않는 새로운 쓰기로 정의하세요. - 환불이 두 번 실패하자 1초, 2초를 기다린 뒤 세 번째 시도에서 환불이 저장됐어요. 보상 실패는 지수 백오프로 재시도해 주세요.