이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- idempotency와 deduplication은 같은 말이 아니라 각각 어떤 문제를 푸는가?
- backfill과 reprocessing에서 event id, business key, source range를 어떻게 구분해야 하는가?
- dedup table만으로는 왜 business side effect 중복을 충분히 막지 못하는가?
- Java/Spring 배치와 consumer에서 멱등성을 검증하려면 어떤 테스트와 쿼리가 필요한가?
개요
idempotency는 같은 입력을 여러 번 처리해도 결과가 한 번 처리한 것과 같게 만드는 설계다.
deduplication은 이미 처리한 입력을 찾아 걸러내는 기술이다.
둘은 함께 쓰이지만 같은 말은 아니다.
dedup table은 중복 입력을 발견하게 해준다.
idempotent write는 발견하지 못한 중복이 들어와도 결과가 한 번으로 수렴하게 만든다.
backfill과 reprocessing에서는 둘 다 필요하다.
Idempotency
멱등 처리의 핵심은 결과가 수렴하는 것이다.
INSERT INTO order_summary (order_id, paid_amount)
VALUES (:order_id, :paid_amount)
ON CONFLICT (order_id)
DO UPDATE SET paid_amount = EXCLUDED.paid_amount;이 쿼리는 같은 주문을 다시 처리해도 row가 하나로 유지된다.
반대로 단순히 paid_amount = paid_amount + :amount를 실행하면 같은 event가 두 번 들어올 때 금액이 두 배가 된다.
멱등성은 “중복을 찾았는가”보다 “중복이 들어와도 결과가 안전한가”에 가깝다.
Deduplication
deduplication은 이미 본 입력을 기록하고 두 번째 입력을 건너뛰는 방식이다.
CREATE TABLE processed_inputs (
input_id varchar(100) PRIMARY KEY,
source_name varchar(80) NOT NULL,
source_position varchar(120) NOT NULL,
processed_at timestamp NOT NULL DEFAULT now()
);consumer에서는 event id가 input_id가 될 수 있다.
batch에서는 source row id나 파일명+line number가 될 수 있다.
CDC에서는 aggregate id와 event id가 될 수 있다.
중요한 것은 dedup key가 business 의미를 가져야 한다는 점이다.
offset이나 Stream ID만으로는 같은 business event를 다른 경로로 다시 넣었을 때 알아보기 어렵다.
둘을 같이 쓰는 이유
dedup만 있으면 race condition에 취약할 수 있다.
두 worker가 같은 입력을 동시에 처리하면 둘 다 “아직 없음”을 보고 side effect를 실행할 수 있다.
그래서 unique constraint가 필요하다.
idempotent write만 있으면 중복이 얼마나 발생했는지 운영자가 모를 수 있다.
그래서 processed table과 duplicate skip metric이 필요하다.
좋은 설계는 다음을 함께 갖는다.
- event id 또는 input id
- processed input unique constraint
- business table unique key
- upsert 또는 상태 전이 조건
- duplicate skip metric
Backfill에서의 Key
backfill은 event stream이 아니라 운영 DB row 범위에서 시작하는 경우가 많다.
이때 dedup key를 무엇으로 둘지 정해야 한다.
backfill_run_id: order-summary-2026-q2
source_range: order_id 1000000..9000000
input_id: order_id
target_key: order_summary.order_idbackfill_run_id는 작업 추적용이다.
input_id는 이미 처리한 source row를 구분한다.
target_key는 결과 table에서 중복 row를 막는다.
이 셋을 섞으면 재시작과 검증이 어려워진다.
Consumer에서의 Key
consumer에서는 event id와 business key를 분리한다.
event_id: evt-2026-07-01-10042
aggregate_id: order-10042
business_key: payment-9001
source_position: kafka:order.events:3:91200event id는 같은 event 재전달을 잡는다.
business key는 결과 중복을 막는다.
source position은 장애 추적 좌표다.
offset을 event id처럼 쓰면 replay나 재발행에서 중복을 놓칠 수 있다.
검증 쿼리
dedup이 작동하는지는 처리 이력으로 본다.
SELECT input_id, count(*)
FROM processed_input_attempts
GROUP BY input_id
HAVING count(*) > 1;business 결과가 안전한지는 target key로 본다.
SELECT order_id, count(*)
FROM order_summary
GROUP BY order_id
HAVING count(*) > 1;중복 시도는 있을 수 있다.
target 중복은 없어야 한다.
Java/Spring 관점
Spring batch나 consumer에서는 dedup insert와 business write를 같은 transaction에 둔다.
@Transactional
public void apply(OrderRow row) {
if (!processedInputs.insertIfAbsent(row.inputId())) {
metrics.incrementDuplicateSkip();
return;
}
orderSummaryRepository.upsert(row.orderId(), row.amount());
}insertIfAbsent는 DB unique constraint를 이용한다.
단순 exists 조회 후 insert는 동시성에서 안전하지 않다.
운영 지표
- processed input count: 처리한 입력 수다.
- duplicate skip count: dedup으로 건너뛴 수다.
- unique conflict count: DB 제약 충돌 수다.
- target duplicate count: 결과 table 중복 수다.
- reprocess input count: 다시 흘린 입력 수다.
- reconciliation gap: source와 target의 합계 차이다.
duplicate skip count가 증가해도 target duplicate count가 0이면 설계가 작동하는 것이다.
개인 프로젝트 기준
개인 프로젝트에서는 같은 source range를 두 번 실행하는 테스트를 만든다.
첫 실행과 두 번째 실행 후 target row 수와 합계가 같아야 한다.
processed input table에는 중복 skip이 기록되어야 한다.
consumer라면 같은 event id를 두 번 넣는 테스트를 만든다.
이 테스트가 없으면 “멱등 처리”라는 주장이 코드로 검증되지 않는다.
위험 신호!
- deduplication과 idempotency를 같은 말로 쓴다.
- offset이나 line number만 dedup key로 쓴다.
- processed table은 있지만 business table unique key가 없다.
- 중복 skip metric이 없다.
- backfill run id와 input id를 구분하지 않는다.
확인 질문
확인 질문
- 이 작업의 input id와 target business key는 각각 무엇인가?
- 둘을 분리해서 답해야 한다.
- 같은 range를 두 번 실행하면 target 결과가 바뀌는가?
- 바뀌지 않아야 idempotent backfill이다.
- 중복을 건너뛴 사실과 결과 중복이 없다는 사실을 각각 어디서 확인하는가?
- processed table metric과 target 검증 query가 필요하다.