프론트엔드 성능 최적화: 흔히 놓치는 6가지
프론트엔드 성능 최적화는 거창한 게 아니다. 대부분은 “알면 바로 고치는데, 모르면 그냥 넘어가는” 것들이다. Lighthouse 점수가 낮은 이유를 파보면 결국 비슷한 패턴이 반복된다.
자주 보이는 6가지를 Before/After로 정리해봤다.
1. 불필요한 리렌더링
React에서 상태가 바뀌면 해당 컴포넌트 + 모든 자식이 재렌더링된다. 자식이 그 상태와 전혀 관련 없어도. 이게 기본 동작이라 좀 당황스럽다.
상태를 너무 높은 곳에 두는 실수
검색 입력값 같은 로컬 상태를 최상위 컴포넌트에 올려놓으면, 키 하나 누를 때마다 앱 전체가 재렌더링된다.
// Before: 입력할 때마다 HeavyList, Sidebar까지 전부 재렌더링
function App() {
const [searchQuery, setSearchQuery] = useState('')
return (
<div>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<HeavyList />
<Sidebar />
</div>
)
} // After: 상태를 필요한 컴포넌트 안으로 내린다
function SearchInput() {
const [searchQuery, setSearchQuery] = useState('')
return <input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
}
function App() {
return (
<div>
<SearchInput />
<HeavyList />
<Sidebar />
</div>
)
} 상태를 실제로 쓰는 컴포넌트 안으로 내리면, 그 컴포넌트만 재렌더링된다. 간단한데 효과가 꽤 크다.
memo + useCallback 조합
상태를 내릴 수 없는 구조라면 React.memo로 막을 수 있다. 근데 주의할 게 있는데, 부모가 렌더링될 때마다 함수가 새로 만들어지면 memo가 무력화된다.
// Before: handleClick이 매 렌더마다 새로 생성되므로 memo 효과 없음
function Parent() {
const [count, setCount] = useState(0)
const handleClick = () => console.log('clicked')
return (
<div>
<button onClick={() => setCount(c => c + 1)}>카운트: {count}</button>
<ExpensiveChild onClick={handleClick} />
</div>
)
} // After: useCallback으로 함수 참조 안정화
const ExpensiveChild = React.memo(function ExpensiveChild({ onClick }) {
return <button onClick={onClick}>비싼 컴포넌트</button>
})
function Parent() {
const [count, setCount] = useState(0)
const handleClick = useCallback(() => console.log('clicked'), [])
return (
<div>
<button onClick={() => setCount(c => c + 1)}>카운트: {count}</button>
<ExpensiveChild onClick={handleClick} />
</div>
)
} memo만 걸고 useCallback을 빠뜨리는 경우가 꽤 많은데, memo는 prop이 안 바뀌었을 때만 건너뛰는 거라 함수가 매번 새로 만들어지면 소용이 없다.
key에 index 쓰지 않기
// Before: 리스트 변경 시 모든 항목이 재마운트
{items.map((item, index) => (
<ItemCard key={index} item={item} />
))}
// After: 안정적인 고유 ID 사용
{items.map(item => (
<ItemCard key={item.id} item={item} />
))} key={index}는 리스트 중간에 항목이 추가되거나 삭제될 때 React가 DOM을 잘못 재사용하게 만든다. 버그와 성능 저하를 동시에 유발한다.
2. 이미지와 폰트 로딩
이미지랑 폰트는 로딩 속도에 직접적인 영향을 준다. 포맷 바꾸고 로딩 순서만 조정해도 차이가 꽤 난다.
이미지 포맷
JPEG 기준으로 WebP는 25~35%, AVIF는 약 50%까지 용량이 줄어든다. 화질 차이는 거의 모른다.
<!-- Before -->
<img src="hero.jpg" alt="히어로 이미지">
<!-- After: picture 태그로 포맷 분기 + 폴백 -->
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg" alt="히어로 이미지" width="1200" height="600">
</picture> 브라우저가 AVIF 되면 AVIF, 안 되면 WebP, 둘 다 안 되면 JPEG. <picture> 태그 하나로 끝난다.
lazy loading과 preload
<!-- 첫 화면 히어로 이미지: preload로 빠르게 -->
<link rel="preload" as="image" href="hero.webp" type="image/webp">
<!-- 뷰포트 밖 이미지: lazy loading -->
<img src="below-fold.webp" loading="lazy" alt="..." width="800" height="400"> 주의할 점은, 첫 화면에 보이는 이미지에 loading="lazy"를 걸면 오히려 느려진다. 화면에 들어올 때까지 로딩을 미루니까. 첫 화면 이미지는 기본값 그대로 두고, 가능하면 preload까지 걸어주는 게 좋다.
폰트: FOIT 방지
커스텀 폰트를 쓰면 폰트가 로드되기 전까지 텍스트가 아예 안 보이는 현상이 발생할 수 있다.
/* Before: font-display 없으면 기본적으로 텍스트가 숨겨진다 */
@font-face {
font-family: 'MyFont';
src: url('myfont.woff2') format('woff2');
}
/* After: swap으로 시스템 폰트 먼저 보여주기 */
@font-face {
font-family: 'MyFont';
src: url('myfont.woff2') format('woff2');
font-display: swap;
} <!-- 폰트 preload로 다운로드 시작을 앞당긴다 -->
<link rel="preload" href="myfont.woff2" as="font" type="font/woff2" crossorigin> font-display: swap을 쓰면 폰트 로딩 전에 시스템 폰트를 먼저 보여준다. 잠깐 다른 글꼴로 보이긴 하는데, 아예 안 보이는 것보단 낫다.
3. 번들 사이즈
번들이 크면 브라우저가 JavaScript를 해석하고 실행하는 데 시간이 더 걸린다. 페이지는 떴는데 버튼을 눌러도 반응이 없는, 그 답답한 시간이 길어지는 거다.
동적 import
// Before: 모든 페이지가 초기 번들에 포함
import Dashboard from './Dashboard'
import Settings from './Settings'
import Analytics from './Analytics' // After: 라우트별로 분할, 필요할 때만 로드
import { lazy, Suspense } from 'react'
const Dashboard = lazy(() => import('./Dashboard'))
const Settings = lazy(() => import('./Settings'))
const Analytics = lazy(() => import('./Analytics'))
function App() {
return (
<Suspense fallback={<div>로딩 중...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
)
} 사용자가 /dashboard에 접속하면 Dashboard 코드만 받는다. 나머지는 해당 페이지 갈 때 받으면 되니까. 이것만으로도 초기 번들이 40~60% 줄어드는 경우가 흔하다.
index.ts 일괄 export 주의
// components/index.ts (barrel export)
export { Button } from './Button'
export { Modal } from './Modal'
export { DataTable } from './DataTable' // 무거운 차트 라이브러리 포함 // Before: Button만 필요한데 DataTable까지 번들에 포함
import { Button } from './components'
// After: 직접 경로로 import
import { Button } from './components/Button' index.ts에서 모든 모듈을 한꺼번에 re-export하는 패턴은 편리하지만, 번들러가 안 쓰는 코드를 제거하는 걸 방해할 수 있다. 특히 import 시 부수 작용이 있는 모듈이 섞여 있으면, 사용하지 않는 코드까지 번들에 포함된다. package.json에 "sideEffects": false를 명시하면 개선되지만, 확실한 건 직접 경로로 import하는 거다.
번들 분석
어디가 문제인지 모르면 최적화할 수도 없다. 번들 분석 도구로 현황을 먼저 파악해야 한다.
# Vite
npx vite-bundle-visualizer
# Webpack
npx webpack-bundle-analyzer stats.json
# Next.js
npm install @next/bundle-analyzer 의외로 moment.js(288KB)나 lodash(72KB) 전체 import가 큰 비중을 차지하는 경우가 많다. date-fns나 lodash-es로 바꾸면 수십 KB가 빠진다.
4. 레이아웃 쉬프트
페이지 로드 중에 요소가 갑자기 밀려나는 거. 기사 읽는데 광고가 끼어들면서 버튼이 밀려나는 경험, 다들 있을 거다. 구글 웹 성능 지표 중 하나로, 0.1 이하면 “양호”다.
이미지 크기 지정
<!-- Before: 이미지 로드 전 높이가 0이다가 로드 후 300px 확보 -->
<img src="product.webp" alt="상품">
<!-- After: width/height로 브라우저가 공간을 미리 예약 -->
<img src="product.webp" alt="상품" width="400" height="300"> CSS aspect-ratio로도 같은 효과를 낼 수 있다.
img {
width: 100%;
aspect-ratio: 4 / 3;
} 이미지 크기만 명시해도 레이아웃 쉬프트 점수가 0.3에서 0.05로 떨어지기도 한다.
Skeleton UI
// Before: 데이터 로드 전 아무것도 안 보이다가 갑자기 콘텐츠 등장
function UserProfile() {
const [user, setUser] = useState(null)
if (!user) return null
return <div className="profile">{user.name}</div>
} // After: 실제 콘텐츠와 같은 크기의 skeleton으로 공간 유지
function UserProfile() {
const [user, setUser] = useState(null)
if (!user) {
return (
<div className="skeleton">
<div className="skeleton-avatar" />
<div className="skeleton-text" />
</div>
)
}
return <div className="profile">{user.name}</div>
} .skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
} Skeleton이랑 실제 콘텐츠 높이가 다르면 어차피 밀리니까, 크기를 맞춰야 의미가 있다.
5. 이벤트 핸들러
스크롤, 리사이즈, 입력 이벤트는 초당 수십~수백 번 발생한다. 핸들러가 조금이라도 무거우면 UI가 버벅거린다.
debounce와 throttle
둘 다 호출 빈도를 줄이는 건데, 쓰임새가 다르다.
debounce: 마지막 이벤트 이후 일정 시간 지나면 실행. 검색 입력에 적합하다.
// Before: 글자 하나 칠 때마다 API 호출
input.addEventListener('input', (e) => {
fetchSearchResults(e.target.value)
})
// After: 입력이 멈춘 후 300ms 뒤에 한 번만 호출
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number) {
let timer: ReturnType<typeof setTimeout>
return (...args: any[]) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
input.addEventListener('input', debounce((e: Event) => {
fetchSearchResults((e.target as HTMLInputElement).value)
}, 300)) throttle: 일정 간격마다 한 번씩 실행. 스크롤, 마우스무브에 적합하다.
// Before: 스크롤 이벤트마다 레이아웃 재계산
window.addEventListener('scroll', () => {
updateParallaxPositions()
})
// After: 최대 60fps로 제한
function throttle<T extends (...args: any[]) => void>(fn: T, limit: number) {
let lastRun = 0
return (...args: any[]) => {
const now = Date.now()
if (now - lastRun >= limit) {
lastRun = now
fn(...args)
}
}
}
window.addEventListener('scroll', throttle(updateParallaxPositions, 16)) 정리하면 debounce는 “연타 끝나면 실행”, throttle은 “일정 주기로 실행”. 이걸 반대로 쓰면, 검색에 throttle 쓰면 타이핑 중에 불필요한 호출이 가고, 스크롤에 debounce 쓰면 끝나고 나서야 한 번 업데이트돼서 뚝뚝 끊긴다.
passive listener
// Before: 브라우저가 preventDefault() 호출 여부를 매번 확인하며 대기
window.addEventListener('scroll', handler)
window.addEventListener('touchstart', handler)
// After: "이 핸들러는 스크롤을 막지 않겠다"고 선언
window.addEventListener('scroll', handler, { passive: true })
window.addEventListener('touchstart', handler, { passive: true }) passive: true를 주면 브라우저가 스크롤을 바로 처리한다. 특히 모바일 터치에서 체감이 확실하다. Chrome DevTools에서 “Non-passive event listener” 경고 뜨면 바로 고치면 된다.
requestAnimationFrame
// Before: setTimeout으로 UI 업데이트. 브라우저 렌더링 사이클과 어긋남
window.addEventListener('scroll', () => {
setTimeout(() => {
element.style.transform = `translateY(${window.scrollY * 0.5}px)`
}, 16)
})
// After: 브라우저의 다음 프레임에 맞춰 실행
let ticking = false
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
element.style.transform = `translateY(${window.scrollY * 0.5}px)`
ticking = false
})
ticking = true
}
}) requestAnimationFrame은 브라우저가 다음 화면을 그리기 직전에 실행된다. setTimeout(fn, 16)이랑 비슷해 보이지만, setTimeout은 렌더링 타이밍이랑 안 맞아서 프레임이 빠질 수 있다. 애니메이션이나 스크롤 기반 UI에는 requestAnimationFrame을 쓰자.
6. CPU vs GPU 렌더링
브라우저가 CSS 변경을 화면에 반영할 때 세 단계를 거친다.
Layout (CPU) → Paint (CPU) → Composite (GPU) 어떤 속성을 바꾸느냐에 따라 세 단계를 다 거칠 수도 있고, 마지막 합성만 거칠 수도 있다.
| 단계 | 비용 | 트리거하는 속성 |
|---|---|---|
| Layout | 높음 | width, height, top, left, margin, padding |
| Paint | 중간 | color, background, border, box-shadow |
| 합성(GPU) | 낮음 | transform, opacity |
CSS: transform vs top/left
/* Before: top/left 애니메이션. 매 프레임 Layout + Paint + Composite */
.modal {
position: absolute;
top: 0;
left: 0;
transition: top 0.3s, left 0.3s;
}
.modal.open {
top: 50%;
left: 50%;
}
/* After: transform 애니메이션. Composite만 실행, GPU가 처리 */
.modal {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
opacity: 0;
transition: transform 0.3s, opacity 0.3s;
}
.modal.open {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
} top/left를 바꾸면 브라우저가 주변 요소 위치를 전부 다시 계산한다. transform은 해당 요소만 GPU에서 이동시키니까 다른 요소에 영향이 없다. 보이는 결과는 같은데 비용이 완전히 다르다.
will-change 선택적 사용
/* 실제 애니메이션이 발생하는 요소에만 */
.animated-overlay {
will-change: transform, opacity;
}
/* 애니메이션이 끝나면 해제 */
.animated-overlay.done {
will-change: auto;
} will-change는 “이 요소가 곧 변할 거야”라고 브라우저한테 알려주는 힌트다. GPU가 미리 레이어를 만들어두는데, 모든 요소에 남발하면 GPU 메모리만 낭비된다. 실제로 움직이는 요소에만, 필요할 때만 쓰자.
Canvas: CPU에서 GPU로
Canvas2D는 CPU에서 매 프레임 직접 그린다. 도형 몇 개는 괜찮은데, 수천 개를 매 프레임 그리면 메인 스레드가 막혀버린다.
// Before: Canvas2D로 매 프레임 파티클 렌더링. CPU 부하 급증
const ctx = canvas.getContext('2d')!
function drawFrame() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
particles.forEach(p => {
ctx.beginPath()
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
ctx.fillStyle = p.color
ctx.fill()
})
requestAnimationFrame(drawFrame)
} GPU로 옮기는 방법은 두 가지다.
첫째, WebGL(또는 Three.js, PixiJS 같은 래퍼)로 렌더링 자체를 GPU에서 처리한다. 수만 개의 파티클도 60fps로 돌릴 수 있다.
둘째, OffscreenCanvas로 렌더링 연산을 Web Worker에 넘긴다. 메인 스레드에서 UI 블로킹이 사라진다.
// main.ts: Canvas 제어권을 Worker로 전달
const canvas = document.getElementById('canvas') as HTMLCanvasElement
const offscreen = canvas.transferControlToOffscreen()
const worker = new Worker(new URL('./render-worker.ts', import.meta.url), { type: 'module' })
worker.postMessage({ canvas: offscreen }, [offscreen])
// render-worker.ts: Worker에서 렌더링 루프 실행
self.onmessage = (e) => {
const canvas = e.data.canvas as OffscreenCanvas
const ctx = canvas.getContext('2d')!
function drawLoop() {
// 모든 렌더링 로직이 여기서 실행
// 메인 스레드는 자유롭다
requestAnimationFrame(drawLoop)
}
drawLoop()
} CSS든 Canvas든 결국 같은 얘기다. CPU가 매 프레임 하던 무거운 일을 GPU로 넘기거나 Worker로 빼면, 메인 스레드가 가벼워진다.
체크리스트
| 항목 | 핵심 액션 | 영향 지표 |
|---|---|---|
| 리렌더링 | 상태 하강, memo+useCallback, key에 고유 ID | JS 실행 시간 |
| 이미지/폰트 | WebP/AVIF, lazy loading, preload, font-display: swap | 로딩 속도, 화면 안정성 |
| 번들 | dynamic import, barrel export 피하기, 번들 분석 | 상호작용 가능 시점 |
| 레이아웃 쉬프트 | width/height 지정, skeleton UI | CLS |
| 이벤트 | debounce/throttle, passive listener, rAF | FPS, 메인 스레드 |
| GPU 렌더링 | transform/opacity, will-change, OffscreenCanvas | FPS, CPU 사용률 |
다 적용할 필요는 없다. DevTools Performance 탭이나 Lighthouse로 병목을 먼저 찾고, 해당되는 것만 고치면 된다. 감으로 하는 최적화는 보통 효과가 없다.
참고 자료
- Optimize Cumulative Layout Shift - web.dev - CLS 최적화 가이드
- Chrome Lighthouse - Serve images in modern formats - 이미지 포맷 최적화
- Tree Shaking - webpack - 안 쓰는 코드 제거 원리와 설정
- CSS Animation Performance - GPU 합성과 애니메이션 성능
- SVG vs Canvas vs WebGL Performance - 렌더링 방식별 성능 비교