기업명은 서로 다른데 생성 요청 두 개가 충돌한다고 하자. 각 요청이 자기 기업 문서만 쓴다면 독립적인 작업처럼 보인다. 그러나 대체 식별자를 발급하는 공통 시퀀스나 소속 집계까지 바꾸면 같은 문서에서 만난다.
이 글의 질문은 서로 다른 생성 요청이 어느 쓰기를 공유하며, 충돌 뒤 무엇을 다시 실행해야 하는가다. Kotlin/JVM에서 MongoDB 복제 세트와 Java Sync Driver를 사용하는 조건으로 기업·회원 소속·처리 기록을 묶는 예제를 사용한다.
API 이름보다 실제 문서 키를 따라간다
기업 조회, 시퀀스 발급, 기업 생성, 소속 변경, 집계 증가를 요청별 순서로 펼친다. 요청 ID, 트랜잭션 시도 번호, 컬렉션·문서 키, 실패 명령과 오류 라벨을 연결하면 공통 키의 경쟁과 같은 기업 생성 경쟁을 나눌 수 있다. 민감한 사업자번호 원문 대신 동일 대상을 추적할 안전한 식별값을 사용한다.
아래 그림은 공통 시퀀스를 사용하는 경우에 두 요청이 만나는 지점을 보여준다. 기업 문서의 ID가 다르다는 사실만으로 전체 쓰기가 독립적이라고 판단하지 않는다.
그림 1 기업 요청이 공유하는 쓰기 키 — 기업명이 다른 두 요청도 식별자 발급의 공통 문서에서 경쟁할 수 있다.
같은 문서를 쓴다고 모든 요청이 실패하는 것은 아니다. 실제 실패 단계와 오류 라벨을 확인해야 경쟁 후보를 원인과 연결할 수 있다. 단일 문서의 원자적 증가도 공통 쓰기 대상을 없애지는 않는다.
읽은 문서를 다른 쓰기가 먼저 바꾸고 트랜잭션이 나중에 수정하면 충돌로 중단될 수 있다. 다만 WriteConflict라는 이름만으로 운영 원인을 확정하지는 않는다. 캐시 압박 등 다른 조건도 MongoDB 트랜잭션 운영 문서에 설명돼 있다. 최소 재현에서는 두 세션의 읽기 뒤 장벽을 두어 같은 쓰기가 겹치는지 확인한다.
원자적 증가와 기업의 유일성은 다른 조건이다
시퀀스를 읽고 저장하는 대신 findOneAndUpdate와 $inc로 증가시킬 수 있다. MongoDB 단일 문서 원자성은 이 한 쓰기에 적용된다. 그 문서가 여러 기업 생성 트랜잭션에 참여하면 공통 경쟁 지점은 여전히 남는다.
같은 기업의 중복 생성도 따로 막아야 한다. 조회 후 삽입하는 두 요청은 함께 없음을 볼 수 있다. 이름·사업자번호·외부 식별자를 정책에 맞게 정규화하고 안정적인 비즈니스 키에 유일 인덱스를 둔다. 매번 새 대체 번호를 발급하는 것만으로 동일 기업을 식별할 수는 없다. 이름이 같은 합법적인 다른 기업을 합치지 않는지도 판단해야 한다.
오류 라벨에 따라 다시 실행할 대상을 고른다
| 실패 상태 | 재시도할 경계 |
|---|---|
| TransientTransactionError | 새 트랜잭션에서 조회와 업무 전체 |
| UnknownTransactionCommitResult | 결과가 불명인 커밋 |
| 유일 키·입력·권한 오류 | 위반 조건을 식별한 별도 업무 정책 |
중단된 트랜잭션의 마지막 save()만 반복하면 앞선 조회와 판단이 오래된 상태에 남는다. 반대로 커밋 결과가 불명인 것을 업무 전체 재실행으로 바꾸면 중복 효과가 생길 수 있다. 드라이버 트랜잭션 오류 처리는 두 라벨의 재시도 범위를 구분한다. retryWrites가 업무 전체 재시도를 대신하지도 않는다.
다음 예제는 같은 요청을 같은 결과에 연결하고 DB 변경과 outbox를 한 세션에 넣는다.
fun linkCompany(command: LinkCommand): LinkResult {
val requestId = command.requestId // 재전송에도 같은 값
val companyKey = normalizeCompanyKey(command)
val candidateId = idGenerator.nextId()
return mongoClient.startSession().use { session ->
session.withTransaction<LinkResult> {
val done = requestRecords.find(session, eq("_id", requestId)).first()
if (done != null) {
assertSamePayload(done, command)
return@withTransaction decodeResult(done)
}
val company = companies.find(session, eq("canonicalKey", companyKey))
.first() ?: newCompany(candidateId, companyKey, command).also {
companies.insertOne(session, it)
}
val result = linkMembership(session, company, command)
requestRecords.insertOne(session, requestRecord(requestId, command, result))
outbox.insertOne(session, membershipLinkedEvent(requestId, result))
result
}
}
}
LinkCommand.requestId는 Kotlin 프로퍼티다. JVM의 use는 콜백 결과를 반환하고 정상 종료·예외 종료 모두에서 세션을 닫는다. return@withTransaction은 기존 요청 결과를 트랜잭션 콜백에서 반환한다. 컬렉션·도메인 변환 함수와 드라이버 import는 생략했다.
Java Sync Driver 가이드에 따라 모든 작업에 같은 세션을 전달한다. 한 트랜잭션에서 병렬 연산을 실행하지 않는다. Spring 관리 트랜잭션을 쓰는 코드에는 세션 API를 임의로 겹치지 않고 그 경계에 맞는 재시도 계층을 선택한다.
요청 기록의 _id와 기업의 canonicalKey는 유일해야 한다. 같은 요청 키의 다른 입력은 저장한 입력 식별값으로 거절한다. 후보 ID와 이벤트 식별자는 콜백이 다시 실행돼도 유지되도록 밖에서 준비한다.
기업 키의 duplicate key 처리까지 이 예제가 완성하지는 않는다. 실패한 제약을 구별하고 새 경계에서 생성된 기업이나 요청 기록을 다시 읽는 제한된 정책이 필요하다. 최대 시도 수와 전체 기한을 정하고 드라이버 재시도와 중첩되는 총 예산도 확인한다.
외부 효과는 반복 가능한 DB 콜백 밖에 둔다
이메일이나 외부 인증 호출은 DB 롤백으로 취소되지 않는다. 재시도 콜백 안에 있으면 같은 효과가 반복될 수 있다. DB와 함께 outbox를 저장하고 커밋 뒤 발행하는 대안을 사용할 수 있지만, 발행 후 완료 표시 전 종료 때문에 중복 전달은 남는다. 소비자는 이벤트 ID로 이를 처리해야 한다.
공통 시퀀스를 먼저 발급하면 짧은 경쟁 구간을 선택하는 대신 번호 공백을 허용한다. 집계를 비동기로 만들면 경쟁을 줄이는 대신 잠시 오래된 집계를 허용한다. 기업 생성과 소속 저장을 분리할 때는 부분 성공을 복구할 상태가 먼저 있어야 한다. 재시도 횟수만 늘리기 전에 이 비용을 비교한다.
같은 기업·다른 요청 키, 같은 키의 재전송, 서로 다른 기업·공통 시퀀스를 나눠 검증한다. 기업 키당 문서 하나, 요청당 결과 하나, 소속 중복 없음, 집계와 실제 소속 수의 일치를 확인한다. 쓰기 중간과 커밋 전후에 중단을 넣어 외부 중복 소비와 outbox 복구도 살핀다. 충돌의 원인과 재시도의 범위를 설명할 수 있어야 업무 조건을 유지하는 재시도가 된다.
