Post

Mongodb multi document transaction에서 발생하는 write conflict

사내에서는 Mongodb 4.0 버전과 호환되어 트랜잭션을 지원하는 Documentdb 4.0 버전을 사용하고 있다. 전체 서비스 구성 중 파트너사와 외부 연동을 담당하는 웹 애플리케이션에서는 Mongodb를 메인 데이터베이스로 사용하고 트랜잭션이 서비스 레이어에 적용되어 있다. Mongodb의 multi document trasaction을 사용하면서 write conflict를 경험한 내용에 대해서 정리해보고자한다.

사용된 테스트 환경

  • documentdb 4.0
  • spring boot 2.6.7
  • spring data mongo 3.3.4
  • mongodb driver 4.4.2
  • mongosh 2.1.4

Body

write-conflict-diagram

Mongodb의 단일 문서 연산은 atomic하게 처리되지만 다중 문서의 연산을 atomic하게 처리하기 위해서 트랜잭션을 사용할 수 있다. 일반적인 RDBMS의 트랜잭션 동작과는 다르게 트랜잭션 외부에서 동일한 문서를 수정하는 경우 write conflict 인해 트랜잭션이 중단된다. 중단된 트랜잭션은 Mongodb 드라이버에서 자동으로 1회 재시도하도록 처리할 수 있지만 callback API가 아닌 core API에서는 재시도 로직이 지원되지 않아 개발자가 직접 구현해야한다.

write-conflict-test-insert

wirte conflict가 발생하는 케이스를 테스트하기 위해서 테스트용 컬렉션에 2개의 document를 insert한다.

write-conflict-test-session1

세션1에서 트랜잭션을 시작하고 이름이 홍길동인 document를 수정하고 커밋하지 않고 대기한다.

write-conflict-test-session2

세션2에서 트랜잭션을 시작하고 이름이 홍길동인 document를 수정하면 write conflict가 발생한다. 만약 트랜잭션을 시작하지 않고 단일 문서 연산을 수행하는 경우는 세션1의 트랜잭션이 종료되거나 제한 시간까지 대기하다가 처리될 수도 있지만 처리 제한 시간(maxTimeMS)이내에 처리되지 못한다면 실패한다.

write-conflict-logging

사내에서 Mongodb를 메인 데이터베이스로 사용하는 애플리케이션에서는 빈번하게 write conflict가 발생했었는데 원인은 위의 테스트처럼 2개 이상의 트랜잭션에서 커밋되기전에 동시에 동일한 document를 수정했기 때문이었다. 하지만 애플리케이션에서는 데이터를 수정없이 저장만 하고있다는 점에서 충돌이 발생하는 것이 잘 이해가 되지 않았다.

mongo-sequence-collection

update 쿼리가 발생하고 있는 부분이 있는지 확인해보니 컬렉션의 ID를 ObjectID가 아닌 정수로 사용하기 위해서 컬렉션별로 시퀀스를 관리하는 시퀀스 컬렉션을 사용하고 있었고 시퀀스를 증가시키면서 발생하는 update 쿼리에서 write conflict가 발생하고 있었다.

mongo-base-entity

시퀀스를 ID로 사용하는 컬렉션을 추상화하는 BaseEntity 클래스가 있다.

mongo-extends-base-entity

시퀀스를 ID로 사용하고자하는 컬렉션은 BaseEntity 클래스를 상속한다.

mongo-sequence-listener-bean

BaseEntity 하위 객체를 영속화하게되면 Mongodb 드라이버로 객체를 전달하기위해 Document 객체로 변환하는 시점에 트리거되는 onBeforeConvert 메소드에서 MongoSequenceGenerator를 사용해서 시퀀스를 조회 및 증가시킨다.

mongo-sequence-generator-bean

MongoSequenceGenerator는 BaseEntity 하위 클래스가 가진 시퀀스 이름으로 시퀀스를 조회 및 증가하는 역할을 수행한다.

mongo-transaction-manager-bean

애플리케이션에서는 multi document transaction을 사용하기위해 MongoTransactionManager를 빈으로 등록되어있다. MongoSequenceGenerator의 시퀀스 조회 및 증가는 findAndModify 연산으로 처리하는데 atomic하고 동시에 발생하는 업데이트에 영향을 받지 않기 때문에 당연하게도 트랜잭션은 적용되어 있지않다. 하지만 트랜잭션을 시작한 상태에서 BaseEntity 하위 객체를 영속화하게되면 트랜잭션이 활성화된 상태에서 시퀀스 조회 및 증가를 처리하게된다.

