ETag와 Cache-Control을 활용한 API 캐싱: 조건부 요청으로 페이로드 최적화

Cache-Control 헤더와 ETag 검증이 반복적인 API 호출을 304 응답으로 전환하고, If-Match를 사용하여 손실된 업데이트를 방지하며, 페이로드 크기를 줄이는 방법을 알아보세요.

Ashley Goolam

Ashley Goolam

31 August 2026

ETag와 Cache-Control을 활용한 API 캐싱: 조건부 요청으로 페이로드 최적화

Apidog 엔터프라이즈

온프레미스 배포

SSO & RBAC

SOC 2 준수

Apidog Enterprise 살펴보기

귀하의 API는 아마도 하루에 수천 번 동일한 JSON을 전송할 것입니다. 클라이언트가 GET /v1/products/42를 요청하면 18KB를 받고, 5분 후에 다시 요청해도 같은 18KB를 받습니다. 아무것도 변하지 않았지만, 대역폭, 직렬화, 데이터베이스 읽기에 비용을 지불했습니다.

HTTP는 이미 이 문제를 해결했습니다. Cache-Control 헤더는 클라이언트에 응답이 얼마나 오랫동안 신선한 상태로 유지되는지 알려줍니다. ETag 헤더는 변경 여부를 확인할 수 있는 지문을 제공합니다. 이 둘은 반복적인 요청을 빈 본문과 함께 304 Not Modified 응답으로 변환하며, 보너스로 손실된 업데이트로부터 쓰기를 보호할 수도 있습니다. 동일한 아이디어는 클라이언트 측 패턴에도 적용됩니다. React에서 API 응답 캐싱에 대한 가이드를 읽었다면, 이것은 그 이야기의 서버 측 부분입니다.

이 가이드는 HTTP 캐싱의 세 가지 계층을 단계별로 살펴보고, 304 왕복 통신이 어떻게 작동하는지 보여주며, no-cache와 no-store의 차이점을 명확히 하고, 작동하는 Express 코드로 마무리합니다. 또한 Apidog에서 조건부 헤더를 전송하고 304를 직접 어설션하여 이 모든 것을 확인하는 방법도 알게 될 것입니다.

앱 다운로드

HTTP 캐싱의 세 가지 계층

API를 위한 HTTP 캐싱은 세 가지 별개의 결정으로 나뉩니다. 이들을 혼동하면 팀은 문제에 빠집니다.

계층 1: 신선도(Freshness). 클라이언트가 요청 없이 응답을 얼마나 오랫동안 재사용할 수 있을까요? 이것이 Cache-Control: max-age=60입니다. 60초 동안 클라이언트는 로컬에 캐시된 사본을 제공합니다. 네트워크 트래픽은 전혀 없습니다. 이것은 가장 저렴한 캐시 적중이지만, 타이머가 만료될 때까지 클라이언트가 변경 사항을 감지할 수 없기 때문에 가장 위험합니다.

계층 2: 유효성 검증(Validation). 응답이 만료된 후에도 클라이언트는 다시 다운로드할 필요가 없습니다. 이전에 제공한 지문을 전송하여 "이것이 변경되었습니까?"라고 묻습니다. 리소스가 변경되지 않았다면, 본문 없이 304 Not Modified로 응답합니다. ETag와 If-None-Match는 이의 정확한 버전이며, Last-Modified와 If-Modified-Since는 1초 단위의 타임스탬프 기반 구형 버전입니다.

계층 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초 후. 사본이 만료되었으므로 클라이언트가 재유효성 검사를 합니다:

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"

본문은 없습니다. 18KB 대신 응답은 수백 바이트의 헤더뿐입니다. 클라이언트는 캐시된 사본을 60초 동안 다시 신선한 것으로 표시하고 이를 제공합니다. 제품이 변경되었다면, 새로운 본문과 새로운 ETag와 함께 정상적인 200 응답을 반환할 것입니다. 우리는 304 Not Modified 설명서에서 상태 코드 자체에 대해 더 자세히 다루었지만, 요약하자면 304는 오류가 아니라 캐시 지시입니다.

경제학은 간단합니다. 조건부 GET은 여전히 왕복 통신과 현재 ETag를 계산하는 모든 작업을 필요로 합니다. 하지만 페이로드 전송과 클라이언트 측 재구문 분석을 제거합니다. 모바일 클라이언트가 폴링하는 대규모 목록 엔드포인트의 경우, 이는 API 송신 트래픽을 60~90%까지 줄이는 일상적인 작업입니다.

API에 중요한 Cache-Control 지시어

Cache-Control에는 십여 개 이상의 지시어가 있습니다. JSON API의 경우 다섯 가지가 가장 중요합니다.

