
웹 성능 최적화: Core Web Vitals
로딩이 3초 넘으면 사용자의 53%가 이탈합니다. 구글이 중요하게 보는 3가지 지표(LCP, INP, CLS)와 최적화 기법.

로딩이 3초 넘으면 사용자의 53%가 이탈합니다. 구글이 중요하게 보는 3가지 지표(LCP, INP, CLS)와 최적화 기법.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

처음 만든 랜딩 페이지의 Lighthouse 점수가 32점이었다. 빨간색 경고가 화면을 도배하고 있었고, 내 노트북에서는 멀쩡했던 페이지가 핸드폰에서는 10초 넘게 로딩되고 있었다. 사용자 입장에서 생각해보니 답이 나왔다. 나라면 3초 안에 안 뜨면 그냥 뒤로가기 누른다.
구글 애널리틱스를 보니 진짜였다. 이탈률이 67%였다. 사람들이 내 페이지를 열자마자 나가고 있었다. 그때 깨달았다. 성능은 선택이 아니라 필수다. 아무리 디자인이 예쁘고 기능이 많아도, 느리면 아무도 안 본다.
그날부터 성능 최적화를 파기 시작했다. Core Web Vitals라는 단어도 처음 들었고, LCP, FID, CLS가 뭔지도 몰랐다. 그런데 이걸 하나씩 고쳐나가면서 점수가 32점에서 91점까지 올라가는 걸 보니, 이게 마법처럼 느껴졌다. 나를 위해, 그리고 같은 고민을 하는 사람들을 위해 이 여정을 정리해본다.
처음에는 무엇부터 손을 대야 할지 막막했다. Lighthouse 리포트에는 "Reduce JavaScript execution time", "Properly size images", "Eliminate render-blocking resources" 같은 경고가 20개 넘게 떠 있었다. 다 중요해 보이는데, 어디서부터 시작해야 할까?
먼저 이미지를 건드렸다. 히어로 이미지가 3MB짜리 PNG였다. 압축도 안 되어 있고, 그냥 디자이너가 준 원본 그대로 올렸다. WebP로 바꾸고 리사이즈했더니 200KB로 줄었다. 점수가 10점 올랐다. 이제 42점이다.
그 다음은 자바스크립트였다. 번들 크기를 보니 700KB였다. 첫 화면에는 필요 없는 라이브러리들이 전부 로딩되고 있었다. 차트 라이브러리, 애니메이션 라이브러리, 심지어 쓰지도 않는 lodash 전체가 들어 있었다. 이걸 Code Splitting으로 나눴더니 초기 번들이 200KB로 줄었다. 점수가 60점대로 뛰었다.
그런데 여전히 문제가 있었다. 페이지가 로딩되는 동안 레이아웃이 움직였다. 이미지가 로딩되면서 텍스트가 아래로 밀려나고, 광고 배너가 뜨면서 버튼 위치가 바뀌었다. 버튼을 누르려던 순간에 레이아웃이 바뀌어서 엉뚱한 곳을 클릭하게 되는 경험. 나도 다른 사이트에서 겪으면 짜증 나던 그 경험을 내가 만들고 있었다.
어느 순간, 이 모든 지표가 사실은 하나의 철학을 말하고 있다는 걸 깨달았다. 사용자 경험을 측정 가능한 숫자로 바꾼 거였다.
결국 이 세 가지 질문으로 귀결됐다. 사용자는 기다리기 싫어한다(LCP), 반응 없는 버튼은 또 누른다(INP), 그리고 읽다가 화면이 움직이면 짜증난다(CLS). 구글이 이 세 지표를 Core Web Vitals로 정한 이유가 여기 있었다. 검색 랭킹에도 반영하고, 크롬 개발자 도구에도 이 지표를 박아놓은 이유.
이때부터 성능 최적화가 단순히 점수 올리기가 아니라, 사용자의 불편함을 제거하는 과정으로 느껴졌다. 숫자를 쫓는 게 아니라, 사람의 경험을 개선하는 거였다.
LCP는 가장 큰 콘텐츠 요소가 화면에 렌더링되는 시간이다. 보통 히어로 이미지, 배너, 혹은 본문의 첫 번째 단락이 이에 해당한다. 구글의 기준은 2.5초 이내다.
나는 이걸 레스토랑 비유로 이해했다. 레스토랑에 들어갔는데 메뉴판이 5분 동안 안 나오면? 나간다. 웹페이지도 마찬가지다. 첫 화면이 빨리 떠야 사용자가 "아, 여기 괜찮네"라고 느낀다.
가장 큰 범인은 보통 이미지다. PNG나 JPEG를 그대로 쓰지 말고, WebP나 AVIF로 바꾸면 용량이 절반 이하로 줄어든다. 나는 Next.js의 <Image> 컴포넌트를 썼는데, 이게 자동으로 WebP로 변환해주고 리사이즈도 해준다.
import Image from 'next/image';
function Hero() {
return (
<Image
src="/hero.png"
alt="Hero image"
width={1200}
height={600}
priority // LCP 요소에는 priority 플래그!
quality={85}
/>
);
}
priority 플래그를 달면 이 이미지를 먼저 로딩한다. Lazy loading을 적용하지 않고, preload 태그도 자동으로 넣어준다. LCP 요소에는 필수다.
이미지가 아무리 최적화되어 있어도, 서버가 응답하는 데 3초 걸리면 소용없다. TTFB(Time to First Byte)를 줄여야 한다. 나는 Vercel에서 Edge Functions를 쓰면서 TTFB가 200ms에서 50ms로 줄었다. CDN을 제대로 쓰는 것만으로도 엄청난 차이가 났다.
3. Critical CSS 인라인첫 화면에 필요한 CSS만 <head>에 인라인으로 넣고, 나머지는 나중에 로딩하는 방법이다. 이렇게 하면 CSS 파일을 기다리는 시간이 없어진다.
<style>
/* Critical CSS - 첫 화면에 필요한 것만 */
.hero { font-size: 48px; margin-top: 100px; }
</style>
Next.js는 자동으로 이걸 해주지만, 직접 구현해야 한다면 critical 같은 라이브러리를 쓰면 된다.
INP(Interaction to Next Paint)는 2024년에 FID(First Input Delay)를 대체한 지표다. 사용자가 클릭, 탭, 키보드 입력을 했을 때, 브라우저가 실제로 반응하기까지 걸리는 시간이다. 200ms 이내가 목표다.
나는 이걸 엘리베이터 버튼 비유로 받아들였다. 버튼 눌렀는데 불도 안 들어오고 반응이 없으면? 사람들은 여러 번 누른다. 웹페이지도 마찬가지다. 버튼 눌렀는데 0.5초 동안 아무 일도 안 일어나면, 사용자는 "고장났나?" 하고 또 누른다.
메인 스레드를 블로킹하는 긴 작업이 가장 큰 문제다. 나는 이걸 고칠 때 requestIdleCallback을 썼다. 급하지 않은 작업은 브라우저가 한가할 때 실행하도록 미뤘다.
function heavyTask() {
// 무거운 계산 작업
for (let i = 0; i < 1000000; i++) {
// ...
}
}
// 급하지 않은 작업은 나중에
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
heavyTask();
});
} else {
setTimeout(heavyTask, 1);
}
2. Code Splitting과 Dynamic Import
모든 코드를 한 번에 로딩하지 말고, 필요할 때만 로딩하는 방식이다. 나는 모달, 차트, 에디터 같은 무거운 컴포넌트를 Dynamic Import로 바꿨다.
import dynamic from 'next/dynamic';
// 차트 라이브러리는 무겁다 - 필요할 때만 로딩
const Chart = dynamic(() => import('./Chart'), {
loading: () => <p>Loading chart...</p>,
ssr: false // 서버에서는 렌더링 안 함
});
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>
Show Chart
</button>
{showChart && <Chart />}
</div>
);
}
버튼을 누르기 전까지는 차트 라이브러리가 로딩되지 않는다. 초기 번들 크기가 확 줄어든다.
3. Debouncing과 Throttling검색창에 타이핑할 때마다 API를 호출하면 메인 스레드가 막힌다. Debouncing을 쓰면 타이핑이 끝난 후에 한 번만 호출한다.
import { useState, useEffect } from 'react';
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
// 500ms 동안 타이핑이 멈추면 그때 검색
const timer = setTimeout(() => {
if (query) {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
}
}, 500);
return () => clearTimeout(timer);
}, [query]);
return (
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}
타이핑할 때마다가 아니라, 멈췄을 때 한 번만 검색한다. INP가 확 개선된다.
CLS(Cumulative Layout Shift)는 예상치 못한 레이아웃 이동을 측정한다. 0.1 이하가 목표다. 이게 가장 짜증나는 경험이다. 기사를 읽고 있는데 갑자기 광고 배너가 뜨면서 텍스트가 밀려난다. 링크를 누르려는 순간 이미지가 로딩되면서 버튼 위치가 바뀐다.
나는 이걸 지하철 손잡이 비유로 이해했다. 지하철이 출발할 때 손잡이가 갑자기 움직이면? 넘어진다. 웹페이지도 마찬가지다. 사용자가 뭔가 하려는 순간에 레이아웃이 바뀌면, UX가 완전히 망가진다.
이미지를 로딩하기 전에 얼마나 공간이 필요한지 브라우저에게 알려줘야 한다. width와 height 속성을 반드시 넣는다.
// Bad
<img src="/photo.jpg" alt="Photo" />
// Good
<img src="/photo.jpg" alt="Photo" width="800" height="600" />
// Better (Next.js)
<Image
src="/photo.jpg"
alt="Photo"
width={800}
height={600}
/>
Next.js의 <Image> 컴포넌트는 자동으로 aspect ratio를 유지하면서 공간을 예약한다.
커스텀 폰트가 로딩될 때 텍스트가 깜빡이거나 움직이는 현상(FOIT/FOUT)을 막아야 한다. font-display: swap을 쓰면 시스템 폰트로 먼저 보여주고, 커스텀 폰트가 로딩되면 바꾼다.
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* 핵심! */
}
Next.js에서는 next/font를 쓰면 자동으로 최적화된다.
import { Inter } from 'next/font/inter';
const inter = Inter({ subsets: ['latin'] });
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
3. 광고와 임베드에 공간 예약
광고, 소셜 미디어 임베드, iframe은 크기를 미리 알 수 없어서 CLS의 주범이다. 최소한 컨테이너의 높이를 지정해서 공간을 예약한다.
<div style={{ minHeight: '250px' }}>
{/* 광고 스크립트 */}
</div>
성능 최적화를 이해하려면 브라우저가 어떻게 화면을 그리는지 알아야 한다. Critical Rendering Path는 HTML을 받아서 화면에 픽셀을 그리기까지의 과정이다.
여기서 병목이 생기는 지점이 render-blocking resources다. CSS와 JavaScript가 로딩될 때까지 브라우저는 화면을 그리지 않는다. 이걸 줄여야 한다.
나는 이걸 요리 비유로 받아들였다. 파스타를 만들 때 물 끓이고, 소스 만들고, 면 삶고, 플레이팅하는 순서가 있다. 병렬로 할 수 있는 건 병렬로 하고, 순서가 중요한 건 순서대로 해야 빠르다. 웹페이지도 마찬가지다.
<head>에, JavaScript는 <body> 끝에
CSS는 CSSOM 생성에 필수니까 <head>에 넣는다. 하지만 JavaScript는 defer나 async를 붙여서 HTML 파싱을 막지 않게 한다.
<head>
<link rel="stylesheet" href="styles.css"> <!-- 먼저 로딩 -->
</head>
<body>
<div id="app"></div>
<script src="app.js" defer></script> <!-- 나중에 실행 -->
</body>
2. Preload와 Prefetch
중요한 리소스는 미리 로딩한다. preload는 "지금 당장 필요해", prefetch는 "나중에 필요할 수도 있어"다.
<link rel="preload" href="/hero.woff2" as="font" type="font/woff2" crossorigin>
<link rel="prefetch" href="/next-page.js">
성능 최적화는 감으로 하는 게 아니다. 측정하고, 병목을 찾고, 고치고, 다시 측정하는 사이클이다.
크롬 개발자 도구에 내장된 성능 측정 도구다. Performance, Accessibility, Best Practices, SEO 점수를 준다. 나는 배포할 때마다 Lighthouse를 돌렸다.
실제 사용자 데이터를 측정하려면 Web Vitals 라이브러리를 쓴다. Google Analytics나 Sentry에 보낼 수 있다.
import { onCLS, onINP, onLCP } from 'web-vitals';
function sendToAnalytics(metric) {
console.log(metric);
// Google Analytics나 Sentry로 전송
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
브라우저 내장 API로 직접 타이밍을 측정할 수 있다.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LCP candidate:', entry.startTime, entry);
}
});
observer.observe({ entryTypes: ['largest-contentful-paint'] });
스크롤해서 보이는 영역에 들어올 때만 이미지를 로딩한다.
import { useEffect, useRef, useState } from 'react';
function LazyImage({ src, alt }) {
const [isVisible, setIsVisible] = useState(false);
const imgRef = useRef();
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
});
});
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, []);
return (
<div ref={imgRef}>
{isVisible ? (
<img src={src} alt={alt} />
) : (
<div style={{ height: '300px', background: '#f0f0f0' }} />
)}
</div>
);
}
Intersection Observer API를 써서 뷰포트에 들어오면 이미지를 로딩한다. Next.js의 <Image>는 이걸 자동으로 해준다.
무거운 컴포넌트는 필요할 때만 로딩한다.
// pages/dashboard.js
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
// 차트는 클릭할 때만 로딩
const Chart = dynamic(() => import('../components/Chart'), {
loading: () => <div>Loading chart...</div>,
});
// PDF 뷰어는 아예 클라이언트에서만 로딩
const PDFViewer = dynamic(() => import('../components/PDFViewer'), {
ssr: false,
});
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading...</div>}>
<Chart data={data} />
<PDFViewer file="/report.pdf" />
</Suspense>
</div>
);
}
이렇게 하니까 초기 번들이 700KB에서 200KB로 줄었다.
오프라인에서도 작동하고, 두 번째 방문은 초고속으로.
// public/sw.js
const CACHE_NAME = 'v1';
const urlsToCache = ['/', '/styles.css', '/app.js'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
// 캐시에 있으면 캐시 반환, 없으면 네트워크
return response || fetch(event.request);
})
);
});
Next.js에서는 next-pwa 플러그인을 쓰면 자동으로 Service Worker를 생성해준다.
성능 최적화를 시작한 지 3주 만에 Lighthouse 점수가 32점에서 91점으로 올랐다. 이탈률도 67%에서 34%로 떨어졌다. 무엇보다 내 사이트가 빨라졌다는 게 느껴졌다. 핸드폰에서도 3초 안에 뜬다.
성능 최적화는 끝이 없다. 새로운 기능을 추가할 때마다 점수가 떨어지고, 다시 고쳐야 한다. 하지만 이제는 뭘 봐야 하는지 알고, 어떻게 고쳐야 하는지 안다.
결국 이 모든 건 사용자를 위한 거다. 빠른 사이트는 더 많이 쓰이고, 더 많이 공유되고, 더 높은 순위에 노출된다. 성능은 기능이다. 이걸 잊지 말자.