MongoDB 기반의 서비스를 개발하다 보면, 동시성 제어에서 RDB와는 다른 접근이 필요하다는 것을 느끼게 됩니다.
RDB 환경이라면 SELECT FOR UPDATE로 행 단위 비관적 락을 걸 수 있습니다. 트랜잭션 안에서 데이터를 읽으면서 잠금을 걸고, 작업이 끝날 때까지 다른 요청을 기다리게 만드는 익숙한 방식입니다.
MongoDB에는 이 메커니즘이 없습니다.
트랜잭션은 지원하지만 성격이 다릅니다. 두 트랜잭션이 같은 Document를 동시에 수정하려 하면, 하나는 성공하고 나머지는 WriteConflict 에러를 받게 됩니다. 대기시키는 것이 아니라 실패를 반환하는 구조입니다.
이 제약 안에서 정합성과 throughput을 모두 확보하기 위해, 상황에 따라 다른 전략을 적용해야 했습니다. 이 글에서는 실무에서 자연스럽게 정리된 네 가지 패턴을 소개합니다. 각 패턴이 어떤 동시성 문제를 해결하는지, 그리고 어떤 상황에서 선택해야 하는지를 중심으로 다룹니다.
| # | 패턴 | 적용 범위 | 핵심 키워드 |
|---|---|---|---|
| 1 | 조건부 원자 연산 | 단일 Document | findAndModify, 락 불필요 |
| 2 | Bulk Operations | 다수 Document (독립적) | 배치, 병렬 실행 |
| 3 | 분산 락 | 다수 Collection / 복합 로직 | Redis, 직렬화 |
| 4 | Outbox 패턴 | 시스템 경계 (서버 간) | 비동기, 최종 일관성 |
MongoDB를 사용하는 서비스에서 가장 빈번하게 마주치는 동시성 문제는 수량 변경입니다. 재고 차감, 좌석 예약, 포인트 차감 등 "현재 값을 읽고 → 계산하고 → 저장하는" 연산은 어디에나 있습니다.
자연스럽게 떠오르는 흐름은 데이터를 조회하고, 값을 확인한 뒤, 새 값을 저장하는 것입니다. RDB에서 하던 방식 그대로입니다.
문제는 조회와 저장 사이의 시간 간격에 있습니다.
순서 Thread A Thread B DB 상태 ① 수량 조회 → 10 qty = 10 ② 수량 조회 → 10 qty = 10 ③ 10 - 3 = 7 저장 qty = 7 ④ 10 - 5 = 5 저장 qty = 5 결과 5 (기대값: 2) → Thread A의 차감 3이 유실됨
두 요청 모두 수량을 10으로 읽고, 각각 차감한 결과를 덮어쓰면서 최종 값이 틀어집니다. 전형적인 Lost Update 문제입니다.
MongoDB는 단일 Document 레벨의 원자성을 보장합니다. findAndModify는 조건 검증과 업데이트를 하나의 원자적 연산으로 실행하기 때문에, 중간에 다른 연산이 끼어들 여지가 없습니다.
예를 들어 "수량이 3 이상인 경우에만 3을 차감하라"는 조건과 연산을 하나로 묶습니다. 조건에 맞지 않으면 아무것도 수정하지 않고 null을 반환합니다.
순서 Thread A Thread B DB 상태 ① findAndModify (qty >= 3 → qty -= 3) 원자적 실행 → qty = 7 ② findAndModify (qty >= 5 → qty -= 5) 원자적 실행 → qty = 2 결과 2 ✅
여기서 핵심은 조회와 수정이 분리되지 않는다는 점입니다. MongoDB 엔진 레벨에서 하나의 연산으로 실행되기 때문에, 별도의 락 없이도 정합성이 보장됩니다.
만약 Thread B의 시점에 수량이 5 미만이었다면? findAndModify는 아무것도 수정하지 않고 null을 반환합니다. 이 반환값으로 비즈니스 로직에서 "수량 부족" 등의 처리를 하면 됩니다.
이 패턴은 단일 Document 안에서 해결되는 연산이라면 폭넓게 적용할 수 있습니다.
| 상황 | 활용 방식 |
|---|---|
| 수량 차감/증가 | qty >= 차감량 조건 + $inc 연산. 재고, 좌석, 쿠폰 등 수량이 음수가 되면 안 되는 모든 케이스 |
| 상태 전이 | status = SENT 조건 + $set(status, PROCESSING). 여러 서버 인스턴스가 동시에 같은 작업을 선점하려 할 때, 먼저 도착한 하나만 성공 |
| 필드 간 이동 | 같은 Document 안에서 $inc(fieldA, -1) + $inc(fieldB, +1). 가용 재고 → 예약 재고 전환 등 |
| Upsert | 동일 조건의 Document가 있으면 수량 증가, 없으면 생성. 조회→분기→저장 방식의 중복 생성 문제를 upsert: true 옵션으로 해결 |
CAS와의 관계
이 패턴은 CPU 수준의 CAS(Compare-And-Swap) 명령어와 원리가 유사합니다. "현재 값이 기대한 상태일 때만 변경"하는 구조입니다. 다만
findAndModify는 CAS와 달리 다양한 조건 조합($in,$gte, 복합 조건 등)을 사용할 수 있어, 단순 값 비교를 넘어서는 복잡한 상태 전이에도 활용할 수 있습니다.
이 패턴의 가장 큰 장점은 락이 없다는 것입니다. 락이 없으니 대기가 없고, 데드락도 없으며, throughput도 높습니다. 단일 Document 범위 안에서 해결할 수 있는 문제라면 이것이 가장 단순하고 성능이 좋은 방법입니다.
주문 하나에 상품이 10종류 포함되어 있다면, 출고 배정 시 10개 상품의 수량을 모두 변경해야 합니다. 패턴 1의 원자적 연산을 10번 개별 호출하면 네트워크 왕복이 10번 발생합니다.
MongoDB의 Bulk Operations는 여러 연산을 하나의 요청으로 묶어 서버로 전송합니다.
개별 호출 — 10번 왕복
App → req1 → MongoDB → res1 → App App → req2 → MongoDB → res2 → App ... App → req10 → MongoDB → res10 → AppBulk Operations — 1번 왕복
App → [req1, req2, ... req10] → MongoDB → [res1, res2, ... res10] → App
여기서 중요한 선택은 UNORDERED 모드입니다. 각 상품의 수량 변경은 서로 독립적인 연산입니다. A 상품의 수량을 줄이는 것과 B 상품의 수량을 줄이는 것은 아무 관계가 없습니다.
UNORDERED 모드를 사용하면 MongoDB가 이 연산들을 병렬로 실행할 수 있어 throughput이 더 높아집니다. 반면 ORDERED 모드는 순차 실행되며, 중간에 하나가 실패하면 나머지를 건너뜁니다.
| 상황 | 활용 방식 |
|---|---|
| 일괄 수량 변경 | 주문에 포함된 여러 상품의 재고를 한 번에 차감하거나 복원 |
| 일괄 상태 변경 | 조건에 맞는 여러 Document의 상태를 동시에 전환 |
| 필드 간 일괄 이동 | 여러 Document에서 가용 수량 → 예약 수량 등의 전환을 병렬로 처리 |
한 가지 주의할 점이 있습니다. Bulk Operations는 트랜잭션이 아닙니다. 10개 중 3개가 실패해도 나머지 7개는 성공합니다.
따라서 실행 후 반환되는 modifiedCount를 확인하여 기대한 만큼 처리되었는지 비즈니스 로직에서 검증해야 합니다. 전부 성공하거나 전부 실패해야 하는 요구사항이라면, 트랜잭션이나 보상 로직을 별도로 구성해야 합니다.
패턴 1과 2는 단일 Document 범위에서의 원자성을 활용합니다. 하지만 실제 비즈니스 로직은 대부분 여러 컬렉션에 걸쳐 있습니다.
주문이 접수되면 주문 생성, 재고 배정, 배송 정보 생성, 이력 저장을 하나의 논리적 단위로 처리해야 합니다. 그리고 네트워크 타임아웃 등으로 인해 같은 요청이 재시도될 수 있습니다.
MongoDB 트랜잭션은 여러 연산의 원자성(전부 성공하거나 전부 실패)을 보장하지만, 동일 자원에 대한 동시 접근 자체를 막아주지는 않습니다. 두 트랜잭션이 동시에 같은 주문을 처리하려 하면 하나는 WriteConflict로 실패합니다.
실패한 쪽의 재시도 로직을 직접 구현하고 관리하는 것보다는, 애초에 하나만 진입하도록 제어하는 것이 더 안정적입니다.
Redisson 라이브러리의 PermitExpirableSemaphore를 기반으로 커스텀 어노테이션을 구현했습니다. 함수에 어노테이션을 붙이고 락 이름과 키를 지정하면, 해당 키에 대해 클러스터 전체에서 하나의 요청만 통과합니다.
분산 락의 구현 상세와 성능 개선 과정은 이전 글에서 다루었습니다. 이 글에서는 언제 분산 락을 적용하는지, 그리고 트랜잭션과 어떻게 조합하는지에 초점을 맞춥니다.
분산 락에서 키 설계는 성능을 좌우하는 핵심 결정입니다.
주문 처리의 경우 락 키를 주문번호로 설정했습니다. 기능 전체에 하나의 락을 거는 것이 아니라, 주문번호별로 개별 락을 겁니다.
기능 단위 락 — 모든 주문이 직렬화
요청 락 결과 주문 A 🔒 주문처리대기 → 처리 주문 B 🔒 주문처리대기 → 처리 주문 C 🔒 주문처리대기 → 처리 → throughput 심각하게 저하
키 단위 락 — 같은 주문만 직렬화, 나머지는 병렬
요청 락 결과 주문 A 🔒 LOCK:A즉시 처리 주문 B 🔒 LOCK:B즉시 처리 (병렬) 주문 C 🔒 LOCK:C즉시 처리 (병렬) 주문 A (재시도) 🔒 LOCK:A대기 (A 완료 후 처리) → 서로 다른 주문은 완전히 병렬 처리
경합이 실제로 발생하는 최소 단위로 키를 설정하는 것이 throughput 확보의 핵심입니다.
단순히 Redis에 락을 걸고 푸는 것만으로는 운영 환경에서 병목이 드러났습니다. 트래픽이 몰리면 Redis 자체가 병목이 되거나, 락 대기열이 길어져 스레드 풀이 고갈되는 상황이 발생했습니다.
이를 해결하기 위해 세 단계의 보호 장치를 적용했습니다.
Layer 1 — Local Semaphore (JVM)
- 같은 인스턴스 내에서 동일 키에 대해 1개 요청만 Redis로 전달
- 나머지는 JVM 레벨에서 대기 → Redis 호출이 1/N로 감소
- Thundering Herd 방지
↓
Layer 2 — Redis PermitExpirableSemaphore
- 클러스터 전체에서 1개 요청만 통과
leaseTime이후 자동 만료 → 데드락 방지trySetPermits캐싱으로 초기화 Redis 호출 1회 제한↓
Layer 3 — maxWaiters Circuit Breaker
- 대기자 수가 임계값 초과 시 즉시 실패 반환
- 스레드 풀 고갈 방지
- 시스템 전체 응답 지연 확산 차단
분산 락과 MongoDB 트랜잭션을 함께 사용할 때, 실행 순서가 중요합니다.
① Lock 획득 ← DistributedLockAspect (@Order = 0) ② Transaction 시작 ← TransactionInterceptor (@Order = LOWEST) ③ 비즈니스 로직 실행 ④ Transaction 커밋 (또는 롤백) ⑤ Lock 해제
반드시 락을 먼저 획득하고, 그 안에서 트랜잭션을 시작해야 합니다.
만약 트랜잭션 커밋 전에 락이 해제되면 어떻게 될까요?
Thread A가 데이터를 변경하고 아직 커밋하지 않은 상태에서 락을 해제하면, Thread B가 락을 획득한 시점에 조회하는 데이터는 커밋되지 않은 이전 상태입니다. 정합성이 깨지게 됩니다.
Spring AOP의 @Order를 활용하여 이 순서를 보장했습니다. 분산 락 Aspect를 @Order(0)으로 설정하면 AOP 체인의 가장 바깥쪽에 위치하고, 트랜잭션 인터셉터(LOWEST_PRECEDENCE)는 안쪽에 위치합니다. 결과적으로 락이 트랜잭션을 감싸는 구조가 됩니다.
Spring WebFlux + Kotlin Coroutine 기반 환경에서는 한 가지 추가로 주의할 부분이 있습니다.
Spring의 @Transactional이 suspend 함수에 적용되면, AOP 프록시는 내부적으로 코루틴을 Reactor의 Mono로 변환합니다. AOP의 around advice 관점에서 joinPoint.proceed()의 반환값은 비즈니스 로직의 결과가 아니라 아직 실행되지 않은 Mono 객체입니다.
블로킹 (Spring MVC)
proceed() → 비즈니스 로직 실행 → 트랜잭션 커밋 → 결과 반환 → 이 시점에 락 해제 ✅리액티브 (WebFlux + Coroutine)
proceed() → Mono 객체 반환 (아직 실행 전!) → 이 시점에 락 해제? ❌ 트랜잭션 커밋 전! Mono.subscribe() → 비즈니스 로직 실행 → 트랜잭션 커밋 → doFinally에서 락 해제 ✅
이 Mono가 실제로 subscribe되어 완료되는 시점이 트랜잭션 커밋 시점입니다. 따라서 Mono 객체를 받자마자 락을 해제하면, 트랜잭션 커밋 전에 락이 풀리게 됩니다.
이를 방지하기 위해 Mono의 doFinally 콜백에서 — Mono가 완전히 완료된 후에 — 락을 해제하도록 처리했습니다. 코루틴이 실제로 suspend되는 경우에는 Continuation을 래핑하여 코루틴 재개 시점에 락이 해제되도록 했습니다.
블로킹 방식의 Spring MVC에서는 고려할 필요 없는 부분이지만, 리액티브 스택에서는 "반환 시점"과 "완료 시점"이 다르기 때문에 반드시 고려해야 합니다.
앞선 패턴들은 하나의 서버, 하나의 MongoDB 안에서의 동시성 문제를 다루었습니다. 하지만 서비스가 여러 서버로 분리되어 각각 별도의 DB를 사용하고 있다면, 하나의 트랜잭션으로 여러 서버의 데이터를 묶을 수 없습니다.
출고가 완료되면 외부 시스템에 배송 상태를 변경해달라는 요청을 보내야 하는 상황을 생각해 봅니다. 단순하게 트랜잭션 안에서 외부 API를 호출하면 두 가지 문제가 발생합니다.
| 문제 | 상황 | 결과 |
|---|---|---|
| 불일치 | API 호출 성공 → 로컬 트랜잭션 롤백 | 외부 시스템은 변경, 로컬은 미변경 |
| 장애 전파 | 외부 API 지연/장애 | 로컬 비즈니스 로직 자체가 블로킹 |
Outbox 패턴은 이 문제를 비동기 이벤트 처리로 해결합니다.
Step 1 — 비즈니스 로직 + 이벤트 저장 (같은 트랜잭션)
- 비즈니스 로직 완료
- Outbox 컬렉션에 이벤트 저장 (상태:
SENT)- 둘 다 성공하거나 둘 다 실패
Step 2 — 스케줄러가 Outbox 폴링
- 미처리 이벤트 조회
findAndModify로 원자적 선점 (SENT→PROCESSING)Step 3 — 외부 시스템 호출
- 성공 → 상태를
SUCCESS로 변경- 실패 → 상태를
FAILED로 변경 → 별도 스케줄러가 재시도
비즈니스 로직과 이벤트 저장이 같은 트랜잭션 안에 있으므로, 둘 다 성공하거나 둘 다 실패합니다. 외부 API 호출은 트랜잭션 밖에서 비동기적으로 처리되기 때문에 장애가 전파되지 않습니다.
이 패턴에서 주목할 부분은, 앞서 다룬 동시성 제어 패턴들이 함께 사용된다는 점입니다.
| 지점 | 사용 패턴 | 역할 |
|---|---|---|
| 이벤트 선점 | 패턴 1 — findAndModify (조건부 원자 연산) | 여러 인스턴스가 같은 이벤트를 가져가는 것 방지 |
| 스케줄러 실행 | 패턴 3 — 분산 락 | 같은 유형의 스케줄러가 여러 인스턴스에서 동시 실행 방지 |
| 비즈니스 로직 | @Transactional | 이벤트 저장과 비즈니스 로직의 원자성 보장 |
서로 다른 DB를 사용하는 서비스 간 데이터 정합성이 필요한 모든 시나리오에 적용할 수 있습니다. 실무에서는 주문 수집, 주문 취소, 배송지 변경, 부분 취소, 배송 상태 동기화 등 외부 시스템과의 연동이 필요한 시나리오에 적용했습니다.
각 이벤트 유형마다 미처리용 스케줄러 락과 실패 재처리용 스케줄러 락을 분리해두면, 서로 다른 유형의 이벤트는 병렬로 처리됩니다.
| 상황 | 패턴 | Throughput | 정합성 |
|---|---|---|---|
| 단일 Document 상태 변경 / 수량 증감 | 패턴 1 — 조건부 원자 연산 | 높음 (락 없음) | 강한 일관성 |
| 여러 Document 독립적 수량 변경 | 패턴 2 — Bulk Operations | 높음 (병렬) | 개별 원자성 |
| 여러 Collection에 걸친 복합 로직 | 패턴 3 — 분산 락 + 트랜잭션 | 중간 (직렬화) | 강한 일관성 |
| 시스템 경계를 넘는 연동 | 패턴 4 — Outbox 패턴 | 비동기 | 최종 일관성 |
MongoDB에서 RDB의 SELECT FOR UPDATE가 없다는 것은 처음에는 제약이었지만, 돌이켜보면 모든 동시성 문제에 동일한 해법을 적용하는 습관에서 벗어나게 해준 계기였습니다.
"이 연산이 정말 락이 필요한가?"를 먼저 판단하고, 필요하지 않다면 원자적 연산으로, 필요하다면 최소 범위의 락으로 해결하는 것. 그것이 정합성과 성능 사이에서 균형을 잡는 방법이었습니다.