Published on

Kotlin MSA에서 Saga 보상 트랜잭션 설계 실전

Authors

서론

MSA에서 가장 자주 마주치는 난제는 “여러 서비스에 걸친 상태 변경을 어떻게 안전하게 묶을 것인가”입니다. 모놀리식이라면 단일 DB 트랜잭션(ACID)으로 끝났겠지만, 서비스가 분리되면 2PC 같은 강한 분산 트랜잭션은 운영 복잡도와 성능, 장애 전파 측면에서 현실적인 선택지가 되기 어렵습니다.

이때 널리 쓰이는 접근이 Saga 패턴입니다. Saga는 “각 서비스의 로컬 트랜잭션들을 이벤트로 엮고, 실패 시 이미 수행된 단계들을 보상(Compensation) 으로 되돌리는” 방식으로 최종 일관성(Eventual Consistency) 을 달성합니다.

이 글은 Kotlin/JVM 기반(예: Spring Boot + JPA + Kafka) 환경을 가정하고, 보상 트랜잭션을 어떻게 설계해야 실제 운영에서 버티는지에 초점을 맞춥니다. 특히 메시지 중복/역순/재처리가 흔한 Kafka 환경에서의 멱등 처리와 함께 설명합니다. 관련해서 이벤트 멱등성은 아래 글이 큰 도움이 됩니다: Kafka 중복·역순 메시지, DDD로 멱등 처리하기


Saga 패턴에서 ‘보상’이 어려운 이유

보상 트랜잭션은 단순히 “취소 API 하나 만들면 끝”이 아닙니다. 실전에서는 다음 4가지가 핵심 난점입니다.

1) 보상은 ‘완전 롤백’이 아니라 ‘비즈니스적 상쇄’

예를 들어 결제 승인 후 재고 차감에 실패했다면 결제를 취소(환불)해야 합니다. 그러나 환불은 카드사/PG 상태에 따라 지연될 수 있고, 이미 영수증이 발행되었을 수도 있습니다. 즉 DB 롤백처럼 즉시 원복이 아니라 상쇄 동작이며, 별도 상태/감사 로그가 필요합니다.

2) 부분 실패와 재시도는 기본값

네트워크 타임아웃, 브로커 지연, 소비자 재밸런싱으로 인해 동일 이벤트가 여러 번 처리될 수 있습니다. 보상 또한 중복 호출될 수 있으므로 멱등성이 필수입니다.

3) “취소 불가” 단계가 존재

배송이 시작되면 주문 취소가 아니라 반품 프로세스로 전환됩니다. Saga 단계별로 보상 가능 여부가 다르고, 보상 정책이 달라집니다.

4) 관측 가능성(Observability)이 없으면 운영 불가

보상이 언제/왜/어디서 실패했는지 추적이 안 되면, 결국 사람이 DB를 열어 수동 정합을 맞추게 됩니다. Saga 실행 상태를 명시적으로 저장하고, 이벤트 상관관계(correlation)를 남겨야 합니다.


오케스트레이션 vs 코레오그래피: 보상 설계 관점의 선택

Saga 구현은 크게 두 가지입니다.

오케스트레이션(Orchestration)

중앙 오케스트레이터(예: Order 서비스)가 다음 단계를 지시하고 실패 시 보상 단계를 호출합니다.

  • 장점: 흐름이 명확, 보상 경로를 한 곳에서 통제, 디버깅 용이
  • 단점: 오케스트레이터에 책임 집중, 변경 시 영향도 큼

코레오그래피(Choreography)

각 서비스가 이벤트를 발행/구독하며 자율적으로 다음 동작을 수행합니다.

  • 장점: 결합도 낮음, 서비스 자율성
  • 단점: 보상 흐름이 분산되어 추적/테스트 어려움, “어떤 이벤트가 누락되면?”에 취약

보상 트랜잭션을 안정적으로 운영하려면, 초기에는 오케스트레이션이 유리한 경우가 많습니다(특히 도메인 복잡도가 높을수록). 이 글의 예제도 오케스트레이션 중심으로 설명하되, 이벤트 기반으로 확장 가능한 형태로 구성합니다.


보상 트랜잭션 설계 원칙 7가지

1) 보상은 ‘역연산’이 아니라 ‘상태 전이’로 모델링

예: PAYMENT_APPROVED -> PAYMENT_CANCELED는 단순 삭제가 아니라 상태 전이입니다. 보상 호출이 성공했는지, 진행 중인지, 실패했는지 상태가 필요합니다.

2) 각 단계는 멱등해야 한다(정방향/보상 모두)

  • 정방향: 동일 commandId로 결제 승인 요청이 2번 와도 1번만 승인
  • 보상: 동일 commandId로 결제 취소 요청이 2번 와도 1번만 취소

3) 보상은 ‘가장 최근 성공 단계부터 역순’으로 수행

Saga가 A→B→C까지 성공 후 D에서 실패하면, 보상은 C’→B’→A’ 순서가 기본입니다.

