본문 바로가기

Design Patterns

마이크로 서비스에서 분산 트랜잭션과 사가 패턴

우리는 애플리케이션을 개발할 때 자연스럽게 트랜잭션을 사용한다. 스프링 프레임워크에서는 선언형 메커니즘(@Transactional)을 제공하여 간편하게 트랜잭셔널 비즈니스 로직을 작성할 수 있다. 그러나 이를 사용하는 데에는 전제가 있다.

해당 비즈니스 로직이 로컬 트랜잭션에서 해결 가능하거나 단일 DB에 접근하는 모놀리식 애플리케이션일 경우이다.

 

그렇다면 마이크로 서비스로 구성된 경우는 어떨까? Database per service로 구성된 아키텍처에서도 트랜잭션을 적용하는 것이 간단할 수 있을까?

 

마이크로서비스 환경에서의 트랜잭션

마이크로 서비스 환경에서의 트랜잭션을 설명하기 위해 배달 애플리케이션의 주문을 예로 생각해보자.

주문, 소비자, 주방, 회계 서비스가 있고 주문이 생성된다면 어떤식으로 트랜잭션이 묶여야 할까?

  • 먼저 주문 서비스로 주문 생성 요청이 들어간다.
  • 소비자 서비스로 유효한 소비자인지 확인 요청을 보낸다.
  • 주방 서비스로 주문 티켓 생성 요청(요리 요청)이 들어간다.
  • 회계 서비스로 결제 요청이 들어간다.

위의 네 가지 작업(실제론 더 많은 서비스에 요청이 갈 것임…)이 하나의 트랜잭션으로 묶여야한다. 근데 이를 선언적 트랜잭션(@Transactional)로 간단하게 묶을 수 있을까?

⇒ 불가능하다.

 

이 포스팅은 이렇게 마이크로 서비스에서 여러 서비스간에 트랜잭션을 유지하는 방법을 설명하는 글이다.

먼저 마이크로서비스 패턴에서 De Facto 라고 설명하는 분산 트랜잭션 관리의 사실상 표준 2PC를 살펴보자

 

분산 트랜잭션 - 2PC (Two Phase Commit)

Two Phase Commit은 말 그대로 두 단계로 나눠서 커밋을 하는 방법이다.

  • First Phase(투표 단계, 혹은 커밋 요청단계라고 함)에서는 각각의 서비스에 데이터 쓰기 요청을 할 때 해당 데이터에 Lock을 건다. 이 때 데이터 쓰기가 가능하다면 가능하다고 투표를 하고 불가능하다면 불가능하다고 투표를 하며 하나라도 불가능하다는 투표가 나오면 트랜잭션은 종료한다.
  • Second Phase(커밋 단계)에서는 Lock을 잡은 각 서비스들에 대해 커밋 메시지를 보내는 단계이다.

다시 예제로 돌아가서 2PC로 문제를 해결해보자

  • 주문을 생성할 때 주문 서비스에서는 소비자 서비스의 데이터를 읽고 주방 서비스, 회계 서비스, 주문 서비스의 데이터를 업데이트한다.
    • 첫번째 단계에서는 주문 서비스에서 소비자서비스, 티켓 서비스, 회계서비스의 데이터를 업데이트 하기위해 해당 데이터에 Lock을 걸고 업데이트를 한다.
    • 두번째 단계에서는 각 서비스에 커밋 메시지를 보낸다.

 

한계와 문제점

분산 트랜잭션은 간단해 보이지만 문제점이 많다.

  • SQL DB에서는 대부분 호환되지만 NoSQL DB나 현대 메시지 브로커는 분산 트랜잭션을 지원하지 않는다.
    • MongoDB, 카산드라, RabbitMQ, 아파치 카프카 등은 분산 트랜잭션을 지원하지 않는다.
  • 동기 IPC 형태라서 가용성이 떨어진다.
    • 분산 트랜잭션에 참여하는 각 서비스의 데이터에 트랜잭션이 완료될 때까지 락을 걸어야한다.
    • 하나의 서비스가 죽으면 이에 결합된 서비스에 문제가 발생할 수 있다.
    • 이에따라 서비스간의 강한 결합이 발생한다.

