[TIL] 분산 환경에서는 트랜잭션 원자성을 어떻게 보장할 수 있을까?(feat. 페이 시스템 설계 개선)

Loopy·2025년 9월 4일
0
post-thumbnail

분산 환경에서 발생하는 문제와 해결 방안을 학습하고, 이를 기존에 진행했었던 페이 프로젝트에도 적용해 설계를 고도화해보자.

모놀로식에서 분산 환경으로의 변화

모놀리식 환경에서는 단일 데이터베이스 내에서 로컬 트랜잭션을 통해 ACID 특성을 쉽게 보장할 수 있었다.

하지만 MSA로 전환되어 서버와 데이터베이스가 각각 분리되고, 하나의 비즈니스 로직이 여러 서비스와 DB에 걸쳐 수행되기 시작하면서 이야기가 달라진다. 이제는 더 이상 하나의 로컬 트랜잭션으로 전체 과정을 묶을 수 없게 되었고, 특정 서버에서 작업이 실패하더라도 다른 서버가 그 사실을 알 수 없어 원자성이 깨질 위험이 생긴다.

가령 상품 구매 서버, 재고 관리 서버, 결제 서버가 분리되어 있는 상황에서 상품구매(성공) -> 재고 관리(성공) -> 결제 서버(실패) 의 흐름이라면 결제 서버의 실패로 인해 상품과 재고 로직까지 모두 원상태로 복구를 해야 원자성이 보장될 것이다.

따라서 이러한 분산 환경에서는 여러 서비스 간 데이터 정합성을 보장하기 위한 글로벌 트랜잭션 관리 방식이 필요하다. 이번 글에서는 그중 대표적인 설계 패턴인 2PC(2-Phase Commit) 과 사가(Saga) 패턴을 중심으로, 각각의 동작 원리와 어떤 상황에서 적용하는 것이 적절한지를 살펴보고자 한다.

1. 2 Page Commit 방식

여러 노드가 하나의 트랜잭션을 동시에 처리해야 할 때, 모든 노드가 '성공'이라고 동의해야만 커밋이 되는 분산 트랜잭션 프로토콜이다. 트랜잭션 상태(커밋/롤백)를 조정하는 조정자와, 트랜잭션의 대상이 되는 참가자들로 구성이 된다.

  1. 투표 단계
  • 트랜잭션 참여자들에게 커밋 가능 여부 질의하면, 참여자들은 트랜잭션 열고 커밋 가능 여부를 조정자에게 응답한다.
  1. 커밋 단계
  • 조정자는 모든 참여자들에게 커밋 가능이라고 응답이 오면, 그때 실제 커밋 요청을 보내 트랜잭션을 종료시킨다. 만약 이때 단 하나라도 커밋 불가능 요청을 보낸 경우, 롤백 요청을 보내 트랜잭션을 실패로 종료시킨다.
Coordinator                                          Participant
                         QUERY TO COMMIT
                -------------------------------->
                         VOTE YES/NO             prepare*/abort*
                <-------------------------------
commit*/abort*           COMMIT/ROLLBACK
                -------------------------------->
                         ACKNOWLEDGEMENT          commit*/abort*
                <--------------------------------  
end

장점

2 Page Commit(2PC) 방식은 시간이 오래 걸리더라도 결과적으로 강하게 일관성을 보장할 수 있다. 사전 투표를 통해 커밋 가능 여부가 확정된 이후에 커밋이 이루어지기 때문이다.

또한 그림을 자세히 보면, DB는 쓰기 시작부터 커밋이 완료될 때까지 락을 걸고 있다. 이는 두 데이터베이스가 불일치된 상태로 조회되는 것을 막아주며 데이터 정합성을 보장해준다.

단점

그렇다면 단점은 어떤 것들이 있을까? 우선 코디네이터에 장애가 발생하다면 각 데이터베이스는 커밋과 롤백 여부를 스스로 결정할 수 없는 SPOF 문제가 존재한다.

무엇보다 특정 참여자가 응답을 늦게 준다면, 다른 서버들은 응답이 느린 참여자까지 기다리면서 조정자에게 커밋 가능 요청이 올 때 까지 트랜잭션을 열고 있어야 한다. 즉, 최종 커밋 명령을 내릴 때까지 참여자들은 해당 리소스에 잠금이 걸려 성능이 떨어지게 되는 것이다.

문제는 현실에서는 항공권 예약, 결제, 알림과 같이 몇 분을 넘어 몇 시간, 심지어 며칠 동안 이어지는 장기 실행 트랜잭션 (Long-Lived Transaction)들이 존재한다. 이런 경우는 2PC 방식으로 처리하기가 거의 불가능 했기에, 2 Page Commit의 단점을 보완한 대안으로 사가(SAGA) 패턴이라는 것이 출범하였다.

2. SAGA 패턴

SAGA 패턴은 개별 노드에서 로컬 트랜잭션이 실행되고 연속적으로 이어지면서 전체 비즈니스 트랜잭션이 구성되는 방식이다.

