[마이크로서비스 패턴] 4. 트랜잭션 관리: 사가

DaeHoon·2023년 11월 4일
0

4.1 마이크로서비스 아키텍처에서의 트랜잭션 관리

  • 단일 DB에 접근하는 모놀리식 애플리케이션의 트랜잭션 관리는 어렵지 않다. 하지만 다중 DB, 다중 메시지 브로커를 사용하는 모놀리식 애플리케이션이나, 자체 DB를 가진 여러 서비스로 구성된 마이크로서비스 아키텍처는 트랜잭션 관리가 어렵기 때문에 좀 더 정교한 매커니즘이 필요하다.

4.1.1 분산 트랜잭션의 필요성

  • createOrder() 함수
    • 주문 가능한 고객인지 확인
    • 주문 내역 확인
    • 카드결제 승인
    • DB에 주문은 생성
  • 모놀리틱 서비스에서 스프링 프레임워크를 사용할 경우 createOrder() 함수 앞에 @Transactional만 붙이면 ACID 트랜잭션이 자동으로 보장됨
  • 반면 데이터가 여러 서비스에 흩어져 있는 마이크로 서비스인 경우에 소비자 서비스, 주문 서비스, 회계 서비스 등 여러 서비스에 있는 데이터에 접근해야 한다.

4.1.2 분산 트랜잭션의 문제점

  • 분산 트랜잭션은 Two Phase Commit을 이용하여 전체 트랜잭션 참여자가 반드시 커밋 아니면 롤백을 하도록 보장함.
  • 분산 트랜잭션의 문제점은 NoSQL DB (몽고 등)와 메시지 브로커 (RebbitMQ, 카프카)에서 분산 트랜잭션을 지원하지 않는다.
  • 또한 동기 IPC 형태라서 가용성도 떨어진다. 참여한 서비스가 모두 가동 중이여야 커밋할 수 있음. -> 더 많은 서비스가 참여할수록 가용성은 더 떨어진다.
  • 즉 이러한 문제점들 때문에, 느슨하게 결합된 비동기 서비스 개념을 토대로 뭔가 다른 메커니즘이 나오게 되었는데, 이게 바로 사가다.

4.1.3 데이터 일관성 유지: 사가 패턴

  • 사가는 마이크로서비스 아키텍처에서 분산 트랜잭션 없이 데이터 일관성을 유지하는 메커니즘
  • 사가는 일련의 로컬 트랜잭션이고 서비스 간 데이터 일관성을 사가로 유지한다.
  • 사가와 ACID 트랜잭션은 두 가지 차이점이 존재함
    • 격리성 (I)이 사가에는 존재하지 않음
    • 사가는 로컬 트랜잭션마다 변경분을 커밋하므로 보상 트랜잭션을 걸어 롤백해야 한다.

예제

  • 주문 생성 사가는 6개의 로컬 트랜잭션으로 구성된다.
    • 주문 서비스: 주문을 APPROVAL_PENDING 상태로 생성한다.
    • 고객 서비스: 주문 가능한 소비자인지 확인한다.
    • 주방 서비스: 주문 내역을 확인하고 티켓을 CREATE_PENDING 상태로 생성한다.
    • 회계 서비스: 고객의 신용카드를 승인한다.
    • 주방 서비스: 티켓 상태를 AWAITING_ACCEPTANCE로 변경한다.
    • 주문 서비스: 주문 상태를 APPROVED로 변경한다.
  • 로컬 트랜잭션이 완료되면 메시지를 발행하여 다음 사가 단계를 트리거한다.
  • 이를 통해 사가에 참여하는 서비스를 느슨하게 결합하고 사가가 반드시 완료되도록 보장한다. 메시지를 수신하는 서비스가 일시 불능 상태라면, 메시지 브로커는 다시 메시지를 전달할 수 있을 때까지 버퍼링한다.
  • 만약 에러가 발생하면 변경된 부분을 어떻게 롤백할 수 있을까?

사가는 보상 트랜잭션으로 변경분을 롤백한다.

  • DB에서 롤백하면 그 시점까지 변경된 내용은 모두 언두됨.
  • 사가는 단계마다 로컬 DB에 변경분을 커밋하므로 자동 롤백은 불가능함. 예를 들어 주문 생성 사가 4번째 단계에서 신용카드 승인이 실패하면 1~3번째 단계에서 적용된 변경분을 명시적으로 언두해야 한다.
  • 즉, 보상 트랜잭션 (compensating transaction) 을 미리 작성해야 한다.
  • (N+1)번째 사가 트랜잭션이 실패하면 이전 N개의 트랜잭션을 언두해야 한다. 이를 점화식으로 나타내면 T(1)..T(n) 순서로 트랜잭션이 실행되다가 T(n+1)에서 실패할 경우 T(1)...T(n)을 언두하기 위해 C(n)...(C1)이 순서대로 실행되어야 한다.

  • 예를 들어 고객의 카드 승인이 실패하면 보상 트랜잭션은 다음 순서대로 작동될 것이다.
    • 주문 서비스: 주문을 APPROVAL_PENDING 상태로 생한다.
    • 고객 서비스: 주문 가능한 고객인지 확인한다.
    • 주방 서비스: 주문 내역을 확인하고 티켓을 CREATE_PENDING으로 생성한다.
    • 회계 서비스: 소비자의 신용카드 승인이 거부됨
    • 주방 서비스: 티켓 상태를 CREATE_REJECTED로 변경됨
    • 주문 서비스: 주문 상태를 REJECT로 변경한다.
  • 5~6번째 단계는 주방 서비스. 주문 서비스가 수행한 업데이트를 언두하는 보상 트랜잭션

