Skip to content

필사 모드: 웹 성능 최적화 완전 가이드 2025: Core Web Vitals, LCP/INP/CLS, 로딩 전략

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

목차

1. 웹 성능이 중요한 이유

웹 성능은 사용자 경험, 비즈니스 성과, SEO 순위에 직접적으로 영향을 미칩니다. Google 연구에 따르면 페이지 로드 시간이 1초에서 3초로 늘어나면 이탈률이 32% 증가하고, 5초로 늘어나면 90% 증가합니다.

1.1 성능이 비즈니스에 미치는 영향

| 지표 | 개선 효과 |

|------|-----------|

| 로드 시간 0.1초 단축 | 전환율 8% 증가 (Walmart) |

| 로드 시간 50% 단축 | 매출 12% 증가 (AutoAnything) |

| 로드 시간 2.2초 단축 | 다운로드 15.4% 증가 (Mozilla) |

| 성능 점수 10점 향상 | 이탈률 5-10% 감소 |

1.2 Google의 페이지 경험 시그널

2021년부터 Google은 Core Web Vitals를 검색 순위 요소에 포함했습니다. 이는 모바일과 데스크톱 검색 모두에 적용됩니다.

페이지 경험 시그널 구성:

├── Core Web Vitals (LCP, INP, CLS)

├── HTTPS 보안

├── 모바일 친화성

├── 침입적 광고 없음

└── Safe Browsing

2. Core Web Vitals 완전 정복

Core Web Vitals는 Google이 정의한 세 가지 핵심 사용자 경험 지표입니다. 2024년 3월부터 INP(Interaction to Next Paint)가 FID를 대체했습니다.

2.1 LCP (Largest Contentful Paint)

LCP는 뷰포트에서 가장 큰 콘텐츠 요소가 렌더링되는 시간을 측정합니다.

**임계값:**

- Good: 2.5초 이하

- Needs Improvement: 2.5초 ~ 4.0초

- Poor: 4.0초 초과

**LCP 대상 요소:**

- `img` 요소

- `svg` 내부의 `image` 요소

- `video` 요소의 poster 이미지

- CSS `background-image`가 있는 요소

- 텍스트 노드를 포함하는 블록 레벨 요소

// LCP 측정 코드

new PerformanceObserver((entryList) => {

const entries = entryList.getEntries();

const lastEntry = entries[entries.length - 1];

console.log('LCP:', lastEntry.startTime);

console.log('LCP Element:', lastEntry.element);

console.log('LCP URL:', lastEntry.url);

console.log('LCP Size:', lastEntry.size);

}).observe({ type: 'largest-contentful-paint', buffered: true });

**LCP 최적화 전략:**

// 1. 히어로 이미지에 fetchpriority="high" 추가

src="/hero-image.webp"

alt="Hero"

fetchpriority="high"

width={1200}

height={600}

/>

// 2. Preload로 LCP 리소스 먼저 로드

rel="preload"

as="image"

href="/hero-image.webp"

fetchpriority="high"

/>

// 3. Next.js Image 컴포넌트 활용

export default function Hero() {

return (

src="/hero.webp"

alt="Hero"

width={1200}

height={600}

priority // LCP 이미지에 priority 추가

placeholder="blur"

blurDataURL="data:image/jpeg;base64,..."

/>

);

}

2.2 INP (Interaction to Next Paint)

INP는 사용자 상호작용(클릭, 탭, 키 입력)부터 다음 프레임이 렌더링될 때까지의 시간을 측정합니다. FID와 달리 전체 세션의 모든 상호작용을 고려합니다.

**임계값:**

- Good: 200ms 이하

- Needs Improvement: 200ms ~ 500ms

- Poor: 500ms 초과

// INP 측정

new PerformanceObserver((entryList) => {

for (const entry of entryList.getEntries()) {

if (entry.interactionId) {

const duration = entry.duration;

const inputDelay = entry.processingStart - entry.startTime;

const processingTime = entry.processingEnd - entry.processingStart;

const presentationDelay = entry.startTime + entry.duration - entry.processingEnd;

console.log('INP Breakdown:', {

duration,

inputDelay, // 입력 지연

processingTime, // 처리 시간

presentationDelay // 렌더링 지연

});

}

}

}).observe({ type: 'event', buffered: true, durationThreshold: 16 });

**INP 최적화 전략:**

// 1. 긴 작업을 yield로 분리

async function processLargeList(items) {

for (let i = 0; i < items.length; i++) {

processItem(items[i]);

// 매 100개마다 메인 스레드에 양보

if (i % 100 === 0) {

await scheduler.yield(); // Scheduler API

// 또는 fallback:

// await new Promise(resolve => setTimeout(resolve, 0));

}

}

}

// 2. React에서 useTransition으로 우선순위 분리

