조회수 100인 문서를 두 요청이 동시에 읽고 각각 101로 저장한다고 하자. 두 쓰기가 성공해도 최종 값은 101이다. 이 문제의 질문은 증가를 한 번의 원자적 쓰기로 표현하면 어디까지 정확해지고 무엇이 남는가다.
표준 MongoDB의 WiredTiger·replica set 환경에서 숫자형 조회수를 다룬다. 먼저 유효 요청마다 1을 더한다는 집계 정의를 사용한다. 사용자별 하루 한 번 같은 업무 규칙은 뒤에서 별도로 다룬다.
읽기와 저장 사이에 증가가 사라진다
val article = repository.findById(articleId).orElseThrow()
article.views = article.views + 1L
repository.save(article)
Kotlin 예제의 Article.views는 숫자형 Long의 변경 가능한 프로퍼티(var)다. 저장소·문서 매핑은 생략했다. 이 코드는 읽은 값에 1을 더해 새 값을 저장한다. 요청 A와 B가 같은 100을 읽었다면 둘 다 101을 만든다. 한 문서의 각 쓰기가 원자적이어도 두 단계로 나뉜 읽기·계산·저장이 하나의 원자적 증가가 되지는 않는다. 매핑 방식에 따라 오래된 객체를 저장하며 다른 필드까지 덮어쓰는지도 확인한다.
두 읽기가 모두 저장보다 먼저 끝난 경우를 아래 순서로 놓을 수 있다. 두 번째 요청도 자신의 오래된 값으로 계산했으므로 첫 증가를 이어받지 못한다.
그림 1 같은 시작값에서 생기는 갱신 손실 — 두 요청이 저장 전에 같은 값을 읽으면 각 저장의 성공만으로 두 증가가 반영되지는 않는다.
$inc는 최종 값 101 대신 현재 값에 1을 더하라는 의도를 전달한다. 이 변경은 오래된 값의 덮어쓰기를 다루며 응답 유실 뒤 새 증가 요청이나 방문 이벤트의 중복 판정을 대신하지 않는다.
재현할 때는 두 요청의 읽기를 모두 끝낸 뒤 저장을 풀어 같은 시작값을 보게 한다. 단순히 많은 요청을 보냈다는 사실만으로 이 실행 순서를 만들었다고 할 수 없다.
새 값을 계산하지 않고 증가 명령을 보낸다
서버가 현재 값에서 증가하도록 표현한다.
val result = mongoTemplate.updateFirst(
Query.query(Criteria.where("_id").`is`(articleId)),
Update().inc("views", 1L),
Article::class.java,
)
if (result.matchedCount == 0L) {
throw ArticleNotFoundException()
}
Criteria의 Java 메서드 이름 is는 Kotlin 키워드이므로 백틱으로 호출한다. Article::class.java는 MongoTemplate에 매핑할 JVM 클래스 타입을 전달하고 matchedCount는 Java getter를 프로퍼티로 읽는다.
$inc는 해당 문서 안에서 증가를 원자적으로 처리한다. 오래된 조회수를 $set하는 흐름과 다르다. 단순 증가에는 이전 조회수 조건을 필터에 붙이지 않는다. 추가 업무 조건이 있다면 그 조건은 별도로 정의한다. MongoDB $inc 문서의 단일 문서 동작을 기준으로 이해한다.
matchedCount가 0이면 대상이 없다는 정책으로 처리한다. 기본 예제는 upsert를 사용하지 않아 삭제된 게시물을 증가 요청으로 되살리지 않는다. 증가 뒤의 값을 응답해야 한다면 findAndModify의 새 값 반환 옵션을 검토한다. 증가 후 따로 읽으면 다른 요청의 증가까지 포함될 수 있다.
필드가 없으면 $inc가 만들 수 있지만 null인 필드에는 오류가 난다. 마이그레이션된 타입·매핑·정수 범위를 확인한다. 예제의 1L과 저장된 조회수 타입도 일치시킨다.
WriteConflict와 값 유실은 같은 증상이 아니다
WiredTiger는 쓰기 충돌을 내부에서 재시도할 수 있다. 따라서 충돌 지표가 있다는 사실만으로 증가가 사라졌다고 판단하지 않는다. MongoDB 동시성 설명과 최종 쓰기 결과를 함께 본다.
명시적 트랜잭션에서 읽은 문서를 다른 요청이 변경하면 트랜잭션이 중단될 수 있다. 다른 기록과 함께 확정할 필요가 없는 단순 조회수 증가를 넓은 트랜잭션에 넣으면 경합 구간이 길어진다. 반대로 방문 이벤트 기록과 카운터를 반드시 함께 반영해야 한다면 그 원자성 요구를 유지해야 한다.
$inc가 갱신 표현을 바로잡아도 인기 게시물 하나에 모든 쓰기가 집중되는 비용은 남는다. 동시성 정확성과 처리 용량을 다른 질문으로 다룬다.
재시도 주체에 따라 같은 증가인지 달라진다
서버 내부의 재시도, 드라이버의 retryable write, 애플리케이션이 새 요청을 보내는 재시도를 나눈다. Retryable Writes 문서는 지원 토폴로지·acknowledged write 조건과 트랜잭션 내부 쓰기의 제약을 설명한다.
드라이버 재시도 기능을 켰다고 임의의 HTTP 재요청까지 같은 작업으로 인식하지는 않는다. 서버가 증가를 적용한 뒤 응답을 잃었는데 애플리케이션이 새 $inc를 보내면 두 번 반영될 수 있다. 또한 명시적 트랜잭션 안의 개별 쓰기는 retryWrites로 각각 재시도되는 것이 아니다.
재시도 가능한 오류는 문자열 일부가 아니라 드라이버의 오류 유형·라벨과 해당 기능의 계약으로 구분한다. 횟수·전체 기한·backoff를 제한해도 하나의 뜨거운 문서에 쓰기가 몰리는 구조는 그대로다. 트랜잭션 전체를 다시 실행하는 콜백에 메일 같은 외부 부수 효과를 넣는지도 확인한다.
무엇을 한 번으로 셀지 정한다
유효 API 요청 한 번, 화면 표시 한 번, 같은 사용자 하루 한 번은 서로 다른 집계다. 새로고침·봇·재전송을 허용할지 먼저 정해야 카운터의 정확성을 평가할 수 있다.
같은 방문 이벤트를 한 번만 반영해야 한다면 안정적인 이벤트 ID와 중복 기록을 사용한다. 중복 판정 기록과 증가 사이의 실패도 처리해야 한다. 둘을 트랜잭션으로 묶거나 영속 이벤트를 멱등 집계하는 대안 중 요구에 맞는 것을 선택한다. 사용자별 일일 집계에는 사용자 식별과 날짜·시간대 경계도 필요하다.
즉시 정확한 합계가 필요하지 않다면 큐에 쌓아 배치로 더하거나 카운터를 분산할 수 있다. 읽기 합산 비용, 지연, 큐 유실, 중복 배치의 반영 정책이 추가된다. 단순 증가에서 출발해 실제 쓰기 부하와 집계 요구가 이 비용을 정당화할 때 확장한다.
확정된 증가 수와 최종 값을 비교한다
초기값 0에 100개 작업이 각각 100번 증가하는 조건이라면 기대값은 10,000이다. 이것은 검증 입력과 기대값이다. 실패하거나 결과가 불명인 호출을 성공 수에 섞으면 최종 값의 차이를 해석할 수 없다.
단일 프로세스와 여러 인스턴스에서 확정 성공 수·최종 값을 비교하고 지연 분포, 쓰기 충돌, 애플리케이션 재시도를 함께 본다. 숫자형·누락·null 필드, 삭제된 대상, 응답 유실 후 새 요청도 별도 조건으로 확인한다.
원자적 증가는 읽고 덮어쓰는 값 유실을 해결하는 표현이다. 중복 집계 방지와 높은 쓰기 용량은 각각의 계약과 검증 조건을 더해야 한다.