마이크로 서비스 아키텍처에서 데이터 일관성을 유지하려면, 느슨하게 결합된 비동기 서비스 개념을 토대로 뭔가 다른 메커니즘이 필요하다. 이것이 사가이다.

 

사가 패턴

사가는 비동기 메시징 방식을 이용해서 편성한 로컬 트랜잭션이다. 서비스간 데이터 일관성을 사가로 유지한다.

다시 주문 생성 예제를 통해 사가를 이해해보자.

위의 그림에서 주문 생성 사가는 6개의 로컬 트랜잭션으로 구성된다.

  1. 주문 서비스: 주문을 APPROVAL_PENDING 상태로 생성한다.
  2. 소비자 서비스: 주문 가능한 소비자인지 확인한다.
  3. 주방 서비스: 주문 티켓을 CREATE_PENDING 상태로 생성한다.
  4. 회계 서비스: 소비자 신용카드를 승인한다.
  5. 주방 서비스: 주문 티켓을 AWAITING_ACCEPTANCE로 변경한다.
  6. 주문 서비스: 주문 상태를 APPROVED로 변경한다.

각각의 서비스는 로컬 트랜잭션이 완료되면 메시지를 발행해서 다음 사가 단계를 트리거한다. 메시지를 통해 사가 참여자를 느슨하게 결합하고 사가의 완결성을 보장한다.

사가패턴에서 사가는 무슨 뜻인가? 사가라는 용어를 왜 썼는지는 사실 명확하지 않다. 아래의 스택 오버플로우의 글을 보면 오래 가는 트랜잭션이라고 하는데 사가의 어원(서사시, 말해진 것, 말로 전해진 것)으로 미루어 보았을 때 사가는 서비스간에 이벤트(말, 메시지)를 전달하여 트랜잭션을 보장한다는 의미로 사용했다고 추정된다.

주문 실패시

사가는 각 로컬트랜잭션의 단계마다 로컬 DB에 커밋하므로 주문 실패시 자동 롤백이 불가능하다.

실패시 이전 단계의 트랜잭션을 모두 언두하는 보상 트랜잭션을 작성하여 이를 실행함으로 롤백을 대신한다.

사가는 위의 그림에서 처럼 실패시에 실패하기 전까지의 트랜잭션에 대해 보상 트랜잭션을 실행한다. 여기서 데이터를 읽기만 하는 단계(read-only)나 피봇 트랜잭션 이후의 단계는 보상 트랜잭션이 필요 없다.

 

아래의 신용카드 승인 실패로 인한 주문 실패 시나리오를 살펴보자

  1. 주문 서비스: 주문을 APPROVAL_PENDING 상태로 생성한다.
  2. 소비자 서비스: 소비자가 주문을 할 수 있는지 확인한다.
  3. 주방 서비스: 주문 티켓을 CREATE_PENDING 상태로 생성한다.
  4. 회계 서비스: 소비자의 신용카드 승인 요청이 거부된다.
  5. 주방 서비스: 주문 티켓 상태를 CREATE_REJECTED로 변경한다.
  6. 주문 서비스: 주문 상태를 REJECTED로 변경한다.
  • 사가의 트랜잭션 구분
    • 보상가능 트랜잭션(compensatable transaction): 실패할 가능성이 있는 단계 이전에 있는 트랜잭션
    • 피봇 트랜잭션(pivot ransaction): 항상 성공하는 단계 이전에 있는 트랜잭션
    • 재시도 가능 트랜잭션(retriable transaction): 항상 성공하는 트랜잭션

 

사가 편성, 종류

사가는 다음과 같이 실행된다.

  1. 첫 번째 사가 참여자를 정하고 로컬 트랜잭션을 실행한다.
  2. 트랜잭션이 완료되면 그 다음 사가 참여자를 호출하고 모든 단계가 실행될 때까지 반복한다.
    1. 도중 하나라도 로컬 트랜잭션이 실패하면 보상 트랜잭션을 역순으로 실행한다.
    2. 모두 성공하면 트랜잭션이 완료된다.

