1. 우리는 ACID를 잃어버렸다
모놀리식(Monolithic) 아키텍처 시절은 개발자에게 천국이었습니다. 적어도 데이터 일관성(Consistency) 문제에 있어서는요. 데이터베이스가 하나였으니까요.
BEGIN TRANSACTION;
UPDATE inventory ...;
INSERT INTO payments ...;
COMMIT; -- 혹은 ROLLBACK;
이 모든 게 한 방(Atomic)에 처리되었습니다. 성공하면 다 같이 성공, 실패하면 깨끗하게 없던 일이 됩니다 (ACID).
하지만 마이크로서비스 아키텍처(MSA)에서는 상황이 다릅니다. '주문 서비스', '재고 서비스', '결제 서비스'가 각각 다른 DB를 씁니다.
- 주문 서비스: 주문 생성 (성공)
- 결제 서비스: 결제 승인 (성공)
- 재고 서비스: 재고 차감 (실패! 재고 부족)
이러면 어떻게 되나요? 이미 결제된 돈은 어떡하죠? 주문은 취소해야 하나요? 자동으로 롤백해주는 DB 기능은 이제 없습니다. 우리가 직접 이 난장판을 수습해야 합니다. 이것이 분산 트랜잭션(Distributed Transaction) 문제입니다.
2. 해결책 1 - Two-Phase Commit (2PC) - 너무 느려서 안 씀
가장 고전적인 방법은 "모두가 준비될 때까지 기다리는 것"입니다. 코디네이터(Coordinator)라는 관리자가 등장해서 두 단계로 진행합니다.
- 준비(Prepare) 단계: 코디네이터가 모든 서비스에게 묻습니다. "너네 커밋할 준비 됐어? 락(Lock) 걸고 대기해."
- 커밋(Commit) 단계: 모두가 "OK" 하면, 코디네이터가 "자, 커밋해!"라고 명령합니다. 한 명이라도 "NO" 하면 "전부 롤백해!"라고 합니다.
치명적인 단점:
- 성능 저하: 모든 서비스가 응답할 때까지 DB 락을 잡고 기다립니다. 전체 시스템 속도가 가장 느린 놈에게 맞춰집니다.
- 단일 실패 지점(SPOF): 코디네이터가 죽으면 모든 서비스가 락이 걸린 채로 멈춰버립니다.
- NoSQL 미지원: MongoDB 같은 NoSQL은 2PC를 지원하지 않는 경우가 많습니다. 그래서 현대적인 MSA에서는 잘 안 씁니다.
3. 해결책 2 - Saga 패턴 - 결국 보상(Compensation)이다
Saga 패턴은 "긴 트랜잭션을 여러 개의 짧은 로컬 트랜잭션으로 쪼개는 것"입니다. 각 서비스는 자기 할 일을 하고, 다음 서비스에게 "내 차례 끝났어"라고 이벤트를 던집니다. 만약 중간에 실패하면? 보상 트랜잭션(Compensating Transaction)을 실행해서 거꾸로 되돌립니다.
보상 트랜잭션이란? (Undo)
결제 승인의 보상 트랜잭션 ->결제 취소재고 차감의 보상 트랜잭션 ->재고 복구주문 생성의 보상 트랜잭션 ->주문 상태를 '취소'로 변경
Saga 패턴에는 두 가지 구현 방식이 있습니다.
3.1. 코레오그래피 (Choreography) - 자율적인 댄스
중앙 관리자 없이 서비스끼리 이벤트를 주고받습니다.
- 주문 서비스: 주문 생성하고
OrderCreated이벤트 발행. - 결제 서비스:
OrderCreated수신 -> 결제 시도 -> 성공 시PaymentProcessed발행. - 재고 서비스:
PaymentProcessed수신 -> 재고 차감 -> 실패! ->InventoryFailed발행. - 결제 서비스:
InventoryFailed수신 -> 결제 취소(보상 트랜잭션). - 주문 서비스:
InventoryFailed수신 -> 주문 상태 취소 변경.
- 장점: 구축하기 쉽고, 서비스 간 결합도가 낮습니다.
- 단점: 프로세스가 복잡해지면 "누가 누구 이벤트를 받는지" 파악하기 힘듭니다. (순환 의존성 위험)
3.2. 오케스트레이션 (Orchestration) - 지휘자가 있다
중앙에 오케스트레이터(Saga Manager)라는 놈이 있어서 지시를 내립니다. "결제 서비스야, 결제해." -> (성공) -> "재고 서비스야, 재고 줄여." -> (실패) -> "결제 서비스야, 아까 그거 취소해."
- 장점: 흐름을 한곳에서 관리하므로 로직 파악이 쉽고, 복잡한 롤백 처리에 유리합니다.
- 단점: 오케스트레이터가 너무 많은 로직을 가지면 "똑똑한 파이프, 멍청한 엔드포인트"가 되어 MSA의 취지가 퇴색될 수 있습니다.
4. 멱등성(Idempotency) - 재시도의 핵심
분산 시스템에서는 네트워크 오류가 일상입니다. 이벤트가 오다가 사라질 수도 있고, 두 번 올 수도 있습니다. 만약 결제 요청이 타임아웃 되어서 재시도를 했는데, 사실 첫 번째 요청이 성공했었다면? 두 번 결제되는 대참사가 일어납니다.
이걸 막으려면 모든 트랜잭션은 멱등성(Idempotent)을 가져야 합니다. "같은 요청을 여러 번 수행해도 결과는 처음 한 번 실행한 것과 같아야 한다." 주문 ID나 결제 ID 같은 고유 키(Unique Key)를 사용해서, "어? 이미 처리된 ID네?" 하고 무시할 수 있어야 합니다. Saga 패턴을 구현할 때 멱등성은 선택이 아니라 필수입니다.
5. 아웃박스 패턴 (Transactional Outbox)
"로컬 DB 업데이트"와 "이벤트 발행"이 원자적(Atomic)이어야 하는 문제가 있습니다. 주문은 생성했는데, 메시지 큐(Kafka)가 죽어서 이벤트를 못 보내면? 주문만 생기고 결제는 시작도 안 하는 불일치가 생깁니다.
이럴 때 쓰는 것이 아웃박스 패턴입니다.
- 이벤트를 바로 큐에 보내지 말고, 현재 DB의
Outbox테이블에 저장합니다. (주문 생성 + 이벤트 저장을 하나의 로컬 트랜잭션으로 묶음). - 별도의 프로세스(CDC, Polling)가
Outbox테이블을 읽어서 메시지 큐로 쏴줍니다. - 전송이 확인되면
Outbox에서 삭제합니다.
이렇게 하면 "적어도 한 번(At-least-once)" 전송을 보장할 수 있습니다.
6. TCC (Try-Confirm-Cancel) 패턴
Saga와 비슷하지만 조금 더 엄격한 패턴입니다. REST API를 설계할 때 유용합니다.
- Try: 예비 동작. (예: 결제 가승인, 좌석 가예약) - 실제로 확정되지 않음. 자원만 선점.
- Confirm: 확정 동작. (예: 결제 승인 확정, 좌석 예약 확정) - Try가 성공하면 실행.
- Cancel: 취소 동작. (예: 가승인 취소, 가예약 취소) - Try 했으나 Confirm 안 할 때 실행.
호텔 예약 같은 경우에 적합합니다. "방 일단 잡아놔(Try)" 해놓고 결제 안 하면 "풀어(Cancel)", 결제하면 "확정해(Confirm)".
7. 마무리 - 결과적 일관성 (Eventual Consistency)
분산 시스템에서 "실시간으로 완벽하게 똑같은 데이터"를 보장하는 것은 불가능에 가깝거나, 비용이 너무 듭니다 (CAP 정리). 그래서 우리는 결과적 일관성을 받아들여야 합니다. "지금 당장은 데이터가 안 맞을 수도 있지만, 잠시 기다리면(이벤트가 다 돌면) 결국에는 맞게 된다"는 개념입니다.
주문 버튼 눌렀는데 "주문 완료" 떴다가, 1분 뒤에 "재고 부족으로 취소되었습니다" 카톡이 오는 것. 이게 바로 결과적 일관성이 적용된 현실 예시입니다. 사용자 경험(UX)으로 기술적 한계를 극복하는 것이죠. MSA를 한다면 ACID에 대한 집착을 버려야 합니다.