본문 바로가기

asset 최적화

이미지·CSS·JavaScript·웹 폰트의 크기를 줄이고 로딩 시점을 조정하며 HTTP 캐시로 반복 전송 비용을 낮춥니다.

앞 글 웹 성능 최적화 기본 원칙에서는 측정을 바탕으로 성능을 개선하는 순환과 Core Web Vitals를 살펴봤습니다. 이번에는 그 가운데 프런트엔드 성능에서 가장 큰 몫을 차지하는 에셋(asset), 즉 이미지, CSS, 자바스크립트, 웹 글꼴 같은 정적 파일의 최적화를 다룹니다.

에셋마다 파일 크기를 줄이는 방법과 불러오는 시점을 조정하는 방법을 정리하고, 이것이 주요 콘텐츠가 보이는 시점인 LCP, 실험실에서 메인 스레드를 막는 시간인 TBT와 어떻게 이어지는지 함께 살펴봅니다. 마지막으로 모든 에셋에 적용되는 HTTP 캐시 전략을 정리합니다.

자산을 줄이고 초기 화면의 우선순위를 정합니다

ASSET DELIVERY

자산을 줄이고 초기 화면의 우선순위를 정합니다

이미지·CSS·JavaScript·폰트의 전송 크기와 실행 비용은 다릅니다. 빌드 결과의 URL·캐시 정책과 브라우저의 발견·실행 시점을 함께 설계합니다.

자산의 변환과 전달을 다른 단계로 구별한다3노드2관계는원본변형→최적화해시→전달재사용의논리파이프라인이다. 초기화면의리소스와나중리소스의발견시점은아래보조설명에구별되어있다. 작은번들/압축전송량만으로LCP/INP/CLS개선을확정하지않고HTML의새URL참조도필요하다. mobile같다.
원본·변형표시 크기·품질폰트 부분집합
연결 관계원본·변형 · 표시 크기·품질 · 폰트 부분집합 → 최적화·해시 URL · 압축·미사용 제거 · 필요 단위 분할
최적화·해시 URL압축·미사용 제거필요 단위 분할
연결 관계최적화·해시 URL · 압축·미사용 제거 · 필요 단위 분할 → 전달·재사용 · HTML 참조 갱신 · CDN·캐시 정책
전달·재사용HTML 참조 갱신CDN·캐시 정책
자산 변환과 전달원본의 크기와 포맷/부분집합→압축·분할 및 내용 해시 URL→CDN과 HTTP캐시 전달.1 · 원본과 변형크기 · 포맷 · 부분집합2 · 최적화·해시압축 · 분할 · 새 URL3 · 전달·재사용CDN · HTTP 캐시

원본·변형 · 필요한 품질

실제 표시 크기에 맞는 이미지 후보를 srcset·sizes로 제공합니다. 폰트는 필요한 문자·굵기를 정하고 품질을 확인합니다.

코드·해시 · 배포 식별

미사용 CSS·JS를 제거하고 필요한 단위로 분할합니다. 내용이 바뀐 파일은 새 해시 URL을 만들고 HTML·manifest의 참조도 갱신합니다.

초기 화면 · 빨리 발견해야 할 자원

LCP 이미지와 핵심 CSS를 먼저 찾도록 합니다. LCP 이미지는 lazy로 미루지 않고 preload는 실제 발견 지연을 확인한 중요한 자원에 제한합니다.

나중 화면 · 사용 시점까지 미룰 자원

화면 밖 이미지에 lazy loading을, 비필수 코드에는 필요한 시점의 동적 import를 검토합니다. async·defer는 그 자체로 “사용할 때까지 다운로드하지 않음”을 뜻하지 않습니다.

일반 외부 script · 실행 순서

async는 준비되면 실행하고 순서를 보장하지 않습니다. defer는 파싱 뒤 문서 순서대로 실행합니다. 둘 다 다운로드와 실행 비용을 없애지는 않습니다.

캐시 · 해시 자산과 HTML

변경되지 않는 해시 자산은 긴 max-age·immutable로 재사용할 수 있습니다. HTML은 정책에 맞게 짧은 캐시 또는 재검증으로 새 자산 참조를 전달합니다.

폰트 교체의 레이아웃 이동, 분할된 JS의 실행 비용, 첫 화면 발견 지연을 다시 측정합니다. 파일 크기만 줄었다고 LCP·INP·CLS가 모두 개선되는 것은 아닙니다.

