Architecture

책 <마이크로 서비스 패턴> (읽는 중...)

팅리엔 2024. 10. 23. 00:48
  • 서비스 간 공유 라이브러리를 사용하고 싶은 유혹이 들겠지만, 변경 가능성이 조금이라도 있다면 별도의 서비스로 구현하는 것이 낫다. 
  • 서비스의 크기가 중요한 게 아니라 가장 짧은 시간에, 다른 팀과 협동하는 부분을 최소로 하여 개발 가능한 서비스를 설계해야 한다. 다른 서비스의 변경분 때문에 내가 맡은 서비스도 계속 바꾸어야 한다면 느슨하게 결합되지 않았다는 반증이다. 이건 distributed monolith다...
  • 1단계: 시스템의 작업을 식별한다.
    • "나는 음식점 주인으로서 주문을 접수해서 ~을 하고 싶다"
    • 고수준의 애플리케이션 도메인 모델을 대략적으로 그려본다. 도메인 명사를 분석하기. 핵심 클래스 생각해보기. 
      (주문 접수: Consumer, Order, OrderLineItem, DeliveryInfo, Restaurant, MeuItem, Courier, Address)
    • 애플리케이션이 어떤 요청을 처리할지 식별한다. Command or Query.
      커맨드 - 사용자 시나리오에 포함된 동사를 본다. 작업 선행 조건, 후행 조건을 생각해본다.
  • 2단계: 어떻게 여러 서비스로 분해할지 결정한다.
    • 기술 개념이 중요한 게 아니다, 비즈니스 개념 중심으로 서비스를 나눈다.
    • 조직의 목표, 구조, 비즈니스 프로세스를 분석하고 식별한다. 
    • 단일 책임 원칙, 공동 폐쇄 원칙(Common Closure Principle: 패키지의 클래스들은 동일한 유형의 변경에 대해 닫혀 있어야 한다. 패키지에 영향을 주는 변경은 그 패키지에 속한 모든 클래스에 영향을 끼친다. 이 말은 어떤 두 클래스가 동일한 사유로 맞물려 변경되면 동일한 패키지에 있어야 한다는 말과 같다.)
    • 장애물 - 다 트레이드오프다...
      • 네트워크 지연, 서비스 간 왕복 횟수의 증가 - 여러 객체를 한 번의 왕복으로 가져오는 배치 api, 하나의 서비스로 합치기
      • 동기 통신으로 인한 가용성 저하 - 비동기 메시징
      • 여러 서비스에 걸친 데이터 일관성 유지 필요 - SAGA 패턴 (메시징을 이용한 트랜잭션) 으로 최종 일관성 보장
      • 만능 클래스의 존재 - DDD를 적용하고 서비스마다 도메인 모델을 따로 설계하여 만능 클래스 제거
  • 3단계: 서비스별로 API를 정의한다.

 

 

 

