AI 조회 도구가 304를 받은 뒤 “검색 결과가 없습니다”라고 답한다면, 빈 본문을 빈 데이터로 바꾼 곳부터 확인한다. 304 Not Modified는 결과가 0건이라는 뜻이 아니다. 조건부 조회에서 재사용할 저장 응답을 확인하는 신호이며, 그 응답 자체에는 본문이 없다. 본문을 보관하지 않고 ETag만 남겼다면 304에서 읽어 올 데이터도 없다.
이 글은 AI·MCP 도구가 다른 서비스의 HTTP API에서 자료를 읽고, 이전 응답을 직접 보관해 재사용하는 구현을 다룬다. 캐시는 이전 응답을 보관했다가 다시 쓰는 기능이다. 이 글에서는 그렇게 보관해 둔 응답을 저장 응답이라고 부른다. GET은 자료를 읽는 HTTP 요청 방식이다. 조건부 GET은 이전에 받은 자료가 여전히 유효한지 확인할 조건을 붙인 조회다. 브라우저가 이미 처리한 캐시 응답과 원시 HTTP 304를 같은 것으로 보지 않는다. MCP의 필수 기능이나 특정 SDK의 동작을 설명하는 글은 아니다.
가상 사례: 태그만 남기면 다음 조회가 막힌다
문서 조회 도구가 첫 응답에서 본문과 ETag: "doc-v3"를 받았다고 하자. ETag는 서버가 응답의 표현을 구별하려고 붙이는 표식이다. 여기서 표현은 같은 자료라도 언어나 형식에 따라 달라질 수 있는 응답 내용을 뜻한다. 태그를 보고 본문을 복원할 수는 없으며, 태그가 반드시 버전 번호나 본문의 해시인 것도 아니다. 재시작 뒤에도 태그는 남아 있지만 본문은 사라졌다. 도구가 다음 조회에 그 태그를 보내고 304를 받으면, 네트워크 오류는 없어도 모델에 전달할 문서가 없다. 이때 빈 배열을 반환하면 “조회 성공, 문서 없음”이라는 잘못된 결론이 생긴다. 실제 서비스에서 관찰한 장애가 아니라 저장 경계를 설명하기 위한 가상 사례다.
GET /documents/demo HTTP/1.1
Host: api.example.invalid
If-None-Match: "doc-v3"
HTTP/1.1 304 Not Modified
ETag: "doc-v3"
위 교환은 인증정보와 기타 헤더를 생략한 설명용 예시다. 그대로 전송하는 명령이 아니다. 정상 설계라면 조건을 만들 때 사용한 본문·검증자·요청 맥락을 함께 찾아야 한다. “태그가 있다”와 “재사용할 응답이 있다”는 별도의 검사다.
규격이 말하는 것과 여기서 제안하는 것
RFC 9110 §13.1.2에서 If-None-Match는 엔터티 태그를 약한 비교로 평가한다. 원래 200 응답을 줄 GET·HEAD에서 해당 조건이 거짓이면 304가 반환된다. §15.4.5에 따르면 304에는 콘텐츠와 트레일러가 없다. 이 글은 그중 본문 조회가 필요한 GET만 다룬다.
RFC 9111 §4.3.4는 304로 갱신할 적합한 저장 응답을 식별하는 절차를 설명한다. §4.1은 Vary에 따른 요청 헤더 차이도 캐시 선택에 반영한다. 따라서 URL이 같다는 이유만으로 아무 저장 본문이나 쓰는 구현은 피해야 한다.
아래 진단 순서와 상태 이름은 이 글의 설계 제안이다. 범용 HTTP 캐시 구현이나 새로운 MCP 표준 필드가 아니다. 특히 “저장 본문 있음”만으로 반환을 승인하지 않고, 해당 응답을 이번 요청에 재사용할 수 있는지 별도 검증하는 지점을 남긴다.
진단 순서: 본문을 읽는 계층부터 찾는다
1. 304를 누가 받았는지 확인한다. 개발자 도구의 네트워크 기록, 프록시 기록, 애플리케이션의 Response가 같은 계층의 관측인지 나눈다. 브라우저 캐시가 결합한 결과를 애플리케이션이 받는 환경이라면 서버 기록의 304만 보고 애플리케이션 본문도 비었다고 단정할 수 없다. 실제 도구가 받은 상태와 본문 읽기 결과를 확인한다.
2. 조건 헤더를 보낸 경로를 찾는다. 요청 코드뿐 아니라 래퍼·캐시 계층이 조건을 추가했는지 살핀다. 이 글의 대상인 조건부 GET이 아닌데 304가 보이면 정상 캐시 경로로 강제 처리하지 말고 요청 기록과 중간 계층을 조사한다. 조건을 반복해서 보내는 자동 재시도부터 추가하지 않는다.
3. 조건에 대응하는 저장 응답을 찾는다. 태그만 있는지, 본문도 있는지, 본문과 태그가 같은 응답에서 저장됐는지 확인한다. 저장소 교체·만료·프로세스 재시작 뒤에도 한쪽만 남는지 확인한다. 태그와 본문을 별개로 업데이트한다면 업데이트 도중 중단됐을 때의 조합도 점검한다.
4. 요청 맥락을 대조한다. 대상 URL과 검색 조건, 응답의 언어나 형식을 고르는 헤더를 대조한다. 사용자와 테넌트도 확인한다. 테넌트는 같은 서비스를 함께 쓰더라도 자료와 접근 권한을 따로 관리하는 회사나 조직 단위다. 사용자 구분은 단순 캐시 최적화가 아니라 데이터 노출 방지 조건이다. 인증값 자체를 캐시 키나 진단 로그에 출력하지 않고, 서비스가 정한 비밀정보 없는 범위 식별자를 사용한다. 권한이 바뀐 상황에 오래된 본문을 무조건 재사용하지 않는다.
5. 응답을 만든 뒤의 모델 입력을 본다. 캐시 경로가 정상이어도 래퍼가 304를 오류나 빈 배열로 치환할 수 있다. “본문 사용 가능”, “재조회 필요”, “재검증 실패”가 최종 도구 결과에서도 구별되는지 확인한다. 원시 고객 문서 대신 합성 문서 ID와 분기 이름으로 먼저 재현한다.
복사 검사: 304를 빈 성공으로 바꾸지 않는 분기
다음 코드는 네트워크를 사용하지 않는 진단용 분기다. Node.js에서 복사해 실행할 수 있다. sameContext는 URL 문자열만 비교한 값이 아니라, 호출자가 요청·사용자·표현 맥락을 확인한 결과라는 가정이다. 참으로 넣었다고 검증이 생기지는 않는다.
function decide304({
status, conditionalGet, bodyAvailable, sameContext
}) {
if (status !== 304) return "use-normal-response-path";
if (!conditionalGet) return "investigate-unexpected-304";
if (!bodyAvailable || !sameContext) return "body-unavailable";
return "validate-cache-entry";
}
console.log(decide304({
status: 304, conditionalGet: true,
bodyAvailable: false, sameContext: true
})); // body-unavailable
console.log(decide304({
status: 304, conditionalGet: true,
bodyAvailable: true, sameContext: true
})); // validate-cache-entry
마지막 분기는 완료가 아니라 다음 검사로의 이동이다. 실제 저장 응답의 선택·검증자 대응·헤더 갱신·저장 및 재사용 정책을 확인한 뒤에만 본문을 넘긴다. 이 함수는 그 절차를 구현하지 않는다. 다른 HTTP 상태도 성공으로 판정하지 않고 기존 응답 처리 경로로 보낸다.
304에 json()을 호출해 예외가 발생한 뒤 catch { return []; }로 덮는 코드는 피한다. 파싱 실패를 “데이터 없음”으로 바꾸기 때문이다. 또한 ok가 거짓이라는 이유만으로 모든 304를 조회 장애로 묶으면 정상 조건부 조회도 실패로 표시할 수 있다. 중요한 것은 304 전용 경로와 재사용할 저장 응답의 존재다.
직접 실행한 범위
2026년 11월 4일 KST, Node.js v24.22.1에서 위 분기와 같은 로직을 검사했다. 세 불리언 입력의 8개 조합, 200의 별도 경로, 합성 Response(null, { status: 304 })의 ok === false·빈 text 결과·json 읽기의 SyntaxError까지 총 12개 검사를 통과했다. 외부 호출이나 패키지 설치는 하지 않았다.
이는 자바스크립트 분기와 합성 Response 객체의 검사다. 실제 HTTP 서버의 304 생성, 브라우저 캐시 결합, 프록시, 저장소 장애, 권한 변경, MCP 호스트 표시를 재현한 시험이 아니다. 실제 제품이 고쳐졌다는 결과로 확대할 수 없다.
저장 본문이 없을 때의 선택
먼저 “현재 요청에 사용할 본문을 확보하지 못했다”로 남긴다. 그다음 같은 승인 범위의 읽기 요청이 허용되고 API 계약도 맞는다면, 조건을 빼고 전체 본문을 다시 받는 GET을 검토할 수 있다. 이는 여기서 제안하는 복구 선택지이지, 304를 받으면 반드시 반복 호출하라는 규칙이 아니다. 권한·요청 한도·비용·취소 조건을 다시 확인하고 반복 횟수에 상한을 둔다.
전체 응답을 다시 받더라도 상태·본문 형식·요청 대상·접근 범위를 확인한 다음 저장한다. 본문이 없다는 이유로 다른 사용자의 캐시를 가져오거나, 조건 헤더만 바꿔 계속 재시도하거나, 저장 정책을 무시해서는 안 된다. 정책상 저장할 수 없는 응답이라면 본문 보관을 추가하는 방식 자체가 맞지 않을 수 있다.
운영 전 합성 자료로 확인할 체크리스트
□ 첫 조회는 전체 응답을 읽고, 다음 조건부 조회는 같은 맥락의 저장 응답을 검증한다.
□ 태그만 남고 본문이 사라진 상태에서 빈 성공을 반환하지 않는다.
□ 언어·사용자·테넌트가 다른 요청의 본문을 재사용하지 않는다.
□ 304와 파싱 실패, 권한 실패를 다른 상태로 기록한다.
□ 저장 본문을 복구하지 못하면 모델도 내용을 읽었다고 주장하지 않는다.
위 항목은 앞으로 수행할 통합 검사 목록이다. 모든 항목을 이미 통과했다는 체크 표시가 아니다. 시험 데이터에는 서로 다른 합성 문서와 언어를 쓰고, 최종 모델 입력까지 어떤 문서가 전달됐는지 추적한다. 로그에는 인증 헤더·쿠키·고객 원문을 남기지 않는다.
관련 진단과 적용 범위
일반적인 HTTP 오류와 본문 파싱의 차이는 fetch 응답을 성공으로 오해하는 이유에서 다뤘다. 이번 글은 그 이후의 조건부 조회와 저장 응답 연결 문제다. ETag를 쓰더라도 문서 변경 충돌을 막는 If-Match·412 진단과는 다르다. 읽기 캐시의 검증자를 안다고 쓰기 충돌이나 중복 실행까지 해결되지는 않는다.
규격 대조 기준은 HTTP Semantics RFC 9110과 HTTP Caching RFC 9111, 모두 2022년 6월 발행판이다. 공식 원문 확인일은 2026년 11월 4일 KST다. Node.js 버전은 위 합성 검사를 실제 실행한 버전이며, 특정 MCP 프로토콜·SDK 버전을 사용하거나 검증하지 않았다. 304를 성공이나 실패 중 하나로 뭉뚱그리지 말고, 이번 요청이 사용할 수 있는 본문을 확보했는지를 마지막 판단으로 삼는다.