위 그림은 에셋을 바꾸고 전달하는 세 단계입니다. 첫 단계에서는 원본을 실제 표시 크기와 품질에 맞게 바꿉니다. 이미지는 srcset과 sizes로 표시 크기에 맞는 후보를 여럿 준비하고, 글꼴은 필요한 문자와 굵기만 남깁니다. 두 번째 단계에서는 압축하고, 쓰지 않는 CSS와 자바스크립트를 지우고, 필요한 단위로 나눈 뒤, 내용이 바뀐 파일에는 내용의 해시(hash)를 넣은 새 URL을 붙입니다. 이때 HTML과 매니페스트(manifest)가 새 URL을 가리키도록 함께 갱신해야 합니다. 세 번째 단계에서는 CDN과 HTTP 캐시로 전달하고 다시 씁니다.

그림 아래쪽은 불러오는 시점과 캐시의 원칙입니다. 첫 화면의 LCP 이미지와 핵심 CSS는 브라우저가 빨리 발견하게 하고, LCP 이미지는 지연 로딩하지 않으며, preload는 실제로 발견이 늦어지는 것을 확인한 중요한 자원에만 씁니다. 나중 화면의 자원은 화면 밖 이미지의 지연 로딩이나 필요한 시점의 동적 import()로 미룹니다. async와 defer는 쓸 때까지 내려받지 않는다는 뜻이 아니며, async는 준비되는 대로 순서 없이, defer는 해석이 끝난 뒤 문서 순서대로 실행하고 둘 다 내려받고 실행하는 비용을 없애지는 않습니다.

캐시는 바뀌지 않는 해시 에셋에 긴 max-age와 immutable을 주고, HTML은 짧게 캐시하거나 다시 확인해 새 에셋의 참조를 전달합니다. 그림 맨 아래의 설명처럼 글꼴이 바뀌며 생기는 레이아웃 이동, 나눈 자바스크립트의 실행 비용, 첫 화면 자원의 발견 지연을 다시 측정해야 하며, 파일이 작아졌다고 LCP, INP, CLS가 모두 나아지는 것은 아닙니다.


이미지 최적화

웹 페이지에서 가장 큰 용량을 차지하는 경우가 많은 것이 이미지라서, 이미지 최적화는 성능 개선 효과가 큰 영역입니다. 줄일 수 있는 길은 세 가지입니다. 같은 그림을 더 작은 파일로 담는 형식을 고르고, 화면에 실제로 필요한 크기로 압축하고, 당장 보이지 않는 이미지는 나중에 받는 것입니다. 아래의 세 절이 이 순서를 따릅니다.

알맞은 형식 고르기

JPEG는 색이 풍부하고 세밀한 사진에 알맞은 손실 압축 형식입니다. PNG는 투명한 배경이나 로고, 아이콘처럼 선명한 가장자리가 중요한 이미지에 알맞은 무손실 압축 형식입니다. WebP는 JPEG나 PNG보다 압축률이 좋으면서 품질 손실이 적고 투명도도 지원하며, 지금의 주요 브라우저가 모두 지원합니다. AVIF는 WebP보다 더 높은 압축률을 낼 수 있는 형식으로, 지원 범위를 확인해 함께 검토할 수 있습니다. SVG는 로고, 아이콘, 간단한 그림 같은 벡터 이미지에 알맞고 확대해도 깨지지 않으며 CSS로 꾸밀 수 있지만, 모양이 복잡하면 파일이 커질 수 있으므로 실제 결과를 비교합니다.

압축과 크기 조정

손실 압축은 JPEG나 WebP 등에 쓰며, 일부 데이터를 잃는 대신 파일을 크게 줄입니다. 무손실 압축은 PNG, GIF, SVG 등에 쓰며 데이터 손실 없이 파일을 줄입니다. Squoosh 같은 도구나 빌드 도구의 플러그인으로 압축을 자동화할 수 있습니다.

이미지는 실제로 화면에 표시될 크기에 맞춰 준비하고, 필요 이상으로 큰 해상도를 쓰지 않습니다. srcset과 sizes 속성을 쓰면 브라우저가 화면 크기와 해상도에 맞는 이미지를 골라 받습니다.

<img srcset="small.jpg 480w, medium.jpg 800w, large.jpg 1200w"
     sizes="(max-width: 600px) 480px, (max-width: 1000px) 800px, 1200px"
     src="medium.jpg" alt="산 위로 해가 뜨는 풍경" width="1200" height="800">

srcset은 각 이미지 파일과 그 실제 너비를, sizes는 화면 너비에 따라 이미지가 차지할 표시 너비를 알려 줍니다. 브라우저는 이 정보와 기기의 픽셀 밀도를 보고 알맞은 파일 하나를 고르며, src는 이를 지원하지 않는 환경을 위한 기본값입니다. width와 height를 적어 두면 이미지를 받기 전에 자리를 미리 잡아 두므로, 이미지가 나타나며 글이 밀리는 CLS를 막을 수 있습니다.

지연 로딩과 CDN

