배포 후 프론트엔드 화면을 열어 보면 요청마다 속도가 달라요. 어떤 요청은 금세 끝나고, 어떤 요청은 서버까지 다시 다녀와요. 네트워크 탭에 304 Not Modified가 떠도 요청은 그대로 나가고, 다만 응답에 본문이 실리지 않아요. 이 차이를 이해하려면 HTTP 캐시를 바로 재사용하는 구간과 서버에 변경 여부만 확인하는 구간을 나눠 봐야 해요. 아래 내용은 MDN 캐시 문서와 RFC 9111을 읽고 정리한 것이고, 뒤쪽 백엔드 이야기는 아직 적용해 보지 않은 검토안이에요.
만료는 캐시를 버리는 시간이 아니다
캐시 설정은 응답을 저장해도 되는지와 서버 확인 없이 얼마나 재사용할지를 응답마다 정하는 일이에요. 공유 캐시(CDN이나 프록시처럼 여러 사용자가 함께 쓰는 캐시)에 저장해도 되는지도 함께 봐야 하고요.
HTTP 캐시에 저장한 응답을 아직 재사용할 수 있으면 fresh, 신선도 수명이 지나면 stale이라고 해요. stale이 됐다고 삭제된 건 아니며, 당장 쓸 수 없어도 재검증하면 다시 사용할 수 있거든요. 응답 헤더 가운데 신선도 수명을 가장 직접적으로 보여 주는 건 Cache-Control이에요.
Cache-Control: max-age=604800
max-age는 원 서버가 응답을 생성한 시점부터 몇 초 동안 fresh로 볼지를 나타내요. 브라우저가 응답을 받은 때가 아니라 원 서버가 생성한 때가 기준이라 시점이 중요하죠. 공유 캐시를 거쳤다면 Age 헤더에 담긴 시간도 고려해야 해요.
Cache-Control: max-age=604800
Age: 100
수명 전체가 604800초여도 이 응답은 이미 100초가 지나, 그만큼 수명을 쓴 것으로 계산해요.
HTTP 캐시는 파일이 로컬에 있는지만 확인하는 게 아니라, 저장한 응답의 메타데이터를 바탕으로 지금 다시 써도 되는지 판단해요. Cache-Control의 no-cache는 이름과 달리 저장을 막지 않고, 저장은 허용하되 재사용하기 전에 원 서버의 검증을 요구해요. 정말 저장하지 말라는 지시자는 no-store예요.
Cache-Control: no-cache
이 응답은 저장할 수 있지만, 다음에 사용할 때는 서버에 다시 확인해야 해요. 저장 자체를 막으려면 다음처럼 써야 해요.
Cache-Control: no-store
재검증은 서버에 묻고 본문은 그대로 쓴다
캐시 수명이 끝나도 브라우저는 저장된 본문부터 버리지 않고, 자신이 가진 버전이 아직 유효한지 서버에 물어요. 이때 대표적으로 쓰는 식별자가 ETag예요. 만료가 곧 폐기라면 재검증이라는 말 자체가 필요 없었을 텐데, 만료된 응답은 버려지기보다 서버 확인 한 번으로 되살아나는 쪽에 가깝다는 게 문서를 읽고 정리한 제 판단이에요. 빈도를 세어 본 건 아니지만, 그렇게 보면 그 확인을 무엇으로 하느냐가 정책의 절반을 정하는 셈이죠.
ETag는 특정 버전의 리소스를 식별하는 응답 헤더로, 값은 큰따옴표로 감싼 문자열이에요. 콘텐츠 해시나 수정 시각 해시, 리비전 번호 등으로 만들 수 있어요.
ETag: "675af34563dc-tr34"
ETag: W/"0815"
앞에 W/가 붙으면 weak validator이며, 바이트가 완전히 같은지보다 의미상 같은 표현으로 볼 수 있는지를 나타내는 데 가까워요. weak ETag는 만들기 쉽지만 정확한 비교에는 덜 유용하고, strong ETag는 비교에 더 좋지만 효율적으로 만들기 어려울 수 있죠. 브라우저는 캐시를 재검증할 때 저장한 ETag를 If-None-Match 요청 헤더에 넣어요.
If-None-Match: "bfc13a64729c4290ef5b2c2730249c88ca92d82d"
If-None-Match: W/"67ab43", "54ed21", "7892dd"
If-None-Match: *
GET이나 HEAD 요청에서 현재 ETag가 If-None-Match 값과 다르면 서버는 200 OK와 본문을 보낼 수 있어요. 값이 같으면 304 Not Modified를 보내요. 조건이 ‘이 값과 일치하지 않으면 보내 달라’는 뜻이라, 일치할 때는 보낼 필요가 없거든요. 값을 콤마로 여러 개 적은 두 번째 형태는 그중 하나라도 현재 ETag와 맞으면 일치로 보고, 값 자리에 별표만 적은 세 번째 형태는 특정 버전 대신 그 리소스의 표현이 있기만 하면 일치로 처리해요. MDN의 If-None-Match 문서는 별표를 주로 PUT으로 리소스를 올리기 전에 같은 리소스가 이미 있는지 확인하는 용도로 설명해요.
If-None-Match와 If-Modified-Since가 함께 있고 서버가 If-None-Match를 지원한다면 If-None-Match가 우선해요. ETag 기반 검증자가 있다면 수정 시각 기반 검증보다 먼저 봐야 해요.
304는 본문 없는 메타데이터 갱신이다
304 Not Modified는 캐시가 실패했다는 표시가 아니라, 이미 가진 본문을 그대로 써도 좋다는 서버의 허가예요. 조건부 GET 또는 HEAD 요청에서 리소스를 다시 보낼 필요가 없다는 뜻이라, 클라이언트는 캐시된 버전을 계속 써도 돼요. 먼저 클라이언트가 If-None-Match를 보내요.
curl --http1.1 -I --header 'If-None-Match: "b20a0973b226eeea30362acb81f9e0b3"' \
https://developer.mozilla.org/en-US/
이 명령에 적힌 ETag는 예시가 만들어진 시점의 값이라, 지금 그대로 실행하면 서버의 현재 ETag와 달라 304 대신 200이 돌아와요. 직접 확인하려면 같은 URL을 한 번 요청해 받은 ETag를 If-None-Match에 넣으면 돼요. HTTP 메시지로 나타내면 다음과 같은 조건부 요청이 돼요.
GET /en-US/ HTTP/1.1
Host: developer.mozilla.org
User-Agent: curl/8.7.1
Accept: */*
If-None-Match: "b20a0973b226eeea30362acb81f9e0b3"
서버의 현재 ETag가 이 값과 일치하면 본문을 다시 보내지 않는데, 직접 받아 본 응답은 아니고 MDN의 304 Not Modified 문서에 실린 예시로는 다음과 같아요.
HTTP/1.1 304 Not Modified
Date: Wed, 28 Aug 2024 10:36:35 GMT
Expires: Wed, 28 Aug 2024 11:02:17 GMT
Age: 662
ETag: "b20a0973b226eeea30362acb81f9e0b3"
Cache-Control: public, max-age=3600
Vary: Accept-Encoding
X-cache: hit
Alt-Svc: clear
MDN의 304 Not Modified 문서는 304에 본문이 없어야 하고, 같은 요청의 200 OK 응답에 들어갔을 Cache-Control, Content-Location, Date, ETag, Expires, Vary 같은 헤더는 포함해야 한다고 정리해요. 클라이언트 캐시는 이 헤더로 저장된 응답의 메타데이터를 갱신하고, 기존 본문을 계속 써요.
예시에 함께 찍힌 X-cache와 Alt-Svc는 앞단 인프라가 붙인 벤더 헤더라, 방금 말한 규범의 헤더 목록에는 들어가지 않아요. Date와 Expires, max-age, Age의 값이 서로 딱 떨어지지 않아 보이는 것도 짚어 둘 만한데, 원 서버가 내린 지시(max-age)와 중간 캐시가 자기 상태로 채운 값(Age, Expires)이 한 응답에 섞여 있어서 그래요. 문서에 실린 값을 그대로 옮겼으니 숫자는 손대지 않고 그대로 읽으면 돼요.
304를 캐시가 안 먹은 표시로 읽기 쉬운데, 얼마나 흔한 오해인지 세어 본 건 아니고 헷갈리기 쉬운 지점이라고 본 제 관찰이에요. 서버에 확인은 하되 리소스가 바뀌지 않았으니 본문 전송만 생략한 것이고, 캐시는 제 몫을 다했어요. 네트워크 탭에 304가 보인다면 만료 뒤 재검증 경로가 제대로 동작했다는 신호일 수 있거든요.
MDN의 304 Not Modified 문서에 달린 설명처럼, 브라우저 개발자 도구의 네트워크 패널은 로컬 캐시 접근을 개발자에게 보여 주려고 추가 요청을 만들어 304 응답을 일으킬 수 있어요. 개발자 도구에 표시된 요청 수만 보고 실제 사용자 환경의 캐시 동작을 단정하면 안 돼요.
응답 성격에 따라 정책이 달라진다
여기까지는 헤더 하나가 무슨 뜻인지를 봤어요. 실제로 정할 때 고르는 건 헤더 하나가 아니라 저장 범위와 신선도 수명, 검증자를 묶은 조합이고, 그 조합을 가르는 기준은 응답이 누구의 것이고 얼마나 자주 바뀌느냐예요.
사용자별 응답은 공유 캐시에 두지 않는다
사용자별 응답을 공유 캐시에 저장하면 안 되며, 쿠키가 있다고 응답이 저절로 private이 되는 것도 아니니 서버가 의도를 명시해야 해요.
Cache-Control: private
브라우저 같은 개인 캐시에만 저장할 수 있다면 private을 고려할 수 있지만, 민감해서 저장 자체가 부적절한 응답에는 no-store를 써야 해요. 인증 토큰이나 사용자별 상태, 일회성 결과라면 짧게 캐시하기보다 아예 저장하지 않는 정책이 더 맞을 수 있죠.
검증자는 싸게 만들 수 있어야 한다
ETag를 만드는 비용도 따져야 해요. 정적 파일처럼 빌드 결과물이나 파일 해시가 이미 있다면 ETag로 자연스럽게 쓸 수 있어요. 매 요청마다 큰 응답 본문을 만들고 해시까지 계산한다면 304를 얻기도 전에 비싼 작업을 끝낸 셈이죠. 이럴 때는 리비전 번호나 갱신 시각, 데이터 버전처럼 더 저렴한 검증자를 먼저 살펴야 해요.
같은 URL이라도 요청 헤더에 따라 표현이 달라진다면 Vary가 필요해요. 압축 방식이나 언어, 인증 관련 헤더처럼 응답을 바꾸는 입력이 있는데 Vary를 제대로 처리하지 않으면 잘못된 응답을 재사용할 수 있어요.
우리 모듈은 저마다 답이 다르다
제가 만드는 백엔드는 Slack에서 들어온 요청을 여러 에이전트에게 넘겨 실행하고 그 결과를 돌려주는 NestJS 서버예요. 기능이 모듈 단위로 갈려 있어서 같은 서버 안이라도 경로마다 응답의 성격이 꽤 달라요.
에이전트 실행 이력을 남기는 agent-run에서 실행 결과를 조회하는 경로 하나만 놓고 봐도, slack 이벤트를 받는 경로와 같은 캐시 정책을 쓸 수는 없어요. slack, webhook처럼 외부 플랫폼 이벤트나 인터랙션을 받는 경로에서는 요청 처리 자체가 이벤트 소비이고, 응답도 플랫폼 프로토콜의 일부예요. 긴 max-age를 붙일 이유가 없으니 Cache-Control: no-store에 가까운 정책이 안전해요.
실행 결과 조회에는 공유 캐시를 쓰지 않는다
agent-run과 하루 계획을 만드는 agent/pm, 업무 회고를 정리하는 agent/work-reviewer, PR을 검토하는 agent/code-reviewer처럼 사용자 입력, GitHub 상태, 모델 응답에 따라 결과가 달라지는 모듈은 특히 조심해야 해요. 결과 조회 API에는 사용자별 private 캐시를 검토할 수 있지만, 공유 캐시에 저장하면 안 되는 데이터가 섞일 가능성이 크거든요. private, no-store, 짧은 재검증 정책 가운데 무엇이 맞는지는 응답마다 정해야 해요.
공개 조회에는 ETag가 잘 맞는다
반대로 crawler의 공개 크롤링 결과 조회나 agent/blog의 공개 가능한 초안 미리보기처럼 같은 URL의 표현이 일정 시간 안정적이라면 ETag가 잘 맞아요. 결과 행의 updatedAt이나 버전 필드로 ETag를 만들고, 만료된 뒤에는 If-None-Match로 재검증하게 할 수 있죠.
이 정책들 가운데 아직 실제로 붙여 본 건 없고, NestJS에서는 컨트롤러나 공통 응답 계층인 common에서 캐시 정책을 일관되게 붙이는 구조가 후보예요.
Cache-Control: private, max-age=60
ETag: "result-version-123"
이런 헤더를 붙일지 말지는 결국 응답을 만드는 쪽이 정해요. 컨트롤러나 common 계층에 헤더를 얹는 사람이 이 응답을 사용자별로 저장해도 되는지, 60초 동안 바뀌지 않는다고 봐도 되는지, 버전 123이 같은 본문을 대표한다고 말할 수 있는지를 차례로 확인하고, 셋 중 하나라도 애매하면 더 보수적인 정책으로 내려가는 식이죠.
붙였다면 이렇게 확인한다
아직 붙여 본 적이 없으니 결과 대신 확인 계약만 적어 둘게요. 확인할 목표는 한 문장으로 이래요. 결과 조회 응답에 ETag와 Cache-Control: private, max-age=60을 붙였을 때, 같은 URL을 다시 요청하면 200 대신 304가 오고 그 304에는 본문이 실리지 않는가.
절차는 같은 URL을 두 번 요청하는 것으로 충분해요. 첫 응답에서 받은 ETag를 두 번째 요청의 If-None-Match에 그대로 넣고, 두 번째 응답의 상태 코드와 본문 유무를 첫 응답과 대조해요. 상태 코드가 304이고 본문이 비어 있으면 재검증 경로가 동작한 것이고, 200에 본문이 그대로 실려 오면 검증자가 안 붙었거나 매 요청마다 값이 달라지고 있다는 뜻이에요.
적용 전후는 같은 엔드포인트에 같은 요청을 반복했을 때의 200과 304 비율, 그리고 본문 전송 여부로 비교할 생각이에요. 이 수치는 아직 재지 않았으니 이 글에 개선폭을 적을 수는 없어요.
실패 경로도 같은 방식으로 확인해요. Vary 누락은 Accept-Encoding처럼 응답을 바꾸는 요청 헤더만 다르게 해서 같은 URL을 두 번 부르고, 뒤 요청이 앞 응답을 그대로 재사용하는지 봐요. private 누락은 서로 다른 사용자 토큰으로 같은 URL을 앞단 공유 캐시를 거쳐 부르고, 먼저 요청한 사용자의 응답이 뒤 사용자에게 그대로 나오는지 봐요. 둘 중 하나라도 재현되면 그 응답에는 캐시를 붙이지 않는 쪽이 맞아요.
HTTP 캐시를 붙인다고 Slack 이벤트 처리나 LLM 실행 자체가 줄지는 않아요. 같은 응답을 서버 확인 없이 재사용할 시간과, 만료 뒤 서버에 묻되 본문은 다시 보내지 않을 경로를 구분하는 도구예요. 응답마다 저장 가능성과 신선도 수명, 검증자, 공유 범위를 명시해야 하죠.
그래서 캐시 설정은 속도를 올리는 작업이라기보다, 이 응답을 누구에게 얼마나 오래 빌려줘도 되는지 정하는 권한 설계에 가까워요.
참고 문서
아래 자료는 2026년 8월 기준으로 확인했어요.
- MDN Web Docs, Cache-Control header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control — max-age가 원 서버 생성 시점을 기준으로 센다는 점과 no-cache·no-store·private의 차이
- MDN Web Docs, ETag header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag — ETag 값의 형태와 weak·strong 검증자의 차이
- MDN Web Docs, If-None-Match header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/If-None-Match — 콤마 목록과 별표 값의 의미, 200과 304가 갈리는 조건, If-Modified-Since보다 우선한다는 규칙
- MDN Web Docs, 304 Not Modified: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/304 — 본문 없이 헤더만 보낸다는 규범, 본문에 옮긴 curl 예시와 304 응답 예시, 개발자 도구가 추가 요청을 만든다는 설명
- MDN Web Docs, HTTP caching: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching — fresh와 stale의 구분, Age를 포함한 신선도 계산, Vary가 필요한 상황
- RFC 9111, HTTP Caching: https://www.rfc-editor.org/rfc/rfc9111.html — 신선도는 4.2절, 재검증은 4.3절, Cache-Control 지시자는 5.2절에 있어요. 링크가 열리지 않으면 IETF Datatracker에서 문서 번호 RFC 9111로 같은 본문을 찾을 수 있어요.