no-store vs no-cache. 이것은 프로덕션 API에서 가장 흔한 캐싱 버그이며 양방향으로 발생합니다. no-store는 "이것을 어떤 캐시에도 기록하지 마라"를 의미합니다. 토큰, 은행 데이터, 영구 저장해서는 안 되는 PII와 같이 진정으로 민감한 페이로드에 사용하세요. no-cache는 들리는 것과 거의 반대되는 의미입니다. 캐시가 응답을 저장할 수 있지만, 모든 재사용 전에 원본 서버와 재유효성 검사를 해야 합니다. ETag와 함께 사용되는 no-cache는 클라이언트가 오래된 데이터를 절대 표시하지 않도록 보장하면서 모든 요청에서 304 절약을 제공합니다. "안전을 위해" 모든 것에 no-store를 붙이는 팀은 조건부 요청을 완전히 비활성화하고 모든 호출에 대해 전체 페이로드 비용을 지불하고 있는 것입니다.

private. 응답이 최종 사용자의 클라이언트에 의해서만 캐시될 수 있고, 공유 캐시나 CDN에 의해서는 절대 캐시될 수 없음을 표시합니다. 사용자마다 다른 응답(대부분의 인증된 API 트래픽)은 private를 포함해야 합니다. 이것이 없으면 잘못 구성된 프록시가 한 사용자의 계정 데이터를 다른 사용자에게 제공할 수 있습니다.

max-age. 초 단위의 신선도 수명입니다. API의 경우 작게 생각하세요: 30에서 300초가 대부분의 읽기 엔드포인트를 커버합니다. 하루 동안 요청을 제거하려는 것이 아니라, 버스트 및 폴링 루프를 흡수하려는 것입니다.

stale-while-revalidate. 실용적인 중간 지점입니다. Cache-Control: max-age=60, stale-while-revalidate=300은 캐시에 다음을 지시합니다: 최대 5분 동안은 오래된 사본을 제공하되, 백그라운드에서 새로 고침하세요. 사용자는 즉각적인 응답을 받고, 원본 서버는 얼마 지나지 않아 업데이트됩니다. Cloudflare 및 Fastly와 같은 CDN뿐만 아니라 브라우저도 이를 지원합니다.

인증된 읽기 엔드포인트에 대한 합리적인 기본값은 다음과 같습니다:

Cache-Control: private, max-age=60, stale-while-revalidate=120
ETag: "9f8b2c41aa73e0d5"

전체 동작 사양은 RFC 7234를 대체한 최종 HTTP 캐싱 문서인 RFC 9111에 있습니다. CDN이 예상치 못한 방식으로 작동할 때, 그 RFC에 답이 있습니다.

강력한 ETag vs 약한 ETag

ETag는 두 가지 형태로 제공되며, W/ 접두사가 이들을 구분합니다.

강력한 ETag (ETag: "33a64df551425fcc")는 바이트 단위의 동등성을 약속합니다. 동일한 강력한 ETag를 가진 두 응답은 동일하며, 이는 강력한 ETag를 바이트 범위 요청에 안전하게 만들고 If-Match를 사용한 동시성 제어에 필요합니다.

약한 ETag (ETag: W/"33a64df551425fcc")는 의미론적 동등성을 약속합니다. 바이트는 다를 수 있습니다(필드 순서가 변경되었거나 타임스탬프 필드가 업데이트되었을 수 있음) 하지만 의미는 동일하므로 캐시는 사본을 유지할 수 있습니다.

여기서 문제가 되는 부분: 압축 미들웨어. Nginx 및 일부 프레임워크는 응답을 실시간으로 gzip 압축할 때 강력한 ETag를 약한 ETag로 재작성합니다. 압축된 바이트가 원본과 더 이상 일치하지 않기 때문입니다. 프록시 뒤에서 동시성 검사가 미스터리하게 실패한다면, 앱 서버가 응답을 보낼 때는 없었던 W/ 접두사를 찾아보세요.

압축되지 않은 본문에서 계산된 강력한 ETag를 기본으로 사용하세요. 동일한 데이터의 변형된 표현을 의도적으로 제공할 때만 약한 ETag를 사용하세요.

ETag 생성: 본문 해시 vs 버전 컬럼

두 가지 전략이 지배적이며, 올바른 전략은 비용이 어디에 있는지에 따라 달라집니다.

응답 본문의 해시. 응답을 직렬화하고, 해시(여기서는 MD5 또는 SHA-1도 괜찮습니다. 이것은 지문이지 보안 경계가 아님)하고, 인용 부호로 감쌉니다. 이는 구성상 정확하며 스키마 변경이 필요 없습니다. 단점: 304 응답을 포함하여 모든 요청에 대해 전체 응답을 구축합니다. 대역폭은 절약하지만 계산 또는 데이터베이스 로드는 절약하지 못합니다.

버전 컬럼 또는 updated_at. 저렴하게 가져올 수 있는 데이터에서 ETag를 파생합니다: 행의 버전 카운터에서 얻은 ETag: "42-v17" 또는 updated_at의 해시. 이제 조건부 요청은 전체 직렬화 대신 하나의 인덱스 조회만 필요합니다. 단점: 버전은 조인된 테이블의 변경을 포함하여 응답에 영향을 미치는 모든 변경에 대해 업데이트되어야 합니다. 하나라도 놓치면 오래된 304 응답을 제공하게 되는데, 이는 보이지 않기 때문에 최악의 캐싱 버그입니다.