화면 밖에 있는 이미지는 지연 로딩(lazy loading)으로 나중에 불러옵니다. loading="lazy" 속성을 쓰거나 Intersection Observer API로 직접 구현할 수 있으며, 브라우저 내장 지연 로딩은 실제로 언제 요청을 시작할지 브라우저가 정해 화면에 들어오기 전에 미리 시작할 수도 있습니다.

<!-- 첫 화면의 LCP 이미지가 아닌, 화면 밖의 갤러리 이미지 예 -->
<img src="gallery.jpg" alt="갤러리 풍경" width="800" height="600" loading="lazy">

여러 지역에 서버를 둔 CDN에 이미지를 두면, 사용자와 가까운 서버에서 이미지를 보내 로딩이 빨라집니다.


CSS 최적화

CSS 파일은 기본으로 페이지를 그리는 것을 막으므로, 효율적으로 관리해야 합니다.

공백과 주석, 마지막 세미콜론 등을 지워 CSS를 줄이는 최소화는 cssnano나 webpack의 css-minimizer-webpack-plugin 같은 도구가 합니다. 실제로 쓰지 않는 CSS 규칙은 PurgeCSS 같은 도구로 지울 수 있는데, Bootstrap이나 MUI 같은 UI 프레임워크를 쓰면 쓰지 않는 CSS가 특히 많을 수 있습니다. 다만 자바스크립트로 나중에 붙이는 클래스까지 지우지 않도록 결과를 확인합니다.

첫 화면을 그리는 데 꼭 필요한 최소한의 CSS(critical CSS)는 HTML의 <head> 안 <style> 태그에 직접 넣으면, 외부 CSS 파일을 받을 때까지 화면 그리기가 늦어지는 것을 줄여 FCP와 LCP를 개선할 수 있습니다. 나머지 CSS는 나중에 불러옵니다.

여러 작은 아이콘을 큰 이미지 하나로 합치고 background-position으로 필요한 부분만 보이게 하는 CSS 스프라이트(CSS sprite)는 요청 수를 줄이는 예전 방법입니다. 요즘은 SVG 아이콘이나 HTML에 직접 넣는 인라인 SVG로 대신하는 경우가 많습니다. <link>의 media 속성으로 print처럼 특정 매체에서만 쓰는 CSS를 지정하면, 지금 화면에 적용되지 않는 CSS가 그리기를 막지 않게 할 수 있습니다. 다만 그런 CSS도 미리 내려받을 수 있으므로, 전송 자체를 건너뛴다고 생각하지는 않습니다.


자바스크립트 최적화

자바스크립트는 내려받는 시간뿐 아니라 해석하고 컴파일하고 실행하는 데 CPU 시간이 많이 들어, 네트워크와 메인 스레드 양쪽에 병목을 만들 수 있습니다.

Terser 같은 도구는 공백을 지우는 것에 더해 변수 이름을 짧게 바꿔 파일을 최소화합니다. 트리 셰이킹(tree shaking)은 webpack이나 Rollup 같은 번들러가 실제로 쓰는 코드만 최종 번들에 넣고 쓰지 않는 코드를 빼는 일입니다. 이렇게 빼려면 정적인 ES 모듈 구조와 패키지의 부수 효과(side effects) 정보처럼, 번들러가 빼도 안전한지 판단할 조건이 필요합니다(webpack의 트리 셰이킹 안내). Lodash처럼 큰 라이브러리에서 일부 기능만 쓸 때는 결과 번들을 비교해 보고, 파일을 나누는 것과 쓰지 않는 코드를 빼는 것을 구별합니다.

코드 분할은 번들을 여러 조각으로 나눠 지금 필요한 코드만 내려받게 합니다. React의 lazy()와 Suspense, Vue의 비동기 컴포넌트, 동적 import()를 활용합니다. 외부 <script>에 async를 붙이면 HTML 해석과 함께 내려받아 준비되는 대로 실행하므로 스크립트 사이의 실행 순서가 보장되지 않고, defer를 붙이면 해석이 끝난 뒤 문서 순서대로 실행하므로 순서에 의존하는 스크립트에 씁니다.

분석 도구, 광고, 소셜 미디어 위젯 같은 서드 파티(third-party) 스크립트는 성능에 큰 영향을 줄 수 있으므로, 꼭 필요할 때만 불러오거나 늦게 불러옵니다. 작은 기능 하나 때문에 큰 라이브러리 전체를 넣기보다 직접 구현하거나 가벼운 대안을 찾습니다. 그리고 메인 스레드를 50ms 넘게 붙잡는 긴 작업을 피하고, 복잡한 계산은 웹 워커로 옮깁니다.


웹 글꼴 최적화

웹 글꼴은 디자인을 일관되게 해 주지만, 용량이 커서 로딩 성능을 떨어뜨릴 수 있습니다. 특히 한글 글꼴은 글자 수가 많아 파일이 큽니다.

