본문 바로가기
카테고리 없음

Saga 패턴, 도입하고 40일 만에 걷어낸 이유

by Hello2 2026. 4. 26.

들어가며

클라우드 매니징 플랫폼을 개발하고 있다. 회원가입 하나를 처리하려면 DB에 회원을 저장하고, Keycloak에 SSO 계정을 만들고, Gitea에 Git 계정을 프로비저닝해야 한다. 프로젝트 생성도 마찬가지다. DB 저장, Keycloak 그룹 생성, ArgoCD 프로젝트 등록까지 여러 외부 시스템을 순차적으로 호출한다.

문제는 중간에 실패하면 생긴다. DB에는 저장됐는데 Keycloak 호출이 실패하면? 부분 완료 상태로 남는다. 사용자는 가입이 됐다고 생각하지만, SSO 로그인은 안 된다.

이 문제를 해결하기 위해 Saga 패턴을 도입했다. 그리고 40일 뒤에 전부 걷어냈다.


1. Saga를 도입한 이유

동기 호출 체인의 한계

초기에는 단순한 동기 호출 체인이었다.

@Transactional
fun createMember(command: CreateMemberCommand) {
    val member = memberRepository.save(command.toEntity())     // 1. DB 저장
    keycloakClient.createUser(member)                          // 2. Keycloak
    giteaClient.createUser(member)                             // 3. Gitea
}

직관적이지만 치명적인 문제가 있다:

  • 2번에서 실패하면? 1번은 이미 커밋됐다. DB에 회원은 있는데 SSO 계정은 없는 유령 회원이 생긴다.
  • 3번에서 실패하면? 2번(Keycloak)은 되돌릴 수 없다. 외부 시스템은 @Transactional이 먹히지 않는다.
  • 응답 시간: 세 번의 외부 호출이 직렬로 실행되어 사용자 응답이 느리다.

Saga Orchestration 도입

2026년 3월, Valkey Streams + Transactional Outbox + Saga 패턴을 도입했다.

[API 요청] → UseCase: DB 저장 + Outbox 이벤트 발행 (단일 TX)
                ↓
         [Outbox Poller] → Valkey Stream에 이벤트 발행
                ↓
         [Saga Consumer] → Step 1: Keycloak 호출
                         → Step 2: Gitea 호출
                         → 실패 시: 보상 트랜잭션 역순 실행

구조는 이랬다:

  • Transactional Outbox: 비즈니스 로직과 이벤트 발행을 단일 트랜잭션으로 묶어 at-least-once 전달 보장
  • Valkey Streams: 이벤트 전달 채널. Consumer Group으로 한 번만 소비 보장
  • Saga Consumer: 각 Step을 실행하고, 실패 시 보상 체인을 역순 실행
  • SagaConsumerSupport: Consumer 공통 로직을 담은 476줄짜리 지원 클래스

도입 결과는 화려했다. 초기 커밋에서 +6,701줄. 14개의 Saga가 3개 모듈에 걸쳐 구성됐다.


2. 현실의 벽

도입 후 한 달. 기능을 추가하고 QA를 돌리면서 문제가 보이기 시작했다.

문제 1: Distributed Monolith

회원 삭제 로직을 보자.

// Saga 적용 버전
class MemberDeleteSagaConsumer {
    fun onStep(event: MemberDeleteStepEvent) {
        when (event.stepType) {
            EXECUTE -> {
                memberRepository.delete(event.memberId)
                publishNextStep(COMPLETE)
            }
            COMPENSATE -> { /* 보상 로직 */ }
        }
    }
}

이 코드의 문제: 회원 삭제는 단일 DB 작업이다. 외부 시스템 호출이 없다. 보상할 것도 없다. 그런데 Saga 패턴 안에 들어가 있다.

이런 케이스가 3건(MEMBER_DELETE, PROJECT_DELETE, PROJECT_MEMBER_DELETE)이었다. "패턴이 있으니 다 적용하자"는 관성이 만든 Distributed Monolith 안티패턴이다. 단일 트랜잭션이면 충분한 작업에 분산 인프라를 얹어서, 복잡성만 늘리고 얻는 것은 없었다.

문제 2: 보상 트랜잭션의 복잡성 폭발

회원가입 Saga의 보상 체인을 생각해보자.

정상 흐름: DB 저장 → Keycloak 생성 → Gitea 생성
보상 흐름: Gitea 삭제 ← Keycloak 삭제 ← DB 삭제

깔끔해 보인다. 하지만 현실에서는:

  1. Keycloak 생성은 성공했는데 Gitea에서 실패 → Keycloak 삭제 보상 실행
  2. Keycloak 삭제 보상이 네트워크 오류로 실패 → 보상의 보상은?
  3. 부분적으로 Gitea에 데이터가 남아있는 경우 → 어떤 보상을 실행해야 하는지 판단 불가