4.2 사가 편성

  • 코레오그래피 (choreography): 의사 결정과 순서화를 사가 참여 서비스에게 맡김. 주로 이벤트 교환 방식으로 통신
  • 오케스트레이션 (orchestration): 사가 편성 로직을 사가 오케스트레이터에 중앙화한다. 사가 참여 서비스에게 커맨드 메시지를 보내 수행할 작업을 지시함

4.2.1 코레오그래피 사가

  • 사가 참여 서비스끼리 서로 이벤트를 구독해서 그에 따라 반응함.

주문 생성 성공

  • 아래와 같은 순서대로 진행됨
    • 주문 서비스: 주문을 APPROVAL_PENDING 상태로 생성 -> 주문 생성 이벤트 발행
    • 고객 서비스: 주문 생성 이벤트 수신 -> 소비자가 주문을 할 수 있는지 확인 -> 고객 확인 이벤트를 발행
    • 주방 서비스: 주문 생성 이벤트 수신 -> 주문 내역 확인 -> 티켓을 CREATE_PENDING 상태로 생성 -> 티켓 생성됨 이벤트 발행
    • 회계 서비스: 주문 생성 이벤트 수신 -> 신용카드 승인을 PENDING 상태로 생성
    • 회계 서비스: 티켓 생성 및 소비자 확인 이벤트 수신 -> 티켓 상태를 AWAITING_ACCEPTANCE로 변경
    • 주문 서비스: 신용카드 승인됨 이벤트 수신 -> 주문 상태를 APPROVED로 변경 -> 주문 승인됨 이벤트를 발행함.
  • 주문 생성 사가는 어떤 사가 참여 서비스가 주문을 거부해서 실패 이벤트가 발생하는 경우 (고객 신용카드 승인 거부)를 대비해야 한다. 이런 일이 발생하면 사가는 어떻게 보상 트랜잭션을 가동하여 이미 수행한 작업을 언두해야 한다.

주문 생성 실패

  • 아래와 같은 순서대로 진행됨
    • 주문 서비스: 주문을 APPROVAL_PENDING 상태로 생성 -> 주문 생성 이벤트 발행
    • 고객 서비스: 주문 생성 이벤트 수신 -> 소비자가 주문을 할 수 있는지 확인 -> 고객 확인 이벤트를 발행
    • 주방 서비스: 주문 생성 이벤트 수신 -> 주문 내역 확인 -> 티켓을 CREATE_PENDING 상태로 생성 -> 티켓 생성됨 이벤트 발행
    • 회계 서비스: 주문 생성 이벤트 수신 -> 신용카드 승인을 PENDING 상태로 생성
    • 회계 서비스: 티켓 생성 및 소비자 확인 이벤트 수신 -> 고객 신용카드 과금 -> 신용카드 승인 실패 이벤트 발행
    • 주방 서비스: 신용카드 승인 실패 이벤트 수신 -> 티켓 상태를 REJECT로 변경
    • 주문 서비스: 신용카드 승인 실패 이벤트 수신 -> 주문 상태를 REJECT로 변경

고려할 점

  • 코레오그래피 방식으로 사가를 구현하려면 두 가지 통신 이슈를 고려해야 한다.
    • 사가 참여자가 자신의 DB를 업데이트 하고, DB 트랜잭션의 일부로 이벤트를 발행하도록 해야 한다. 즉 DB를 업데이트하는 작업과 이벤트를 발행하는 작업은 원자적으로(atomically) 일어나야 한다. 사가 참여 서비스가 확실하게 통신하려면 트랜잭셔널 메시징을 사용해야 한다.
    • 사가 참여 서비스는 자신이 수신한 이벤트와 자신이 가진 데이터를 연관 지을 수 있어야 한다. 가령 신용카드 승인됨 이벤트를 받은 주문 서비스는 여기에 해당하는 주문을 찾을 수 있어야 한다. 해당 해결책은 데이터를 매핑할 수 있도록 다른 사가 참여 서비스가 관련 ID가 포함된 이벤트를 발행하는 것
      • 예를 들면 주문 생성 사가에서 각 참여자가 orderId를 상관관계 ID로 삼아 다른 서비스들에게 건네주면 된다. 신용카드 승인됨 이벤트를 받은 회계 서비스는 orderId로 주문 정보를 가져올 수 있고, 주방 서비스도 같은 방법으로 티켓 정보를 가져올 수 있다.

장단점

  • 장점
    • 단순함: 비즈니스 객체를 생성, 수정, 삭제할 때 이벤트를 발행
    • 느슨한 결합 (Loose Coupling): 서비스는 서로 구독할 뿐 서로를 직접 참조하지 않는다.
  • 단점
    • 이해하기 어려움: 사가를 어느 한 곳에 정의한 것이 아니라 구현 로직이 서비스에 흩어져 있음. 어떻게 작동이 되는지 개발자가 이해하기 어려움
    • 서비스 간 순환 의존성: 서로 이벤트를 구독하는 특성상 순환 의존성이 발생하기 쉬움. 그림 4-4에서도 순환의존성 (주문 서비스 -> 회계 서비스 -> 주문 서비스)가 형성되어 있음. 순환 의존성은 잠재적인 설계 취약점
    • 단단히 결합될 위험성: 사가 참여 서비스는 각자 자신에게 영향을 미치는 이벤트를 모두 구독해야 한다. 예를 들어 회계 서비스는 소비자 신용카드를 과금/환불 처리하게 만드는 모든 이벤트를 구독해야 한다. 이 서비스는 주문 서비스에 구현된 주문 주기와 맞물려 업데이트 되어야 하는 위험이 있음

즉, 간단한 사가라면 코레오그래피 방식으로도 충분하지만 복잡한 사가는 오케스트레이션 방식이 적합하다.