글꼴 형식은 압축률이 가장 좋은 WOFF2를 쓰며, 지금의 주요 브라우저가 모두 지원하므로 대부분 WOFF2 하나로 충분합니다. 실제로 페이지에서 쓰는 문자만 남긴 부분 집합 글꼴(subset font)을 만들면, 예를 들어 자주 쓰는 한글과 숫자, 영문만 남겨 파일을 크게 줄일 수 있습니다.

CSS의 @font-face 규칙에서 font-display 속성으로 글꼴을 불러오는 동안의 표시 방식을 정합니다. swap은 글꼴을 받기 전에는 시스템 글꼴로 보여 주다가 받은 뒤 웹 글꼴로 바꿉니다. 글꼴이 바뀌며 글자 모양이 잠깐 달라 보일 수 있지만, 글이 바로 보여 사용 경험에 좋습니다. optional은 아주 짧은 대기 시간 뒤 교체 기간을 두지 않아, 제때 준비되지 않은 글꼴은 그 화면에서 대체 글꼴을 계속 쓰게 될 수 있습니다. 내려받기를 항상 멈춘다는 뜻은 아닙니다(MDN의 font-display).

가장 중요한 글꼴 파일은 <link rel="preload" href="your-font.woff2" as="font" type="font/woff2" crossorigin>으로 브라우저가 가능한 한 빨리 받게 할 수 있습니다. 글꼴은 같은 출처라도 crossorigin 속성이 있어야 미리 받은 파일을 다시 씁니다.


HTTP 캐시 전략

HTTP 캐시는 모든 에셋에 적용할 수 있는 중요한 최적화입니다. 서버는 응답의 Cache-Control 헤더로 브라우저의 캐시 규칙을 알려 줍니다. public, max-age=31536000, immutable은 정적 에셋을 1년 동안 신선하게 보고 다시 확인하지 않아도 된다는 뜻으로, 다시 방문할 때 바로 불러오게 합니다. immutable은 그 응답이 신선한 동안 다시 확인을 건너뛰어도 된다는 뜻이라서, 파일 이름에 내용의 해시를 넣고 내용이 바뀌면 새 URL로 배포하는 방식과 함께 씁니다. no-cache는 저장은 하되 다시 쓰기 전에 항상 서버에 확인하라는 뜻이고, no-store는 아예 저장하지 말라는 뜻으로 민감한 정보에 씁니다.

ETag와 Last-Modified는 파일이 바뀌었는지 확인하는 검증 값입니다. 브라우저가 캐시된 에셋을 다시 요청할 때 If-None-Match나 If-Modified-Since 헤더에 이 값을 담아 보내면, 서버는 파일이 바뀌지 않았을 때 본문 없이 304 Not Modified로 응답합니다. 파일을 통째로 다시 보내는 것보다 훨씬 효율적입니다.

파일 이름에 bundle.1a2b3c4d.js처럼 해시나 버전을 넣어 내용이 바뀔 때마다 새 이름으로 배포하는 것을 캐시 무효화(cache busting)라고 합니다. 새 파일 이름은 새 캐시 항목이 되므로, 사용자가 새 파일을 받으려면 HTML이나 매니페스트도 그 새 URL을 가리켜야 합니다. 오래된 HTML이 옛 URL을 계속 가리키는 문제까지 저절로 풀어 주지는 않습니다.

HTTP 캐시 저장·재검증 생명주기 — 상태 추적

저장 body·ETag를 이용한 조건부 GET에서 304와 200은 서로 다른 응답 대안입니다. 304는 검증된 기존 body를 사용하고, 200은 새 응답의 저장 정책을 확인합니다.

캐시는 저장 여부와 재사용 조건을 따로 판단합니다

HTTP CACHE

캐시는 저장 여부와 재사용 조건을 따로 판단합니다

같은 URL을 다시 요청할 때 저장된 응답의 존재·신선도·정책을 확인합니다. 캐시가 오래됐다는 이유만으로 본문을 바로 삭제하지 않습니다.

재검증 요청의 304와 200을 구별한다5노드4관계의기본ETag조건부GET이다. 저장응답이있고재검증이필요하며서버가성공적으로판단하는조건을가정한다. 동일304는본문없이기존본문/메타데이터사용,변경200은새본문과정책확인이다. miss/fresh/no-store/private와다른오류·ServiceWorker·stale예외는아래보조설명/별도범위이고이그림에억지로추가하지않는다. mobile같다.재검증 필요한 응답저장된 ETag 있음조건부 GETIf-None-Match성공한 검증내용 동일?304 · 새 본문 없음기존 본문 재사용200 · 새 본문새 정책 확인·교체동일변경
재검증 요청의 304와 200을 구별한다5노드4관계의기본ETag조건부GET이다. 저장응답이있고재검증이필요하며서버가성공적으로판단하는조건을가정한다. 동일304는본문없이기존본문/메타데이터사용,변경200은새본문과정책확인이다. miss/fresh/no-store/private와다른오류·ServiceWorker·stale예외는아래보조설명/별도범위이고이그림에억지로추가하지않는다. mobile같다.재검증 필요저장된 ETag조건부 GETIf-None-Match성공한 검증내용 동일?304기존 본문메타데이터 갱신200 · 새 본문새 정책으로저장 여부 판단동일변경

