개발 일지/대규모 시스템 설계

[대규모 시스템 설계] 6. MSA 분산 트랜잭션 관리

배발자 2023. 10. 15.
반응형

개요

이전에 진행했던 프로젝트는 분산 트랜잭션 처리 로직을 구현하지 않은 아쉬운 MSA 환경을 구축하였다. 각 마이크로 서비스들은 공유 DB를 바라보고 있었으며, 특정 서버가 트랜잭션을 처리하게되면, 단일 DB 환경에서 데이터의 일관성과 안정성을 보장하는 DB에 의존하여 ACID 특성을 보장할 수 있었다.

 

하지만, 이러한 방식은 왜 우리가 MSA 환경으로 구성했는지 의문을 품을 수도 있는 부분이다. 하나의 공유 DB를 바라보면서 서비스만 나눴다면 차라리 서비스를 나누지 않고 모놀리식 아키텍처로 구성하는 것이 더 낫지 않나 생각이 드는 것이다. 즉, MSA 환경을 구성하려고 했다면 MSA 답게 하나의 DB에 중앙 집중화를 하지 않고 서비스 별 별도의 DB를 사용하여 다른 서비스 컴포넌트에 대한 의존성을 없애고 서비스를 독립적으로 개발 및 배포/운영을 할 수 있게끔 말이다. 

 

기간이 길지 않은 프로젝트를 진행하다보면 프로젝트의 범위 설정을 하는 것이 굉장히 힘들었는데 특히, 한정된 기간 내에 특정 기술 구현을 계획했다면 A까지 진행하고 A+ 까지 진행되는 부분을 포기할 때가 종종 있었다. 그 중 하나가 아쉬운 MSA 환경을 구축한 것이다.   

 

아무튼, 진행했던 프로젝트를 회고하면서 아쉬웠던 아키텍처를 다시 한 번 생각해보며 각 마이크로 서비스에서 개별적으로 독립적인 DB가 존재할 때를 상상해보고 그 안에서 발생할 수 있는 새로운 문제점이 무엇인지 찾아보게 되었다. 

 

놀리식 아키텍처 환경에서는 데이터베이스에 의존하여 비지니스 단계에 대해서 ACID 특성을 보장하지만, MSA 환경에서는  특정 로직을 처리하기 위해서는 여러 마이크로 서비스(여러 데이터베이스)에 걸쳐 있기 때문에 ACID 특성을 사용할 수가 없게 된다는 문제점이 존재한다. 

 

즉, 오늘의 주제는 분산 환경에서의 트랜잭션을 관리하는 방법을 알아볼 것이다. 

 

ACID

*이 내용은 개발자가 되기 위해 취준하는 모든 분들이 면접을 위해 꼭 알아둬야 할 개념 중 하나이기도 하다.  

 

일반적으로 데이터베이스 트랜잭션에 대해 이야기할 때 ACID 트랜잭션에 대해 많이 이야기한다. ACID는 데이터 저장소의 내구성과 일관성을 보장하기 위해 신뢰할 수 있는 시스템으로 이어지는 데이터베이스 트랜잭션의 주요 속성을 설명하는 약어이다. ACID 는 원자성(Atomicity), 일관성(Consistency), 격리(Isolation) 및 지속성(Durability)을 나타내며 이러한 속성이 제공하는 것은 다음과 같다.

 

  • 원자성(Atomicity) : 트랜잭션은 모두 성공하거나 모두 실패한다. 
  • 일관성(Consistency) : 트랜잭션이 끝난 후 데이터베이스는 일관된 상태를 유지해야 한다. 
  • 격리성(Isolation) : 트랜잭션은 다른 트랜잭션과 독립적으로 실행되어야 한다. 
  • 지속성(Durability) : 트랜잭션이 성공했을 경우 그 결과는 영구적으로 반영되어야 한다. 

 

이 ACID 속성 중 트랜잭션 경계를 나눌 때 가장 먼저 부딪히는 문제가 원자성이다. 그 이유에 대해서 한 번 살펴보자. 

 

분산 트랜잭션

 

위의 그림을 보면 결제 프로세스를 MSA 분산 환경으로 그려놓은 것이다. 각 서비스는 각각의 DB를 바라보고 있으며, 결제(Payment) 서비스는 3개의 서비스와 DB를 거쳐야 완료된다. 즉, 주문을 하고 재고를 업데이트하고 결제 완료가 되어야한다는 뜻이다. 하지만 MSA 환경에서는 서버의 장애라던지 예상치못한 오류 등 부가적으로 생각해야할 부분들이 많고 항상 정상적인 서비스가 동작하는 것이 아니다. 예를 들어, 주문 서비스가 정상적으로 처리가 되고 재고 서비스에서 업데이트 하는 과정에서 장애가 발생한다면 이전에 정상적으로 처리가 된 주문 서비스에서의 데이터는 어떻게 처리해야할 지 생각을 해야한다는 뜻이다. 

 

