Idempotency — The Pipeline Will Certainly Run Again
한국어 원문으로 표시합니다.
한 줄 요약
파이프라인은 실패하고, 실패하면 다시 돌리며, 그때 결과가 달라지면 데이터가 오염되므로, 재실행 안전성은 선택이 아니라 설계의 전제다.
왜 이게 필요했나
배치가 새벽에 실패했다고 하자. 아침에 다시 돌린다. 그런데 실패 지점이 적재 중간이었다면 일부는 이미 들어가 있다. 그대로 다시 넣으면 중복이 생기고, 통째로 지우고 다시 넣으면 그 사이 들어온 다른 데이터까지 날아간다.
이 상황을 매번 사람이 판단하게 두면 언젠가 실수가 난다. 애초에 몇 번을 넣어도 같은 결과가 되도록 만드는 편이 낫다.
어떻게 동작하나
멱등성의 출발점은 자연 키다. 각 행을 유일하게 식별하는 값이 있어야 "이미 들어온 것"을 알아볼 수 있다. 전표 번호, 주문 번호, 이벤트 ID 같은 것이다. 이 값에 유일 제약을 걸면 데이터베이스가 중복을 대신 막아 준다.
그다음이 upsert 다.
INSERT INTO orders_final (order_ref, amount, status, content_hash)
SELECT ...
ON CONFLICT (order_ref) DO UPDATE
SET amount = EXCLUDED.amount,
status = EXCLUDED.status,
content_hash = EXCLUDED.content_hash,
updated_at = now()
WHERE orders_final.content_hash IS DISTINCT FROM EXCLUDED.content_hash;
마지막 WHERE 절이 중요하다. 이것이 없으면 내용이 하나도 안 바뀐 재실행에서도 모든 행의 updated_at 이 갱신된다. 그러면 "무엇이 실제로 바뀌었는가"를 알 수 없게 되고, 하류에서 변경분만 가져가는 증분 소비도 망가진다.
내용 해시를 두면 비교가 간단해진다. 값들을 정해진 순서로 이어 붙여 해시를 계산하고, 그 값이 다를 때만 갱신한다. 컬럼이 많아져도 비교 로직이 한 줄로 유지된다.
중복 제거도 필요하다. 같은 전표가 두 번 적재되었다면 어느 쪽을 남길지 규칙을 정해야 한다. 보통 나중에 들어온 것을 남긴다. 원본에 적재 순서를 나타내는 증가 키가 있으면 전표별로 최대값 행만 고르면 된다.
파티션 단위로 갈아 끼우기
행 단위 upsert 로 안 되는 경우가 있습니다. 원본이 하루치를 통째로 다시 주는 경우, 또는 삭제된 행을 알아낼 방법이 없는 경우입니다. 이때는 덮어쓰기 단위를 파티션으로 잡습니다.
BEGIN;
-- 1) 새 데이터를 임시 표에 적재한다
CREATE TEMP TABLE stage_20260906 (LIKE orders_final INCLUDING ALL);
COPY stage_20260906 FROM ...;
-- 2) 그날 파티션만 통째로 바꿔 끼운다
ALTER TABLE orders_final DETACH PARTITION orders_20260906;
ALTER TABLE orders_final ATTACH PARTITION stage_20260906
FOR VALUES FROM ('2026-09-06') TO ('2026-09-07');
COMMIT;
핵심은 "지우고 넣는다" 가 아니라 "만들어 두고 바꿔 끼운다" 는 것입니다. 지우고 넣는 사이에는 데이터가 없는 구간이 생기고, 그때 조회한 사람은 빈 결과를 봅니다. 바꿔 끼우기는 트랜잭션 안에서 원자적으로 끝나므로 그 구간이 없습니다.
이 방식은 재실행에도 안전합니다. 몇 번을 돌려도 그 파티션의 최종 상태는 마지막 실행 결과 하나입니다.
안전하지 않은 연산을 알아보는 법
멱등하지 않은 연산에는 공통점이 있습니다. 현재 값을 읽어서 그것을 바탕으로 쓰는 것 입니다.
| 안전하지 않다 | 안전하다 |
|---|---|
UPDATE t SET n = n + 1 |
UPDATE t SET n = <계산된 절대값> |
INSERT (제약 없음) |
INSERT ... ON CONFLICT DO UPDATE |
| 파일에 append | 파일을 새로 쓰고 rename |
| 큐에 메시지 발행 | 발행하되 소비자가 중복을 거른다 |
| 외부 API 호출(결제 등) | 멱등 키를 함께 보낸다 |
마지막 줄이 중요합니다. 파이프라인이 외부 시스템을 부를 때는 그쪽의 멱등성에
기대야 합니다. 대부분의 결제 API 는 Idempotency-Key 헤더를 받습니다. 같은 키로
두 번 부르면 두 번째는 첫 번째의 결과를 그대로 돌려줍니다. 키는 재시도해도
같아야 하므로 요청마다 새로 만들면 안 되고, 원본 데이터에서 결정적으로
유도해야 합니다(예: sha256(주문번호 + 금액)).
워터마크와 재처리 범위
증분 적재에서는 "어디까지 처리했는가" 를 기록합니다. 이 값을 처리한 뒤에 갱신 하는 것이 원칙입니다. 먼저 갱신하면 중간에 죽었을 때 그 구간을 영영 건너뜁니다.
읽기 → 변환 → 적재 → (성공 시에만) 워터마크 갱신
그리고 워터마크에 여유를 둡니다. 원본이 이벤트 시각 기준으로 늦게 도착하는 데이터를 허용한다면(late arrival), 워터마크를 마지막 시각이 아니라 마지막 시각 − 유예 기간 으로 잡습니다. 그러면 겹치는 구간을 매번 다시 읽게 되는데, 멱등하게 만들어 두었으니 문제가 되지 않습니다. 멱등성이 있으면 겹쳐 읽기가 공짜가 됩니다.
현장에서 만나는 모습
실행 로그를 남기는 것이 큰 도움이 된다. 실행마다 삽입 건수와 갱신 건수를 기록해 두면, 아무것도 바뀌지 않은 실행은 둘 다 0 으로 남고 늦게 도착한 변경이 반영된 실행은 갱신 건수가 올라간다. 이 두 숫자만 봐도 파이프라인이 정상인지, 원천에 이상이 있는지 판단할 수 있다.
그리고 재시도 코드 안에는 데이터베이스 작업만 넣어야 한다. 메일 발송이나 외부 API 호출이 섞여 있으면 재시도할 때마다 그 부수 효과가 반복된다. 외부 호출이 필요하다면 그쪽도 멱등성 키를 받는 방식이어야 한다.
다음 실습에서 할 것
자연 키에 기본 키를 걸고, 중복 전표를 제거하고, 내용 해시로 변경 여부를 판단하는 upsert 를 만든다. 그리고 아무것도 바뀌지 않은 재실행과 한 건만 바뀐 재실행이 각각 어떻게 기록되는지 확인한다.