function SearchResults() {

const [query, setQuery] = useState('');

const [isPending, startTransition] = useTransition();

function handleChange(e) {

// 입력은 즉시 반영 (긴급 업데이트)

setQuery(e.target.value);

// 검색 결과는 낮은 우선순위 (전환 업데이트)

startTransition(() => {

setSearchResults(filterResults(e.target.value));

});

}

return (

{isPending ? <Spinner /> : <ResultsList />}

);

}

// 3. requestIdleCallback으로 비필수 작업 지연

function deferAnalytics(data) {

if ('requestIdleCallback' in window) {

requestIdleCallback(() => {

sendAnalytics(data);

}, { timeout: 2000 });

} else {

setTimeout(() => sendAnalytics(data), 100);

}

}

2.3 CLS (Cumulative Layout Shift)

CLS는 페이지 수명 동안 발생하는 예상치 못한 레이아웃 이동의 누적 점수를 측정합니다.

**임계값:**

- Good: 0.1 이하

- Needs Improvement: 0.1 ~ 0.25

- Poor: 0.25 초과

// CLS 측정

let clsValue = 0;

let clsEntries = [];

let sessionValue = 0;

let sessionEntries = [];

new PerformanceObserver((entryList) => {

for (const entry of entryList.getEntries()) {

// 사용자 입력 후 500ms 이내의 이동은 제외

if (!entry.hadRecentInput) {

const firstSessionEntry = sessionEntries[0];

const lastSessionEntry = sessionEntries[sessionEntries.length - 1];

// 세션 윈도우: 1초 이내, 5초 한도

if (

sessionValue &&

entry.startTime - lastSessionEntry.startTime < 1000 &&

entry.startTime - firstSessionEntry.startTime < 5000

) {

sessionValue += entry.value;

sessionEntries.push(entry);

} else {

sessionValue = entry.value;

sessionEntries = [entry];

}

if (sessionValue > clsValue) {

clsValue = sessionValue;

clsEntries = [...sessionEntries];

}

}

}

}).observe({ type: 'layout-shift', buffered: true });

**CLS 최적화 전략:**

/* 1. 이미지/비디오에 항상 크기 지정 */

img, video {

width: 100%;

height: auto;

aspect-ratio: 16 / 9; /* CSS aspect-ratio 활용 */

}

/* 2. 광고/임베드 영역 사전 확보 */

.ad-slot {

min-height: 250px;

contain: layout; /* CSS Containment */

}

/* 3. 폰트 로드 시 레이아웃 이동 방지 */

@font-face {

font-family: 'CustomFont';

src: url('/fonts/custom.woff2') format('woff2');

font-display: optional; /* CLS를 위해 optional 사용 */

size-adjust: 100.5%; /* 폴백 폰트와 크기 맞춤 */

ascent-override: 95%;

descent-override: 22%;

line-gap-override: 0%;

}

/* 4. 동적 콘텐츠에 contain 속성 */

.dynamic-content {

contain: layout style;

content-visibility: auto;

contain-intrinsic-size: 0 500px;

}

3. 이미지 최적화

이미지는 평균적으로 웹 페이지 바이트의 50% 이상을 차지합니다. 이미지 최적화는 성능 향상의 가장 효과적인 방법입니다.

3.1 차세대 이미지 포맷

포맷별 비교 (동일 품질 기준):

┌─────────┬────────┬─────────┬──────────┬────────────┐

│ 포맷 │ 압축률 │ 투명도 │ 애니메이션│ 브라우저 │

├─────────┼────────┼─────────┼──────────┼────────────┤

│ JPEG │ 기준 │ X │ X │ 100% │

│ PNG │ 낮음 │ O │ X │ 100% │

│ WebP │ 25-34% │ O │ O │ 97%+ │

│ AVIF │ 50%+ │ O │ O │ 92%+ │

│ JPEG XL │ 35-60% │ O │ O │ 제한적 │

└─────────┴────────┴─────────┴──────────┴────────────┘

<!-- picture 요소로 포맷 폴백 -->

3.2 반응형 이미지

<!-- srcset과 sizes로 적절한 크기 제공 -->

srcset="

/image-400w.webp 400w,

/image-800w.webp 800w,

/image-1200w.webp 1200w,

/image-1600w.webp 1600w

"

sizes="

(max-width: 640px) 100vw,

(max-width: 1024px) 50vw,

33vw

"

src="/image-800w.webp"

alt="Responsive image"

loading="lazy"

decoding="async"

width="800"

height="600"

/>

3.3 Lazy Loading과 Priority Hints

// Native lazy loading

// Intersection Observer를 이용한 커스텀 lazy loading

