프로그램·병원·시술 정보를 조합해 검색 행을 만든다고 하자. 전체 조회를 청크로 바꾸고 저장을 묶으면 작업량을 제한할 수 있다. 그런데 세 번째 청크가 저장 도중 멈추거나, 스캔이 계산한 오래된 값이 실시간 갱신보다 늦게 도착하면 무엇이 남을까?
이 글은 중단 뒤 어느 구간을 다시 처리하고, 경쟁하는 쓰기 중 어떤 버전을 남길 것인가를 다룬다. 원본 읽기와 PostgreSQL 검색 projection의 저장 경계를 나눠 설명한다. 다음 그림에서 읽기·계산과 커밋 위치를 먼저 본다.
그림 1 청크 재색인 처리 흐름 — 읽기와 계산은 청크별로 제한하고, 검색 쓰기와 체크포인트는 같은 트랜잭션에서 확정한다. 변경 로그의 수집 경계는 스캔 전에 확보한다.
다음 조회 위치와 작업 종료 범위를 함께 저장한다
청크 결과를 전체 실행의 리스트에 계속 누적하면 메모리는 여전히 대상 수에 비례한다. 병원·시술 ID도 청크에서 중복 제거해 묶어 조회하고 청크가 끝나면 맵을 비운다. 읽기 건수, 출력 바이트, 계산 시간으로 상한을 정하되 읽기 크기와 JDBC 전송 크기·커밋 크기가 같을 필요는 없다.
순회에는 마지막 키와 작업 시작 시 저장한 종료 키를 사용한다.
SELECT id, hospital_id, name, source_revision, status
FROM programs
WHERE id > :last_id
AND id <= :upper_id
ORDER BY id ASC
LIMIT :chunk_size;
last_id는 성공한 커밋 뒤에 전진하고 upper_id는 재시작해도 유지한다. 큰 OFFSET은 건너뛸 행을 계산하는 비용이 있으며 LIMIT에는 예측 가능한 정렬이 필요하다. PostgreSQL LIMIT/OFFSET이 이 선택의 근거다. 수정 시각을 순회 키로 쓰면 동률을 구분할 보조 키도 필요하다.
종료 키는 스냅샷이 아니다. 범위 안의 데이터가 바뀔 수 있고 낮은 ID의 늦은 커밋이 이미 지나간 구간에 나타날 수도 있다. READ COMMITTED의 문장별 스냅샷과 청크별 트랜잭션을 구분한다. 관련 저장소의 변경은 이후 따라잡을 경계가 필요하다.
bulk라는 이름보다 실제 저장 문장을 확인한다
JPA merge()를 반복하고 flush()·clear()를 호출하면 영속성 컨텍스트를 제한할 수 있다. 하지만 native upsert 한 묶음과 같은 SQL을 보장하지는 않는다. 추가 조회와 JDBC batching의 적용은 실제 SQL·설정을 확인해야 한다. flush도 커밋이 아니다. Hibernate 6.6 batching을 참고한다.
재생성할 수 있는 검색 projection이라면 버전 조건을 가진 SQL upsert가 한 대안이다.
INSERT INTO program_search_index AS existing
(program_id, searchable_text, source_revision, deleted)
VALUES
(:id, :text, :revision, :deleted)
ON CONFLICT (program_id) DO UPDATE
SET searchable_text = EXCLUDED.searchable_text,
source_revision = EXCLUDED.source_revision,
deleted = EXCLUDED.deleted
WHERE existing.source_revision < EXCLUDED.source_revision;
program_id는 고유해야 하고 실제 묶음에는 같은 ID를 중복 넣지 않는다. ON CONFLICT는 삽입·갱신의 충돌을 처리하지만 읽은 값이 최신인지를 판단하지 않는다. source_revision 비교는 그 판단을 추가한 설계다.
projection 버전은 프로그램뿐 아니라 병원 공개 상태와 시술명 변경도 반영해야 한다. 같은 revision은 같은 결과를 만들고 변환 규칙을 바꾸면 새 revision이나 스키마 버전을 사용한다. 비공개 병원을 읽기에서 건너뛰기만 하면 기존 검색 행이 남으므로 제거 상태도 써야 한다.
체크포인트를 먼저 전진시키지 않는다
검색 쓰기와 체크포인트 사이에 중단되면 저장한 구간의 재처리나 미저장 구간의 누락이 생긴다. 같은 PostgreSQL에 있는 둘을 청크 트랜잭션으로 묶는다.
// 별도 Spring Bean의 트랜잭션 메서드
@Service
class SearchChunkWriter(
private val indexWriter: SearchIndexWriter,
private val jobRepository: JobRepository,
) {
@Transactional("transactionManager")
fun commitChunk(job: Job, rows: List<SearchRow>, nextId: Long) {
indexWriter.bulkUpsert(rows)
val changed = jobRepository.advance(
job.id, job.lastId, nextId, job.fencingToken,
)
if (changed != 1) {
throw IllegalStateException("Job ownership changed")
}
}
}
Job의 값은 Kotlin 프로퍼티로 읽는다. SearchIndexWriter·JobRepository는 같은 DB 자원을 사용하는 예제 계약이며 구현은 생략했다. 이 @Service는 kotlin-spring으로 클래스와 메서드가 open 처리되는 조건에서 외부 빈이 호출한다.
체크포인트는 이전 위치와 소유권 토큰을 조건으로 전진한다. 인계받기 전의 작업자가 늦게 저장하면 조건 실패로 전체 청크를 롤백하는 구조다. 잘못된 행을 건너뛰는 정책에도 영속 실패 기록이 필요하다.
이 보장은 같은 DB·트랜잭션에 참여할 때 적용된다. MongoDB 원본 읽기까지 하나의 어노테이션으로 묶이지는 않는다. 다음 그림처럼 커밋 전후와 응답 유실을 구분해 재시작 위치를 확인한다.
그림 2 커밋 시점에 따른 재시작 위치 — 커밋 전 중단은 이전 체크포인트부터 다시 처리하고, 커밋 후 중단은 저장된 다음 구간으로 이어간다. 커밋 응답을 잃은 경우에는 DB에 저장된 체크포인트로 결과를 확인한다.
버전 비교로 막는 경쟁과 변경 로그로 찾는 누락을 나눈다
실시간 작업이 새 revision을 저장한 뒤 오래된 스캔 값이 도착하면 버전 비교로 덮어쓰기를 거절할 수 있다. 그러나 스캔이 놓친 신규 행과 삭제 이벤트는 이 조건이 만들어주지 않는다. 스캔 전에 변경 로그의 수집 위치를 확보하고 기본 스캔 뒤 정한 경계까지 따라잡은 다음 실시간 처리로 인계한다.
로그 위치도 단순 발급 번호와 다르다. 낮은 번호의 늦은 커밋을 놓치지 않는 CDC 위치나 미처리 이벤트를 남기는 outbox 소비 규칙이 필요하다.
물리 삭제된 행에는 비교할 버전이 없다. 검색 데이터에 삭제 revision을 가진 tombstone을 남기고 조회에서 제외하면 오래된 삽입의 부활을 막을 수 있다. 다음 그림은 낮은 버전의 갱신과 삭제 뒤 삽입을 같은 규칙으로 다룬다.
그림 3 버전 비교로 덮어쓰기와 부활을 차단하는 과정 — 낮은 revision의 쓰기는 정상적인 조건부 미갱신으로 처리한다. 삭제 이후에도 tombstone의 revision을 유지해야 오래된 데이터가 다시 삽입되는 것을 막을 수 있다.
tombstone은 오래된 작업·이벤트가 도착하지 않는 보존 경계를 확보한 뒤 정리한다. 전체 결과를 한 번에 공개하려면 세대별 검색 데이터와 라우팅 전환을 선택할 수 있지만 새 세대도 변경을 따라잡고 전환 뒤 이벤트 대상도 정해야 한다. 이번 스캔에서 안 보였다는 이유만으로 삭제하면 동시 신규 데이터까지 지울 수 있다.
적용 전에는 커밋 전후 중단, 새 값 뒤 오래된 upsert, tombstone 뒤 오래된 삽입, 작업 인계 뒤 옛 실행자의 커밋을 각각 시험한다. 건수와 함께 공개 상태·검색 내용·revision 차이·실패 목록을 확인한다. 처리량이 늘어도 이 조건이 깨지면 완료한 재색인이라고 판단할 수 없다.


