리액티브 환경에서 몽고DB 트랜잭션을 찾을 수 없을 때

Broccolism·2026년 2월 21일

dev-story

목록 보기
11/11

그럴 땐 병렬 요청을 보내고 있는게 아닌지 확인해보세요!


똑같은 증상을 겪었던 누군가의 이슈: https://github.com/spring-projects/spring-data-mongodb/issues/4804

증상

스프링 웹플럭스 환경, @Transactional 을 사용한 구간에서 간헐적으로 NoSuchTransaction 이 발생했다.

springframework.data.mongodb.TransientClientSessionException: Command failed with error 251 (NoSuchTransaction): 'cannot continue txnId -1 for session ... with txnId 1' on server ...

원인

요약

MongoDB 클라이언트 드라이버가 하나의 세션 내에서 트랜잭션 시작 후, 여러 연산을 동시에 요청하는 것을 허용하지 않기 때문

클로드야 도와줘!

기존 구현: @Transactional 이 붙어있는 함수 내부에서 Flux.flatMap() 을 사용하여 몽고DB로 여러 개 요청을 보내고 있었다.

여러 요청을 보내더라도 몽고DB client 는 단일 세션만 사용하는데..: 이것이 ClientSession 객체다.

ClientSession 객체 안에는 트랜잭션 아이디, 트랜잭션 시작 여부 등을 관리하기 위한 필드와 메소드가 있고 이들은 thread-safe 하지 않다.!!!

ClientSession instances are not thread safe or fork safe. They can only be used by one thread or process at a time.

Drivers MUST document the thread-safety and fork-safety limitations of sessions. Drivers MUST NOT attempt to detect simultaneous use by multiple threads or processes (see Q&A for the rationale).

https://github.com/mongodb/specifications/blob/master/source/sessions/driver-sessions.md#clientsession

이는 버그나 특정 버전에 한정된 것이 아니라 디자인 상 의도된 것이다.

커밋되지 않은 쓰기를 사용할 때

세션 ID는 동시에 두 개 이상의 작업에서 사용되어서는 안 됩니다. 드라이버는 승인되지 않은 쓰기 작업에 대한 응답을 기다리지 않으므로, 세션 ID를 언제 재사용할 수 있는지 알 수 없습니다. 이론적으로 드라이버는 커밋되지 않은 쓰기 작업마다 새로운 세션 ID를 사용할 수 있지만, 그렇게 하면 서버에 사용되지 않는 세션이 많이 쌓이게 됩니다.

따라서 드라이버는 어떠한 경우에도 확인되지 않은 쓰기 작업과 함께 세션 ID를 전송해서는 안 됩니다.

https://github.com/mongodb/specifications/blob/master/source/sessions/driver-sessions.md#when-using-unacknowledged-writes

그렇다면 flatMap을 썼는데 여러 스레드에서 동일한 1개 세션을 사용하게 된 이유는 뭘까? flatMap 은 여러 요청을 병렬로 보내는거잖아?

라고 생각해서 더 물어보니까 이렇게 알려줬다.

@Transactional 메서드가 호출되면 Spring이 doBegin()을 실행합니다.

  • ReactiveMongoTransactionManager.java 의 protected Mono doBegin(...) 메소드 (링크)
  • 이 내부에서 newResourceHolder 를 호출한다. 이 때 클라이언트 세션을 하나 만들어서 ReactiveMongoResourceHolder 에 넣은 뒤 Reactor Context 에 바인딩한다.

즉, 이런 일이 생겼던 것…

@Transactional 붙은 메소드 호출
--> doBegin() 에서 클라이언트 세션 하나 생성
--> Reactor context 에 저장 --> 이후 flatMap 처리하는 각 스레드에서 동일한 context 를 사용 == 동일한 클라이언트 세션 사용 == 1개 클라이언트 세션으로 트랜잭션 요청이 여러개 나감
--> Race condition 발생

해결

flatMap 대신 순차적으로 실행되는 concatMap 을 사용하자. concatMap 함수는 아래와 같이 여러 요소가 있을 때 이전 요소의 처리가 완료된 후 다음 요소를 subscribe 한다. 따라서 @Transactional 이 붙은 메소드 안의 flatMap 을 concatMap 으로 바꾸면 하나의 클라이언트 세션에서 동시에 여러 요청을 보내지 않고 1개씩 순서대로 보내게 되고, 에러가 발생하지 않게 된다.

concatMap

참고

flatMapSequential : 결과가 순차적이라는 뜻이지 과정도 순차적이라고는 하지 않았다..^^ 이름에 속기 쉬운 함수다. 이 함수의 다이어그램을 확인해보자.

초록색과 분홍색 처리에 주목하자. 내부적으로는 분홍색 네모(결과)가 먼저 나왔지만 input이 들어온 순서는 초록색이 먼저이기 때문에 output 을 낼 때 초록색을 먼저 내보낸다. 그렇다는 건 내부 처리 방식은 비순차적이고 결과 집계만 순서대로 해서 보여준다는 뜻이다. 초록 input 의 종료도 기다리지 않고 분홍 input을 subscribe 한 걸 봐도 알 수 있다. 이번 글에서 생긴 버그는 클라이언트 세션 1개에서 트랜잭션 요청이 여러개 나가서 생긴 것이었기 때문에 이 함수로 바꾸더라도 결국 flatMap 사용했을 때와 같은 에러를 받게 될 것이다.

profile
설계를 좋아합니다. 코드도 적고 그림도 그리고 글도 씁니다. 넓고 얕은 경험을 쌓고 있습니다.

0개의 댓글