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