리뷰의 빈 pointDetails를 예약 정보로 채운다고 하자. 저장 목록을 500개씩 나누면 메모리 사용과 트랜잭션 시간도 함께 줄어들까? 조회가 전체 List를 만들고 루프 바깥의 트랜잭션이 끝까지 유지된다면 두 비용은 그대로 남는다.

이 글은 작업을 나눴는데 왜 메모리와 트랜잭션 수명이 제한되지 않는가에 답한다. 표준 MongoDB 복제 세트와 Spring Data MongoDB를 기준으로 설명한다. MongoDB 호환 엔진에는 해당 버전의 제한을 따로 적용해야 한다.

500개씩 저장해도 전체 조회와 한 번의 커밋은 남는다

다음 예제의 분할은 이미 조회한 목록을 나누는 작업이다.

// repository와 fillPointDetails 구현은 생략했다.
@Service
class ReviewMigration {
    @Transactional("mongoTransactionManager")
    fun migrate() {
        val reviews = repository.findByPointDetailsIsEmpty()
        for (batch in reviews.chunked(500)) {
            batch.forEach(::fillPointDetails)
            repository.saveAll(batch)
        }
    }
}

ReviewMigration은 kotlin-spring으로 열리는 Spring 빈의 예제다. chunked(500)은 이미 만든 전체 목록을 분할할 뿐 커서 소비나 트랜잭션 커밋을 나누지 않는다. ::fillPointDetails는 같은 객체의 계산 함수 참조이며 별도 프록시 호출이 아니다.

findByPointDetailsIsEmpty()가 전체 결과를 보관하고, migrate()가 끝나야 트랜잭션이 커밋된다. 저장 명령 수와 커밋 수를 구분해야 한다. 실패를 기록할 때도 find, getMore, 쓰기, commitTransaction 중 어느 단계인지 남긴다. 원본 오류 코드·라벨과 청크 번호가 있어야 전송 실패와 커밋 실패를 나눌 수 있다.

batchSize가 제한하는 것은 전송 묶음이다

배치에는 세 가지 의미가 있다.

경계제한할 대상놓치기 쉬운 조건
커서 배치서버가 한 번에 반환할 문서 묶음전체 결과는 후속 getMore로 계속 온다
메모리 청크계산 중 보관할 데이터최종 List에 모두 모으면 전체가 남는다
커밋 청크한 트랜잭션의 변경외부 루프의 트랜잭션에 참여하면 나뉘지 않는다

MongoDB의 cursor.batchSize 설명은 문서 수와 배치 크기의 제한을 구분한다. batchSize(10000)이 언제나 만 개를 반환한다는 뜻은 아니다. 전체 조회가 16 MiB보다 크다는 이유만으로 일반 커서가 오류를 내는 것도 아니다. 개별 문서와 응답 배치의 제한을 섞지 않는다.

작은 배치는 왕복을 늘릴 수 있고 큰 배치는 버퍼와 처리 대기량을 늘릴 수 있다. 메모리를 제한하려면 필요한 필드만 가져오고 제한된 쿼리나 닫을 수 있는 스트림으로 소비한다. 전송 설정만 바꿔서는 이 경계를 만들 수 없다.

커밋을 나누려면 바깥 트랜잭션을 끝낸다

MongoDB 트랜잭션에는 조회, 연관 정보 조회, 계산, 쓰기 시간이 누적된다. MongoDB 8.0 운영 고려사항의 기본 수명은 1분 미만이며 서버 설정의 영향을 받는다. 소켓 타임아웃이나 개별 명령의 제한을 늘린다고 같은 설정이 바뀌지는 않는다.

리뷰별 독립 계산이라면 루프는 트랜잭션 밖에서 실행하고 별도 writer가 청크를 커밋하는 구조를 선택할 수 있다.

// jobs·reader·calculator의 구현과 주입은 생략했다.
// 이 루프는 트랜잭션 밖에서 실행한다.
class MigrationRunner(private val chunkWriter: ReviewChunkWriter) {
    fun runMigration() {
        val upperId = jobs.loadOrCreateUpperId()
        var lastId = jobs.loadCheckpoint()
        while (true) {
            val rows = reader.findNext(lastId, upperId, 500)
            if (rows.isEmpty()) break
            val changes = calculator.calculate(rows)
            val nextId = rows.last().id
            chunkWriter.apply(changes, nextId)
            lastId = nextId
        }
    }
}