3. 프로세스간 통신

  • 프론트엔드 개발자랑 API 먼저 협의 완료하기
  • API 발전시키기 - 새 기능의 추가나 기존 기능의 변경. 신구 버전의 동시 실행(클라이언트를 강제로 모두 업그레이드 시킬 수는 없다). 전략을 잘 세워야 한다. ㅠㅠ
    • 버저닝: semantic versioning (버전 매기는 규칙) - majon.minor.patch
      major: 하위 호환되지 않는 변경분
      minor: 하위 호환되는 변경분
      patch: 하위 호환되는 오류
    • 가급적 하위 호환성을 보장하는 방향으로 API를 변경해야 한다. (뭔가 API 추가하는 변경은 대부분 하위 호환됨)
    • 중대한 대규모 변경에도 일정 기간 서비스는 신구 버전 API를 모두 지원해야 한다. (API 경로에 /v2/를 붙인다든가)
  • 포맷: 텍스트(xml, json) vs 이진 메시지(프로토콜버퍼, 아브로)
  • REST 성숙도
    • 0: 유일한 url 끝점에 post 요청으로 서비스 호출. 요청과 액션을 지정한다.
    • 1: 리소스 개념을 지원. 요청과 매개변수가 지정된 post 요청.
    • 2: http 동사를 이용해 액션을 수행. 요청 쿼리 매개변수 및 바디. 서비스는 get 요청을 캐시하는 등 웹 인프라를 활용한다.
    • 3: HATEOAS 원칙에 기반한 설계. 하드코딩한 url을 클라이언트 코드에 심지 않아도 된다. 
  • gRPC - http는 한정된 동사만 지원해서 다양한 업데이트 작업(order라면 cancel, revise 등)을 지원하는 REST api를 설계하기 쉽지 않다. 그래서 gRPC가 등장.
    • 다양한 언어로 클라이언트/서버를 작성할 수 있다. 
    • 다양한 업데이트 작업이 포한된 api를 설계하기 쉽다.
    • 큰 메시지를 교환할 때 컴팩트하고 효율적이다.
    • 양방향 스트리밍 덕분에 RPI, 메시징 두 가지 통신 방식 모두 가능하다.
  • 분산 서비스 - 부분 실패의 가능성이 항상 존재한다. => 부분 실패 처리: 회로 차단기 패턴 circuir breaker
    • 다른 서비스를 호출할 때 자기 스스로를 방어하는 방법: 네트워크 타임아웃, 미처리 요청 개수 제한, 회로 차단기 패턴
  • 서비스 디스커버리 : 애플리케이션 서비스 인스턴스의 네트워크 위치를 DB화 한다. 서비스 레지스트리. 서비스 인스턴스가 시작/종료할 때마다 서비스 레지스트리가 업데이트 된다. 클라이언트가 서비스를 호출하면 서비스 디스커버리가 서비스 레지스트리에서 가용 서비스 인스턴스 목록을 가져오고, 그 중 한 서비스로 요청을 라우팅한다. 
  • 비동기 메시징 패턴
    • 메시지는 헤더와 바디로 구성된다. 
    • 메시지 채널: point-to-point 채널(컨슈머 중 딱 하나에만 메시지 전달), publish-subscribe 채널(같은 채널을 바라보는 모든 컨슈머에 메시지 전달).
  • 메시지 브로커를 선택할 때 생각할 항목들
    • 다양한 프로그래밍 언어를 지원할수록 좋다.
    • AMQP, STOMP 등 표준 프로토콜을 지원하는 제품일수록 좋다.
    • 메시지 순서가 유지되는가?
    • 어떤 종류의 전달 보장을 하는가?
    • 브로커가 고장나도 문제가 없도록 메시지를 디스크에 저장하는가?
    • 컨슈머가 메시지 브로커에 다시 접속할 경우, 접속이 중단된 시간에 전달된 메시지를 받을 수 있는가?
    • 얼마나 확장성이 좋은가?
    • 종단 간 지연 시간은 얼마나 되는가?
    • 경쟁사의 컨슈머를 지원하는가?
  • 메시지 순서 유지 - 샤딩(파티셔닝)된 채널. 샤드키에 따라 동일한 샤드에 이벤트 메시지가 발행된다.
  • 중복 메시지 처리 - 적어도 한 번 전달 / 정확히 한 번 전달
    • 멱등한 메시지 핸들러
      • 그러나 멱등한 애플리케이션 로직은 실제로 별로 없다.
      • 중복 메시지 or 순서가 안 맞는 메시지(e.g. 주문 완료 이전에 주문 취소)는 오류를 일으킨다.
    • 메시지를 추적하고 중복 걸러내기
      • 컨슈머가 메시지 ID를 전용 테이블(e.g. PROCESSED_MESSAGES) 또는 일반 테이블에 insert하고, 중복된 메시지라면 무시한다.
  • 트랜잭셔널 메시징
    • 서비스는 보통 DB를 업데이트하는 트랜잭션의 일부로 메시지를 발행한다. DB 업데이트와 메시지 전송을 한 트랜잭션으로 묶지 않으면, DB 업데이트 후 메시지는 아직 전송되지 않은 상태에서 서비스가 중단될 수 있다.
    • DB 테이블을 메시지큐로 활용: 트랜잭셔널 아웃박스 패턴 - 테이블이 임시 메시지큐 역할을 하고, 메시지 relay는 메시지큐 테이블을 읽어 메시지 브로커에 메시지를 발행한다. 어떻게 읽어서 발행?
      • polling 발행기 패턴: select 쿼리를 주기적으로 실행해서 조회한 메시지를 메시지 브로커로 보내고 테이블에서 메시지를 삭제한다. 규모가 작을 경우 쓸만한 단순한 방법. 자주 폴링할 때 발생하는 비용, NoSQL 쿼리 능력을 고려해야 한다. 
      • transaction log tailing 패턴: DB 트랜잭션 로그(커밋 로그)를 테일링한다. transaction log miner로 이 로그를 읽어서 변경분을 하나씩 메시지 브로커에 발행한다. (e.g. Debezium, LinkedIn Databus, DynamoDB Streams)
  • 서비스가 메시지를 주고받으려면 라이브러리가 필요하다. 메시지 브로커에도 클라이언트 라이브러리가 있지만 다음과 같은 문제가 있다.
    • 메시지 브로커 API에 메시지를 발행하는 비즈니스 로직이 클라이언트 라이브러리와 결합된다.
    • 메시지 브로커의 클라이언트 라이브러리는 대부분 저수준으로 단순 반복적인 코드이다. 기본적인 메시지 소통 수단일 뿐, 고수준의 상호 작용 스타일은 지원하지 않는다.
  • 가용성을 최대화하려면 동기 통신을 최소화해야 한다. 메시지를 사용해서 요청/응답(!) 하기(응답도 메시지로 받아서 사용자에게 응답해주는 방식도 있단다). 데이터 레플리카 사용하기(주문 서비스가 음식점 서비스의 데이터 레플리카를 갖고 있다면 주문 생성을 요청할 때 굳이 상호작용할 필요가 없다 - 그럼 일관성 유지가 또 문제야). 응답 반환 후 마무리 하기.

 

 

 