보상 트랜잭션은 반드시 성공한다는 보장이 없다. 보상이 실패하면 수동 개입이 필요하고, 이를 위한 모니터링 코드가 또 필요하다. 보상 전용 OutPort/Adapter만 7개가 생겼다:

DeleteKeycloakMemberOutPort / DeleteKeycloakMemberAdapter
DeleteMemberBySubIdOutPort / DeleteMemberBySubIdJpaAdapter
DeleteGiteaUserOutPort / DeleteGiteaUserAdapter
RemoveGiteaUserFromTeamOutPort / RemoveGiteaUserFromTeamAdapter
DeleteArgoProjectOutPort / DeleteArgoProjectAdapter
UnregisterArgoRepositoryOutPort / UnregisterArgoRepositoryAdapter
DeleteGiteaRepositoryOutPort / DeleteGiteaRepositoryAdapter

정상 흐름보다 보상 코드가 더 많았다.

문제 3: 비즈니스 의미의 왜곡

Saga 구조에서 회원가입 UseCase는 이런 역할이었다:

class CreateMemberSignUpUseCase {
    fun execute(command: Command) {
        validate(command)                    // 검증만 함
        publishSagaEvent(MEMBER_SIGNUP)      // Saga 시작 이벤트 발행
    }
}

이름은 CreateMemberSignUp인데 실제로 회원을 생성하지 않는다. 실제 생성은 Saga Consumer가 비동기로 처리한다. 결국 ValidateMemberSignUpUseCase로 리네이밍했지만, UseCase 이름에 Create가 아닌 Validate가 붙는 것 자체가 Saga라는 인프라가 비즈니스 의미를 침범한 증거였다.


3. 전환을 결심한 기준

Saga를 걷어내기로 한 판단 기준은 세 가지였다.

첫째, 보상보다 재시도가 현실적이다. 실패한 외부 호출을 되돌리는 것보다, 실패한 작업을 다시 시도하는 것이 단순하고 확실하다. 대부분의 외부 호출 실패는 일시적 네트워크 오류다.

둘째, 100% 즉시 일관성이 불필요하다. 회원가입 후 Gitea 계정이 30초 뒤에 생겨도 사용자 경험에 문제없다. 엄격한 즉시 일관성이 필요한 곳은 결제 같은 도메인이지, 계정 프로비저닝이 아니다.

셋째, Saga 인프라 코드가 비즈니스 코드를 압도했다. SagaConsumerSupport 476줄, 보상 전용 Adapter 7개, SagaConstants의 Step/Type/StreamKey 상수들. 비즈니스 로직 자체보다 패턴 유지 비용이 더 컸다.


4. Reconciliation 패턴으로의 전환

Reconciliation이란

Kubernetes의 Controller 패턴에서 영감을 받았다. Kubernetes는 "원하는 상태(desired state)"와 "현재 상태(actual state)"를 주기적으로 비교하고, 차이(drift)가 있으면 보정한다.

Saga:          실패 → 즉시 보상 (되돌리기)
Reconciliation: 실패 → 기록 → 주기적으로 확인 → drift 보정 (앞으로 나아가기)

핵심 차이는 방향이다. Saga는 실패 시 뒤로 돌아가지만, Reconciliation은 앞으로 나아간다.

새로운 구조

[API 요청] → UseCase: DB 저장 + Operation Tracking(PENDING) + Outbox 이벤트 (단일 TX)
                                    ↓
                 [Outbox Poller] → Valkey Stream 발행
                                    ↓
                 [Consumer] → 외부 시스템 호출
                            → 성공: markDone(COMPLETED)
                            → 실패: markFailed(FAILED) — 보상 없음
                                    ↓
                 [Reconciler] → 주기적으로 PENDING/FAILED 작업 스캔
                              → drift 감지 시 재시도 또는 TIMEOUT 처리

구성 요소:

구성 요소 역할
Operation Tracking 작업 상태(PENDING → IN_PROGRESS → COMPLETED/FAILED/TIMEOUT) 추적
Outbox + Valkey Stream 이벤트 전달 보장 (이 부분은 Saga 시절과 동일하게 유지)
Consumer 외부 시스템 호출. 성공/실패만 기록. 보상 로직 없음
Reconciler 정체된 작업을 주기적으로 스캔하고 drift를 보정

보상 대신 Drift 보정

Saga 시절 회원가입의 보상 체인:

Gitea 실패 → Keycloak 삭제 → DB 삭제 → 처음부터 다시

Reconciliation에서의 처리:

Gitea 실패 → Operation: FAILED로 기록 → 끝
Reconciler: "Keycloak은 있는데 Gitea가 없네" → Gitea 재생성 시도

보상(되돌리기)이 아니라 수렴(앞으로 나아가기)이다. 외부 시스템에 이미 만들어진 리소스는 그대로 두고, 아직 안 된 것만 마저 처리한다.


5. 전환 과정: 하루 만에 5 Phase

전환은 의존 관계가 적은 것부터 단계적으로 진행했다. 실제 작업일은 하루(2026-04-20)였다.

