왜 성능이 중요한가
Google의 연구에 따르면 페이지 로딩이 1초 지연될 때마다 전환율이 약 7% 하락합니다. 모바일에서 3초 이상 걸리면 53%의 사용자가 이탈합니다. 성능은 사용자 경험의 문제이자 비즈니스 지표와 직결되는 문제입니다.
Core Web Vitals 이해하기
Google이 정의한 사용자 경험의 핵심 지표입니다. 검색 순위에도 영향을 줍니다.
LCP (Largest Contentful Paint)
페이지에서 가장 큰 콘텐츠(이미지나 텍스트 블록)가 화면에 표시되는 시간입니다. 사용자가 "페이지가 로드됐다"고 느끼는 시점에 가장 가깝습니다.
- 좋음: 2.5초 이하
- 개선 필요: 2.5~4초
- 나쁨: 4초 초과
LCP 개선 방법: 가장 큰 이미지를 미리 로드(<link rel="preload">), 서버 응답 속도 개선, 렌더링 차단 리소스 제거
INP (Interaction to Next Paint)
사용자가 클릭, 탭, 키보드 입력을 했을 때 화면이 반응하기까지 걸리는 시간입니다. 2024년에 FID(First Input Delay)를 대체했습니다.
- 좋음: 200ms 이하
- 개선 필요: 200~500ms
- 나쁨: 500ms 초과
INP 개선 방법: 무거운 JavaScript 연산을 Web Worker로 분리, 이벤트 핸들러 최적화
CLS (Cumulative Layout Shift)
페이지 로딩 중 레이아웃이 얼마나 갑자기 이동하는지 측정합니다. 이미지 크기를 지정하지 않아 로딩 후 콘텐츠가 밀리는 현상이 대표적입니다.
- 좋음: 0.1 이하
- 개선 필요: 0.1~0.25
- 나쁨: 0.25 초과
CLS 개선 방법: 이미지/영상에 width, height 속성 지정, 광고나 동적 콘텐츠를 위한 공간 미리 확보
이미지 최적화 구체적 방법
이미지는 일반적으로 웹 페이지 용량의 50~70%를 차지합니다. 이미지 최적화만으로도 성능을 크게 개선할 수 있습니다.
Next.js의 next/image 사용
next/image 컴포넌트를 사용하면 다음이 자동으로 처리됩니다.
- WebP/AVIF 형식으로 자동 변환
- 디바이스 해상도에 맞는 크기로 리사이징
- Lazy loading (뷰포트에 들어올 때 로드)
priorityprop으로 LCP 이미지 우선 로드
import Image from 'next/image'
// 일반 이미지 (lazy loading)
<Image
src="/product.jpg"
alt="상품 이미지"
width={800}
height={600}
/>
// LCP 이미지 (우선 로드)
<Image
src="/hero.jpg"
alt="히어로 이미지"
fill
priority
/>
WebP 포맷 사용
JPEG 대비 25~35%, PNG 대비 최대 90% 작은 파일 크기를 가집니다. Squoosh, Sharp 같은 도구로 변환할 수 있습니다.
Lazy Loading
뷰포트 밖의 이미지는 나중에 로드합니다. next/image를 사용하면 기본으로 적용됩니다. 일반 <img> 태그라면 loading="lazy" 속성을 추가합니다.
JavaScript 번들 최적화
Code Splitting
모든 JavaScript를 하나의 파일로 묶으면 초기 로딩이 느려집니다. 코드 스플리팅은 필요한 코드를 필요한 시점에만 로드하는 방법입니다.
Next.js는 페이지별로 자동으로 코드를 분리합니다. 추가로 무거운 컴포넌트는 dynamic import로 지연 로드합니다.
import dynamic from 'next/dynamic'
// 차트 라이브러리처럼 무거운 컴포넌트를 지연 로드
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <p>로딩 중...</p>,
ssr: false // 서버에서 렌더링 불필요 시
})
Tree Shaking
사용하지 않는 코드를 번들에서 제거하는 과정입니다. 모던 번들러(webpack, Rollup)는 자동으로 처리합니다.
주의할 점은 라이브러리 전체를 import하지 않는 것입니다.
// 나쁜 예: lodash 전체를 import
import _ from 'lodash'
_.debounce(fn, 300)
// 좋은 예: 필요한 함수만 import
import debounce from 'lodash/debounce'
debounce(fn, 300)
캐싱 전략
HTTP 캐시
서버 응답에 Cache-Control 헤더를 설정해 브라우저가 응답을 재사용하게 합니다.
Cache-Control: public, max-age=31536000 # 1년 (정적 자산)
Cache-Control: no-cache # 매번 서버에 확인
Cache-Control: private, max-age=300 # 5분 (사용자별 데이터)
CDN (Content Delivery Network)
정적 파일을 전 세계 엣지 서버에 분산 저장합니다. 사용자와 가까운 서버에서 파일을 전달하므로 레이턴시가 줄어듭니다. Vercel, Cloudflare는 자동으로 CDN을 제공합니다.
SWR / React Query
클라이언트에서 API 응답을 캐시합니다. 같은 데이터를 여러 컴포넌트에서 요청해도 실제 API 호출은 한 번만 합니다. 백그라운드에서 데이터를 갱신하면서 화면은 즉시 보여주는 "stale-while-revalidate" 패턴을 구현합니다.
import useSWR from 'swr'
function PostList() {
const { data, error, isLoading } = useSWR('/api/posts', fetcher)
// 데이터가 캐시에 있으면 즉시 표시, 백그라운드에서 갱신
}
데이터베이스 쿼리 최적화 기초
인덱스 추가
자주 검색하는 컬럼에 인덱스를 추가하면 쿼리 속도가 크게 향상됩니다.
-- 이메일로 자주 검색한다면
CREATE INDEX idx_users_email ON users(email);
-- 날짜 범위 검색이 많다면
CREATE INDEX idx_posts_created_at ON posts(created_at);
단, 인덱스가 많으면 쓰기 성능이 저하됩니다. 실제로 자주 사용되는 쿼리에만 인덱스를 추가합니다.
N+1 문제 해결
N+1 문제는 목록 조회 시 각 항목마다 추가 쿼리가 발생하는 문제입니다.
// 나쁜 예 (N+1): 게시글 100개 조회 후 각 게시글의 작성자를 별도로 조회
const posts = await db.posts.findMany() // 쿼리 1회
for (const post of posts) {
post.author = await db.users.findUnique({ where: { id: post.userId } })
// 게시글 수만큼 추가 쿼리 발생 (N회)
}
// 좋은 예: JOIN으로 한 번에 조회
const posts = await db.posts.findMany({
include: { author: true } // Prisma에서 자동으로 JOIN 처리
})
성능 측정 도구
Lighthouse
Chrome DevTools나 web.dev/measure에서 실행할 수 있습니다. Performance, Accessibility, SEO, Best Practices를 0~100 점수로 보여주고 개선 방법을 제안합니다.
PageSpeed Insights
Google이 제공하는 실제 사용자 데이터(CrUX)와 Lighthouse 분석을 함께 보여줍니다. 모바일과 데스크톱 점수를 별도로 확인할 수 있습니다.
WebPageTest
더 상세한 분석이 필요할 때 사용합니다. 다양한 네트워크 환경, 위치, 브라우저에서 테스트할 수 있고 폭포수(Waterfall) 차트로 각 자원이 언제 로드되는지 시각적으로 확인할 수 있습니다.
성능 최적화는 한 번에 모든 것을 완벽하게 만들려 하지 않고, 가장 영향이 큰 문제부터 순서대로 해결하는 것이 중요합니다. Lighthouse 점수가 낮은 항목부터 시작하세요.