이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Backfill은 왜 작은 배치와 재시작 가능성이 필요한가?
- 대량 UPDATE/DELETE가 운영 DB에 어떤 부하를 주는가?
- 백엔드 배치 작업은 어떤 지표를 보며 속도를 조절해야 하는가?
개요
Backfill은 기존 데이터에 새 컬럼 값이나 파생 데이터를 채우는 작업이다. 대량 작업은 운영 DB의 CPU, I/O, lock, WAL/binlog, replica lag에 영향을 준다.
작은 데이터에서는 단일 UPDATE가 편하지만, 운영 대형 테이블에서는 장애가 될 수 있다.
원리
Backfill은 운영 DB에 의도적으로 긴 부하를 넣는 작업이다. 좋은 backfill은 빠른 SQL 하나가 아니라 작게 나뉜 작업, 재시작 가능한 진행률, 멱등성, 중단 기준, 검증 쿼리, 인프라와 합의한 속도 조절로 구성된다.
백엔드 개발자는 “데이터를 채운다”보다 “사용자 트래픽과 복제, 백업, autovacuum, connection pool에 피해를 주지 않고 채운다”를 목표로 잡아야 한다.
나쁜 예
UPDATE members
SET normalized_email = lower(email)
WHERE normalized_email IS NULL;대상 row가 수천만 건이면 긴 트랜잭션, 많은 WAL/binlog, replica lag, lock 경합을 만들 수 있다.
좋은 방향
작은 범위로 나누어 실행한다.
UPDATE members
SET normalized_email = lower(email)
WHERE id > :lastId
AND id <= :nextId
AND normalized_email IS NULL;애플리케이션 배치에서는 진행률을 저장한다.
while (true) {
BackfillRange range = progressRepository.nextRange("member-email", 10000);
int updated = memberRepository.backfillNormalizedEmail(range.startId(), range.endId());
progressRepository.markDone("member-email", range.endId(), updated);
Thread.sleep(200);
}실제 구현에서는 실패 처리, 중복 실행 안전성, lock timeout, 모니터링이 필요하다.
더 안전하게 만들려면 작업 단위를 DB에 기록한다.
CREATE TABLE backfill_jobs (
job_name varchar(100) PRIMARY KEY,
last_id bigint NOT NULL,
updated_count bigint NOT NULL,
status varchar(20) NOT NULL,
updated_at timestamp NOT NULL
);작업은 같은 범위를 다시 실행해도 결과가 깨지지 않아야 한다. 예를 들어 normalized_email = lower(email)처럼 결정적이고 idempotent한 갱신은 재시도에 유리하다. 반대로 balance = balance + 1000 같은 증분 갱신은 중복 실행 시 치명적이므로 별도 이벤트 id나 처리 기록이 필요하다.
지표
Backfill 중에는 다음을 본다.
- update rows per second
- DB CPU와 I/O
- lock wait
- replica lag
- WAL/binlog 증가량
- connection pool 사용량
- 사용자 API 지연
- 배치 실패율
속도는 고정이 아니라 지표에 따라 조절해야 한다. 사용자 트래픽이 늘면 batch sleep을 늘리거나 중지할 수 있어야 한다.
작업 제어값은 코드 상수보다 설정으로 빼두는 편이 좋다.
backfill:
member-email:
enabled: true
batch-size: 5000
sleep-ms: 200
max-replica-lag-seconds: 5장애 상황에서 코드를 다시 배포하지 않고 batch size를 낮추거나 작업을 멈출 수 있으면 인프라와의 협업이 훨씬 쉬워진다.
삭제 작업
대량 DELETE도 위험하다.
DELETE FROM access_logs
WHERE created_at < now() - interval '90 days';한 번에 삭제하면 많은 lock과 로그를 만든다. 작은 배치로 지우거나 partition drop 같은 구조적 대안을 검토한다.
DELETE FROM access_logs
WHERE id IN (
SELECT id
FROM access_logs
WHERE created_at < now() - interval '90 days'
ORDER BY id
LIMIT 1000
);DB별 문법과 최적화가 다르므로 실제 계획을 확인한다.
PostgreSQL에서는 대량 update/delete가 dead tuple을 만들고 vacuum 부담을 키울 수 있다. MySQL InnoDB에서는 undo/redo log와 purge, replication에 영향을 준다. “삭제했으니 공간이 바로 줄어든다”고 기대하면 안 된다.
운영 Runbook
Backfill runbook에는 다음이 있어야 한다.
- 대상 테이블과 예상 row 수
- batch size와 sleep 초기값
- 작업 계정과 connection pool 분리 여부
- 시작, 중단, 재개 명령
- lock wait, replica lag, DB CPU/I/O 중단 기준
- 사용자 API p95/p99 영향 기준
- 검증 쿼리와 완료 조건
- 실패 시 재시작 위치와 중복 실행 안전성
간단한 검증 쿼리도 미리 준비한다.
SELECT count(*)
FROM members
WHERE normalized_email IS NULL;완료 검증은 count 하나로 부족할 수 있다. 샘플링, checksum, 새 코드 read path 검증, 에러 로그 확인까지 묶어야 한다.
실전 팁
- Backfill은 재시작 가능해야 한다.
- 작업 속도는 운영 지표를 보며 조절한다.
- 대량 DML은 트래픽 낮은 시간대에 실행한다.
- 사용자 API와 같은 pool을 쓰지 않도록 분리할 수 있다.
- 검증 쿼리와 완료 조건을 미리 정한다.
- batch size와 sleep은 운영 지표에 따라 바꿀 수 있게 만든다.
- 멱등하지 않은 갱신은 처리 기록이나 이벤트 id 없이 재시도하지 않는다.
- 대량 update/delete 후 vacuum, purge, replica lag 같은 후폭풍을 관찰한다.
위험 신호!
- 수천만 row update를 한 트랜잭션으로 실행한다.
- 진행률 저장이 없어 실패 시 처음부터 다시 한다.
- replica lag가 커져도 작업을 멈출 수 없다.
- 배치가 사용자 API와 같은 커넥션 풀을 독점한다.
- 완료 검증 없이 컬럼 제약을 추가한다.
- backfill이 멱등하지 않은데 실패 후 재시도 전략이 없다.
- 작업 중단 기준을 정하지 않아 인프라 담당자가 위험을 느껴도 멈추기 어렵다.
- 대량 삭제 후 디스크 공간과 vacuum/purge 영향을 고려하지 않는다.
확인 질문
- Backfill을 작은 배치로 나누는 이유는 무엇인가?
- lock 시간, rollback 범위, WAL/binlog, replica lag, I/O 부하를 줄이고 중단과 재시작을 가능하게 하기 위해서다.
- Backfill 중 관찰해야 할 대표 지표는 무엇인가?
- DB CPU/I/O, lock wait, replica lag, WAL/binlog 증가, 사용자 API 지연, batch 처리량이다.
- 대량 DELETE의 구조적 대안은 무엇인가?
- 작은 batch delete, partitioning 후 old partition drop, 보관 정책 변경 등을 검토할 수 있다.
- Backfill 작업을 인프라 담당자와 함께 준비할 때 합의해야 할 기준은 무엇인가?
- batch size, sleep, 실행 시간대, replica lag, lock wait, DB CPU/I/O, 사용자 API 지연, 중단과 재개 방법을 합의해야 한다.