본문 바로가기
Legacy to Microservices

[Legacy to Microservices] 분산 트랜잭션 설계 최종

by 뿔난 도비 2025. 3. 5.
반응형

분산 트랜잭션을 어떻게 구현할지에 대해서 팀원과 많은 얘기를 했고, 결과적으로 이전 게시글에서 설계를 수정했다. 그래서 이번 게시글에서는 그 설계가 어떻게 이루어졌는지를 알아보고자 한다.

 

분산 트랜잭션 설계 최종

 

 

책임 분배

 

- 실제 구현은 팀원이 하고 있기 때문에 나는 설계 관점에서 어떻게 접근했는지를 공유하려고 한다.

- 먼저, 나는 클래스들이 어떤 책임을 가져야 하는지를 명확하게 설정했다.

- 아래에서는 분산 트랜잭션으로 인해 영향을 많이 받은 네 가지의 클래스에 대해 책임을 작성해보았다.

 

1) OrderController

- 주문 관련 요청에 대한 응답을 생성한다.

- 세션에 접근하는 책임과 적절한 서비스를 호출하는 책임을 갖는다.

- 기존에 Service 클래스로 HttpSession을 넘겨주고, Service에서 세션에 접근해서 객체를 가져오는 로직이 있었는데 이것을 수정했다.

- 이로써 세션에 있는 데이터가 유효한지 검증하는 로직을 Service에서 할 필요가 없어졌다.

 

2) OrderService

- 주문 관련 핵심 로직을 실행한다.

- 이 과정에서 데이터베이스에 접근이 필요하면, 적절한 Mapper 일종의 DAO를 통해서 데이터를 가져온다.

- 분산 트랜잭션에서 비정상 응답을 받을 경우 재요청하는 로직을 가져야 한다.

- 기존 프로토타입에서 Http 통신을 위한 객체에 응답에 대한 검증 로직과 재요청 로직을 넣었다가 정말 복잡해지는 문제가 발생해서 수정했다.

- 보상 트랜잭션을 위해 KafkaProducer를 활용해 메시지를 발행하기도 한다.

 

3) HttpFacade

- 외부 서비스와 HTTP 통신을 하기 위한 객체이다.

- 정말 순수하게 요청을 보내고 응답을 반환하는 용도로만 쓰인다.

 

4) CatalogController

- 상품과 관련된 요청들에 대한 응답을 생성한다.

- 현재 설계에서 ProductService에 속하는 Controller이다.

- OrderService에서 발행된 보상 트랜잭션 메시지를 처리하는 클래스이기도 하다.

- 서비스 간의 경계에 있는 녀석이기 때문에 KafkaConsumer로 적절하다고 생각했다.

경우에 따른 시퀀스 다이어그램

 

- 우리가 고려한 경우의 수들에 대한 시퀀스 다이어그램을 소개하려고 한다.

- 우리는 주문 상태를 기록하기 위해 OrderRetryStatus라는 데이터베이스 테이블을 만들어서 사용하고 있다.

- 주문의 상태는 Success, Fail, Unprocessed, Unknown으로 네 가지의 상태가 존재한다.

 

* 주문 성공

주문 성공

 

* 주문 삽입 실패

주문 삽입 실패

 

* 재고 변경 실패

재고 변경 실패

 

* 재고 변경 알 수 없어서 재요청했는데 정상 처리된 경우

재고 변경을 알 수 없어서 재요청 했는데 정상 응답을 받은 경우

 

* 재고 변경을 알 수 없어서 재요청 했는데 역시나 실패한 경우

재고 변경을 알 수 없어서 재요청 했는데 실패 응답을 받은 경우

 

* 재고 변경을 알 수 없어서 재요청 했는데 여전히 알 수 없는 경우

 

- 이러한 경우, Unknown 상태로 기록하고 사용자에게 재요청을 보내도록 한다.

- 이런 상태들은 나중에 트랜잭션을 모니터링할 때 관리 감독될 필요가 있다.

재고 변경을 알 수 없어서 재요청 했는데 여전히 알 수 없는 경우

 

* 주문 삽입을 하려고 보니 Unknown 상태의 주문인 경우

 

- 이 경우, 재고 감소를 하는 것이 아니라 재고 감소가 이루어졌는지 확인하는 것으로 로직을 시작한다.

- 성공 응답을 받았다면, 주문을 삽입하게 된다.

- 실패 응답을 받았다면, 기본적인 주문 로직과 같이 재고 감소 요청을 보내고 성공 응답을 받은 경우에만 주문을 삽입하게 된다.