const imageObserver = new IntersectionObserver(

(entries) => {

entries.forEach((entry) => {

if (entry.isIntersecting) {

const img = entry.target;

img.src = img.dataset.src;

img.srcset = img.dataset.srcset || '';

img.classList.add('loaded');

imageObserver.unobserve(img);

}

});

},

{

rootMargin: '200px 0px', // 200px 전에 로드 시작

threshold: 0.01,

}

);

document.querySelectorAll('img[data-src]').forEach((img) => {

imageObserver.observe(img);

});

3.4 Blur Placeholder 구현

// Next.js에서 blur placeholder

export async function getStaticProps() {

const { base64 } = await getPlaiceholder('/public/hero.jpg');

return {

props: { blurDataURL: base64 },

};

}

export default function Page({ blurDataURL }) {

return (

src="/hero.jpg"

alt="Hero"

width={1200}

height={600}

placeholder="blur"

blurDataURL={blurDataURL}

/>

);

}

// CSS로 blur 효과 직접 구현

const BlurImage = ({ src, alt }) => {

const [loaded, setLoaded] = useState(false);

return (

{!loaded && (

style={{

position: 'absolute',

inset: 0,

backgroundImage: `url(${src}?w=20&q=10)`,

backgroundSize: 'cover',

filter: 'blur(20px)',

transform: 'scale(1.1)',

}}

/>

)}

src={src}

alt={alt}

onLoad={() => setLoaded(true)}

style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}

/>

);

};

4. JavaScript 최적화

4.1 Tree Shaking

Tree Shaking은 사용하지 않는 코드를 번들에서 제거하는 최적화 기법입니다.

// package.json - sideEffects 설정

{

"name": "my-library",

"sideEffects": false,

// 또는 사이드 이펙트가 있는 파일만 지정

"sideEffects": ["*.css", "*.scss", "./src/polyfills.js"]

}

// Bad: 전체 라이브러리 임포트 (tree shaking 불가)

const result = _.map(data, fn);

// Good: 개별 함수만 임포트

const result = map(data, fn);

// Best: lodash-es 사용 (ES Module)

const result = map(data, fn);

// webpack.config.js - Tree Shaking 최적화

module.exports = {

mode: 'production',

optimization: {

usedExports: true, // 사용된 export만 마킹

minimize: true, // 미사용 코드 제거

sideEffects: true, // sideEffects 플래그 활용

concatenateModules: true, // 모듈 연결 (Scope Hoisting)

},

};

4.2 Code Splitting

// 1. 동적 import로 코드 분할

const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

function App() {

return (

);

}

// 2. 라우트 기반 코드 분할 (Next.js)

const DashboardChart = dynamic(

() => import('@/components/DashboardChart'),

{

loading: () => <ChartSkeleton />,

ssr: false, // 클라이언트에서만 로드

}

);

// 3. webpack magic comments로 청크 제어

const AdminPanel = React.lazy(

() => import(

/* webpackChunkName: "admin" */

/* webpackPrefetch: true */

'./AdminPanel'

)

);

// 4. Named Export 코드 분할

const MyComponent = React.lazy(() =>

import('./MyModule').then((module) => ({

default: module.MyComponent,

}))

);

4.3 번들 분석

// next.config.js - webpack-bundle-analyzer 설정

const withBundleAnalyzer = require('@next/bundle-analyzer')({

enabled: process.env.ANALYZE === 'true',

});

module.exports = withBundleAnalyzer({

// Next.js config

});

// 실행: ANALYZE=true next build

번들 크기 모니터링 도구들

1. bundlephobia - 패키지 크기 확인

npx bundlephobia lodash

2. source-map-explorer

npx source-map-explorer dist/main.*.js

3. webpack-bundle-analyzer

npx webpack-bundle-analyzer dist/stats.json

4. size-limit - CI에서 크기 제한

npx size-limit

// .size-limit.json - 번들 크기 제한 설정

[

{

"path": "dist/index.js",

"limit": "50 KB",

"import": "{ Button }",

"ignore": ["react", "react-dom"]

},

{

"path": "dist/index.js",

"limit": "100 KB"

}

]

5. CSS 최적화

5.1 Critical CSS 추출

// critters 플러그인으로 Critical CSS 인라인

// next.config.js

module.exports = {

experimental: {

optimizeCss: true, // Next.js 내장 CSS 최적화

},

};

// 수동 Critical CSS 추출

const critical = require('critical');

critical.generate({

inline: true,

base: 'dist/',

src: 'index.html',

target: 'index-critical.html',

width: 1300,

height: 900,

penthouse: {

blockJSRequests: false,

},

});

5.2 사용하지 않는 CSS 제거

// PurgeCSS 설정

// postcss.config.js

