Defending Against Duplicate Delivery With Idempotency Keys
한국어 원문으로 표시합니다.
목표
멱등키를 설계하고 DB 제약으로 중복을 막고, 동시 실행에서도 안전하게 만들고, 저장-후-응답까지 구현해 재전송이 안전한 수신측을 만들 수 있게 됩니다.
왜 중요한가
비동기 연동과 재시도가 있는 세상에서 중복은 예외가 아니라 기본값입니다.
그런데 중복 방어를 애플리케이션의 조회 후 없으면 삽입 으로 구현하면
동시에 두 건이 들어오는 순간 뚫립니다. 조회와 삽입 사이에 틈이 있기 때문입니다.
DB 제약은 그 틈을 없애고, 애플리케이션에 버그가 있어도 뚫리지 않습니다.
그리고 한 걸음 더 나가 최초 응답을 저장해 두고 중복 요청에 그대로 돌려주면,
송신측이 타임아웃 후 마음 놓고 재전송할 수 있게 됩니다.
결제 API 들이 Idempotency-Key 를 쓰는 이유가 이것입니다.
단계
/root/i/idem.db(sqlite)에inbox_log테이블을 만듭니다. 컬럼은msg_id,biz_key,status,response,created_at이고,msg_id에 PRIMARY KEY 또는 UNIQUE 제약이 있어야 합니다./root/i/key.md를 작성합니다. 아래 세 가지가 본문에 있어야 합니다.- 이 인터페이스의 멱등키를 무엇으로 잡을지와 그 이유
order_no하나만으로 잡으면 안 되는 이유- 멱등 이력의 보관 기간과 그 근거
멱등키,보관,수정세 단어가 모두 등장해야 합니다.
/root/i/apply.sh를 만듭니다. 인자 두 개(DB파일 JSON파일)를 받아- 신규면 적재하고 첫 줄에
applied출력, 종료코드 0 - 이미 있으면 아무것도 하지 않고 첫 줄에
duplicate출력, 종료코드 0 조회 후 삽입 방식이 아니라 제약을 이용한 방식이어야 합니다.
- 신규면 적재하고 첫 줄에
/opt/lab/fixtures/eai/idem/messages/의 모든 JSON 을apply.sh로 적재합니다.inbox_log행 수가 고유 msg_id 수와 같아야 합니다- 중복으로 무시된
msg_id를/root/i/dup.txt에 오름차순으로 저장합니다
/root/i/race.sh를 만듭니다. 인자 두 개(DB파일 JSON파일)를 받아 같은 메시지를 동시에 10번 적재 시도하고, 마지막 줄에rows=<해당 msg_id 의 행 수>를 출력합니다. 값은 1 이어야 합니다. 실행 결과를/root/i/race.txt에 저장하세요. (sqlite 잠금 경합에 대비해PRAGMA busy_timeout을 설정하세요.)/root/i/purge.sh를 만듭니다. 인자 두 개(DB파일 보관일수)를 받아created_at이 보관일수보다 오래된 행만 삭제하고, 마지막 줄에deleted=<건수> remain=<건수>를 출력합니다.apply.sh를 확장해 저장-후-응답을 구현합니다.- 신규 처리 시 응답 JSON 을
response컬럼에 저장하고,applied다음 줄(둘째 줄)에 그 응답을 출력합니다 - 중복이면
duplicate다음 줄(둘째 줄)에 저장된 최초 응답을 그대로 출력합니다 같은 메시지를 두 번 적용했을 때 두 번째 출력의 2번째 줄이 첫 번째의 응답과 같아야 합니다. 확인 결과를/root/i/replay.txt에 저장하세요.
- 신규 처리 시 응답 JSON 을
/root/i/report.md를 작성합니다. 중복이 발생하는 네 가지 경로를 각각 한 항목으로 적고, 각 경로마다 방어 지점을 함께 적습니다.재시도,큐,수동,배치네 단어가 모두 등장해야 합니다.
참고
- 제약 활용 삽입:
INSERT OR IGNORE INTO ...후changes()로 반영 여부 판정 - 동시 실행:
for i in $(seq 10); do ... & done; wait - 잠금 대기:
PRAGMA busy_timeout=5000; - 날짜 비교:
created_at < datetime('now', '-30 days') - 흔한 실수 1:
SELECT로 확인하고INSERT하는 방식. 동시 실행에서 뚫립니다. - 흔한 실수 2: 정리 배치를 '오래된 것부터 N 건' 으로 만드는 것. 유입이 급증하면 최근 것을 지웁니다.
- 흔한 실수 3:
busy_timeout없이 병렬 실행해database is locked로 실패하는 것.
멱등 이력 테이블
/root/i/idem.db (sqlite)에 inbox_log 테이블을 만듭니다.
컬럼은 msg_id, biz_key, status, response, created_at 이고,
msg_id 에 PRIMARY KEY 또는 UNIQUE 제약이 있어야 합니다.
중복 방어의 마지막 방어선은 애플리케이션이 아니라 DB 제약입니다. 어떤 컬럼에 제약을 걸어야 할지 먼저 정하세요.
멱등키 설계 문서
/root/i/key.md 를 작성합니다. 아래 세 가지가 본문에 있어야 합니다.
- 이 인터페이스의 멱등키를 무엇으로 잡을지와 그 이유
order_no하나만으로 잡으면 안 되는 이유- 멱등 이력의 보관 기간과 그 근거
멱등키,보관,수정세 단어가 모두 등장해야 합니다.
업무 키만으로는 부족한 경우가 많습니다. 같은 주문에 대한 수정 전문이 있을 수 있고, 전문번호가 일 단위로 순환할 수도 있습니다.
적재 스크립트
/root/i/apply.sh 를 만듭니다. 인자 두 개(DB파일 JSON파일)를 받아
- 신규면 적재하고 첫 줄에
applied출력, 종료코드 0 - 이미 있으면 아무것도 하지 않고 첫 줄에
duplicate출력, 종료코드 0 조회 후 삽입 방식이 아니라 제약을 이용한 방식이어야 합니다.
이미 처리한 키면 아무것도 하지 않고 그렇게 알려야 합니다. 조회 후 삽입은 동시 실행에서 뚫리니, 제약을 활용하는 방식을 쓰세요.
일괄 적재와 중복 집계
/opt/lab/fixtures/eai/idem/messages/ 의 모든 JSON 을 apply.sh 로 적재합니다.
inbox_log행 수가 고유 msg_id 수와 같아야 합니다- 중복으로 무시된
msg_id를/root/i/dup.txt에 오름차순으로 저장합니다
적재 결과에서 신규와 중복을 구분해 세어야 합니다. 중복된 키 목록을 남겨 두면 나중에 원인 분석에 씁니다.
동시 실행 방어
/root/i/race.sh 를 만듭니다. 인자 두 개(DB파일 JSON파일)를 받아
같은 메시지를 동시에 10번 적재 시도하고,
마지막 줄에 rows=<해당 msg_id 의 행 수> 를 출력합니다. 값은 1 이어야 합니다.
실행 결과를 /root/i/race.txt 에 저장하세요.
(sqlite 잠금 경합에 대비해 PRAGMA busy_timeout 을 설정하세요.)
같은 메시지를 병렬로 여러 번 넣어도 한 건이어야 합니다. sqlite 는 잠금 경합이 잦으니 대기 시간을 설정해야 합니다.
보관 기간 정리
/root/i/purge.sh 를 만듭니다. 인자 두 개(DB파일 보관일수)를 받아
created_at 이 보관일수보다 오래된 행만 삭제하고,
마지막 줄에 deleted=<건수> remain=<건수> 를 출력합니다.
정리하는 순간 그 구간의 중복 방어가 사라집니다. 기간 조건으로만 지워야 하고, 건수 기준으로 지우면 최근 것을 지울 수 있습니다.
저장-후-응답
apply.sh 를 확장해 저장-후-응답을 구현합니다.
- 신규 처리 시 응답 JSON 을
response컬럼에 저장하고,applied다음 줄(둘째 줄)에 그 응답을 출력합니다 - 중복이면
duplicate다음 줄(둘째 줄)에 저장된 최초 응답을 그대로 출력합니다 같은 메시지를 두 번 적용했을 때 두 번째 출력의 2번째 줄이 첫 번째의 응답과 같아야 합니다. 확인 결과를/root/i/replay.txt에 저장하세요.
중복 요청에 최초 응답을 그대로 돌려주면 송신측 입장에서 재전송이 완전히 안전해집니다. 결제 API 들이 쓰는 방식입니다.
중복 발생 경로 정리
/root/i/report.md 를 작성합니다.
중복이 발생하는 네 가지 경로를 각각 한 항목으로 적고,
각 경로마다 방어 지점을 함께 적습니다.
재시도, 큐, 수동, 배치 네 단어가 모두 등장해야 합니다.
중복은 네 가지 경로로 옵니다. 각 경로에서 어느 지점이 방어선인지 함께 적으세요.