들어가며
봄툰 Push/Alarm 개선 프로젝트를 진행하던 중,
MongoDB 트랜잭션을 적용하는 과정에서 동시성 부하에 의한 “Write Conflict”가 발생했습니다.
이 글에서는 해당 이슈가 발생한 과정을 시간 순으로 정리하고, 이를 해결하기 위해 어떤 판단을 했으며,
어째서 그 판단들이 근본적인 해결책이 되지 못했는지에 대해 차례로 분석해보겠습니다.
이를 통해 향후 프로젝트에서는 MongoDB 트랜잭션을 보다 안정적으로 운영하기 위한 실질적인 가이드 라인을 도출하고자 합니다.
대상
- MongoDB 트랜잭션을 처음 접하는 개발자
- 트랜잭션 충돌 원리와 해결 방안을 학습하고자 하는 개발자
다루는 내용
- MongoDB의 Transaction 개념과 동작 원리
- Transaction 개념을 이해하기 위한 Mongo Replica Set 구성의 읽기/쓰기 작업
- Spring boot 에서의 MongoDB Transaction 관리
- Write conflict 발생 원인 및 해결 방법

Write conflict 와의 첫 대면
사용자 동작 관련 엔드포인트를 대상으로 부하 테스트를 진행했습니다.
이 과정에서 RPS 300 정도의 부하가 발생한 경우 write conflict 오류가 발생한다는 사실을 발견했습니다.

여기서 write-conflict 를 오해하면서 첫번째 실수가 발생합니다.
write-conflict 라는 단어에 꽂혀 “쓰기경합” 이 발생했다고 이해를 했고
→ 부하테스트로 동시작업이 많아졌다
→ 쓰기 작업이 경쟁상태에 놓였다
→ A 세션이 쓰기작업을 하는 도중 B 세션의 쓰기작업이 발생했다
→ conflict 가 나면서 B 세션의 작업이 취소되었다

라는 일련의 과정이였을 것이라고 추측하게됩니다.
MySQL InnoDB 엔진 같은 경우 쓰기 작업을 할 때 배타적 락을 걸고 작업을 진행하고
다른 세션은 이를 기다리는 비관적 락 방식을 적용하지만
MongoDB 는 낙관적 락 방식을 채택하여 발생하는 문제라고 생각했습니다.
낙관적 제어 때문이라는 방향 자체가 틀린 것은 아니지만,
이는 근본적인 원인이 아니었습니다.
Update 연산 시간을 줄여보자 ! - save 를 update로
위 그림과 같이 쓰기작업에서 경합이 발생한 것이라고 생각하고 해결책을 찾기 시작했습니다.
그러던 과정에서 Mongo의 CoroutineCrudRepository 인터페이스에서 save 메서드가 replaceOne 으로 동작한다는 사실을 알게되었습니다.
replaceOne 으로 동작한다는 의미는 문서 전체를 update 한다는 의미이고
이는 불필요한 연산이 더해져 필요한 필드만 update 하는 것 보다 더 많은 시간이 소요된다는 것을 의미합니다.
이러한 문제점을 해결하기 위해 저희는 모든 CoroutineCrudRepository save() 메서드를 ReactiveMongoTemplate 의 update() 메서드로 교체하게됩니다.
교체 후 성능테스트를 실행시킨 결과 1000 RPS 에서도 write conflict 가 발생하지 않는 것을 확인하였고
이렇게 write conflict 와는 싱겁게 작별하게 되는 듯 했습니다.
.
.
.
그런데 말입니다
운영에 반영하고 얼마 지나지 않아 이 write conflict 가 다시 일어나기 시작했습니다.

1000 RPS 까지 테스트 해봤지만 문제 없었던 update 마저 버티지 못하고 터져(?)버린 것입니다.
타는 속을 아는지 모르는지 write conflict 오류는 현재 진행형이였습니다.
이 당시 저희가 발견한 특이사항은 다음과 같습니다.
- IOS_APP 으로 요청이 들어온 경우만 write conflict 가 발생한다
- 간헐적으로 발생한다
- 부하가 많지 않을 때도 발생한다
확인결과 실제로 IOS_APP 에서 push_key 등록 요청이 두번 오는 것을 확인했지만,
그렇다 할지라도 write conflict가 발생해서는 안되는 상황이였습니다.
dev, stage 환경에서는 아무리 해도 재현이 되지 않았고 이 때부터 다양한 방법들을 시도하게 됩니다.
재시도를 해보자
write conflict 의 오류 메세지를 자세히 보면 재시도 하라는 안내를 해줍니다.