module.exports = {

plugins: [

require('@fullhuman/postcss-purgecss')({

content: [

'./src/**/*.{js,jsx,ts,tsx}',

'./public/index.html',

],

defaultExtractor: (content) =>

content.match(/[\w-/:]+(?<!:)/g) || [],

safelist: {

standard: [/^modal-/, /^tooltip-/],

deep: [/^data-theme/],

greedy: [/animate/],

},

}),

],

};

5.3 CSS Containment

/* contain 속성으로 렌더링 범위 제한 */

.card {

contain: layout style paint;

/* layout: 레이아웃 격리 */

/* style: 카운터/quotes 격리 */

/* paint: 페인트 격리 (overflow: hidden 효과) */

}

/* content-visibility로 화면 밖 렌더링 건너뛰기 */

.article-section {

content-visibility: auto;

contain-intrinsic-size: 0 500px; /* 예상 크기 힌트 */

}

/* 긴 목록의 각 아이템에 적용 */

.list-item {

content-visibility: auto;

contain-intrinsic-size: auto 80px;

}

6. 폰트 최적화

6.1 font-display 전략

/* font-display 옵션 비교 */

@font-face {

font-family: 'MyFont';

src: url('/fonts/myfont.woff2') format('woff2');

/* swap: FOUT 허용, CLS 발생 가능 */

font-display: swap;

/* optional: FOIT 3초 후 시스템 폰트 유지 (CLS 없음) */

font-display: optional;

/* fallback: 100ms FOIT 후 swap, 3초 후 유지 */

font-display: fallback;

}

6.2 폰트 프리로드와 Variable Fonts

<!-- 폰트 프리로드 -->

rel="preload"

href="/fonts/inter-var.woff2"

as="font"

type="font/woff2"

crossorigin="anonymous"

/>

/* Variable Font으로 파일 수 줄이기 */

@font-face {

font-family: 'Inter';

src: url('/fonts/inter-var.woff2') format('woff2-variations');

font-weight: 100 900; /* 가변 weight */

font-style: normal;

font-display: optional;

}

/* subset으로 필요한 문자만 포함 */

@font-face {

font-family: 'NotoSansKR';

src: url('/fonts/noto-sans-kr-subset.woff2') format('woff2');

unicode-range: U+AC00-D7A3; /* 한글 완성형만 */

font-display: swap;

}

// Next.js - next/font 최적화

const inter = Inter({

subsets: ['latin'],

display: 'optional',

preload: true,

variable: '--font-inter',

adjustFontFallback: true, // CLS 방지를 위한 폴백 조정

});

export default function RootLayout({ children }) {

return (

);

}

7. 캐싱 전략

7.1 HTTP Cache Headers

캐시 전략 흐름도:

┌─────────────────────────────────────────┐

│ 리소스가 재사용 가능한가? │

├── No → Cache-Control: no-store │

├── Yes → 매번 서버 확인이 필요한가? │

│ ├── Yes → Cache-Control: no-cache │

│ └── No → 중간 캐시 허용? │

│ ├── Yes → Cache-Control: public │

│ └── No → Cache-Control: private │

│ └── max-age 설정 │

│ ├── 해시된 파일 → 31536000│

│ └── HTML → 0 + ETag │

└─────────────────────────────────────────┘

Nginx 캐시 설정 예시

server {

HTML - 항상 서버 확인

location ~* \.html$ {

add_header Cache-Control "no-cache";

add_header ETag $upstream_http_etag;

}

해시된 정적 에셋 - 1년 캐시

location ~* \.(js|css|webp|avif|woff2)$ {

add_header Cache-Control "public, max-age=31536000, immutable";

}

API 응답 - stale-while-revalidate

location /api/ {

add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";

}

}

7.2 Service Worker 캐싱

// service-worker.js - Workbox 기반 캐싱 전략

CacheFirst,

StaleWhileRevalidate,

NetworkFirst,

} from 'workbox-strategies';

// 빌드 시 생성된 에셋 프리캐시

precacheAndRoute(self.__WB_MANIFEST);

// 이미지: Cache First

registerRoute(

({ request }) => request.destination === 'image',

new CacheFirst({

cacheName: 'images',

plugins: [

new CacheableResponsePlugin({ statuses: [0, 200] }),

new ExpirationPlugin({

maxEntries: 100,

maxAgeSeconds: 30 * 24 * 60 * 60, // 30일

}),

],

})

);

// API: Stale While Revalidate

registerRoute(

({ url }) => url.pathname.startsWith('/api/'),

new StaleWhileRevalidate({

cacheName: 'api-cache',

plugins: [

new CacheableResponsePlugin({ statuses: [0, 200] }),

new ExpirationPlugin({

maxEntries: 50,

maxAgeSeconds: 5 * 60, // 5분

}),

],

})

);

// HTML 페이지: Network First

registerRoute(

({ request }) => request.mode === 'navigate',

new NetworkFirst({

cacheName: 'pages',

plugins: [

new CacheableResponsePlugin({ statuses: [0, 200] }),

],

networkTimeoutSeconds: 3,

})

);