4.2.2 오케스트레이션 사가

  • 사가 참여자가 할 일을 알려주는 오케스트레이터 클래스를 정의한다.
  • 사가 오케스트레이터는 커맨드/비동기 응답 상호 작용을 하며 서비스와 통신함
  • 즉, 사가 단계를 실행하기 위해 해당 참여자가 무슨 일을 해야 하는지 커맨드 메시지에 적어 보낸다.

주문 생성 사가

  • 사가 오케스트레이터인 CreateOrderSaga 클래스가 비동기 요청/응답을 주고 받으면서 주방 서비스, 소비자 서비스 같은 사가 서비스를 호출하고 그 처리 과정에 따라 커맨드 메시지를 전송. 그리고 이 클래스는 자신의 응답 채널에서 메시지를 읽어 다음 사가 단계를 결정한다.

  • 주문 서비스는 먼저 주문 및 주문 생성 사가 오케스트레이터를 생성한다.

    • 사가 오케스트레이터가 소비자 확인 커맨드를 소비자 서비스에 전송한다.
    • 소비자 서비스는 소비자 확인 메시지를 응답한다.
    • 사가 오케스트레이터는 티켓 생성 커맨드를 주방 서비스에 전송한다.
    • 주방 서비스는 티켓 생성 메시지를 응답한다.
    • 사가 오케스트레이터는 신용카드 승인 메시지를 회계 서비스에 전송한다.
    • 회계 서비스는 신용카드 승인됨 메시지를 응답한다.
    • 사가 오케스트레이터는 티켓 승인 커맨드를 주방 서비스에 전송한다.
    • 사가 오케스트레이터는 주문 승인 커맨드를 주문 서비스에 전송한다.
  • 제일 마지막 단계에서 사가 오케스트레이터는 커맨드 메시지를 주문 서비스에 전송한다. 물론 주문 생성 사가가 주문을 직접 업데이트해서 승인 처리해도 되지만, 일관성 차원에서 주문 서비스가 마치 다른 참여자인 것처럼 취급하는 것이다.

사가 오케스트레이터를 상태 기계 (State Machine)으로 모델링

  • 상태 기계는 상태와 이벤트에 의해 트리거 되는 상태 전이 (transition)으로 구성된다. 전이가 발생할 때 마다 액션 (action)이 일어나는데, 사가의 액션은 사가 서비스를 호출하는 작용이다.
  • 상태 간 전이는 서비스가 로컬 트랜잭션을 완료하는 시점에 트리거되고, 로컬 트랜잭션의 상태와 결과에 따라 상태 전이를 어떻게 하고 어떤 액션을 취할지 결정됨.
  • 상태 기계는 효율적으로 테스트할 수 있기 때문에 상태 기계를 이용하여 사가를 모델링하면 설계, 구현, 테스트를 더 쉽게 진행할 수 있다.

  • 소비자 확인: 초기 상태. 사가는 소비자 서비스가 주문 가능한 소비자인지 확인할 때 까지 기다림.
  • 티켓 생성: 사가는 티켓 생성 커맨드에 대한 응답을 기다림
  • 신용카드 승인: 회계 서비스가 소비자 신용카드를 승일할 때 까지 기다림
  • 주문 승인됨: 사가가 성공적으로 완료되었음을 나타내는 최종 상태
  • 주문 거부됨: 참여자 중 하나가 주문을 거부했음을 나타내는 최종 상태

사가 오케스트레이션과 트랜잭셔널 메시징

  • 오케스트레이션 사가 DB를 업데이트 하는 서비스와 메시지를 발행하는 서비스가 단계마다 존재함.
    • 예를 들어 주문 서비스는 주문 및 주문 생성 사가 오케스트레이터를 생성한 후 1번 사가 참여자에게 메시지를 보냄.
    • 사가 참여자는 자신의 DB를 업데이트 한 후 응답 메시지를 보내는 식으로 커맨드 메시지를 처리함.
    • 다시 주문 서비스는 사가 오케스트레이터 상태를 업데이트 한 후 커맨드 메시지를 다음 사가 참여자에게 보냄.
    • 이런 식으로 참여자의 응답 서비스를 처리한다.
  • 이때 서비스는 트랜잭셔널 메시지를 사용해서 DB 업데이트와 메시지 발행 작업을 원자적으로 처리해야 한다.

오케스트레이션 사가의 장단점

  • 장점
    • 의존 관계 단순화: 오케스트레이터는 참여자를 호출하지만, 참여자는 오케스트레이터를 호출하지 않음. 즉 오케스트레이터는 참여자에게 의존하지만 그 반대는 성립되지 않는다.
    • 낮은 결합도: 각 서비스는 오케스트레이터가 호출하는 API를 구현할 뿐, 사가 참여자가 발행하는 이벤트는 몰라도 됨
    • 관심사를 더 분리하고 비즈니스 로직을 단순화: 사가 편성 로직이 사가 오케스트레이터 한 곳에만 있으므로 도메인 객체는 더 단순해지고 자신이 참여한 사가에 대해서는 알지 못함
      • 예를 들어, Order 클래스는 사가를 모르기 때문에 상태 기계 모델을 더욱 간단해져서 주문 생성 사가 실행 도중 APPROVAL_PENDING -> APPROVED로 바로 상태 전이됨.
  • 단점
    • 오케스트레이터에 너무 많이 중앙화하면 똑똑한 오케스트레이터 하나가 깡통 서비스에 일일이 할 일을 지시하는 모양새가 될 수도 있다.
      • 이 문제는 오케스트레이터에 비즈니스 로직을 갖지 않게 설계하면 된다.