sample-service

동시에 2개 이상의 트랜잭션에서 동일한 document 대상으로 수정이 발생하는 상황을 재현하기 위해 트랜잭션을 시작하고 BaseEntity 하위 객체를 영속화하고 스레드를 3초간 대기하는 서비스를 만들어보자

sample-fail-test-code sample-fail-test-result

스레드1에서 시작한 트랜잭션이 커밋되기 전 3초간 대기하는 도중에 스레드2에서 시작한 트랜잭션에서 동일한 문서를 수정하여 실패한 것을 확인할 수 있다.

mongo-sequence-generator-bean-solution

애플리케이션에서 트랜잭션을 사용하는 로직들이 존재하고 현시점에서 시퀀스 조회 및 증가 처리를 위한 수정을 제외하고는 데이터를 수정없이 저장만하고 있다는 점에서 다른 부분에서는 write conflict가 발생하지 않기 때문에 우선은 최소한의 코드 수정으로 문제를 해결하고자 MongoSequenceGenerator에 트랜잭션 전파 유형을 NOT_SUPPORTED으로 적용하여 write conflict 발생 문제를 해결했다.

transaction-manager-handle
transaction-manager-suspend
mongo-transaction-manager-suspend

트랜잭션 진행을 관리하는 TransactionManager는 전파 유형이 NOT_SUPPORTED이면 적용된 메소드는 기본적으로 트랜잭션을 시작하지 않는다. 하지만 이미 현재 쓰레드에서 시작한 트랜잭션이 존재하면 현재 트랜잭션을 유예한다. 트랜잭션을 유예한다는 것은 트랜잭션을 시작한 데이터베이스 세션을 사용하지 않고 새로운 세션을 시작해서 처리한다는 것이다. 트랜잭션을 유예하기 위해서 MongoTransactionManager에서 TransactionSynchronizationManager에 바인딩된 mongodb 세션 정보를 언바인딩한다.

mongo-database-utils-get-session
mongo-client-delegate-get-session

MongoTemplate에서 쿼리를 요청할 데이터베이스 세션을 MongoDatabaseUtils을 통해서 가져오는데 TransactionSynchronizationManager에 바인딩된 세션 정보가 존재하지 않아 null을 리턴한다. 최종적으로 쿼리를 수행하는 OperationExecutor에서 세션 정보가 없으므로 새로운 세션을 시작하게된다.

sample-success-test-code sample-success-test-code

트랜잭션을 시작한 세션이 아닌 트랜잭션을 시작하지 않은 새로운 세션에서 시퀀스 조회 및 증가를 처리하기 때문에 동일한 조건에서도 write conflict가 발생하지 않는 것을 확인할 수 있다.

Mongodb에서 ObjectId를 사용하지 않고 시퀀스를 구현해서 사용하는 방법에 대해서는 공식 문서에서도 소개되고는 있지만 권장하지는 않는다. Mongodb에 대한 경험은 많이 부족하지만 아직까지는 시퀀스 구현이 필요했던 상황은 없었다.

시퀀스를 사용하는 RDBMS의 데이터와 동일한 키 값으로 매핑하여 사용하기 위해서 같은 특별한 경우를 생각해볼 수는 있겠다. 하지만 ObjectID에 비해 Id 생성 과정에서 불필요한 오버헤드가 발생하기 때문에 특별한 이유가 없다면 사용하지 않는 것이 Mongodb를 컨셉에 맞게 잘 사용하는 방향이라고 생각한다.

Mongodb에서도 트랜잭션은 지원되지만 트랜잭션이 불필요하거나 필요하지 않도록 비즈니스 로직의 구현이 가능한 경우에는 트랜잭션을 사용하지 않는 방향으로 가는 것이 좋을 것 같다. 현재 사내 코드에서는 트랜잭션이 불필요한 경우에도 트랜잭션을 사용하는 부분이 존재해서 점차 개선해나가야할 부분이기도하다.

최소한의 코드 수정으로 write conflict 문제를 해결하기위해 트랜잭션의 NOT_SUPPORTED 전파 유형을 사용했다. 이전까지는 사용해본적 없는 전파 유형이고 어떤 경우에 사용할 수 있을지 잘 와닿지는 않았지만 이번에 사용해보면서 이런 비슷한 케이스에서 활용하기 좋겠다는 생각이 들었다.

This post is licensed under CC BY 4.0 by the author.