트랜잭션1 완료 -> 트랜잭션2 트리거 -> 트랜잭션2 완료 -> 트랜잭션3 트리거 -> 트랜잭션 3완료 의 형태를 띄게 된다.

2PC 방식과의 차이점

  • 문제가 발생했을 때는 롤백을 하지 않고 보상을 통해 트랜잭션의 전체의 일관성을 간접적으로 유지한다.
  • 각각 관련된 단계를 독립적으로 수행하기 때문에 때문에 2PC와 다르게 오랜 시간 동안 리소스를 잠그지 않아도 된다.

여기서 가장 주목해야할 차이점은 사가 패턴에서는 하나의 비즈니스 로직이 여러 서비스에 걸쳐 수행되므로, 중간에 일부 단계가 실패하더라도 전체를 롤백하기보다는 이전에 수행된 작업을 보상 트랜잭션으로 되돌려 논리적 일관성을 유지한다는 것이다.

이처럼 사가 패턴에서 서비스 간 통신은 상태를 주고받기 위해 주로 이벤트 기반 아키텍처로 설계가 된다. 그렇기 때문에 이벤트 결과를 통해 어떤 작업을 수행할지 결정하기 위한 state machine 을 가지고 있어야 한다.

사가 패턴에는 아래와 같이 오케스트레이션과 메시징 두 가지 방식이 존재한다.

1. 오케스트레이션 방식

중앙 제어자가 서비스들에게 트랜잭션과 보상 트랜잭션을 직접 명령하는 방식이다. 예를 들어 주문-결제-배송의 흐름이라면, 오케스트레이터가 ‘주문 생성 → 결제 승인 → 배송 요청’을 순차적으로 지시하고, 중간 단계에서 실패가 발생하면 대응되는 보상 트랜잭션(ex) 결제 취소, 주문 취소)을 실행하도록 제어한다.

중앙에서 전체 트랜잭션 흐름을 제어하기 때문에 비즈니스 로직을 한눈에 파악하기 쉽고, 중간 상태들을 쉽게 모니터링하고 추적할 수 있다는 장점이 있다.

하지만 중앙 제어자가 오히려 모든 흐름을 관리하기 때문에 단일 장애 지점(SPOF)이 될 수 있으며, 구현의 복잡도 또한 증가한다는 단점도 있다.

2. 메시징 방식

중앙 제어자 없이 메시지 큐로 명령을 전송하는 방식으로, 오케스트레이션 방식과 다르게 1) 비동기 방식인 메시지 브로커로 인해 각 서비스들이 느슨하게 결합되고 2) 중앙 제어자로 인한 SPOF가 존재하지 않는다는 장점이 있다.

하지만 문제는 반대로 중앙 제어자가 없으므로 현재 진행중인 트랜잭션 상태를 추적하거나 디버깅하기 어렵다. 메시지 브로커를 사용하므로 "어디까지 완료됐는지", "어디서 실패했는지"를 DB만 보고 바로 알 수 없기 때문이다.

🔗 언제 사용하면 좋을까?
데이터의 상태를 실시간으로 추적하고 관리해야 하는 경우, 즉 모니터링이 필요하다면 오케스트레이션 사가가 더 적합하고, 그렇지 않다면 대게 메시징 방식이 더 유리하다.

사가 패턴에서 실패를 핸들링 하는 방법

정상적 실패 처리 : 보상 트랜잭션 발생

앞서 말했듯이, 비즈니스 예외와 같은 정상적 실패의 경우 보상 트랜잭션을 통해 원자성을 보장할 수 있다. 보상 트랜잭션은 전통적인 DB 롤백과 달리 비즈니스 레벨에서의 논리적 복구를 의미한다.

예를 들어 환전 로직이 출금 -> 입금 순서대로 이루어진다고 가정해보자. 고객/계좌 거래 제한 등으로 인해 입출금이 실패하는 경우 다음과 같이 처리할 수 있다.

  1. 출금이 실패하는 경우 : 추가적은 요청 없이 그냥 실패로 마무리한다.
  2. 입금이 실패하는 경우 : 앞서서 수행했던 출금 요청을 되돌리기 위해 X원을 다시 입금하는 보상 트랜잭션을 수행한다.

보상 트랜잭션의 원자성 보장 : 트랜잭션 아웃박스 패턴

하지만 다음과 같은 최악의 상황을 가정해보자. 보상 트랜잭션을 수행하기 위해 메시지 브로커에 이벤트를 발행해야 하는데, 만약 비즈니스 로직은 성공했지만 "메시지 발행이 실패"한다면 어떻게 될까?


// 출금 비즈니스 로직 수행 : 성공
PaymentService.withdraw();

// 다른 서버가 입금 로직을 수행하기 위해 메시지 브로커로 이벤트 전송 : 실패
MessageBroker.send(new Event(...)); 

위와 같은 경우 출금은 성공했는데 메시지 브로커 특성 상 비동기로 인해 보상 메시지는 전송되지 않아 다른 서비스(입금 서비스)는 상태를 복구하지 못하게 된다. 즉, 비즈니스 트랜잭션과 메시지 발행의 원자성이 깨지는 문제가 발생하는 것이다.

