HTTP 캐싱으로 API 응답 비용 줄이기: Cache-Control, ETag, 304
귀하의 API는 하루에도 수천 번 동일한 JSON을 전송할 수 있습니다. 클라이언트가 GET /v1/products/42를 요청해 18KB를 받은 뒤 5분 후 다시 요청하면, 아무것도 변경되지 않았는데도 동일한 18KB를 다시 전송하게 됩니다. 그 결과 대역폭, 직렬화, 데이터베이스 읽기 비용이 반복해서 발생합니다.
HTTP에는 이 문제를 해결하는 표준 기능이 있습니다.
-
Cache-Control: 응답을 얼마나 오래 신선한 상태로 재사용할 수 있는지 지정 -
ETag: 리소스 변경 여부를 확인하는 지문 제공 -
304 Not Modified: 본문 없이 캐시된 응답을 재사용하도록 지시
이 기능들은 반복 요청을 빈 본문의 304 응답으로 바꾸고, If-Match와 함께 사용하면 손실된 업데이트로부터 쓰기도 보호합니다. React에서 API 응답 캐싱을 살펴봤다면, 이 글에서는 같은 주제를 서버 측 관점에서 다룹니다.
이 글에서는 HTTP 캐싱의 세 계층, 304 왕복 과정, no-cache와 no-store의 차이, Express 구현, 그리고 Apidog에서 조건부 요청을 검증하는 방법을 살펴봅니다.
HTTP 캐싱의 세 가지 계층
API 캐싱은 다음 세 가지 결정으로 나뉩니다. 각 계층을 혼동하면 캐시가 지나치게 오래된 데이터를 제공하거나, 캐싱 자체가 비활성화될 수 있습니다.
1. 신선도(Freshness)
클라이언트가 서버에 요청하지 않고 응답을 재사용할 수 있는 기간입니다.
Cache-Control: max-age=60
60초 동안 클라이언트는 로컬 캐시를 사용하므로 네트워크 트래픽이 발생하지 않습니다. 가장 저렴한 캐시 적중이지만, max-age가 만료될 때까지 변경 사항을 감지하지 못한다는 단점이 있습니다.
2. 유효성 검증(Validation)
응답이 만료된 뒤에도 클라이언트는 전체 본문을 다시 다운로드할 필요가 없습니다. 이전에 받은 지문을 보내 리소스가 변경되었는지 확인할 수 있습니다.
-
ETag/If-None-Match: 정확한 버전 비교 -
Last-Modified/If-Modified-Since: 1초 단위 타임스탬프 비교
리소스가 변경되지 않았다면 서버는 본문 없이 304 Not Modified를 반환합니다.
3. 무효화(Invalidation)
데이터가 변경되었을 때 오래된 캐시를 제거하거나 만료시키는 방법입니다.
- 개인 클라이언트 캐시:
max-age로 자동 만료 - 공유 캐시 및 CDN: 명시적 삭제, 짧은 TTL,
stale-while-revalidate등 사용
신선도는 가장 많은 요청을 제거하고, 유효성 검증은 신선도가 놓치는 변경을 확인하며, 무효화는 두 메커니즘이 오래된 데이터를 계속 제공하지 않도록 합니다. 대부분의 API에는 세 계층이 모두 필요합니다.
304 Not Modified 왕복 통신
제품 엔드포인트의 전체 흐름은 다음과 같습니다.
첫 번째 요청
클라이언트에 캐시가 없습니다.
GET /v1/products/42 HTTP/1.1
Host: api.example.com
첫 번째 응답
서버는 본문과 캐싱 메타데이터를 반환합니다.
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
Content-Length: 18432
클라이언트는 본문과 ETag를 저장합니다. 이후 60초 동안은 서버에 요청하지 않고 캐시된 사본을 제공합니다.
두 번째 요청: 60초 후
캐시가 만료되면 클라이언트는 저장해 둔 ETag를 보내 재유효성 검사를 수행합니다.
GET /v1/products/42 HTTP/1.1
Host: api.example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f2"
변경되지 않은 경우
서버가 현재 ETag와 요청의 ETag를 비교해 일치 여부를 확인합니다.
HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
304 응답에는 본문이 없습니다. 18KB의 JSON 대신 수백 바이트의 헤더만 전송되며, 클라이언트는 기존 사본을 다시 60초 동안 신선한 것으로 표시합니다.
제품이 변경되었다면 새로운 본문과 ETag를 포함한 일반적인 200 OK가 반환됩니다. 304 Not Modified 설명서에서도 상태 코드 자체를 확인할 수 있습니다. 304는 오류가 아니라 캐시 재사용을 지시하는 응답입니다.
조건부 GET도 왕복 통신과 현재 ETag 계산을 필요로 합니다. 하지만 응답 본문 전송과 클라이언트 측 재파싱을 제거합니다. 모바일 클라이언트가 대규모 목록을 폴링하는 경우 API 송신 트래픽을 60~90% 줄이는 효과를 기대할 수 있습니다.
API에 중요한 Cache-Control 지시어
Cache-Control에는 다양한 지시어가 있지만, JSON API에서는 다음 다섯 가지가 특히 중요합니다.
no-cache와 no-store
가장 흔한 캐싱 오해입니다.
-
no-store: 어떤 캐시에도 응답을 저장하지 않음 -
no-cache: 저장은 허용하지만 재사용 전에 원본 서버에서 반드시 재검증
토큰, 은행 데이터, 영구 저장해서는 안 되는 PII처럼 진정으로 민감한 데이터에는 no-store를 사용하세요.
no-cache는 이름과 달리 캐시 저장을 막지 않습니다. ETag와 함께 사용하면 클라이언트가 매번 재검증하면서도 변경되지 않은 응답에는 304를 사용해 전체 페이로드를 절약할 수 있습니다.
모든 응답에 no-store를 붙이면 조건부 요청이 완전히 비활성화되어 모든 호출에서 전체 응답을 전송하게 됩니다.
private
응답을 최종 사용자 클라이언트만 캐시할 수 있고, 공유 캐시나 CDN은 캐시할 수 없도록 지정합니다.
사용자별로 다른 응답을 반환하는 인증 API는 일반적으로 private를 포함해야 합니다. 그렇지 않으면 잘못 구성된 프록시가 한 사용자의 계정 데이터를 다른 사용자에게 제공할 수 있습니다.
max-age
초 단위의 신선도 수명입니다. 읽기 API에서는 보통 30~300초부터 시작하는 것이 실용적입니다. 하루 동안 요청을 완전히 제거하기보다는 짧은 요청 버스트와 폴링 루프를 흡수하는 데 목적이 있습니다.
stale-while-revalidate
응답성을 유지하면서 백그라운드에서 캐시를 갱신하는 방식입니다.
Cache-Control: max-age=60, stale-while-revalidate=300
캐시는 60초 동안 신선한 응답을 제공하고, 이후 최대 5분 동안 오래된 사본을 제공하면서 백그라운드에서 원본 서버에 재검증을 요청할 수 있습니다. 사용자는 즉시 응답을 받고, 원본 서버의 데이터는 곧 갱신됩니다. 브라우저와 Cloudflare, Fastly 같은 CDN이 이를 지원합니다.
인증된 읽기 엔드포인트의 합리적인 기본값은 다음과 같습니다.
Cache-Control: private, max-age=60, stale-while-revalidate=120
ETag: "9f8b2c41aa73e0d5"
전체 동작은 RFC 7234를 대체한 최종 HTTP 캐싱 문서인 RFC 9111에서 확인할 수 있습니다.
강력한 ETag와 약한 ETag
ETag는 W/ 접두사로 강력한 형태와 약한 형태를 구분합니다.
강력한 ETag
ETag: "33a64df551425fcc"
바이트 단위의 동일성을 보장합니다. 동일한 강력한 ETag를 가진 두 응답은 바이트 수준에서 동일해야 하므로, 바이트 범위 요청과 If-Match 기반 동시성 제어에 사용할 수 있습니다.
약한 ETag
ETag: W/"33a64df551425fcc"
의미론적 동일성만 보장합니다. 필드 순서나 타임스탬프 필드처럼 바이트는 달라도 의미가 같다면 캐시가 사본을 재사용할 수 있습니다.
압축 미들웨어를 사용할 때 주의하세요. Nginx나 일부 프레임워크는 응답을 실시간으로 gzip 압축하면서 강력한 ETag를 약한 ETag로 바꿀 수 있습니다. 압축된 바이트가 원본 바이트와 달라지기 때문입니다.
프록시 뒤에서 동시성 검사가 예상과 다르게 실패한다면, 앱 서버에서는 보이지 않던 W/ 접두사가 추가되었는지 확인하세요.
기본적으로는 압축되지 않은 본문에서 계산한 강력한 ETag를 사용하세요. 동일한 데이터에 대해 의도적으로 변형된 표현을 제공할 때만 약한 ETag를 사용하는 것이 좋습니다.
ETag 생성 전략: 본문 해시와 버전 컬럼
응답 본문 해시
응답을 직렬화하고 해시한 뒤 따옴표로 감쌉니다.
const hash = crypto.createHash("sha1")
.update(JSON.stringify(payload))
.digest("hex");
const etag = `"${hash}"`;
MD5나 SHA-1도 사용할 수 있습니다. 여기서 ETag는 보안 경계가 아니라 지문이기 때문입니다.
장점은 스키마 변경 없이 구성상 정확하다는 점입니다. 단점은 304 응답을 포함한 모든 요청에서 전체 응답을 구축해야 하므로, 대역폭은 줄여도 직렬화나 데이터베이스 부하는 줄이지 못할 수 있다는 점입니다.
버전 컬럼 또는 updated_at
행의 버전 카운터나 updated_at처럼 저렴하게 조회할 수 있는 값으로 ETag를 생성합니다.
ETag: "42-v17"
조건부 요청에서 전체 응답을 직렬화하지 않고 인덱스 조회 하나만 수행할 수 있습니다.
다만 응답에 영향을 주는 모든 변경이 버전을 갱신해야 합니다. 조인된 테이블의 변경을 놓치면 오래된 응답을 304로 처리하게 되며, 이런 버그는 눈에 잘 드러나지 않아 특히 위험합니다.
처음에는 본문 해시를 사용하세요. 프로파일링 결과 직렬화 비용이 중요한 것으로 확인될 때 핫 엔드포인트를 버전 기반 ETag로 옮기는 방식이 안전합니다.
낙관적 동시성: If-Match와 412
읽기에서 대역폭을 절약하는 ETag는 쓰기에서 손실된 업데이트도 방지할 수 있습니다.
예를 들어 두 명의 관리자가 동시에 제품 42를 조회한다고 가정해 보겠습니다.
- 관리자 A가 가격을 변경하고 저장합니다.
- 관리자 B가 30초 후 오타를 수정하고 저장합니다.
- B의 오래된 가격이 A의 변경 사항을 덮어씁니다.
해결책은 클라이언트가 마지막으로 확인한 ETag를 모든 업데이트의 조건으로 보내는 것입니다.
PUT /v1/products/42 HTTP/1.1
If-Match: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
서버는 If-Match와 현재 ETag를 비교합니다.
- 일치: 업데이트를 적용하고 새 ETag와 함께
200 OK반환 - 불일치: 변경을 거부하고
412 Precondition Failed반환
412 Precondition Failed가 반환되면 클라이언트는 최신 데이터를 다시 가져온 뒤 변경 사항을 재적용하고 재시도해야 합니다.
더 엄격한 API는 If-Match가 없는 PUT에 428 Precondition Required를 반환해 안전 검사를 강제할 수 있습니다.
CDN과 프록시가 헤더를 사용하는 방식
공유 캐시는 클라이언트와 원본 서버 사이에서 동일한 헤더를 읽고 자체 규칙에 따라 응답을 저장합니다.
-
private는 응답을 CDN 캐시에서 제외합니다.s-maxage=600은 브라우저의max-age와 별도로 CDN 전용 TTL을 설정합니다. - 대부분의 CDN은 조건부 요청으로 원본 서버를 재검증합니다. 원본 서버가
If-None-Match에 304로 응답하면 CDN은 본문을 다시 가져오지 않고 저장된 사본을 갱신합니다. - 프레임워크가
Vary를 올바르게 전송하는지 확인하세요. 같은 URL에서 JSON과 CSV를 제공한다면 다음 헤더가 필요합니다.
Vary: Accept
그렇지 않으면 공유 캐시가 CSV 응답을 JSON 클라이언트에 제공할 수 있습니다.
- 압축 과정에서 프록시가 ETag를 약한 ETag로 변경하는지도 확인해야 합니다.
Express에서 ETag와 If-None-Match 처리하기
Express는 기본적으로 약한 ETag를 설정합니다. 직접 처리하면 강력한 ETag와 412 쓰기 경로를 구현할 수 있습니다.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use(express.json());
function etagFor(payload) {
const hash = crypto.createHash("sha1")
.update(JSON.stringify(payload))
.digest("hex");
return `"${hash}"`;
}
app.get("/v1/products/:id", async (req, res) => {
const product = await db.products.find(req.params.id);
const etag = etagFor(product);
res.set("Cache-Control", "private, max-age=60, stale-while-revalidate=120");
res.set("ETag", etag);
if (req.get("If-None-Match") === etag) {
return res.status(304).end(); // 지문이 일치: 본문 없음
}
res.json(product);
});
app.put("/v1/products/:id", async (req, res) => {
const product = await db.products.find(req.params.id);
const currentEtag = etagFor(product);
const ifMatch = req.get("If-Match");
if (!ifMatch) {
return res.status(428).json({ error: "If-Match header required" });
}
if (ifMatch !== currentEtag) {
return res.status(412).json({ error: "Resource changed since you fetched it" });
}
const updated = await db.products.update(req.params.id, req.body);
res.set("ETag", etagFor(updated));
res.json(updated);
});
304 분기에서도 Cache-Control과 ETag를 전송해야 합니다. RFC 9111에 따르면 304 응답은 저장된 응답의 메타데이터를 갱신하므로, 클라이언트가 캐시를 신선하게 유지하는 데 필요한 헤더를 다시 보내야 합니다.
Apidog에서 캐싱 동작 검증하기
미들웨어와 프록시가 개입하면 코드가 올바르게 보여도 실제 HTTP 응답은 다를 수 있습니다. 따라서 코드가 아니라 HTTP 수준에서 테스트하세요.
Apidog에서 수동으로 확인하는 방법은 다음과 같습니다.
-
GET /v1/products/42를 보내고 응답 헤더를 엽니다. -
ETag와Cache-Control이 존재하는지, ETag가 따옴표로 묶여 있는지 확인합니다. - ETag 값을 복사합니다.
- 같은 요청에 다음 헤더를 추가해 다시 보냅니다.
If-None-Match: "33a64df551425fcc55e4d42a148795d9f2"
- 본문이 없는
304 Not Modified응답을 확인합니다. - 데이터를 변경한 뒤 요청을 다시 보내 새로운 ETag와 함께
200 OK가 반환되는지 확인합니다.
배포 후에도 검증을 자동화하려면 이 흐름을 테스트 시나리오로 연결하세요.
- 첫 번째 요청의 응답 헤더에서
ETag를 변수로 추출 - 두 번째 요청에서 해당 변수를
If-None-Match로 전송 - 상태 코드가 304이고 본문이 비어 있는지 어설션
- 세 번째 요청에서 오래된
If-Match값으로 PUT 전송 - 상태 코드가 412인지 어설션
API 어설션 가이드에서 상태 코드와 헤더 어설션 문법을 확인할 수 있습니다.
CI에서 시나리오를 실행하면 ETag를 제거하는 미들웨어 업그레이드가 대역폭 비용 증가로 이어지기 전에 실패한 파이프라인으로 드러납니다. Apidog를 무료로 다운로드해 자체 엔드포인트에 대한 시나리오를 구축해 보세요.
FAQ
no-cache와 no-store의 차이점은 무엇인가요?
no-store는 캐싱을 완전히 금지합니다. 디스크나 메모리에 응답을 저장하지 않으므로 모든 요청이 전체 응답을 다운로드합니다.
no-cache는 저장을 허용하지만 재사용 전에 반드시 재검증하도록 합니다. ETag와 함께 사용하면 304 응답으로 페이로드를 절약할 수 있습니다.
민감한 데이터에만 no-store를 사용하세요. 모든 응답에 적용하면 API 팀이 저지를 수 있는 가장 비용이 큰 Cache-Control 실수가 될 수 있습니다.
ETag는 POST와 함께 작동하나요?
대부분의 경우 작동하지 않으며, 의도된 동작입니다. ETag는 URL이 나타내는 리소스 상태를 설명하지만, POST는 일반적으로 안정적인 리소스를 읽는 대신 새 리소스를 생성합니다. 캐시는 POST 응답도 일반적으로 캐시하지 않습니다.
쓰기에서 중요한 조건부 헤더는 PUT, PATCH, DELETE에 사용하는 If-Match입니다. 이를 통해 ETag로 손실된 업데이트를 방지할 수 있습니다.
POST 응답을 캐싱하고 싶다면 해당 작업이 실제로 GET이어야 하는지 검토해 보세요.
304 응답이 API를 더 빠르게 만드나요?
전송량은 줄여주지만, 반드시 전체 API 처리 시간이 빨라지는 것은 아닙니다. 서버는 여전히 요청을 받고 인증을 수행하며 현재 ETag를 계산해야 합니다.
원본 CPU 절감 효과는 ETag를 얼마나 저렴하게 생성하는지에 따라 달라집니다. 주요 이점은 대역폭, 모바일 배터리, 느린 네트워크에서의 렌더링 시간에 나타납니다. 적용 전후의 지연 시간과 처리량을 측정하세요. API 성능 테스트 가이드에서 벤치마킹 방법을 확인할 수 있습니다.
ETag와 Last-Modified 중 무엇을 사용해야 하나요?
가능하다면 둘 다 보내세요.
ETag는 타임스탬프가 놓칠 수 있는 1초 미만의 변경과 콘텐츠 수준의 차이를 더 정확하게 감지합니다. 두 헤더가 모두 도착하면 If-None-Match가 If-Modified-Since보다 우선합니다.
Last-Modified는 오래된 클라이언트를 위한 폴백이자 일부 캐시가 신선도를 추정할 때 사용하는 휴리스틱으로 여전히 유용합니다. 하나만 선택해야 한다면 ETag를 사용하세요.
Top comments (0)