보상 트랜잭션과 Saga 패턴 - 분산 시스템에서 되돌리기
TL;DR
- 분산 시스템에서는 DB 트랜잭션처럼 자동 롤백이 불가능하다.
- 보상 트랜잭션은 이미 수행된 작업을 역방향 작업으로 취소한다.
- Saga는 보상 트랜잭션을 체계적으로 관리하는 실행 패턴이다.
1. 개념
보상 트랜잭션은 완료된 작업의 효과를 취소하는 역방향 작업이며, Saga는 여러 로컬 트랜잭션과 보상 작업을 순서로 관리하는 패턴이다.
2. 배경
마이크로서비스는 서비스별 DB와 외부 시스템을 사용하므로 단일 DB의 ACID 트랜잭션으로 전체 작업을 묶을 수 없다.
3. 이유
결제, 재고, 배송 등 단계 중 실패가 발생했을 때 자동 롤백이 불가능하므로 애플리케이션 레벨의 되돌리기 전략이 필요하다.
4. 특징
멱등성, 재시도, 이벤트 기반 choreography 또는 orchestrator 기반 orchestration, 그리고 eventual consistency를 전제로 하는 흐름 관리가 핵심이다.
5. 상세 내용
보상 트랜잭션과 Saga 패턴 - 분산 시스템에서 “되돌리기”
작성일: 2026-02-26 카테고리: Backend / Distributed Systems / Transaction 포함 내용: Compensating Transaction, Saga Pattern, 분산 트랜잭션, 2PC, Eventual Consistency, Orchestration, Choreography, 롤백, 멱등성
1. 먼저 이해해야 할 것: 트랜잭션이란?
1.1 트랜잭션 = “다 되거나, 아무것도 안 되거나”
┌─────────────────────────────────────────────────────────────────┐
│ 트랜잭션이란 무엇인가? │
│ │
│ 트랜잭션(Transaction) = 하나의 작업 단위 │
│ "전부 성공하거나, 전부 실패해야 하는 작업 묶음" │
│ │
│ 비유: 은행 송금 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ A 계좌에서 -10만원 │ │
│ │ B 계좌에서 +10만원 │ │
│ │ │ │
│ │ ❌ A에서 빠졌는데 B에 안 들어가면? │ │
│ │ → 돈이 증발! (10만원이 공중분해) │ │
│ │ │ │
│ │ ✅ 트랜잭션을 쓰면? │ │
│ │ → 둘 다 성공하거나, 둘 다 실패 (All or Nothing) │ │
│ │ → B에 입금 실패 시 A 출금도 자동 취소! │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 일상 비유: │
│ ├── 편의점에서 물건 사기 = 트랜잭션 │
│ │ (돈 주기 + 물건 받기 = 둘 다 되거나 둘 다 안 되거나) │
│ ├── 돈만 주고 물건 못 받으면? → 거래 취소! (롤백) │
│ └── 물건만 받고 돈 안 내면? → 그것도 안 됨! │
│ │
└─────────────────────────────────────────────────────────────────┘
1.2 ACID - 트랜잭션의 4가지 약속
┌─────────────────────────────────────────────────────────────────┐
│ ACID = 트랜잭션이 지켜야 할 4가지 원칙 │
│ │
│ A = Atomicity (원자성) │
│ ├── "전부 되거나, 전부 안 되거나" │
│ ├── 원자(atom)처럼 더 이상 쪼갤 수 없는 단위 │
│ └── 비유: 스위치 ON/OFF (반만 켤 수 없음) │
│ │
│ C = Consistency (일관성) │
│ ├── "데이터가 항상 올바른 상태" │
│ ├── 규칙 위반하는 데이터가 저장되면 안 됨 │
│ └── 비유: 잔고가 마이너스가 되면 안 됨 │
│ │
│ I = Isolation (격리성) │
│ ├── "동시에 실행되는 작업끼리 서로 안 보임" │
│ ├── A가 수정 중인 데이터를 B가 읽으면 안 됨 │
│ └── 비유: 시험 볼 때 옆 사람 답안지 안 보이게 칸막이 │
│ │
│ D = Durability (지속성) │
│ ├── "한번 저장(커밋)되면 절대 유실 안 됨" │
│ ├── 서버가 꺼져도, 정전이 나도 데이터 보존 │
│ └── 비유: 계약서에 도장 찍으면 없어지지 않음 │
│ │
└─────────────────────────────────────────────────────────────────┘
1.3 DB 트랜잭션의 자동 롤백
┌─────────────────────────────────────────────────────────────────┐
│ DB 트랜잭션 = 자동 되돌리기의 마법 │
│ │
│ 롤백(ROLLBACK)이란? │
│ ├── "하던 작업을 전부 취소하고 원래 상태로 되돌리기" │
│ ├── 게임에서 세이브 포인트로 돌아가는 것과 같음 │
│ └── DB가 알아서 해줌! │
│ │
│ Spring @Transactional 예시 (Kotlin): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ @Transactional │ │
│ │ fun transfer(from: Long, to: Long, amount: Int) { │ │
│ │ accountRepository.debit(from, amount) // 출금 │ │
│ │ accountRepository.credit(to, amount) // 입금 │ │
│ │ // ↑ 여기서 예외 발생하면? │ │
│ │ // → DB가 자동으로 출금도 취소! (ROLLBACK) │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 동작 원리: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 1. 트랜잭션 시작 (BEGIN) │ │
│ │ 2. 출금 실행 → "임시 메모"에 기록 (아직 확정 아님) │ │
│ │ 3. 입금 실행 → 실패! 예외 발생! │ │
│ │ 4. ROLLBACK → "임시 메모"를 버림 │ │
│ │ 5. 결과: 출금도 안 된 상태로 원복 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 핵심: 이건 "하나의 DB" 안에서만 가능한 마법! │
│ DB가 커밋 전까지 데이터를 확정하지 않기 때문 │
│ │
└─────────────────────────────────────────────────────────────────┘
2. 문제의 시작: 분산 시스템에서는 자동 롤백이 안 된다
2.1 하나의 DB가 아닌 여러 시스템
┌─────────────────────────────────────────────────────────────────┐
│ 마이크로서비스 = 각자 자기 DB를 가짐 │
│ │
│ 옛날 (모놀리스): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ 하나의 큰 서버 │ │ │
│ │ │ ┌────────┐ ┌────────┐ │ │ │
│ │ │ │ 주문 │ │ 결제 │ │ │ │
│ │ │ └────────┘ └────────┘ │ │ │
│ │ │ ┌────────┐ ┌────────┐ │ │ │
│ │ │ │ 재고 │ │ 알림 │ │ │ │
│ │ │ └────────┘ └────────┘ │ │ │
│ │ └─────────────┬────────────────┘ │ │
│ │ │ │ │
│ │ ┌─────┴─────┐ │ │
│ │ │ 하나의 DB │ ← @Transactional 하나로 끝! │ │
│ │ └───────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 지금 (마이크로서비스): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ 주문 │ │ 결제 │ │ 재고 │ │ 알림 │ │ │
│ │ │ 서비스 │ │ 서비스 │ │ 서비스 │ │ 서비스 │ │ │
│ │ └───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘ │ │
│ │ │ │ │ │ │ │
│ │ ┌───┴──┐ ┌───┴──┐ ┌───┴──┐ 이메일/SMS │ │
│ │ │ DB 1 │ │ DB 2 │ │ DB 3 │ PG사 API │ │
│ │ └──────┘ └──────┘ └──────┘ S3 스토리지 │ │
│ │ │ │
│ │ → 각각 다른 DB, 다른 시스템, 다른 네트워크! │ │
│ │ → @Transactional 하나로 묶을 수 없음! │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
2.2 왜 자동 롤백이 안 되는가? (핵심!)
┌─────────────────────────────────────────────────────────────────┐
│ 외부 시스템은 "임시 메모"가 없다! │
│ │
│ DB 트랜잭션의 비밀: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 커밋(COMMIT) 전까지는 실제로 저장되지 않음! │ │
│ │ │ │
│ │ ① "임시 메모"에 적어둠 (undo log) │ │
│ │ ② 모든 작업 성공 → COMMIT → 확정! │ │
│ │ ③ 중간에 실패 → ROLLBACK → 임시 메모 버림 │ │
│ │ │ │
│ │ 비유: 연필로 적다가 마음에 안 들면 지우개로 지움 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 외부 시스템은 다름: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ S3에 파일 업로드 → 즉시 올라감! 되돌릴 수 없음! │ │
│ │ PG사에 결제 요청 → 즉시 결제됨! 자동 취소 안 됨! │ │
│ │ 이메일 발송 → 이미 보냄! 회수 불가! │ │
│ │ SMS 전송 → 이미 도착! 회수 불가! │ │
│ │ │ │
│ │ 비유: 볼펜으로 쓴 건 지우개로 안 지워짐! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 핵심 비유: 편지와 우체통 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ DB 트랜잭션 = 아직 편지를 쓰고 있는 상태 │ │
│ │ 마음에 안 들면 찢으면 됨 (ROLLBACK) │ │
│ │ │ │
│ │ 외부 호출 = 이미 편지를 우체통에 넣은 상태 │ │
│ │ 꺼낼 수 없음! (회수 불가) │ │
│ │ → 새 편지를 보내야 함 (보상 트랜잭션) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
2.3 실제 시나리오로 이해하기
┌─────────────────────────────────────────────────────────────────┐
│ 온라인 쇼핑몰 주문 처리 시나리오 │
│ │
│ 주문 처리 흐름: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 1단계: 주문 생성 (주문 DB) → ✅ 성공 │ │
│ │ 2단계: 결제 처리 (PG사 API) → ✅ 성공 │ │
│ │ 3단계: 재고 차감 (재고 DB) → ❌ 실패! (재고 부족) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 현재 상황: │
│ ├── 1단계: 주문이 이미 DB에 저장됨 (되돌려야 함) │
│ ├── 2단계: 결제가 이미 완료됨 (돈이 빠져나감!) (되돌려야 함) │
│ └── 3단계: 재고가 없어서 실패 (할 게 없음) │
│ │
│ 자동 ROLLBACK? → 불가! │
│ ├── 1단계의 주문 DB와 2단계의 PG사는 별개 시스템 │
│ ├── PG사에 이미 결제된 건 DB가 알아서 취소 못 함 │
│ └── 각 시스템이 "한 몸"이 아니기 때문 │
│ │
│ 그러면 어떻게? │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ "내가 직접 되돌리는 코드"를 작성해야 함! │ │
│ │ │ │
│ │ → 2단계 되돌리기: PG사 환불 API 호출 │ │
│ │ → 1단계 되돌리기: 주문 상태를 '취소'로 변경 │ │
│ │ │ │
│ │ 이것이 바로 "보상 트랜잭션"! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
3. 보상 트랜잭션 (Compensating Transaction)
3.1 보상 트랜잭션이란?
┌─────────────────────────────────────────────────────────────────┐
│ 보상 트랜잭션의 정의 │
│ │
│ 보상 트랜잭션 (Compensating Transaction) │
│ = "원래 작업의 효과를 취소하는 역방향 작업" │
│ = DB ROLLBACK의 수동 버전 │
│ = "이미 보낸 편지를 회수할 수 없으니, 정정 편지를 보내는 것" │
│ │
│ 비유: 레스토랑에서 주문 취소 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 상황: 스테이크를 주문했는데 취소하고 싶음 │ │
│ │ │ │
│ │ ✅ 아직 요리 시작 전이라면? │ │
│ │ → 주방에 "취소요!" 한마디면 끝 (DB ROLLBACK) │ │
│ │ │ │
│ │ ❌ 이미 고기를 꺼내고 불을 켰다면? │ │
│ │ → 자동으로 냉장고에 돌아가지 않음! │ │
│ │ → 셰프가 직접 불 끄고, 고기를 냉장고에 넣어야 함 │ │
│ │ → 이게 보상 트랜잭션! │ │
│ │ │ │
│ │ 더 심한 경우: 이미 요리가 완성됐다면? │ │
│ │ → 원재료로 되돌릴 수 없음 (스테이크 → 생고기 불가) │ │
│ │ → "환불"이라는 다른 방식으로 보상 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
3.2 각 단계별 보상 트랜잭션 예시
┌─────────────────────────────────────────────────────────────────┐
│ 원래 작업 → 보상 트랜잭션 매핑표 │
│ │
│ 원래 작업 보상 트랜잭션 (되돌리기) │
│ ───────────────────────────────────────────────── │
│ 주문 생성 (INSERT) → 주문 취소 │
│ (UPDATE status='CANCELLED') │
│ │
│ 결제 처리 (PG사 결제) → 결제 취소 │
│ (PG사 환불 API 호출) │
│ │
│ 재고 차감 (UPDATE -1) → 재고 복원 │
│ (UPDATE +1) │
│ │
│ S3 파일 업로드 → S3 파일 삭제 │
│ (deleteObject 호출) │
│ │
│ 포인트 차감 → 포인트 복원 │
│ (포인트 다시 지급) │
│ │
│ 쿠폰 사용 처리 → 쿠폰 미사용 처리 │
│ (쿠폰 상태 복원) │
│ │
│ 이메일 발송 → ??? │
│ (보상 불가! → "취소 이메일" 전송) │
│ │
│ 주의: 모든 작업이 "깨끗하게" 되돌려지는 건 아님! │
│ → 이메일처럼 보상 불가능한 작업도 존재함 │
│ │
└─────────────────────────────────────────────────────────────────┘
3.3 보상 트랜잭션 vs DB ROLLBACK 비교
┌─────────────────────────────────────────────────────────────────┐
│ 보상 트랜잭션 vs DB ROLLBACK 핵심 차이 │
│ │
│ DB ROLLBACK: │
│ ├── "하던 일을 취소" (커밋 전이므로 흔적 없음) │
│ ├── 아예 안 한 것처럼 됨 │
│ ├── DB가 자동으로 해줌 │
│ └── 비유: 연필로 쓰다가 지우개로 지움 (깨끗함) │
│ │
│ 보상 트랜잭션: │
│ ├── "이미 한 일을 되돌리는 새로운 일" (흔적이 남음!) │
│ ├── 원래 기록 + 보상 기록 둘 다 남음 │
│ ├── 개발자가 직접 코드를 작성해야 함 │
│ └── 비유: 볼펜으로 쓴 뒤 줄 긋고 옆에 정정 (흔적 남음) │
│ │
│ 왜 흔적을 남기는가? │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 감사 추적 (Audit Trail) │ │
│ │ │ │
│ │ ❌ 나쁜 방식: │ │
│ │ 결제됨 → DELETE (결제 기록 삭제) │ │
│ │ → "이 돈이 어디 갔지?" 추적 불가! │ │
│ │ │ │
│ │ ✅ 좋은 방식: │ │
│ │ 결제됨 → 환불 기록 INSERT │ │
│ │ → 원래 결제 기록 + 환불 기록 둘 다 보존 │ │
│ │ → "1월 3일 결제, 1월 5일 환불" 이력 추적 가능 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 비교 표: │
│ ┌──────────────┬──────────────────┬──────────────────────┐ │
│ │ │ DB ROLLBACK │ 보상 트랜잭션 │ │
│ ├──────────────┼──────────────────┼──────────────────────┤ │
│ │ 실행 주체 │ DB가 자동 │ 개발자가 직접 코딩 │ │
│ │ 흔적 유무 │ 없음 (깨끗) │ 있음 (이력 남음) │ │
│ │ 실패 가능성 │ 거의 없음 │ 보상 자체가 실패 가능 │ │
│ │ 적용 범위 │ 단일 DB 내 │ 여러 시스템 간 │ │
│ │ 복잡도 │ 매우 낮음 │ 높음 │ │
│ │ 비유 │ 지우개로 지움 │ 정정 편지를 보냄 │ │
│ └──────────────┴──────────────────┴──────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
3.4 보상 트랜잭션의 어려운 점
┌─────────────────────────────────────────────────────────────────┐
│ 보상 트랜잭션 구현 시 주의할 점 │
│ │
│ 문제 1: 보상 자체가 실패하면? │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 상황: 결제는 이미 됐고, 재고 부족으로 주문 실패 │ │
│ │ → 환불(보상) 시도 │ │
│ │ → 그런데 PG사 서버가 다운! │ │
│ │ → 환불 API 호출 실패! │ │
│ │ │ │
│ │ 해결: │ │
│ │ ├── 재시도 (Retry with Exponential Backoff) │ │
│ │ │ 1차: 1초 후 재시도 │ │
│ │ │ 2차: 2초 후 재시도 │ │
│ │ │ 3차: 4초 후 재시도 │ │
│ │ │ 4차: 8초 후 재시도 ... │ │
│ │ │ │ │
│ │ ├── 최종 실패 시 │ │
│ │ │ → Dead Letter Queue(DLQ)에 저장 │ │
│ │ │ → 운영팀에 알림 │ │
│ │ │ → 수동 개입으로 처리 │ │
│ │ │ │ │
│ │ └── DLQ(Dead Letter Queue)란? │ │
│ │ = "처리 실패한 메시지를 모아두는 곳" │ │
│ │ = 비유: 배달 불가 우편물 보관소 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 문제 2: 같은 보상이 여러 번 실행되면? │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 상황: 네트워크 장애로 환불 응답을 못 받음 │ │
│ │ → "환불 안 된 거 같은데?" → 다시 환불 호출 │ │
│ │ → 실제로는 첫 번째 환불이 성공했었음 │ │
│ │ → 결과: 2번 환불! (고객에게 2배 지급!) │ │
│ │ │ │
│ │ 해결: 멱등성(Idempotency) 보장 │ │
│ │ │ │
│ │ 멱등성이란? │ │
│ │ = "같은 작업을 여러 번 해도 결과가 한 번 한 것과 같음" │ │
│ │ = 비유: 엘리베이터 버튼. 10번 눌러도 1번 누른 것과 같음│ │
│ │ │ │
│ │ 구현: 멱등키(Idempotency Key) 사용 │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ refund(paymentId="PAY-001", │ │ │
│ │ │ idempotencyKey="REFUND-PAY-001") │ │ │
│ │ │ │ │ │
│ │ │ 1차 호출: 환불 실행 → 성공 │ │ │
│ │ │ 2차 호출: "이미 REFUND-PAY-001 처리됨" → 무시 │ │ │
│ │ │ 3차 호출: "이미 REFUND-PAY-001 처리됨" → 무시 │ │ │
│ │ │ │ │ │
│ │ │ → 몇 번을 호출해도 환불은 1번만 됨! │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
4. Saga 패턴 - 보상 트랜잭션의 체계적 관리
4.1 Saga란?
┌─────────────────────────────────────────────────────────────────┐
│ Saga 패턴이란? │
│ │
│ Saga = "긴 트랜잭션을 여러 개의 로컬 트랜잭션으로 쪼개고, │
│ 실패 시 보상 트랜잭션으로 되돌리는 패턴" │
│ │
│ 기원: │
│ ├── 1987년, Hector Garcia-Molina & Kenneth Salem 논문 │
│ ├── "SAGAS" 논문에서 최초 제안 │
│ └── 원래는 DB 내 장시간 트랜잭션 문제 해결용 │
│ │
│ 비유: 여행 계획 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 여행 계획: │ │
│ │ ① 항공권 예약 ✅ │ │
│ │ ② 호텔 예약 ✅ │ │
│ │ ③ 렌터카 예약 ❌ 실패! (차량 없음) │ │
│ │ │ │
│ │ 여행 자체를 취소해야 한다면? │ │
│ │ → 역순으로 되돌리기! │ │
│ │ ② 호텔 취소 (보상) │ │
│ │ ① 항공권 취소 (보상) │ │
│ │ │ │
│ │ 각 단계마다 "취소하는 방법"이 미리 정해져 있음! │ │
│ │ 이것이 Saga 패턴의 핵심 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Saga의 구성: │
│ ├── T1, T2, T3 ... = 각 단계의 로컬 트랜잭션 │
│ ├── C1, C2, C3 ... = 각 단계의 보상 트랜잭션 │
│ └── Ti 실패 시 → C(i-1), C(i-2), ..., C1 역순 실행 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 정상: T1 → T2 → T3 → T4 (모두 성공!) │ │
│ │ │ │
│ │ 실패: T1 → T2 → T3(실패!) → C2 → C1 │ │
│ │ ────────────────────── ────────── │ │
│ │ 정방향 실행 역방향 보상 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
4.2 Saga의 두 가지 실행 방식
4.2.1 Choreography (안무, 이벤트 기반)
┌─────────────────────────────────────────────────────────────────┐
│ Choreography = 이벤트 기반 (안무 방식) │
│ │
│ 안무(Choreography)란? │
│ ├── 발레 공연에서 각 무용수가 음악에 맞춰 자율적으로 춤 │
│ ├── 중앙에서 지시하는 사람 없음 │
│ └── 각자가 "다음에 뭘 할지" 알고 있음 │
│ │
│ 소프트웨어에서의 Choreography: │
│ ├── 각 서비스가 이벤트를 발행하고, 다음 서비스가 구독 │
│ ├── 중앙 조정자 없음 = 각 서비스가 자율적으로 반응 │
│ └── Kafka, RabbitMQ 같은 메시지 브로커 사용 │
│ │
│ 비유: 도미노 │
│ ├── 하나가 넘어지면 다음이 알아서 넘어짐 │
│ ├── 누가 "넘어져라!" 하고 지시하지 않음 │
│ └── 각 도미노가 자기 역할을 알고 있음 │
│ │
│ 정상 흐름: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 주문 서비스 │ │
│ │ │ 주문 생성 │ │
│ │ └─ [주문생성됨] 이벤트 발행 ──→ Kafka │ │
│ │ │ │ │
│ │ 결제 서비스 (구독) ◄───────────────────┘ │ │
│ │ │ 결제 처리 │ │
│ │ └─ [결제완료됨] 이벤트 발행 ──→ Kafka │ │
│ │ │ │ │
│ │ 재고 서비스 (구독) ◄───────────────────┘ │ │
│ │ │ 재고 차감 │ │
│ │ └─ [재고차감됨] 이벤트 발행 ──→ Kafka │ │
│ │ │ │ │
│ │ 알림 서비스 (구독) ◄───────────────────┘ │ │
│ │ └─ 확인 이메일 발송 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 실패 시 (역방향 보상): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 재고 서비스: 재고 차감 실패! │ │
│ │ └─ [재고차감실패] 이벤트 발행 ──→ Kafka │ │
│ │ │ │ │
│ │ 결제 서비스 (구독) ◄───────────────────┘ │ │
│ │ │ 결제 취소 (보상) │ │
│ │ └─ [결제취소됨] 이벤트 발행 ──→ Kafka │ │
│ │ │ │ │
│ │ 주문 서비스 (구독) ◄───────────────────┘ │ │
│ │ └─ 주문 취소 (보상) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 장점: │
│ ├── 느슨한 결합 (서비스 간 직접 호출 없음) │
│ ├── 새 서비스 추가가 쉬움 (이벤트만 구독하면 됨) │
│ └── 단일 장애 지점 없음 │
│ │
│ 단점: │
│ ├── 전체 흐름 파악이 어려움 (누가 뭘 듣고 있지?) │
│ ├── 디버깅이 매우 어려움 (이벤트가 어디로 갔지?) │
│ └── 순환 의존성 위험 (A→B→C→A 이벤트 무한루프) │
│ │
└─────────────────────────────────────────────────────────────────┘
4.2.2 Orchestration (오케스트레이션, 중앙 조정자)
┌─────────────────────────────────────────────────────────────────┐
│ Orchestration = 중앙 조정자 방식 (지휘자) │
│ │
│ 오케스트레이션이란? │
│ ├── 오케스트라의 지휘자(Conductor)처럼 │
│ ├── 한 명이 전체 흐름을 관리하고 지시 │
│ └── "너 먼저, 그다음 너, 실패하면 되돌려!" 지시 │
│ │
│ 소프트웨어에서의 Orchestration: │
│ ├── Saga Orchestrator라는 "지휘자 서비스"가 존재 │
│ ├── 각 서비스에 "이것 해!" 하고 명령을 보냄 │
│ └── 실패 감지 시 역순으로 보상 명령을 보냄 │
│ │
│ 정상 흐름: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ [Saga Orchestrator] (지휘자) │ │
│ │ │ │ │
│ │ ①──→ 주문 서비스: "주문 생성해!" ──→ ✅ 성공 │ │
│ │ ②──→ 결제 서비스: "결제 처리해!" ──→ ✅ 성공 │ │
│ │ ③──→ 재고 서비스: "재고 차감해!" ──→ ✅ 성공 │ │
│ │ ④──→ 알림 서비스: "이메일 보내!" ──→ ✅ 성공 │ │
│ │ │ │
│ │ → Saga 성공 완료! │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 실패 시 (역순 보상): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ [Saga Orchestrator] (지휘자) │ │
│ │ │ │ │
│ │ ①──→ 주문 서비스: "주문 생성해!" ──→ ✅ 성공 │ │
│ │ ②──→ 결제 서비스: "결제 처리해!" ──→ ✅ 성공 │ │
│ │ ③──→ 재고 서비스: "재고 차감해!" ──→ ❌ 실패! │ │
│ │ │ │
│ │ !! 실패 감지! 역순으로 보상 시작 !! │ │
│ │ │ │
│ │ ④──→ 결제 서비스: "결제 취소해!" (보상) │ │
│ │ ⑤──→ 주문 서비스: "주문 취소해!" (보상) │ │
│ │ │ │
│ │ → Saga 보상 완료 (모두 원복됨) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 장점: │
│ ├── 전체 흐름이 한 곳에서 명확하게 보임 │
│ ├── 디버깅이 쉬움 (Orchestrator 로그만 보면 됨) │
│ ├── 복잡한 분기/조건 처리 가능 │
│ └── 새로운 단계 추가/수정이 Orchestrator만 변경하면 됨 │
│ │
│ 단점: │
│ ├── Orchestrator가 SPOF(단일 장애 지점)이 될 수 있음 │
│ ├── Orchestrator에 로직이 집중됨 (God Object 위험) │
│ └── 서비스 간 결합도가 Choreography보다 높음 │
│ │
└─────────────────────────────────────────────────────────────────┘
4.3 Choreography vs Orchestration 비교표
┌─────────────────────────────────────────────────────────────────┐
│ Choreography vs Orchestration 비교 │
│ │
│ ┌────────────────┬──────────────────┬──────────────────────┐ │
│ │ │ Choreography │ Orchestration │ │
│ │ │ (안무/이벤트) │ (지휘자/중앙관리) │ │
│ ├────────────────┼──────────────────┼──────────────────────┤ │
│ │ 비유 │ 도미노 │ 오케스트라 지휘자 │ │
│ │ 통신 방식 │ 이벤트 발행/구독 │ 명령/응답 │ │
│ │ 중앙 조정자 │ 없음 │ Orchestrator 존재 │ │
│ │ 결합도 │ 낮음 (느슨) │ 중간 │ │
│ │ 흐름 파악 │ 어려움 │ 쉬움 │ │
│ │ 디버깅 │ 어려움 │ 쉬움 │ │
│ │ 복잡한 분기 │ 어려움 │ 쉬움 │ │
│ │ 단일 장애점 │ 없음 │ Orchestrator │ │
│ │ 적합한 경우 │ 단순한 흐름 │ 복잡한 비즈니스 로직 │ │
│ │ │ 서비스 3개 이하 │ 서비스 4개 이상 │ │
│ └────────────────┴──────────────────┴──────────────────────┘ │
│ │
│ 현업에서의 선택 기준: │
│ ├── 단순한 흐름 (2-3단계) → Choreography │
│ ├── 복잡한 흐름 (4단계+) → Orchestration │
│ ├── 조건 분기가 많으면 → Orchestration │
│ └── 팀이 분산되어 있으면 → Choreography │
│ │
│ 실무 팁: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 대부분의 실무 프로젝트에서는 Orchestration을 선택 │ │
│ │ 이유: 흐름 파악, 디버깅, 유지보수가 훨씬 쉬움 │ │
│ │ Choreography는 간단한 알림/로깅 같은 부수 효과에 적합 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
5. 왜 이런 것이 나오게 되었는가? (역사적 배경)
5.1 모놀리스 시대 (2000년대 이전)
┌─────────────────────────────────────────────────────────────────┐
│ 모놀리스 시대: 아무 문제 없었다 │
│ │
│ 모놀리스(Monolith)란? │
│ ├── "하나의 거대한 덩어리"라는 뜻 │
│ ├── 하나의 앱, 하나의 서버, 하나의 DB │
│ └── 모든 기능이 한 프로젝트 안에 있음 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ 하나의 큰 서버 │ │ │
│ │ │ 주문 + 결제 + 재고 + 알림 │ │ │
│ │ └────────────────┬────────────────┘ │ │
│ │ │ │ │
│ │ ┌─────┴─────┐ │ │
│ │ │ 하나의 DB │ │ │
│ │ └───────────┘ │ │
│ │ │ │
│ │ @Transactional 하나면 끝! │ │
│ │ 모든 작업이 하나의 DB에서 일어나니까 │ │
│ │ 자동 ROLLBACK이 완벽하게 작동! │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 이 시절 트랜잭션은 정말 간단했음: │
│ ├── 실패? → DB가 알아서 ROLLBACK │
│ ├── 보상 트랜잭션? → 뭐 그게? │
│ └── Saga? → 몰라도 됨! │
│ │
└─────────────────────────────────────────────────────────────────┘
5.2 2PC (Two-Phase Commit) 시도 (2000년대)
┌─────────────────────────────────────────────────────────────────┐
│ 2PC = 여러 DB를 하나처럼 묶으려는 시도 │
│ │
│ 2PC (Two-Phase Commit)란? │
│ = "2단계 커밋 프로토콜" │
│ = 여러 DB/시스템이 함께 커밋하거나 함께 롤백하게 만드는 방법 │
│ │
│ 비유: 결혼식 서약 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 주례(Coordinator): "신랑, 이 여자를 아내로 맞이하시 │ │
│ │ 겠습니까?" │ │
│ │ 신랑: "네" (Phase 1: 준비 완료) │ │
│ │ │ │
│ │ 주례: "신부, 이 남자를 남편으로 맞이하시겠습니까?" │ │
│ │ 신부: "네" (Phase 1: 준비 완료) │ │
│ │ │ │
│ │ 주례: "둘 다 동의했으니, 부부로 선언합니다!" │ │
│ │ (Phase 2: 커밋!) │ │
│ │ │ │
│ │ 만약 신부가 "아니오"라고 했다면? │ │
│ │ → 전체 취소! (Phase 2: 롤백!) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 2PC 동작 과정: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Phase 1 (Prepare, 준비): │ │
│ │ ┌──────────────┐ │ │
│ │ │ Coordinator │───"준비됐어?"──→ DB A → "OK!" │ │
│ │ │ (조정자) │───"준비됐어?"──→ DB B → "OK!" │ │
│ │ │ │───"준비됐어?"──→ DB C → "OK!" │ │
│ │ └──────────────┘ │ │
│ │ │ │
│ │ Phase 2 (Commit/Rollback, 확정/취소): │ │
│ │ ┌──────────────┐ │ │
│ │ │ Coordinator │───"커밋해!"────→ DB A → ✅ │ │
│ │ │ (모두 OK면) │───"커밋해!"────→ DB B → ✅ │ │
│ │ │ │───"커밋해!"────→ DB C → ✅ │ │
│ │ └──────────────┘ │ │
│ │ │ │
│ │ 하나라도 "아니오"면? │ │
│ │ ┌──────────────┐ │ │
│ │ │ Coordinator │───"롤백해!"────→ DB A → 취소 │ │
│ │ │ (하나 실패) │───"롤백해!"────→ DB B → 취소 │ │
│ │ │ │───"롤백해!"────→ DB C → 취소 │ │
│ │ └──────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 2PC의 심각한 문제점: │
│ ├── 느림: 모든 참여자가 응답할 때까지 대기 (블로킹!) │
│ ├── 가용성 저하: 참여자 하나가 죽으면 전체 멈춤 │
│ ├── 확장성 한계: 참여자가 많을수록 느려짐 │
│ ├── 단일 장애점: Coordinator가 죽으면 전부 불확실 상태 │
│ └── 현대 시스템 미지원: NoSQL, 메시지 큐, 외부 API는 │
│ 2PC를 아예 지원하지 않음! │
│ │
│ 결론: 2PC는 "이론적으로 좋지만 현실에서 못 쓰는" 방식 │
│ │
└─────────────────────────────────────────────────────────────────┘
5.3 마이크로서비스 시대 (2010년대~)
┌─────────────────────────────────────────────────────────────────┐
│ 마이크로서비스 시대와 Saga의 부상 │
│ │
│ 마이크로서비스란? │
│ ├── 하나의 큰 앱을 작은 서비스 여러 개로 쪼갬 │
│ ├── 각 서비스가 자체 DB를 가짐 (Database per Service) │
│ ├── 서비스 간 통신은 네트워크 (HTTP, gRPC, Kafka) │
│ └── Netflix, Amazon, 배달의민족 등이 이 방식 │
│ │
│ 왜 마이크로서비스가 유행했나? │
│ ├── 모놀리스가 너무 커져서 배포가 힘들어짐 │
│ ├── 한 기능 수정 → 전체 서버 재배포 → 위험! │
│ ├── 팀이 커지면서 코드 충돌이 잦아짐 │
│ └── 서비스별로 독립 배포, 독립 확장 필요 │
│ │
│ 그래서 생긴 문제: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 각 서비스가 자기 DB만 관리 │ │
│ │ → 여러 서비스에 걸친 트랜잭션이 불가능! │ │
│ │ → 2PC는 현실적으로 불가 (느림, 미지원) │ │
│ │ → 그러면 어떻게? │ │
│ │ │ │
│ │ → Saga 패턴 + 보상 트랜잭션이 사실상 표준! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 시대별 흐름 정리: │
│ ├── 2000년대 이전: 모놀리스 → @Transactional로 충분 │
│ ├── 2000년대: 2PC 시도 → 느리고 확장 안 됨 │
│ ├── 2010년대~: 마이크로서비스 → Saga가 표준 │
│ └── 현재: Saga + Eventual Consistency가 주류 │
│ │
└─────────────────────────────────────────────────────────────────┘
5.4 Eventual Consistency (최종 일관성)
┌─────────────────────────────────────────────────────────────────┐
│ Eventual Consistency = 결국에는 맞아진다 │
│ │
│ 일관성(Consistency)의 두 종류: │
│ │
│ Strong Consistency (강한 일관성): │
│ ├── "데이터 변경 즉시 모든 곳에서 동일하게 보임" │
│ ├── 은행 ATM에서 출금하면 즉시 잔고 반영 │
│ └── 비유: 칠판에 쓰면 교실의 모든 학생이 즉시 봄 │
│ │
│ Eventual Consistency (최종 일관성): │
│ ├── "지금 당장은 다를 수 있지만, 시간이 지나면 결국 같아짐" │
│ ├── 즉시 일관성을 포기하는 대신 가용성과 성능을 얻음 │
│ └── 비유: 편지 보내기 │
│ │
│ 비유: 은행 이체 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Strong Consistency: │ │
│ │ "A에서 출금 → B에 즉시 입금" (동시에!) │ │
│ │ → 편리하지만 시스템이 무거움 │ │
│ │ │ │
│ │ Eventual Consistency: │ │
│ │ "A에서 출금 → ... 3~5분 후 → B에 입금" │ │
│ │ → 즉시는 아니지만, 결국에는 맞아짐 │ │
│ │ → 대부분의 은행 이체가 실제로 이렇게 동작! │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Saga와의 관계: │
│ ├── Saga는 Eventual Consistency를 전제로 함 │
│ ├── 각 단계가 진행되는 동안 데이터가 "중간 상태"일 수 있음 │
│ │ (주문은 생성됐지만, 결제가 아직 안 된 상태) │
│ ├── 모든 단계가 완료되면 비로소 일관된 상태가 됨 │
│ └── 실패 시 보상을 통해 일관된 상태로 복원 │
│ │
│ CAP 정리 (참고): │
│ ├── 분산 시스템은 C(일관성), A(가용성), P(분할내성) 중 │
│ │ 세 개를 동시에 만족할 수 없다는 이론 │
│ ├── 마이크로서비스는 보통 A+P를 선택 (일관성 양보) │
│ └── → Eventual Consistency로 타협 │
│ │
└─────────────────────────────────────────────────────────────────┘
6. 실전 구현 패턴
6.1 Orchestration Saga 구현 (Kotlin 예시)
┌─────────────────────────────────────────────────────────────────┐
│ Orchestration Saga 코드 구현 (Kotlin) │
│ │
│ 핵심 구조: │
│ ├── SagaStep: 원래 작업 + 보상 작업을 한 쌍으로 정의 │
│ ├── SagaOrchestrator: 단계를 순서대로 실행 │
│ └── 실패 시 완료된 단계를 역순으로 보상 │
│ │
│ Step 1: Saga 단계 정의 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ // 각 단계의 "실행"과 "보상"을 쌍으로 정의 │ │
│ │ data class SagaStep( │ │
│ │ val name: String, │ │
│ │ val id: String │ │
│ │ ) │ │
│ │ │ │
│ │ enum class SagaResult { │ │
│ │ SUCCESS, // 모든 단계 성공 │ │
│ │ COMPENSATED // 실패 후 보상 완료 │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Step 2: Saga Orchestrator 구현 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ class OrderSaga( │ │
│ │ private val orderService: OrderService, │ │
│ │ private val paymentService: PaymentService, │ │
│ │ private val inventoryService: InventoryService │ │
│ │ ) { │ │
│ │ fun execute(request: OrderRequest): SagaResult { │ │
│ │ val completedSteps = mutableListOf<SagaStep>() │ │
│ │ │ │
│ │ try { │ │
│ │ // Step 1: 주문 생성 │ │
│ │ val orderId = orderService │ │
│ │ .create(request) │ │
│ │ completedSteps.add( │ │
│ │ SagaStep("ORDER", orderId) │ │
│ │ ) │ │
│ │ │ │
│ │ // Step 2: 결제 │ │
│ │ val paymentId = paymentService │ │
│ │ .charge(request.amount) │ │
│ │ completedSteps.add( │ │
│ │ SagaStep("PAYMENT", paymentId) │ │
│ │ ) │ │
│ │ │ │
│ │ // Step 3: 재고 차감 │ │
│ │ inventoryService.deduct( │ │
│ │ request.productId, │ │
│ │ request.quantity │ │
│ │ ) │ │
│ │ completedSteps.add( │ │
│ │ SagaStep("INVENTORY", │ │
│ │ request.productId) │ │
│ │ ) │ │
│ │ │ │
│ │ return SagaResult.SUCCESS │ │
│ │ │ │
│ │ } catch (e: Exception) { │ │
│ │ // 실패! 역순으로 보상 실행 │ │
│ │ compensate(completedSteps.reversed()) │ │
│ │ return SagaResult.COMPENSATED │ │
│ │ } │ │
│ │ } │ │
│ │ │ │
│ │ private fun compensate(steps: List<SagaStep>) { │ │
│ │ for (step in steps) { │ │
│ │ try { │ │
│ │ when (step.name) { │ │
│ │ "ORDER" -> │ │
│ │ orderService.cancel(step.id) │ │
│ │ "PAYMENT" -> │ │
│ │ paymentService.refund(step.id) │ │
│ │ "INVENTORY" -> │ │
│ │ inventoryService │ │
│ │ .restore(step.id) │ │
│ │ } │ │
│ │ } catch (e: Exception) { │ │
│ │ // 보상 실패 시 로그 + 재시도 큐 │ │
│ │ logger.error("보상 실패: ${step}", e) │ │
│ │ retryQueue.enqueue(step) │ │
│ │ } │ │
│ │ } │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 동작 흐름: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 성공 시: │ │
│ │ ORDER(생성) → PAYMENT(결제) → INVENTORY(차감) │ │
│ │ → SagaResult.SUCCESS │ │
│ │ │ │
│ │ INVENTORY 실패 시: │ │
│ │ ORDER(생성) → PAYMENT(결제) → INVENTORY(실패!) │ │
│ │ → PAYMENT(환불) → ORDER(취소) │ │
│ │ → SagaResult.COMPENSATED │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
6.2 S3 + DB 보상 트랜잭션 (실전 시나리오)
┌─────────────────────────────────────────────────────────────────┐
│ S3 + DB 연동 시 보상 트랜잭션 구현 │
│ │
│ 시나리오: 파일 업로드 서비스 │
│ ├── DB에 파일 메타데이터 저장 (파일명, 크기, 경로) │
│ ├── S3에 실제 파일 업로드 │
│ └── 둘 다 성공해야 "진짜 업로드 완료" │
│ │
│ 문제 상황: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Case 1: S3 성공 → DB 실패 │ │
│ │ → S3에 파일은 있는데 DB에 기록이 없음 │ │
│ │ → "유령 파일" (S3에만 존재하는 미아 파일) │ │
│ │ → 보상: S3 파일 삭제 │ │
│ │ │ │
│ │ Case 2: DB 성공 → S3 실패 │ │
│ │ → DB에 기록은 있는데 S3에 파일이 없음 │ │
│ │ → "깨진 링크" (DB가 가리키는 파일이 없음) │ │
│ │ → 보상: DB 기록 삭제 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 구현 코드 (Kotlin): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ class FileUploadService( │ │
│ │ private val s3Client: S3Client, │ │
│ │ private val fileRepository: FileRepository │ │
│ │ ) { │ │
│ │ fun upload(file: MultipartFile): FileMetadata { │ │
│ │ val s3Key = generateKey(file) │ │
│ │ │ │
│ │ // Step 1: S3에 파일 업로드 │ │
│ │ try { │ │
│ │ s3Client.putObject( │ │
│ │ bucket, s3Key, file.bytes │ │
│ │ ) │ │
│ │ } catch (e: Exception) { │ │
│ │ throw UploadException( │ │
│ │ "S3 업로드 실패", e │ │
│ │ ) │ │
│ │ } │ │
│ │ │ │
│ │ // Step 2: DB에 메타데이터 저장 │ │
│ │ try { │ │
│ │ return fileRepository.save( │ │
│ │ FileMetadata( │ │
│ │ name = file.originalFilename, │ │
│ │ s3Key = s3Key, │ │
│ │ size = file.size │ │
│ │ ) │ │
│ │ ) │ │
│ │ } catch (e: Exception) { │ │
│ │ // DB 실패! → S3 파일 삭제 (보상) │ │
│ │ s3Client.deleteObject(bucket, s3Key) │ │
│ │ throw UploadException( │ │
│ │ "DB 저장 실패, S3 파일 삭제 완료", e │ │
│ │ ) │ │
│ │ } │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 주의: S3 삭제(보상)도 실패할 수 있음! │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 개선된 보상 코드: │ │
│ │ │ │
│ │ } catch (e: Exception) { │ │
│ │ try { │ │
│ │ s3Client.deleteObject(bucket, s3Key) │ │
│ │ } catch (s3Error: Exception) { │ │
│ │ // 보상도 실패! → DLQ에 기록 │ │
│ │ deadLetterQueue.send( │ │
│ │ OrphanedFile(bucket, s3Key) │ │
│ │ ) │ │
│ │ logger.error("S3 보상 실패: $s3Key") │ │
│ │ } │ │
│ │ throw UploadException("업로드 실패", e) │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 전략: "덜 위험한 것 먼저" 원칙 │
│ ├── S3 업로드를 먼저 (보상=삭제, 비교적 쉬움) │
│ ├── DB 저장을 나중에 (보상=삭제, 더 쉬움) │
│ └── 실패 가능성이 높은 작업을 먼저 실행하는 것이 유리 │
│ │
└─────────────────────────────────────────────────────────────────┘
6.3 멱등성 보장 (Idempotency)
┌─────────────────────────────────────────────────────────────────┐
│ 멱등성 = 여러 번 해도 결과가 같음 │
│ │
│ 멱등성(Idempotency)이란? │
│ = "같은 작업을 1번 하든 100번 하든 결과가 동일한 성질" │
│ │
│ 일상 비유: │
│ ├── 멱등한 것: │
│ │ ├── 엘리베이터 버튼: 10번 눌러도 1번 누른 것과 같음 │
│ │ ├── 전등 스위치 ON: 이미 켜져있으면 아무 일 없음 │
│ │ └── 도어락 잠금: 이미 잠겨있어도 다시 잠그면 그대로 │
│ │ │
│ ├── 멱등하지 않은 것: │
│ │ ├── 은행 입금: 1만원 입금을 2번 하면 2만원이 됨! │
│ │ ├── 총 발사: 2번 쏘면 총알이 2발 나감! │
│ │ └── 이메일 발송: 2번 보내면 2통 도착! │
│ │ │
│ └── 보상 트랜잭션에서 멱등성이 중요한 이유: │
│ 네트워크 장애로 보상이 2번 호출될 수 있기 때문! │
│ │
│ 멱등성 없을 때의 참사: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ❌ 멱등성 없는 환불 │ │
│ │ │ │
│ │ 1차 환불 호출 → 성공 (10만원 환불) │ │
│ │ → 네트워크 타임아웃! 응답 못 받음 │ │
│ │ → "환불 안 된 줄 알고" 재호출 │ │
│ │ 2차 환불 호출 → 또 성공 (10만원 또 환불) │ │
│ │ │ │
│ │ 결과: 20만원 환불됨! (10만원 손해!) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 멱등성 구현 방법: 멱등키(Idempotency Key) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ✅ 멱등키 적용 │ │
│ │ │ │
│ │ // 보상 작업에 고유 ID(멱등키)를 부여 │ │
│ │ fun refund(paymentId: String, │ │
│ │ idempotencyKey: String) { │ │
│ │ │ │
│ │ // 이미 처리된 키인지 확인 │ │
│ │ if (processedKeys.contains(idempotencyKey)) { │ │
│ │ return // 이미 처리됨 → 무시! │ │
│ │ } │ │
│ │ │ │
│ │ // 환불 실행 │ │
│ │ pgApi.refund(paymentId) │ │
│ │ │ │
│ │ // 처리 완료 기록 │ │
│ │ processedKeys.add(idempotencyKey) │ │
│ │ } │ │
│ │ │ │
│ │ // 사용: │ │
│ │ refund("PAY-001", "REFUND-PAY-001-20260226") │ │
│ │ refund("PAY-001", "REFUND-PAY-001-20260226") // 무시! │ │
│ │ refund("PAY-001", "REFUND-PAY-001-20260226") // 무시! │ │
│ │ │ │
│ │ → 3번 호출해도 환불은 1번만 실행! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 멱등키 저장 방식: │
│ ├── DB 테이블에 저장 (가장 일반적) │
│ ├── Redis에 저장 (TTL 설정으로 자동 만료) │
│ └── 분산 락(Lock)과 함께 사용 │
│ │
└─────────────────────────────────────────────────────────────────┘
7. 보상이 불가능한 경우
7.1 보상 불가능한 작업들
┌─────────────────────────────────────────────────────────────────┐
│ 보상할 수 없는 작업이 존재한다 │
│ │
│ 보상이 불가능한 작업들: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 이메일/SMS 발송 │ │
│ │ ├── 이미 보낸 메시지는 회수 불가 │ │
│ │ └── 받은 사람의 기억을 지울 수 없음 │ │
│ │ │ │
│ │ 물리적 작업 │ │
│ │ ├── 택배가 이미 출발함 │ │
│ │ ├── 3D 프린터가 이미 출력 시작 │ │
│ │ └── 음식이 이미 조리됨 │ │
│ │ │ │
│ │ 시간 경과 효과 │ │
│ │ ├── "30분 무료 체험" → 이미 30분 지남 │ │
│ │ └── 시간을 되돌릴 수 없음 │ │
│ │ │ │
│ │ 외부 시스템 통지 │ │
│ │ ├── 신용정보 기관에 조회 기록 → 삭제 불가 │ │
│ │ └── 제3자 API에 전달된 정보 → 회수 불가 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 비유: "엎질러진 물은 다시 담을 수 없다" │
│ │
└─────────────────────────────────────────────────────────────────┘
7.2 대안: 보상 불가능한 작업 다루기
┌─────────────────────────────────────────────────────────────────┐
│ 보상 불가능한 작업의 대안들 │
│ │
│ 대안 1: 의미적 보상 (Semantic Compensation) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 원래 작업을 "물리적으로" 되돌리는 건 불가능 │ │
│ │ 하지만 "의미적으로" 보정하는 새 작업을 수행 │ │
│ │ │ │
│ │ 예시: │ │
│ │ ├── 주문 확인 이메일 발송 → 주문 취소 이메일 발송 │ │
│ │ ├── 결제 완료 SMS → 환불 안내 SMS │ │
│ │ └── 발송 완료 알림 → 반품 접수 안내 알림 │ │
│ │ │ │
│ │ 비유: 잘못된 기사 발행 → 정정 기사 발행 │ │
│ │ (원래 기사를 없앨 순 없지만, 바로잡을 순 있음) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 대안 2: 전략적 순서 배치 (가장 중요!) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ "보상 불가능한 작업을 가장 마지막에 배치" │ │
│ │ │ │
│ │ ❌ 나쁜 순서: │ │
│ │ ① 이메일 발송 (보상 불가!) │ │
│ │ ② 결제 처리 │ │
│ │ ③ 재고 차감 ← 여기서 실패하면 이메일 회수 불가! │ │
│ │ │ │
│ │ ✅ 좋은 순서: │ │
│ │ ① 재고 차감 (보상 가능: +1) │ │
│ │ ② 결제 처리 (보상 가능: 환불) │ │
│ │ ③ 이메일 발송 (보상 불가 → 마지막!) │ │
│ │ → 이메일 전에 다른 모든 것이 확정되었으므로 안전! │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 대안 3: 예약 후 확정 패턴 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 보상 불가능한 작업을 "예약"과 "확정"으로 분리 │ │
│ │ │ │
│ │ ① 이메일 "예약" (아직 안 보냄, 큐에 넣어둠) │ │
│ │ ② 결제 처리 │ │
│ │ ③ 재고 차감 │ │
│ │ ④ 모든 것 성공! │ │
│ │ ⑤ 이메일 "확정" (이제 진짜 보냄!) │ │
│ │ │ │
│ │ 중간에 실패하면? │ │
│ │ → 이메일 예약만 취소하면 됨 (아직 안 보냈으니!) │ │
│ │ │ │
│ │ 비유: 편지를 우체통에 넣기 전에 봉투에만 넣어두기 │ │
│ │ 모든 확인이 끝나면 그때 우체통에 넣기 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 핵심 원칙: │
│ "편지를 보내기 전에 모든 확인을 끝내자!" │
│ │
└─────────────────────────────────────────────────────────────────┘
8. 실전에서 자주 쓰이는 Saga 프레임워크/도구
┌─────────────────────────────────────────────────────────────────┐
│ Saga 구현을 도와주는 도구들 │
│ │
│ 프레임워크/도구란? │
│ ├── Saga 패턴을 직접 처음부터 코딩하면 복잡하고 버그 위험 │
│ ├── 검증된 도구를 사용하면 안전하고 빠르게 구현 가능 │
│ └── 비유: 집을 지을 때 벽돌부터 만들지 않고 기성품을 쓰는 것 │
│ │
│ 1. Axon Framework (Java/Kotlin) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ├── Saga + Event Sourcing + CQRS 지원 │ │
│ │ ├── 어노테이션 기반으로 Saga 정의 가능 │ │
│ │ ├── @SagaEventHandler로 이벤트 매핑 │ │
│ │ └── Spring Boot와 자연스러운 통합 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 2. Temporal.io │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ├── 워크플로우 오케스트레이션 플랫폼 │ │
│ │ ├── 자동 재시도, 타임아웃, 보상을 플랫폼이 관리 │ │
│ │ ├── Java, Go, TypeScript, Python 등 다양한 SDK │ │
│ │ └── Netflix, Uber, Stripe 등에서 사용 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 3. AWS Step Functions │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ├── AWS 서버리스 환경에서 Saga 구현 │ │
│ │ ├── 상태 기계(State Machine)로 흐름 정의 │ │
│ │ ├── 시각적 워크플로우 디자이너 제공 │ │
│ │ └── Lambda 함수와 연결하여 각 단계 실행 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 4. Kafka + 이벤트 기반 Choreography │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ├── Kafka 토픽으로 이벤트 발행/구독 │ │
│ │ ├── 프레임워크 없이 직접 구현하는 방식 │ │
│ │ ├── 자유도가 높지만 복잡도도 높음 │ │
│ │ └── Choreography Saga에 적합 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 5. MicroProfile LRA (Long Running Actions) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ├── Jakarta EE 표준 기반의 Saga 구현 │ │
│ │ ├── 어노테이션으로 참여자와 보상 정의 │ │
│ │ ├── @Compensate로 보상 메서드 지정 │ │
│ │ └── Quarkus, WildFly 등에서 지원 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 선택 가이드: │
│ ├── Spring + Kotlin → Axon Framework │
│ ├── 복잡한 워크플로우 → Temporal.io │
│ ├── AWS 환경 → Step Functions │
│ ├── 이벤트 기반 → Kafka 직접 구현 │
│ └── Jakarta EE → MicroProfile LRA │
│ │
└─────────────────────────────────────────────────────────────────┘
9. 실전 설계 체크리스트
┌─────────────────────────────────────────────────────────────────┐
│ Saga 설계 시 반드시 확인할 체크리스트 │
│ │
│ □ 1. 각 단계의 보상 트랜잭션이 정의되어 있는가? │
│ ├── 모든 정방향 작업에 대해 역방향 보상이 있어야 함 │
│ └── 보상 불가능한 작업은 식별하고 대안 마련 │
│ │
│ □ 2. 보상 트랜잭션에 멱등성이 보장되는가? │
│ ├── 같은 보상이 여러 번 실행되어도 안전한가? │
│ └── 멱등키를 사용하고 있는가? │
│ │
│ □ 3. 보상 실패에 대한 대비가 되어 있는가? │
│ ├── 재시도 로직 (Exponential Backoff) │
│ ├── Dead Letter Queue │
│ └── 수동 개입 알림 │
│ │
│ □ 4. 작업 순서가 최적화되어 있는가? │
│ ├── 실패 가능성 높은 것 → 먼저 │
│ ├── 보상 가능한 것 → 먼저 │
│ └── 보상 불가능한 것 → 마지막 │
│ │
│ □ 5. 중간 상태가 사용자에게 보이는가? │
│ ├── Saga 진행 중일 때 "처리 중" 상태 표시 │
│ ├── 최종 결과를 비동기로 알림 (이메일, 푸시 등) │
│ └── 타임아웃 처리 (너무 오래 걸리면 자동 취소?) │
│ │
│ □ 6. 모니터링과 추적이 가능한가? │
│ ├── 각 Saga 인스턴스의 상태를 추적할 수 있는가? │
│ ├── 어떤 단계에서 실패했는지 로그가 있는가? │
│ └── 보상 진행 상황을 모니터링할 수 있는가? │
│ │
└─────────────────────────────────────────────────────────────────┘
10. 정리
┌─────────────────────────────────────────────────────────────────┐
│ 전체 핵심 정리 │
│ │
│ 보상 트랜잭션이란? │
│ = "이미 완료된 작업을 되돌리는 역방향 작업" │
│ = DB ROLLBACK을 수동으로 구현하는 것 │
│ = 비유: "이미 보낸 편지를 회수할 수 없으니 정정 편지를 보냄" │
│ │
│ Saga 패턴이란? │
│ = "보상 트랜잭션을 체계적으로 관리하는 패턴" │
│ = 긴 트랜잭션을 작은 단계로 쪼개고, 실패 시 역순 보상 │
│ = 비유: 여행 계획 - 각 예약의 "취소 방법"이 미리 정해져 있음 │
│ │
│ 왜 필요한가? │
│ = 분산 시스템(마이크로서비스)에서 자동 ROLLBACK이 불가능 │
│ = 여러 DB, 외부 API, 메시지 큐 등을 하나로 묶을 수 없기 때문 │
│ │
│ 두 가지 방식: │
│ ├── Choreography (안무) = 이벤트 기반, 자율적, 느슨한 결합 │
│ └── Orchestration (지휘) = 중앙 조정자, 명확한 흐름, 디버깅 │
│ │
│ 핵심 원칙 5가지: │
│ ├── ① 멱등성: 같은 보상을 여러 번 해도 결과가 동일 │
│ ├── ② 역순 보상: 실패 시 완료된 단계를 역순으로 되돌림 │
│ ├── ③ 보상 불가 → 마지막: 이메일 등은 가장 마지막에 실행 │
│ ├── ④ 재시도: 보상 실패 시 자동 재시도 + DLQ │
│ └── ⑤ 감사 추적: DELETE가 아닌 상태 변경으로 이력 보존 │
│ │
│ 시대별 흐름: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 모놀리스 → @Transactional 하나로 충분 │ │
│ │ 2PC 시도 → 느리고 확장 안 됨 (실패) │ │
│ │ 마이크로서비스 → Saga + 보상 트랜잭션이 표준! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 실무 팁: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 1. 단순한 흐름(2-3단계) → Choreography │ │
│ │ 2. 복잡한 흐름(4단계+) → Orchestration │ │
│ │ 3. 모든 단계에 보상을 미리 정의 │ │
│ │ 4. 멱등키로 중복 실행 방지 │ │
│ │ 5. 보상 실패 대비: 재시도 + DLQ + 수동 개입 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
관련 키워드
보상 트랜잭션, Compensating Transaction, Saga 패턴, 분산 트랜잭션, 2PC, Two-Phase Commit, Eventual Consistency, 최종 일관성, Orchestration, Choreography, 롤백, 멱등성, Idempotency, Dead Letter Queue, 마이크로서비스, ACID