이를 해결하기 위한 대표적인 접근법 중 하나가 트랜잭션 아웃박스(Transaction Outbox) 패턴 이다. 트랜잭션 아웃박스 패턴은 메시지 생성 자체를 로컬 DB 트랜잭션에 포함시켜 비즈니스 로직과 메시지 발행이 항상 함께(원자적으로) 처리되도록 하는 방법을 의미한다.


// 출금 비즈니스 로직 수행
PaymentService.withdraw();

// 다른 서버가 입금 로직을 수행하기 위해 메시지 브로커로 이벤트 전송
OUTBOXRepository.save(new Event(...));

이벤트를 바로 브로커에 발행하지 않고, 별도의 OUTBOX 테이블에 저장하는 것 까지를 하나의 트랜잭션으로 묶는다. 만약 트랜잭션 커밋이 성공적으로 되었다면 OUTBOX 테이블에 저장된 이벤트 데이터를, 이후 별도의 메시지 전송 프로세스가 OUTBOX를 읽어 메시지 브로커(ex) Kafka, RabbitMQ)에 실제로 발행한다.

이러한 방식은 1)데이터베이스 트랜잭션이 커밋되면 메시지가 발행되고 트랜잭션이 롤백되면 메시지를 보내지 않음을 보장할 수 있고, 2) 메시지 서비스는 보낸 순서를 유지한 채로 브로커로 전송됨을 보장할 수 있다.

그렇다면 Outbox 데이터는 어떻게 메시지 브로커로 전송될까?

일반적으로 두 가지 방식이 사용된다.

  • Polling Publisher : Outbox 테이블에 쌓인 메시지를 일정 주기로 polling(조회)하여 브로커로 전송하는 방식이다. 구현이 단순하지만, DB polling으로 인한 부하가 생길 수 있어 잘 사용되지 않는다.

  • Transaction Log Tailing  : DB의 트랜잭션 로그(binlog)를 읽어 커밋된 이벤트를 감지하고 브로커로 전송하는 방식이다. CDC(Change Data Capture) 기술을 활용하며 대표적으로 Debezium + Kafka Connect 조합을 많이 사용한다.

기존 시스템을 분산 트랜잭션으로 리팩토링 해본다면?

사실 여기까지는 일반적인 내용이였고, 좀 더 고도화해서 해당 포스팅에서 작성했었던 페이 프로젝트를 위에 학습한 내용을 적용해 다시 설계해본다면, 대략적으로 아래와 같이 구현할 수 있어보인다.

기존 시스템(단일 트랜잭션)과 달라진 점이라고 하면 A 차감과 B 입금 트랜잭션이 분리되고, 그 사이에 이벤트 브로커로 이벤트를 비동기적으로 전송한다는 것이다.

현재는 락을 걸지는 않았지만 만약 A에 비관적 락을 걸었을 경우 B 로직이 끝날 때 까지 기다리지 않고 락을 빠르게 반환할 수 있을 것이며, 사용자가 직접 확인을 하고 수취를 하는 송금 Pending 상태를 구현할 수 있다. 또한 비동기이기 때문에 장애 상황에서도 우선 사용자에게 빠른 응답을 반환하게끔 하고 뒷단에서 장애를 극복할 시간을 벌 수 있다.

다만 해당 과정에서 Transactional Outbox 패턴으로 차감 로직과 이벤트 발행을 원자적으로 처리해야 할 것이고, B 입금 로직이 실패하는 경우를 대비해서 X 번의 재시도 이후 다시 A 입금 보상 트랜잭션 로직을 적용해 데이터 정합성을 맞춰야 할 것이다.

분산 환경의 트레이드 오프를 고려하자!

물론 MSA는 잘 설계되고 운영되는 환경에서는 독립적인 배포와 높은 확장성, 장애 전파 방지로 격리성 확보, 기술 스택의 자율성 등 매우 강력한 장점을 가진다.

하지만 모든건 장단점이 있듯이 이 역시 원자성을 보장하기 위해 사가 패턴이나 트랜잭션 아웃박스 패턴, CDC 같은 여러 패턴을 도입하게 되면서, 그만큼 구현 난이도와 시스템 복잡도는 급격히 증가하고 모니터링 및 운영 비용 또한 함께 커지게 된다. 또한 이러한 부분들을 제대로 고려하지 않으면 정합성이 깨져 장애가 발생하는 더 큰 문제로 발생할 수 있다.

따라서 아키텍처의 트레이드오프를 충분히 고려하고, MSA가 오버엔지니어링이 되지 않도록 현재 시스템의 규모와 운영 역량을 면밀히 검토한 뒤 단계적으로 도입하는 것이 좋지 않을까 생각한다.

참고 자료
https://microservices.io/patterns/data/saga.html
https://learn.microsoft.com/ko-kr/azure/architecture/patterns/saga
https://medium.com/nerd-for-tech/transactions-in-distributed-systems-b5ceea869d7d
https://www.youtube.com/watch?v=xpwRTu47fqY

profile
개인용으로 공부하는 공간입니다. 피드백 환영합니다 🙂

0개의 댓글