저장된 응답 없음 · miss

네트워크로 요청합니다. 받은 응답이 저장 가능한 정책이면 본문과 헤더·신선도 정보를 저장합니다.

fresh · 재사용 허용

max-age 등 신선도 기간 안이며 재검증 요구가 없으면 저장된 응답을 사용합니다. 요청·응답의 다른 캐시 조건도 확인합니다.

재검증 필요 · stale / no-cache

stale이거나 no-cache가 재검증을 요구하면 서버에 확인합니다. ETag·Last-Modified가 있으면 조건부 요청을 사용합니다.

조건부 요청 → 서버 응답 → 캐시 처리

If-None-Match: "asset-v1"

304 Not Modified · 기존 본문 재사용

검증된 기존 본문을 유지하고 응답에 따라 메타데이터를 갱신합니다. 304는 새 본문을 보내지 않습니다.

200 OK · 응답 교체

새 본문과 헤더를 받고 정책이 허용하면 저장합니다. 검증자가 없으면 일반 요청으로 새 응답을 받아야 할 수 있습니다.

no-store · 저장 금지

새 응답을 캐시에 저장하지 않습니다. 이미 저장된 같은 URL의 응답을 자동으로 삭제한다는 뜻은 아닙니다.

private · 개인 캐시

브라우저 같은 개인 캐시는 저장할 수 있지만 공유 캐시는 저장하지 않습니다. no-store와 서로 다른 정책입니다.

no-cache · 재사용 전 검증

저장은 가능하지만 재사용 전 서버 검증을 요구합니다. fresh여도 검증을 생략하지 않습니다.

새 내용 해시 URL은 이전 URL의 응답을 지우는 대신 다른 캐시 항목을 요청합니다. 이 그림은 일반 HTTP 요청의 기본 흐름이며 Service Worker·history 복원·stale 허용 정책은 별도입니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 저장된 ETag가 있고 재검증이 필요한 정상 GET을 가정합니다. 304와 200은 같은 요청의 연속 응답이 아닌 독립 대안입니다. 200 장면의 캐시 교체는 새 응답 저장을 정책이 허용한 경우만 추적합니다. no-store는 새 응답 저장 금지이며 이전 동일 URL 응답의 자동 삭제를 뜻하지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명노드 5개·관계 4개의 기본 ETag 조건부 GET이다. 저장 응답이 있고 재검증이 필요하며 서버가 성공적으로 판단하는 조건을 가정한다. 동일한 내용의 304는 본문 없이 기존 본문/메타데이터 사용, 변경된 내용의 200은 새 본문과 정책 확인이다. miss/fresh/no-store/private와 다른 오류·ServiceWorker·stale 예외는 아래 보조 설명/별도 범위이고 이 그림에 억지로 추가하지 않는다. mobile도 같다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
저장한 응답캐시: 저장 body+ETag / 요청: If-None-Match캐시에 body와 ETag가 있지만 재검증이 필요하다stale 응답은 재사용 가능성 확인이 필요하다
대안 A · 내용 동일 304HTTP: 304 / 캐시 body: 재사용서버가 같은 표현임을 확인한다검증자가 같은 표현을 확인했으므로 새 body 없이 기존 body를 재사용한다
대안 B · 내용 변경 200HTTP: 200 새 body / 캐시: 저장 허용 시 현재 표현으로 갱신새 body와 헤더를 받고 저장 허용 정책을 확인한 뒤 캐시를 교체한다내용이 변경되어 새 body를 받으며, 이 사례는 저장이 허용되어 기존 캐시 항목을 교체한다

HTTP 캐시 저장·재검증 생명주기 — 상태 추적

저장 body·ETag를 이용한 조건부 GET에서 304와 200은 서로 다른 응답 대안입니다. 304는 검증된 기존 body를 사용하고, 200은 새 응답의 저장 정책을 확인합니다.

캐시는 저장 여부와 재사용 조건을 따로 판단합니다

HTTP CACHE

캐시는 저장 여부와 재사용 조건을 따로 판단합니다

같은 URL을 다시 요청할 때 저장된 응답의 존재·신선도·정책을 확인합니다. 캐시가 오래됐다는 이유만으로 본문을 바로 삭제하지 않습니다.