4) “보상 불가 단계”를 명시하고 대체 플로우를 준비

배송 시작 이후 취소 불가라면, 보상은 ‘주문 취소’가 아니라 ‘반품 요청’ 또는 ‘CS 티켓 생성’이 될 수 있습니다.

5) Outbox 패턴으로 이벤트 발행을 트랜잭션에 묶어라

서비스 로컬 DB 업데이트와 이벤트 발행을 원자적으로 맞추지 않으면, “DB는 반영됐는데 이벤트는 유실” 같은 치명적인 불일치가 생깁니다.

6) 재시도 정책은 단계별로 다르게

  • 네트워크/일시 장애: 지수 백오프 재시도
  • 비즈니스 거절(예: 잔액 부족): 재시도 무의미 → 즉시 보상

7) 타임아웃과 데드레터(또는 수동介入) 경로를 설계

어떤 보상은 외부 시스템(PG) 이슈로 오래 걸릴 수 있습니다. 일정 시간 이상 지연되면 “보상 실패”로 마킹하고 운영자가 처리할 수 있어야 합니다.


예시 시나리오: 주문 생성 Saga

간단한 주문 흐름을 예로 들겠습니다.

  1. Order 서비스: 주문 생성(PENDING)
  2. Payment 서비스: 결제 승인
  3. Inventory 서비스: 재고 예약
  4. Shipping 서비스: 배송 요청

실패 시 보상 예시:

  • 재고 예약 실패 → 결제 취소 → 주문 취소
  • 배송 요청 실패 → 재고 예약 취소 → 결제 취소 → 주문 취소

Kotlin으로 보는 Saga 상태 모델(오케스트레이터)

오케스트레이터는 “현재 어디까지 성공했는지”를 저장해야 보상을 역순으로 정확히 수행할 수 있습니다.

Saga 상태 엔티티(예: JPA)

import jakarta.persistence.*
import java.time.Instant

enum class SagaStatus { RUNNING, COMPENSATING, COMPLETED, FAILED }

enum class StepStatus { PENDING, SUCCEEDED, FAILED, COMPENSATED }

@Entity
@Table(name = "order_saga")
class OrderSaga(
    @Id
    val sagaId: String,

    val orderId: String,

    @Enumerated(EnumType.STRING)
    var status: SagaStatus = SagaStatus.RUNNING,

    var currentStep: String = "ORDER_CREATED",

    var updatedAt: Instant = Instant.now(),

    @Version
    var version: Long? = null,
)

@Entity
@Table(name = "order_saga_step")
class OrderSagaStep(
    @Id @GeneratedValue
    val id: Long? = null,

    val sagaId: String,

    val stepName: String,

    @Enumerated(EnumType.STRING)
    var status: StepStatus = StepStatus.PENDING,

    val commandId: String, // 멱등 키

    var updatedAt: Instant = Instant.now(),
)

포인트:

  • commandId를 단계별로 발급해 정방향/보상 모두 멱등 키로 사용합니다.
  • @Version으로 낙관적 락을 걸어 중복 실행을 방지합니다.

Outbox 패턴으로 “DB 업데이트 + 이벤트 발행” 원자화

오케스트레이터가 Payment 승인 요청 이벤트를 발행할 때, DB에 Saga step을 SUCCEEDED로 바꿨는데 이벤트가 유실되면 흐름이 끊깁니다. 이를 막기 위해 Outbox 테이블에 이벤트를 저장하고 별도 퍼블리셔가 Kafka로 내보냅니다.

@Entity
@Table(name = "outbox")
class OutboxMessage(
    @Id
    val messageId: String,

    val aggregateId: String,
    val topic: String,

    @Lob
    val payloadJson: String,

    var published: Boolean = false,

    val createdAt: Long = System.currentTimeMillis()
)

서비스 트랜잭션 안에서:

  • Saga 상태/스텝 업데이트
  • Outbox insert

이 두 개가 같은 DB 트랜잭션으로 커밋되면, 최소한 “이벤트 발행해야 하는 사실”은 유실되지 않습니다.


보상 실행의 핵심: 멱등 + 역순 + 상태 기반

배송 요청 단계에서 실패했다고 가정하고 보상을 수행하는 코드를 간단히 구성해보겠습니다.

보상 서비스 인터페이스

interface CompensationAction {
    val stepName: String
    suspend fun compensate(sagaId: String, commandId: String)
}

class PaymentCompensation(
    private val paymentClient: PaymentClient,
    private val sagaStepRepo: SagaStepRepository,
) : CompensationAction {
    override val stepName: String = "PAYMENT_APPROVED"

    override suspend fun compensate(sagaId: String, commandId: String) {
        // 멱등: 이미 COMPENSATED면 스킵
        val step = sagaStepRepo.findBySagaIdAndStepName(sagaId, stepName)
            ?: error("missing step")
        if (step.status == StepStatus.COMPENSATED) return

        paymentClient.cancel(commandId = commandId) // 외부 호출도 commandId로 멱등 처리 권장
        step.status = StepStatus.COMPENSATED
        sagaStepRepo.save(step)
    }
}