4.3 비격리 문제 처리

  • 사가에는 ACID에서 I가 빠져있음.
    • 원자성 (Atomicity): 사가는 트랜잭션을 모두 완료하거나, 모든 변경분을 언두해야 함.
    • 일관성 (Consistency): 서비스 내부의 참조 무결성 (Referential Integrity)는 로컬 DB가, 여러 서비스에 걸친 참조 무결성은 서비스에서 처리
    • 지속성 (Durability) : 로컬 DB로 처리

4.3.1 비정상 개요

  • 비격리로 인한 비정상은 아래와 같이 정리할 수 있다.
    • 소실된 업데이트 (lost updates): 한 사가의 변경분을 다른 사가가 미쳐 못 읽고 덮어씀
    • 더티 리드 (dirty reads): 사가 업데이트를 하지 않은 변경분을 다른 트랜잭션이나 사가가 읽음
    • 퍼지/반복 불가능한 읽기 (fuzzy/nonrepeatable reads): 한 사가의 상이한 두 단계가 같은 데이터를 읽어도 결과가 달라지는 현상. 다른 사가가 그 사이 업데이트를 했기 때문에 생기는 문제
  • 소실된 업데이트, 더티 리드는 가장 흔하지만 처리하기는 가장 까다로운 비정상 현상

소실된 업데이트 (lost updates)

  • 한 사가의 변경분을 다른 사가가 덮어 쓸 때 일어남
    • 주문 생성 사가 첫 번째 단계에서 주문을 생성
    • 사가 실행 중 주문 취소 사가가 주문을 취소함
    • 주문 생성 사가 마지막 단계에서 주문을 승인
  • 주문 생성 사가는 주문 취소 사가가 업데이트한 데이터를 덮어 씀, 결국 고객은 자신이 주문 취소한 음식을 배달받게 된다.

더티 리드 (dirty reads)

  • 한 사가가 업데이트 중인 데이터를 다른 사가가 읽을 때 발생. 예를 들어 애플리케이션의 고객은 각자 한도가 정해져 잇고, 주문 취소 사가는 다음과 같은 트랜잭션으로 구성된다.
    • 소비자 서비스: 한도를 늘림
    • 주문 서비스: 주문을 취소 상태로 변경
    • 배달 서비스: 배달을 취소함
  • 주문 취소 사가와 주문 생성 사가의 실행이 서로 겹쳐 실행 중인데, 고객이 배달을 취소하기는 너무 늦어 주문 취소 사가가 롤백되는 경우를 가정해보자. 그러면 소비자 서비스를 호출하는 트랜잭션 순서가 아래와 같이 엉켜버린다.
    • 주문 취소 사가: 한도를 늘린다
    • 주문 생성 사가: 한도를 줄임
    • 주문 취소 사가: 한도를 줄이는 보상 트랜잭션이 가동
  • 주문 생성 사가는 한도를 더티 리드 하게 됨, 소비자는 한도를 초과하는 주문도 할 수 있게 된다.

4.3.2 비격리 대책

  • 개발자는 비격리로 인한 비정상을 방지하고 비즈니스에 미치는 영향을 최소화하는 방향으로 사가를 작성한 의무가 있다.
  • 앞에 나왔던 *_PENDING 상태도 이런 이상 현상을 예방하는 전략 중 하나, 주문 생성 사가처럼 주문을 업데이트 하는 사가는 일단 주문을 *_PENDING 상태로 두고 시작한다. 현재 주문을 사가로 업데이트 하는 중이니 그에 맞게 행동하라고 다른 사가에게 알리는 것.
  • 이런 식으로 상태를 두는 것은 시맨틱 락 대책이라고 칭한 기법의 일례.

사가의 구조

  • 보상 가능 트랜잭션 (compensatable transaction): 보상 트랜잭션으로 롤백 가능한 트랜잭션
  • 피봇 트랜잭션 (pivot transaction): 사가의 진행/중단 지점. 피봇 트랜잭션이 커밋되면 사가는 완료될 때 까지 실행됨.
  • 재시도 가능 트랜잭션 (retriable transaction): 피봇 트랜잭션 이후의 트랜잭션. 반드시 성공

  • createOrder(), verifyConsumerDetails(), createTicket()은 모두 보상 가능 트랜잭션. 단 verifyConsumerDetails()은 읽기 전용이라 따로 보상 트랜잭션이 필요 없음.
  • authorizeCreditCard()는 피봇 트랜잭션. 고객 신용카드가 승인되면 이 사가는 반드시 완료됨 approveTicket(), approveOrder()는 피봇 트랜잭션 이후 재시도 가능 트랜잭션

대책: 시맨틱 락

  • 보상 가능 트랜잭션이 생성/수정하는 레코드에 무조건 플래그를 세팅. 레코드가 아직 커밋 전이라서 변경될지 모른다는 표시를 하는 것
  • 플래그를 세팅해서 다른 트랜잭션이 레코드에 접근하지 못하게 락을 걸거나, 다른 트랜잭션이 해당 레코드를 처리할 때 조심하도록 경고한다.
  • 플래그는 재시도가능 트랜잭션 (사가 완료) 또는 보상 트랜잭션 (사가 롤백)에 의해 해제된다.
  • 시맨틱 락을 사용하면 ACID의 트랜잭션 고유의 격리 기능을 되살릴수있다. 다만 애플리케이션에서 락을 관리하는 부담을 감수해야하고, 데드락 감지 알고리즘을 구현해서 데드락이 발생하면 사가를 롤백시켜 데드락을 해소하고 재실행할 수 있게 조치해야 한다.