- 이 과정에서 또 다시 실패를 겪는다면 Unknown 상태로 계속 기록된다.

- 모니터링 과정에서는 이런 Unknown 상태가 오랫동안 지속될 경우 수동으로 관리자가 처리할 수 있도록 하는 등 실패를 핸들링하기 위한 대비책이 마련될 필요가 있을 것 같다.

주문 삽입을 하려고 보니 Unknown 상태의 주문인 경우

 

* 주문 삽입을 하려고 보니 이미 처리된 주문인 경우 (Success 상태)

 

- 이것은 혹시나 주문 요청이 여러 번 반복해서 발생할 수 있는데, 그런 경우에 주문이 중복으로 삽입되는 것을 예방한다.

- 카카오페이의 분산 트랜잭션에 관한 포스팅을 읽어보면 재요청을 사용자에게 위임하고, 그 과정에서 발생될 수 있는 문제들을 예방하기 위해 멱등성이 있는 API를 설계할 것을 강조한다.

- 이에 따라서, 동일한 요청이면 동일한 응답을 반환해야 하기 때문에 동일한 orderId에 대한 주문 요청이 한 번이라도 성공적으로 처리된 적이 있다면, 그 이후 요청들은 모두 성공을 반환해야 한다.

- 우리는 주문 결과 페이지가 성공 응답이기 때문에 정상적으로 주문을 삽입했을 때와 동일하게 주문 결과 페이지로 이동하게 된다.

주문 삽입을 하려고 보니 이미 처리된 주문인 경우 (Success 상태)

보완 요청

 

- 구현상 미구현된 부분들이 있어 온라인 미팅을 통해 소스 코드 상에서 몇 가지 이슈에 관해 팀원에게 건의를 했다.

 

1) 보상 트랜잭션의 발생 위치가 잘못되었었다.

원래는 주문을 삽입하는 과정에서 실패를 겪을 경우 보상 트랜잭션이 이뤄져야 하는데 현재 로직 상에서는 재요청 응답이 성공인 경우 보상 트랜잭션을 통해 주문을 무효화하고 있었다.

재요청 응답이 성공인 경우에는 그대로 주문을 처리하면 되기 때문에 보상 트랜잭션의 위치를 옮겨야된다고 건의했다.

주문 삽입을 위해 Mapper를 호출하는 부분에서 try-catch를 통해 실패를 핸들링하라고 건의했다.

 

2) 주문을 데이터베이스에 삽입하는 코드가 존재하지 않았다.

이것은 분산 트랜잭션에서 실패 핸들링에 너무 신경을 쏟다 보니 자연스럽게 실수를 한 것 같았다.

Mapper를 호출하는 기존의 코드 한 줄만 처리하면 되기 때문에 쉽게 수정할 수 있는 내용이었다.

첫 번째 요청과 같이 처리해야 한다.

 

3) getNextId()의 트랜잭션과 동시성 문제를 해결해야 한다.

현재 orderId는 Sequence 테이블에 마지막으로 삽입된 주문 번호가 기록되어 있고, 그 값을 가져와 +1하는 형태로 주문 번호를 직접 관리한다.

그런데 지금 값을 가져오는 select 요청과 +1로 update하는 요청이 트랜잭션으로 관리되고 있지 않았기 때문에 문제가 있었다.

또, 여러 주문이 동시에 밀려들어올 때, 동일한 주문 번호를 여러 곳에서 select해 가는 경우, 동일한 id의 주문이 여러 개가 생겨버리는 문제가 발생할 수 있다. 따라서 동시성 문제를 해결하기 위해 잠금을 거는 등 조치를 취해야 한다고 건의했다.

 

4) 재요청 응답이 성공인 경우, 보상 트랜잭션을 수행하는 것이 아니라 주문을 이어서 처리하면 된다.

첫 번째에서도 얘기했듯이, 재요청 응답이 성공이면 주문 삽입을 이어서 진행하면 된다.

현재는 보상 트랜잭션을 수행하고 있었기 때문에 변경을 요청했다.

 

느낀점

 

나는 생각보다 설계 관점에서 코드를 분석하는 것이 빠른 것 같다.

확실히 연구를 수행하면서 여러 웹 앱들을 분석해 본 경험이 도움이 많이 되었다고 생각한다.

팀원이 개발 속도도 그렇고 이해도 굉장히 빠르다.

개발 과정에서 소통이 정말 중요하다고 생각한다.

추후에는 벤치마킹 서비스 개발 그리고 트랜잭션 모니터링 서비스 개발 이렇게 두 가지를 개발할 계획이다.

반응형