저장 응답을 못 받았을 때 다시 보내는 것은 자연스럽다. 그 재시도가 같은 작업인지 새 작업인지 서버가 구별하도록 기준을 남겨야 했다. 실제 API 소스와 검사를 읽고 익명 메모리 예제로 그 기준을 공개한다.
응답을 못 받은 것과 저장되지 않은 것은 다르다
글을 저장했는데 화면이 계속 기다리고 있으면 다시 누르고 싶어진다. AI가 관리하는 경우에도 시간 초과 뒤 같은 명령을 재시도할 수 있다. 첫 요청이 서버에서 이미 처리되었다면 두 번째 요청은 새 글을 만들 수 있다. 나는 실제 API의 재요청 검사와 구현을 읽고 사용자의 동작 한 번과 통신 요청 여러 번을 구별하는 기준을 정리했다.
이번에는 원래 소스의 동일 요청과 변경된 본문, 다른 사용자와 동시 요청, 키 회수 뒤 재요청을 다루는 다섯 검사를 로컬에서 실행했다. 모두 통과했지만 검사 저장소는 메모리 기반이다. 실제 분산 서버와 영속 데이터베이스의 중복 저장까지 검증한 결과로 넓히지 않는다. 검사가 재현한 범위를 먼저 분명히 남긴다.
같은 키보다 같은 의도의 범위를 먼저 정한다
요청 키 하나만 비교하면 서로 다른 사용자가 우연히 같은 키를 사용한 경우를 섞을 수 있다. 원래 구현은 권한을 확인한 주체와 동작, 요청 키를 함께 범위로 사용했다. 새 모듈도 주체와 작업 종류, 요청 키의 조합을 비교한다. 주소나 입력칸에 적힌 사용자 이름을 그대로 신뢰하기보다 인증 단계가 확인한 주체를 기준으로 삼아야 한다.
같은 범위의 키로 같은 본문을 보내면 먼저 기록한 결과를 돌려준다. 본문이 다르면 새 저장을 하지 않고 충돌을 알린다. 새 예제에서는 일반 JSON 객체의 키 순서를 정리해 비교하되 배열 순서는 유지한다. 저장할 데이터의 실제 의미가 달라지는 조건은 숨기지 않는다. 파일이나 특수 객체는 이 단순 계약 밖에 있다.
기다리는 요청도 먼저 기록한다
첫 요청의 저장이 끝나야만 기록한다면 그 사이 들어온 두 번째 요청도 일을 시작할 수 있다. 새 예제는 같은 실행 환경 안에서 저장을 시작할 Promise를 먼저 등록한다. 동시에 도착한 같은 요청은 그 Promise를 함께 기다린다. 검사는 세 요청이 같은 결과를 받고 저장 횟수가 하나로 남는지 확인했다.
이 방식은 한 자바스크립트 실행 환경의 예제다. 여러 프로세스나 서버에서는 서로의 Map을 볼 수 없으며 재시작하면 기록도 없어진다. 실제 운영에서는 주체·동작·요청 키의 고유 조건과 저장 트랜잭션을 영속 저장소에서 함께 설계해야 한다. 단순 로컬 성공을 전세계의 한 번 저장 보장으로 말하지 않는다.
코드와 구현 살펴보기
권한 확인 뒤 재요청을 묶는 메모리 예제
function canonicalBody(value,depth=0){
if(depth>20)throw new TypeError('Body is too deeply nested');
if(value===null||typeof value==='string'||typeof value==='boolean')return JSON.stringify(value);
if(typeof value==='number'&&Number.isFinite(value))return JSON.stringify(value);
if(Array.isArray(value))return '['+value.map(x=>canonicalBody(x,depth+1)).join(',')+']';
if(value&&Object.getPrototypeOf(value)===Object.prototype)return '{'+Object.keys(value).sort().map(k=>JSON.stringify(k)+':'+canonicalBody(value[k],depth+1)).join(',')+'}';
throw new TypeError('Plain JSON body required');
}
export function createRequestLedger({authorize,commit,maxEntries=1000}){
if(typeof authorize!=='function'||typeof commit!=='function'||!Number.isInteger(maxEntries)||maxEntries<1)throw new TypeError('Explicit authorization, commit and capacity required');
const entries=new Map();
return async function request({actor,operation,key,body}){
// Authorization is repeated before reading even a successful cached response.
const principal=await authorize(actor,operation);
if(typeof principal!=='string'||!principal||principal.length>128||typeof operation!=='string'||!operation||operation.length>128||typeof key!=='string'||! /^[A-Za-z0-9_-]{8,128}$/.test(key))throw new TypeError('Principal, operation and bounded request key required');
const fingerprint=canonicalBody(body);if(fingerprint.length>100000)throw new RangeError('Example body is too large');
const identity=JSON.stringify([principal,operation,key]),previous=entries.get(identity);
if(previous){if(previous.fingerprint!==fingerprint){const error=new Error('Same request key has different content');error.status=409;throw error;}return JSON.parse(await previous.promise);}
if(entries.size>=maxEntries)throw new RangeError('Example ledger capacity reached');
// Install the promise before the deferred commit can begin.
const promise=Promise.resolve().then(()=>commit({principal,operation,body:JSON.parse(fingerprint)})).then(result=>canonicalBody(result));
entries.set(identity,{fingerprint,promise});
// Failed operations stay recorded: their side effect may have already happened.
return JSON.parse(await promise);
};
}
실패한 요청의 결과도 함부로 지우지 않는다
실패 응답이 왔다고 실제 저장이 없었다고 단정할 수는 없다. 저장 뒤 응답 생성에서 실패했을 수도 있다. 새 모듈은 실패한 Promise도 기록에 남겨 같은 키의 재시도가 곧바로 부작용을 반복하지 않도록 했다. 이런 경우에는 원래 저장 상태를 대조해 결과를 복구하는 절차가 필요하다.
예제의 기록 수에는 상한을 두었고 꽉 차면 명시적으로 거절한다. 오래된 기록을 언제 지울지, 실패 결과를 어떻게 정정할지까지 임의로 자동화하지 않았다. 운영 저장소의 보존 기간과 복구 기준은 실제 요청의 성격에 맞춰 정해야 한다. 새 키를 무조건 만들어 다시 보내는 행동도 새로운 의도인지 먼저 확인해야 한다.
권한을 회수하면 지난 성공도 다시 확인한다
재요청 기록을 먼저 읽고 권한을 나중에 확인하면 회수된 키에도 지난 결과가 나갈 수 있다. 원래 소스는 인증과 동작 권한을 먼저 확인하고 재요청 기록을 읽었다. 새 예제도 매번 authorize를 호출한다. 성공한 기록이 있다는 사실은 현재 요청을 허용할 근거가 되지 않는다.
새 모듈의 로컬 검사 아홉 개에서는 동시 요청과 충돌, 권한 회수와 실패 기록, 반환값 변경도 확인했다. 아래 화면은 임시 메모리의 설명용 시뮬레이션이며 실제 게시판이나 관리자 키를 조작하지 않는다. 새 브라우저 조작은 미검증이다. 독자는 한 번 저장의 기준을 가져가되 실제 저장소와 권한 체계에 맞춰 다음 검사를 이어갈 수 있다.