시맨틱 락 예시

  • Order.state*_PENDING이 시맨틱 락을 구현한 것. 이 필드를 이용하여 주문에 접근하는 다른 사가에 현재 어떤 사가가 주문을 업데이트 하고 있음을 알림.
    • 예를 들어, 주문 생성 사가 첫 번째 단계 (보상 가능 트랜잭션)은 APPROVAL_PENDING 상태로 주문을 생성하고 마지막 단계(재시도 가능 트랜잭션)은 이 필드를 다시 APPROVED로 변경한다. 보상 트랜잭션은 이 필드를 REJECTED로 변경한다.
  • 락도 관리해야 하지만 잠금된 레코드를 어떻게 사가로 처리할지 사례별로 결정해야 한다.
    • 예를 들어 클라이언트가 시스템 커맨드 cancelOrder()를 호출해서 APPROVAL_PENDING 상태의 주문을 취소하려면 어떻게 해야 할까?
      • cancelOrder()를 실패 처리하고 클라이언트에 나중에 다시 시도하라고 알림. -> 재시도 로직까지 구현해야 하므로 클라이언트가 복잡해짐
        • 락이 해제될 때까지 cancelOrder()를 블로킹한다.

대책: 교환적 업데이트

  • 업데이트를 어떤 순서로도 실행 가능하게 설계하면 소실된 업데이트 문제를 방지할 수 있다.
  • 예를 들어 보상 가능 트랜잭션이 계좌를 인출 후 사가를 롤백 시켜야 하는 상황이라면 보상 트랜잭션은 단순히 계좌를 입금해서 업데이트를 언두하면 된다. 다른 사가의 업데이트를 덮어 쓸 일이 없다.

대책: 비관적 관점

  • 비관적 관점은 더티 읽기로 인한 비즈니스 리스크를 최소화 하기 위해 사가 단계의 순서를 재조정하는 것이다
  • 주문 취소 사가 단계를 아래와 같이 재조정하면 주문 생성 사가가 신용 잔고를 더티 리드해서 소비자 신용 한도를 초과하는 주문을 생성할 위험을 줄일 수 있다.
    • 주문 서비스: 주문을 취소 상태로 변경
    • 배달 서비스: 배달을 취소
    • 회계 서비스: 한도를 늘림
  • 이렇게 순서를 바꾸면 한도는 재시도 가능 트랜잭션에서 증가하므로 더티 리드 가능성은 사라진다.

대책: 값 다시 읽기

  • 사가가 레코드를 업데이트 하기 전에 값을 다시 읽어 값이 변경되지 않았는지 확인하는 것. 값을 다시 읽었더니 변경되었다면 사가를 중단하고 나중에 재시작한다.
  • 일종의 낙관적 오프라인 락 패턴 (Optimistic Offline Lock)
  • 주문이 승인되는 도중 취소되는 불상사를 방시할 수 있음. 주문 승인 트랜잭션은 해당 주문이 처음 생성된 이후 이전 사가에서 값이 변경되었는지 체크함. 변경되지 않았으면 주문을 승인 처리하고, 주문이 취소되었으면 사가를 멈추고 보상 트랜잭션을 가동

대책: 버전 파일

  • 레코드에 수행한 작업을 하나하나 기록하는 대책

대책: By Value

  • 비즈니스 위험성을 기준으로 동시성 메커니즘을 선택하는 것.

4.4 주문 서비스 및 주문 생성 사가 설계

시맨틱 락을 이용해 주문 서비스 및 주문 생성 사가를 설계/구현 하는 예제를 살펴보자

  • 비즈니스 로직이 포함된 orderServie, Order 등의 클래스와 주문 생성 사가를 오케스트레이션 하는 createOrderSaga 클래스가 존재한다.
  • 주문 서비스는 자신의 사가에도 참여하므로 orderService를 호출하여 커맨드 메시지를 처리하는 어댑터 클래스 orderCommanHandlers가 있다.
  • 핵심 비즈니스 로직은orderServie, Order, orderRepository에 존재한다.
  • 사가 오케스트레이터인 주문 서비스는 그 자신이 사가 참여자이기도 한 서비스고, 사가 참여자 프록시 클래스 (KitchenServiceProxy,, OrderServiceProxy)를 거쳐 사가 참여자에게 커맨드 메시지를 전달한다.
  • 사가 참여자 프록시는 사가 참여자의 메시징 API가 정의된 클래스
  • OrderCommandHandlers 클래스는 사가가 주문 서비스에 전송한 커맨드 메시지를 처리한다.

4.4.1 OrderService 클래스

  • OrderService 클래스는 주문/생성 관리를 담당하는 서비스 API 계층이 호출되는 도메인 서비스. Order를 생성/수정하고, OrderRepository를 호출하여 Order를 저장하며, SagaManager를 이용하여 CreateOrderSaga같은 사가를 생성한다.
// 예제 4-1 OrderService 클래스와 createOrder() 메서드

@Transactional
public class OrderService {
  
  @Autowired
  private OrderRepository orderRepository;

  @Autowired
  private RestaurantRepository restaurantRepository;

  @Autowired
  private SagaManager<CreateOrderSagaState> createOrderSagaManager;

  @Autowired
  private DomainEventPublisher eventPublisher;


  


  public Order createOrder(OrderDetails orderDetails) {
    ...
    ResultWithEvents<Order> orderAndEvents = Order.createOrder(...); // Order 생성
    Order order = orderAndEvent.result;
    orderRepository.save(order) // DB에 Order 저장
    
    eventPublisher.publish(Order.class, Long.toString(order.getId()), orderAndEvents.events); // 도메인 이벤트 발행
    
    CreateOrderSagaState data = new CreateOrderSagaState(order.getId(), orderDetails); // CreateOrderSaga 생성
    CreateOrderSagaManager.create(data, Order.class, order.getId());
   
    return order;
  }
  
}
  • createOrder()는 정적 팩토리 메서드 Order.createOrder()를 호출하여 Order를 생성한 후, OrderRepository로 Order를 저장함.
  • 그리고 새로 저장된 Order 및 OrderDetails의 ID가 포함된 CreateOrderSagaState를 SagaManager.create()에 넘겨 CreateOrderSaga를 생성
  • SagaManager가 사가 오케스트레이터 인스턴스를 생성하면, 곧바로 첫 번째 사가 참여 서비스에게 커맨드 메시지가 전달되고 사가 오케스트레이터를 DB에 저장한다.

