배치를 트랜잭션 하나로 돌리지 마라
백만 건 야간 배치를 트랜잭션 하나로 감쌌더니, 도는 두 시간 동안 서비스가 느려지고 실패하면 처음부터 다시 해야 했어요. 배치를 잘게 나눠 중간부터 이어 하게 만드는 법을 정리했어요.
야간 배치가 백만 건을 처리해요. 전부 성공하거나 전부 실패해야 한다고 생각해서 트랜잭션 하나로 감쌌어요.
배치가 도는 두 시간 동안 서비스가 이상해져요. 테이블이 부풀고, 복제가 밀리고, 다른 쿼리들이 느려져요. 배치가 끝나면 서서히 회복되고요.
그리고 배치가 중간에 실패하면 두 시간이 통째로 날아가요. 처음부터 다시 돌려야 하는데, 다시 두 시간이 걸리고 실패 원인이 데이터에 있으면 또 같은 자리에서 실패해요.
전부 아니면 전무라는 설계가 실제로는 아무것도 못 끝내는 구조가 된 거예요. 안전하게 만들려던 선택이 가장 취약한 지점이 됐습니다.
왜 그런가
트랜잭션 하나로 백만 건을 처리하면, 그 트랜잭션이 두 시간짜리가 돼요. 앞 편에서 본 세 가지 비용이 두 시간 동안 유지되는 거예요.
커넥션이 두 시간 묶여요. 스냅샷이 두 시간 동안 청소를 막아요 — 배치가 도는 동안 서비스 쪽 테이블까지 부풀어요. 그리고 배치가 잠근 행들이 두 시간 동안 안 풀려요.
여기에 배치 고유의 문제가 더해져요.
변경 로그가 커밋 시점까지 쌓여요. 백만 건의 변경이 하나의 트랜잭션이니 커밋 전까지는 회수될 수 없어요. 디스크가 이 크기를 감당해야 하고, 복제본은 커밋 이후 그 전체를 한꺼번에 재생해야 해요.
롤백 비용도 커요. 두 시간 작업이 실패하면 되돌리는 데도 시간이 걸려요. 롤백이 진행되는 동안 아무것도 못 합니다.
그리고 진행 상황을 알 수 없어요. 커밋 전까지는 아무것도 보이지 않으니 "지금 몇 건까지 했는지"를 모릅니다. 중단하고 이어서 하는 것도 불가능하고요.
운영 관점에서 이게 가장 아파요. 두 시간짜리 작업이 지금 어디쯤인지, 정상인지 멈춘 건지 판단할 근거가 없습니다.
코드로 보면
나누고, 어디까지 했는지 기록한다:
BEGIN;
-- 백만 건 처리
COMMIT; -- 두 시간짜리 트랜잭션
· 청크로 나눈다 — 각각이 짧은 트랜잭션
last_id = 체크포인트에서 읽는다
loop:
BEGIN;
SELECT id, ... FROM src
WHERE id > :last_id ORDER BY id LIMIT 1000;
... 처리 ...
UPDATE checkpoint SET last_id = :max_id; -- 같은 트랜잭션
COMMIT;
지연이 크면 잠깐 쉰다
· 체크포인트가 같은 트랜잭션에 있어야
· '처리했는데 기록 안 됨' 이 안 생긴다✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
핵심은 원자성의 단위를 다시 정하는 거예요.
"전부 아니면 전무"가 정말 요구사항인 경우는 생각보다 드물어요. 백만 건의 정산을 처리하는데, 50만 건까지 처리된 상태가 정말 잘못된 상태일까요? 대부분은 "각 건이 정확히 한 번 처리되면 된다"가 진짜 요구사항이에요. 그러면 건별 원자성으로 충분하고, 배치 전체를 묶을 이유가 없어요.
이렇게 바꾸면 체크포인트가 필요해져요. 어디까지 처리했는지 기록하고, 재시작하면 그다음부터 이어가요. 중요한 건 체크포인트 갱신이 처리와 같은 트랜잭션에 있어야 한다는 점이에요. 따로 하면 "처리는 됐는데 기록은 안 된" 상태가 생겨 중복 처리가 일어나요.
청크 크기는 시간으로 정하세요. 한 청크가 1초 안에 끝나는 크기가 안전해요. 행 수로 고정하면 데이터에 따라 시간이 크게 달라져요.
복제 지연을 보면서 속도를 조절하는 것도 필수예요. 지연이 임계값을 넘으면 쉬고, 회복되면 재개하는 루프요. 고정 속도로 돌리는 것보다 훨씬 안전합니다.
멱등성도 함께 설계하세요. 재시작이 전제된 구조니 같은 건이 두 번 처리될 수 있어요. 처리 결과를 덮어쓰는 형태(같은 값을 다시 써도 결과가 같음)로 만들거나, 처리 여부를 표시하는 컬럼을 두세요.
그리고 실패한 건을 따로 모으세요. 데이터 문제로 실패하는 건이 있으면, 그것 때문에 전체가 멈추면 안 돼요. 실패 목록에 넣고 계속 진행한 뒤 나중에 따로 처리하는 게 실무적입니다.
처방
- 배치를 청크로 나누세요. 한 청크가 1초 안에 끝나는 크기로요.
- 체크포인트를 처리와 같은 트랜잭션에 기록하세요. 중복 처리와 누락을 동시에 막는 유일한 방법이에요.
- 대상 범위를 인자로 받으세요. "지금부터 24시간 전"이 아니라 "2026-08-05 의 데이터"처럼요. 재실행이 안전해지고 서머타임 문제도 사라져요.
- 멱등하게 만드세요. 같은 건을 두 번 처리해도 결과가 같도록요.
- 복제 지연을 보면서 속도를 조절하세요. 임계값을 넘으면 쉬고 회복되면 재개하세요.
- 실패한 건을 모아 두고 계속 진행하세요. 한 건 때문에 전체가 멈추면 안 돼요.
- 진행 상황을 로그로 남기세요. 몇 건까지 했는지, 얼마나 걸렸는지. 두 시간짜리 작업이 지금 어디쯤인지 모르는 것 자체가 운영 위험이에요.
- 정말 전부 아니면 전무가 필요한지 다시 확인하세요. 대부분은 건별 원자성으로 충분합니다.
지금 확인해 보세요
배치가 서비스를 해치고 있지 않은가:
· 배치 세션의 트랜잭션 나이 — 짧게 유지되고 있나
SELECT pid, now()-xact_start AS age, left(query,50)
FROM pg_stat_activity
WHERE application_name LIKE '%batch%'
ORDER BY age DESC;
분 단위로 길면 청크가 안 나뉜 것이다
· 복제 지연 — 이걸 보면서 속도를 조절한다
SELECT client_addr, replay_lag FROM pg_stat_replication;
· 배치 중 죽은 행이 회수되고 있나
SELECT relname, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 5;
· 체크포인트가 실제로 전진하는지
SELECT * FROM batch_checkpoint;
멈춰 있으면 배치가 죽었거나 같은 청크에서 반복 실패 중핵심 한 줄전부 아니면 전무는 대개 아무것도 못 끝낸다. 나누고 기록하라.