저장을 한 번 눌렀습니다. 요청이 일시적 오류로 실패했다가 자동 재시도 후 성공했는데, 목록에는 같은 메모가 두 개 생겼습니다. 두 번째 메모는 어디에서 만들어졌을까요?
요청마다 메모 하나를 만드는 작은 서비스를 가정해 보겠습니다. 이 독립적인 JavaScript 예제는 쓰기가 끝난 뒤 응답을 잃게 만들고, 같은 서비스를 호출하는 두 클라이언트를 비교합니다.
쓰기와 실패를 하나의 시간순 표에 놓기
예제를 다운로드한 뒤 재시도 루프보다 먼저 전송 함수를 읽어 보세요. 메모 서비스를 호출하고 반환된 ID를 기록한 다음에야 ResponseLost를 던집니다. 클라이언트가 오류를 받았을 때 첫 쓰기는 이미 완료된 상태입니다.
| 단계 | 클라이언트가 보는 상태 | 저장된 메모 |
|---|---|---|
| 첫 시도에서 쓰기 완료 | 응답 대기 | 1개 |
| 전송 계층에서 응답 유실 | 오류 | 1개 |
| 클라이언트가 다시 전송 | 다시 응답 대기 | 키와 서버 조회에 따라 달라짐 |
| 두 번째 응답 도착 | 성공 | 1개 또는 2개 |
실패를 의도적으로 쓰기 뒤에 배치했습니다. 호출 전으로 옮기면 다른 상황을 검사하게 되어 중복 생성이 드러나지 않습니다. 정해진 횟수만큼 오류를 던지므로 실제 네트워크나 타이머를 사용하지 않습니다.
루프에 들어가기 전에 작업 키 만들기
두 클라이언트는 키를 만드는 위치만 다릅니다.
function saveUnsafe(send, title, newKey) {
return createWithRetry(send, title, () => newKey());
}
function save(send, title, newKey) {
const key = newKey();
return createWithRetry(send, title, () => key);
}
saveUnsafe의 콜백은 루프가 요청을 보낼 때마다 새 키를 만듭니다. 서비스는 별개의 작업으로 받아들여 메모를 두 개 만듭니다. save는 재시도 전에 키 하나를 보관하므로 두 시도 모두 같은 식별자와 제목을 보냅니다.
예제에서는 쉽게 비교하도록 순번 문자열을 사용합니다. 키는 하나의 저장 의도를 식별하며, 개별 전송 시도나 제목을 식별하지 않습니다. 제목이 같아도 별개의 저장 두 번에는 서로 다른 키를 줍니다. 실제 코드에서는 생성기의 수명을 확인하세요. 이름이 “멱등성 키”여도 재시도 래퍼 안에서 다시 만들어질 수 있습니다.
키를 따라 서버의 조회까지 읽기
키를 유지하는 방식이 여기서 효과가 있는 이유는 서비스가 메모를 추가하기 전에 completed를 조회하기 때문입니다. deduplicate 옵션은 기본으로 켜져 있으며, 테스트 하나가 이 조회를 끕니다.
if (deduplicate && completed.has(key)) {
const previous = completed.get(key);
if (previous.title !== title)
throw new Error('key reused with another title');
return previous.note;
}
const note = Object.freeze({ id: `note-${notes.length + 1}`, title });
notes.push(note);
completed.set(key, { title, note });
return note;
키와 제목이 모두 같으면 앞서 만든 메모를 반환합니다. 같은 키에 다른 제목이 들어오면 추가 쓰기 전에 오류를 던집니다. 이 예제의 요청 본문은 제목이 전부이므로 이 비교가 입력 전체를 확인합니다.
저장된 항목은 입력과 결과를 함께 담습니다. 조회 뒤의 조기 반환도 살펴보세요. 추가 처리까지 계속 진행하면 여전히 중복이 생깁니다. 수정된 구현의 테스트는 최종 개수와 반환 객체를 모두 검사합니다. 다른 재현 테스트는 서버의 중복 제거를 끕니다. 이 경우 클라이언트가 같은 키를 유지해도 메모가 두 개 만들어집니다.
실패 상황을 실행하고 저장 결과 비교하기
retry-idempotency.mjs를 내려받아 Node.js 22 이상에서 실행하세요. 의존성 설치는 필요 없습니다.
node --test retry-idempotency.mjs
테스트 여섯 개가 모두 통과합니다. 이름에 “reproduce”가 붙은 두 개는 의도한 결함이 발생해야 통과합니다. createWithRetry는 총 두 번까지 시도하고, 모의 응답 유실 오류만 재시도합니다. 제목 충돌은 한 번의 시도 후 종료됩니다.
| 상황 | 전송 횟수 | 저장된 메모 수 | 호출자가 받은 결과 |
|---|---|---|---|
| 재시도마다 새 키 생성 | 2 | 2 | 두 번째 메모 note-2 |
| 같은 키로 결과 재사용 | 2 | 1 | 원래 메모 note-1 |
| 별개의 저장 두 번 | 2 | 2 | 서로 다른 ID 두 개 |
| 기존 키의 제목 변경 | 추가 1회 | 총 1개 | 거부 |
| 두 응답 모두 유실 | 2 | 1 | 두 번째 시도 후 오류 |
| 같은 키지만 서버 중복 제거 해제 | 2 | 2 | 두 번째 메모 |
다섯 번째 행은 오류 처리를 읽을 때 특히 유용합니다. 클라이언트는 마지막에 오류를 받지만 메모는 이미 존재합니다. 단언문이 저장 내용, 시도별 키, 반환 ID를 확인하므로 성공 응답만 보고 중복 쓰기를 놓치는 일을 막습니다.
실제 앱에서 다섯 위치 따라가기
- 사용자 동작을 찾아 어떤 시도들이 같은 작업 의도에 속하는지 정합니다.
- 키 생성 지점부터 각 재시도 래퍼와 요청 인수를 따라갑니다.
- 실제 쓰기 함수를 열고, 쓰기가 끝난 뒤 발생할 수 있는 오류를 표시합니다.
- 서버의 조회, 입력 비교, 이전 결과를 반환하는 분기를 읽습니다.
- 재시도 상한과 마지막 오류 경로를 실제 저장 상태와 대조합니다.
각 관찰에 소스 위치를 함께 적으세요. 래퍼가 키를 만드는 콜백을 다시 실행한다면 그 콜백까지 확인해야 합니다. 라우트부터 저장소까지 읽는 방법은 코드에서 HTTP 요청 추적하기에서 이어서 볼 수 있습니다.
메모리 예제와 영속성 보장을 구분하기
이 서비스는 단일 프로세스의 Map과 배열에 모든 데이터를 저장하며 조회와 쓰기를 동기적으로 처리합니다. 데이터베이스 트랜잭션, 여러 worker의 동시 실행, 재시작, 영속 데이터 복구는 테스트하지 않았습니다. 운영 환경에서 exactly-once를 보장하는 구현이 아닙니다.
실제 구현에서는 키의 범위, 요청 내용 비교, 보존 기간, 동시 시도 처리, 업무 데이터 쓰기와 결과 기록을 함께 복구하는 방법을 결정해야 합니다. 이 클라이언트는 저장이 끝나면 키도 잊습니다. 나중에 수동으로 다시 시도하거나 페이지를 새로 고칠 때는 작업의 수명을 별도로 설계해야 합니다. 실제 저장소와 클라이언트 코드를 확인하고, 코드 읽기 가이드를 참고해 관련 호출자와 정의를 함께 살펴보세요.