MongoDB WriteConflict 해결: MVCC 이해와 트랜잭션 동시성 제어
MongoDB WriteConflict 해결: MVCC 이해와 트랜잭션 동시성 제어
들어가며
지원서 수정 기능을 개발하면서 예상치 못한 문제를 마주했습니다. 개발 당시에는 트랜잭션에 대한 이해가 부족했고, Race Condition을 충분히 고려하지 못해 잘못된 설계로 이어졌습니다. 이로 인해 동시 요청 상황에서 데이터 정합성이 깨지는 심각한 문제가 발생했습니다.
특히 데이터베이스의 지원서 데이터는 변경되었고 Google Sheet도 생성되었지만 버전이 올라가지 않거나, 변경된 Google Sheet가 반영되지 않은 상태가 되어버렸습니다. 이미 시트가 생성된 상태에서 재수정을 시도하면 "시트가 이미 존재한다"는 오류만 반복되었고, 깨진 데이터 정합성 때문에 기능 자체가 작동하지 않았습니다. 결국 수동으로 데이터를 복구해야 했습니다.
이번 글에서는 문제의 원인을 파악하고 MongoDB의 WriteConflict 메커니즘을 깊이 있게 분석한 뒤, 최종적으로 어떻게 해결했는지 그 과정을 공유하려고 합니다.
문제가 되는 코드
async putSubmission( @Param('id') id: string, @Body() dto: RequestBodyPutSubmissionDto, @User() user: UserData, @Submission() submission: SubmissionSchema, ) { // transaction 1 const isNeedVersioning = await this.submissionService.putSubmission({ originSubmission: submission, updateFields: { id, ...dto, userId: user.id, }, }); // transaction 1 // transaction 2 await this.submissionService.migrateSubmissionAnswer({ isNeedVersioning, submissionId: id, }); // transaction 2 // transaction 3 await this.submissionSpreadSheetService.migrateSpreadSheet({ isNeedVersioning, submissionId: id, }); // transaction 3 await this.submissionService.increaseSubmissionVersion({ submission, isNeedVersioning, }); await this.submissionHistoryService.createSubmissionHistory({ submissionId: id, }); await this.pinnedLinkKeywordService.invalidateRelatedSubmissionCacheBySubmissionId({ submissionId: id, }); return await this.submissionService.getSubmissionWithOwner({ submissionId: id, }); }
이 코드는 여러 개의 메서드를 호출하면서 지원서 데이터뿐만 아니라 관련 데이터도 함께 수정합니다. 마이그레이션이 필요할 때는 외부 자원인 Google Sheet 생성 작업과 지원서 답변 전체를 마이그레이션하는 작업까지 포함됩니다. 모든 작업이 완료된 후 마이그레이션 발생 여부에 따라 버전을 올립니다.
이 코드는 작업별로 액션을 나누고 각 작업을 트랜잭션으로 분리해 작업별 원자성을 확보하려는 의도로 작성되었습니다. 또한 어드민에서 한 명만 수정하도록 페이지가 제한되어 있어 동시 수정이 없다고 가정했습니다.
문제점
이 코드는 하나의 메서드에 과도한 책임이 부여되어 있고 구조가 정리되지 않았습니다. 더 심각한 문제는 원자성이 깨져 데이터 정합성이 손상되는 오류가 발생한다는 점입니다.
문제 상황을 정리하면 다음과 같습니다. 지원서 데이터는 변경되었고 Google Sheet는 생성되었지만 버전이 올라가지 않거나, 변경된 Google Sheet가 반영되지 않은 상태가 되었습니다. 이러한 상태에서는 시트가 이미 생성되어 있어 재수정 시 "시트가 이미 존재한다"는 오류만 발생하고, 깨진 정합성 때문에 API가 제대로 작동하지 않습니다.
원인
원인은 Race Condition 상황에서 트랜잭션 분리와 동시성 제어 부재입니다. 한 명의 유저만 수정 API를 요청할 수 있다고 해도, 해당 유저가 짧은 시간에 여러 번 요청할 수 있습니다.
트랜잭션 분리 문제는 API를 하나의 트랜잭션으로 묶지 않고 여러 개의 트랜잭션으로 분리한 것에서 발생합니다. 특히 동일한 데이터(컬렉션-도큐먼트)에 대해 각각의 트랜잭션이 나뉘어져 있어, 실행 순서나 트랜잭션 간 간격에서 오류가 발생합니다.
문제가 되는 메서드들
첫 번째 메서드는 수정 요청으로 들어온 설정 데이터의 값이나 타입을 변경하며 트랜잭션이 적용되어 있습니다.
await this.submissionService.putSubmission({ originSubmission: submission, updateFields: { id, ...dto, userId: user.id, }, });
두 번째 메서드도 트랜잭션이 적용되어 있으며, 마이그레이션이 필요할 때 모든 답변을 새로 변경된 값에 따라 마이그레이션 큐에 넣습니다.
await this.submissionService.migrateSubmissionAnswer({ isNeedVersioning, submissionId: id, });
세 번째 메서드 역시 트랜잭션이 적용되어 있으며, 마이그레이션이 필요할 때 시트를 새로 생성하고 데이터베이스에 저장합니다.
await this.submissionSpreadSheetService.migrateSpreadSheet({ isNeedVersioning, submissionId: id, });
문제가 되는 시나리오

WriteConflict 발생 시나리오
특정 도큐먼트의 초기 상태를 다음과 같다고 가정하겠습니다.
초기 DB 상태: { id: 1, version: 8, spreadSheet: [...] }
1단계: 요청 A 시작
요청 A의 putSubmission(트랜잭션 1)이 실행됩니다. findOne으로 도큐먼트를 조회하면 다음과 같은 상태입니다.
{ id: 1, version: 8, title: "기존 제목", spreadSheet: [...] }
이후 title을 "새 제목"으로 업데이트하고 COMMIT합니다. 결과는 다음과 같습니다.
{ id: 1, version: 8, title: "새 제목", spreadSheet: [...] }
2단계: 요청 A의 마이그레이션 작업
요청 A의 migrateSubmissionAnswer(트랜잭션 2)가 실행되고 COMMIT됩니다. 이어서 migrateSpreadSheet(트랜잭션 3)가 시작됩니다. findOne으로 도큐먼트를 조회하면 동일한 상태입니다.
{ id: 1, version: 8, title: "새 제목", spreadSheet: [...] }
createNewSpreadSheetList가 시작되는데, 이 작업은 약 3초가 소요됩니다. 시트 생성이 완료되었습니다.
3단계: 요청 B 동시 실행
요청 A의 트랜잭션 3이 진행 중인 동안 요청 B의 putSubmission(트랜잭션 B-1)이 실행됩니다. findOne으로 도큐먼트를 조회하면 다음과 같습니다.
{ id: 1, version: 8, title: "새 제목", spreadSheet: [...] }
title을 "다른 제목"으로 업데이트하고 COMMIT합니다.
{ id: 1, version: 8, title: "다른 제목", spreadSheet: [...] }
요청 B의 migrateSubmissionAnswer가 실행되고 COMMIT됩니다. 이어서 migrateSpreadSheet(트랜잭션 B-3)가 시작되어 도큐먼트를 조회하지만, createNewSpreadSheetList 실행 시 시트명 중복으로 실패합니다. 에러가 throw되고 전체 작업이 실패합니다.
4단계: 요청 A의 트랜잭션 3 완료 시도 및 충돌
요청 A의 createNewSpreadSheetList가 완료되고 updateSubmission을 시도합니다. spreadSheet 필드를 업데이트하고 COMMIT을 시도하는 순간 WriteConflict가 발생합니다.
원인은 요청 B의 putSubmission이 도큐먼트를 변경했기 때문입니다. 트랜잭션 내에서 알고 있던 상태와 실제 데이터가 달라진 것입니다.
트랜잭션 내에서 알고 있던 상태는 다음과 같습니다.
{ id: 1, version: 8, title: "새 제목", spreadSheet: [...] }
하지만 실제 데이터는 다음과 같습니다.
{ id: 1, version: 8, title: "다른 제목", spreadSheet: [...] }
MongoDB의 WriteConflict 감지 방식
MongoDB는 도큐먼트의 내부 메타데이터로 변경을 감지합니다. WiredTiger 스토리지 엔진의 낙관적 동시성 제어(Optimistic Concurrency Control)를 사용합니다. 도큐먼트 수준 동시성 제어를 제공하며 내부 메타데이터로 변경을 감지합니다.
에러 예시
{ error: MongoServerError: Caused by :: Write conflict during plan execution and yielding is disabled. :: Please retry your operation or multi-document transaction. at Connection.sendCommand (/Users/taek/Desktop/gem-server/node_modules/.pnpm/mongodb@6.18.0_@aws-sdk+credential-providers@3.922.0_aws-crt@1.27.3__mongodb-client-encryption@6.5.0/node_modules/mongodb/src/cmap/connection.ts:559:17) at process.processTicksAndRejections (node:internal/process/task_queues:105:5) ... errorLabelSet: Set(1) { 'TransientTransactionError' }, errorResponse: { errorLabels: [Array], ok: 0, errmsg: 'Caused by :: Write conflict during plan execution and yielding is disabled. :: Please retry your operation or multi-document transaction.', code: 112, codeName: 'WriteConflict', '$clusterTime': [Object], operationTime: new Timestamp({ t: 1764026755, i: 6 }) }, ok: 0, code: 112, codeName: 'WriteConflict', ... } }
메타데이터 필드
MongoDB는 각 도큐먼트에 내부 메타데이터를 유지합니다.
_ts: 타임스탬프 (Timestamp)_txn: 트랜잭션 번호 (Transaction Number)
WriteConflict 감지 과정
트랜잭션이 도큐먼트를 읽을 때 현재 메타데이터를 확인합니다.
{ "_id": 1, "version": 8, "title": "원본", "_ts": Timestamp(1234567890), // 내부 타임스탬프 "_txn": 100 // 트랜잭션 번호 }
다른 트랜잭션이 도큐먼트를 수정하고 커밋하면 메타데이터가 업데이트됩니다.
{ "_id": 1, "version": 8, "title": "수정됨",
초기 트랜잭션이 도큐먼트를 수정하고 커밋을 시도할 때 다음 과정을 거칩니다.
- MongoDB는 읽었던 시점의
_ts와_txn값을 확인합니다. - 현재 도큐먼트의
_ts와_txn값과 비교합니다. - 값이 다르면 다른 트랜잭션에 의한 변경이 있었음을 감지합니다.
- WriteConflict 오류를 발생시킵니다.
MVCC(Multi Version Concurrency Control)
MVCC는 하나의 레코드(도큐먼트)에 대해서 여러 개의 버전을 동시에 관리하면서 필요에 따라 적절한 버전을 사용할 수 있게 하는 기술입니다. 데이터가 변경되면 여러 개의 버전이 어떻게 관리되고 사용되는지 살펴보겠습니다.
디스크의 페이지 상태가 WiredTiger 스토리지 엔진의 공유 캐시에 적재된 초기 상태입니다.

MVCC 초기 상태
적재된 초기 상태에서는 아직 아무도 데이터를 변경하지 않았기 때문에 변경 이력은 비어있습니다. 각 도큐먼트의 trx-id 값은 그 도큐먼트를 변경한 최종 트랜잭션 아이디를 의미합니다.
db.users.update({name: 'matt'}, {$set: { score: 84}})
새로운 커넥션에서 트랜잭션 id 12로 새로운 트랜잭션을 시작하고 업데이트를 한다고 가정하겠습니다.

MVCC 첫 번째 업데이트
WiredTiger 스토리지 엔진은 디스크에서 읽어온 데이터 페이지의 도큐먼트를 직접 변경하지 않고, 변경 이력에 새로운 변경 데이터를 기록합니다.
db.users.update({name: 'lara'}, {$set: { score: 97}}) // 17 db.users.update({name: 'matt'}, {$set: { score: 78}}) // 21
이제 트랜잭션 id 17과 21에서 위와 같이 변경해보겠습니다. 동일하게 디스크에서 읽어온 데이터 페이지는 그대로 유지하고, 새롭게 변경된 데이터를 변경 이력에 추가합니다.

MVCC 여러 업데이트
WiredTiger 스토리지 엔진은 메모리상에서 도큐먼트의 변경 이력을 저장하기 위해서 스킵 리스트를 사용하는데, 최근 변경은 스킵 리스트의 앞쪽으로 정렬해서 최근 데이터를 더 빠르게 검색할 수 있도록 유지합니다.
이처럼 디스크 데이터를 직접 덮어 쓰지 않고 새롭게 변경되는 데이터를 리스트로 관리하면서 각각의 도큐먼트에 대해서 여러 개의 버전이 관리되도록 유지합니다.
그런데 이렇게 새로운 버전을 계속 스킵 리스트에 추가하기만 하면 상당히 많은 메모리가 추가로 필요하게 됩니다. 그래서 변경 이력이 늘어나서 memory_page_max 설정 값보다 큰 메모리를 사용하는 페이지를 찾아서 자동으로 디스크에 기록하는 작업(Eviction)을 수행합니다.
이때 리컨실레이션(Reconciliation) 과정을 거치면서 원래의 데이터 페이지 내용과 변경된 내용이 병합되어 디스크에 기록되는데, 데이터가 너무 큰 경우에는 2개 이상의 페이지로 나뉘어서 디스크에 기록되기도 합니다.
데이터의 변경 이력에 저장하는 과정에서 쿼리가 실행되었을 때 작동 방식

MVCC 쿼리 실행
위 예시에서 마지막 상태에서는 3개의 변경 이력을 가지게 되었습니다. 이때 name 필드가 matt인 도큐먼트를 찾는 쿼리가 실행되었다고 가정해보겠습니다.
WiredTiger 엔진은 어떤 도큐먼트를 반환해야 할까요? 이때 반환하는 도큐먼트를 결정하는데 있어서 중요한 것이 데이터를 쿼리하는 커넥션의 트랜잭션 아이디입니다.
트랜잭션 id 1 ~ 11 -> { name: matt, score: 80} 트랜잭션 id 12 ~ 21 -> { name: matt, score: 84} 트랜잭션 id 21 ~ -> { name: matt, score: 78}
검색을 하는 커넥션은 반드시 자신의 트랜잭션 번호보다 낮은 트랜잭션이 변경한 마지막 데이터만 볼 수 있습니다. 이는 REPEATABLE_READ 격리 수준과 같습니다(SNAPSHOT 격리 수준이라고 합니다).
읽기에 대해서는 자신의 트랜잭션 번호보다 낮은 트랜잭션이 변경한 마지막 데이터만을 볼 수 있습니다. 쓰기에 대해서는 데이터 덮어쓰기를 방지하기 위해서 이미 변경한 데이터가 자신보다 더 높은 트랜잭션이라면 쓰지 못하게 충돌이 발생합니다. 즉, 자신보다 높은 트랜잭션이 이미 변경했다면 덮어쓰기를 막기 위해 WriteConflict가 발생합니다.
재현
문제를 정확히 이해하기 위해 MongoDB Memory Server를 사용하여 WriteConflict 상황을 재현해보았습니다.
// 메인 재현 함수 async function reproduceWriteConflict() { console.log('='.repeat(60)); console.log('MongoDB WriteConflict 재현 스크립트'); console.log('='.repeat(60)); try { // MongoDB Memory Server 시작 console.log('\n📡 MongoDB Memory Server 시작 중...'); console.log(' (레플리카셋으로 자동 설정됨)'); mongoReplSet = await MongoMemoryReplSet.create({ replSet: { count: 1, // 테스트용이므로 1개 멤버로 충분 }, }); MONGODB_URI = mongoReplSet.getUri(); console.log(`✅ MongoDB Memory Server 시작 완료`); // MongoDB 연결 console.log('\n📡 MongoDB 연결 중...'); await mongoose.connect(MONGODB_URI, { serverSelectionTimeoutMS: 10000, socketTimeoutMS: 45000, }); // 레플리카셋 상태 확인 const adminDb = mongoose.connection.db?.admin(); if (adminDb) { try { const status = await adminDb.command({ replSetGetStatus: 1 }); console.log('✅ MongoDB 레플리카셋 연결 완료'); } catch (error) { console.warn('⚠️ 레플리카셋 상태 확인 실패 (하지만 계속 진행):', error); } } // 초기 데이터 설정 const submissionId = 'submission-1'; console.log('\n📝 초기 데이터 설정...'); // 기존 데이터 삭제 await Submission.deleteMany({}); // 초기 도큐먼트 생성 await Submission.create({ id: submissionId, version: 8, title: '기존 제목', spreadSheet: [ { spreadSheetId: 'spreadsheet-1', sheetId: 0, title: '지원 내역 v8', }, ], }); console.log('✅ 초기 데이터 설정 완료'); // MongoDB 연결 생성 const connection = mongoose.connection; // ============================================ // 시나리오 재현 // ============================================ console.log('\n' + '='.repeat(60)); console.log('시나리오 재현 시작'); console.log('='.repeat(60)); // 1단계: 요청 A의 putSubmission (트랜잭션 1) const sessionA1 = await connection.startSession(); await sessionA1.startTransaction(); await requestAPutSubmission(sessionA1, submissionId); await sessionA1.endSession(); // 2단계: 요청 A의 migrateSpreadSheet (트랜잭션 3) 시작 (비동기로 실행) const sessionA3 = await connection.startSession(); await sessionA3.startTransaction(); // 요청 A의 migrateSpreadSheet를 비동기로 시작 const migrateSpreadSheetPromise = requestAMigrateSpreadSheet(sessionA3, submissionId).catch((error) => { return { error }; }); // 3단계: 요청 B가 동시 실행 (약 500ms 후) await sleep(500); console.log('\n⚡ 요청 B 동시 실행 시작...'); // 요청 B의 putSubmission const sessionB1 = await connection.startSession(); await sessionB1.startTransaction(); await requestBPutSubmission(sessionB1, submissionId); await sessionB1.endSession(); // 요청 B의 migrateSpreadSheet - 시트명 중복으로 실패 const sessionB3 = await connection.startSession(); await sessionB3.startTransaction(); try { await requestBMigrateSpreadSheet(sessionB3, submissionId); } catch (error: any) { console.log(`요청 B 실패로 인해 트랜잭션 롤백: ${error.message}`); } await sessionB3.endSession(); // 4단계: 요청 A의 트랜잭션 3 완료 시도 및 충돌 console.log('\n⏳ 요청 A의 migrateSpreadSheet 완료 대기 중...'); try { const result = await migrateSpreadSheetPromise; if (result && 'error' in result) { throw result.error; } } catch (error: any) { // WriteConflict 에러 코드 확인 if ( error.code === 251 || error.code === 112 || error.message?.includes('WriteConflict') || error.errorLabels?.includes('TransientTransactionError') ) { console.log('\n' + '='.repeat(60)); console.log('🔥 WriteConflict 재현 성공!'); console.log('='.repeat(60)); console.log('\n문제 상황:'); console.log('1. 요청 A의 migrateSpreadSheet가 트랜잭션 내에서 도큐먼트를 조회'); console.log('2. createNewSpreadSheetList가 약 3초 소요되는 동안 (Google API 호출 성공)'); console.log('3. 요청 B가 동일 도큐먼트를 업데이트하고 COMMIT 성공'); await sessionA3.endSession(); // 최종 상태 확인 const finalSubmission = await Submission.findOne({ id: submissionId }); console.log('최종 도큐먼트:', JSON.stringify(finalSubmission, null, 2)); } catch (error) { console.error('\n❌ 에러 발생:', error); throw error; } finally { // 정리 if (mongoose.connection.readyState !== 0) { await mongoose.connection.close(); } if (mongoReplSet) { await mongoReplSet.stop(); } } }
재현 결과
============================================================ MongoDB WriteConflict 재현 스크립트 ============================================================ 📡 MongoDB Memory Server 시작 중... ✅ MongoDB Memory Server 시작 완료 ✅ MongoDB 레플리카셋 연결 완료 📝 초기 데이터 설정... ✅ 초기 데이터 설정 완료 ============================================================ 시나리오 재현 시작 ============================================================ === [요청 A] putSubmission 시작 === [2025-11-30T11:04:20.883Z] 요청 A - putSubmission COMMIT 완료 === [요청 A] migrateSpreadSheet 시작 === [2025-11-30T11:04:20.896Z] createNewSpreadSheetList 시작 (약 3000ms 소요)... ⚡ 요청 B 동시 실행 시작... === [요청 B] putSubmission 시작 === [2025-11-30T11:04:21.415Z] 요청 B - putSubmission COMMIT 완료 === [요청 B] migrateSpreadSheet 시작 === [2025-11-30T11:04:21.418Z] 요청 B - 시트명 중복으로 실패! ⏳ 요청 A의 migrateSpreadSheet 완료 대기 중... [2025-11-30T11:04:23.897Z] createNewSpreadSheetList 완료 [2025-11-30T11:04:23.910Z] ❌ WriteConflict 발생! 🔥 WriteConflict 재현 성공! ============================================================ 최종 상태 확인 ============================================================ 최종 도큐먼트: { "id": "submission-1", "version": 8, "title": "다른 제목", "spreadSheet": [ { "spreadSheetId": "spreadsheet-1", "sheetId": 0, "title": "지원 내역 v8" } ] }
결과에서 볼 수 있듯이 다른 요청의 수정 요청과 WriteConflict가 발생하여 데이터베이스에 변경사항이 반영되지 않았습니다. 이외에 외부 API인 시트는 성공하고 롤백이 되지 않아 무결성이 깨진 상태입니다. MongoDB는 필드 단위가 아닌 도큐먼트 단위로 충돌을 감지합니다.
해결 방안
우선 가장 먼저 도큐먼트 수정에 대해서 하나의 트랜잭션으로 묶긴 해야합니다. 쓰기 실패가 하나라도 일어날 경우 모두 롤백되어 원자성을 유지할 수 있기 때문이에요. (당연한 부분인데, 놓친 부분입니다)
트랜잭션을 하나로 묶는다고 해서 해결되지 않는다 WriteConflict가 발생한다
[트랜잭션을 하나로 묶는다고 해도 WriteConflict가 발생한다]는 사실 맞는 말입니다. 기존의 코드에서 볼 수 있듯이 다른 트랜잭션이 동시에 쓰기 처리를 진행해서 WriteConflict가 발생할 수 있습니다.
기존의 코드는 요청 A의 트랜잭션 1, 2와 요청 B의 트랜잭션 3, 4가 있다고 가정할 때 요청 A의 트랜잭션 2가 요청 B의 트랜잭션 3의 쓰기에 의해서 WriteConflict가 발생한 상황입니다. 하나로 묶는다고 해서 완전히 해결되는 상황은 아닙니다.
에러 코드를 다시 확인해보면 "Write conflict during plan execution and yielding is disabled"라는 메시지가 있습니다. MongoDB는 쿼리 실행 중에 다음 단계를 거칩니다.
- 쿼리 플랜 생성 (어떤 인덱스를 사용할지, 어떻게 실행할지)
- 문서 읽기 (WHERE 조건에 맞는 문서 찾기)
- 문서 잠금
- 업데이트 실행
문제는 2번과 3번 사이에 yielding(양보)이 발생할 수 있다는 것입니다.
undefined
위와 같이 요청이 있다고 가정하겠습니다. 둘 다 같은 문서를 읽었지만, MongoDB 내부 WiredTiger 스토리지 엔진 레벨에서는 다음과 같이 처리됩니다.
- 둘 다 같은 행(row)에 쓰기를 시도
- 스냅샷 격리(Snapshot Isolation) 위반 감지
- 첫 번째 것도 포함해서 모두 WriteConflict 발생 가능
- 쿼리 실행 중 다른 작업에 양보하지 않는 설정
- 동시 쓰기가 감지되면 즉시 WriteConflict
첫 번째 요청조차 실패할 수 있다는 것입니다. MongoDB는 Optimistic Concurrency Control을 사용합니다.
- 모든 요청이 문서를 읽을 때 트랜잭션 스냅샷을 기록
- 쓰기 시도할 때 다른 트랜잭션이 같은 문서를 수정 중인지 확인
- 충돌 감지 시점이 커밋 직전이기 때문에, 먼저 시작했어도 나중에 감지될 수 있음
결국 흐름은 다음과 같습니다.
- 모든 요청이 같은 도큐먼트를 읽음
- MongoDB에 동시에 도착
결국은 분산락
결론적으로 이후 분산락을 걸면 해결이 가능합니다. 하지만 분산 락은 요청 효율을 떨어뜨리는 만큼 도입을 신중하게 해야 합니다.
만약 여러 유저가 동시에 요청을 진행하고 모든 요청을 잘 처리해야 한다면 분산 락이 필요합니다. 현재와 같은 경우도 유저가 한 명만 요청하지만 여러 번 요청을 할 수 있는 상황으로 여러 명이 요청하는 것과 동일한 것으로 보아도 됩니다. 이때 여러 요청 중 하나만 성공하면 됩니다. 나머지는 실패해도 좋습니다.
따라서 분산 락도 사용하면서 하나의 트랜잭션으로 묶어 데이터의 원자성을 보장하고, 실패 처리를 적절히 조절하는 것이 해결 방안입니다.
마치며
이번 문제를 해결하면서 트랜잭션과 동시성 제어에 대해 많은 것을 배울 수 있었습니다. 특히 MongoDB의 낙관적 동시성 제어와 MVCC 메커니즘을 이해하게 되면서, 왜 WriteConflict가 발생하는지 명확히 알게 되었습니다.
단순히 트랜잭션을 묶는 것만으로는 충분하지 않고, 분산 락과 같은 동시성 제어 메커니즘이 필요하다는 것도 깨달았습니다. 또한 외부 API 호출을 트랜잭션 내부에서 수행하는 것이 얼마나 위험한지도 경험했습니다.
결국 데이터 정합성을 보장하기 위해서는 트랜잭션 설계 단계에서부터 동시성을 고려해야 하고, 외부 자원과의 상호작용을 신중하게 다뤄야 한다는 교훈을 얻었습니다. 이러한 경험이 앞으로 더 안정적인 시스템을 설계하는 데 도움이 될 것이라 생각합니다.