7.3 CDN Edge Caching

// Vercel Edge Config 예시

// next.config.js

module.exports = {

headers() {

return [

{

source: '/:path*',

headers: [

{

key: 'CDN-Cache-Control',

value: 'public, max-age=60, stale-while-revalidate=3600',

},

{

key: 'Vercel-CDN-Cache-Control',

value: 'public, max-age=3600, stale-while-revalidate=86400',

},

],

},

];

},

};

// CloudFront Cache Policy (AWS CDK)

const cachePolicy = new cloudfront.CachePolicy(this, 'CachePolicy', {

defaultTtl: Duration.hours(1),

maxTtl: Duration.days(365),

minTtl: Duration.seconds(0),

enableAcceptEncodingGzip: true,

enableAcceptEncodingBrotli: true,

headerBehavior: cloudfront.CacheHeaderBehavior.allowList(

'Accept',

'Accept-Encoding'

),

queryStringBehavior: cloudfront.CacheQueryStringBehavior.all(),

});

8. 렌더링 패턴 비교

8.1 CSR vs SSR vs SSG vs ISR vs Streaming

렌더링 패턴 비교:

┌──────────┬──────────┬──────────┬──────────┬──────────┐

│ │ TTFB │ FCP │ TTI │ SEO │

├──────────┼──────────┼──────────┼──────────┼──────────┤

│ CSR │ 빠름 │ 느림 │ 느림 │ 나쁨 │

│ SSR │ 느림 │ 빠름 │ 보통 │ 좋음 │

│ SSG │ 매우빠름│ 매우빠름│ 빠름 │ 좋음 │

│ ISR │ 매우빠름│ 매우빠름│ 빠름 │ 좋음 │

│ Streaming│ 빠름 │ 매우빠름│ 빠름 │ 좋음 │

└──────────┴──────────┴──────────┴──────────┴──────────┘

8.2 Streaming SSR (React 18 + Next.js App Router)

// app/dashboard/page.tsx - Streaming SSR

async function SlowDataComponent() {

const data = await fetchSlowData(); // 3초 소요

return <div>{/* data rendering */}</div>;

}

async function FastDataComponent() {

const data = await fetchFastData(); // 100ms 소요

return <div>{/* data rendering */}</div>;

}

export default function DashboardPage() {

return (

{/* 빠른 컴포넌트는 즉시 렌더링 */}

{/* 느린 컴포넌트는 스트리밍 */}

);

}

// app/layout.tsx - loading.tsx 활용

// app/dashboard/loading.tsx

export default function Loading() {

return (

);

}

8.3 ISR (Incremental Static Regeneration)

// Next.js App Router - ISR

// app/products/[id]/page.tsx

export const revalidate = 3600; // 1시간마다 재생성

export async function generateStaticParams() {

const products = await getTopProducts();

return products.map((product) => ({

id: product.id.toString(),

}));

}

export default async function ProductPage({ params }) {

const product = await getProduct(params.id);

return <ProductDetail product={product} />;

}

// On-Demand Revalidation

// app/api/revalidate/route.ts

export async function POST(request) {

const { path, tag, secret } = await request.json();

if (secret !== process.env.REVALIDATION_SECRET) {

return Response.json({ error: 'Invalid secret' }, { status: 401 });

}

if (tag) {

revalidateTag(tag);

} else if (path) {

revalidatePath(path);

}

return Response.json({ revalidated: true, now: Date.now() });

}

9. Prefetching 전략

9.1 Link Prefetch와 Route Prefetch

<!-- DNS Prefetch: 외부 도메인 DNS 미리 해석 -->

<!-- Preconnect: DNS + TCP + TLS 미리 연결 -->

<!-- Prefetch: 다음 네비게이션에 필요한 리소스 미리 가져오기 -->

<!-- Prerender: 전체 페이지 미리 렌더링 -->

9.2 Speculation Rules API

<!-- Speculation Rules API (Chrome 121+) -->

{

"prerender": [

{

"where": {

"and": [

{ "href_matches": "/*" },

{ "not": { "href_matches": "/logout" } },

{ "not": { "href_matches": "/api/*" } }

]

},

"eagerness": "moderate"

}

],

"prefetch": [

{

"urls": ["/products", "/about"],

"eagerness": "eager"

}

]

}

// Next.js에서의 Prefetch 전략

// Link 컴포넌트는 자동으로 viewport에 들어오면 prefetch

Dashboard

// router.prefetch로 프로그래매틱 prefetch

function Navigation() {

const router = useRouter();

const handleMouseEnter = () => {

router.prefetch('/settings');

};

return (

Settings

);

}

10. 서드파티 스크립트 최적화

10.1 defer/async와 로딩 전략