그리고 write conflict 오류의 분류 자체가 TransientTransactionError로 재시도를 통해 성공 할 수 있다는 것을 나타내는 분류였습니다.
가장 간단하게 driver 설정에서
- retryWrites=true
설정을 통해 재시도를 설정하게됩니다.

하지만 아무런 효과가 없었습니다…
그 이유인 즉슨, driver 자체의 retryWrites 설정은
트랜잭션과 관련된 재시도가 아니라 트랜잭션 외부의 동작의 실패에 대한 것들만 자동으로 재시도 해주는 정책이였습니다.

다음 방법으로 재시도 코드를 직접 작성하여 재시도를 수행하도록 합니다.
하지만 결과적으로 이 또한 아무런 효과가 없었는데
내용을 정리하며 다시 보니 이 부분에서는 실패의 원인이 명확했습니다.
- retry 를 transaction 안에서 수행
이 때 까지만 하더라도 update 를 수행하는 시점에서 충돌이 발생하는 것이라 생각했기에 transaction 내부에서 retry 를 진행했고
이는 잘못된 판단이였습니다.
→ transaction 자체를 재시도 해야 했음
Transaction 너 out
반복된 실패 끝에
“해당 메서드에 트랜잭션을 적용해야 할 이유가 있는가?”라는 근본적인 의문점이 제기되었고 이에 대해 검토하기 시작했습니다.
검토 결과, 비즈니스적으로 트랜잭션이 필요 없다는 결론에 도달했고, 트랜잭션을 제거함으로써 상황은 종결되었습니다.
? 이렇게 끝난건가요?
하지만 실제로 Transaction 이 필요한 상황이였다면 어떻게 해야할까요?
지금부터 왜 이런 사태가 벌어졌고
Transaction을 보장해야 하는 상황을 다시 마주한다면 어떻게 해결해야하는지 파헤쳐보겠습니다.
지금부터 풀어갈 질문은 크게 세 가지입니다.
- MongoDB 트랜잭션은 도대체 어떤 격리 수준으로 동작하길래 write conflict 가 나는가?
- 그렇다면 write conflict 는 정확히 언제 발생하는가?
- 발생했을 때, "올바른" 재시도는 무엇인가?
(*본 내용은 Mongo Atlas 기준 Replica Set 환경에서의 트랜잭션에 대해 다룹니다.)
Mongo 의 Transaction
Mongo v3.2 부터 기본 Storage Engine 으로 "WiredTiger" 가 채택되었고, 이 엔진의 스냅샷 기능을 기반으로 멀티 도큐먼트 트랜잭션이 적용되기 시작
WiredTiger 라는 엔진 자체는 RDB처럼 여러 isolation level 을 정의 하고 있습니다.
- read-uncommitted
- read-committed
- snapshot (repeatable read)
그런데 여기서 주의할 점이 있습니다.
이건 "WiredTiger 엔진이 이론적으로 지원하는 옵션"일 뿐이고,
MongoDB 가 이 엔진 위에서 실제로 쓰기 연산을 돌릴 때는 처음부터 snapshot 모드로 동작합니다.
즉 우리가 트랜잭션 밖에서 마주하는 것도 사실상 snapshot 이고,
MongoDB Transaction 에서는 이 isolation level이
- snapshot
으로 (명시적으로) 고정됩니다.
이 정책은 공식문서의 read-concern 에 대한 내용을 확인해보면 알 수 있습니다.
https://www.mongodb.com/ko-kr/docs/manual/reference/read-concern/
read-concern 이 뭔데 ?
read-concern 이란 Mongo DB 의 읽기 작업이 반환할 데이터의 일관성 및 가시성 수준을 뜻하며
다음과 같은 수준이 있습니다.
| level | 설명 |
| local (default) | 현재 노드(primary/secondary)에 저장된 데이터를 반환. 다수 노드 전파 보장 없음(롤백 가능성) |
| available | 샤드환경이 아니면 local 과 동일한 동작. 샤드 환경에서는 가장 낮은 latency 를 보장하지만 고아 문서 반환 가능 |
| majority | 복제 세트 멤버 과반수 이상에 커밋된 데이터를 반환. 장애 발생 시에도 롤백되지 않는 안정성을 제공 |
| linearizable | 단일 도큐먼트(single-document)에 한해 "실시간(real-time) 선형화(linearizable)" 읽기 제공 |
| snapshot | 멀티 도큐먼트 트랜잭션(4.0+)에서 특정 시점의 "snapshot"처럼 일관된 뷰를 제공. (MongoDB 5.0부터는 트랜잭션 밖의 일반 read 에서도 snapshot read concern 사용 가능) |

