- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 트랜잭션이 서비스를 넘어서는 순간
- 왜 서비스를 가로지르는 ACID가 어려운가
- 2단계 커밋(2PC) — 억지로 원자성 만들기
- Saga 패턴 — 원자성을 포기하고 보상으로 대체
- 코레오그래피 vs 오케스트레이션
- 이중 쓰기 문제 — 왜 아웃박스가 필요한가
- 아웃박스 패턴 — 하나의 로컬 트랜잭션으로 묶기
- 결과적 일관성 — 실무에서 뜻하는 것
- 실무 지침 정리
- 마치며
- 참고 자료
들어가며 — 트랜잭션이 서비스를 넘어서는 순간
한 데이터베이스 안에서 트랜잭션은 든든한 친구입니다. BEGIN으로 시작하고 COMMIT으로 끝내면, 그 안의 모든 변경이 전부 반영되거나 전부 취소됩니다. 그런데 시스템을 여러 서비스로 쪼개면 이 든든함이 흔들립니다. 주문 서비스, 결제 서비스, 재고 서비스가 각자 자기 데이터베이스를 가지면, "주문을 만들고, 결제를 처리하고, 재고를 줄이는" 하나의 논리적 작업이 세 개의 물리적으로 분리된 데이터베이스에 걸치게 됩니다.
이제 무서운 질문이 돌아옵니다. 결제는 성공했는데 재고 감소가 실패하면? 하나의 COMMIT으로 세 데이터베이스를 한꺼번에 확정할 방법이 없습니다. 이것이 분산 트랜잭션(distributed transaction) 문제이고, 마이크로서비스가 피할 수 없는 근본적 어려움입니다. 이 글은 두 가지 대표 접근인 2단계 커밋과 Saga 패턴을 대조하며, 그 사이에서 우리가 무엇을 잃고 무엇을 얻는지를 정리합니다.
이 개념들과 연결되는 메시징을 눈으로 보고 싶다면, 이 사이트의 메시지 큐 놀이터에서 비동기 메시지 흐름을 시각화해 볼 수 있습니다.
왜 서비스를 가로지르는 ACID가 어려운가
단일 데이터베이스의 트랜잭션이 강력한 이유는, 하나의 시스템이 모든 데이터를 통제하기 때문입니다. 하나의 트랜잭션 관리자가 잠금을 걸고, WAL에 기록하고, 원자적으로 커밋을 확정합니다. 모든 것이 한 지붕 아래 있으니 "전부 또는 전무"를 보장하기 쉽습니다.
여러 서비스로 나뉘면 이 전제가 깨집니다.
- 공유 트랜잭션 관리자가 없습니다. 각 서비스의 데이터베이스는 자기 트랜잭션만 압니다. 서비스 A의 커밋과 서비스 B의 커밋을 하나로 묶어 줄 상위 존재가 기본적으로는 없습니다.
- 네트워크가 끼어듭니다. 서비스 간 통신은 네트워크를 탑니다. 네트워크는 느리고, 메시지를 잃고, 지연시키고, 순서를 뒤바꾸며, 무엇보다 끊깁니다. "요청은 갔는데 응답이 안 왔다"가 성공인지 실패인지 보내는 쪽은 알 수 없습니다.
- 부분 실패가 일상입니다. 단일 프로세스에서는 "전체가 죽거나 전체가 산다"에 가깝지만, 분산에서는 "일부는 성공하고 일부는 실패한" 어중간한 상태가 흔합니다.
이 세 가지 때문에, 단일 DB에서 당연했던 원자성을 여러 서비스에 걸쳐 재현하는 것은 근본적으로 어렵습니다. 두 갈래의 해법이 있습니다. 하나는 "그래도 원자성을 억지로라도 구현하자"는 2단계 커밋이고, 다른 하나는 "원자성을 포기하고 대신 결과적 일관성으로 가자"는 Saga입니다.
2단계 커밋(2PC) — 억지로 원자성 만들기
**2단계 커밋(Two-Phase Commit, 2PC)**은 여러 참가자(participant)의 커밋을 하나로 묶으려는 고전적 프로토콜입니다. 중심에는 **코디네이터(coordinator)**가 있고, 이름 그대로 두 단계로 진행됩니다.
1단계 — 준비(prepare / voting). 코디네이터가 모든 참가자에게 "커밋할 준비가 됐는가?"라고 묻습니다. 각 참가자는 실제로 변경을 커밋하지는 않되, 커밋할 수 있도록 모든 준비를 마치고(잠금 확보, 로그 기록) "예(준비됨)" 또는 "아니오"로 투표합니다. "예"라고 답했다면, 그 참가자는 이후 코디네이터가 커밋을 명령하면 반드시 커밋할 수 있어야 합니다.
2단계 — 커밋 또는 중단(commit / abort). 모든 참가자가 "예"라고 답하면, 코디네이터는 전원에게 "커밋하라"고 명령합니다. 하나라도 "아니오"이거나 응답이 없으면, 전원에게 "중단하라"고 명령합니다.
1단계 (준비):
코디네이터 --"준비됐나?"--> 참가자들
참가자들 --"예/아니오"--> 코디네이터
2단계 (커밋/중단):
모두 "예"였다면:
코디네이터 --"커밋!"--> 참가자들 (전원 커밋)
하나라도 "아니오"/무응답이면:
코디네이터 --"중단!"--> 참가자들 (전원 롤백)
논리적으로는 깔끔합니다. 모두가 준비된 것을 확인한 뒤에야 커밋하므로, 원자성이 지켜지는 듯 보입니다. 하지만 2PC에는 심각한 약점이 있습니다.
블로킹(blocking) 문제. 2PC의 가장 큰 결함입니다. 참가자가 "예(준비됨)"라고 투표한 뒤, 코디네이터가 2단계 명령을 내리기 직전에 코디네이터가 죽으면, 참가자는 오도 가도 못하는 상태에 빠집니다. 커밋하라는 것도 중단하라는 것도 못 들었는데, 자기 마음대로 결정할 수도 없습니다("예"라고 약속했으니까요). 이 참가자는 코디네이터가 부활할 때까지 잠금을 쥔 채 무기한 기다립니다. 그 잠금에 걸린 다른 트랜잭션들도 함께 멈춥니다.
기타 실패 모드. 준비 단계는 통과했는데 커밋 명령이 일부 참가자에게만 도달하는 경우, 네트워크 분단으로 코디네이터와 일부 참가자가 갈라지는 경우 등, 부분 실패의 조합이 많습니다. 이를 다루기 위한 변형(3PC 등)이 있지만 완전한 해법은 아니며 복잡성만 커집니다.
그래서 2PC는 원자성을 주지만, 대가로 가용성과 성능을 희생합니다. 코디네이터가 단일 실패점이 되고, 준비-커밋 사이에 잠금을 오래 쥐어 처리량이 떨어집니다. 서비스가 많고 지연이 큰 마이크로서비스 환경에서 2PC가 잘 쓰이지 않는 이유입니다. (반대로, 잘 통제된 단일 데이터센터 내부나 XA 트랜잭션 같은 특정 상황에서는 여전히 쓰입니다.)
Saga 패턴 — 원자성을 포기하고 보상으로 대체
2PC가 "원자성을 지키려다 블로킹을 감수"한다면, Saga 패턴은 발상을 뒤집습니다. 원자성을 포기하는 대신, 각 단계를 개별 로컬 트랜잭션으로 커밋하고, 문제가 생기면 이미 한 일을 되돌리는 보상 트랜잭션으로 정리한다.
핵심 개념은 **보상 트랜잭션(compensating transaction)**입니다. 어떤 단계를 커밋한 뒤 나중 단계가 실패하면, 앞서 커밋한 것을 "의미적으로 취소"하는 별도의 트랜잭션을 실행합니다. 롤백이 "아직 커밋 안 한 것을 없던 일로" 하는 것이라면, 보상은 "이미 커밋해서 세상에 반영된 것을 되돌리는" 것입니다.
주문 예시로 보면 이렇습니다.
정상 흐름 (각 단계는 독립적으로 커밋됨):
1. 주문 생성 (커밋)
2. 결제 청구 (커밋)
3. 재고 감소 (커밋)
4. 배송 예약 (커밋)
3단계에서 재고 부족으로 실패하면 -> 보상을 역순으로:
2번 보상: 결제 환불
1번 보상: 주문 취소
여기서 중요한 성질 몇 가지를 짚어야 합니다.
- 보상은 원래 연산의 완벽한 역이 아닐 수 있습니다. "결제 청구"의 보상은 "환불"이지만, 환불은 청구가 없던 상태와 완전히 같지 않습니다(거래 내역이 남고, 수수료가 붙을 수도 있습니다). 보상은 물리적 되돌리기가 아니라 의미적(semantic) 되돌리기입니다.
- 중간 상태가 외부에 노출됩니다. Saga가 진행되는 동안, 다른 관찰자는 "결제는 됐지만 배송은 아직 없는" 중간 상태를 볼 수 있습니다. 2PC의 격리와 달리 Saga는 격리를 보장하지 않습니다. 그래서 애플리케이션이 이런 중간 상태를 견디도록 설계돼야 합니다.
- 보상 자체도 실패할 수 있습니다. 그래서 보상은 재시도 가능하고 멱등(idempotent)해야 하며, 최악의 경우 사람이 개입하는 경로도 필요합니다.
Saga는 이렇게 원자성과 격리를 내주는 대신, 블로킹 없는 높은 가용성과 서비스 간 느슨한 결합을 얻습니다. 이것이 마이크로서비스에서 Saga가 사실상 표준이 된 이유입니다.
코레오그래피 vs 오케스트레이션
Saga를 실제로 구현하는 방식은 크게 두 가지입니다. 누가 "다음 단계로 넘어가라"를 결정하느냐가 갈림길입니다.
코레오그래피(choreography) — 중앙 지휘자 없는 춤. 각 서비스가 자기 일을 마치면 이벤트를 발행하고, 다른 서비스가 그 이벤트를 구독해 자기 단계를 이어갑니다. 중앙 조정자가 없습니다. 마치 무용수들이 지휘자 없이 서로의 동작에 반응해 춤을 이어가는 것과 같습니다.
주문 서비스: "주문 생성됨" 이벤트 발행
|
v (구독)
결제 서비스: 결제 처리 -> "결제 완료" 이벤트 발행
|
v (구독)
재고 서비스: 재고 감소 -> "재고 확정" 이벤트 발행
|
v (구독)
배송 서비스: 배송 예약
장점은 서비스 간 결합이 느슨하고, 새 서비스를 이벤트 구독만으로 끼워 넣기 쉽다는 것입니다. 단점은 전체 흐름이 여러 서비스에 흩어져 한눈에 파악하기 어렵다는 것입니다. "지금 이 주문이 어느 단계에 있지?"를 알려면 여러 서비스를 뒤져야 하고, 이벤트가 순환하거나 의도치 않게 얽히면 디버깅이 까다롭습니다.
오케스트레이션(orchestration) — 지휘자가 있는 연주. 중앙의 **오케스트레이터(orchestrator)**가 전체 Saga를 지휘합니다. 오케스트레이터가 "이제 결제해", 응답을 받고 "이제 재고 줄여"처럼 각 단계를 순서대로 호출하고, 실패하면 보상 단계들을 지시합니다.
┌───────────────── 오케스트레이터 ─────────────────┐
│ 1. 결제 서비스에 "청구" 요청 -> 성공 │
│ 2. 재고 서비스에 "감소" 요청 -> 실패! │
│ 3. 보상: 결제 서비스에 "환불" 요청 │
│ 4. 보상: 주문 서비스에 "취소" 요청 │
└──────────────────────────────────────────────────┘
장점은 전체 흐름이 한곳에 모여 이해하고 추적하기 쉽다는 것입니다. Saga의 상태를 오케스트레이터가 들고 있으니 "지금 어느 단계"인지 명확합니다. 단점은 오케스트레이터가 로직의 중심이 되어 복잡해지고, 잘못하면 다시 단일 병목이나 단일 실패점이 될 수 있다는 것입니다.
둘 사이 선택은 규모와 복잡도에 달렸습니다. 단계가 적고 단순하면 코레오그래피가 가볍고, 단계가 많고 흐름이 복잡하며 가시성이 중요하면 오케스트레이션이 관리하기 좋습니다.
이중 쓰기 문제 — 왜 아웃박스가 필요한가
Saga든 이벤트 기반 아키텍처든, 실무에서 반드시 부딪히는 함정이 하나 있습니다. 이중 쓰기(dual write) 문제입니다.
상황은 이렇습니다. 서비스가 어떤 작업을 하면 두 가지를 해야 할 때가 많습니다. (1) 자기 데이터베이스를 갱신하고, (2) 그 사실을 알리는 이벤트/메시지를 발행합니다. 문제는 이 두 가지가 서로 다른 시스템(로컬 DB와 메시지 브로커)이라 하나의 트랜잭션으로 묶이지 않는다는 것입니다.
위험한 순서:
1. DB에 주문 저장 (커밋 성공)
2. 카프카에 "주문 생성됨" 발행 <- 여기서 프로세스가 죽으면?
결과: DB에는 주문이 있는데, 이벤트는 발행 안 됨.
하류 서비스는 이 주문을 영영 모른다. (불일치!)
순서를 바꿔도 마찬가지입니다. 이벤트를 먼저 발행하고 DB 저장이 실패하면, 세상에는 "생성된 주문" 이벤트가 떠도는데 실제 DB에는 주문이 없습니다. 어느 쪽을 먼저 하든, 그 사이에 죽으면 DB 상태와 발행된 이벤트가 어긋납니다.
이 문제의 표준 해법이 **아웃박스 패턴(outbox pattern)**입니다.
아웃박스 패턴 — 하나의 로컬 트랜잭션으로 묶기
아웃박스 패턴의 핵심 아이디어는 이렇습니다. 발행하려는 이벤트를, 비즈니스 데이터와 같은 데이터베이스의 "아웃박스" 테이블에 같은 트랜잭션으로 함께 저장한다. 그러면 "데이터 변경"과 "이벤트 기록"이 하나의 로컬 트랜잭션 안에 들어가므로, 둘은 원자적으로 함께 커밋되거나 함께 실패합니다. 이중 쓰기가 단일 쓰기로 바뀌는 것입니다.
하나의 로컬 트랜잭션:
BEGIN
INSERT INTO orders (...) -- 비즈니스 데이터
INSERT INTO outbox (event, ...) -- 발행할 이벤트
COMMIT -- 둘 다 커밋되거나 둘 다 롤백
이후 별도의 프로세스가:
아웃박스 테이블을 폴링(또는 DB 로그 추적)
-> 새 이벤트를 메시지 브로커로 발행
-> 발행 성공하면 아웃박스에서 처리 표시/삭제
이벤트를 실제 브로커로 내보내는 방법은 두 가지가 흔합니다. 하나는 **폴링 발행자(polling publisher)**로, 별도 프로세스가 아웃박스 테이블을 주기적으로 읽어 아직 안 보낸 이벤트를 발행합니다. 다른 하나는 **트랜잭션 로그 추적(transaction log tailing / CDC)**으로, 데이터베이스의 변경 로그(예: PostgreSQL의 WAL)를 읽어 아웃박스에 삽입된 행을 감지해 발행합니다(Debezium 같은 도구가 이 방식입니다).
여기서 중요한 점: 아웃박스는 이벤트가 "적어도 한 번(at-least-once)" 발행되는 것을 보장하지, "정확히 한 번"을 보장하지는 않습니다. 발행 후 "처리 표시"를 하기 전에 발행자가 죽으면, 같은 이벤트가 다시 발행될 수 있습니다. 그래서 이벤트를 받는 쪽(소비자)은 반드시 멱등해야 합니다. 같은 이벤트를 두 번 받아도 결과가 같도록 말이지요. 이 멱등성과 중복 제거가 분산 시스템 신뢰성의 핵심인데, 이는 다음 글의 주제이기도 합니다.
결과적 일관성 — 실무에서 뜻하는 것
2PC를 포기하고 Saga와 아웃박스로 가면, 우리는 강한 일관성 대신 **결과적 일관성(eventual consistency)**을 받아들이는 것입니다. 이 말의 뜻을 오해 없이 정리합시다.
결과적 일관성은 "언젠가는 모든 복제본과 서비스의 상태가 일치한다"는 보장입니다. 단, 그 사이에는 불일치가 관찰될 수 있습니다. 결제는 처리됐지만 주문 상태가 아직 "결제 완료"로 안 바뀐 짧은 창이 존재할 수 있습니다. Saga가 아직 진행 중이거나, 이벤트가 아직 전파 중이기 때문입니다.
이것이 실무에서 의미하는 바는 구체적입니다.
- UI와 API가 중간 상태를 견뎌야 합니다. "처리 중입니다"라는 상태를 사용자에게 보여 주는 것이 정직하고 안전합니다. 즉시 최종 상태를 약속하지 마세요.
- 읽은 직후 자기 쓰기가 안 보일 수 있습니다. 방금 만든 것을 바로 조회했는데 아직 없을 수 있습니다("read-your-writes"가 기본 보장되지 않음). 필요하면 그 부분만 강한 일관성을 따로 확보해야 합니다.
- 비즈니스가 중간 상태를 정의해야 합니다. "결제는 됐는데 재고 확보 실패" 같은 상황에서 무엇을 사용자에게 보이고 어떻게 보상할지는 기술이 아니라 비즈니스 결정입니다.
핵심은, 결과적 일관성이 "일관성 없음"이 아니라 **"지연된 일관성"**이라는 것입니다. 이 지연을 인정하고 그 창을 설계에 반영하는 것이 분산 트랜잭션을 다루는 실전 감각입니다.
실무 지침 정리
지금까지의 내용을 압축합니다.
가능하면 분산 트랜잭션 자체를 피하세요. 하나의 트랜잭션으로 묶여야 하는 데이터가 있다면, 그것들을 같은 서비스·같은 데이터베이스에 두는 경계 설계가 최선입니다. 서비스를 나누는 선은 트랜잭션 경계를 존중해야 합니다.
정말 서비스를 가로질러야 한다면 2PC보다 Saga를 우선 고려하세요. 2PC의 블로킹과 단일 실패점은 마이크로서비스의 가용성 목표와 충돌합니다. Saga의 결과적 일관성과 보상이 대개 더 현실적입니다.
흐름이 단순하면 코레오그래피, 복잡하고 가시성이 중요하면 오케스트레이션. 단계가 늘고 실패 처리가 복잡해질수록 중앙 오케스트레이터의 추적 가능성이 값집니다.
이벤트를 발행한다면 아웃박스 패턴을 기본으로 쓰세요. 이중 쓰기 불일치는 조용히 데이터를 어긋나게 만드는 대표적 버그입니다. 아웃박스로 "데이터 변경과 이벤트 기록"을 하나의 로컬 트랜잭션으로 묶으세요.
소비자를 멱등하게 만드세요. 아웃박스든 어떤 브로커든 "적어도 한 번"이 현실입니다. 중복 수신을 전제로 설계하지 않으면 반드시 중복 처리 버그가 납니다.
마치며
단일 데이터베이스의 트랜잭션은 하나의 시스템이 모든 것을 통제하기에 강력했습니다. 시스템을 여러 서비스로 나누는 순간 그 통제가 흩어지고, 네트워크와 부분 실패가 끼어들며, "전부 또는 전무"를 그대로 재현하기가 근본적으로 어려워집니다.
이에 대한 두 갈래 해법이 2PC와 Saga입니다. 2PC는 원자성을 지키려다 블로킹과 단일 실패점을 감수하고, Saga는 원자성을 포기하는 대신 보상 트랜잭션과 결과적 일관성으로 높은 가용성을 얻습니다. 그리고 이벤트 기반 세계의 이중 쓰기 함정은 아웃박스 패턴으로 다스립니다. 그 밑바탕에는 언제나 멱등성이라는 전제가 깔려 있습니다.
결국 분산 트랜잭션은 "완벽한 원자성이라는 이상"과 "가용한 시스템이라는 현실" 사이의 선택입니다. 대부분의 경우 우리는 약간의 지연된 일관성을 받아들이고, 그 창을 설계로 감싸는 쪽을 택합니다. 그 트레이드오프를 의식적으로 다룰 때, 분산 시스템의 트랜잭션은 두려움이 아니라 다룰 수 있는 공학이 됩니다.
참고 자료
- Chris Richardson, "Pattern: Saga": https://microservices.io/patterns/data/saga.html
- Chris Richardson, "Pattern: Transactional outbox": https://microservices.io/patterns/data/transactional-outbox.html
- Hector Garcia-Molina & Kenneth Salem, "Sagas" (1987): https://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf
- Martin Kleppmann, "Designing Data-Intensive Applications" (9장, Consistency and Consensus)
- Debezium, "Outbox Event Router": https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html