물론 각각의 서비스에서 동작하는 CUD 동작을 하나의 트랜잭션으로 묶어 처리할 수는 있지만, 결합도를 낮추는 마이크로 서비스를 구현하는 방식과는 맞지않다. 이렇게 구현한다면, 차라리 하나의 서비스로 통합하는 모놀리식 아키텍처로 구현하는 것이 더 나을 것이다. 

 

그렇다면 위의 예시처럼 특정 트랜잭션이 처리가 되지 않을 때 어떻게 Atomic(원자성)하게 처리할 수 있을까? 

이때 고려되는 솔루션으로 대표적인 방식은 2Phase CommitSAGA Pattern이다.  

 

 

Two-Phase Commit (2PC)

2PC는 일반적인 싱글 노드 트랜잭션에 존재하지 않는 새로운 컴포넌트인 코디네이터(또는 트랜잭션 매니저)를 사용한다. 코디네이터는 트랜잭션을 요청하는 같은 애플리케이션 프로세스 내 라이브러리에 구현되어 있으나, 분리된 서비스나 프로세스일 수도 있다. 

 

출처 : https://dongwooklee96.github.io/post/2021/03/26/two-phase-commit-%EC%9D%B4%EB%9E%80/

 

 

 

[투표 단계]

2PC 트랜잭션은 애플리케이션이 여러 데이터베이스 노드들에 읽고 쓰면서 시작하게 되는데 애플리케이션이 커밋할 준비가 되면, 코디네이터는 phase 1을 시작하며 각 노드에 prepare 요청을 보내서 커밋할 수 있는지 질의하게 된다. 이후 코디네이터는 각 노드(참여자)들의 응답을 추적한다. 

 

[커밋 단계]

만약 모든 노드(참여자)가 YES라고 응답하면, 코디네이터는 phase 2로 넘어가서 commit 요청을 보내어 커밋이 수행되도록 한다. 하지만 어느 하나라도 NO를 응답하면, 코디네이터는 phase 2로 넘어가 모든 노드들에 abort 요청을 보내게 된다. 여기서 유의해야할 점은 커밋 단계에서는 모든 서비스에 정확히 동시에 적용된다고 보장할 수 없다. 코디네이터는 모든 서비스에 커밋 요청을 보내야하며 해당 메세지는 다른 시간에 도착하여 처리될 수 있다는 말이다. 그래서 타이밍 이슈로 DB 1에서는 변경된 값을 확인할 수 있지만 DB 2에서는 변경 사항이 아직 반영되지 않을 수도 있다. 

 

문제점

2PC는 서비스가 증가할수록 시스템의 대기 시간이 길어지게 되고 응답시간의 증가를 초래하게 된다. 또한, 2PC는 코디네이터를 기반으로 강력한 결합을 유도하게 된다. 예를 들어, prepare 요청을 받고 yes를 응답한 이후에 코디네이터가 다운되면 참여자는 더이상 혼자서 abort를 수행할 수 없게 된다.

 

마이크로 서비스의 최종 목적지인 데이터의 분리를 위해서 코디네이터의 관리 방식에서 벗어나는 방법, 대기시간을 줄일 수 있는 방법에 대해 고민을 해야하며 이와 같은 문제점에서 대안으로 나온 방법이 Saga Pattern이다.   

 

Saga Pattern

Saga Pattern은 마이크로 서비스에서 데이터의 일관성을 관리하는 방법이다. 각 서비스는 각각의 데이터베이스로 로컬 트랜잭션을 가지고 있으며, 해당 서비스의 데이터를 업데이트 하게 되면 그와 관련된 메시지 또는 이벤트를 발행해서 다음 단계의 서비스에서 트랜잭션을 수행하는 것이다. 만약, 특정 서비스에서 수행 실패가 발생한다면 데이터의 정합성을 맞추기 위해 이전 트랜잭션에 대한 보상 트랜잭션이 수행된다. 

 

일반적으로 SAGA 패턴은 크게 2가지로 나누어지는데, Choreography based SAGA pattern이고 다른 하나는 Orchestration based SAGA pattern이다.

 