트랜잭션에서는 이 read-concern 이 어떻게 적용이 될까?
공식문서에 따르면

- transaction-level 의 read concern 정책
- 없으면 → session-level 의 read concern 정책
- 없으면 → client-level 의 read concern 정책
순서로 적용이 된다고 합니다.

Application 에서 별도로 적용을 하지 않았기에
client-level default 값인 local 로 적용이 됩니다.
🤷🏼❓MongoDB Transaction에서 snapshot 으로 고정된다면서요
네 맞습니다(?). 추가로 적힌 문구에 대해서 확인해보면 한가지 조건이 추가됩니다.

따라서, replica set 에서 read-concern 은 설정이 없어도 snapshot 형태로 제공 된다는 것을 알 수 있습니다.
그럼 snapshot 은 어떤 수준까지 write 된 내용을 가져오는거지?
이런 의문점이 들 수 있습니다. snapshot 이 시점을 기준으로 데이터를 가져온다는 것은 알겠는데,
그럼 그 시점에서 어느 수준까지 (local 인지 majority 인지 등) 반영된 데이터를 가져오는 걸까요?

과반수 이상이 커밋한 데이터 → “majority”
를 기준으로 값을 가져온다고 합니다.
사실 당연한 얘기입니다. snapshot 은 repeatable read가 가능한 신뢰성이 보장되는 데이터 여야만 하는데
local 같은 읽기 고려 수준의 데이터는 아직 쓰기가 완료됐다고 보기 힘든 상태이기 때문입니다.
read concern이 있으니 write concern도 있나요?
write concern 도 존재합니다.
write concern 은 쓰기 작업 수행 시 어느 수준까지 작업이 완료된 상테에서 응답을 반환할 것인가에 대한 내용입니다.
| level | 설명 |
| w: 0 | Acknowledged 없음 (쓰기 확인 안 함, 속도는 빠르지만 안정성 없음) |
| w: 1 | Primary 노드에서만 확인되면 OK (기본값) |
| w: "majority" | Majority 노드(Primary 포함)의 확인이 있어야 성공 (데이터 안정성 보장) |
| w: n | n개의 복제본 노드에서 성공해야 완료로 간주 (n ≥ 2 등 지정 가능) |
MongoDB Transaction 에서는 이 write concern 은 majority 로 기본 설정이 되고
majority 로 설정 된 상태에서만 Transaction을 보장합니다.
concern에 대한 내용은 알겠는데, 그럼 Write conflict은 왜 발생하지?
Mongo DB는 Transaciton을 시작하면 snapshot을 통해 읽기 일관성을 유지합니다.
그런데 여기서 일반적인 RDB 와의 큰 차이가 존재하는데,
바로 동시성 제어 방식의 차이와 스냅샷 용도의 차이입니다.
| 구분 | MongoDB (WiredTiger) | RDBMS (InnoDB/PostgreSQL 등) |
| 동시성 제어 | 낙관적 제어 (MVCC 기반) | 비관적 제어 (행 단위 락) / MVCC+락 |
| 충돌 처리 | 스냅샷 버전 불일치 즉시 WriteConflict 발생 → 자동 재시도 | 락 충돌 시 대기 / Deadlock 시 롤백, 수동 재시도 필요 |
| 성능·특징 | 짧은 지연, 자동 재시도로 개발자 부담 감소 | 잠금 대기로 인한 블로킹, 애플리케이션 재시도 로직 필요 |
기본적으로 MongoDB 는 낙관적 락을 통해 충돌이 발생하는 경우 오류를 뱉고 프로세스를 종료시킵니다.
이 때 트랜잭션을 시작 할 때 획득했던 스냅샷과 쓰기 작업이 발생하는 순간의 실제 DB의 스냅샷의 버젼이 다르면 write conflict 가 발생합니다.

