성능 병목 현상 식별 및 해결
Lighthouse·DevTools·Speed Insights·번들 분석기로 병목을 측정하고 이미지·폰트·스크립트·데이터를 개선합니다.
애플리케이션은 기능이 정상적으로 동작하는 것만큼 빠르게 반응하는 것도 중요합니다. 사용자는 빠르고 반응성이 좋은 웹사이트를 선호하며, 느린 웹사이트는 사용자 이탈로 이어질 수 있습니다. Next.js는 기본적으로 좋은 성능을 내도록 설계되었지만, 복잡한 로직, 과도한 데이터 페칭, 최적화하지 않은 이미지 등으로 성능 병목이 생길 수 있습니다. 이 글에서는 Next.js 애플리케이션에서 성능 병목을 찾아내는 도구와, 이를 해결하는 구체적인 전략을 알아봅니다.
성능 병목 현상이란?
성능 병목 현상(performance bottleneck)은 애플리케이션의 전체 성능을 떨어뜨리는 특정 부분이나 자원을 말합니다. 병의 목처럼 데이터나 처리 흐름이 한 지점에서 막혀 전체 시스템의 속도를 늦추는 현상입니다.
병목을 찾을 때는 시간을 단순히 더하지 않고, 완료를 기다리는 경로를 찾습니다. 독립적인 상품 조회 A(300ms)와 재고 조회 B(500ms)를 동시에 시작하고, 둘 다 끝난 뒤 렌더링(40ms)과 전송(60ms)을 차례로 한다고 합시다. 다른 대기 시간이 없다면 전체는 max(300, 500) + 40 + 60 = 600ms입니다. A만 150ms로 줄여도 B를 기다리므로 전체는 그대로이고, A가 300ms인 채로 B를 350ms로 줄이면 전체는 450ms가 됩니다. 반면 B가 A의 결과를 받아야 시작할 수 있다면 두 조회는 직렬이므로 900ms입니다. 그래서 실제 기록에서 요청의 시작과 종료, 의존 관계를 확인한 뒤 개선 대상을 고릅니다.
웹 애플리케이션에서 병목이 주로 생기는 영역은 다음과 같습니다.
- 네트워크 요청: 과도한 API 호출, 큰 이미지와 비디오 파일, 최적화하지 않은 CSS와 JavaScript 번들
- 렌더링 성능: 복잡한 UI, 불필요한 다시 렌더링, 비효율적인 CSS 사용
- 서버 응답 시간: 비효율적인 데이터베이스 쿼리, 복잡한 서버 쪽 로직
- 클라이언트 쪽 JavaScript 실행: 긴 스크립트 실행 시간, 메인 스레드를 막는 작업
성능 병목 현상 식별 도구
문제를 해결하려면 먼저 문제가 어디에서 생기는지 정확히 파악해야 합니다. 웹 성능 분석에 쓰는 주요 도구들을 살펴봅니다.
Lighthouse로 기준 측정하기
Lighthouse는 성능, 접근성, 권장사항(Best Practices), SEO 카테고리와 LCP, CLS, TBT 같은 실험실(lab) 지표를 측정합니다. 개발자 도구에서 직접 실행하거나, PageSpeed Insights 웹사이트에 URL을 입력해 쓸 수 있습니다. TBT(Total Blocking Time)는 로딩 중 메인 스레드가 막힌 양을 나타내며, 실제 상호작용의 응답을 측정하는 INP를 대신하지 않습니다. Lighthouse 12부터 PWA 카테고리는 제거되었으므로, 설치 가능성, manifest, Service Worker와 오프라인 동작은 별도의 점검표와 DevTools의 Application 패널에서 확인합니다. Lighthouse의 진단과 절감 예상치는 실험실 실행 조건에서 얻은 개선 후보이며, 실제 사용자에게 같은 효과가 난다고 보장하지는 않습니다.
TBT는 측정 구간에 포함된 메인 스레드 작업마다 50ms를 넘는 부분을 더합니다. 작업이 40ms, 80ms, 130ms라면 총 실행 시간은 250ms지만 TBT 기여량은 0 + 30 + 80 = 110ms입니다. 정확히 50ms인 작업의 기여량은 0입니다. 어떤 구간을 집계했는지도 확인해야 하며, 이 계산만으로 개별 사용자의 클릭 응답 시간을 알 수는 없습니다.
Chrome DevTools
Chrome 개발자 도구는 웹 애플리케이션의 성능을 그 자리에서 분석하는 기본 도구입니다.
Network 탭은 페이지를 불러올 때 생기는 모든 네트워크 요청(HTML, CSS, JS, 이미지, API 호출 등)을 시각화합니다. 각 자원의 크기, 로딩 시간, 캐시 여부 등을 확인해 불필요하게 큰 파일이나 느린 요청을 찾을 수 있습니다. Disable cache를 켜면 DevTools가 열린 동안 브라우저 HTTP 캐시의 재사용을 끄고, Throttling으로 느린 네트워크 조건을 재현할 수 있습니다. 다만 이 설정만으로 Service Worker, CDN, 서버의 캐시까지 비워지지는 않습니다.
Performance 탭은 스크롤이나 클릭 같은 사용자 상호작용 중의 런타임 성능을 기록하고 분석합니다. JavaScript 실행 시간, 렌더링 시간, 레이아웃 재계산 시간 등을 그래프로 보여 주므로, 긴 작업을 하는 함수나 불필요한 다시 렌더링을 찾아낼 수 있습니다. Record and reload 버튼을 쓰면 페이지 로딩부터의 전체 과정을 기록합니다. Memory 탭은 애플리케이션의 메모리 사용량을 분석해 메모리 누수나 과도한 메모리 사용을 찾는 데 씁니다.
Vercel Speed Insights
Vercel Speed Insights는 Vercel에 배포한 Next.js 애플리케이션에서 실제 사용자의 데이터를 바탕으로 Core Web Vitals 같은 성능 지표를 수집하고 시각화합니다. 개발 환경의 테스트를 넘어 실제 사용자 환경의 성능을 보고 문제 해결의 우선순위를 정할 수 있습니다. 쓰려면 Vercel 대시보드에서 기능을 켜고 @vercel/speed-insights를 설치한 뒤, 루트 레이아웃에 <SpeedInsights />를 렌더링해야 합니다.
번들 분석기
번들 분석기는 빌드에 포함된 패키지와 모듈의 크기, 의존 관계를 분석합니다. Turbopack을 쓰는 프로젝트는 Next.js 16.1부터 제공하는 next analyze 명령으로 서버와 클라이언트 모듈의 크기와 임포트 경로를 대화형 화면에서 살펴볼 수 있고, next analyze --output은 결과를 .next/diagnostics/analyze에 저장해 개선 전후를 비교할 수 있게 합니다. Webpack을 쓰는 프로젝트는 @next/bundle-analyzer 플러그인을 씁니다. 이렇게 찾은 큰 라이브러리는 제거하거나, 동적 임포트(next/dynamic)를 적용하거나, 브라우저 API가 필요 없는 무거운 변환 작업이라면 서버 컴포넌트로 옮기는 후보가 됩니다.
Next · Performance observation scopes
성능 자료의 관측 범위
실험실 실행, 실사용 수집, 실행 기록, 번들 분석은 각각 다른 질문에 답합니다. 조건·표본·기간을 기록하고 자료의 범위에 맞춰 원인과 변경 효과를 판단합니다.
| 자료 | 확인할 수 있는 것 | 단정할 수 없는 것 |
|---|---|---|
| 실험실 실행 | 정한 기기·네트워크의 LCP·CLS 로딩 중 메인 스레드 차단량 TBT | 그 점수가 모든 사용자의 경험을 대표하지는 않습니다. |
| 실사용 수집 | 실제 방문에서 수집된 LCP·INP·CLS의 분포 | 표본·기간·기기 구성이 다르면 단순 전후 비교가 어렵습니다. |
| 실행 기록 | 특정 재현 구간의 긴 작업·레이아웃·이벤트 처리 | 한 번의 trace만으로 전체 사용자 원인을 확정하지 않습니다. |
| 번들 분석 | 포함된 모듈의 크기와 import 관계 | 파일 크기만으로 실제 실행 시간이나 사용자 지연을 알 수 없습니다. |
- 실험실 실행
- 확인할 수 있는 것
- 정한 기기·네트워크의 LCP·CLS로딩 중 메인 스레드 차단량 TBT
- 단정할 수 없는 것
- 그 점수가 모든 사용자의 경험을 대표하지는 않습니다.
- 실사용 수집
- 확인할 수 있는 것
- 실제 방문에서 수집된 LCP·INP·CLS의 분포
- 단정할 수 없는 것
- 표본·기간·기기 구성이 다르면 단순 전후 비교가 어렵습니다.
- 실행 기록
- 확인할 수 있는 것
- 특정 재현 구간의 긴 작업·레이아웃·이벤트 처리
- 단정할 수 없는 것
- 한 번의 trace만으로 전체 사용자 원인을 확정하지 않습니다.
- 번들 분석
- 확인할 수 있는 것
- 포함된 모듈의 크기와 import 관계
- 단정할 수 없는 것
- 파일 크기만으로 실제 실행 시간이나 사용자 지연을 알 수 없습니다.
그림은 성능 자료마다 답할 수 있는 질문이 다르다는 점을 정리합니다. 실험실 실행은 정한 기기와 네트워크에서의 LCP, CLS, TBT를 보여 주지만, 그 점수가 모든 사용자의 경험을 대표하지는 않습니다. 실사용 수집은 실제 방문에서 모은 LCP, INP, CLS의 분포를 보여 주지만, 표본, 기간, 기기 구성이 다르면 단순한 전후 비교가 어렵습니다. 실행 기록은 특정 재현 구간의 긴 작업과 이벤트 처리를, 번들 분석은 포함된 모듈의 크기와 임포트 관계를 보여 줍니다. 한 번의 기록이나 파일 크기만으로 전체 사용자의 원인이나 실제 지연을 확정하지 않으며, 조건, 표본, 기간을 함께 기록하고 자료의 범위에 맞춰 판단합니다.
성능 병목 현상 해결 전략
병목을 찾았다면 다음 전략들로 성능을 개선할 수 있습니다. 어떤 전략을 먼저 볼지는 앞의 도구가 가리킨 지표에 따라 정합니다. LCP나 CLS가 나쁘면 이미지와 폰트를, TBT나 INP가 나쁘면 스크립트, 동적 분할, 컴포넌트 렌더링을, 서버 응답 자체가 느리면 데이터 페칭과 서버 로직을 먼저 봅니다. 여러 전략을 한꺼번에 바꾸면 어느 변경이 효과를 냈는지 알 수 없으므로, 하나씩 적용하고 같은 조건에서 다시 측정합니다.
이미지 최적화
next/image 컴포넌트는 기본 이미지 최적화기로 허용한 포맷과 크기 후보를 제공합니다. 실제로 어떤 파일을 고를지는 브라우저 지원, sizes, 이미지 설정 등에 따라 달라집니다. 기본값인 loading="lazy"는 브라우저가 계산한 뷰포트 주변 거리에 이르면 이미지를 불러오기 시작하므로, 첫 화면의 중요한 이미지는 지연시키지 않도록 따로 판단합니다. placeholder="blur"를 정적 import가 만든 blurDataURL이나 직접 넣은 blurDataURL과 함께 쓰면 로딩 중에 흐릿한 미리보기를 보여 줍니다.
CLS를 줄이는 핵심은 이미지의 공간을 먼저 확보하는 것입니다. width와 height를 지정하거나, 크기가 정해진 부모 안에서 fill을 써서 이미지의 비율과 공간을 미리 잡습니다. 아이콘이나 단순한 그래픽은 SVG 형식으로 만들어 파일 크기를 줄입니다.
폰트 최적화
next/font는 폰트를 자체 호스팅하고 대체 폰트를 조정해 표시 지연과 레이아웃 이동을 줄입니다. swap 자체가 CLS 0을 보장하지는 않으므로 실제 글꼴과 배치를 확인합니다. Google Fonts 등을 쓸 때도 폰트를 앱의 정적 자산으로 자체 호스팅하므로, 브라우저가 Google의 폰트 서버에 직접 요청하지 않습니다. Vercel에 배포해야만 쓸 수 있는 기능도 아닙니다.
스크립트 최적화
next/script는 Google Analytics나 광고 같은 서드파티 스크립트의 로딩 전략을 제어해 페이지 로딩에 주는 영향을 줄입니다. 꼭 필요하지 않은 스크립트는 strategy="afterInteractive"나 strategy="lazyOnload"로 로딩 시점을 늦춥니다. 쓰지 않는 라이브러리나 스크립트는 제거해 번들 크기를 줄입니다.
데이터 페칭 및 캐싱 전략
초기 로딩에 필요한 데이터는 서버 컴포넌트에서 가져오면 클라이언트의 JavaScript 번들을 줄이고, SSR과 SSG로 빠른 초기 렌더링을 제공할 수 있습니다.
Next.js의 데이터 캐싱도 활용합니다. fetch 응답은 기본적으로 캐시되지 않으므로, 반복해서 재사용할 요청에는 cache: 'force-cache'를 지정합니다. use cache는 Next.js 16에서 cacheComponents: true를 켠 프로젝트에 적용할 수 있습니다. 한 번의 렌더링 안에서 같은 fetch 요청은 메모이제이션될 수 있지만, 이는 요청 사이에 응답을 보존하는 Data Cache와 다릅니다. 캐시한 경로의 갱신은 revalidatePath로, 태그의 SWR 방식 갱신은 revalidateTag(tag, 'max')로, 서버 액션 안의 즉시 만료는 updateTag로 처리합니다. Cache Components를 쓰지 않는 Node.js 라우트에서는 export const revalidate = 60; 같은 세그먼트 설정을 쓸 수 있고, cacheComponents: true인 모델에서는 캐시 대상 안에서 cacheLife 같은 그 모델의 API를 씁니다. 응답 DTO와 필드 선택으로 화면에 필요한 필드만 조회하고 반환하면 데이터베이스 작업과 네트워크 전송량도 줄어듭니다.
캐시의 효과와 쿼리의 개선은 따로 봅니다. 첫 요청에서 DB 조회에 700ms가 걸렸는데 다음 요청이 Data Cache의 값으로 20ms 만에 끝났다면, 재사용 경로가 빨라진 것입니다. DB 쿼리 자체가 700ms에서 20ms로 개선되었다고 결론 낼 수는 없습니다. 쿼리 개선을 비교하려면 양쪽 모두 캐시 미적중처럼 같은 조건에서 서버 쪽 조회 시간을 측정하고, 캐시 적중 때의 응답은 따로 기록합니다.
동적 분할 적용 지점
Next.js는 라우트와 컴포넌트 단위로 코드를 자동 분할하지만, 공유 모듈이나 링크 prefetch 때문에 다른 라우트의 코드를 미리 불러올 수도 있습니다. 특정 컴포넌트나 라이브러리가 초기 로딩에 꼭 필요하지 않다면 next/dynamic으로 동적 임포트를 적용해, 필요할 때만 불러오게 합니다. 큰 차트 라이브러리나 에디터 등에서 특히 유용합니다.
"use client";
import dynamic from 'next/dynamic';
const DynamicMap = dynamic(() => import('./MapComponent'), {
ssr: false, // 이 컴포넌트는 클라이언트에서만 렌더링
loading: () => <p>지도를 로딩 중...</p>,
});
export default function ClientOnlyMap() {
return (
<div>
<DynamicMap />
</div>
);
}이 예제는 ClientOnlyMap이 렌더될 때마다 DynamicMap을 포함합니다. ssr: false는 지도 컴포넌트의 서버 사전 렌더링을 생략하는 설정일 뿐, 사용자가 지도 버튼을 누를 때까지 표시를 미룬다는 뜻은 아닙니다. 클릭한 뒤에만 필요하다면 별도의 상태와 조건부 렌더링으로 그 시점을 정합니다. 실제 청크 요청은 번들러, prefetch, 캐시의 영향도 받으므로 Network 기록으로 확인합니다.
컴포넌트 최적화 및 렌더링 성능
불필요한 다시 렌더링을 줄일 때는 memo로 컴포넌트를, useMemo로 계산 결과를, useCallback으로 함수 참조를 재사용할 수 있습니다. 다만 측정된 비용에 적용해야 하며, 이것만으로 재렌더 생략이나 성능 향상이 보장되지는 않습니다. 자주 바뀌는 상태는 상위 컴포넌트가 props로 하위에 넘기기보다, 그 상태를 쓰는 컴포넌트 안에서 관리하도록 구조를 조정합니다.
CSS도 살펴봅니다. Tailwind CSS 4는 소스에서 감지한 클래스에 필요한 유틸리티만 생성합니다. 동적으로 조립한 클래스 이름은 감지되지 않을 수 있으므로 완전한 문자열로 적습니다. CSS-in-JS 라이브러리를 쓴다면 서버 사이드 렌더링을 위한 설정이 올바른지 확인합니다.
수백, 수천 개의 항목이 있는 긴 목록은 가상화(virtualization)로 다룹니다. react-window 같은 라이브러리를 쓰면 뷰포트 주변의 일부 항목만 렌더링해 DOM과 렌더 비용을 줄일 수 있습니다. 이때 overscan 범위와 키보드 탐색도 함께 확인합니다.
서버 측 로직 최적화
Route Handler와 서버 액션에서는 먼저 데이터베이스 쿼리를 최적화합니다. 조인이나 배치 조회로 반복 쿼리를 줄여 N+1 쿼리 문제를 해결하되, Mongoose의 populate가 SQL JOIN처럼 항상 단일 쿼리를 실행하지는 않으므로 실제 조회 횟수와 지연을 확인합니다. 자주 조회하는 필드에는 데이터베이스 인덱스를 만들어 검색 속도를 높이고, 필요한 필드만 select해 가져옵니다.
서버리스의 콜드 스타트(cold start)도 측정부터 합니다. Proxy는 Node.js 런타임을 쓰며, Edge Route Handler는 지역 분산의 이점이 측정될 때만 고릅니다. 주기적인 웜업 요청은 새 인스턴스나 늘어난 인스턴스의 준비 상태를 보장하지 않습니다. 공급자의 실행 모델과 실제 콜드 스타트의 비중을 먼저 확인하고, 주기 호출의 비용도 함께 계산합니다.
성능 병목을 찾고 해결하는 일은 반복되는 과정입니다. URL, 기기, 네트워크, 캐시 상태를 기록하고 한 가지 수정의 전후를 같은 조건에서 비교합니다. 실사용 지표는 표본 수와 기간을 함께 확인해 개선의 우선순위를 정합니다.
정리
- 병목은 시간을 단순히 더하지 않고 완료를 기다리는 경로에서 찾으며, 동시 작업은 가장 긴 작업이 전체를 정합니다.
- Lighthouse의 실험실 지표, DevTools의 기록, Speed Insights의 실사용 지표, 번들 분석은 각각 다른 질문에 답합니다.
- TBT는 작업마다 50ms를 넘는 부분의 합이며, 실제 상호작용의 INP를 대신하지 않습니다.
- 이미지는 공간을 먼저 확보하고, 폰트는
next/font로 자체 호스팅하며, 서드파티 스크립트는next/script로 로딩 시점을 늦춥니다. - 캐시 적중으로 빨라진 응답과 DB 쿼리 자체의 개선은 같은 조건에서 따로 측정합니다.
ssr: false는 서버 사전 렌더링을 생략할 뿐이며, 표시 시점은 조건부 렌더링으로 정합니다.
연습 문제
1. 동시 작업의 전체 시간
독립적인 조회 A(400ms)와 B(250ms)를 동시에 시작하고, 둘 다 끝나면 렌더링 50ms와 전송 50ms를 차례로 합니다. 다른 대기는 없습니다. 전체 시간을 구하고, B만 100ms로 줄였을 때와, 원래 B는 그대로 두고 A만 200ms로 줄였을 때의 시간을 각각 구하세요.
해설 보기
처음에는 max(400, 250) + 50 + 50 = 500ms입니다. B만 줄여도 A의 400ms를 기다리므로 그대로 500ms입니다. A만 200ms로 줄이면 이번에는 B가 더 길어져 max(200, 250) + 50 + 50 = 350ms입니다. 동시에 시작하고 두 결과를 모두 기다린다는 조건이 있어서 max로 계산하며, 한쪽을 줄인 효과는 다른 쪽보다 짧아지는 지점까지만 나타납니다.
2. TBT 기여량 계산하기
TBT 집계 구간에 온전히 들어간 작업이 45ms, 70ms, 200ms입니다. 이 세 작업의 TBT 기여량을 구하고, 정확히 50ms인 작업 하나를 더했을 때의 기여량도 구하세요. 이 값을 실제 사용자의 INP라고 말할 수 있을까요?
해설 보기
0 + (70 − 50) + (200 − 50) = 170ms입니다. 45ms 작업은 50ms를 넘지 않아 기여량이 0입니다. 50ms 작업도 넘는 부분이 없으므로 합계는 그대로 170ms입니다. TBT는 그 로딩 측정 구간의 차단량이며, 실제 상호작용의 응답을 측정한 INP와 같다고 볼 수 없습니다.
3. 순차 작업에서 우선순위 정하기
한 요청에서 서로 겹치지 않는 순차 작업의 시간이 DB 조회 700ms, 서버 렌더 30ms, 그 뒤 응답 전송 70ms였다고 합시다. 다른 비용은 없고 결과 데이터는 같다는 연습 조건입니다. 렌더 30ms를 모두 없애는 안과 DB 조회를 200ms로 줄이는 안의 전체 시간은 각각 얼마일까요?
해설 보기
처음에는 700 + 30 + 70 = 800ms입니다. 렌더 비용을 모두 없애면 770ms로 30ms가 줄고, DB 조회가 200ms가 되면 300ms로 500ms가 줄어듭니다. 이 조건에서는 DB가 먼저 살펴볼 대상입니다. 실제 작업이 겹쳐 실행된다면 단순 합산을 그대로 쓰지 말고 시간축을 확인해야 합니다.
Network의 Disable cache를 켠 재측정이라도 서버의 Data Cache나 Service Worker의 응답은 별도 조건입니다. 변경 전후에 어떤 계층을 재사용했는지 함께 기록해야 DB 개선과 캐시 적중을 혼동하지 않습니다. 이 수치는 연습용이며 실제 앱의 측정 결과가 아닙니다.
4. ssr: false와 표시 시점
위 지도 예제에는 표시를 제어하는 버튼이나 상태 조건이 없습니다. ssr: false이므로 사용자가 클릭할 때까지 지도를 표시하지 않는다는 설명은 맞을까요?
해설 보기
맞지 않습니다. 지도는 ClientOnlyMap의 렌더 결과에 항상 포함됩니다. 서버 사전 렌더링을 생략하는 것과 클릭 조건으로 표시를 미루는 것은 별개입니다. 필요한 표시 시점을 상태와 조건부 렌더링으로 정한 뒤, 실제 청크를 불러오는 시점은 Network 기록으로 확인합니다.
5. 캐시 적중과 쿼리 개선
변경 전에는 캐시 미적중 요청의 DB 조회가 900ms였습니다. 변경 후에는 Data Cache 적중 요청 전체가 35ms입니다. 이 두 값만으로 DB 쿼리가 865ms 빨라졌다고 결론 낼 수 있을까요? 추가로 맞춰야 할 비교 조건을 제시하세요.
해설 보기
결론 낼 수 없습니다. 뒤의 요청은 저장된 조회 결과를 재사용했으므로 DB 작업을 직접 비교하지 않았습니다. 같은 데이터와 부하에서 양쪽 모두 캐시 미적중 조건의 DB 조회 시간을 비교하고, 캐시 적중 때의 응답 시간은 별도 결과로 제시합니다. 서버 시간, 전송 시간, 브라우저 렌더 시간 가운데 무엇을 쟀는지도 양쪽에서 같게 맞춥니다.
번들 분석 방법은 Next.js의 패키지 번들링 최적화 안내에서, Core Web Vitals 지표는 web.dev의 Web Vitals 안내에서 확인할 수 있습니다.
다음 글 브라우저 개발자 도구 활용에서는 Elements, Console, Network, Sources 같은 패널로 Next.js 애플리케이션을 디버깅하는 방법을 알아봅니다.