MSA환경에서 트랜잭션 관리하기

최창효·2023년 10월 31일
1
post-thumbnail

들어가기 앞서

이 글은 최근 2달간 진행한 학습용 프로젝트에 대한 회고 및 정리를 목적으로 작성된 글입니다.

MSA환경으로 온라인 쇼핑몰을 만드는 프로젝트였고, 저는 거기서 주문과 관련된 개발을 담당했습니다.

트랜잭션

트랜잭션이란 데이터베이스의 상태를 변화시키기 위해 수행하는 작업의 단위를 말합니다. 트랜잭션은 일련의 작업 묶음이 안정적으로 처리되는걸 보장해주는 개념이며, 트랜잭션의 4가지 특징인 ACID 중 Consistency(일관성)는 이 글의 중간에 핵심적으로 다뤄질 예정입니다.

MSA환경, 좀 더 정확히는 여러 데이터베이스에 걸쳐 하나의 작업 묶음을 처리해야 하는 상황에서는 우리가 일반적으로 사용하는 트랜잭션으로 여러 작업이 다 같이 진행되거나 혹은 모두 진행되지 않음(all or nothing)을 보장할 수 없습니다. 트랜잭션은 기본적으로 하나의 데이터베이스에 대한 여러 동작을 책임지기 때문입니다.

비유를 하자면 이런 느낌인 겁니다.

분산 트랜잭션

그렇기 때문에 MSA환경에서 트랜잭션을 보장하는 특별한 방법이 필요하고, 대표적으로 2PC(Two-Phase-Commit)와 SAGA패턴이 있습니다.
(분산 트랜잭션에 대한 구체적인 개념은 이전 포스팅에서 언급했으므로 여기서는 그 내용을 간략히만 적었습니다.)

2PC

2PC는 Coordinator라는 곳에서 트랜잭션을 시작합니다. Coordinator는 각각의 DB에게 순차적으로 커밋 가능 여부를 묻고(1Phase), 모든 DB가 커밋이 가능하면 각각의 DB에게 커밋을 진행하라는 명령을 전달하는(2Phase) 방식으로 진행됩니다.
2PC의 개별 DB는 Coordinator에게 커밋 가능하다는 응답을 보낸 뒤(1Phase) 커밋을 진행하라는 Coordinator의 명령이 오기까지(2Phase) Lock을 걸어두기 때문에 성능에 취약합니다.

SAGA

반면 SAGA패턴은 보상 트랜잭션을 이용해 데이터의 결과적 정합성을 보장하는 방식입니다.
SAGA에서의 원자성은 우리가 아는 일반적인 DB 트랜잭션의 ACID관점의 원자성이 아닙니다. DB에서의 롤백은 커밋 전에 발생하며 롤백이 일어나면 트랜잭션이 전혀 시작하지 않은 것처럼 되돌려집니다. 하지만 SAGA에서는 이미 트랜잭션이 발생했습니다. SAGA의 보상 트랜잭션을 통한 롤백은 의미적 롤백입니다. 한마디로 SAGA는 우리가 생각하는 완벽한 롤백을 해주지 못합니다!

OS에서 동시성이 여러 작업을 실제로 동시에 실행하는 게 아니라 엄청 빠른 Context Switching으로 인해 "동시에 실행되는 것처럼" 느껴지게 하는 것과 마찬가지로
SAGA의 보상 트랜잭션 역시 실제 트랜잭션이 일어난 게 아니라 "트랜잭션이 일어난 것처럼" 느껴지게 만드는 것입니다

SAGA패턴은 실제 롤백이 아니기 때문에 보상이 진행되는 그 순간에는 정합성이 보장되지 않을 수 있습니다. 하지만 결과적으로 시간이 흐른 뒤 모든 보상 트랜잭션이 수행되고 나면 트랜잭션이 롤백된 것과 다름없는 상태로 데이터의 정합성이 보장됩니다. 이러한 정합성을 결과적 정합성이라고 합니다.

SAGA패턴은 구현하는 방법에 따라 Orchestration saga와 Choreography saga로 나눌 수 있습니다. 롤백을 제어하는 중앙 조정자가 존재하는 방식을 Orchestration saga, 운영에 대한 책임을 분산시키는 방식을 Choreography saga라고 합니다.

