Published on

Spring Cloud 트랜잭션 전파로 SAGA 깨질 때

Authors

서로 다른 마이크로서비스에 걸친 분산 트랜잭션을 SAGA로 풀어내려는 팀이 가장 자주 밟는 지뢰 중 하나가 트랜잭션 전파(propagation) 입니다. 특히 Spring 기반 서비스에서 @Transactional 이 “편리함” 때문에 서비스 경계를 무시하게 만들면, SAGA가 전제하는 로컬 트랜잭션의 독립성비동기 이벤트 기반 정합성이 깨지며 보상 트랜잭션이 실패하거나, 더 나쁘게는 “성공한 것처럼 보이지만 실제로는 깨진 상태”가 만들어집니다.

이 글에서는 Spring Cloud 환경에서 트랜잭션 전파가 SAGA를 깨뜨리는 대표 패턴을 재현하고, 왜 그런지(커밋 타이밍, 이벤트 발행 시점, 전파 옵션의 의미)와 실전 대응책(Outbox, REQUIRES_NEW 사용 기준, 이벤트 리스너 트랜잭션 경계, 멱등성)을 정리합니다.

관련해서 보상 트랜잭션 디버깅과 중복 방지 전략은 아래 글도 함께 보면 좋습니다.

전제: SAGA가 기대하는 트랜잭션 모델

SAGA는 “하나의 글로벌 트랜잭션”이 아니라, 다음의 조합으로 문제를 푸는 패턴입니다.

  • 각 서비스는 자기 DB에서 로컬 트랜잭션으로 상태를 바꾼다.
  • 상태 변화는 이벤트(또는 커맨드)로 외부에 알린다.
  • 실패 시에는 보상 트랜잭션으로 이전 효과를 되돌린다.
  • 네트워크/메시지 지연을 감안해 최종 일관성을 받아들인다.

따라서 핵심은 “서비스 경계를 넘는 순간 트랜잭션을 이어 붙이려 하지 말 것”입니다. 그런데 Spring의 트랜잭션 전파는 개발자에게 다음과 같은 착각을 줍니다.

  • 같은 JVM 내부 호출에서 @Transactional 은 마치 “안전한 원자성”을 제공하는 것처럼 보인다.
  • Feign/RestTemplate/WebClient 호출을 트랜잭션 안에서 해도 “어차피 실패하면 롤백되니까” 괜찮아 보인다.

SAGA에서 이 습관은 거의 항상 부작용을 만듭니다.

케이스 1: @Transactional 안에서 원격 호출을 하고 이벤트를 발행

가장 흔한 사고 패턴입니다. 주문 생성 트랜잭션 안에서 결제 서비스 호출, 재고 서비스 호출을 하고, 마지막에 이벤트를 발행합니다.

@Service
public class OrderService {

  private final PaymentClient paymentClient;
  private final ApplicationEventPublisher publisher;
  private final OrderRepository orderRepository;

  @Transactional
  public Long placeOrder(PlaceOrderCommand cmd) {
    Order order = orderRepository.save(Order.create(cmd));

    // 원격 호출을 트랜잭션 안에서 수행
    paymentClient.authorize(order.getId(), cmd.amount());

    // 이벤트 발행(겉보기에는 "이제 다음 단계로")
    publisher.publishEvent(new OrderPlacedEvent(order.getId()));

    return order.getId();
  }
}

겉보기에는 “중간에 예외 나면 롤백”이라 안전해 보이지만, SAGA 관점에서는 다음 문제가 생깁니다.

  • 원격 호출은 DB 롤백으로 되돌릴 수 없다

    • 주문 DB 롤백은 되지만 결제 승인 요청은 이미 외부로 나갔습니다.
    • 즉, “로컬 트랜잭션 원자성”을 “분산 원자성”으로 착각하게 됩니다.
  • 이벤트 발행 시점이 커밋과 불일치

    • publishEvent 는 기본적으로 즉시 리스너를 호출할 수 있고(동기 리스너), 비동기라 해도 메시지 발행이 커밋보다 먼저 될 수 있습니다.
    • 결과적으로 다운스트림 서비스가 이벤트를 처리하려고 하는데, 정작 주문 레코드는 아직 커밋되지 않아 조회가 실패하거나, 상태가 PENDING 인데 다음 단계가 진행되는 식의 레이스가 생깁니다.

이 패턴이 누적되면 “보상 트랜잭션이 왜 실행됐는데도 상태가 이상하지” 같은 디버깅 지옥이 열립니다.

케이스 2: REQUIRED 전파가 내부 단계들을 한 트랜잭션으로 묶어 SAGA 단계를 붕괴

SAGA의 각 단계는 보통 “독립적으로 커밋되는 로컬 트랜잭션”을 전제합니다. 그런데 같은 서비스 내부라도 단계별로 커밋되어야 하는데, REQUIRED 로 전파되어 전부 하나로 묶이면 보상/재시도 설계가 무너집니다.