RDB 에도(MySQL InnoDB) REPEATABLE READ 있잖아?
RDB 에서는 이 snapshot 형태로 제공되는 REPEATABLE READ 는 온전히 읽기 일관성을 유지하는 것에만 사용되고
실제 command 에서는 행에 X-락(배타적 락)을 걸고 작업을 수행하고 다른 세션에서는 이를 기다리는 방식으로 동작합니다.
결론 = 트랜잭션 자체를 재시도하라!

그런데 위의 표에서 트랜잭션 Write conflict 발생하면 자동 재시도 해준다고 하지 않았나요?

맞습니다. 이 부분이 제가 재시도 코드를 작성하면서도 의문이였던 점이였는데요
“재시도를 알아서 한번은 해준다는데 왜 재시도 코드가 필요한거지? 두번이나 재시도가 필요할 정도로 충돌이 자주 발생하는건가?”
그런데 이는 반은 맞고 반은 틀렸습니다.
드라이버 API 의 종류에 따라 다르다!
Driver를 통한 MongoDB 트랜잭션을 구현하는 방법에는 2가지 방법이 존재합니다.
- Callback API
- Core API
(세부적인 차이점은 드라이버 API - 데이터베이스 매뉴얼 - MongoDB Docs 를 참고)
가장 큰 차이점은 Callback API 를 통한 구현은 자동 재시도를 해주지만
Core API 를 통한 구현은 재시도를 직접 구현해야한다는 점입니다(!)
그렇다면 Spring Boot 의 TransacationManager 는 어떤 API 를 통해 구현했을까?

Core API 를 통해 구현했다 → 재시도 로직을 직접 작성해야한다 !
뭔가 Spring 이라면 이런 재시도 가능한 오류들에 대해서 Core API 로 구현했어도 한번 더 wrapping 하고 추상화 해서
알아서 재시도 한번은 하도록 구현했을거 같았지만
확인결과 그런거없다 재시도 로직은 직접 작성해야합니다..
그렇다면 어떻게 재시도를 구현하는게 좋을까?
- 이것이 앞으로 당면한 과제
1. MongoDB Driver 의 callback api 를 직접 사용
- withTransaction() 으로 callback api 형태의 transaction 직접 사용
장점
- transaction 을 직접 관리하여 매뉴얼하게 구현 할 수 있다
- 별도 재시도 로직을 작성하지 않아도 된다
단점
- transaction 을 직접 관리해야한다…
- Reactive 환경에서 사용할 수 없음
- Reactive Streams 사양을 따르지 않음
2. Retry 로직 작성
- Spring AOP 의 Retry 라이브러리를 통해 적용
장점
- AOP 기반 간편 적용
- 백오프 설정 등으로 세밀하게 조정 가능
단점
- 추가 의존성 필요
- retry 로직 직접 작성해야함
- 동작에 대한 이해도 미흡 존재 가능성
- 과거 AOP 순서에 대한 이슈 → 순서 적용 필요 등
- Transaction 마다 Retry 어노테이션 설정
- Coroutine 환경에서 일반적인 방법으로 사용할 수 없음
3. 수동 재시도 루프
- Kotlin 코루틴만으로 재시도 로직 구현 (유틸 함수 처럼)
장점
- 추가 의존성 불필요
- 동작이 가시적으로 예측 가능
- 설정 이슈 없음
단점
- 코드 중복
- 유지보수 부담
Notifty 프로젝트의 환경 특성 상 수동 재시도 루프 밖에는 답이 없었다고한다...
참고문서
- WiredTiger Transaction Isolation level : WiredTiger: Transactions
- Mongo DB의 Read cocern : 읽기 고려 - 데이터베이스 매뉴얼 - MongoDB Docs
- Mongo DB의 Write cocern : 쓰기 고려 - 데이터베이스 매뉴얼 - MongoDB Docs
- Mongo DB 의 Transaction Read concern : 트랜잭션 - 데이터베이스 매뉴얼 - MongoDB Docs
- Mongo DB Trasnaction Driver API : 드라이버 API - 데이터베이스 매뉴얼 - MongoDB Docs
'백엔드' 카테고리의 다른 글
| 우리의 MongoDB 의 Rolling Update 는 안전한가 (2) | 2026.05.20 |
|---|---|
| 하네스 엔지니어링 - 2 (1) | 2026.05.16 |
| 하네스 엔지니어링 - 1 (2) | 2026.05.04 |
| [GC] 나야 메모리, 근데 이제 누수를 곁들인 (2) | 2024.10.22 |
| [Base64] 빼앗긴 Parameter 찾습니다. (3) | 2024.10.11 |