4.4.2 주문 생성 사가 구현

  • CreateOrderSaga: 사가의 상태 기계를 정의한 싱글톤 클래스, Create OrderSagaState로 커맨드 메시지를 생성하고, 사가 참여자 프록시 클래스가 지정한 메시지 채널을 통해 참여하는 서비스에 메시지를 전달한다.
  • CreateOrderSagaState: 사가의 저장 상태. 커맨드 메시지를 생성함
  • 사가 참여자 프록시 클래스: 프록시 클래스마다 커맨드 채널, 커맨드 메시지 타입, 반환형으로 구성된 사가 참여자의 메시징 API를 정의한다.

CreateOrderSaga 오케스트레이터

예제 4-2, 4-3 CreateOrderSaga 데피니션


package net.chrisrichardson.ftgo.orderservice.sagas.createorder;

import io.eventuate.tram.sagas.orchestration.SagaDefinition;
import io.eventuate.tram.sagas.simpledsl.SimpleSaga;
import net.chrisrichardson.ftgo.orderservice.sagaparticipants.*;
import net.chrisrichardson.ftgo.kitchenservice.api.CreateTicketReply;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class CreateOrderSaga implements SimpleSaga<CreateOrderSagaState> {


  private Logger logger = LoggerFactory.getLogger(getClass());

  private SagaDefinition<CreateOrderSagaState> sagaDefinition;

  public CreateOrderSaga(OrderServiceProxy orderService, ConsumerServiceProxy consumerService, KitchenServiceProxy kitchenService,
                         AccountingServiceProxy accountingService) {
    this.sagaDefinition =
             step()
              .withCompensation(orderService.reject, CreateOrderSagaState::makeRejectOrderCommand)
            .step()
              .invokeParticipant(consumerService.validateOrder, CreateOrderSagaState::makeValidateOrderByConsumerCommand)
            .step()
              .invokeParticipant(kitchenService.create, CreateOrderSagaState::makeCreateTicketCommand) // 포워드 트랜잭션 정의
              .onReply(CreateTicketReply.class, CreateOrderSagaState::handleCreateTicketReply) // 성공 응답을 수신하면 handleCreateTicketReply() 호출
              .withCompensation(kitchenService.cancel, CreateOrderSagaState::makeCancelCreateTicketCommand) // 보상 트랜잭션 정의
            .step()
              .invokeParticipant(accountingService.authorize, CreateOrderSagaState::makeAuthorizeCommand)
            .step()
              .invokeParticipant(kitchenService.confirmCreate, CreateOrderSagaState::makeConfirmCreateTicketCommand)
            .step()
              .invokeParticipant(orderService.approve, CreateOrderSagaState::makeApproveOrderCommand)
            .build();

  }


  @Override
  public SagaDefinition<CreateOrderSagaState> getSagaDefinition() {
    return sagaDefinition; // 사가 데피니션을 반환
  }


}
  • 이벤추에이트 트램 사가 프레임워크에서 제공되는 dsl을 이용하여 정의
  • CreateOrderSaga 생성자는 사가 데피니션을 생성하여 sagaDefinition 필드에 세팅. getSagaDefinition()은 사가 데피니션을 반환하는 메서드.
  • invokeParticipant()은 포워드 트랜잭션을 정의한 메서드. CreateOrderSagaState.makeCreateTicketCommand()CreateTicket 커맨드 메시지를 생성한 후, KitchenService.create에 지정된 채널로 보낸다.
  • onReply()로 호출해서 주방 서비스로부터 성공 응답을 받으면 handleCreateTicketReply()를 호출한다.
  • withCompensation() 클래스로 보상 트랜잭션을 정의하고, 실패 시 makeCancelCreateTicketCommand()를 호출한다.

CreateOrderSagaState 클래스

// 예제 4-4 CreateOrderSagaState는 사가 인스턴스 상태를 저장한다.

public class CreateOrderSagaState {


  private Long orderId;

  private OrderDetails orderDetails;
  private long ticketId;

  public Long getOrderId() {
    return orderId;
  }

  private CreateOrderSagaState() {
  }

  public CreateOrderSagaState(Long orderId, OrderDetails orderDetails) { // OrderService가 호출하여 CreateOrderSagaState 인스턴스를 생성
    this.orderId = orderId;
    this.orderDetails = orderDetails;
  }

  @Override
  public boolean equals(Object o) {
    return EqualsBuilder.reflectionEquals(this, o);
  }

  CreateTicket makeCreateTicketCommand() { // CreateTicket 커맨드 메시지 생성
    return new CreateTicket(getOrderDetails().getRestaurantId(), getOrderId(), makeTicketDetails(getOrderDetails()));
  }

  void handleCreateTicketReply(CreateTicketReply reply) { // 새로 만든 티켓 ID 저장
    logger.debug("getTicketId {}", reply.getTicketId());
    setTicketId(reply.getTicketId());
  }

  CancelCreateTicket makeCancelCreateTicketCommand() { // CancelCreateTicket 커맨드 메시지 생성
    return new CancelCreateTicket(getOrderId());
  }

}
  • CreateOrderSagaCreateOrderSagaState를 호출하여 커맨드 메시지를 작성하고, 생성된 메시지를 KitchenServiceProxy 같은 클래스의 끝점으로 전달한다.

KitchenServiceProxy 클래스

  • 주방 서비스의 커맨드 메시지 3개의 엔드포인트를 정의함.