재검증 요청의 304와 200을 구별한다5노드4관계의기본ETag조건부GET이다. 저장응답이있고재검증이필요하며서버가성공적으로판단하는조건을가정한다. 동일304는본문없이기존본문/메타데이터사용,변경200은새본문과정책확인이다. miss/fresh/no-store/private와다른오류·ServiceWorker·stale예외는아래보조설명/별도범위이고이그림에억지로추가하지않는다. mobile같다.재검증 필요한 응답저장된 ETag 있음조건부 GETIf-None-Match성공한 검증내용 동일?304 · 새 본문 없음기존 본문 재사용200 · 새 본문새 정책 확인·교체동일변경
재검증 요청의 304와 200을 구별한다5노드4관계의기본ETag조건부GET이다. 저장응답이있고재검증이필요하며서버가성공적으로판단하는조건을가정한다. 동일304는본문없이기존본문/메타데이터사용,변경200은새본문과정책확인이다. miss/fresh/no-store/private와다른오류·ServiceWorker·stale예외는아래보조설명/별도범위이고이그림에억지로추가하지않는다. mobile같다.재검증 필요저장된 ETag조건부 GETIf-None-Match성공한 검증내용 동일?304기존 본문메타데이터 갱신200 · 새 본문새 정책으로저장 여부 판단동일변경

저장된 응답 없음 · miss

네트워크로 요청합니다. 받은 응답이 저장 가능한 정책이면 본문과 헤더·신선도 정보를 저장합니다.

fresh · 재사용 허용

max-age 등 신선도 기간 안이며 재검증 요구가 없으면 저장된 응답을 사용합니다. 요청·응답의 다른 캐시 조건도 확인합니다.

재검증 필요 · stale / no-cache

stale이거나 no-cache가 재검증을 요구하면 서버에 확인합니다. ETag·Last-Modified가 있으면 조건부 요청을 사용합니다.

조건부 요청 → 서버 응답 → 캐시 처리

If-None-Match: "asset-v1"

304 Not Modified · 기존 본문 재사용

검증된 기존 본문을 유지하고 응답에 따라 메타데이터를 갱신합니다. 304는 새 본문을 보내지 않습니다.

200 OK · 응답 교체

새 본문과 헤더를 받고 정책이 허용하면 저장합니다. 검증자가 없으면 일반 요청으로 새 응답을 받아야 할 수 있습니다.

no-store · 저장 금지

새 응답을 캐시에 저장하지 않습니다. 이미 저장된 같은 URL의 응답을 자동으로 삭제한다는 뜻은 아닙니다.

private · 개인 캐시

브라우저 같은 개인 캐시는 저장할 수 있지만 공유 캐시는 저장하지 않습니다. no-store와 서로 다른 정책입니다.

no-cache · 재사용 전 검증

저장은 가능하지만 재사용 전 서버 검증을 요구합니다. fresh여도 검증을 생략하지 않습니다.

새 내용 해시 URL은 이전 URL의 응답을 지우는 대신 다른 캐시 항목을 요청합니다. 이 그림은 일반 HTTP 요청의 기본 흐름이며 Service Worker·history 복원·stale 허용 정책은 별도입니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 저장된 ETag가 있고 재검증이 필요한 정상 GET을 가정합니다. 304와 200은 같은 요청의 연속 응답이 아닌 독립 대안입니다. 200 장면의 캐시 교체는 새 응답 저장을 정책이 허용한 경우만 추적합니다. no-store는 새 응답 저장 금지이며 이전 동일 URL 응답의 자동 삭제를 뜻하지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명노드 5개·관계 4개의 기본 ETag 조건부 GET이다. 저장 응답이 있고 재검증이 필요하며 서버가 성공적으로 판단하는 조건을 가정한다. 동일한 내용의 304는 본문 없이 기존 본문/메타데이터 사용, 변경된 내용의 200은 새 본문과 정책 확인이다. miss/fresh/no-store/private와 다른 오류·ServiceWorker·stale 예외는 아래 보조 설명/별도 범위이고 이 그림에 억지로 추가하지 않는다. mobile도 같다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
저장한 응답캐시: 저장 body+ETag / 요청: If-None-Match캐시에 body와 ETag가 있지만 재검증이 필요하다stale 응답은 재사용 가능성 확인이 필요하다
대안 A · 내용 동일 304HTTP: 304 / 캐시 body: 재사용서버가 같은 표현임을 확인한다검증자가 같은 표현을 확인했으므로 새 body 없이 기존 body를 재사용한다
대안 B · 내용 변경 200HTTP: 200 새 body / 캐시: 저장 허용 시 현재 표현으로 갱신새 body와 헤더를 받고 저장 허용 정책을 확인한 뒤 캐시를 교체한다내용이 변경되어 새 body를 받으며, 이 사례는 저장이 허용되어 기존 캐시 항목을 교체한다

