결제 상태 변경과 메시지 발행 사이에 아웃박스를 둔 이유
DB 트랜잭션과 큐 발행은 원자적이지 않다
결제나 주문처럼 상태 전이가 중요한 기능은 보통 다른 시스템에 이벤트를 전달해야 한다.
주문이 결제 완료가 되면 재고를 줄여야 할 수 있다. 예약이 확정되면 알림을 보내야 할 수 있다. 콘텐츠 검수가 끝나면 검색 인덱스를 갱신해야 할 수 있다.
가장 단순한 코드는 이렇게 생긴다.
1. DB 상태 변경
2. 메시지 큐 발행정상 상황에서는 문제가 없다. 하지만 서버가 1번과 2번 사이에서 죽으면 어떻게 될까. DB에는 결제 완료가 저장됐는데, 메시지 큐에는 이벤트가 없다. 반대로 메시지를 먼저 보내고 DB 저장에 실패하면, 실제로 완료되지 않은 이벤트가 외부로 나간다.
이 작은 틈이 분산 시스템에서는 꽤 큰 문제다.
publish 실패만 재시도해서는 부족하다
처음에는 메시지 큐 발행 실패만 잘 재시도하면 된다고 생각했다.
if err := repo.MarkPaid(ctx, id); err != nil {
return err
}
if err := queue.Publish(ctx, PaidEvent{ID: id}); err != nil {
return err
}하지만 이 코드는 Publish가 실패했을 때만 재시도할 수 있다. MarkPaid 이후 프로세스가 죽으면 애플리케이션은 발행해야 할 이벤트가 있었다는 사실을 모른다.
문제는 큐가 아니라 경계다. DB 트랜잭션과 외부 메시지 발행은 하나의 원자적 작업이 아니다.
상태 변경과 outbox 저장을 같은 트랜잭션에 넣기
해결하고 싶은 문제는 정확히 이것이다.
비즈니스 상태가 변경되었다면, 발행해야 할 이벤트도 함께 남아 있어야 한다.
그래서 상태 변경과 메시지 발행을 한 번에 끝내려 하지 않는다. 대신 DB 트랜잭션 안에서는 두 가지만 한다.
- 도메인 상태를 변경한다.
- 발행 대기 이벤트를 저장한다.
그리고 별도 publisher가 발행 대기 이벤트를 읽어 메시지 큐로 보낸다. 이것이 아웃박스 패턴이다.
DB transaction
- order.status = PAID
- outbox(event_type = order.paid, status = PENDING)
Background publisher
- PENDING outbox 조회
- queue publish
- outbox.status = PUBLISHEDpublisher의 claim과 재시도 기준
아웃박스 테이블에는 최소한 다음 정보가 필요하다.
- 이벤트 타입
- 대상 리소스 ID
- payload
- 발행 상태
- 재시도 횟수 또는 마지막 에러
- 생성 시각
다중 인스턴스에서 같은 이벤트를 동시에 발행하지 않도록 claim 상태도 둘 수 있다.
PENDING -> PUBLISHING -> PUBLISHED
└──> FAILED -> PUBLISHING -> PUBLISHEDpublisher는 한 번에 일정 개수만 가져와 발행한다. 발행에 성공하면 PUBLISHED로 바꾼다. 실패하면 FAILED로 남기고 다음 루프에서 다시 시도한다.
중복 발행을 전제로 consumer 만들기
package outbox
import (
"context"
"fmt"
"time"
)
type Event struct {
ID int64
Type string
Payload []byte
Status string
ClaimedAt *time.Time
}
type Repository interface {
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
MarkOrderPaid(ctx context.Context, orderID string) error
CreateOutbox(ctx context.Context, typ string, payload []byte) error
ClaimPending(ctx context.Context, limit int) ([]Event, error)
MarkPublished(ctx context.Context, id int64) error
MarkFailed(ctx context.Context, id int64, reason string) error
}
type Queue interface {
Publish(ctx context.Context, typ string, payload []byte) error
}
type Service struct {
repo Repository
queue Queue
}
func (s *Service) MarkPaid(ctx context.Context, orderID string, payload []byte) error {
return s.repo.WithTx(ctx, func(ctx context.Context) error {
if err := s.repo.MarkOrderPaid(ctx, orderID); err != nil {
return fmt.Errorf("mark order paid: %w", err)
}
if err := s.repo.CreateOutbox(ctx, "order.paid", payload); err != nil {
return fmt.Errorf("create outbox: %w", err)
}
return nil
})
}
func (s *Service) PublishPending(ctx context.Context) error {
events, err := s.repo.ClaimPending(ctx, 50)
if err != nil {
return fmt.Errorf("claim pending events: %w", err)
}
for _, event := range events {
if err := s.queue.Publish(ctx, event.Type, event.Payload); err != nil {
_ = s.repo.MarkFailed(ctx, event.ID, err.Error())
continue
}
if err := s.repo.MarkPublished(ctx, event.ID); err != nil {
return fmt.Errorf("mark published: %w", err)
}
}
return nil
}이 코드는 메시지 발행을 완벽히 한 번만 보장하지 않는다. 분산 시스템에서 중복 전달 가능성은 남는다. 그래서 consumer는 멱등해야 한다.
아웃박스의 목표는 중복을 0으로 만드는 것이 아니라, 상태 변경 이후 이벤트 자체가 사라지는 일을 줄이는 것이다.
큐 발행보다 먼저 기록을 남긴다
아웃박스는 큐를 더 잘 쓰기 위한 장식이 아니다. DB 트랜잭션과 외부 메시지 발행 사이에 있는 실패 지점을 다루는 방법이다.
처음에는 메시지 큐 발행 실패만 재시도하면 된다고 보기 쉽다. 하지만 실제로 더 위험한 순간은 실패를 기록할 기회조차 없이 프로세스가 멈추는 시점이다.
작게 시작한다면 복잡한 워커부터 만들 필요는 없다.
- 상태 변경과 발행 대기 이벤트를 같은 트랜잭션에 저장한다.
- publisher는 pending 이벤트를 주기적으로 발행한다.
- consumer는 같은 이벤트가 다시 와도 상태가 깨지지 않게 만든다.
이 세 가지가 아웃박스 패턴의 핵심이다.