사가의 종류는 크게 두가지이다.

  • 코레오그래피 사가
  • 오케스트레이션 사가

코레오그래피 사가

  • 코레오그래피 방식은 중앙에서 관리하거나 편성해주는 것이 아닌 의사 결정과 순서화를 사가 참여자에게 맡긴다.
  • 주로 이벤트 교환 방식으로 각자 통신한다.

다시 주문생성 예제를 코레오그래피 스타일로 구현해보면서 이해해보자

  1. 주문 서비스: 주문을 APPROVAL_PENDING 상태로 생성, 주문 생성 이벤트를 발행한다.
  2. 소비자 서비스: 주문 생성 이벤트를 수신, 소비자 확인, 소비자 이벤트 발행
  3. 주방 서비스: 주문 생성 이벤트 수신, 주문 확인, 주문 티켓을 CREATE_PENDING 상태로 생성, 주문 티켓 생성 이벤트 발행
  4. 회계 서비스: 주문 생성 이벤트 수신, 신용카드 승인을 PENDING 상태로 생성
  5. 회계 서비스: 주문 티켓 생성, 소비자 이벤트 수신, 소비자 신용카드 승인, 신용카드 승인 이벤트 발행
  6. 주방 서비스: 신용카드 승인 이벤트 수신, 주문 티켓 상태를 AWAITING_ACCEPTANCE로 변경
  7. 주문 서비스: 신용카드 승인됨 이벤트 수신, 주문 상태를 APPROVED로 변경, 주문 승인됨 이벤트 발행
  • 코레오그래피 사가의 조건
    • 위와 같이 코레오 그래피 사가에서는 각 참여자가 트랜잭션안에서 DB를 업데이트하고 이벤트를 발행해야 한다. 이 작업이 원자적으로 이루어져야 한다.
    • 또한, 수신한 이벤트와 자신의 데이터를 연관짓기 위해 각 이벤트에 상관관계가 있는 ID가 포함되어야 한다.
  • 코레오그래피 사가의 장점
    • 단순함: 각 서비스의 로직마다 필요시 이벤트를 발행한다.
    • 느슨한 결합: 참여자는 이벤트를 구독할 뿐 서로를 모른다.
  • 코레오그래피 사가의 단점
    • 이해하기 어렵다: 서비스가 많아지면 각 사가가 어떻게 동작하는지 이해하기 어렵다.
    • 서비스 간 순환 의존성 발생 가능
    • 단단한 결합이 될 가능성이 있음: 자신에게 영향을 미치는 모든 이벤트를 구독하게 되면 각 서비스끼리 단단한 결합이 형성될 수 있음
  • 따라서, 복잡한 사가라면 오케스트레이션 방식이 적합하다.

오케스트레이션 사가

  • 사가 편성 로직을 사가 오케스트레이터가 맡는다.
  • 사가 오케스트레이터는 커맨드/비동기 응답 상호 작용을 하며 참여자와 통신한다.
    1. 오케스트레이터는 사가 단계를 실행하기 위해 해당 참여자가 무슨 일을 해야 하는지 커맨드 메시지에 적어 보낸다.
    2. 사가 참여자는 작업을 마치고 응답 메시지를 오케스트레이터에게 준다.
    3. 오케스트레이터는 응답 메시지를 처리한 후 다음 사가 단계를 어느 참여자가 수행할지 결정한다.

  1. 사가 오케스트레이터는 소비자 확인 커맨드를 소비자 서비스에 전송한다.
  2. 소비자 서비스는 소비자 확인 메시지를 응답한다.
  3. 사가 오케스트레이터는 주문 티켓 생성 커맨드를 주방 서비스에 전송한다.
  4. 주방 서비스는 주문 티켓 생성 메시지를 응답한다.
  5. 사가 오케스트레이터는 신용카드 승인 메시지를 회계 서비스에 전송한다.
  6. 회계 서비스는 신용카드 승인됨 메시지를 응답한다.
  7. 사가 오케스트레이터는 티켓 승인 커맨드를 주방 서비스에 전송한다.
  8. 사가 오케스트레이터는 주문 승인 커맨드를 주문 서비스에 전송한다.