4. 트랜잭션 관리 : 사가 패턴

  • 여러 DB에 걸쳐 데이터 일관성을 유지하기
  • 2단계 커밋(2PC)
    • XA(X/Open DTP(Distributed Transaction Processing)) 모델이 분산 트랜잭션 관리의 사실상 표준 
    • XA는 2PC를 이용해 전체 트랜잭션 참여자가 반드시 커밋 아니면 롤백을 하도록 보장한다. 
    • SQL DB는 대부분 XA와 호환되지만 NoSQL과 일부 메시지브로커(RabbitMQ, 카프카 등)은 분산 트랜잭션을 지원하지 않는다.
    • 동기 IPC 형태라서 가용성이 떨어진다. 참여한 서비스가 모두 가동 중이어야 커밋할 수 있다.
    • 로컬 트랜잭션과 프로그래밍 모델이 동일하므로 매력적이지만 요즘 애플리케이션과는 잘 맞지 않는다. 
  • 사가 패턴
    • 서비스는 로컬 트랜잭션이 완료되면 메시지를 발행하여 다음 사가 단계를 트리거한다.
    • 보상 트랜잭션으로 변경분을 롤백한다. n번째 사가 트랜잭션이 실패하면 이전 (n-1)개의 트랜잭션을 역순으로 undo해야 한다. 
    • 모든 단계에 보상 트랜잭션이 필요한 것은 아니다. read-only 단계(e.g. 파라미터 validation)나 항상 성공하는 단계(e.g. authorization)는 보상 트랜잭션이 필요없다. 
    • 아래 표에서, 소비자의 신용카드 승인이 실패하면 보상 트랜잭션은 다음 순서대로 작동될 것이다.
      1. 주문 서비스: 주문을 APPROVAL_PENDING 상태로 생성한다.
      2. 소비자 서비스: 소비자가 주문을 할 수 있는지 확인한다.
      3. 주방 서비스: 주문 내역을 확인하고 티켓을 CREATE_PENDING 상태로 생성한다.
      4. 회계 서비스: 소비자의 신용카드 승인 요청이 거부된다.
      5. 주방 서비스: 티켓 상태를 CREATE_REJECTED로 변경한다. (보상 트랜잭션)
      6. 주문 서비스: 주문 상태를 REJECTED로 변경한다. (보상 트랜잭션)

  • 코레오그래피 Choreography 사가
    • 참여자participant가 각자 서로 이벤트를 교환한다.
    • 사가 참여자가 자신의 DB를 업데이트하고 DB 트랜잭션의 일부로 이벤트를 발행하도록 해야한다.
    • 사가 참여자는 자신이 수신한 이벤트와 자신이 가진 데이터를 연관지을 수 있어야 한다. (상관관계 ID가 포함된 이벤트를 발행한다.)
    • 장점
      • 단순하다 : 비즈니스 객체를 생성, 수정, 삭제할 때 서비스가 이벤트를 발행한다.
      • 느슨한 결합: 참여자는 이벤트를 구독할 뿐 서로를 직접 알지 못한다. 
    • 단점
      • 이해하기 어렵다: 오케스트레이션 사가와 달리, 사가를 어느 한 곳에 정의한 것이 아니라서 여러 서비스에 구현 로직이 분산되어 있다. 어떤 사가가 어떻게 동작하는지 개발자가 이해하기 어려운 편이다.
      • 서비스간 순환 의존성: 참여자가 서로 이벤트를 구독하는 특성상, 순환 의존성이 발생하기 쉽다. (e.g. 주문 서비스 → 회계 서비스 → 주문 서비스) 이것이 반드시 문제인 것은 아니지만 설계 취약점이다.
      • 단단히 결합될 위험성: 사가 참여자는 각자 자신에게 영향을 미치는 이벤트를 모두 구독해야 한다. (e.g. 회계 서비스는 신용카드 과금/환불과 관련된 모든 이벤트를 구독해야 한다. 따라서 회계 서비스는 주문 서비스에 구현된 주문 주기와 맞물려 업데이트 되어야 하는 위험이 있다.)
  • 오케스트레이션 Orchestration 사가
    • 중앙제어장치가 참여자가 해야할 일을 지시한다.
    • 오케스트레이션 클래스는 커맨드/비동기 응답 상호작용으로 참여자와 통신해 사가 참여자가 할 일을 알려준다. 즉, 사가 단계를 실행하기 위해 해당 참여자가 무슨 일을 해야하는지 커맨드 메시지에 적어 보낸다. 사가 참여자가 작업을 마치고 응답 메시지를 오케스트레이터에 주면, 오케스트레이터는 응답 메시지를 처리한 후 다음 사가 단계를 어느 참여자가 수행할지 결정한다.
    • e.g. CreateOrderSaga 클래스가 비동기로 주방 서비스 등의 사가 참여자를 호출해서 커맨드 메시지를 전송, 자신의 응답 채널에서 메시지를 읽어서 다음 사가 단계를 결정
      1. 주문 서비스가 주문 및 '주문 생성 사가 오케스트레이터'를 생성한다.
      2. 사가 오케스트레이터가 '소비자 확인 커맨드'를 소비자 서비스에 전송한다. 
      3. 소비자 서비스는 '소비자 확인 메시지'를 응답한다.
      4. 사카 오케스트레이터는 '신용카드 승인 커맨드'를 회계 서비스에 전송한다.
      5. 회계 서비스는 '신용카드 승인됨 메시지'를 응답한다.
      6. 사가 오케스트레이터는 '주문 승인 커맨드'를 주문 서비스에 전송한다. (사가 오케스트레이터 자신이 주문 서비스의 한 컴포넌트이지만, 일관성 차원에서 주문 서비스가 마치 다른 참여자인 것처럼 취급하는 것이다.)  
    • 상태 기계 state machine
      • 상태 기계는 상태(state)와 이벤트에 의해 트리거 되는 상태 전이(transition)로 구성된다.
      • 전이가 발생할 때마다 액션이 일어나는데, 사가의 액션은 사가 참여자를 호출하는 작용이다.
      • 장점
        • 의존 관계 단순화: 참여자는 오케스트레이터를 호출하지 않으므로 순환 의존성이 발생하지 않는다.
        • 낮은 결합도: 각 서비스는 오케스트레이터가 호출하는 API를 구현할 뿐, 사가 참여자가 발행하는 이벤트는 몰라도 된다.
        • 관심사를 더 분리하고 비즈니스 로직을 단순화: 사가 편성 로직이 사가 오케스트레이터 한 곳에만 있으므로 도메인 객체는 더 단순해진다. 
      • 단점
        • 비즈니스 로직을 오케스트레이터에 너무 많이 중앙화하면 똑똑한 오케스트레이터 하나가 깡통 서비스에 일일이 할 일을 지시하는 모양새가 될 수 있다. → 오케스트레이터가 순서화만 담당하고 여타 비즈니스 로직은 갖고 있지 않도록 설계해서 해결한다. 
    • 비격리 문제 처리
      • 격리성: 동시에 실행 중인 여러 트랜잭션의 결과가 어떤 순서대로 실행된 결과와 동일함을 보장한다. 
      • 비격리는 도저히 용납되지 못할 문제처럼 보이지만, 실제로는 성능 향상을 위해 격리 수준을 낮추는 경우가 흔하다.
      • 사가는 격리성이 빠져 있다.
      • 비격리로 인한 비정상
        • 소실된 업데이트 lost updates: 한 사가의 변경분을 다른 사가가 미처 못 읽고 덮어쓴다. 
        • 더티 읽기 dirty reads: 사가 업데이트를 하지 않은 변경분을 다른 트랜잭션이나 사가가 읽는다.
        • 퍼지/반복 불가능한 읽기 fuzzy/nonrepeatable reads: 한 사가의 상이한 두 단계가 같은 데이터를 읽어도 결과가 달라지는 현상. 다른 사가가 그 사이에 업데이트 했기 때문에.
      • 비격리 대책
        • 시맨틱 락 semantic lock: 애플리케이션 수준의락
        • 교환적 업데이트 commutative updates: 업데이트 작업은 어떤 순서로 실행해도 되게끔 설계
        • 비관적 관점 pressimistic view: 사가 단계 순서를 재조정하여 비즈니스 리스크 최소화
        • 값 다시 읽기 reread value: 데이터를 덮어 쓸 때 그 전에 변경된 내용은 없는지 값을 다시 읽고 확인
        • 버전 파일 version file: 순서를 재조정할 수 있게 업데이트 기록
        • 값에 의한 by value: 요청별 비즈니스 위험성을 기준으로 동시성 매커니즘을 동적 선택
      • 사가는 롤백 가능한 보상 트랜잭션 compensatable transaction, 사가의 진행/중단 지점에 위치한 피봇 트랜잭션 pivot transaction, 롤백할 필요 없이 완료가 보장되는 재시도 트랜잭션 retriable transaction, 이렇게 세 가지 트랜잭션으로 구성된다.
        authorizeCreditCard() 소비자 신용카드가 승인되면 이 사가는 반.드.시 완료된다. approveTicket(), approveOrder()는 피봇 트랜잭션 이후의 재시도 가능 트랜잭션이다.
      • 대책: 시맨틱 락 semantic lock
        • 보상 가능 트랜잭션이 생성/수정하는 레코드에 무조건 플래그를 세팅한다. 레코드가 아직 커밋 전이라서 변경될지 모른다는 표시를 하는 것이다. 플래그를 세팅해서 다른 트랜잭션이 레코드에 접근하지 못하게 락을 걸어놓거나, 다른 트랜잭션이 해당 레코드를 처리할 때 조심하도록 경고한다. 
        • 플래그는 재시도 가능 트랜잭션(사가 완료) 또는 보상 트랜잭션(사가 롤백)에 의해 해제된다. 
        • e.g. Order.state 필드를 *_PENDING 상태로 둔다. 주문에  접근하는 다른 사가한테 이 주문이 업데이트 되고 있으을 알린다. createOrder()에서 APPROVAL_PENDING이 되고, approveOrder()에서 APPROVED가 된다. rejectOrder()나 rejectTicket()은 이 필드를 REJECTED로 바꾼다.
          만약 APPROVAL_PENDING 상태의 주문을 cancelOrder() 하려면? 여러 방법이 있는데, 클라이언트에 에러를 반환하거나, 해당 주문이 언락될 때까지 기다리게 할 수도 있다.
      • 대책: 교환적 업데이트 commutative update
        • 업데이트를 교환적으로, 즉 어떤 순서로도 실행 가능하게 설계한다. 
        • e.g. Account의 debit()과 credit(). 사가를 롤백해야 한다면 단순히 반대되는 작업을 해서 업데이트를 undo하면 된다. 
      • 대책: 비관적 관점 pessimistic view
        • 더티 읽기로 인한 비즈니스 리스트를 최소화하기 위해 사가 단계의 순서를 재조정한다. 
        • e.g. 주문 취소 사가 단계를 다음과 같이 잡으면, 주문 생성 사가가 신용 잔고를 더티 읽기 해서 소비자 신용 한도를 초과하는 주문을 생성할 가능성을 줄일 수 있다. 이런 순서면 신용 잔고는 재시도 가능 트랜잭션에서 증가하므로 더티 읽기 가능성이 사라진다. (그니까, 할 거 다 하고 마지막에 돈 돌려준다는 거임)
          1. 주문 서비스: 주문을 취소 상태로 변경함
          2. 배달 서비스: 배달을 취소함
          3. 회계 서비스: 신용 잔고를 늘림
      • 대책: 값 다시 읽기 reread value
        • 소실된 업데이트 방지. 레코드를 업데이트 하기 전에 값을 다시 읽어 값이 변경되지 않았는지 확인한다. 값을 다시 읽었더니 변경되었다면 사가를 중단하고 나중에 재시작한다. 일종의 낙관적 오프라인 락.
      • 대책: 버전 파일 version file
        • 레코드에 수행한 작업을 하나하나 기록한다. 즉, 비교환적 작업을 교환적 작업으로 변환한다. 
        • e.g. 주문 생성 사가와 주문 취소 사가가 동시에 실행되는 경우. 시맨틱 락 대첵을 안 쓰면 신용카드가 승인되기 전에 신용카드 승인을 취소하는 말도 안 되는 상황이 일어날 수 있다. 순서가 안 맞는 요청을 회계 서비스가 받아 처리하려면, 작업이 도착하면 기록해 두었다가 정확한 순서대로 실행하면 된다. 일단 승인 취소 요청을 기록하고, 나중에 신용카드 승인 요청이 도착하면 이미 승인 취소 요청이 접수된 상태이니 승인 작업은 생략해도 되겠구나 인지하는 것이다.
      • 대책: 값에 의해 by value
        • 위험성이 낮은 요청은 위의 대책들을, 위험성이 큰 요청들은(like 큰 돈 거래) 분산 트랜잭션을 실행한다.
    •  예시) 주문 서비스 및 주문 생성 사가

      • Order
      • OrderService: 도메인 서비스. Order를 생성/수정한다. OrderRepository를 호출해 Order를 저장한다. SagaManager(eventuate tram saga framework)를 이용해 CreateOrderSaga 같은 사가를 생성한다.  
        @Transactional
        public class OrderService {
            @Autowired
            private SagaManager<CreateOrderSagaState> createOrderSagaManager;
            
            @Autowired
            private OrderRepository orderRepository;
            
            @Autowired
            private DomainEventPublisher eventPublisher;
            
            public Order createOrder(OrderDetails orderDetails) {
            	...
                // Order 생성
                ResultWithEvents<Order> orderAndEvents = Order.createOrder(...);
                Order order = orderAndEvents.result;
                
                // DB 저장
                orderRepository.save(order);
                
                // 도메인 이벤트 발행
                eventPublisher.publish(Order.class,
                                       Long.toString(order.getId()),
                                       orderAndEvents.event);
                                       
                // CreateOrderSaga 생성
                CreateOrderSagaState data = 
                	new CreateOrderSagaState(order.getId(), orderDetails);
                createOrderSagaManager.create(data, Order.class, order.getId());
                
                return order;
            }
            
            ...
        }
        Q. createOrderSagaManager.create에서 예외 발생 시 이미 publish된 이벤트는? 되돌릴 수 없는데?
      • OrderRepository
      • CreateOrderSage 클래스: 주문 생성 사가를 오케스트레이션
        public class CreateOrderSaga implements SimpleSaga<CreateOrderSagaState> {
            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)
                            .withCompensation(kitchenService.cancle,
                                              CreateOrderSageState::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;
                }
            }
        }
        • invokeParticipant() : 포워드 트랜잭션 정의
        • onReply(): 성공 응답 수신 시 행동 정의
        • withCompensation(): 보상 트랜잭션 정의
      • CreateOrderSagaState 클래스: 사가 인스턴스의 상태를 나타내는 클래스. 사가 참여자에게 보낼 메시지를 만든다. OrderService가 이 클래스의 인스턴스를 생성하고, 이벤추에이트 트램 사가 프레임워크가 이 인스턴스르르 DB에 저장한다. CreateOrderSaga는 CreateOrderSagaState를 호출하여 커맨드 메시지를 생성하고, 생성된 메시지를 KitchenServiceProxy 같은 클래스의 엔드포인트로 전달한다.
        public class CreateOrderSagaState {
            private Long orderId;
            private OrderDetails orderDetails;
            private long ticketId;
            
            public long getOrderId() {
                get orderId;
            }
            
            private CreateOrderSagaState(Long orderId, OrderDetails orderDetails) {
                this.orderId = orderId;
                this.orderDetails = orderDetails;
            }
            
            CreateTicket makeCreateTicketCommand() {
                return new CreateTicket(
                    getOrderDetails().getRestaurantId(), 
                    getOrderId(), 
                    makeTicketDetails(getOrderDetails()));
            }
            
            void handleCreateTicketReply(CreateTicketReply reply) {
                logger.debug("getTicketId {}", reply.getTicketId());
                setTicketId(reply.getTicketId());
            }
            
            CancelCreateTicket makeCancelCreateTicketCommand() {
                return new CancelCreateTicket(getOrderId());
            }
            
            ...
        }
      • KitchenServiceProxy 클래스: 주방 서비스의 커맨드 메시지 3개의 엔드포인트를 정의한다. 이런 프록시 클래스가 반드시 필요한 것은 아니다. 사가 참여자에게 직접 커맨드 메시지를 보낼 수도 있지만, 프록시 클래스를 사용하면 인터페이스를 정의할 수 있고, well-defined된 서비스 호출 API라서 코드를 이해하고 테스트하기 쉽다.  
        public class KitchenServiceProxy {
            public final CommandEndPoint<CreateTicket> create = 
                CommandEndpointBuilder
                    .forCommand(CreateTicket.class)
                    .withChannel(KitchenServiceChannels.ketchenServiceChannel)
                    .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 =
                CommandEndpointBuild
                    .forCommand(CancelCreateTicket.class)
                    .withChannel(KitchenServiceChannels.kitchenServiceChannel)
                    .withReply(Success.class)
                    .build();
        }
      • OrderCommandHandlers 클래스: 주문 생성 사가가 보낸 커맨드를 처리하는 어댑터 클래스. 커맨드 메시지 타입별 핸들러 메서드를 commandHandlers()에서 매핑하고, 각 핸들러 메서드는 커맨드 메시지를 파라미터로 받아 OrderService를 호출한 후 응답 메시지를 반환한다.
        public class OrderCommandHandlers {
            @Autowired
            private OrderService orderService;
            
            public CommandHandlers commandHandlers() {
                return SagaCommandHandlerBuilder
                    .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);
                return withSuccess();
            }
            
            public Message rejectOrder(CommandMessage<RejectOrderCommand> cm) {
                long orderId = cm.getCommand().getOrderId();
                orderService.rejectOrder(orderId);
                return withSuccess();
            }
        }
         

 

 

 

