데이터베이스 6편 - 분산 트랜잭션, 사라지지 않은 합의 비용
Published: June 1, 2026
1편에서 영속성은 디스크 I/O라는 비용을 요구했다. 2편에서 인덱스는 읽기를 위해 쓰기를 팔았다. 3편에서 트랜잭션은 정합성과 동시성 사이에서 타협했다. 4편에서 시스템은 중앙 통제를 해체하고 책임을 나눴다. 5편에서 정확성의 일부를 내려놓고 속도와 확장성을 얻었다.
그러나 사용자가 결제 버튼을 누르는 순간, 이 다섯 편의 모든 트레이드오프가 한꺼번에 충돌한다. 디스크는 데이터를 영구히 남겨야 하고, 인덱스는 동시에 갱신되어야 하며, 재고는 수많은 동시 요청 속에서도 무너지지 않아야 한다. 문제는 이 작업들이 더 이상 하나의 서버 안에 존재하지 않는다는 점이다. 4편에서 해체했고 5편에서 느슨하게 풀었다. 그런데 결제만큼은 “대충 맞으면 된다”가 통하지 않는다. 흩어진 시스템이 다시 동시에 같은 결과를 보장해야 할 때, 이미 지불했다고 생각한 비용이 되돌아온다.
Shopify의 결제 인프라 엔지니어는 말했다. “백만 분의 일 확률로 발생하는 네트워크 장애가 Shopify 규모에서는 매일 여러 번 터진다.” 결제 API가 타임아웃되면 클라이언트는 재시도하고, 보호 장치가 없으면 카드가 이중 청구된다. 이중 결제는 고객 신뢰를 무너뜨리고, 미처 발견하지 못한 중복 청구는 차지백으로 돌아온다.
단일 DB 환경에서는 모든 상태를 파악할 수 있었다. 하지만 서비스가 분산된 이후, 전체 처리 결과를 동시에 알 수 없게 됐다. 완벽한 합의를 유지하는 비용이 생각보다 훨씬 크다는 사실을 업계도 깨닫기 시작했다.
이번 편은 그 이후 등장한 선택들을 다룬다. 완벽한 합의를 강제할 때 치르는 대가, 합의를 느슨하게 풀었을 때의 위험, 그리고 아예 합의 시점 자체를 미루는 선택이다.
Reference: Shopify: 10 Tips for Building Resilient Payment Systems
버튼 하나, 트랜잭션 네 개
섹션 제목: “버튼 하나, 트랜잭션 네 개”사용자에게 결제는 버튼 한 번이다. 하지만 시스템 입장에서는 서로 다른 서버에서 동시에 움직이는 네 개의 독립된 트랜잭션이다.
[ 사용자 체감 ]
결제 버튼 클릭 ──────▶ 주문 완료
하나의 행동, 하나의 결과
---
[ 인프라 현실 ]
결제 버튼 클릭 ├──▶ Payment Service (결제 승인) ── DB A ├──▶ Order Service (주문 생성) ── DB B ├──▶ Inventory Service (재고 차감) ── DB C └──▶ Coupon Service (쿠폰 소멸) ── DB D
4개의 서비스, 4개의 DB, 4개의 독립된 트랜잭션각 서비스는 자기 데이터베이스만 책임진다. Payment Service는 결제를 승인하고, Order Service는 주문을 생성하며, Inventory Service는 재고를 차감하고, Coupon Service는 쿠폰 상태를 변경한다. 중요한 건 이 작업들이 더 이상 하나의 트랜잭션 안에 존재하지 않는다는 점이다.
비즈니스적으로는 하나의 거래다. 결제만 되고 주문이 누락되거나, 재고만 차감되고 결제가 실패하거나, 쿠폰만 소멸하는 상태는 허용되지 않는다. 하지만 인프라 구조상 네 개의 COMMIT은 서로 다른 서버에서 독립적으로 일어난다.
서비스가 분리된 지금, “전부 성공하거나 전부 실패한다”는 과거의 약속을 다시 지키려면 별도의 합의 비용을 지불해야 한다. 문제는 합의 비용 자체를 제거할 수 없다는 점이다. 시스템마다 다른 것은 그 비용을 어디에서, 어떤 형태로 지불할 것인가뿐이다.
한 대 안에서 가능했던 관리
섹션 제목: “한 대 안에서 가능했던 관리”단일 DB 시절에는 모든 것이 한곳에 있었다. 하나의 트랜잭션 로그, 하나의 락 매니저, 하나의 장애 상태만 존재했다. BEGIN과 COMMIT 사이에 들어간 작업은 전부 성공하거나 아예 취소됐다. 3편에서 다뤘던 ACID는 모든 상태가 하나의 프로세스 안에 존재할 때 강력하게 동작했다. 정합성이 깨질 틈도, 중간 상태가 노출될 여지도 없었다. 모든 판단을 내리는 주체가 하나뿐이었기 때문에 가능한 일이었다.
네트워크 너머에서 증명할 수 없는 것
섹션 제목: “네트워크 너머에서 증명할 수 없는 것”하지만 분산 시스템으로 넘어오는 순간 이 상식이 깨진다. DB가 여러 대로 쪼개지면 트랜잭션 로그도, 락 매니저도, 장애 상태도 전부 제각각이 된다.
가장 큰 문제는 시야다. 하나의 트랜잭션이 전체 거래를 책임지던 환경에선 같은 프로세스 안에서 일이 벌어지므로 작업의 실패 여부를 즉시 알 수 있었다. 반면 네트워크 너머의 다른 서버가 성공했는지, 실패했는지, 혹은 처리 중인지 지금은 알 방법이 없다. 응답이 없다고 실패를 단정할 수도 없다. 처리는 성공했으나 응답만 유실됐을 가능성이 있기 때문이다.
분산 시스템의 본질적인 문제는 계산이 아니라 확신할 수 없다는 점이다. 실패와 지연을 구분할 수 없고, 상대 서버의 생사조차 증명하기 어렵다. 불완전한 정보 속에서 판단을 내려야 하는 것, 이것이 분산의 현실이다.
성공도 실패도 아닌 상태
섹션 제목: “성공도 실패도 아닌 상태”단일 서버에서는 전체 성공 혹은 전체 실패뿐이었다. 그러나 분산 환경에서는 그 사이에 회색지대가 생긴다.
Payment Service는 카드 승인을 마쳤고, Order Service는 주문을 생성했다. 하지만 Inventory Service는 타임아웃으로 응답이 없고, Coupon Service는 서버가 다운되었다. 성공도 실패도 아닌 상태가 각 서비스에 제각각 존재하게 된다.
이 상황에서 시스템은 결정을 내려야 한다. 결제를 취소할지, 재고 차감을 기다릴지, 아니면 쿠폰 서버가 복구될 때까지 전체를 멈출지 선택해야 한다. 분산 시스템에서 진짜 무서운 것은 실패가 아니라, 실패 여부를 알 수 없는 상태다. “전부 성공하거나 전부 실패한다”는 원칙은 간결하지만, 네트워크는 이 원칙을 온전히 허용하지 않는다.
안전과 맞바꾼 가용성
섹션 제목: “안전과 맞바꾼 가용성”계약서에 다섯 명의 서명이 필요한 거래가 있다. 한 명이라도 빠지면 계약은 성립하지 않는다. 전원이 같은 테이블에 앉을 때까지 아무도 펜을 들 수 없다. 느리지만, 한 글자라도 틀리면 수십억이 날아가는 거래에서는 이 방식 외에 선택지가 없다.
도메인 특성에 따른 절대적 합의
섹션 제목: “도메인 특성에 따른 절대적 합의”분산 환경에서 부분 실패가 불가피하다면, 왜 굳이 완벽한 합의를 고집하는 걸까?
금융처럼 타협의 여지가 없는 도메인이 있기 때문이다. 은행 간 송금, 카드 결제 승인, 증권 거래가 그렇다. 이런 비즈니스에서는 처리가 느린 것은 용서받아도 “거의 다 성공함”, “대부분 일치함”, “나중에 정산 예정” 같은 모호함은 허용되지 않는다. 시스템이 멈추더라도 원자성만큼은 사수해야 하기 때문이다.
2PC (Two-Phase Commit) - 흩어진 노드 위에 다시 세운 중앙 승인
섹션 제목: “2PC (Two-Phase Commit) - 흩어진 노드 위에 다시 세운 중앙 승인”모든 상태를 한 시스템이 볼 수 있던 때는 하나의 트랜잭션 매니저만 있으면 됐다. 하지만 서비스가 분리되면서 더 이상 누구도 전체 거래 결과를 단독으로 파악할 수 없게 됐다.
2PC는 흩어진 노드 위에 중앙 승인 체계를 다시 복원하려는 시도다. 지연을 감수하더라도 모든 참여 노드가 완벽하게 동일한 결론에 도달하도록 강제한다.
[ Phase 1: Prepare ] ┌──▶ Node ACoordinator ── "Can Commit?" ───┼──▶ Node B └──▶ Node C
Node A ── "READY" ──┐Node B ── "READY" ──┼──▶ CoordinatorNode C ── "READY" ──┘
(모든 노드는 commit 직전 상태로 대기)(락 유지 / 변경사항 미확정)
---
[ Phase 2: Decision ]
모든 노드 READYCoordinator ── "COMMIT" ─────────▶ 전체 노드
하나라도 실패Coordinator ── "ROLLBACK" ───────▶ 전체 노드Coordinator(조율자)가 모든 참여 노드에 준비 요청을 보내면, 각 노드는 커밋할 수 있는 상태인지 응답한다. 모든 노드가 OK를 보내야만 전체 커밋이 실행된다. 단 하나라도 실패 신호를 보내면 전체가 롤백된다.
안전의 대가
섹션 제목: “안전의 대가”문제는 이 합의 과정 때문에 전체 시스템 속도가 가장 느린 서버에 맞춰진다는 것이다.
모든 참여 노드는 Coordinator의 최종 판단이 내려질 때까지 멈춰서 기다려야 한다. 대기하는 동안 각 노드는 해당 데이터의 락(Lock)을 계속 잡고 있어야 한다. 다른 요청이 같은 데이터에 접근하려 해도 락이 풀릴 때까지 줄을 서야 한다. 노드가 많아질수록 대기 시간은 가장 느린 참여자에 맞춰 늘어난다.
4편에서 시스템은 중앙 통제를 해체하고 각 노드에 독립적 결정권을 넘겼다. 그러나 2PC는 그 독립성을 다시 거둬들인다. 분산 시스템을 구축해 두고도, 강력한 중앙 조율자가 다시 모든 판단을 쥐는 구조다.
4편: 중앙 통제 해체, 독립적 결정권
Coordinator 장애
섹션 제목: “Coordinator 장애”여기서 2PC의 아킬레스건이 드러난다. Coordinator 자체의 장애다.
Phase 1에서 모든 노드가 OK를 보낸 뒤, 조율자가 Phase 2 결정을 내리기 전에 다운되는 상황이다. 각 노드는 이미 커밋 준비를 마치고 락을 잡은 상태지만 최종 명령은 오지 않는다.
커밋해야 할지, 롤백해야 할지, 더 기다려야 할지 노드 스스로 판단할 수 없다. 다른 노드가 어떤 응답을 보냈는지도 모른다. 이미 락은 잡혀 있고 모든 노드가 멈춘다. 어느 상태를 기준으로 시스템을 확정해야 하는지 알 방법이 없다. 시스템 전체가 얼어붙고, 해당 데이터에 대한 모든 후속 요청이 막힌다.
2PC는 가장 강한 합의를 보장하는 대신, 가장 비싼 비용을 청구하는 방식이다.
3편: ACID, 락, 격리 수준
안전과 맞바꾼 격리성
섹션 제목: “안전과 맞바꾼 격리성”반대로, 모든 부서장의 결재를 받아야 출고되는 회사가 있다고 해보자. 부서가 세 개일 때는 문제없었다. 열 개가 되자 결재 대기 시간이 실제 생산 시간을 넘어서기 시작했다.
결국 회사는 방식을 바꿨다. 일단 출고하고, 문제가 생기면 사후에 회수한다.
Saga 패턴 - 글로벌 승인을 포기한 대가
섹션 제목: “Saga 패턴 - 글로벌 승인을 포기한 대가”2PC가 “모두 동의할 때까지 아무도 움직이지 마라”였다면, Saga는 정반대의 발상에서 출발한다. 전체 시스템을 감싸는 하나의 거대한 트랜잭션을 아예 포기하는 것이다.
대신 그 덩어리를 쪼갠다. 각 서비스 내부에서 완결되는 작은 로컬 트랜잭션들의 연쇄로 전체 거래를 풀어낸다.
[ Saga — 로컬 트랜잭션의 연쇄 ]
Order Service ── 주문 생성 ── COMMIT (DB B) │ ▼Payment Service ── 결제 승인 ── COMMIT (DB A) │ ▼Inventory Service ── 재고 차감 ── COMMIT (DB C) │ ▼Coupon Service ── 쿠폰 소멸 ── COMMIT (DB D)
각 단계는 자기 DB에서만 COMMIT. 글로벌 락 없음.각 서비스는 자기 영역의 DB만 책임지고 커밋한다. 2PC처럼 전체 노드의 응답을 기다리지 않는다. 앞 단계가 끝나면 다음 단계로 넘어갈 뿐이다. 글로벌 트랜잭션이 아니라, 로컬 수준의 ACID만 철저히 유지하는 방식이다.
실패하면 어떻게 하는가
섹션 제목: “실패하면 어떻게 하는가”순조롭게 진행되다 마지막 단계에서 실패가 터지면 어떻게 될까. 쿠폰 적용은 실패했는데 앞선 세 서비스는 이미 각자 DB 커밋을 끝낸 상태가 된다.
글로벌 트랜잭션이 없으니 ROLLBACK 한 번으로 되돌릴 수 없다. 대신 이미 커밋된 상태를 새로운 트랜잭션으로 하나씩 되돌려야 하는데, 이를 **보상 트랜잭션(Compensation Transaction)**이라 한다.
[ Saga — 보상 트랜잭션 ]
쿠폰 적용 실패 감지 │ ▼Inventory Service ── 재고 복구 ── COMMIT (DB C) │ ▼Payment Service ── 결제 취소 ── COMMIT (DB A) │ ▼Order Service ── 주문 무효화 ── COMMIT (DB B)
시스템 레벨 롤백이 아니라, 비즈니스 로직으로 구현하는 사후 정산.재고 복구, 결제 취소, 주문 무효화가 각각 별도의 로컬 트랜잭션으로 실행된다. DB가 자동으로 되돌려주지 않으므로 개발자가 취소 로직을 직접 구현해야 한다. 이는 데이터베이스 레벨의 ROLLBACK이 아니라 기존 장부의 기록을 없던 일로 하려고 ‘마이너스(-)’ 처리를 하는 것과 비슷하다. 트랜잭션 매니저가 담당하던 복구 책임이 애플리케이션 영역으로 넘어온 셈이다.
확장의 대가
섹션 제목: “확장의 대가”Saga의 장점은 명확하다. 서비스 간 결합도가 낮아지고 글로벌 락이 사라져 처리량과 확장성이 높아진다. 일부 서비스가 마비되어도 전체 시스템은 멈추지 않는다.
하지만 대가도 만만치 않다.
2PC에서는 커밋 전까지 중간 상태가 노출되지 않았지만, Saga는 각 단계가 즉시 커밋되므로 미완료 상태가 다른 서비스에 그대로 노출된다. 사용자가 주문 내역을 확인했을 때 결제는 ‘처리 중’이고, 재고는 빠졌는데 쿠폰은 살아있는 상황이 생긴다. 고객센터에 “결제는 됐는데 주문이 안 보인다”는 문의가 들어오는 이유다. 시스템 하나가 트랜잭션 전체를 처리하던 시절에는 숨겨져 있던 중간 상태가, 분산 환경이 되면서 고객에게 그대로 노출되기 시작했다.
거기에 보상 트랜잭션의 구현 비용이 추가된다. 정상 흐름 하나를 만드는 것보다, 실패 시 되돌리는 흐름을 만드는 것이 훨씬 복잡하다. 모든 단계마다 “이게 실패하면 앞의 것들을 어떻게 취소할 것인가”를 설계해야 하기 때문이다.
격리성을 일부 양보하는 대신, 시스템 가용성과 확장성을 확보한 트레이드오프다.
3편: ACID, 롤백
4편: 서비스 독립성
2PC vs Saga
섹션 제목: “2PC vs Saga”두 전략은 하나의 문제를 해결하는 정반대의 접근법이다.
2PC
Node A ──┐Node B ──┼── 전원 대기 ──▶ Coordinator 판단 ──▶ 전체 COMMIT or 전체 ROLLBACKNode C ──┘
강한 일관성. 높은 조율 비용. 조율자가 죽으면 전체 정지.분산된 결정권을 다시 하나의 승인 체계로 모은다. 데이터 일관성을 얻는 대가로 가용성과 속도를 포기한다.
Saga
Step 1 ── COMMIT ──▶ Step 2 ── COMMIT ──▶ Step 3 ── COMMIT ──▶ ... │실패 시 │ ◀── 보상 3 ◀── 보상 2 ◀── 보상 1
각 단계 독립 커밋. 중간 상태 노출. 실패 시 역순 보상.각 서비스의 독립성을 지키는 대가로 격리성과 복구의 단순함을 양보한다.
어느 쪽이 우월하다는 정답은 없다. 비즈니스가 어떤 형태의 실패를 감당할 수 있는지, 어디까지 비용을 감수할 수 있는지의 문제다.
실시간 합의를 포기한 구조
섹션 제목: “실시간 합의를 포기한 구조”부서가 세 개일 때는 유선 전화로 충분했다. 기획이 영업에 전화하고, 영업이 확인하면 물류에 전화하고, 물류가 확인하면 다음으로 넘어갔다.
부서가 열다섯 개가 되자 전화 한 통이 끝나기 전에 다음 전화가 밀리기 시작했다. 결국 회사는 전화를 걸지 않기로 했다. 공지 게시판에 올리고, 각 부서가 자기 타이밍에 확인하는 방식으로 바꿨다.
동기 사슬이 만든 병목
섹션 제목: “동기 사슬이 만든 병목”2PC는 전원이 합의할 때까지 멈추고, Saga는 각자 커밋하되 실패 시 역순으로 되돌린다. 하지만 둘 다 여전히 서비스 간 호출이 동기적으로 엮여 있다면, 한 서비스의 지연이 전체 체인을 잡아먹는다.
여기서 한 단계 더 나아간 대안이 비동기 이벤트 기반 구조다. “모두가 같은 순간에, 같은 정답으로 합의할 필요는 없다.”는 발상이다.
이벤트 브로커가 등장한 배경
섹션 제목: “이벤트 브로커가 등장한 배경”시스템이 확장되면서 새로운 조율 비용이 수면 위로 드러났다.
2PC를 포기했음에도 서비스 간 호출이 여전히 동기식으로 묶여 있었기 때문이다. 매 단계마다 다음 단계의 응답을 받아야만 작업을 이어갈 수 있었다. 구조만 분산형일 뿐, 실시간 합의를 강제하는 모순이 그대로 남아 있었던 셈이다.
규모가 커질수록 이 대기 시간 자체가 새로운 병목으로 드러나기 시작했다. 그 결과 일부 시스템은 서비스 간 직접 호출을 줄이고, Kafka 같은 이벤트 브로커를 중심으로 비동기 메시징 구조를 도입하기 시작했다.
[ 동기 호출 ]
Order ──▶ Payment ──▶ Inventory ──▶ Coupon │ 타임아웃 ──▶ 전체 체인 마비
---
[ 이벤트 기반 ]
Order ──▶ "주문 생성됨" ──▶ ( 이벤트 브로커 ) ──▶ Payment (자기 속도로 처리) ──▶ Inventory (자기 속도로 처리) ──▶ Coupon (자기 속도로 처리)각 서비스는 상대방의 작업 완료를 기다리지 않는다. 할 일을 마친 뒤 이벤트를 발행하면 끝이다. 결제, 재고, 쿠폰 서비스는 각자 속도에 맞춰 이벤트를 소비하고 처리한다.
시간차 합의라는 대가
섹션 제목: “시간차 합의라는 대가”동기 방식에서는 사슬처럼 엮인 서비스 중 하나만 느려져도 도미노처럼 전체 요청이 마비된다. 4편에서 다룬 위장된 중앙집권이 일어나는 것이다. 겉으로는 분산 구조지만 호출 체인이 동기식으로 묶여 있다면 가장 느린 서비스가 전체 속도를 결정한다.
비동기 이벤트 방식은 이 사슬을 끊는다. 주문이 들어오면 즉시 ‘주문 생성’ 이벤트만 발행하고 응답을 반환한다. 나머지 서비스는 각자의 타이밍에 이벤트를 소비할 뿐이다.
물론 대가도 있다. 특정 시점을 기준으로 보면 데이터가 일시적으로 어긋나는 구간이 반드시 존재한다. 하지만 일부 데이터가 잠시 불일치하더라도 전체 서비스가 마비되는 사태는 막을 수 있다. 순간의 동시 합의를 포기하는 대신 시간차 합의를 수용하는 전략이다.
4편: 위장된 중앙집권, 독립적 결정권, 분산 구조
5편: Eventual Consistency
DB는 기록했지만 사라진 메시지
섹션 제목: “DB는 기록했지만 사라진 메시지”지점에서 계약이 체결되면 두 가지 일이 동시에 일어나야 한다. 원본을 장부에 기록하는 것, 그리고 본사에 통보하는 것. 문제는 장부는 썼는데 통보 팩스가 안 나가는 경우다.
반대로 팩스는 나갔는데 장부 기록이 누락되면, 본사는 존재하지 않는 계약을 처리하기 시작한다. 해결책은 간단했다. 장부에 기록할 때 통보 사본도 같은 바인더에 끼운다. 별도의 담당자가 그 바인더를 정기적으로 확인하고 본사에 전달한다.
두 시스템이 서로를 모르는 구조
섹션 제목: “두 시스템이 서로를 모르는 구조”이벤트 기반 구조가 동기 체인의 문제를 해결한 듯 보이지만, 여전히 까다로운 예외 상황이 남는다.
주문 DB에 데이터를 저장하는 행위와 Kafka에 이벤트를 발행하는 행위는 서로 다른 시스템을 대상으로 하는 별개의 작업이다.
[ DB 성공, 이벤트 실패 ]
Order Service ──▶ INSERT 주문 ──▶ COMMIT (DB) ✓ │ └──▶ 이벤트 발행 ──▶ Kafka ✗ (네트워크 오류)
결과: DB에만 주문 존재. 다른 서비스는 모름.
---
[ 이벤트 성공, DB 실패 ]
Order Service ──▶ 이벤트 발행 ──▶ Kafka ✓ │ └──▶ INSERT 주문 ──▶ COMMIT (DB) ✗ (장애)
결과: 존재하지 않는 유령 주문 이벤트가 퍼짐.데이터베이스와 Kafka는 서로의 성공 여부를 알 수 없다. DB는 자체 트랜잭션만, Kafka는 자체 로그만 책임진다. 각자 영역에서는 일관되지만 비즈니스 관점에서는 이 둘을 하나의 사건으로 처리해야 한다. DB 커밋은 성공했으나 네트워크 오류로 이벤트 발행에 실패하면, 내 DB에만 주문 데이터가 남고 다른 서비스는 인지하지 못한다. 반대로 이벤트는 발행됐는데 DB 커밋이 실패하면, 존재하지 않는 주문 이벤트가 전체 시스템으로 유포된다.
Outbox 패턴 - DB 트랜잭션에 묶인 이벤트의 탄생
섹션 제목: “Outbox 패턴 - DB 트랜잭션에 묶인 이벤트의 탄생”이 격차를 메우기 위해 고안된 방법이 Outbox 패턴이다. 목적은 명확하다. DB 저장과 이벤트 생성을 하나의 트랜잭션으로 묶는 것이다.
[ Outbox 패턴 ]
Order Service│├── INSERT 주문 데이터 ──┐│ ├──▶ 같은 DB, 같은 트랜잭션 ──▶ COMMIT└── INSERT Outbox 테이블 ──┘│▼별도 프로세스가 Outbox를 감시│▼Kafka로 이벤트 전송 대행주문 데이터를 저장할 때 동일한 DB의 Outbox 테이블에 발행할 이벤트 내용을 함께 담아 커밋한다. 같은 DB를 사용하므로 이 로컬 트랜잭션은 원자성이 보장된다. 이후 별도 프로세스가 Outbox 테이블을 주기적으로 읽어 Kafka 전송을 대행한다.
DB 커밋 성공과 이벤트 생성이 물리적으로 동기화되는 구조다.
3편: 원자성
즉시 전달을 포기하고 얻은 보장
섹션 제목: “즉시 전달을 포기하고 얻은 보장”Outbox 패턴은 ‘즉각적인 이벤트 전달’을 포기하는 대신 ‘DB에 기록된 사실은 반드시 전파된다’는 보장을 선택한다. 릴레이 프로세스가 데이터를 읽어갈 때 발생하는 미세한 지연을 감수하는 대가로, 최소한 시스템 내 사건의 순서가 뒤바뀌지 않도록 보장한다.
네트워크 시리즈 결제 시나리오에서 다뤘던 멱등성 키가 “중복 요청도 한 번만 처리한다”는 약속이라면, Outbox는 “기록된 데이터는 반드시 이벤트로 발행한다”는 약속이다. 멱등성 키와 Outbox 모두 분산 환경에서 끊어진 신뢰를 복구하기 위한 안전장치다. 트랜잭션 매니저가 암묵적으로 보장하던 기능을 분산 환경에서는 시스템 전반에 걸쳐 명시적으로 다시 구현해야 하며, 여기에는 명확한 비용이 따른다.
Reference: Stripe: Designing Robust APIs with Idempotency
네트워크 5편: 멱등성 키, 결제 재시도
분산 트랜잭션의 딜레마
섹션 제목: “분산 트랜잭션의 딜레마”이번 편에서 다룬 모든 전략은 같은 비용을 서로 다른 형태로 지불했을 뿐이다.
2PC는 완벽한 합의를 위해 전원을 멈춰 세운다. 안전하지만 조율자가 다운되면 시스템 전체가 마비되는 리스크가 있다. Saga는 각 서비스에 독립성을 주는 대신 중간 상태 노출과 보상 로직이라는 사후 복잡성을 감수한다. 이벤트 구조는 비동기 호출이지만 데이터가 일시적으로 불일치하는 시간차를 허용해야 한다. Outbox 패턴은 DB와 메시지 큐 사이의 격차를 메우는 대신 즉시성을 포기하고 릴레이 프로세스라는 인프라 비용을 추가로 지불한다.
어떤 전략도 합의 비용 자체를 없애지는 못한다. 결국 분산 트랜잭션 설계의 본질은 특정 기술의 선택이 아니다. 어떤 형태의 실패를 허용할지 먼저 결정하고, 그 리스크에 맞는 비용 구조를 선택하는 과정이다.
송금처럼 단 한 건의 불일치도 허용할 수 없다면 2PC의 대기 비용을 감수해야 한다. 반면 주문처럼 일시적 불일치를 사후 정산으로 해결할 수 있다면 Saga나 이벤트 기반 구조가 합리적인 대안이 된다.
분산 트랜잭션은 결국 같은 질문으로 수렴한다. 이 피할 수 없는 비용을 누가 떠안을 것인가.
1편의 디스크 I/O, 2편의 인덱스 쓰기 세금, 3편의 락 경합, 4편의 분산 조율, 5편의 정확성 타협 모두 같은 맥락이다.
The Bottom Line
섹션 제목: “The Bottom Line”시스템이 쪼개지고 분산 아키텍처로 설계될 때, 단 하나의 상태를 실시간으로 유지하는 일 자체가 거대한 조율 비용을 요구하게 된다.
2PC는 완전한 합의를 위해 가용성과 성능을 희생했다. Saga는 서비스 독립성을 위해 즉각적인 일관성과 격리성을 포기했다. Kafka와 이벤트 기반 구조는 처리량과 확장성을 위해 순간의 동시 합의를 내려놓았다. Outbox는 데이터 정합성을 위해 즉시성과 단순성을 추가 비용으로 교환했다.
답은 단 한 번도 같지 않았고, 단 한 번도 공짜가 아니었다.
1편의 디스크 I/O도, 2편의 인덱스 쓰기 세금도, 3편의 락 경합도, 4편의 분산 조율도, 5편의 정확성 타협도—우리가 마주한 모든 기술은 결국 트레이드오프의 형태를 바꾸는 수단에 불과했다.
시스템 설계란 완벽함을 만드는 기술이 아니다. 어떤 불완전함을 감당할 것인지 결정하는 기술이다.
다음 시리즈에서는 운영체제를 다룬다. 앞서 등장한 디스크 I/O, 락 경합, 컨텍스트 스위칭, 메모리 한계는 모두 운영체제가 제어하는 물리 자원의 제약이다. 데이터베이스가 “비용은 사라지지 않는다”는 현실을 전제로 설계됐다면, 운영체제는 그 비용을 숨기고, 미루고, 없는 척하는 기술이다. 운영체제 시리즈는 그 속임수의 구조에서 시작한다.