웹 성능 최적화 — CRP부터 Core Web Vitals까지
웹 성능 최적화는 "왜 느린지 분석해보세요"라는 질문에서 시작해서 끝없이 파고드는 주제입니다. CRP 동작 원리부터 Core Web Vitals, 번들 최적화, 캐싱 전략까지 한 번에 정리했습니다.
Critical Rendering Path (CRP)
사용자가 URL을 입력하고 화면에 픽셀이 찍히기까지, 브라우저는 꽤 복잡한 과정을 거쳐요. 이걸 Critical Rendering Path 라고 부릅니다.
전체 흐름
HTML → DOM → ┐
├→ Render Tree → Layout → Paint → Composite
CSS → CSSOM → ┘
단계별로 뜯어볼게요.
1. HTML 파싱 → DOM 트리
브라우저는 HTML 바이트를 받아서 토큰으로 분해하고, 토큰을 노드로 변환한 뒤, 이 노드들을 트리 구조로 조립합니다. 이게 DOM(Document Object Model)이에요.
document
├── html
│ ├── head
│ │ └── title
│ └── body
│ ├── h1
│ └── p
2. CSS 파싱 → CSSOM 트리
CSS도 똑같이 바이트 → 토큰 → 노드 → 트리 과정을 거쳐 CSSOM(CSS Object Model)을 만듭니다. CSSOM은 DOM과 별개의 트리로 구성돼요.
3. Render Tree 생성
DOM과 CSSOM을 합쳐서 Render Tree를 만듭니다. 여기서 중요한 건, 화면에 보이는 노드만 Render Tree에 포함된다는 점이에요.
display: none→ Render Tree에서 제외visibility: public→ Render Tree에 포함 (공간은 차지하되 안 보임)<head>,<script>,<meta>→ 제외
4. Layout (Reflow)
Render Tree의 각 노드가 화면에서 어디에 위치하고 어떤 크기를 가지는지 계산합니다. 뷰포트 크기를 기준으로 모든 %, em, vh 같은 상대 단위가 이 단계에서 픽셀로 변환돼요.
5. Paint
Layout에서 계산된 위치와 크기에 실제 픽셀을 채웁니다. 텍스트, 색상, 그림자, 테두리 — 눈에 보이는 모든 것이 이 단계에서 그려져요.
6. Composite
레이어별로 그려진 결과물을 합성해서 최종 화면을 만듭니다. transform, opacity 같은 속성은 별도 레이어에서 처리되기 때문에, 이 단계만으로 변경사항을 반영할 수 있어요. GPU가 처리하니까 성능이 좋습니다.
핵심 포인트: "DOM, CSSOM은 독립적으로 생성되고, 둘을 결합한 Render Tree에서 보이는 노드만 포함해 Layout → Paint → Composite 순서로 진행됩니다."
렌더링 차단 리소스
CRP에서 진짜 중요한 건 뭐가 렌더링을 막느냐 입니다.
CSS는 렌더 블로킹
CSSOM이 완성되기 전까지 Render Tree를 만들 수 없습니다. 그래서 CSS는 기본적으로 렌더 블로킹 리소스 예요. 브라우저가 CSS를 다 받아서 파싱이 끝날 때까지 화면에 아무것도 안 그려집니다.
그래서 CSS는 <head>에 최대한 일찍 넣어야 해요. 반대로 불필요한 CSS가 많으면 렌더링이 늦어집니다.
<!-- 미디어 쿼리로 조건부 로딩 — 해당 조건이 아니면 렌더 블로킹하지 않음 -->
<link rel="stylesheet" href="print.css" media="print">
<link rel="stylesheet" href="portrait.css" media="(orientation: portrait)">
JS는 파서 블로킹
브라우저가 HTML을 파싱하다가 <script> 태그를 만나면, DOM 파싱을 멈추고 스크립트를 다운로드 + 실행합니다. JS가 DOM을 조작할 수 있기 때문에, 파싱을 계속하면 스크립트 실행 결과와 충돌할 수 있어서예요.
<!-- 파서 블로킹 (기본) -->
<script src="app.js"></script>
<!-- async: 다운로드는 병렬, 실행 시점에만 파싱 중단 -->
<script src="analytics.js" async></script>
<!-- defer: 다운로드는 병렬, DOM 파싱 완료 후 실행 -->
<script src="app.js" defer></script>
| 속성 | 다운로드 | 실행 시점 | 실행 순서 보장 |
|---|---|---|---|
| 없음 | 파싱 중단 후 다운로드 | 즉시 | O |
async | 파싱과 병렬 | 다운로드 완료 즉시 | X |
defer | 파싱과 병렬 | DOM 파싱 완료 후 (DOMContentLoaded 전) | O |
defer가 대부분의 경우에 정답이에요. DOM에 접근해야 하는 앱 로직은 defer, 독립적인 분석 스크립트는 async입니다.
핵심 포인트: "CSS는 CSSOM 완성 전까지 렌더를 막고, JS는 DOM 파싱을 멈추기 때문에, defer/async로 블로킹을 회피합니다."
Core Web Vitals
구글이 2020년에 발표한 사용자 경험 측정 지표입니다. SEO 랭킹에도 반영되기 때문에 실제로도 중요하고, 자주 헷갈리는 부분이에요.
LCP (Largest Contentful Paint)
뷰포트 안에서 가장 큰 콘텐츠 요소 가 렌더링되기까지의 시간이에요. 사용자가 "아, 페이지가 로딩됐네"라고 느끼는 순간이 바로 LCP입니다.
- 목표: 2.5초 이내
- 대상 요소:
<img>,<video>포스터, CSSbackground-image, 큰 텍스트 블록
개선 방법:
- 서버 응답 시간(TTFB) 줄이기
- 렌더 블로킹 리소스 제거
- LCP 이미지에
fetchpriority="high"+preload적용 - CDN 사용
- 이미지 최적화 (WebP/AVIF, 적절한 크기)
FID → INP
FID (First Input Delay) — 사용자가 처음으로 상호작용(클릭, 탭, 키 입력)했을 때, 브라우저가 실제로 이벤트 핸들러를 실행하기까지의 지연 시간이에요. 메인 스레드가 바쁘면 입력에 대한 반응이 늦어집니다.
2024년 3월부터는 FID 대신 INP (Interaction to Next Paint) 가 Core Web Vitals에 포함됐어요. FID는 "첫 번째 인터랙션"만 측정했지만, INP는 페이지 수명 전체에서 가장 느린 인터랙션 을 측정합니다. 더 현실적인 지표인 셈이에요.
- 목표: 200ms 이내
개선 방법:
- Long Task 분할 (50ms 넘는 작업 쪼개기)
requestIdleCallback또는scheduler.yield()활용- 불필요한 JS 줄이기
- 메인 스레드를 막는 서드파티 스크립트 지연 로딩
CLS (Cumulative Layout Shift)
페이지 로딩 중에 레이아웃이 갑자기 밀리는 현상을 수치화한 거예요. 광고가 로드되면서 기사 내용이 확 밀려내려가는 경험, 다들 해보셨을 겁니다.
- 목표: 0.1 이하
개선 방법:
- 이미지에
width,height명시하거나aspect-ratio사용 - 광고 슬롯에 고정 크기 컨테이너 확보
- 웹폰트 로딩 시
font-display: swap+size-adjust사용 - 동적으로 삽입되는 콘텐츠를 기존 콘텐츠 위에 추가하지 않기
transform애니메이션 사용 (Layout을 트리거하지 않으므로 CLS에 영향 없음)
핵심 포인트: "LCP는 로딩 체감 속도, INP는 인터랙션 반응성, CLS는 시각적 안정성을 측정합니다. 셋 다 사용자 경험을 정량화한 지표입니다."
번들 최적화
SPA 시대에 번들 크기는 곧 성능입니다. 안 쓰는 코드를 잘라내고, 필요한 코드만 필요한 시점에 로드하는 게 핵심이에요.
Tree Shaking
사용하지 않는 코드(dead code)를 번들에서 제거하는 기법이에요. Webpack, Rollup, esbuild 등 대부분의 모던 번들러가 지원합니다.
ES Module의 import/export가 정적으로 분석 가능하기 때문에 가능한 기법이에요. CommonJS의 require()는 동적이라서 Tree Shaking이 안 됩니다.
// math.js
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
// app.js
import { add } from './math.js';
// multiply는 사용하지 않으므로 번들에서 제거됨
주의할 점 — sideEffects: false를 package.json에 명시하지 않으면, 번들러가 "이 모듈이 import만으로 사이드 이펙트를 일으킬 수도 있다"고 판단해서 제거를 못 합니다.
Code Splitting
하나의 거대한 번들을 여러 개의 작은 청크(chunk)로 나누는 기법이에요. 초기 로딩에 필요한 코드만 먼저 로드하고, 나머지는 나중에 가져옵니다.
Webpack은 entry point, SplitChunksPlugin, Dynamic Import 세 가지 방법으로 코드 스플릿을 지원해요.
Dynamic Import
// 정적 import — 무조건 번들에 포함
import { HeavyChart } from './HeavyChart';
// 동적 import — 필요한 시점에 로드
const module = await import('./HeavyChart');
React에서는 React.lazy로 컴포넌트 수준의 코드 스플릿을 쉽게 할 수 있어요.
const HeavyChart = React.lazy(() => import('./HeavyChart'));
function Dashboard() {
return (
<Suspense fallback={<Spinner />}>
<HeavyChart />
</Suspense>
);
}
라우트 기반 코드 스플릿이 가장 흔합니다. 페이지 단위로 청크를 나누면 사용자가 방문하지 않는 페이지의 코드를 다운로드할 필요가 없어져요.
핵심 포인트: "Tree Shaking은 ESM 정적 분석 기반으로 미사용 코드를 제거하고, Code Splitting은 청크를 분리해서 초기 번들 크기를 줄입니다."
이미지 최적화
웹 페이지 전체 용량에서 이미지가 차지하는 비중이 보통 50%를 넘습니다. 이미지 최적화만 제대로 해도 체감 성능이 확 달라져요.
차세대 포맷 — WebP, AVIF
| 포맷 | 압축률 | 브라우저 지원 | 특징 |
|---|---|---|---|
| JPEG | 기준 | 전체 | 손실 압축, 사진에 적합 |
| PNG | 낮음 | 전체 | 무손실, 투명도 지원 |
| WebP | JPEG 대비 25~35% 작음 | 95%+ | 손실/무손실 모두 가능 |
| AVIF | WebP 대비 20% 더 작음 | 90%+ | 최신 포맷, 인코딩 느림 |
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg" alt="히어로 이미지">
</picture>
<picture> 태그로 감싸면 브라우저가 지원하는 가장 효율적인 포맷을 자동 선택합니다.
Lazy Loading
뷰포트에 들어올 때까지 이미지 로딩을 지연시킵니다.
<img src="photo.webp" alt="..." loading="lazy">
네이티브 loading="lazy"는 대부분의 모던 브라우저에서 지원해요. 스크롤해서 이미지가 뷰포트에 가까워지면 그때 로드를 시작합니다.
주의 — LCP 대상이 되는 이미지에는 loading="lazy"를 쓰면 안 됩니다. 히어로 이미지는 오히려 빨리 로드해야 하니까요.
srcset / sizes — 반응형 이미지
디바이스 해상도와 뷰포트 크기에 맞는 이미지를 제공합니다.
<img
srcset="photo-320w.webp 320w,
photo-640w.webp 640w,
photo-1024w.webp 1024w"
sizes="(max-width: 600px) 100vw,
(max-width: 1200px) 50vw,
33vw"
src="photo-640w.webp"
alt="반응형 이미지"
>
모바일에서 1024px짜리 이미지를 받을 이유가 없어요. srcset과 sizes를 쓰면 브라우저가 알아서 적절한 크기를 골라줍니다.
이미지 CDN
Cloudinary, imgix, Cloudflare Image 같은 이미지 CDN을 쓰면 URL 파라미터로 리사이징, 포맷 변환, 품질 조절까지 서버 사이드에서 처리할 수 있어요. 원본 하나만 올려놓으면 다양한 변형을 자동 생성해주니까, 빌드 타임에 이미지를 일일이 변환하는 것보다 훨씬 편합니다.
캐싱 전략
같은 리소스를 매번 새로 받아오는 건 낭비예요. 캐싱을 잘 써야 재방문 시 빠른 로딩을 보장할 수 있습니다.
Cache-Control
Cache-Control: max-age=31536000, immutable
max-age: 초 단위 캐시 유효 기간. 31536000이면 1년immutable: 유효 기간 내에는 재검증 요청도 보내지 않음no-cache: 캐시는 하되, 매번 서버에 재검증 요청no-store: 아예 캐시하지 않음
실제로 자주 쓰는 패턴은 이래요.
# HTML — 매번 서버 확인 (콘텐츠가 바뀔 수 있으니까)
Cache-Control: no-cache
# JS/CSS (해시 포함 파일명) — 장기 캐싱
Cache-Control: max-age=31536000, immutable
파일명에 해시가 들어있으면(app.a3b4c5.js), 내용이 바뀌면 파일명 자체가 바뀌니까 immutable을 써도 괜찮아요. HTML만 no-cache로 두면 새 번들 파일명을 항상 참조할 수 있습니다.
ETag
서버가 리소스의 고유 식별자를 응답에 포함시키고, 다음 요청 때 클라이언트가 그 값을 보내서 "변경됐나요?"를 확인하는 방식이에요.
# 첫 응답
HTTP/1.1 200 OK
ETag: "abc123"
# 재요청
GET /style.css
If-None-Match: "abc123"
# 변경 없으면 → 304 Not Modified (본문 없이 응답)
# 변경 있으면 → 200 + 새 리소스
304 응답은 본문이 없으니까 네트워크 비용이 거의 없어요. Last-Modified + If-Modified-Since 조합도 비슷한 원리인데, ETag가 더 정확합니다.
Service Worker 캐시
Service Worker는 브라우저와 네트워크 사이에 위치하는 프록시 같은 존재예요. 네트워크 요청을 가로채서 캐시된 리소스를 바로 반환할 수 있습니다.
// Cache First 전략
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
const clone = response.clone();
caches.open('v1').then((cache) => cache.put(event.request, clone));
return response;
});
})
);
});
캐싱 전략 종류:
| 전략 | 동작 | 적합한 리소스 |
|---|---|---|
| Cache First | 캐시 먼저, 없으면 네트워크 | 정적 자산 (이미지, 폰트) |
| Network First | 네트워크 먼저, 실패하면 캐시 | API 응답, 뉴스 |
| Stale While Revalidate | 캐시 반환 + 백그라운드에서 갱신 | 자주 변경되지만 약간의 지연은 허용 |
| Network Only | 항상 네트워크 | 실시간 데이터 |
| Cache Only | 항상 캐시 | 오프라인 전용 |
핵심 포인트: "정적 자산은 해시 파일명 + immutable 장기 캐싱, HTML은 no-cache, API는 Stale While Revalidate 조합이 실제로 흔한 패턴입니다."
프리로딩 / 프리페칭
브라우저에게 "이 리소스 곧 필요하니까 미리 준비해"라고 힌트를 줄 수 있어요.
preload
현재 페이지에서 곧 필요한 리소스를 미리 로드합니다. 우선순위가 높아요.
<!-- 히어로 이미지 미리 로드 -->
<link rel="preload" href="hero.webp" as="image">
<!-- 폰트 미리 로드 -->
<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>
as 속성을 반드시 지정해야 합니다. 안 그러면 브라우저가 우선순위를 제대로 못 잡아서 오히려 두 번 다운로드할 수도 있어요.
prefetch
다음 페이지 에서 필요할 수 있는 리소스를 유휴 시간에 미리 가져옵니다. 현재 페이지 로딩에는 영향을 주지 않아요.
<link rel="prefetch" href="/about/page-data.json">
라우트 기반 코드 스플릿과 함께 쓰면 효과적이에요. 현재 페이지를 다 로딩한 뒤, 사용자가 이동할 가능성이 높은 페이지의 청크를 미리 받아놓는 거죠.
preconnect
리소스를 가져오기 전에 DNS 조회 + TCP 연결 + TLS 핸드셰이크를 미리 수행합니다.
<!-- 서드파티 도메인에 미리 연결 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
외부 도메인에서 리소스를 가져올 때 연결 수립에 걸리는 시간을 절약할 수 있어요. 단, 너무 많은 도메인에 preconnect를 걸면 오히려 리소스 낭비가 되니까 핵심 도메인 2~3개만 쓰는 게 좋습니다.
| 힌트 | 타이밍 | 용도 |
|---|---|---|
preload | 현재 페이지, 높은 우선순위 | 히어로 이미지, 폰트, 크리티컬 CSS |
prefetch | 유휴 시간, 낮은 우선순위 | 다음 페이지 리소스 |
preconnect | 빠른 연결 수립 | 외부 도메인 |
dns-prefetch | DNS 조회만 미리 | preconnect보다 가벼운 대안 |
SSR vs CSR vs SSG vs ISR
렌더링 전략 선택은 성능에 직접적인 영향을 미칩니다.
CSR (Client-Side Rendering)
빈 HTML + JS 번들을 내려보내고, 브라우저에서 렌더링합니다. CRA(Create React App)의 기본 방식이에요.
- 장점: 서버 부하 낮음, SPA 전환이 매끄러움
- 단점: 초기 로딩 느림 (빈 화면 → JS 다운로드 → 렌더링), SEO 불리
SSR (Server-Side Rendering)
서버에서 HTML을 완성해서 내려보냅니다. 클라이언트에서는 이벤트 핸들러를 붙이는 Hydration 만 수행해요.
- 장점: 빠른 FCP/LCP, SEO 유리
- 단점: 서버 부하 증가, TTFB 길어질 수 있음
SSG (Static Site Generation)
빌드 타임에 HTML을 미리 생성해놓습니다. 요청 시 서버에서 렌더링할 필요가 없으니 가장 빨라요.
- 장점: 최고의 성능, CDN 캐싱에 최적
- 단점: 빌드 후 내용 변경 불가, 페이지 수 많으면 빌드 시간 증가
ISR (Incremental Static Regeneration)
Next.js에서 도입한 방식이에요. SSG처럼 정적 페이지를 제공하되, 설정한 시간이 지나면 백그라운드에서 재생성 합니다.
// Next.js
export async function getStaticProps() {
const data = await fetchData();
return {
props: { data },
revalidate: 60, // 60초마다 재생성
};
}
- 장점: SSG의 성능 + 데이터 최신성
- 단점: Next.js 종속적 (유사 구현은 다른 프레임워크에도 있음)
| 전략 | FCP/LCP | SEO | 데이터 최신성 | 서버 부하 |
|---|---|---|---|---|
| CSR | 느림 | 나쁨 | 실시간 | 낮음 |
| SSR | 빠름 | 좋음 | 실시간 | 높음 |
| SSG | 가장 빠름 | 좋음 | 빌드 시점 | 없음 |
| ISR | 빠름 | 좋음 | 주기적 | 낮음 |
핵심 포인트: 어떤 전략이 "최고"라는 건 없습니다. 프로젝트 요구사항에 따라 다릅니다. 블로그 → SSG/ISR, 대시보드 → CSR, 이커머스 상품 페이지 → SSR/ISR.
Lighthouse 활용법
Chrome DevTools에 내장된 성능 측정 도구입니다. 성능, 접근성, SEO, Best Practices를 점수로 보여줘요.
사용 방법:
- DevTools → Lighthouse 탭
- 측정 카테고리 선택 (Performance, Accessibility 등)
- "Analyze page load" 클릭
주의사항:
- 시크릿 모드 에서 측정해야 합니다. 확장 프로그램이 결과에 영향을 주기 때문이에요.
- 로컬 환경과 실제 배포 환경의 점수는 달라요. 실제 사용자 데이터는 PageSpeed Insights 나 Chrome UX Report (CrUX) 에서 확인할 수 있습니다.
- Lighthouse 점수는 Lab Data(시뮬레이션)이고, CrUX는 Field Data(실제 사용자)예요.
주요 진단 항목:
- Render-blocking resources 제거
- 사용하지 않는 CSS/JS 제거
- 이미지 최적화
- 텍스트 압축 여부 (gzip / brotli)
- 효율적인 캐시 정책 사용 여부
CLI에서도 쓸 수 있어요.
npx lighthouse https://example.com --output html --output-path report.html
CI에 넣어서 PR마다 성능 회귀를 체크하는 팀도 많습니다.
주의할 점
TTFB (Time To First Byte) 개선
TTFB는 요청을 보내고 첫 번째 바이트를 받기까지의 시간이에요. 서버 처리 속도 + 네트워크 지연이 합쳐진 값입니다.
- CDN 사용 (사용자와 가까운 서버에서 응답)
- 서버 사이드 렌더링 최적화 (DB 쿼리 최적화, 캐싱)
- HTTP/2 이상 사용
- Early Hints (
103상태 코드로 preload 힌트를 먼저 전송) - 서버 사이드 캐싱 (Redis, Varnish)
폰트 최적화 — FOUT vs FOIT
웹폰트를 로드하는 동안 텍스트가 어떻게 보이는지에 대한 두 가지 현상이에요.
- FOIT (Flash of Invisible Text): 폰트가 로드될 때까지 텍스트가 안 보입니다. Safari의 기본 동작이에요.
- FOUT (Flash of Unstyled Text): 시스템 폰트로 먼저 보여주다가 웹폰트 로드 후 교체합니다. Chrome/Firefox의 기본 동작이에요.
@font-face {
font-family: 'CustomFont';
src: url('custom.woff2') format('woff2');
font-display: swap; /* FOUT 방식 — 텍스트를 빨리 보여줌 */
}
font-display 값 정리:
| 값 | 블록 기간 | 스왑 기간 | 설명 |
|---|---|---|---|
auto | 브라우저 기본 | - | 브라우저에 맡김 |
block | 3초 | 무한 | FOIT 동작 |
swap | 매우 짧음 | 무한 | FOUT 동작 — 텍스트 빠르게 표시 |
fallback | 100ms | 3초 | 절충안 |
optional | 100ms | 없음 | 폰트 못 받으면 포기 |
추가로, size-adjust 속성으로 폴백 폰트와 웹폰트의 크기를 맞추면 CLS를 줄일 수 있어요.
@font-face {
font-family: 'Adjusted Fallback';
src: local('Arial');
size-adjust: 105%;
ascent-override: 95%;
}
HTTP/2 멀티플렉싱과 번들링
HTTP/1.1에서는 도메인당 동시 연결 수가 6개로 제한됐기 때문에, 요청 수를 줄이기 위해 파일을 합치는(번들링) 것이 중요했어요. CSS 스프라이트, JS concat 같은 기법이 다 이 이유 때문에 나왔습니다.
HTTP/2는 멀티플렉싱 을 지원해요. 하나의 TCP 연결에서 여러 요청/응답을 병렬로 주고받을 수 있습니다. 그래서 이론적으로는 "번들링이 필요 없다"는 주장도 있었어요.
하지만 실제로는 HTTP/2 환경에서도 번들링은 여전히 유효합니다.
- 압축 효율: 여러 작은 파일보다 하나의 큰 파일이 gzip/brotli 압축률이 높아요
- Tree Shaking: 번들러를 통해야 미사용 코드 제거가 가능합니다
- 요청 오버헤드: 아무리 멀티플렉싱이라도 수백 개의 개별 요청은 헤더 오버헤드가 쌓여요
결론은 적절한 수준의 코드 스플릿 + 번들링 이 정답입니다. 수백 개의 개별 모듈을 그대로 서빙하는 것도, 하나의 거대 번들로 합치는 것도 비효율적이에요.
핵심 포인트: "HTTP/2 멀티플렉싱이 동시 요청 제한을 해결했지만, 압축 효율과 Tree Shaking 때문에 번들링은 여전히 필요합니다. 다만 과도한 번들링 대신 적절한 코드 스플릿이 병행되어야 합니다."
파생 개념
| 개념 | 링크 |
|---|---|
| 브라우저 렌더링 | HTML — 시맨틱 태그, 접근성, 브라우저 렌더링까지 |
| React 성능 최적화 | React 심화 — Hooks 동작 원리, 상태 관리, 성능 최적화 |
| CDN (Content Delivery Network) | 전 세계 엣지 서버에 콘텐츠를 캐싱해서 사용자와 가까운 곳에서 서빙. TTFB와 대역폭 비용 절감 |
| PWA (Progressive Web App) | Service Worker + Web App Manifest + HTTPS 기반으로 네이티브 앱 수준의 경험 제공. 오프라인 캐시, 푸시 알림 지원 |