// 별도 Spring Bean. reader는 id > lastId, id <= upperId,
// id ASC, limit, 필요한 필드 projection을 적용한다.
@Service
class ReviewChunkWriter(
    private val template: MongoTemplate,
    private val checkpointRepository: CheckpointRepository,
) {
    @Transactional("mongoTransactionManager")
    fun apply(changes: List<PointChange>, nextId: Long) {
        val bulk = template.bulkOps(
            BulkOperations.BulkMode.UNORDERED, ReviewDocument::class.java,
        )
        for (change in changes) {
            bulk.updateOne(
                Query.query(Criteria.where("_id").`is`(change.id)),
                Update().set("pointDetails", change.details),
            )
        }
        bulk.execute()
        checkpointRepository.advance(nextId)
    }
}

runner에는 트랜잭션을 붙이지 않고 주입한 writer 빈을 호출한다. writer는 kotlin-spring의 자동 open 처리를 전제로 한다. PointChange.id·details는 DTO 프로퍼티이며, Java API의 is는 Kotlin 예약어와 구분해 백틱으로 호출한다. CheckpointRepository와 runner 의존성은 별도 구현이 필요하다.

예제의 ID는 안정적인 순회 키다. upperId로 종료 범위를 정하고 마지막 성공 ID로 다음 조회를 시작한다. writer를 별도 빈으로 둔 이유는 기본 Spring 트랜잭션 프록시가 내부 호출을 가로채지 않기 때문이다. 바깥 호출에 이미 트랜잭션이 있으면 writer가 그 경계에 참여할 수 있으므로 전체 호출 경로도 확인한다.

리뷰 변경과 체크포인트는 같은 MongoDB 트랜잭션·세션에 참여해야 한다. 체크포인트를 다른 DB에 두면 이 어노테이션 하나로 함께 커밋되지 않는다. bulk 오류를 삼킨 뒤 체크포인트만 전진시키면 누락을 만든다.

커서의 전송 단위와 별개로 writer 한 번의 흐름을 보면 커밋 위치가 드러난다. 다음 조회 위치를 갱신하기 전에 쓰기와 체크포인트가 함께 확정돼야 한다.

제한된 리뷰 조회와 계산을 거쳐 같은 MongoDB 청크 트랜잭션에서 필드 쓰기와 체크포인트를 저장하고 커밋 뒤 조회 위치를 전진시키는 흐름

그림 1 청크 쓰기와 체크포인트의 커밋 — 읽기·계산은 제한된 청크로 수행하고 쓰기와 체크포인트를 확정한 뒤 다음 조회 위치를 전진시킨다.

이 흐름은 루프가 트랜잭션 밖에 있고 writer의 저장 자원이 같은 MongoDB 세션에 참여할 때 적용된다. bulk 오류를 삼키지 않으며 동시 수정의 최신성은 별도 갱신 조건으로 검사한다.

UNORDERED는 독립 리뷰 쓰기를 묶는 선택이다. bulk 자체가 다중 문서 원자성을 제공하지 않으며, 트랜잭션 밖에서는 부분 성공을 관리해야 한다. BulkOperations API는 개별 결과가 필요한 @Version 처리를 지원하지 않는다고 설명한다.

재시작할 수 있어도 최신 값은 별도로 지켜야 한다

체크포인트는 성공한 청크의 마지막 ID다. 쓰기 전에 저장하면 중단 뒤 미처리 리뷰를 건너뛴다. 같은 입력을 $set하는 재실행은 증가 연산과 다르게 설계할 수 있지만, 오래된 계산으로 최신 값을 덮어쓰는 문제는 남는다.

대상이 여전히 비어 있는지 조건으로 확인하거나 읽었던 원본 버전을 비교한다. 조건 불일치, 계산 실패, 조회 이후 삭제는 처리 정책을 정해 기록한다. 실패 리뷰를 건너뛰면서 마지막 ID만 남기면 그 대상을 다시 찾을 근거가 없다.

모든 리뷰를 한 순간에 전환해야 한다면 청크 커밋만으로 요구를 충족하지 못한다. 데이터 준비 후 활성화 버전을 바꾸는 등 공개 시점을 따로 설계한다.

적용 전에는 청크 중간 종료, 커밋 직전 실패, 커밋 후 응답 유실을 각각 재현한다. 문서 크기와 계산 지연도 바꿔 힙, 반환 바이트, 트랜잭션 시간을 함께 본다. 청크 크기 500과 커서 값 10000은 구조를 보여주는 예제다. 선택의 기준은 숫자보다 작업 수명을 제한하고 실패한 경계에서 다시 시작할 수 있는가에 있다.