Phase 0: 인프라 전환

saga_instance 테이블을 operation_tracking으로 RENAME하고, 불필요한 saga_step_log 테이블을 DROP했다. 5값의 OperationStatusKind enum으로 상태 모델을 단순화했다.

37 files changed, +1,348 / -432

Phase 1: Distributed Monolith 해소

단일 DB 작업에 Saga를 적용한 3건을 단순한 @Transactional UseCase로 전환했다.

// Before: Saga Consumer + StepEvent + 보상 로직
class MemberDeleteSagaConsumer { /* 60줄 */ }
class MemberDeleteStepEvent { /* 31줄 */ }

// After: 단일 UseCase
@Transactional
fun execute(command: DeleteMemberCommand) {
    memberRepository.delete(command.memberId)
}
18 files changed, +97 / -435

97줄 추가하고 435줄을 삭제했다. 줄어든 코드가 4배 이상이다.

Phase 2: 핵심 Saga 전환

MEMBER_SIGNUP, PROJECT_CREATE를 Operation Tracking 기반으로 전환했다. UseCase가 단일 TX로 DB 저장 + Operation 생성 + Outbox 이벤트 발행을 처리하고, Consumer는 보상 없이 성공/실패만 기록한다.

32 files changed, +751 / -328

Phase 3: 나머지 정리 + 공통화

CLUSTER_CREATE의 dead code를 삭제하고, CICD_REPOSITORY_CREATE를 L2로 전환했다. OwnerModuleKind, OperationTypeKind 등 공통 enum을 중앙화하여 하드코딩 상수를 제거했다.

23 files changed, +292 / -410

Exit: Saga 인프라 완전 제거

마지막 Saga인 GITEA_PROVISION을 전환하고, Saga 인프라를 완전히 삭제했다.

삭제 목록:

  • SagaConsumerSupport (476줄)
  • SagaStatusAdapter / SagaStatusOutPort
  • 보상 전용 OutPort/Adapter 7개
  • SagaExecutionException + 전용 ExceptionHandler
  • MemberSagaTypeKind, PortalSagaTypeKind (빈 enum)
28 files changed, +129 / -1,408

+ Reconciler 도입

Operation Tracking에서 PENDING/IN_PROGRESS 상태로 일정 시간 이상 정체된 작업을 스캔하고, 도메인별 OperationReconcileHandler를 호출하여 drift를 보정하는 스케줄러를 추가했다.

12 files changed, +419 / -1

6. 전환 결과

정량적 변화

지표 Saga Reconciliation 변화
인프라 코드 SagaConsumerSupport 476줄 + SagaStatusAdapter 200줄 OperationTrackingOutPort ~80줄 -596줄
보상 전용 코드 OutPort/Adapter 7쌍 (14파일) 0 -14파일
Consumer 복잡도 Step 분기 + 보상 체인 단일 처리 + 성공/실패 기록 분기 제거
Exit Phase 총계 - - +129 / -1,408줄

구조적 변화

Before (Saga)

UseCase: 검증 + 이벤트 발행
Consumer: Step 라우팅 + 비즈니스 로직 + 보상 체인
→ 비즈니스 로직이 Consumer에 분산

After (Reconciliation)

UseCase: DB 저장 + Operation 생성 + 이벤트 발행 (비즈니스 로직의 시작점)
Consumer: 외부 호출 + 상태 기록 (인프라 역할만)
Reconciler: drift 보정 (독립적으로 동작)
→ 비즈니스 로직이 UseCase에 집중

UseCase가 다시 이름값을 한다. CreateMemberSignUpUseCase실제로 회원 생성 프로세스를 시작한다.


7. 교훈

Saga가 적합한 경우

  • 보상이 반드시 즉시 필요한 도메인 (결제, 재고 차감)
  • 외부 시스템이 보상 API를 안정적으로 제공하는 환경
  • 부분 완료 상태를 사용자가 절대 볼 수 없어야 하는 경우

Reconciliation이 적합한 경우

  • 외부 시스템 호출이 일시적 오류로 실패하는 비중이 높은 경우
  • 최종 일관성(eventual consistency)으로 충분한 도메인
  • 외부 시스템의 보상 API가 불안정하거나 없는 경우
  • 보상보다 재시도가 자연스러운 작업 흐름

패턴 선택의 기준

패턴은 문제를 해결하기 위해 존재한다. 문제보다 패턴이 커지면 패턴이 문제가 된다.

Saga 패턴은 훌륭한 패턴이다. 하지만 "분산 트랜잭션 = Saga"라는 등식은 위험하다. 보상 트랜잭션의 비용, 도메인의 일관성 요구 수준, 외부 시스템의 특성을 먼저 평가하고, 가장 단순한 해법부터 시도해야 한다.

우리의 경우 대부분의 작업은 "기록하고, 재시도하고, 주기적으로 확인하면" 충분했다. 40일간의 Saga 경험은 그 판단을 내리기 위해 필요한 시간이었다.