Spring에서 MongoDB로 트랜잭션 사용시 주의점

개발해규스·2025년 1월 20일
0

Java-Spring

목록 보기
2/5
post-thumbnail

들어가며

SpringData모듈들(JPA, MongoDB...)에서는 PlatformTransactionManager를 구현한 각 DB별 TransactionManager를 활용해 메소드 위에 @Transactional 를 작성하는 것 만으로도 AOP를 통해 트랜잭션을 매우 쉽게 사용할 수 있도록 해줍니다.
트랜잭션의 주요 사용 목적은 보통 여러 종류의 작업을 마치 하나의 작업처럼 커밋되거나, 롤백하고 프로세스의 동시접근을 제어하는 목적으로 사용할 것 입니다.
RDB에서 사용하는 DB엔진들의 경우 Transaction이 걸려있는 상황에서 이미 다른 트랜잭션에서 먼저 해당 데이터의 Lock을 획득했다면, Lock을 획득하기 위해 Transaction timeout시간까지 대기 할 텐데요, MongoDB의 경우 조금 다릅니다.

MongoDB에서는 Transaction 쓰기 충돌(Write Conflict) 발생하면 MongoDB의 엔진인 Wired Tiger가 쓰기 충돌을 발생시키고, MongoDB에서 내부적으로 1회 재시도를 하는데, 이때도 충돌이 발생하면 예외가 발생하게 됩니다.

또 MongoDB의 DB Management 역할인 mongos에서 DB 자체적으로 충돌이 발생한 트랜잭션을 Abort 시켜버립니다.(이미 해당 시점 이전에 데이터를 Write 하는데 성공한것이 있다면 모두 Rollback되겠죠?)

이 경우 서버 애플리케이션에서 예외가 발생하기 때문에 이를 클라이언트로 예외를 보내서 사용자가 다시 시도하도록 유도하거나, 서버 애플리케이션에서 자체적으로 재시도를 해주는 방식을 사용하게 될텐데요, 서버에서 재시도를 하게 될때 주의사항이 있습니다.

시작

MongoDB에서 Write Conflict가 발생할 때 마다 예외를 클라이언트에 넘겨서 유저에게 똑같은 액션을 반복하도록 구성하는건 개인적으로 UX상으로 유저에게 굉장히 불편한 경험을 줄 수 있다고 생각합니다. (한정판 예약, 사전예약 등의 애초에 불편 할 수 밖에 없다는 인식이 있는 도메인이라면 얘기가 다르겠지만요)
그렇게 되면 결국 서버에서 어느정도 재시도 정책을 가져야 할텐데, 특정 상황을 예로 들어 주의해야할 점에 대해 공유하겠습니다.

서비스 메소드에서 @Transactional을 걸었고, 그 내부에서 다른 서비스의 메소드를 호출하는데, 그 메소드에도 @Transactional이 존재하는 상황에서 재시도 적용


서비스의 프로세스 구조는 다음과 같습니다
1. MongoDBService가 MongoDBInnerService의 updateDocs() 메소드를 호출
1-1. updateDocs()는 주어진 list의 값에 해당하는 데이터의 count 필드 값 +1씩 증가
2. 예외 발생시 log 작성
3. 다시 MongoDBInnerService의 updateDocs() 메소드를 호출
실제 저 서비스 메소드를 실행하기위한 프로세스는 아래와 같습니다.

1. Update할 데이터 Insert
2. 병렬로 위의 서비스 메소드 호출
이제 테스트 메소드를 실행시켜보면 아래와 같이 예외가 발생합니다.

위 예외 메시지를 보면 txnNumber:1 이라는 트랜잭션이 이미 abort가 되었다는 얘기만 있습니다.

어라 이상하다..?

위 예외를 보면 이렇게 생각이 드실겁니다. (제가 그랬어요)
1. 트랜잭션이 왜 Abort가 됐지?
2. Abort가 된건 그렇다 치고, 왜 Abort가 이미 됐다는 예외가 발생하는 거지?
답은 아래 그림을 보시면 이해가 되실겁니다.

1. 스레드 1번이 먼저 Transaction으로 Document에 Write Lock을 겁니다.
2. 스레드 2번과 3번이 차례로 Transaction으로 Document에 Write를 시도하고, WriteConflict가 발생하여 내부적으로 재시도를 시도하지만, 그 텀이 매우 짧기 때문에 이 역시 WriteConflict가 발생하게되고, DB에서는 스레드 2번과 3번에 WriteConflict 예외를 응답으로 보냅니다.
3. 스레드 2번과 3번에서 update를 시도하다가 예외가 발생했기 때문에 상위 메소드의 try-catch절에 의해 예외가 catch되어 다시 Document 업데이트를 시도합니다.(재시도정책)
4. 하지만 여기서 새로운 트랜잭션을 이용해 재시도를 하는 것이 아니기 때문에 MongoDB에서는 이미 Abort된 트랜잭션이라는 예외를 응답으로 보냅니다.
5. 재시도 역시 실패했기 때문에 예외가 발생하고, 예외가 throw 됩니다.

여기서 중요하게 볼 점은 예외가 발생하고 다시 update를 했을때 txnNumber를 기존과 똑같은 번호를 가지고 Operation을 한다는 점입니다.
뭔가 이상하죠? 분명 innerClass 안의 메소드에서 예외가 발생하면서 @Transactional에 의해 rollback 되면서 새로 트랜잭션을 열어야 할 것 같은데 안 그러고 있으니까요.
이는 @Transactional의 전파 수준에 따른 동작을 이해하면 왜 이렇게 동작하는지 이해 하실 수 있으실텐데, 이건 나중에 다른 글에서 다루겠습니다.

정리

회사에서 MongoDB로 Migration 한 뒤 이 문제로 인해 약 한 달간 원인을 알아내려고 Mongosh로 트랜잭션도 상황마다 직접 걸어보고, MongoDB 공식문서, Spring Data MongoDB 소스도 뜯어보면서 분석했는데, 사실 각각의 예외에 대한 원인을 알아내는건 그렇게 어렵진 않았지만, 이 원인들을 연관짓고 한 단계 한 단계 결론에 도달하기까지가 매우 어려웠던 경험입니다. 그 덕에 MongoDB와 Spring Data MongoDB의 메커니즘을 더 이해하게 된 계기가 되었습니다.
여러분께 도움이 되었으면 좋겠네요. 긴 글 읽어주셔서 감사합니다.

profile
개발, 기타 좋아하는 뒷단 개발자

0개의 댓글