* 보상 트랜잭션 : 원본 트랜잭션을 역으로 실행하여 취소 처리하는 트랜잭션

 

Choreography based SAGA pattern

Choreography-based Saga 패턴은 보유한 서비스 내의 Local 트랜잭션을 관리하며 트랜잭션이 종료하게 되면 완료 Event를 발행하게 된다. 즉, 서비스끼리 직접적으로 통신하지 않고, 이벤트 Pub/Sub을 활용해서 통신하는 방식이다. 이전에 포스팅 했던 ELK-Stack에서 미들웨어로 도입한 Kafka를 통해 비동기 방식으로 전달할 수 있다.

 

 

 

위의 그림을 보면서 흐름을 이해해보자. 

사용자는 특정 물품에 대하여 주문을 한다. 그러면 Order 서비스에서 주문 번호를 생성하여 바라보고 있는 DB에 데이터를 추가한다. 이후 Kafka에 주문 번호 생성 이벤트를 발행하고 Stock 서비스에서 주문 번호 생성 이벤트를 구독하여 관련된 재고를 뺀다. 이후 Kafka에 재고 빼기 이벤트를 발행하고 Payment 서비스에서 해당 이벤트를 구독해서 결제 과정을 진행하게 된다. 

 

 

 

다음은 트랜잭션을 실패했을 때 흐름이다.  

주문과 재고 서비스에서 정상적으로 커밋되고 결제 서비스에서 장애가 발생할 경우 롤백을 처리해야할 것이다. 먼저, 결제 서비스에서 재고 롤백 이벤트를 발행하고 재고 서비스는 재고 롤백 이벤트를 구독하여 해당 재고에 대해 롤백 처리를 한다. 그리고 재고 서비스에서 주문 롤백 이벤트를 발행하여 주문 서비스에서 해당 이벤트를 구독하여 주문 롤백 처리를 하게된다.  Choreography-based Saga 패턴은 구성하기 편하지만 운영자 입장에서 트랜잭션의 현재 상태를 확인하기 어렵다는 단점이 있다. 

 

Orchestration based SAGA pattern

Orchestration-Based Saga 패턴은 트랜잭션 처리를 위해 Saga 인스턴스(Manager)가 별도로 존재한다. 트랜잭션에 관여하는 모든 App은 Manager에 의해 점진적으로 트랜잭션을 수행하며 결과를 Manager에게 전달하게 되고, 비지니스 로직상 마지막 트랜잭션이 끝나면 Manager를 종료해서 전체 트랜잭션 처리를 종료한다. 만약 중간에 실패하게 되면 Manager에서 보상 트랜잭션을 발동하여 일관성을 유지한다. 

 

 

위의 그림처럼,  Orchestration-based 방식에서는 중앙 오케스트레이터(Manager)가 전체 트랜잭션의 흐름을 관리하고 각 서비스에게 언제 어떤 작업을 해야하는지를 명시적으로 지시하는 것으로 생각하면 된다. 마치 오케스트라에서 지휘자가 각 연주자들에게 언제 어떻게 연주해야 하는지를 지시하는 것과 유사한 것이다. 

 

이렇게 모든 관리를 해주는 Manager가 존재하니 중앙에서 컨트롤하면서 복잡성이 줄어들고 구현과 테스트가 상대적으로 쉽지만 이를 관리하는 Manager 즉, Orchestrator 서비스가 추가되며, 과도하게 많은 비즈니스 로직이 오케스트레이터 안에 들어갈 위험이 있다. 이를 피하기 위해서는 오케스트레이터가 오퍼레이션의 순서에 대해서만 책임지고 비지니스 로직을 포함하지 않도록 설계해야한다. 

 

 

마치며

이번 포스팅을 통해서 분산 환경에서의 트랜잭션을 어떤 방식을 가지고 해결할 수 있는지 알게 되었다. 다만, 분산 트랜잭션 처리는 분산 시스템에서 데이터 일관성을 유지하기 위한 중요한 메커니즘이지만, 항상 필요한 것은 아니라고 생각한다. 즉, 애플리케이션의 요구사항이라던지 사용하는 데이터의 특성 등 시스템의 전반적인 설계에 따라 달라질 것이며 비지니스 로직상 트랜잭션 처리가 반드시 필요한 경우에만 사용하는 것이 좋을 것이라고 판단한다. 그렇지 않으면 여러곳에서 복잡한 트랜잭션 처리 로직을 경험할 수 있기 때문에 필요한 곳에서만 사용할 수 있도록 비지니스 로직을 설계하고 사용하는 것이 좋을 것이다. 

반응형

댓글