5. 비즈니스 로직 설계

  • MSA에서 복잡한 비즈니스 로직을 개발할 때의 문제점: 1) 서비스 경계를 넘나드는 객체 레퍼런스를 제거해야 한다. 2) MSA에서의 트랜잭션 관리 제약 조건 하에서 동작해야 한다. 여러 서비스에 걸쳐 데이터 일관성을 유지하려면 사가 패턴을 적용해야만 한다.
  • 유용한 해결책, DDD 애그리거트: 1) 애그리거트를 사용하면 객체 레퍼런스가 서비스 경계를 넘나들 일이 없다. 서로 PK를 이용해서 참조하기 때문에. 2) 한 트랜잭션으로 하나의 애그리거트만 생성/수정할 수 있다. => ACID 트랜잭션은 반드시 하나의 서비스 내부에만 걸리게 된다.
  • 객체지향적(도메인 모델 패턴) vs 절차지향적(트랜잭션 스크립트 패턴)
    • 객체 지향을 좀 지나치게 할 때가 있다. 간단한 비즈니스 로직은 절차적인 코드를 작성하는 게 더 합리적이다. 보통 behavior 구현 클래스와 state 보관 클래스가 따로 존재한다. 클래스 구성에 대한 고민 없이 단순하게 코딩할 수 있다. 하지만 비즈니스 로직이 복잡해지면 거의 관리 불가 상태가 된다. 
    • 객체 지향적 비즈니스 로직은 비교적 작은 클래스가 그물망처럼 얽히게 구성된다. 서비스 메서드는 거의 항상 비즈니스 로직이 포함된 영속화 도메인 객체에 책임을 위임하여 서비스 메서드는 단순해진다.

 

 

 

