보상 트랜잭션과 Saga 패턴 - 분산 시스템에서 “되돌리기”
TL;DR
- 보상 트랜잭션과 Saga 패턴 - 분산 시스템에서 “되돌리기”의 핵심 개념을 빠르게 파악할 수 있다.
- 배경과 이유를 통해 왜 필요한지 맥락을 이해할 수 있다.
- 특징과 상세 내용을 통해 실무 적용 포인트를 확인할 수 있다.
1. 개념
보상 트랜잭션과 Saga 패턴 - 분산 시스템에서 “되돌리기”의 핵심 정의와 문제 공간을 간단히 정리한다.
2. 배경
이 주제가 등장한 기술적·조직적 배경과 기존 접근의 한계를 설명한다.
3. 이유
왜 지금 이 방식을 채택해야 하는지, 기대 효과와 트레이드오프를 함께 정리한다.
4. 특징
핵심 동작 방식, 장단점, 적용 시 주의점을 빠르게 훑을 수 있도록 요약한다.
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