<!-- 스크립트 로딩 패턴 비교 -->

<!-- 1. 기본: HTML 파싱 차단 -->

<!-- 2. async: 다운로드 병렬, 실행 시 차단 (순서 보장 X) -->

<!-- 3. defer: 다운로드 병렬, DOMContentLoaded 전 순서대로 실행 -->

<!-- 4. type=module: defer처럼 동작 + ES Module -->

10.2 Partytown으로 서드파티 격리

// Partytown: 서드파티 스크립트를 Web Worker로 이동

// next.config.js

const { withPartytown } = require('@builder.io/partytown/next');

module.exports = withPartytown({

partytown: {

forward: ['dataLayer.push', 'gtag'],

},

});

<!-- Partytown 적용 -->

window.dataLayer = window.dataLayer || [];

function gtag(){dataLayer.push(arguments);}

gtag('js', new Date());

gtag('config', 'G-XXXXXXXXXX');

// Next.js Script 컴포넌트 활용

export default function MyApp({ Component, pageProps }) {

return (

<>

{/* beforeInteractive: _document에서 로드 */}

src="https://polyfill.io/v3/polyfill.min.js"

strategy="beforeInteractive"

/>

{/* afterInteractive: 페이지 하이드레이션 후 (기본값) */}

src="https://www.googletagmanager.com/gtag/js"

strategy="afterInteractive"

/>

{/* lazyOnload: 브라우저 idle 시 로드 */}

src="https://connect.facebook.net/en_US/fbevents.js"

strategy="lazyOnload"

/>

{/* worker: Partytown으로 Web Worker 실행 */}

src="https://example.com/tracking.js"

strategy="worker"

/>

</>

);

}

11. Lighthouse 심층 분석

11.1 Lighthouse 점수 체계

Lighthouse Performance 점수 가중치 (v12):

┌──────────────────────┬────────┐

│ 지표 │ 가중치 │

├──────────────────────┼────────┤

│ FCP (First Content.) │ 10% │

│ SI (Speed Index) │ 10% │

│ LCP (Largest Cont.) │ 25% │

│ TBT (Total Blocking) │ 30% │

│ CLS (Cumulative L.S.)│ 25% │

└──────────────────────┴────────┘

INP는 필드 데이터(CrUX)에서만 측정됨

11.2 Lighthouse CI 자동화

.github/workflows/lighthouse.yml

name: Lighthouse CI

on: [pull_request]

jobs:

lighthouse:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- uses: actions/setup-node@v4

with:

node-version: 20

- run: npm ci && npm run build

- name: Run Lighthouse

uses: treosh/lighthouse-ci-action@v11

with:

configPath: ./lighthouserc.json

uploadArtifacts: true

temporaryPublicStorage: true

// lighthouserc.json

{

"ci": {

"collect": {

"numberOfRuns": 3,

"url": [

"http://localhost:3000/",

"http://localhost:3000/blog",

"http://localhost:3000/products"

],

"startServerCommand": "npm start"

},

"assert": {

"assertions": {

"categories:performance": ["error", { "minScore": 0.9 }],

"categories:accessibility": ["warn", { "minScore": 0.95 }],

"first-contentful-paint": ["error", { "maxNumericValue": 1800 }],

"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],

"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],

"total-blocking-time": ["error", { "maxNumericValue": 300 }]

}

},

"upload": {

"target": "temporary-public-storage"

}

}

}

12. 성능 모니터링

12.1 Web Vitals 라이브러리

// web-vitals 라이브러리로 실제 사용자 지표 수집

function sendToAnalytics(metric) {

const body = JSON.stringify({

name: metric.name,

value: metric.value,

rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'

delta: metric.delta,

id: metric.id,

navigationType: metric.navigationType,

entries: metric.entries,

});

// Beacon API로 페이지 이탈 시에도 안정적 전송

if (navigator.sendBeacon) {

navigator.sendBeacon('/api/vitals', body);

} else {

fetch('/api/vitals', { body, method: 'POST', keepalive: true });

}

}

onCLS(sendToAnalytics);

onINP(sendToAnalytics);

onLCP(sendToAnalytics);

onFCP(sendToAnalytics);

onTTFB(sendToAnalytics);

12.2 RUM (Real User Monitoring) 대시보드

// 커스텀 RUM 수집기