// 예제 4-5 KitchenServiceProxy는 KitchenService의 커맨드 메시지 끝점을 정의한다.
package net.chrisrichardson.ftgo.orderservice.sagaparticipants;

import io.eventuate.tram.commands.common.Success;
import io.eventuate.tram.sagas.simpledsl.CommandEndpoint;
import io.eventuate.tram.sagas.simpledsl.CommandEndpointBuilder;
import net.chrisrichardson.ftgo.kitchenservice.api.*;

public class KitchenServiceProxy {

  public final CommandEndpoint<CreateTicket> create = CommandEndpointBuilder
          .forCommand(CreateTicket.class)
          .withChannel(KitchenServiceChannels.kitchenServiceChannel)
          .withReply(CreateTicketReply.class)
          .build();

  public final CommandEndpoint<ConfirmCreateTicket> confirmCreate = CommandEndpointBuilder
          .forCommand(ConfirmCreateTicket.class)
          .withChannel(KitchenServiceChannels.kitchenServiceChannel)
          .withReply(Success.class)
          .build();
  public final CommandEndpoint<CancelCreateTicket> cancel = CommandEndpointBuilder
          .forCommand(CancelCreateTicket.class)
          .withChannel(KitchenServiceChannels.kitchenServiceChannel)
          .withReply(Success.class)
          .build();

}
  • 프록시 클래스가 반드시 필요한 것은 아니나, 장점이 두 가지가 존재한다.
    • 프록시 클래스는 타입이 정해진 엔드포인트를 정의하므로 엉뚱한 메시지가 서비스에 전달될 일은 거의 없음.
    • 프록시 클래스는 잘 정의된 서비스 호출 API라서 코드를 이해하고 테스트하기가 쉽다.

이벤추에이트 트램 사가 프레임워크

  • sagas.orchestration 패키지는 이 프레임워크에서 가장 복잡함. 사가 기초 인터페이스 SimpleSaga, 사가 인스턴스를 생성/관리하는 클래스 SagaManager가 이 패키지에 존재
  • SagaManager는 사가를 저장하고, 자신이 생성한 커맨드 메시지를 전송하고, 응답 메시지를 구독하고, 사가를 호출하여 응답을 처리한다.

  • orderService가 사가를 생성할 때 이벤트 순서는 다음과 같다.
    • orderServicecreateOrderSagaState를 생성한다.
    • orderServiceSagaManager를 호출하여 사가 인스턴스를 생성한다.
    • SagaManager는 사가 데피니션의 첫 번째 단계를 실행한다.
    • createOrderSagaState를 호출하여 커맨드 메시지를 생성한다.
    • SagaManager는 커맨드 메시지를 사가 참여자 (소비자 서비스)에 보낸다.
    • SagaManager는 사가 인스턴스를 DB에 저장한다.

  • SagaManager가 소비자 서비스의 응답을 수신할 때 이벤트 순서는 아래와 같다.

    • 이벤추에이트 트램은 소비자 서비스의 응답을 SagaManager에 전달한다.
    • SagaManager는 DB에서 사가 인스턴스를 조회한다.
    • SagaManager는 그 다음 사가 데피니션 단계를 실행한다.
    • createOrderSagaState를 호출하여 커맨드 메시지를 생성한다.
    • SagaManager는 커맨드 메시지를 사가 참여자 (주방 서비스)에 보낸다.
    • SagaManager는 사가 인스턴스를 DB에 저장한다.
  • 사가 참여 서비스가 실패하면 SagaManager는 보상 트랜잭션을 역순으로 실행한다.

4.4.3 OrderCommandHandlers 클래스

  • 사가가 전송한 커맨드 메시지를 담당할 핸들러 메서드는 OrderCommandHandlers 클래스에 정의한다.
  • 핸들러 메서드는 OrderService를 호출하여 주문 업데이트 후 응답 메시지를 생성한다. SagaCommandDispatcher는 커맨드 메시지를 적절한 핸들러 메서드에 보내고 응답을 반환하는 클래스다.

// 예제 4-6 OrderCommandHandlers 클래스
public class OrderCommandHandlers {

  @Autowired
  private OrderService orderService;

  public CommandHandlers commandHandlers() {
    return SagaCommandHandlersBuilder
          .fromChannel("orderService")
          .onMessage(ApproveOrderCommand.class, this::approveOrder)
          .onMessage(RejectOrderCommand.class, this::rejectOrder)

          ...
          .build();

  }

  public Message approveOrder(CommandMessage<ApproveOrderCommand> cm) {
    long orderId = cm.getCommand().getOrderId(); 
    orderService.approveOrder(orderId); // Order를 승인 상태로 변경
    return withSuccess(); // 제네릭 성공 메시지 반환
  }


  public Message rejectOrder(CommandMessage<RejectOrderCommand> cm) {
    long orderId = cm.getCommand().getOrderId();
    orderService.rejectOrder(orderId); // Order를 거부 상태로 변경
    return withSuccess();
  }

}
  • 각 핸들러 메서드는 커맨드 메시지를 매개변수로 받아 OrderService를 호출한 후, 응답 메시지를 반환한다.

4.4.4 OrderServiceConfiguration 클래스

// 예제 4-7 OrderServiceConfiguration은 OrderService의 스프링 빈이 정의된 구성 클래스다
package net.chrisrichardson.ftgo.orderservice.domain;