위 그림은 저장해 둔 응답을 다시 확인해야 할 때의 흐름입니다. 저장된 응답에 ETag가 있으면 브라우저는 If-None-Match에 그 값을 담아 조건부 GET을 보내고, 서버가 내용이 같은지 판단합니다. 같으면 304로 새 본문 없이 응답하므로, 브라우저는 기존 본문을 그대로 쓰고 헤더 같은 메타데이터만 갱신합니다. 바뀌었으면 200과 새 본문이 오므로, 새 응답의 규칙을 보고 저장할지 판단해 기존 응답을 바꿉니다.

그림 아래쪽은 다른 경우를 정리합니다. 저장된 응답이 없으면(miss) 네트워크로 요청하고, 받은 응답이 저장해도 되는 규칙이면 본문과 헤더, 신선도 정보를 저장합니다. max-age 같은 신선도 기간 안이고 다시 확인할 요구가 없으면(fresh) 저장된 응답을 그대로 씁니다. 기간이 지났거나(stale) no-cache가 다시 확인을 요구하면 서버에 확인하는데, 이것이 위의 조건부 요청입니다.

no-store는 새 응답을 저장하지 말라는 뜻일 뿐, 이미 저장된 같은 URL의 응답을 지운다는 뜻은 아닙니다. private는 브라우저 같은 개인 캐시에는 저장해도 되지만 여러 사용자가 함께 쓰는 공유 캐시에는 저장하지 말라는 뜻으로, no-store와는 다른 규칙입니다. 그림 아래의 설명처럼 내용의 해시가 바뀐 새 URL은 옛 URL의 응답을 지우는 것이 아니라 다른 캐시 항목을 요청하는 것이며, 이 그림은 일반 HTTP 요청의 기본 흐름이라 서비스 워커나 오래된 응답을 허용하는 별도 규칙은 다루지 않습니다.

더 깊이: Cache-Control이 없어도 캐시되는 이유

서버가 Cache-Control을 보내지 않으면 브라우저가 캐시하지 않을 것 같지만, 실제로는 반대인 경우가 있습니다. HTTP 캐시 명세인 RFC 9111은 응답에 명시적인 만료 시간이 없을 때 캐시가 스스로 만료 시간을 추정해도 된다고 정하며, 이를 휴리스틱 신선도(heuristic freshness)라고 합니다. 명세는 정해진 계산법을 두지 않지만, Last-Modified가 있다면 그 시각부터 지난 시간의 일부만큼만 신선하다고 보라고 권하며, 그 비율의 예로 10%를 듭니다.

예를 들어 1년 전에 마지막으로 바뀐 파일을 Cache-Control 없이 보냈다면, 브라우저는 그 응답을 약 0.1년, 곧 한 달 남짓 동안 서버에 묻지 않고 다시 쓸 수 있습니다. 그래서 파일 이름에 해시가 없는 app.js를 고쳐 배포했는데 일부 사용자가 계속 옛 파일을 받는 일이 생길 수 있습니다. 명시적인 만료 시간이 있으면 캐시는 이런 추정을 하지 않습니다.

MDN의 HTTP 캐시 설명은 휴리스틱 캐시가 Cache-Control이 널리 쓰이기 전의 임시방편이라며, 사실상 모든 응답에 Cache-Control을 명시하라고 권합니다. 따로 정할 것이 없는 응답에는 기본으로 Cache-Control: no-cache를 보내 매번 다시 확인하게 하고, 사용자마다 다른 응답이라면 private를 더합니다.


계산으로 따져 보기

전송량만 계산하는 간단한 모델을 세워 봅니다. 첫 화면의 이미지 200kB, 로고 20kB, 글꼴 80kB, 자바스크립트 400kB와 화면 밖의 갤러리 300kB를 모두 처음에 요청하면 합계는 1000kB입니다. 갤러리만 필요한 시점으로 미루면 처음에 받는 양은 200 + 20 + 80 + 400 = 700kB로 30% 줄지만, 나중에 갤러리를 보면 300kB를 다시 받습니다. 이 모델은 캐시와 압축의 차이를 빼고 계산한 것이라, 전송량이 30% 줄었다고 LCP가 30% 빨라진다는 보장은 없습니다. 첫 화면의 LCP 이미지까지 함께 미루면 오히려 LCP가 늦어질 수 있습니다.