class PerformanceMonitor {

constructor() {

this.metrics = {};

this.init();

}

init() {

// Navigation Timing

window.addEventListener('load', () => {

const nav = performance.getEntriesByType('navigation')[0];

this.metrics.dns = nav.domainLookupEnd - nav.domainLookupStart;

this.metrics.tcp = nav.connectEnd - nav.connectStart;

this.metrics.ttfb = nav.responseStart - nav.requestStart;

this.metrics.domLoad = nav.domContentLoadedEventEnd - nav.fetchStart;

this.metrics.fullLoad = nav.loadEventEnd - nav.fetchStart;

this.report();

});

// Resource Timing

const resourceObserver = new PerformanceObserver((list) => {

for (const entry of list.getEntries()) {

if (entry.transferSize === 0) continue; // 캐시에서 로드된 리소스 제외

this.trackResource({

name: entry.name,

type: entry.initiatorType,

duration: entry.duration,

size: entry.transferSize,

});

}

});

resourceObserver.observe({ type: 'resource', buffered: true });

// Long Tasks

const longTaskObserver = new PerformanceObserver((list) => {

for (const entry of list.getEntries()) {

this.trackLongTask({

duration: entry.duration,

startTime: entry.startTime,

attribution: entry.attribution,

});

}

});

try {

longTaskObserver.observe({ type: 'longtask', buffered: true });

} catch (e) {

// longtask observer not supported

}

}

trackResource(data) {

// 느린 리소스 경고

if (data.duration > 1000) {

console.warn('Slow resource:', data.name, data.duration + 'ms');

}

}

trackLongTask(data) {

// 50ms 이상의 Long Task 기록

console.warn('Long Task:', data.duration + 'ms');

}

report() {

console.table(this.metrics);

}

}

new PerformanceMonitor();

12.3 CrUX (Chrome User Experience Report)

// CrUX API로 필드 데이터 조회

async function getCruxData(url) {

const apiKey = process.env.CRUX_API_KEY;

const response = await fetch(

'https://chromeuxreport.googleapis.com/v1/records:queryRecord' +

'?key=' + apiKey,

{

method: 'POST',

body: JSON.stringify({

url: url,

formFactor: 'PHONE',

metrics: [

'largest_contentful_paint',

'interaction_to_next_paint',

'cumulative_layout_shift',

'experimental_time_to_first_byte',

],

}),

}

);

const data = await response.json();

// p75 값 추출

const lcp = data.record.metrics.largest_contentful_paint;

console.log('LCP p75:', lcp.percentiles.p75 + 'ms');

console.log('LCP distribution:', lcp.histogram);

return data;

}

13. 실전 최적화 체크리스트

성능 최적화 체크리스트:

[ ] 이미지

[ ] WebP/AVIF 포맷 사용

[ ] 적절한 크기의 반응형 이미지 제공

[ ] LCP 이미지에 priority/fetchpriority 설정

[ ] 스크롤 아래 이미지에 loading="lazy"

[ ] aspect-ratio 또는 width/height 명시

[ ] JavaScript

[ ] 코드 분할 (route-based + component-based)

[ ] Tree Shaking 확인 (sideEffects: false)

[ ] 번들 크기 모니터링

[ ] 불필요한 polyfill 제거

[ ] 서드파티 스크립트 defer/async/worker

[ ] CSS

[ ] Critical CSS 인라인

[ ] 미사용 CSS 제거

[ ] content-visibility 활용

[ ] CSS Containment 적용

[ ] 폰트

[ ] WOFF2 포맷 사용

[ ] font-display: optional 또는 swap

[ ] 서브셋 적용 (한글/라틴 분리)

[ ] Variable Font 활용

[ ] preload 설정

[ ] 캐싱

[ ] 정적 에셋: immutable + 1년

[ ] HTML: no-cache + ETag

[ ] API: stale-while-revalidate

[ ] Service Worker 캐싱 전략

[ ] CDN Edge 캐싱 설정

[ ] 렌더링

[ ] 적절한 렌더링 패턴 선택

[ ] Suspense로 스트리밍

[ ] ISR revalidate 설정

[ ] loading.tsx 스켈레톤

[ ] 모니터링

[ ] Lighthouse CI 자동화

[ ] Web Vitals RUM 수집

[ ] CrUX 데이터 모니터링

[ ] 성능 회귀 알림 설정

14. 퀴즈

아래 퀴즈로 학습 내용을 점검해 보세요.

**A1.** LCP의 Good 기준값은 **2.5초 이하**입니다.

가장 효과적인 개선 방법:

1. **LCP 이미지에 `fetchpriority="high"` 설정** - 브라우저가 LCP 리소스를 최우선으로 다운로드하게 합니다.

2. **`preload` 링크 태그 사용** - 파서가 발견하기 전에 LCP 리소스 다운로드를 시작합니다.

추가로 서버 응답 시간 단축(CDN, 캐싱), 렌더링 차단 리소스 제거, 이미지 최적화(WebP/AVIF) 등이 효과적입니다.

**A2.** FID는 **첫 번째 상호작용의 입력 지연만** 측정했지만, INP는 **전체 세션의 모든 상호작용**을 고려하여 가장 느린 상호작용(또는 p98)을 보고합니다. 또한 INP는 입력 지연뿐 아니라 처리 시간과 렌더링 지연까지 포함합니다.