예를 들어 “주문 생성”과 “아웃박스(outbox) 이벤트 적재”가 반드시 같은 트랜잭션이어야 하는 경우가 있습니다. 반대로 “주문 생성”과 “외부 결제 승인”을 같은 트랜잭션으로 묶으려 하면 안 됩니다.

문제는 내부 메서드가 모두 @Transactional 이고 기본 전파가 REQUIRED 인 경우, 호출 구조에 따라 의도치 않게 한 트랜잭션으로 합쳐진다는 점입니다.

@Service
public class OrderFacade {

  private final OrderAppService orderAppService;
  private final PaymentSagaStarter paymentSagaStarter;

  @Transactional // REQUIRED
  public void placeOrder(PlaceOrderCommand cmd) {
    Long orderId = orderAppService.createOrder(cmd); // 같은 TX로 합쳐짐
    paymentSagaStarter.start(orderId);              // 같은 TX로 합쳐짐
  }
}

@Service
public class OrderAppService {
  @Transactional // REQUIRED
  public Long createOrder(PlaceOrderCommand cmd) {
    // ... insert order
    return 1L;
  }
}

이 구조에서 paymentSagaStarter.start 가 “이벤트 발행” 또는 “원격 호출”을 포함하면, 앞의 케이스 1 문제가 더 크게 터집니다. 또한 SAGA 단계 분리가 아니라, 그냥 “큰 트랜잭션 + 외부 호출”이 됩니다.

케이스 3: REQUIRES_NEW 남발로 보상 트랜잭션이 더 꼬이는 경우

반대로 REQUIRES_NEW 를 만능처럼 쓰는 팀도 있습니다. “독립 커밋이 필요하니까 전부 REQUIRES_NEW로” 같은 접근인데, 이 또한 SAGA를 깨뜨릴 수 있습니다.

대표적인 부작용은 다음입니다.

  • 외부 호출 실패로 상위 트랜잭션이 롤백되어도, REQUIRES_NEW 로 저장한 일부 상태는 이미 커밋되어 유령 상태가 남습니다.
  • 재시도 로직이 상위 트랜잭션 기준으로 설계되어 있으면, 내부 REQUIRES_NEW 커밋과 충돌해 중복 이벤트중복 보상을 유발합니다.

REQUIRES_NEW 는 “SAGA 단계 분리”가 아니라 “트랜잭션을 인위적으로 쪼개는 도구”일 뿐이고, 언제 쪼개야 하는지(무엇이 원자적으로 묶여야 하는지) 모델링이 먼저입니다.

트랜잭션 전파가 SAGA를 깨는 핵심 메커니즘 3가지

1) 커밋 이전에 외부 세계가 변한다

DB 트랜잭션은 DB 안에서만 원자적입니다. 트랜잭션 안에서 호출한 원격 API, 메시지 발행, 캐시 업데이트는 DB 롤백으로 되돌릴 수 없습니다.

SAGA는 “외부 세계 변화”를 이벤트로 연결하고, 실패 시 보상으로 되돌리는 모델인데, 트랜잭션 안에서 외부 호출을 섞으면 보상 설계가 더 어려워집니다.

2) 이벤트 발행과 커밋의 순서가 어긋난다

이벤트는 “커밋된 사실”을 알려야 합니다. 그런데 애플리케이션 이벤트/메시지 발행이 커밋 전에 나가면, 다운스트림은 커밋되지 않은 데이터를 기반으로 동작하게 됩니다.

3) 전파 옵션이 서비스 경계를 가려버린다

REQUIRED 는 “이미 트랜잭션이 있으면 합류”입니다. 내부 호출이 많아질수록 의도치 않게 거대한 트랜잭션이 되고, 그 안에서 원격 호출이 섞이면 SAGA 단계가 사라집니다.

해결 전략 1: Outbox 패턴으로 “커밋 후 이벤트”를 강제

SAGA에서 가장 먼저 고쳐야 하는 것은 이벤트 발행 타이밍입니다. 가장 안정적인 해법은 Outbox 패턴입니다.

  • 주문 저장 트랜잭션 안에서
    • 주문 상태 변경
    • outbox 테이블에 이벤트 레코드 저장
  • 별도 퍼블리셔가 outbox를 읽어 메시지 브로커로 발행
  • 발행 성공 시 outbox를 SENT 처리

이렇게 하면 “DB 커밋과 이벤트 생성”이 원자적으로 묶이고, 메시지 발행은 커밋 이후에만 일어납니다.

@Entity
@Table(name = "outbox_event")
public class OutboxEvent {
  @Id
  private String id;
  private String aggregateType;
  private String aggregateId;
  private String eventType;
  @Lob
  private String payload;
  private String status; // NEW, SENT
  private Instant createdAt;
}

@Service
public class OrderService {
  private final OrderRepository orderRepository;
  private final OutboxRepository outboxRepository;