6. 비즈니스 로직 개발: 이벤트 소싱

  • 애그리거트를 일련의 이벤트 형태로 저장한다. 이벤트 소싱을 잘 활용하면 애그리거트가 생성/수정될 때마다 무조건 이벤트를 발행한다. 
  • 이벤트 소싱을 안 쓸 때의 문제점
    • 애그리거트 이력을 관리 용도로 온전히 보전하려면 개발자가 직접 코드를 구현해야 한다. 
    • 감사 로깅 코드 및 비즈니스 로직이 계속 분화하고 중복된다.
    • 이벤트 발행 로직이 비즈니스 로직에 추가된다. 개발자는 이벤트 생성 로직을 추가해야 되는데, 자칫 빼먹을 수도 있다.
  • 단점: 비즈니스 로직 작성 방법이 특이해 학습 시간이 필요하다. 이벤트 저장소를 쿼리하기가 쉽지 않아 CQRS 패턴을 적용해야 한다.  
  • 기존 영속화는 애그리거트를 테이블에, 필드를 컬럼에, 인스턴스를 로우에 각각 매핑한다. 이벤트 소싱은 일련의 이벤트로 저장한다. Order를 ORDER 테이블에 로우 단위로 저장하는 것이 아니라, Order 애그리거트를 EVENTS 테이블의 여러 로우로 저장한다. 각 로우가 바로 주문생성됨, 주문승인됨, 주문배달됨 등의 도메인 이벤트이다.
    • event_id, event_type(ORDER_CREATED, ORDER_APPROVED, ...), event_type(ORDER), entity_id, event_date(json 등 직렬화된 이벤트)
  • 애그리거트 생성/수정 시 애플리케이션은 애그리거트가 발생시킨 이벤트를 EVENT 테이블에 삽입한다. 그리고 애그리거트를 로드할 때 이벤트 저장소에서 이벤트를 가져와 재연한다. 
    • 1) 애그리거트의 이벤트를 로드한다.
    • 2) 기본 생성자를 호출하여 애그리거트 인스턴스를 생성한다.
    • 3) 이벤트를 하나씩 순회하며 apply()를 호출한다.

 

 

 

 

 

+

 

마이크로서비스 아키텍처(MSA). 서비스 개발팀 이야기

2015년 쯤 아키텍처 연구팀에 잠깐 있었는데, auto Scale in/out, 도커, 대용량 데이터베이스, 대용량 스토리지 등이 연구되고 있었다. 내가 있던 셀은 SaaS나 멀티테넌트 아키텍처가 주된 관심사 였다.

kihoonkim.github.io

 

 

Testing Strategies in a Microservice Architecture

The microservice architectural style presents challenges for organizing effective testing, this deck outlines the kinds of tests you need and how to mix them.

martinfowler.com