내가 선택한 방식

저는 프로젝트에서 2PC방식이 아닌 SAGA패턴으로 분산 트랜잭션을 관리했으며, Orchestration Saga방식으로 구현했습니다.
또한 각각의 Microservice간 통신을 위해 FeignClient, 그리고 Kafka를 혼합해서 사용했습니다.

이러한 선택은 나름의 고민 그리고 팀원과의 회의 및 조율을 거쳐 나오게 되었습니다. 서비스의 요구사항 및 기능을 정의할 때 '고객의 돈이 빠져나갔는데 다른 요인(ex-재고 부족)으로 구매가 실패하는 상황은 반드시 피하자'는 원칙을 세웠습니다. 고객의 입장에서 자신의 돈이 빠져나갔었는데 구매가 안되고 나중에 다시 돈이 채워지는 건 매우 불쾌한 경험이라 판단했기 때문입니다.

이를 만족시키기 위해 재고를 차감하는 작업은 결제를 진행하는 작업보다 우선한다는 순서가 반드시 필요했고, 순서를 보장하기 위해 비동기 통신이 아닌 FeignClient를 이용한 동기적인 통신을 선택했습니다.

또한 커밋이 완료되기 전까지 오랜 시간 데이터베이스에 락을 거는 2PC방식은 성능에 매우 좋지 못하다 판단해 SAGA패턴을 선택했습니다.

SAGA패턴을 구현할 때는 중앙 관리자가 존재하는 Orchestration방식을 활용했습니다. 프로젝트가 끝난 지금 시점에서 생각해보면 이는 잘못된 선택이였던거 같습니다.

Orchestration방식을 선택한 근거는 앞서 얘기한 요구사항에 의해 작업에 순서가 필요했고, 그 순서를 중앙에서 보장하기 위해서 였습니다. 하지만 Orchestrator가 보장하는 순서는 보상 작업에 대한 순서이고, 우리의 서비스에 필요한 순서는 트랜잭션 동작 진행에 대한 순서였습니다.

또한 보상 트랜잭션을 구현할 당시 보상 작업의 예외 처리에 대한 고민이 있었습니다. A->B->C순서로 작업을 진행하다가 C가 실패하면 B와 A는 보상 트랜잭션을 실행해야 합니다. 이때 Orchestrator가 B에 먼저 보상을 요청하고 이후에 A에게 보상을 요청하게 되는데, B에서 요청이 실패하더라도 A는 보상을 받아야 했습니다. 보상 요청이 실패해도 나머지가 진행되게 하기 위해서는 try-catch가 필요했는데, 서비스가 많아졌을 때 이를 처리하기가 어렵다는 생각이 들었습니다.

다시 생각해보니 저희 서비스는 보상 패턴이 순차적으로 실행될 필요가 없었고, 하나의 서비스로의 보상 패턴 요청이 실패하더라도 다른 서비스로의 보상 패턴 요청은 정상적으로 전달하기 위해 Kafka를 도입하게 되었습니다. (이때까지도 Orchestrator가 필요하지 않다는 생각을 못했습니다ㅠ)

서비스에 순차적인 작업을 보장해야 한다는 생각과 보상에 순서가 필요하지 않다는 생각이 뒤섞여 결과적으로 중앙 Orchestrator가 존재하는 Orchestration Saga를 구현했지만 Kafka를 통해 순서 없이 비동기적으로 보상을 진행하는 기형적인 구조가 되어버렸습니다.

Orchestration Saga는 보상 작업의 순서를 보장하는 것 외에 보상패턴에 대한 가시성을 확보할 수 있다는 장점도 존재했지만, 이 장점 역시 프로젝트에서 잘 활용하지 못했기 때문에 Orchestration Saga를 선택할 이유가 전혀 없었다고 생각됩니다.

서비스 구조 - 정상 주문 로직

그렇게 완성된 주문 시스템의 구조는 다음과 같습니다. '주문 생성, 재고 차감, 결제'작업이 각각의 MicroService에서 하나의 트랜잭션처럼 작업되어야 했고, 그 작업은 '1. 주문 생성 2. 재고 차감 3. 결제'의 순서로 일어나야 했습니다.