import io.eventuate.tram.events.publisher.DomainEventPublisher;
import io.eventuate.tram.events.publisher.TramEventsPublisherConfiguration;
import io.eventuate.tram.sagas.orchestration.SagaCommandProducer;
import io.eventuate.tram.sagas.orchestration.SagaManager;
import io.eventuate.tram.sagas.orchestration.SagaManagerImpl;
import io.eventuate.tram.sagas.orchestration.SagaOrchestratorConfiguration;
import io.micrometer.core.instrument.MeterRegistry;
import net.chrisrichardson.ftgo.common.CommonConfiguration;
import net.chrisrichardson.ftgo.orderservice.sagaparticipants.AccountingServiceProxy;
import net.chrisrichardson.ftgo.orderservice.sagaparticipants.ConsumerServiceProxy;
import net.chrisrichardson.ftgo.orderservice.sagaparticipants.KitchenServiceProxy;
import net.chrisrichardson.ftgo.orderservice.sagaparticipants.OrderServiceProxy;
import net.chrisrichardson.ftgo.orderservice.sagas.cancelorder.CancelOrderSaga;
import net.chrisrichardson.ftgo.orderservice.sagas.cancelorder.CancelOrderSagaData;
import net.chrisrichardson.ftgo.orderservice.sagas.createorder.CreateOrderSaga;
import net.chrisrichardson.ftgo.orderservice.sagas.createorder.CreateOrderSagaState;
import net.chrisrichardson.ftgo.orderservice.sagas.reviseorder.ReviseOrderSaga;
import net.chrisrichardson.ftgo.orderservice.sagas.reviseorder.ReviseOrderSagaData;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import java.util.Optional;

@Configuration
@Import({TramEventsPublisherConfiguration.class, SagaOrchestratorConfiguration.class, CommonConfiguration.class})
public class OrderServiceConfiguration {
  // TODO move to framework

  @Bean
  public SagaCommandProducer sagaCommandProducer() {
    return new SagaCommandProducer();
  }

  @Bean
  public OrderService orderService(RestaurantRepository restaurantRepository, OrderRepository orderRepository, DomainEventPublisher eventPublisher,
                                   SagaManager<CreateOrderSagaState> createOrderSagaManager,
                                   SagaManager<CancelOrderSagaData> cancelOrderSagaManager ...) {
    return new OrderService(orderRepository, eventPublisher, restaurantRepository,
            createOrderSagaManager, cancelOrderSagaManager ...);
  }

  @Bean
  public SagaManager<CreateOrderSagaState> createOrderSagaManager(CreateOrderSaga saga) {
    return new SagaManagerImpl<>(saga);
  }

  @Bean
  public CreateOrderSaga createOrderSaga(OrderServiceProxy orderService, ConsumerServiceProxy consumerService, KitchenServiceProxy kitchenServiceProxy, AccountingServiceProxy accountingService) {
    return new CreateOrderSaga(orderService, consumerService, kitchenServiceProxy, accountingService);
  }

  @Bean
  public SagaManager<CancelOrderSagaData> CancelOrderSagaManager(CancelOrderSaga saga) {
    return new SagaManagerImpl<>(saga);
  }

  @Bean
  public SagaCommandDispatcher orderCommandHandlersDispatcher(OrderCommandHandlers orderCommandHandlers) {
    return sagaCommandDispatcherFactory.make("orderService", orderCommandHandlers.commandHandlers());
  }


  @Bean
  public KitchenServiceProxy kitchenServiceProxy() {
    return new KitchenServiceProxy();
  }

  @Bean
  public OrderServiceProxy orderServiceProxy() {
    return new OrderServiceProxy();
  }

  • 이 클래스에는 orderService, creatOrderSaga, orderCommandHandlers, orderCommandHandlersDispatcher 등 다양한 스프링 빈이 저장되어 있고, KitchenServiceProxy, orderServiceProxy 등 프록시 클래스를 가리키는 스프링 빈도 있다.
  • CreatOrderSaga는 주문 서비스의 여러 사가 중 하나일 뿐이다. 기타 여러 시스템 작업 역시 사가를 사용함. 예를 들어 cancleOrder()는 주문 취소 사가, reviseOrder()는 주문 변경 사가를 사용한다.
  • 그러므로 여러 서비스가 REST, gRPC 같은 동기 프로토콜을 사용하는 API를 통해 클라이언트와 통신하지만 서비스 간 통신은 대부분 비동기 메시징을 사용한다.

스터디 하면서 논의한 내용들

  • Saga에서 격리성(I)이 존재하지 않는 이유.
    • 분리된 데이터베이스 환경에서 각각의 로컬 트랜잭션이 합쳐져 하나의 트랜잭션을 만드는 구조라, 이미 각각의 로컬 트랜잭션은 디비에 커밋이 된 상태다. 그래서 격리성을 어플리케이션에서 구현해줘야 한다. (시맨틱 락)
  • 코레오그래피 사가의 가장 큰 단점
    • 로직을 추가 시 아키텍처가 점점 복잡해진다.
    • 예를 들어 주문 완료 로직이 주문 완료 처리 → 사용한 적립금 처리 → 사용한 상품권 처리 → 재고 차감 이라고 가정하자. 이 때, 새로운 요구사항으로 재고 차감 전에 쿠폰 사용 처리라는 로직을 추가해달라고 하면 서비스 1개를 새로 만들고 2개의 서비스를 수정해야 한다.
  • 오케스트레이션 사가에서 오케스트레이터가 비즈니스 로직을 가지면 안 되는 이유.
    • 오케스트레이터에서 비즈니스 로직을 구현하게 되면 오케스트레이터와 연관되어 있는 분리되어 있는 서비스들이 모두 오케스트레이터와 비즈니스 적으로 연관이 되게 된다. 오케스트레이터가 다운이 되면 연관된 모든 서버에도 장애가 발생할 확률이 높다.

반대로 오케스트레이션으로 구현을 했을 경우에는 해당 서비스를 새로 만들고 오케스트레이션에 코드만 추가하면 되죠

Reference

마이크로서비스 패턴 (저자: 크리스 리처드슨)

profile
평범한 백엔드 개발자

0개의 댓글