React에서의 개선 패턴:

- **`useTransition`**: 긴급하지 않은 상태 업데이트를 낮은 우선순위로 처리

- **`useDeferredValue`**: 값의 업데이트를 지연시켜 UI 응답성 유지

- **`scheduler.yield()`**: 긴 작업을 분할하여 메인 스레드에 양보

**A3.**

**CacheFirst**: 캐시에 있으면 캐시에서 즉시 반환, 없으면 네트워크 요청. 캐시 히트 시 네트워크 요청을 하지 않습니다.

- 적합한 사례: 이미지, 폰트, 정적 에셋 등 자주 변경되지 않는 리소스

**StaleWhileRevalidate**: 캐시에서 즉시 반환하면서 동시에 백그라운드에서 네트워크 요청으로 캐시를 갱신합니다.

- 적합한 사례: API 응답, 뉴스 피드 등 최신성은 필요하지만 즉각적인 응답도 중요한 리소스

NetworkFirst는 HTML 페이지처럼 항상 최신 콘텐츠가 필요한 경우에 적합합니다.

**A4.** 기존 SSR은 모든 데이터 fetching이 완료될 때까지 HTML 전송을 시작하지 못했습니다. **Streaming SSR**은 준비된 부분부터 HTML 청크를 즉시 전송하여:

- **TTFB를 단축**합니다 (느린 데이터에 의존하지 않음)

- **FCP를 개선**합니다 (빠른 컴포넌트 먼저 표시)

- **사용자 체감 성능이 향상**됩니다

Next.js App Router에서의 구현:

1. `Suspense` 컴포넌트로 느린 데이터 컴포넌트를 감쌈

2. `fallback` prop에 스켈레톤/로딩 UI 제공

3. `loading.tsx` 파일로 라우트 레벨 로딩 상태 정의

4. 서버 컴포넌트에서 `async/await`으로 데이터 페칭

**A5.** 기존 `link rel="prefetch"`는 리소스를 미리 다운로드만 하지만, **Speculation Rules API**는 전체 페이지를 미리 렌더링(prerender)할 수 있습니다.

주요 차이점:

- **Prefetch**: 리소스 다운로드만 (HTML, JS 등)

- **Prerender (Speculation Rules)**: 전체 페이지를 숨겨진 탭에서 렌더링까지 완료

장점:

1. **즉각적인 페이지 전환** (이미 렌더링 완료)

2. **조건부 규칙**: URL 패턴, eagerness 레벨 설정 가능

3. **브라우저 최적화**: 메모리/네트워크 상황에 따라 자동 조절

4. **JSON 기반 선언적 문법**으로 유지보수 용이

단, Chrome 121 이상에서만 지원되며 로그아웃이나 API 엔드포인트는 제외해야 합니다.

15. 참고 자료

1. [web.dev - Core Web Vitals](https://web.dev/vitals/) - Google의 Core Web Vitals 공식 가이드

2. [web.dev - Optimize LCP](https://web.dev/optimize-lcp/) - LCP 최적화 가이드

3. [web.dev - Optimize INP](https://web.dev/optimize-inp/) - INP 최적화 가이드

4. [web.dev - Optimize CLS](https://web.dev/optimize-cls/) - CLS 최적화 가이드

5. [Chrome Developers - Speculation Rules API](https://developer.chrome.com/docs/web-platform/prerender-pages) - Speculation Rules 가이드

6. [Next.js Documentation - Optimizing](https://nextjs.org/docs/app/building-your-application/optimizing) - Next.js 최적화 문서

7. [Workbox - Service Worker Libraries](https://developer.chrome.com/docs/workbox/) - Google Workbox 공식 문서

8. [web.dev - Optimize Images](https://web.dev/fast/#optimize-your-images) - 이미지 최적화 가이드

9. [Partytown - Web Worker for Third-party Scripts](https://partytown.builder.io/) - Partytown 공식 사이트

10. [web-vitals - JavaScript Library](https://github.com/GoogleChrome/web-vitals) - web-vitals 라이브러리

11. [CrUX Dashboard](https://developer.chrome.com/docs/crux/) - Chrome UX Report 문서

12. [Lighthouse CI](https://github.com/GoogleChrome/lighthouse-ci) - Lighthouse CI 자동화

13. [HTTP Caching - MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching) - HTTP 캐싱 완전 가이드

현재 단락 (1/954)

웹 성능은 사용자 경험, 비즈니스 성과, SEO 순위에 직접적으로 영향을 미칩니다. Google 연구에 따르면 페이지 로드 시간이 1초에서 3초로 늘어나면 이탈률이 32% 증가하고...

작성 글자: 0원문 글자: 23,115작성 단락: 0/954