MongoDB 샤드 클러스터 쓰기 경로 시리즈 2/6편이다.
이 글이 답하는 질문: 소켓 타임아웃이 난 쓰기는 서버에서 멈추는가? 드라이버의 retryWrites와 애플리케이션의 재시도가 함께 켜져 있으면 쓰기 하나가 서버에 몇 번 도착하는가? 서버에서는 몇 개가 동시에 도는가? maxTimeMS는 이 겹침을 얼마나 막는가?
기준 버전: MongoDB 4.2 (소스r4.2.12태그, 4.2 공식 문서), MongoDB Java 드라이버r5.4.0, retryable writes·driver sessions 명세(2026-11-05에 받은 판). 최신 서버 버전의 동작은 확인하지 않았다.
쓰기 재시도는 두 층에서 따로 일어난다. 드라이버는 네트워크 오류가 나면 retryable writes 규칙에 따라 같은 쓰기를 한 번 더 보낸다. 애플리케이션(예: Spring Kafka 컨슈머)은 드라이버가 끝내 던진 예외를 받아 쓰기를 처음부터 다시 실행한다. 두 횟수는 곱해진다.
socketTimeoutMS는 클라이언트가 응답을 기다리다 포기하는 시간이다. 서버는 이 타임아웃을 모른다. MongoDB 4.2에서는 클라이언트 연결이 끊겨도 insert·update·delete·findAndModify가 서버에서 계속 돈다.
드라이버의 재전송은 원래 요청과 같은 세션 id(lsid)와 트랜잭션 번호(txnNumber)를 단다. 서버는 둘을 같은 쓰기로 보고 한 번만 실행한다. 애플리케이션 재시도는 새 세션이나 새 번호를 단 새 쓰기다. 같은 쓰기가 서버 엔진에서 따로 실행되는 것을 이 글에서는 사본이라 부른다. 서버로 간 전송은 최대 애플리케이션 실행 수의 두 배다. 겹쳐 도는 사본은 최대 애플리케이션 실행 수만큼이다.
두 설정 모두 시간 제한이지만 시계를 쥔 쪽이 다르다.
| 항목 | socketTimeoutMS |
maxTimeMS |
|---|---|---|
| 시계를 쥔 쪽 | 클라이언트(드라이버) | 서버 |
| 시간이 다 되면 | 드라이버가 소켓 읽기를 포기하고 예외를 던진다 | 서버가 다음 중단 검사 위치에서 작업을 멈춘다 |
| 서버 작업은 | 모른다. 계속 돈다 | MaxTimeMSExpired 오류로 끝난다 |
| 설정 위치 | 연결 문자열, MongoClientSettings |
명령마다 붙이는 인자 |
4.2 문서 "Terminate Running Operations"는 maxTimeMS를 이렇게 설명한다.
When the operation reaches the specified time limit, MongoDB interrupts the operation at the next interrupt point.
interrupt point(중단 검사 위치)는 작업이 "나를 멈추라는 표시가 있는가"를 스스로 확인하는 코드 위치다. 작업은 이 위치에 와야 멈춘다. 이런 방식을 협조형 중단이라고 부른다. 4/6편에서 자세히 다룬다.
서버는 명령 종류와 상관없이 일반 인자 maxTimeMS를 읽어 작업에 마감 시각을 건다(src/mongo/db/service_entry_point_common.cpp:817-830 (r4.2.12)). 그래서 insert 명령에 maxTimeMS를 붙여도 마감이 걸린다.
MongoDB 4.2부터 일부 명령은 클라이언트가 끊기면 종료 표시(killOp과 같은 표시)를 받는다. 4.2 릴리스 노트 "Client Disconnection" 절의 목록에는 10개 명령이 있다. find, aggregate($out·$merge가 없을 때), count, distinct 같은 읽기 명령과 인증·목록 조회 명령이다.
insert·update·delete·findAndModify는 목록에 없다. 소스에서도 끊김 시 종료 표시를 거는 함수 handleMarkKillOnClientDisconnect를 부르는 파일은 위 읽기 명령 파일들뿐이다. 쓰기 명령 파일 write_commands.cpp는 이 함수를 부르지 않는다.
종료 표시를 받는 읽기도 협조형으로 멈춘다. 작업이 중단 검사 위치에 오지 못하면 읽기도 계속 돈다.
공식 문서 "Retryable Writes"의 첫 문장이 retryable writes의 범위를 정한다.
Retryable writes let drivers retry specific write operations once after network errors or if they cannot find a healthy primary in the replica set or sharded cluster.
MongoDB 4.2 이상과 호환되는 드라이버는 이 기능을 기본으로 켠다. 끄려면 연결 문자열에 retryWrites=false를 넣는다. 재시도 대상은 문서 하나만 바꾸는 쓰기다.
| 구분 | 예 |
|---|---|
| 재시도함 | insertOne, insertMany, updateOne, replaceOne, deleteOne, findOneAndUpdate 등 |
| 재시도함 | 위 단일 문서 쓰기만 담은 bulkWrite (ordered·unordered 모두) |
| 재시도 안 함 | updateMany, deleteMany, multi: true update나 limit: 0 delete가 하나라도 든 bulk |
| 재시도 안 함 | write concern {w: 0} 쓰기 |
소켓 읽기 타임아웃도 재시도 대상 오류다. Java 드라이버 r5.4.0에서 MongoSocketReadTimeoutException은 MongoSocketException을 상속한다. CommandOperationHelper.isRetryableException은 MongoSocketException이면 true를 돌려준다. retryable writes 명세도 네트워크 오류에 RetryableWriteError 레이블을 붙이라고 정한다.
retryable writes 명세는 재전송 규칙을 이렇게 정한다.
When retrying a write command, drivers MUST resend the command with the same transaction ID.
트랜잭션 ID는 lsid(서버 세션 id)와 txnNumber(그 세션 안에서 커지는 번호)의 짝이다. mongod는 txnNumber가 있는 요청을 받으면 먼저 그 세션을 체크아웃한다. 체크아웃은 세션을 한 작업만 쓰도록 잡는 것이다. service_entry_point_common.cpp:397-399 (r4.2.12) 주석은 "only requests with a transaction number will check out the session"이라고 적는다.
원래 요청과 재전송이 같은 세션을 다투므로 결과는 둘 중 하나다.
어느 쪽이든 실제로 쓰기를 하는 사본은 하나다. 재전송은 서버에 도착하지만 일을 더 만들지 않는다.
드라이버의 재전송까지 실패하면 예외가 애플리케이션으로 올라온다. 애플리케이션이 같은 쓰기를 다시 실행하면 드라이버는 그것을 처음 보는 쓰기로 다룬다.
lsid를 받는다.txnNumber가 새로 올라간다.그래서 원래 작업이 아직 돌고 있으면 두 작업이 서버 엔진 안에서 나란히 돈다.
예를 들어 Spring Kafka의 DefaultErrorHandler(FixedBackOff(0L, 3L))는 간격 0으로 3번 재시도한다. 처음 1번에 재시도 3번을 더해 레코드 하나를 4번 실행한다. 아무 설정도 하지 않은 DefaultErrorHandler의 기본값은 FixedBackOff(0L, 9)라서 레코드 하나를 10번 실행한다.
| 항목 | 드라이버 retryWrites | 애플리케이션 재시도 (Spring Kafka 예) |
|---|---|---|
| 설정 | retryWrites=true (4.2 호환 드라이버 기본값) |
DefaultErrorHandler의 BackOff |
| 언제 | 네트워크 오류, primary를 못 찾을 때 | 리스너가 예외를 던질 때 |
| 몇 번 | 1번 더 (timeoutMS를 쓰지 않을 때) |
설정한 재시도 수만큼 |
| 무엇을 다시 | 실패한 명령 하나 (bulk면 그 batch) | 리스너 처리 전체 |
| 간격 | 서버를 다시 고르는 즉시 | BackOff가 정한 대기 |
| 서버가 보기에 | 같은 lsid·txnNumber. 같은 쓰기 |
새 lsid 또는 새 txnNumber. 새 쓰기 |
아래는 가상의 예시다. 숫자도 설명을 위한 값이다.
store_inventory 컬렉션에 bulk 쓰기를 보낸다.socketTimeoutMS는 30초, retryWrites는 기본값(켜짐)이다.DefaultErrorHandler(FixedBackOff(0L, 3L))를 쓰고 다 실패하면 레코드를 DLQ(dead letter 토픽)로 보낸다.| 시각 | 클라이언트에서 | 서버에서 (가정) |
|---|---|---|
| 0초 | 애플리케이션 1번째 실행. bulk 전송 (세션 A, 번호 1) | 사본 1이 돌기 시작 |
| 30초 | 소켓 타임아웃. 드라이버가 같은 bulk 재전송 (A, 1) | 재전송이 세션 A를 기다림. 사본 1개 |
| 60초 | 재전송도 타임아웃. 예외가 애플리케이션까지 올라옴. 애플리케이션 2번째 실행 (B, 1) | 사본 2가 돌기 시작. 사본 1도 계속 돎 |
| 90초 | 드라이버 재전송 (B, 1) | 세션 B 대기. 사본 2개 |
| 120초 | 애플리케이션 3번째 실행 (C, 1) | 사본 3개 |
| 180초 | 애플리케이션 4번째 실행 (D, 1) | 사본 4개 |
| 240초 | 4번째 실행의 재전송도 타임아웃. 재시도 소진. DLQ로 보냄 | 사본 4개가 여전히 돎 |
애플리케이션이 레코드를 포기한 240초 시점에 서버에는 같은 bulk의 사본 4개와 세션을 기다리는 재전송 4개가 남는다. 서버 작업이 풀리면 사본 4개가 모두 실행된다.
서버로 간 전송 최대 = 애플리케이션 실행 수 × 2
엔진에서 겹쳐 도는 사본 최대 = 애플리케이션 실행 수
위 예시에서 애플리케이션 실행 수는 4다. 전송은 최대 8번, 사본은 최대 4개다. 기본값 FixedBackOff(0L, 9)라면 전송 20번, 사본 10개다.
두 경우에는 재시도가 줄어 최댓값에 닿지 않는다.
이 계산은 레코드 하나에 대한 것이다. 컨슈머 여러 개가 같은 상황에 빠지면 사본 수는 컨슈머 수만큼 다시 곱해진다.
maxTimeMS를 소켓 타임아웃보다 짧게 두면 클라이언트가 포기하기 전에 서버가 먼저 작업을 멈추고 MaxTimeMSExpired를 돌려준다. 이 오류는 네트워크 오류가 아니라서 드라이버가 재전송하지 않는다. 재시도 겹침이 그만큼 줄어든다.
MaxTimeMSExpired(코드 50)가 재시도 대상이 아니라는 근거는 셋이다.
ExceededTimeLimit(262)은 다른 코드다.RetriableError 범주의 오류에만 RetryableWriteError 레이블을 붙인다(src/mongo/db/error_labels.cpp, r4.4.0). 같은 태그의 error_codes.yml에서 MaxTimeMSExpired의 범주는 Interruption과 ExceededTimeLimitError다.r5.4.0의 RETRYABLE_ERROR_CODES 목록에 50이 없다.maxTimeMS는 줄 선 작업도 정리한다. MongoDB는 동시에 엔진에 들어가는 쓰기 수를 ticket(입장권)으로 제한한다. ticket이나 글로벌 락을 기다리는 대기는 중단할 수 있어서 마감이 지나면 대기 중인 작업이 빠진다.
엔진 안에서 중단 검사 위치에 오지 못하는 작업은 maxTimeMS로도 끊지 못한다. 3/6편의 WiredTiger 루프가 그 예다. 이때는 서버가 마감을 지키지 못하므로 소켓 타임아웃이 먼저 나고 위의 곱셈이 그대로 일어난다.
Spring Data MongoDB에서는 조회에만 Query.maxTime(Duration)으로 maxTimeMS를 줄 수 있다. BulkOperations에는 넘길 옵션이 없다. Spring Data MongoDB 4.4.0의 BulkOperations와 DefaultBulkOperations 소스에 maxTime이 한 번도 나오지 않는다.
Java 드라이버의 CSOT(Client-Side Operation Timeout, timeoutMS)는 서버 실행까지 묶는 시간 제한이다. 공식 문서는 timeoutMS를 설정하면 쓰기를 여러 번 재시도할 수 있다고 쓴다. 이 경우 위의 두 배 계산은 성립하지 않는다.
currentOp에 같은 컬렉션을 대상으로 한 쓰기가 애플리케이션 실행 수보다 많이 보인다. lsid와 txnNumber로 묶으면 묶음 수가 사본 수다.FixedBackOff는 사본을 즉시 겹쳐 올린다. ExponentialBackOffWithMaxRetries로 간격을 늘리고 컨슈머가 많으면 지터(무작위 흔들림)를 더한다. 간격을 둬도 서버가 멈춰 있으면 사본은 쌓인다. 간격은 쌓이는 속도를 늦추고 서버가 회복할 시간을 번다.maxTimeMS를 소켓 타임아웃보다 짧게 둔다. 예를 들어 maxTimeMS 20초, socketTimeoutMS 30초로 둔다. 서버가 먼저 멈추면 드라이버 재전송이 생기지 않는다.max.poll.interval.ms(Kafka 기본값 300000ms)를 넘지 않게 한다. 넘으면 컨슈머가 그룹에서 빠지고 오프셋 커밋이 실패한다. Spring Kafka의 기본 BackOffHandler는 대기하는 동안 컨슈머 스레드를 재운다. 넉넉하게 잡는 기준식은 아래와 같다.max.poll.records × (한 번 실행 최대 시간 × 실행 수 + 대기 합) < max.poll.interval.ms
한 번 실행 최대 시간은 소켓 타임아웃 × 2로 잡는다. 위 예시라면 레코드 하나가 4번 실행에 240초를 쓴다._id로 upsert하는 쓰기는 몇 번 겹쳐도 결과가 같다. _id를 드라이버가 매번 새로 만드는 insert는 애플리케이션 재시도마다 문서가 하나씩 늘어난다.updateMany·deleteMany는 retryWrites가 재전송하지 않는다. 실패했을 때 일부만 반영됐는지 알 수 없으므로 애플리케이션 재시도가 안전한지 따로 따져야 한다.DefaultErrorHandler.addNotRetryableExceptions(...)로 지정한다.
- 소켓 타임아웃을 취소로 읽지 않는다. 클라이언트가 포기한 쓰기는 서버에서 계속 돈다.
- retryWrites를 끈다고 겹침이 사라지지 않는다. 서버 사본 수는 애플리케이션 실행 수가 정한다. retryWrites를 끄면 서버로 간 전송만 절반이 된다.
deleteMany는 retryWrites가 지켜 주지 않는다. 네트워크 오류가 나면 드라이버는 재전송 없이 예외를 던지고 몇 건이 지워졌는지는 알 수 없다.maxTimeMS를 만능 마감으로 쓰지 않는다. 중단 검사 위치에 오지 못하는 엔진 안 작업은 마감이 지나도 돈다.
실행으로 검증하지 않은 절차다. 아래 결과는 소스와 명세를 읽고 예상한 것이다.
준비할 것은 둘이다.
--setParameter enableTestCommands=1로 띄운다. 4.2.12에서 configureFailPoint는 테스트 명령으로 등록된다(src/mongo/db/commands/fail_point_cmd.cpp의 MONGO_REGISTER_TEST_COMMAND).절차는 다음과 같다.
failCommand fail point를 건다. appName이 retry-lab인 연결의 insert를 60초씩 붙잡는다.db.adminCommand({
configureFailPoint: "failCommand",
mode: { times: 8 },
data: { failCommands: ["insert"], blockConnection: true,
blockTimeMS: 60000, appName: "retry-lab" }
})
socketTimeoutMS=5000&retryWrites=true&appName=retry-lab으로 연결한다._id는 지정하지 않는다.for (let i = 1; i <= 4; i++) {
try { db.retry_lab.insertOne({ productId: 1, storeId: 7, attempt: i }); break; }
catch (e) { print(`attempt ${i}: ${e.name}`); }
}
db.getSiblingDB("admin").aggregate([
{ $currentOp: { allUsers: true } },
{ $match: { "command.insert": "retry_lab" } },
{ $group: { _id: { lsid: "$command.lsid.id", txn: "$command.txnNumber" },
n: { $sum: 1 } } }
])
db.retry_lab.countDocuments({ productId: 1 })로 문서 수를 센다.셸의 드라이버가 소켓 타임아웃 뒤 재전송한다면 예상 결과는 다음과 같다.
attempt 1부터 attempt 4까지 약 10초마다 하나씩 찍힌다. 실행 한 번이 원래 전송 5초와 재전송 5초를 쓴다.n이 2다. fail point는 service_entry_point_common.cpp:715에서 명령을 붙잡는다. 세션 체크아웃은 그보다 뒤인 912행의 runCommandImpl 호출 안에서 일어난다. 그래서 이 실험에서는 재전송도 세션을 기다리지 않고 원래 요청과 함께 fail point에 붙잡혀 있다._id로 들어갔다. 드라이버 재전송 4번은 문서를 늘리지 않는다.
- 세션 대기의 서버 소스: 재전송이 원래 요청이 끝날 때까지 기다린다는 서술은 driver sessions 명세에 근거한다. 4.2 서버의
session_catalog.cpp와 직접 대조하지는 않았다.- 샤드 클러스터 경로: mongos가
lsid와txnNumber를 shard로 넘기고 shard의 mongod가 세션을 체크아웃하는 경로는 소스로 따라가지 않았다. 이 글의 서버 동작 서술은 mongod 기준이다.- CSOT의 적용 범위: Java 드라이버
timeoutMS의 도입 버전, bulk 쓰기에 적용되는 범위, 이때 재시도가 몇 번까지 일어나는지는 확인하지 않았다.- 지터 지원: Spring의
ExponentialBackOff계열이 지터를 지원하는지는 버전별로 확인하지 않았다.- 재현 절차의 드라이버: mongosh가 쓰는 Node.js 드라이버가 소켓 타임아웃 뒤 쓰기를 재전송하는지는 그 드라이버 소스로 확인하지 않았다. 재전송하지 않으면 4단계의
n은 1이다.$currentOp필드 위치:lsid와txnNumber가command문서 안에 그대로 보이는지는 실행으로 확인하지 않았다.- 최신 서버 버전: 4.2 이후 끊김 시 종료 표시를 받는 명령 목록이 바뀌었는지는 확인하지 않았다. 4.2는 지원이 끝난 버전이다.