HTTP 캐시에서는, 이미 저장된 응답의 현재 나이(age)가 600초이고 max-age=3600이며 다른 확인 요구가 없다면 신선도가 3000초 남아 있습니다. 나이가 3700초가 되면 만료되지만, 본문이 바로 사라지는 것은 아닙니다. 저장된 ETag를 조건부 GET으로 보내 304를 받으면 기존 본문을 쓰고 메타데이터를 갱신하며, 이때도 네트워크 왕복과 헤더 전송은 일어납니다.


정리

  • 에셋 최적화는 원본을 표시 크기에 맞게 바꾸고, 압축하고 나누고 해시 URL을 붙인 뒤, CDN과 HTTP 캐시로 전달하는 과정입니다.
  • 이미지는 알맞은 형식과 압축, srcset과 sizes, width와 height로 크기와 자리를 맞추고, 화면 밖 이미지만 지연 로딩하며 LCP 이미지는 미루지 않습니다.
  • CSS는 최소화와 쓰지 않는 규칙 제거, 핵심 CSS 인라인으로 그리기를 막는 시간을 줄입니다.
  • 자바스크립트는 최소화, 트리 셰이킹, 코드 분할, async와 defer, 서드 파티 정리, 긴 작업 분리로 전송과 실행 비용을 줄입니다.
  • 글꼴은 WOFF2와 부분 집합, font-display, preload로 크기와 표시 시점을 다룹니다.
  • 해시 에셋은 긴 max-age와 immutable로, HTML은 짧은 캐시나 재확인으로 다루고, ETag로 바뀌지 않은 파일은 304로 다시 씁니다.

연습 문제

1. no-cache와 새 본문

① 저장된 응답의 나이가 60초로 아직 신선한데, 응답 규칙에 인수 없는 no-cache가 있다면 서버 확인을 건너뛸 수 있을까요? ② 확인 요청에 200과 새 본문이 왔다면, 기존 본문으로 계속 보여 줘야 할까요?

해설 보기

①은 건너뛸 수 없습니다. no-cache는 저장은 허락하지만, 신선하더라도 다시 쓰기 전에 서버의 확인을 받으라는 뜻입니다.

②는 새 본문을 써야 합니다. 200은 내용이 바뀌었다는 응답이므로 새 본문을 보여 주고, 저장할지는 새 응답의 규칙으로 다시 판단합니다. 더 넓은 재사용과 검증 조건은 HTTP 캐시 명세(RFC 9111)에서 확인할 수 있습니다.

2. 오래된 HTML과 새 해시 파일

새 해시 이름의 자바스크립트 파일을 배포했는데, 어떤 사용자의 브라우저가 캐시된 오래된 HTML을 계속 쓰고 있어 옛 파일 이름을 가리킵니다. 새 파일이 저절로 선택될까요? 어떻게 해야 할까요?

해설 보기

선택되지 않습니다. 옛 URL과 새 URL은 서로 다른 캐시 항목이라서, HTML이 옛 URL을 가리키는 동안 브라우저는 옛 파일을 씁니다.

HTML이 새 URL을 가리키도록 갱신되어야 하므로, HTML은 긴 캐시를 주지 않고 짧게 캐시하거나 no-cache로 매번 다시 확인하게 합니다. 또 배포 직후 옛 HTML이 옛 파일을 찾을 수 있도록, 옛 해시 파일을 한동안 서버에 남겨 두는 것도 좋습니다.

3. 줄인 뒤의 초기 전송량

앞의 전송량 모델에서 자바스크립트를 400kB에서 250kB로 줄이고 갤러리도 필요한 시점으로 미뤘습니다. 처음에 받는 양의 합계와, 처음의 1000kB에 대한 감소율은 얼마일까요?

해설 보기

처음에 받는 양은 200 + 20 + 80 + 250 = 550kB이고, 1000kB에서 450kB가 줄었으므로 감소율은 45%입니다.

이 값은 캐시와 압축을 뺀 단순한 모델의 계산이라서, 실제 배포에서 압축된 크기나 실행 시간을 측정한 값으로 넓혀 해석하지 않습니다. 자바스크립트가 줄면 TBT가 줄 가능성이 있지만, 실제로 줄었는지는 같은 조건에서 다시 측정해 확인해야 합니다.

이것으로 13장을 마칩니다. 13장에서는 HTTPS와 XSS, CSRF 같은 웹 보안의 기초, 인증과 권한 관리, 측정을 바탕으로 한 성능 최적화의 원칙, 그리고 이미지와 CSS, 자바스크립트, 글꼴, HTTP 캐시를 다루는 에셋 최적화까지 서비스를 안전하고 빠르게 만드는 방법을 살펴봤습니다. 다음 장의 Git 기초와 협업 워크플로우부터는 Git으로 코드의 변경 이력을 관리하고 협업하는 방법을 시작으로, npm과 패키지 관리, webpack, Babel과 폴리필까지 개발 도구와 작업 흐름을 다룹니다.