보상 실행기(역순 실행)

class CompensationRunner(
    private val sagaRepo: SagaRepository,
    private val stepRepo: SagaStepRepository,
    private val actions: List<CompensationAction>,
) {
    private val actionMap = actions.associateBy { it.stepName }

    suspend fun compensate(sagaId: String) {
        val saga = sagaRepo.findById(sagaId).orElseThrow()
        saga.status = SagaStatus.COMPENSATING
        sagaRepo.save(saga)

        val succeededSteps = stepRepo.findSucceededStepsBySagaIdOrderByDesc(sagaId)
        for (step in succeededSteps) {
            val action = actionMap[step.stepName] ?: continue
            try {
                action.compensate(sagaId, step.commandId)
            } catch (e: Exception) {
                saga.status = SagaStatus.FAILED
                sagaRepo.save(saga)
                throw e
            }
        }

        saga.status = SagaStatus.COMPLETED
        sagaRepo.save(saga)
    }
}

실전 팁:

  • findSucceededStepsBySagaIdOrderByDesc는 “성공한 단계만” 역순으로 가져오도록 합니다.
  • 보상 중 하나라도 실패하면 Saga를 FAILED로 두고 재시도/수동介入 대상으로 넘깁니다.

이벤트 중복/역순에서 보상 트랜잭션이 깨지는 지점

Kafka 기반에서는 다음이 흔합니다.

  • 동일 이벤트가 재전송되어 소비자가 2번 처리
  • 파티션/키 설계가 잘못되어 순서가 뒤섞임
  • 소비자 장애 후 재처리로 이미 처리한 이벤트를 다시 처리

따라서 보상 설계는 “정상 흐름”이 아니라 “이상 흐름”이 기본값이어야 합니다.

멱등 처리 전략(요약)

  • 모든 커맨드/이벤트에 commandId 또는 eventId를 부여
  • 서비스는 처리 이력을 저장(ProcessedMessage 테이블 등)
  • 이미 처리한 eventId면 no-op

이 주제는 아래 글에서 더 깊게 다룹니다: Kafka 중복·역순 메시지, DDD로 멱등 처리하기


“보상도 실패한다”를 전제로 한 운영 설계

보상은 외부 시스템 의존이 많아 실패 가능성이 큽니다. 운영에서 중요한 체크리스트는 다음입니다.

1) 재시도와 서킷 브레이커

  • 일시 장애: 재시도
  • 지속 장애: 서킷 브레이커로 폭주 방지 + DLQ로 격리

2) Saga 타임아웃

예: 주문 생성 후 10분 내 결제 승인 이벤트가 없으면 주문을 자동 취소하는 타임아웃 잡을 둡니다.

3) 관측 가능성

  • sagaId, orderId, commandId를 로그/트레이싱에 반드시 포함
  • 단계별 상태 테이블을 운영자가 조회할 수 있는 어드민/대시보드 제공

쿠버네티스 운영에서 장애가 길어질 때는 “서비스는 살아있는데 외부로 못 나가는” 유형도 자주 보입니다. 이런 경우 보상이 계속 실패하며 적체될 수 있으니 네트워크/egress 점검도 필요합니다: EKS에서 Pod는 정상인데 egress만 막힐 때 점검


테스트 전략: 보상은 통합 테스트로 검증하라

단위 테스트만으로는 부족합니다. 최소한 다음을 자동화하는 것이 좋습니다.

  1. 성공 플로우: A→B→C→D 완료
  2. 중간 실패: C 실패 시 B’→A’ 보상
  3. 보상 중복: 동일 실패 이벤트 2번 도착해도 보상 1번만 수행
  4. 보상 실패: B’가 외부 장애로 실패하면 Saga가 FAILED로 남고 재시도 가능

테스트 환경에서 Kafka/DB를 붙인 통합 테스트를 돌리면, CI 시간이 늘어날 수 있습니다. 빌드 캐시가 무효화되어 느려지는 경우도 자주 생기니 파이프라인 최적화도 같이 보세요: GitHub Actions 캐시 무효화로 빌드가 느릴 때


결론

Kotlin MSA에서 Saga 보상 트랜잭션을 “설계”한다는 것은, 단순히 취소 API를 추가하는 일이 아니라 다음을 함께 구축하는 것입니다.

  • 단계별 상태 전이 모델과 저장소(Saga/Step)
  • 정방향/보상 모두에 대한 멱등성 키와 처리 이력
  • Outbox로 이벤트 유실 방지
  • 역순 보상 실행과 실패 시 재시도/DLQ/수동介入 경로
  • 중복/역순/재처리 등 메시징의 현실을 전제로 한 방어적 구현

이 원칙을 지키면 “분산 트랜잭션을 완벽히 롤백”하는 환상 대신, 장애와 비정상 상황에서도 일관성을 회복할 수 있는 시스템에 가까워집니다.