Cache-Control은 클라이언트, 프록시 서버 그리고 CDN에서 리소스의 캐싱 규칙을 디렉티브 집합을 통해 정의하는 HTTP 헤더입니다. 구된 Expires 헤더와 달리, Cache-Control은 수십 가지 조합을 지원합니다: max-age는 수산으로 수명을 설정하고, private과 public은 캐시 가용성을 제어하며, no-cache와 no-store는 강제 확인을 수행합니다. Google Web Dev (2025)에 따르면, 올바른 Cache-Control 구성은 재방문시 페이지 로드 시간을 50-80% 감축할 수 있습니다. 이로써 이 헤더는 Web 및 모바일 애플리케이션 성능에 극히 중요합니다.
주요 포인트
Cache-Control은 HTTP/1.1 (RFC 7234)에서 표준화된 HTTP 헤더로, 서버가 클라이언트, 프록시 그리고 CDN이 응답을 어떻게 그리고 얼마나 캐시할 수 있는지 지정할 수 있게 합니다. Expires(HTTP/1.0)와 달리, Cache-Control은 디렉티브를 사용합니다 — 코마로 결합된 텍스트 명령어: Cache-Control: public, max-age=3600, must-revalidate. 이 헤더는 캐싱 체인의 각 고리를 세밀하게 제어할 수 있습니다.
캐싱은 Web 및 모바일 애플리케이션 성능의 기본 메커니즘 중 하나입니다. 캐싱이 없으면 머든 사용자 요청이 직접 서버로 가서 과다한 부하와 지연을 유발할 것입니다. Cache-Control은 세 가지 캐싱 레벨을 정의합니다: 브라우저/애플리케이션(개인 캐시), 프록시 서버(공유 캐시), CDN(분산 캐시). 각 레벨은 디렉티브를 다르게 해석합니다.
잘못된 Cache-Control 구성은 성능 문제의 가장 일반적인 원인 중 하나입니다. 너무 공격적인 캐싱은 사용자가 구된 데이터를 보는 결과를 가져옵니다. 너무 약한 캐싱은 서버에 과다한 요청과 느린 로딩을 초래합니다. Akamai (2025)에 따르면, 정적 콘텐츠에 대한 Cache-Control 최적화는 서버 부하를 70-90% 감소시키고 모바일 사용자의 로딩 시간을 40-60% 개선합니다.
Cache-Control은 HTTP/1.1 (RFC 2616, 1999)에서 Expires를 대체하는 것으로 나타났습니다. Expires에는 근본적인 문제가 있었습니다: 서버와 클라이언트의 시간대에 의존하는 절대적인 날짜를 사용했습니다. Cache-Control은 상대적 시간(응답을 받은 순간부터의 수산 단위 max-age)으로 전환하여 이 문제를 해결했습니다. 그 후, RFC 7234(2014)에서는 새로운 디렉티브가 추가되었습니다: 정적 자산의 경우 immutable, 지연 확인의 경우 stale-while-revalidate 및 stale-if-error.
Cache-Control은 세 그룹으로 나뛈 시단 10개 이상의 디렉티브를 포함합니다: 요청 디렉티브(클라이언트 → 서버), 응답 디렉티브(서버 → 클라이언트) 그리고 확장. 실제로 모바일 개발에서는 95% 캐싱 시나리오를 다루는 6-7개의 주요 응답 디렉티브를 사용합니다. 예시와 권고사항과 함께 각각을 살펴보겠습니다.
| 디렉티브 | 의미 | 예시 |
|---|---|---|
| max-age | 응답 순간부터의 수산 단위 수명 | max-age=3600 — 1시간 |
| s-maxage | 공유 캐시(프록시, CDN)의 max-age | s-maxage=86400 — CDN에서 1일 |
| public | 모든 사용자가 캐싱 허용(프록시 포함) | public, max-age=3600 |
| private | 브라우저/애플리케이션만 캐싱 허용 | private, max-age=600 |
| no-cache | 확인 없이 사용 금지(304 필요) | no-cache |
| no-store | 캐싱 완전 금지 | no-store |
| must-revalidate | max-age 후에 원본 서버에서 재확인 필요 | max-age=3600, must-revalidate |
| immutable | 리소스가 변경되지 않음(버전 고정된 정적 자산의 경우) | max-age=31536000, immutable |
max-age는 가장 중요한 디렉티브입니다. 지정된 시간 동안 클라이언트가 서버에 요청하는 것을 금지합니다. 정적 자산(CSS, JS, 이미지)의 경우 max-age는 보통 1일에서 1년까지 설정됩니다. API 응답의 경우 0초(항상 최신 데이터)에서 5-10분(참조 데이터)까지입니다. s-maxage는 CDN과 브라우저에 다른 수명을 설정할 수 있습니다: CDN은 1일 동안 사본을 저장하고, 브라우저는 1시간 동안 저장합니다.
이 두 디렉티브는 종존 혼동됩니다. no-cache는 캐싱을 금지하지 않습니다 — 조건부 요청(If-Modified-Since 또는 If-None-Match)을 통해 사용할 때마다 캐시된 사본을 확인하도록 요구합니다. 서버가 304로 응답하면 클라이언트는 캐시를 사용합니다. 200이면 업데이트합니다. 반면, no-store는 디스크와 메모리를 포함한 모든 캐시에 응답을 저장하는 것을 완전히 금지합니다. 민감한 데이터(토큰, 결제 데이터, 개인 문서)에만 no-store를 사용하십시오.
Expires 헤더(HTTP/1.0)는 리소스의 수명도 지정하지만 절대적인 날짜를 사용합니다: Expires: Thu, 03 Jul 2026 12:00:00 GMT. Cache-Control max-age는 응답 순간부터의 상대적 시간을 사용합니다. 이 차이는 분산 시스템에 중요합니다. 서버와 클라이언트가 다른 시간대에 있는 경우 Expires가 잘못 해석될 수 있습니다. Cache-Control에는 이러한 문제가 없습니다 — 3600초는 항상 3600초입니다.
두 헤더가 모두 있을 경우, Expires보다 Cache-Control이 우선순위를 갖습니다. 이는 RFC 7234에 정의되어 있습니다: “응답에 max-age 디렉티브가 있는 Cache-Control 필드가 포함된 경우, 수신자는 Expires 필드를 무시해야 합니다.” 실제로는 Cache-Control이 모든 Expires 시나리오를 다루기 때문에 근대 클라이언트에는 Expires를 반환하지 않는 것이 좋습니다. 그러나 구 프록시와 브라우저와의 호환성을 위해 두 헤더를 모두 반환할 수도 있습니다.
Expires는 주로 Nginx와 Apache에서 정적 콘텐츠용으로 남아 있습니다 — 이러한 서버는 자동으로 두 헤더를 추가합니다. 프로젝트에서 Cache-Control 없이 Expires를 만나면 max-age가 있는 Cache-Control로 대체하십시오: 캐시 제어 정확도가 향상되고 시간대에 대한 의존성이 제거됩니다. 마이그레이션을 위해서는 Expires 대신 Cache-Control을 추가하도록 서버를 구성하면 됩니다.
# Nginx: 정적 파일용 Cache-Control
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 30d;
add_header Cache-Control "public, immutable, max-age=2592000";
}
# 다른 콘텐츠 유형에 대한 다른 정책
location /api/config {
expires -1;
add_header Cache-Control "no-cache, must-revalidate";
}
location /api/static-data {
expires 5m;
add_header Cache-Control "public, max-age=300";
}
Nginx 구성에서 정적 파일(CSS, JS, 이미지)은 immutable 속성으로 30일 동안 Cache-Control로 설정됩니다 — 이 속성은 이 URL에서 리소스가 결코 변경되지 않음을 브라우저에 알립니다(파일 이름에 해시를 통한 버전 관리). API 엔드포인트는 동적 데이터에 no-cache를, 참조 데이터(자주 요청되지만 거의 변경되지 않는 목록)에 구단 max-age의 public을 사용합니다.
모바일 애플리케이션에서 Cache-Control은 모바일 네트워크의 제한(높은 지연, 불안정한 접속, 트래픽 제한)으로 인해 특별한 역할을 합니다. 적절한 캐싱을 통해 오프라인에서도 데이터를 그례로 표시하고 백그라운드에서 업데이트할 수 있습니다. Android의 OkHttp와 iOS의 URLSession에는 Cache-Control을 준수하는 내장 캐싱 시스템이 있습니다.
OkHttp는 응답에서 Cache-Control을 읽고 캐싱을 자동으로 관리하는 CacheInterceptor를 사용합니다. 서버가 Cache-Control: max-age=3600을 반환한 경우 OkHttp는 1시간 동안 서버에 요청하지 않습니다. max-age가 만료된 후 OkHttp는 If-Modified-Since와 If-None-Match를 사용한 조건부 요청을 보냅니다. OkHttp에서 캐시 구성: OkHttpClient.Builder().cache(Cache(directory, maxSize)).
fun createCachedClient(cacheDir: File): OkHttpClient {
return OkHttpClient.Builder()
.cache(Cache(cacheDir, 10L * 1024 * 1024))
.addNetworkInterceptor { chain ->
val response = chain.proceed(chain.request())
response.newBuilder()
.header("Cache-Control",
"public, max-age=300")
.removeHeader("Pragma")
.build()
}
.build()
}
이 코드는 10MB 캐시가 있는 OkHttpClient를 생성하고 NetworkInterceptor를 통해 Cache-Control을 재정의합니다. 서버가 Cache-Control을 반환하지 않거나 Expires를 사용하는 경우, 인터셉터가 public, max-age=300(5분)을 추가합니다. 인터셉터는 호환성을 위해 구 헤더인 Pragma(HTTP/1.0)를 제거합니다. iOS에서 캐싱은 memoryCapacity와 diskCapacity 설정을 가진 URLCache.shared를 통해 비슷하게 동작합니다.
stale-while-revalidate 디렉티브는 애플리케이션이 백그라운드에서 새로운 데이터를 가져오는 동안 사용자에게 구 캐시를 표시할 수 있게 합니다. 이를 통해 즉각 응답 효과를 낼 수 있습니다: 사용자가 그례로 콘텐츠를 보고 잠시 후에 최신 버전으로 업데이트됩니다. OkHttp 버전 3.10부터, 그리고 iOS 14+에서 URLCache에서 지원됩니다. 예: Cache-Control: max-age=3600, stale-while-revalidate=300 — 1시간 최신 캐시, 그 후 5분 동안 백그라운드 업데이트와 구 데이터 표시.
리소스 유형마다 다른 캐싱 전략이 필요합니다. 모바일 개발의 일반적인 시나리오에 대한 최적의 구성을 살펴보겠습니다. 파일 이름에 해시가 있는 정적 콘텐츠(bundle.abc123.js)의 경우 immutable과 함께 max-age를 1년까지 설정할 수 있습니다. 거의 업데이트되지 않는 API 목록(디렉토리, 카테고리)의 경우 stale-while-revalidate와 함께 5분에서 1시간의 max-age입니다.
| 리소스 유형 | Cache-Control | 설명 |
|---|---|---|
| 버전 고정 정적 자산 | public, max-age=31536000, immutable | 1년, 파일이 변경되지 않음(URL에 해시) |
| 비버전 정적 자산 | public, max-age=86400, must-revalidate | 그 후 강제 재확인으로 1일 |
| API: 참조 데이터 | public, max-age=600, stale-while-revalidate=60 | 10분 캐시 + 1분 구 데이터 |
| API: 사용자 데이터 | private, max-age=60 | 1분, 특정 사용자만 허용 |
| API: 민감 데이터 | no-store | 캐싱 완전 금지 |
| HTML 페이지 | no-cache, must-revalidate | 모든 요청에서 확인, 변경 없으면 304 |
보안을 기억하는 것이 중요합니다: 개인 사용자 데이터가 포함된 응답에는 항상 private을 설정하십시오. 이 디렉티브가 없으면 공용 프록시(예: 기업용)가 응답을 캐시하여 다른 사용자에게 전달할 수 있습니다. 인증 토큰과 결제 정보에는 no-store를 사용하십시오 — 개인 캐시라도 이러한 데이터를 디스크에 저장해서는 안 됩니다.
Cache-Control의 올바른 동작을 확인하려면 Age 헤더(캐시가 저장된 초 수)와 X-Cache(CDN의 hit/miss)를 사용하십시오. 브라우저에서 Network 탭의 Size 열에 “from disk cache” 또는 “304 Not Modified”가 표시됩니다. 캐시되어야 할 리소스가 매번 로딩되는 경우, 서버가 사용자의 디렉티브와 함께 Cache-Control: no-cache 또는 Pragma: no-cache를 추가하지 않는지 확인하십시오.
자주 묻는 질문
max-age는 모든 캐시(브라우저 포함)에 적용되고, s-maxage는 공유 캐시(프록시, CDN)에만 적용됩니다. s-maxage가 지정된 경우 CDN은 max-age를 무시하고 s-maxage를 사용합니다. 이를 통해 브라우저와 CDN에 다른 수명을 설정할 수 있습니다.
아니요. max-age가 있는 응답을 보낸 후 타이머가 만료될 때까지 클라이언트는 요청을 보내지 않습니다. 캐시를 즉시 무효화하려면 리소스 URL을 변경하고(버전/해시 추가) 강제 재설정을 위해 push 알림 또는 WebSocket 메시지를 보내야 합니다.
immutable 디렉티브(RFC 8246)는 이 URL에서 리소스가 결코 변경되지 않는다는 것을 브라우저에 알립니다. 페이지 새로고침 시 브라우저는 조건부 요청을 시도하지 않습니다 — max-age가 만료될 때까지 캐시를 사용합니다. 버전 고정 파일에서만 작동합니다.
Googlebot은 Cache-Control을 고려합니다: 긴 캐싱은 재크롤링을 가속화합니다. 빠른 캐시의 noindex는 괜찮습니다. no-store는 Googlebot이 매번 페이지를 처음부터 로드하기 때문에 인덱싱을 늘리고, max-age가 너무 껇으면 크롤링 시 서버 부하가 늘어납니다.
helmet 또는 미들웨어를 통해: res.set('Cache-Control', 'public, max-age=3600'). 정적 파일의 경우 maxAge 매개변수가 있는 express.static을 사용하십시오: express.static('public', {maxAge: '1y'}). 동적 라우트의 경우 각 핸들러에서 개별으로 설정합니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.