사가를 닫다 — 결제 서비스와 주문의 완주
2025-08-11
솔직히 고백할 게 있습니다. 8편짜리 시리즈로 이벤트 기반 쇼핑몰을 "완성"했다고 했지만, 실은 주문이 PLACED 상태에서 멈춰 있었습니다. 주문을 넣으면 재고가 예약되긴 했는데, 그 뒤로 아무 일도 일어나지 않았죠. 결제도, 확정도, 배송도 없었습니다. 이벤트 체인이 재고 예약에서 뚝 끊겨 있었던 겁니다.
이 심화 시리즈에서 그 끊긴 흐름을 잇고 다듬습니다. 첫 편의 목표는 하나 — 사가를 닫는 것. 결제 서비스를 붙여, 주문이 이벤트만으로 PLACED → PAID → CONFIRMED 까지 스스로 흘러가고, 재고가 부족하면 자동으로 취소되게 만듭니다.
💻 이 편의 코드는 github.com/kahnco/go-ddd-shop 의
part-9태그에 있습니다.
사가(saga)란 — 분산 트랜잭션의 현실적 답
주문 하나가 완성되려면 여러 컨텍스트가 협력해야 합니다. 재고를 잡고, 결제를 받고, 주문을 확정하죠. 한 대의 DB라면 트랜잭션 하나로 "다 되거나 다 안 되거나"를 보장하겠지만, 이들은 서로 다른 서비스 입니다. 분산 환경엔 그런 전역 트랜잭션이 없습니다.
사가(saga) 는 이 문제의 현실적인 답입니다. 하나의 큰 트랜잭션을 여러 개의 로컬 트랜잭션 + 이벤트 로 쪼개고, 중간에 실패하면 앞서 한 일을 보상(compensation) 으로 되돌립니다. 사가를 엮는 방식은 둘입니다.
- 코레오그래피(choreography) — 지휘자 없이, 각 서비스가 남의 이벤트를 듣고 알아서 반응합니다. 우리가 4편부터 써온 방식이죠.
- 오케스트레이션(orchestration) — 중앙의 "사가 오케스트레이터"가 각 단계를 명령합니다.
우리는 코레오그래피로 갑니다. 서비스 간 결합이 느슨하고, 새 참여자를 끼우기 쉬우니까요. (흐름이 복잡해지면 추적이 어려워지는 게 단점인데, 이건 8편에서 붙인 상관 ID로 상당 부분 상쇄됩니다.)
우리가 닫을 사가의 전체 안무는 이렇습니다.
[주문] order.placed ─────────▶ [재고] 예약
│
┌── 성공 ────────────┤
▼ └── 실패 ──▶ stock.reservation_failed
[재고] stock.reserved │
│ ▼
▼ [주문] 주문 취소 (보상)
[결제] 결제 처리
│
▼
[결제] payment.completed ──▶ [주문] 결제완료 → 주문 확정
이벤트에 무엇을 더 실을까 — 결제엔 금액이 필요하다
결제 서비스가 결제하려면 얼마를 청구할지 알아야 합니다. 그런데 결제가 구독할 StockReserved 이벤트에는 주문 ID밖에 없었습니다. 4편에서 배운 원칙 그대로 — 받는 쪽이 되묻지 않도록, 이벤트에 필요한 데이터를 실어 보냅니다(event-carried state transfer). 재고 서비스는 주문 이벤트에서 총액을 이미 알고 있으니, 그걸 StockReserved 에 실어 다음으로 넘깁니다.
type StockReserved struct {
OrderID OrderID `json:"order_id"`
Amount int64 `json:"amount"` // ← 결제가 청구할 금액
}
결제 컨텍스트 — 목업이지만 구조는 진짜
새 결제 컨텍스트를 만듭니다. 결제 게이트웨이 연동은 목업으로 두되(금액이 유효하면 승인), 구조는 실제와 똑같이 잡습니다. 나중에 Process() 안을 진짜 PG 호출로 바꾸면 되도록요.
func (p *Payment) Process() {
if p.amount <= 0 {
p.status = StatusFailed
p.record(PaymentFailed{OrderID: p.orderID, Reason: "유효하지 않은 결제 금액"})
return
}
p.status = StatusCompleted
p.record(PaymentCompleted{OrderID: p.orderID, Amount: p.amount})
}
결제 서비스는 stock.reserved 를 구독해 결제를 처리하고, 결과를 payment.completed 또는 payment.failed 로 발행합니다. 이 컨텍스트도 주문·재고의 도메인 타입을 import하지 않고, 이벤트 JSON을 자기만의 타입으로 번역 합니다(ACL). 컨텍스트 독립성은 끝까지 지킵니다.
func (s *PaymentService) OnStockReserved(ctx context.Context, cmd ProcessPaymentCommand) error {
payment := domain.NewPayment(domain.OrderID(cmd.OrderID), cmd.Amount)
payment.Process() // 승인/거절 + 이벤트 기록(도메인)
if err := s.repo.Save(ctx, payment); err != nil {
return err
}
return s.publisher.Publish(ctx, payment.PullEvents()...)
}
주문 서비스가 이제 이벤트를 "듣는다"
지금까지 주문 서비스는 이벤트를 발행만 했습니다. 이번에 처음으로 구독 도 하게 됩니다 — 결제 완료와 재고 부족에 반응해야 하니까요. 여기서 season 1의 2편에서 만들어 두고 한 번도 바깥에서 못 불렀던 도메인 메서드들(MarkPaid·Confirm·Cancel)이 드디어 쓰입니다.
먼저 애플리케이션 계층에 "이벤트에 반응하는" 유스케이스를 더합니다. 저장된 주문을 불러와 상태를 전이하고, 다시 저장하고, 새 이벤트를 발행합니다. 규칙(어떤 전이가 허용되는가)은 여전히 도메인이 판단합니다.
// 결제 완료 → 결제완료 → 확정
func (s *OrderService) ConfirmPaidOrder(ctx context.Context, id domain.OrderID) error {
order, err := s.repo.FindByID(ctx, id)
if err != nil {
return err
}
if err := order.MarkPaid(); err != nil { // PLACED → PAID
return err
}
if err := order.Confirm(); err != nil { // PAID → CONFIRMED
return err
}
if err := s.repo.Save(ctx, order); err != nil {
return err
}
return s.publisher.Publish(ctx, order.PullEvents()...)
}
그리고 이 유스케이스를 이벤트에 잇는 사가 소비자 를 둡니다. payment.completed 면 확정, inventory.stock.reservation_failed 면 취소(보상)입니다.
func (c *OrderSagaConsumer) OnPaymentCompleted(env eventbus.Envelope) error {
ctx, log, ref, _ := c.prepare(env) // 상관 ID 이어받기
if err := c.svc.ConfirmPaidOrder(ctx, domain.OrderID(ref.OrderID)); err != nil {
log.Error("주문 확정 실패", "order", ref.OrderID, "err", err)
return err
}
log.Info("결제 완료 → 주문 확정", "order", ref.OrderID)
return nil
}
조립 루트(main)에서 이 구독을 겁니다. 주문 서비스는 이제 HTTP도 받고, 이벤트도 발행하고, 이벤트에 반응도 합니다.
saga := infra.NewOrderSagaConsumer(svc, logger)
bus.Subscribe("payment.completed", "ordering", saga.OnPaymentCompleted)
bus.Subscribe("inventory.stock.reservation_failed", "ordering", saga.OnStockReservationFailed)
사가 전체를 TDD하기
조각조각은 다 테스트했지만, 정말로 끝까지 흐르는가? 세 컨텍스트(주문·재고·결제)를 임베디드 NATS 위에 모두 올리고, 주문 하나를 넣어 order.confirmed 까지 도달하는지 종단 간으로 확인합니다.
func TestSaga_해피패스면_주문이_확정된다(t *testing.T) {
// ... 임베디드 NATS + 세 컨텍스트 배선 ...
wireInventory(t, bus, map[string]int{"prod-A": 10, "prod-B": 5}) // 재고 충분
wirePayment(t, bus)
orderRepo, orderSvc := wireOrdering(t, bus)
confirmed := make(chan eventbus.Envelope, 1)
bus.Subscribe("ordering.order.confirmed", "test", func(e eventbus.Envelope) error {
confirmed <- e; return nil
})
placeSampleOrder(t, orderSvc) // 주문 → 이벤트 체인 시작
select {
case <-confirmed: // 확정까지 흘렀다
case <-time.After(4 * time.Second):
t.Fatal("타임아웃: 주문 확정까지 흐르지 않음")
}
got, _ := orderRepo.FindByID(context.Background(), "order-1")
if got.Status() != orderingdomain.StatusConfirmed {
t.Fatalf("주문 상태 = CONFIRMED 여야 하는데 %s", got.Status())
}
}
재고가 부족한 경우(prod-B 재고 0)엔 order.cancelled 까지 흘러 주문이 자동 취소되는지도 같은 방식으로 못 박습니다. 두 테스트 모두 초록불입니다.
진짜로 완주하는지 — 네 서비스를 띄워서
브로커와 세 서비스(주문·재고·결제)를 띄우고, 상관 ID를 지정해 주문을 넣습니다.
$ curl -X POST localhost:8080/orders -H 'X-Correlation-ID: saga-001' -d '{
"customer_id":"c1",
"items":[{"product_id":"prod-A","quantity":2,"unit_price":1000},
{"product_id":"prod-B","quantity":1,"unit_price":3000}]}'
{"order_id":"order_4b996b47…"}
$ curl localhost:8080/orders/order_4b996b47…
{"order_id":"order_4b996b47…","status":"CONFIRMED","total":5000, ...} ← PLACED 가 아니라 CONFIRMED!
주문이 CONFIRMED로 완주했습니다. 그리고 8편에서 붙인 상관 ID 덕에, 이 주문이 세 서비스를 어떻게 지나갔는지 하나의 ID로 꿰어 볼 수 있습니다.
[ordering] "http request" path=/orders status=201 correlation_id=saga-001
[inventory] "order.placed 처리 완료" order=order_4b996b47… correlation_id=saga-001
[payment] "결제 처리 완료" amount=5000 correlation_id=saga-001
[ordering] "결제 완료 → 주문 확정" order=order_4b996b47… correlation_id=saga-001
주문 → 재고 예약 → 결제 → 주문 확정. 이벤트만으로 네 걸음이 자동으로 이어졌습니다. 그 어떤 서비스도 다른 서비스를 직접 호출하지 않았고, 각자 자기가 들어야 할 이벤트에 반응했을 뿐입니다. 이게 코레오그래피 사가입니다.
정리 — 그리고 아직 남은 것
- 사가로 분산 트랜잭션을 흉내 냈습니다. 하나의 큰 거래를 여러 로컬 트랜잭션 + 이벤트로 쪼개고, 실패는 보상으로 되돌립니다.
- 주문이 이벤트만으로 PLACED → PAID → CONFIRMED 까지 완주하고, 재고 부족 시 자동 취소 됩니다. season 1에서 만들어만 두고 못 쓰던 상태 전이가 드디어 살아났습니다.
- 결제엔 금액을 이벤트에 실어 보냈고, 결제 컨텍스트도 남의 도메인을 모른 채 이벤트로만 협력합니다.
- 주문 서비스가 처음으로 이벤트를 구독 하며, 발행자이자 소비자가 됐습니다.
물론 아직 정직하게 남은 구멍이 있습니다. 결제 실패(payment.failed) 경로는 아직 안 닫혔습니다 — 결제가 실패하면 주문을 취소하는 건 물론, 이미 잡아둔 재고를 되돌려야 하는데, 그러려면 재고 서비스가 "무엇을 예약했는지" 기억해야 합니다. 그리고 배송 도 아직 없어서 주문이 CONFIRMED에서 멈춥니다. 다음 2편에서는 배송 서비스를 붙여 주문을 SHIPPED까지 보내고, 결제 실패 시 재고까지 복원하는 완전한 보상 으로 사가를 양방향 모두 닫습니다.
이번 편 전체 코드는 리포의
part-9태그에 있습니다.