위에서 설명하지 않았던 결제 로직이 합쳐지면서 그림이 조금 더 복잡해 졌지만 분산 트랜잭션의 전체적인 구조는 앞선 설명과 동일합니다.

  1. 유저가 주문하기를 클릭한다
  2. 주문, 주문 상세정보를 DB에 저장한다
  3. Payment에게 요청을 보낸다
  4. Kakao로부터 요청을 보내 QR코드 url을 받아온다
  5. QR코드 url을 orchestration에게 반환한다
  6. QR코드 url을 유저에게 반환한다
  1. 유저가 QR코드를 찍어 결제를 완료한다
  2. Inventory에게 재고 차감을 요청한다
  3. 재고를 확인한 뒤 차감한다
  4. 재고 차감에 성공했다는 응답을 보낸다
    • 실패 시 보상 패턴(1) 실행
  5. payment에게 결제 요청을 보낸다
  6. Kakao에게 결제 진행을 요청한다
  7. 결제 테이블의 상태를 변경한다
  8. 결제 요청에 성공했다는 응답을 보낸다
    • 실패 시 보상 패턴(2) 실행
  9. (장바구니 주문 시) 장바구니 비우기 요청을 보낸다
    • 이 요청은 주문의 관점에서는 중요하지 않기 때문에 트랜잭션에 포함되지 않는다

파란색 작업(1~6과정)은 사용자에게 QR코드 화면을 띄워주기 위한 로직이라 트랜잭션과 크게 상관이 없습니다. 다만 주문 및 주문 상세정보를 DB에 저장하는 작업(2번)은 다른 작업이 실패했을 시 보상패턴에 의해 롤백되어야 하는 부분입니다.

결제 담당자의 요청사항 중 Kakao API스펙을 맞추기 위해 QR코드를 반환(1~6과정)하기 위해서는 QR요청 시 orderId가 필요하다는 내용이 있었고, 이를 위해 Order는 미리 생성되어야 했습니다. 하지만 Order의 보상패턴은 Inventory의 실패 또는 Payment의 실패 시에만 발생했고, 만약 사용자가 QR작업을 정상적으로 진행하지 않았을 경우 보상패턴이 실행되지 못해 Order가 삭제(softDelete)되지 못하는 문제가 있습니다. 이 부분 역시 제대로 해결하지 못한 아쉬운 부분 중 하나입니다.

실패 시 보상 패턴(1) - 재고 차감 실패 시

  1. Orchestrator가 Inventory에게 재고 차감을 요청한다
  2. Inventory가 재고 차감에 실패한다(ex- 재고 수량 부족, 존재하지 않는 상품으로 차감 요청 등등)
  3. Inventory는 Orchestrator에게 실패를 반환한다
  4. Orchestrator는 Order에게 요청해 주문과 주문상세를 삭제(softDelete)한다

실패 시 보상 패턴(2) - 주문 결제 실패 시

  1. Orchestrator는 Inventory에게 재고 차감을 요청하고, Inventory는 성공적으로 재고를 차감했다
  2. Orchestrator는 Payment에게 결제를 요청한다
  3. Payment가 결제에 실패한다
  4. Orchestrator는 Order에게 요청해 주문과 주문상세를 삭제(softDelete)한다
  5. Orchestrator는 주문이 실패했다는 내용을 Kafka에 produce한다
  6. Inventory는 Kafka를 consume해 주문이 실패했다는 내용을 전달받는다
  7. Inventory는 차감했던 재고를 원래대로 복구시킨다

마무리

이번 프로젝트를 통해 처음으로 MSA아키텍처의 시스템을 만들어 봤습니다. 분산 트랜잭션에 대해 깊게 고민하고 직접 설계 및 구현까지 해볼 수 있어서 좋았습니다. MSA환경을 다뤄본 게 처음이었던 만큼 미숙한 부분도 많았지만 동시에 배운점도 많았던 프로젝트 였습니다.

References

profile
기록하고 정리하는 걸 좋아하는 개발자.

0개의 댓글