위와같이 오케스트레이션 사가 방식에서는 오케스트레이터가 모든 단계를 관리한다. 사가 오케스트레이션 방식도 코레오그래피방식과 마찬가지로 각 서비스에서 업데이트와 메세지 발행을 하나의 트랜잭션으로(원자적으로) 처리해야 한다.

  • 오케스트레이션 사가의 장점
    • 의존 관계 단순화: 오케스트레이터가 참여자를 호출하는 단방향 관계, 코레오그래피 사가처럼 순환 의존성이 발생하지 않는다.
    • 낮은 결합도: 각 서비스간 결합이 발생하지 않는다.
    • 관심사를 더 분리하고 비즈니스 로직을 단순화: 관심사의 분리로 도메인 객체가 단순해진다.
  • 오케스트레이션 사가의 단점
    • 비즈니스로직을 오케스트레이터에 집중하게 되면 각각의 서비스가 깡통이 될 수 있다.
      • 따라서, 오케스트레이터는 순서화만 담당하고 비즈니스 로직을 각각의 서비스가 갖도록 해야한다.

지금까지 사가의 종류와 장단점에 대해 알아봤다. 사가는 마이크로서비스에서 데이터 일관성을 유지하는 좋은 방법이지만 주의해서 사가를 편성하지 않으면 발생할 수 있는 문제가 있다. 이제 그 문제를 알아보자

사가의 비격리 문제

사가에서는 ACID 트랜잭션의 격리성(Isolation)이 빠져 있다. 이는 두 가지 문제를 야기한다.

  1. 한 사가가 실행 중에 접근하는 데이터를 도중에 다른 사가가 바꿔치기 할 수 있다.
  2. 한 사가가 업데이트 하기 이전 데이터를 다른 사가가 읽을 수 있어서 데이터 일관성이 깨질 수 있다.

쉽게 말해서 격리가 되지 않으면 트랜잭션이 동시에 실행한 결과와 순차적으로 실행한 결과가 달라질 수 있다.

  • 비격리로 인한 비정상 문제 유형 정리
    • 소실된 업데이트(lost updates): 한 사가의 변경분을 다른 사가가 못 읽고 덮어 쓴다.
    • 더티 읽기(dirty read): 사가 업데이트를 하지 않은 변경분을 다른 트랜잭션이나 사가가 읽는다.
    • 퍼지/반복 불가능한 읽기(fuzzy/nonrepeatable reads): 한 사가의 상이한 두 단계가 같은 데이터를 읽어도 결과가 달라진다.
  • 비격리로 인한 비정상 문제 문제 해결 방안
    • 시맨틱 락: 애플리케이션 수준의 락, 보상 가능 트랜잭션이 생성/수정하는 레코드에 플래그를 세팅(커밋 예정)하여 락을 건다.
    • 교환적 업데이트: 업데이트 작업은 어떤 순서로 실행해도 되게끔 설계한다.
    • 비관적 관점: 사가 단계 순서를 재조정하여 리스크를 최소화한다.
    • 값 다시 읽기: 데이터를 덮어 쓸 때 그 전에 변경된 내용이 없는지 다시 확인하여 더티 쓰기를 방지한다. (값을 다시 읽었을 때 변경 되었다면 사가를 중단하고 나중에 재시작한다.) 일종의 낙관적 오프라인 락 패턴이다.
    • 버전 파일: 순서를 재조정할 수 있게 업데이트를 기록한다. 레코드에 수행한 작업을 하나하나 기록하는 방법이다. 나중에 이를 참조하여 순서가 안맞는 요청을 맞추는 등의 처리를 한다.
    • 값에 의한: 요청별 비즈니스 위험성을 기준으로 동시성 메커니즘을 동적 선택한다. 위험성이 낮은 요청은 사가를 사용하고, 위험성이 큰 요청은 분산 트랜잭션을 사용한다.

Reference