본문 해싱으로 시작하세요. 기본적으로 정확합니다. 직렬화 비용이 중요하다는 프로파일링 결과가 나오면 핫 엔드포인트를 버전 기반 ETag로 옮기세요.

낙관적 동시성을 위한 ETag: If-Match 및 412

읽기에서 대역폭을 절약하는 동일한 지문은 쓰기에서 손실된 업데이트를 방지합니다.

손실된 업데이트 문제: 두 명의 관리자가 동시에 제품 42를 로드합니다. 관리자 A는 가격을 변경하고 저장합니다. 관리자 B는 30초 후에 오타를 수정하고 저장하여, B가 로드한 오래된 가격으로 A의 가격 변경을 덮어씁니다. 아무도 오류를 보지 못합니다. 데이터는 조용히 잘못됩니다.

해결책은 클라이언트가 마지막으로 본 버전을 조건으로 모든 업데이트를 만드는 것입니다:

PUT /v1/products/42 HTTP/1.1
If-Match: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json

서버는 If-Match를 리소스의 현재 ETag와 비교합니다. 일치하면: 업데이트를 적용하고, 새 ETag와 함께 200을 반환합니다. 일치하지 않으면 다른 사람이 먼저 변경한 경우이므로: 412 Precondition Failed로 거부하고 데이터를 건드리지 않습니다. 그러면 클라이언트는 다시 가져와서 새 버전에서 변경 사항을 재적용하고 재시도합니다. 엄격한 API는 더 나아가 If-Match가 없는 모든 PUT에 대해 428 Precondition Required를 반환하여 안전 검사를 필수로 만듭니다.

ETag가 이미 존재한다면 이를 추가하는 데 거의 비용이 들지 않으며, 조용한 데이터 손상 버그를 명시적이고 재시도 가능한 HTTP 상태로 변환합니다.

CDN 및 프록시가 이러한 헤더를 사용하는 방법

공유 캐시는 원본 서버와 클라이언트 사이에 위치하며, 자체 규칙에 따라 동일한 헤더를 읽습니다.

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 수준에서 테스트하세요.

Apidog에서 수동 검사는 약 1분 정도 걸립니다:

  1. GET /v1/products/42를 보내고 응답 헤더 패널을 엽니다. ETagCache-Control이 존재하고 ETag가 인용 부호로 묶여 있는지 확인합니다. ETag 값을 복사합니다.
  2. 동일한 요청에 복사한 값으로 If-None-Match 헤더를 추가하고 다시 보냅니다. 본문이 없는 304 응답을 받아야 합니다. 여전히 200 응답을 받는다면, 유효성 검증 계층이 지문을 비교하고 있지 않은 것입니다.
  3. 기록을 변경하고 다시 보내서 새로운 ETag와 함께 200 응답으로 돌아가는지 확인합니다.

모든 배포 후에도 이 작업이 계속되도록 하려면, 동일한 흐름을 테스트 시나리오에 연결하세요. 두 요청을 연결합니다: 첫 번째는 응답 헤더에서 ETag를 변수로 추출하고, 두 번째는 이를 If-None-Match로 다시 보내고 상태가 304이고 본문이 비어 있음을 어설션합니다. 쓰기 경로를 위한 세 번째 단계를 추가합니다: 의도적으로 오래된 "deadbeefcafe1234"와 같은 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를 더 빠르게 만드나요?

전송량을 줄여주지만, 이는 '더 빠르다'는 것과 같지 않습니다. 서버는 여전히 요청을 받고, 인증을 실행하며, 현재 ETag를 계산합니다. 따라서 원본 CPU 절약은 그 지문을 얼마나 저렴하게 파생시키는지에 달려 있습니다. 이점은 대역폭, 모바일 배터리, 느린 네트워크에서의 렌더링 시간에 나타납니다. 전후를 측정하세요. API 성능 테스트 가이드는 추측 대신 차이를 증명할 수 있도록 지연 시간과 처리량을 벤치마킹하는 방법을 보여줍니다.

ETag 또는 Last-Modified 중 무엇을 사용해야 하나요?

가능하다면 둘 다 보내세요. ETag는 더 정확합니다. 타임스탬프가 놓치는 1초 미만의 변경 사항과 콘텐츠 수준의 차이를 잡아내며, If-None-Match는 둘 다 도착했을 때 If-Modified-Since보다 우선합니다. Last-Modified는 오래된 클라이언트를 위한 폴백과 일부 캐시가 신선도를 추정하는 데 사용하는 휴리스틱으로서 여전히 유용합니다. 하나만 보내야 한다면 ETag를 보내세요.

Apidog에서 API 설계-첫 번째 연습

API를 더 쉽게 구축하고 사용하는 방법을 발견하세요