  @Transactional
  public Long placeOrder(PlaceOrderCommand cmd) {
    Order order = orderRepository.save(Order.create(cmd));

    OutboxEvent evt = new OutboxEvent(/* ... payload ... */);
    outboxRepository.save(evt);

    return order.getId();
  }
}

퍼블리셔는 스케줄러든 CDC든 상관없지만, 최소한 “커밋된 것만 발행”을 보장해야 합니다.

해결 전략 2: @TransactionalEventListener 로 커밋 이후 훅 사용

Outbox가 정답에 가깝지만, 시스템 규모나 요구사항에 따라 “애플리케이션 이벤트를 쓰되 커밋 이후에만 실행”으로도 개선이 가능합니다.

@Component
public class OrderEventHandler {

  private final MessagePublisher publisher;

  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  public void onOrderPlaced(OrderPlacedEvent event) {
    publisher.publish("order.placed", event);
  }
}

주의할 점:

  • 이 방식은 “같은 프로세스에서 이벤트를 브로커로 발행”하므로, 발행 실패 시 재시도/내구성은 별도로 설계해야 합니다.
  • 결국 내구성을 확보하려면 Outbox로 가는 경우가 많습니다.

해결 전략 3: 원격 호출은 트랜잭션 밖으로 빼고, 상태 머신으로 단계 관리

SAGA는 상태 전이로 모델링하는 게 안전합니다.

  • ORDER_CREATED 커밋
  • 이벤트 발행
  • 결제 서비스가 승인하면 PAYMENT_AUTHORIZED 로 전이
  • 실패하면 PAYMENT_FAILED 로 전이 후 보상 실행

즉, “원격 호출을 한 번에 다 끝내고 커밋”이 아니라 “커밋된 상태를 기반으로 다음 단계가 진행”되도록 만듭니다.

Spring 코드 레벨에서는 보통 다음 원칙이 실용적입니다.

  • 트랜잭션 메서드: DB 변경과 outbox 적재까지만
  • 원격 호출: 메시지 컨슈머(또는 워커)에서 수행

해결 전략 4: 보상 트랜잭션은 반드시 멱등하게

트랜잭션 전파 문제가 섞이면 “중복 실행”이 현실이 됩니다.

  • 메시지 재전송
  • 컨슈머 재시작
  • 타임아웃 후 재시도
  • 동일 이벤트 중복 발행

이때 보상 로직이 멱등하지 않으면, 한 번 더 실행되는 순간 데이터가 더 망가집니다. 보상 실행을 안전하게 만드는 대표 기법은 아래 글에서 더 깊게 다룹니다.

실무적으로는 다음 조합이 자주 쓰입니다.

  • 보상 요청에 compensationId 를 두고 처리 테이블에 유니크 제약
  • 상태 전이 시 version(낙관적 락)으로 중복 전이 방지
  • 외부 API 호출은 “취소”가 아니라 “취소 요청을 기록하고 비동기 확인”으로 설계

체크리스트: Spring Cloud에서 SAGA 안 깨지게 하는 규칙

트랜잭션 전파 규칙

  • 기본값 REQUIRED 를 무심코 중첩시키지 말고, “무엇이 같은 원자성 단위인지”부터 결정
  • REQUIRES_NEW 는 감사 로그/아웃박스 같은 “반드시 남아야 하는 기록”에 제한적으로
  • 원격 호출이 포함된 메서드에 @Transactional 을 붙이지 않기

이벤트 규칙

  • 이벤트는 커밋 이후에만 외부로 나가게 설계
    • Outbox 또는 AFTER_COMMIT
  • 이벤트 소비자는 항상 멱등 처리
  • 이벤트 payload에는 조회 키뿐 아니라 “필요 최소 상태”를 포함(커밋 레이스 완화)

관측/디버깅 규칙

  • SAGA 상관관계 키(sagaId, orderId)를 로그 MDC에 넣기
  • 각 단계별 상태 전이 테이블(또는 이벤트 로그)을 남기기
  • 보상 실패 시 재시도 정책과 데드레터 큐 전략을 문서화

보상 트랜잭션이 실패하는 실제 디버깅 흐름(어느 로그를 먼저 보고, 어떤 상태를 확인해야 하는지)은 아래 글이 실전적입니다.

결론: 전파 옵션은 “편의 기능”이지 분산 트랜잭션 해법이 아니다

Spring의 트랜잭션 전파는 단일 서비스 내부의 일관성을 유지하는 데 매우 유용하지만, SAGA의 핵심인 “독립 커밋 + 이벤트 기반 연결 + 보상”을 대신해주지 않습니다.

정리하면 다음 한 줄이 가장 중요합니다.

  • DB 트랜잭션 경계 안에는 DB 변경과 outbox 기록만 두고, 외부 세계와의 상호작용은 커밋 이후 이벤트로 넘겨라.

이 원칙을 지키면 트랜잭션 전파 때문에 SAGA가 깨지는 대부분의 문제(커밋 레이스, 유령 상태, 보상 꼬임)를 구조적으로 제거할 수 있습니다.