프론트 성능 최적화 작업 진행 로그
아이차트 성능 지표 개선하기
category
frontend
type
Post
slug
ichart-lighthouse
summary
아이차트 성능 지표 개선하기
date
May 1, 2026
updatedAt
May 1, 2026
tags
iChart
lighthouse
thumbnail
성능 최적화 작업을 시작하면서 가장 먼저 세운 목표는 단순했다.
페이지가 최대한 빠르게 뜨게 만드는 것.
대상은 Project A 서비스 본체와 커뮤니티 플랫폼, 그리고 각각의 어드민 페이지까지 총 4개 앱이었다.
처음부터 코드를 수정하지는 않았다. 먼저 PageSpeed Insights와 시크릿 창에서 현재 상태를 측정하고 스크린샷을 남겼다. 이후 하나의 변경을 적용하고 같은 조건에서 다시 측정했다.
처음에는 Lighthouse 점수를 올리면 되는 문제라고 생각했다.
그런데 실제로 작업을 진행해보니 그렇지 않았다.
LCP를 줄이기 위해 하나의 리소스를 제거했더니 다른 리소스가 새로운 병목으로 드러났고, 번들을 줄였는데도 실제 화면의 체감 속도는 크게 달라지지 않는 경우가 있었다. 반대로 단순히 몇 KB를 줄이는 것보다 서버와 클라이언트의 경계를 다시 나누는 것이 훨씬 큰 효과를 내기도 했다.
더 깊게 들어가면서 문제는 번들 크기에만 있지 않았다.
- 초기 HTML과 JavaScript가 어떻게 전달되는가
- Server Component와 Client Component의 경계가 어디에 있는가
- 공통 패키지의 export 구조가 어떤 dependency graph를 만드는가
- 브라우저가 실제로 필요한 리소스를 언제 다운로드하는가
- TanStack Query가 서버에서 어떤 방식으로 캐시를 유지하는가
- Node.js 프로세스의 메모리가 왜 계속 증가하는가
- CDN과 이미지 프록시가 네트워크 경로에 어떤 영향을 주는가
결국 이번 작업에서 중요했던 것은 무언가를 무작정 제거하는 것이 아니라, 병목이 발생하는 계층을 하나씩 좁혀가는 것이었다.
번들링부터 들여다봤다
ECharts를 통째로 가져오고 있었다
가장 먼저 확인한 것은 JavaScript 번들이었다.
특히 차트 페이지에서
echarts가 눈에 띄었다.기존 코드는 다음과 같은 형태였다
import * as echarts from 'echarts';
이 방식의 문제는 명확했다.
실제 화면에서 사용하는 것은 Line Chart와 몇 가지 컴포넌트뿐인데, 애플리케이션 입장에서는 ECharts의 상당 부분을 초기 dependency graph에 포함시킬 가능성이 있었다.
특히 Next.js App Router에서는 단순히 "파일 하나에서 import했다"로 끝나지 않는다.
Client Component에서 참조하는 dependency는 해당 client graph에 포함되고, 결국 브라우저가 내려받고 파싱하고 실행해야 하는 JavaScript의 일부가 된다.
즉,
페이지 진입 ↓ Client Component ↓ Chart Component ↓ echarts ↓ 대형 dependency graph ↓ 초기 JavaScript 증가 ↓ parse / compile / execute 비용 증가 ↓ hydration 지연
이라는 경로가 만들어질 수 있다.
실제로 차트에서 필요한 기능은 일부뿐이었다.
그래서 ECharts를 모듈 단위로 가져오는 방식으로 변경했다.
import * as echarts from 'echarts/core'; import { LineChart } from 'echarts/charts'; import { GridComponent, TooltipComponent } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
이렇게 하면 애플리케이션이 사용하는 기능을 명시할 수 있다.
중요한 것은 단순히
import 문을 바꾸는 것 자체가 아니었다.실제 빌드 결과에서 필요한 코드만 남았는지를 확인하는 것이 중요했다.
그래서
ANALYZE=true로 빌드하고 실제 chunk 구성을 확인했다.또한
next.config.ts의 optimizePackageImports에도 관련 패키지를 추가했다.optimizePackageImports: [ 'date-fns', 'usehooks-ts', '@project-a/core', '@project-a-team/uikit', 'tailwind-variants', 'echarts', 'echarts-for-react', ],
빌드 결과에서 차트 관련 코드가 초기 페이지 chunk에 남아 있는지, 별도의 dynamic chunk로 빠졌는지를 다시 확인했다.
그 결과
song/[id]의 First Load JS는 약 585kB에서 380kB 수준까지 감소했다.ECharts 관련 초기 번들 포함량은 약 205kB gzip에서 제거됐다.
결국 약 35% 정도의 초기 JavaScript를 줄일 수 있었다.
항목 | Before (추정) | After (실측) |
song/[id] First Load JS | ~585 kB | 380 kB |
ECharts 초기 번들 포함량 | ~205 kB (gzip) | 0 kB |
절감량 | — | ~205 kB / 약 35% 감소 |
중요했던 것은 차트를 제거한 것이 아니라 차트가 필요하지 않은 시점에 차트 코드를 다운로드하지 않도록 만든 것이었다.
Dynamic Import는 "컴포넌트를 나누는 것" 이상의 의미가 있었다
다음 타깃은 메인 페이지인
/rank 확인했다.여기서는 ECharts처럼 하나의 거대한 라이브러리가 문제라기보다, 초기 화면에서 당장 필요하지 않은 UI가 함께 bundle graph에 들어가 있었다.
그동안의 음원 순위 기록과 댓글, 그리고 Swiper 롼련 코드까지 초기 bundle에 포함되고 있었다.
그래서 필요한 영역을
next/dynamic으로 분리했다.const CommentListPanel = dynamic( () => import('./CommentListPanel.client'), { ssr: false, }, );
여기서 한 가지 제약도 있었다.
댓글창의 경우 서버 컴포넌트 경계에서 직접
ssr: false를 사용할 수 있는 구조가 아니었기 때문에 별도의 Client Component wrapper를 만들었다.Server Component │ ├── static content │ └── Client boundary │ └── dynamic import │ └── CommentListPanel
이렇게 하면 초기 렌더링 경로와 브라우저에서 나중에 필요한 코드의 경계를 명확하게 만들 수 있다.
변경 결과
/rank의 Page Chunk는 약 54.3kB에서 49.1kB, First Load JS는 576kB에서 567kB로 감소했다.수치만 보면 아주 큰 변화는 아니었다.
지표 | 변경 전 | 변경 후 | 절감 |
Page Chunk | 54.3 kB | 49.1 kB | -5.2 kB |
First Load JS | 576 kB | 567 kB | -9 kB |
그런데 여기서 더 중요한 문제가 보였다.
TipTap과 ProseMirror가 거의 줄어들지 않고 있었다.
/rank에서는 editor를 사용하지 않는데도 editor 관련 코드가 공통 chunk에 남아 있었다.이때부터 문제의 위치가 페이지에서 패키지 레벨로 이동했다.
공통 UI 패키지에서 더 큰 문제가 발견됐다
빌드 결과를 계속 따라가다 보니
@project-a-team/uikit/components가 여러 페이지에서 공통으로 참조되고 있었다.그리고 그 내부에 editor가 포함되어 있었다.
export { ..., Editor, CompoundEditor, ... } from './editor';
문제는 이 구조였다.
애플리케이션에서 다음처럼 단순한 컴포넌트만 가져와도,
import { BorderScreen } from '@project-a-team/uikit/components';
components/index.ts가 editor를 re-export하고 있기 때문에 bundler가 전체 dependency graph를 따라가면서 editor와 관련된 dependency까지 분석해야 했다.editor 내부에는 다음과 같은 dependency가 있었다.
@tiptap/core
prosemirror-view
- ProseMirror model
- ProseMirror transform
- ProseMirror state
- ProseMirror commands
ANALYZE 결과 editor를 사용하지 않는 페이지에서도 관련 chunk가 공통 번들에 포함되고 있었다.
특히 다음 chunk가 눈에 띄었다.
chunk | 내용 | gzip |
f27b67c4 | @tiptap/core | 27 kB |
52dbf3db | prosemirror-view | 30 kB |
3036 | uikit + ProseMirror | 91 kB |
즉 editor를 사용하지 않는 페이지에서 약 87kB gzip 수준의 TipTap + ProseMirror dependency가 로드되고 있었다.
/rank에서 dynamic import를 하나 더 추가하는 것보다 공통 UI 패키지의 dependency graph 를 제거하는게 먼저였다.공통 패키지 export 구조 문제
1. editor export 제거
// 기존 export { ... Editor, CompoundEditor, ... } from './editor';
editor를 사용하는 앱에서는 직접 editor 경로를 참조하도록 변경했다.
import { Editor } from '@project-a-team/uikit/components/editor';
반대로 editor를 사용하지 않는 페이지에서는 editor dependency graph가 애초에 연결되지 않도록 했다.
2. barrel export를 명시적 export로 변경
기존에는 다음과 같은 패턴이 많았다.
export * from './button'; export * from './dialog'; export * from './datepicker'; export * from './editor';
이를 명시적인 named export 구조로 변경했다.
그리고 애플리케이션 코드에서도 다음과 같은 barrel import를 제거했다.
import { Button, DatePicker, SearchBar, } from '@project-a-team/uikit/components';
대신 실제 사용하는 모듈을 직접 가져왔다.
import { Button } from '@project-a-team/uikit/components/button'; import { DatePicker } from '@project-a-team/uikit/components/datepicker'; import { SearchBar } from '@project-a-team/uikit/components/searchbar';
총 37개 파일의 import를 변경했다.
이 차이는 단순한 코드 스타일 차이가 아니다.
패키지의 public entrypoint가 거대한 barrel이 되면 애플리케이션에서 실제 사용하는 모듈과 관계없는 dependency가 동일한 import graph에 들어오기 쉬워진다.
반대로 모듈 단위 entrypoint를 제공하면 bundler가 실제 사용 경로를 기준으로 dependency를 제거하기 쉬워진다.
변경 전후 First Load JS를 비교했다.
페이지 | 원본 | editor 제거 | 명시적 export | 총 절감 |
/rank | 576kB | 420kB | 411kB | 165kB |
/song/[id] | 551kB | 396kB | 383kB | 168kB |
/search | 523kB | 359kB | 315kB | 208kB |
/artist/[id] | 511kB | 328kB | 284kB | 227kB |
/about/project-a | 496kB | 343kB | 296kB | 200kB |
/history | 359kB | 355kB | 354kB | 5kB |
여기서 재미있는 점은 페이지별 효과가 크게 달랐다는 것이다.
/artist/[id]는 227kB가 줄었지만 /history는 5kB밖에 줄지 않았다.결국 공통 패키지 최적화의 효과는 그 페이지가 실제로 어떤 dependency를 공유하고 있었느냐에 따라 결정된다.
그래서 단순히 "uikit을 최적화해서 200kB를 줄였다"고 설명하기보다는, 실제 dependency graph를 확인하고 어느 페이지에서 어떤 shared dependency가 제거됐는지를 확인하는 것이 중요했다.
Shared JS를 따로 보면 더 명확했다
페이지별 chunk만 보면 원인을 놓칠 수 있어서 Shared First Load JS도 따로 확인했다.
Chunk | 기존 | 변경 후 | 변화 |
Next.js runtime + Sentry | ~153kB | 156kB | +3kB |
react-dom | ~53kB | 54.2kB | +1.2kB |
Sentry Replay | ~36kB | 36.7kB | +0.7kB |
uikit + ProseMirror | 91kB | 제거 | -91kB |
uikit + Radix + date-fns | 54kB | 제거 | -54kB |
prosemirror-view | 30kB | 제거 | -30kB |
@tiptap/core | 27kB | 제거 | -27kB |
기타 shared | — | 5.02kB | — |
합계 | ~444kB | 252kB | -192kB |
페이지 코드만 줄이는 것보다 공통 dependency를 제거했을 때 여러 라우트에 동시에 효과가 발생했다.
이 경험 이후 번들을 볼 때 단순히 "이 페이지의 JS가 몇 KB인가"만 확인하지 않게 됐다.
다음과 같이 나눠서 보기 시작했다.
Route Chunk │ ├── Page-specific dependency │ └── Shared Chunk │ ├── Framework ├── UI library ├── Monitoring └── Unexpected dependency
특히 마지막 항목이 중요했다.
Shared chunk에 왜 이 dependency가 들어갔는가? barrel export나 잘못된 Client Component 경계 같은 구조적인 문제를 찾을 수 있었다.
번들 크기와 별개로 런타임 비용도 확인했다
JavaScript 용량을 줄이는 것만으로는 충분하지 않았다.
리스트 페이지에서는 브라우저가 실제로 몇 개의 DOM element를 만들고 있는지도 확인했다.
기존에는 약 200개의 항목을 한 번에 렌더링하고 있었다.
각 항목 내부에 여러 DOM element가 있기 때문에 실제 DOM node 수는 약 5,000개까지 증가했다.
문제는 브라우저가 단순히 DOM을 만드는 데서 끝나지 않는다는 것이다.
React가 element tree를 생성하고,
React render ↓ DOM mutation ↓ Style calculation ↓ Layout ↓ Paint ↓ Composite
과정을 거친다.
리스트가 커질수록 초기 렌더뿐 아니라 스크롤 중 layout과 paint에도 영향을 준다.
그래서
@tanstack/react-virtual을 사용해 virtualization을 적용했다.화면에 실제로 보이는 영역과 앞뒤의 일부 item만 DOM에 유지하고, 나머지는 데이터로만 관리하는 방식이다.
변경 결과는 다음과 같았다.
항목 | 기존 | virtualization | 개선 |
DOM Element | 약 5,000개 | 약 700개 | 약 86% 감소 |
초기 렌더 | 180ms | 120ms | 약 33% 개선 |
메모리 | 32MB | 24MB | 약 25% 감소 |
React 렌더 비용 | 100% | 60% | 약 40% 감소 |
스크롤 FPS | 54 | 59 | 약 9% 개선 |
Main Thread | 95ms | 65ms | 약 32% 감소 |
여기서 중요한 것은 virtualization이 번들 크기를 줄이는 기술이 아니라는 점이다.
JavaScript를 얼마나 다운로드했는가와, 다운로드한 JavaScript가 브라우저에서 얼마나 많은 일을 해야 하는가는 다른 문제다.
그래서 성능을 볼 때
Network ↓ Download ↓ Parse / Compile ↓ Execute ↓ React Render ↓ DOM ↓ Layout / Paint
각 단계를 분리해서 봐야 했다.
React Query의 캐시 정책도 다시 확인했다
번들과 DOM을 정리한 다음에는 데이터 fetching 쪽을 확인했다.
기존에는
refetchOnWindowFocus와 refetchOnMount가 글로벌하게 활성화되어 있었다.이 설정은 기본적으로 데이터를 최신 상태로 유지하는 데 유용하지만, 모든 query에 동일하게 적용하면 불필요한 네트워크 요청이 발생할 수 있다.
특히 App Router 환경에서는 Suspense와 Client Component boundary 때문에 개발자가 생각한 것보다 mount/remount가 자주 발생할 수 있다.
기존에는 다음과 같은 상황이 반복됐다.
페이지 진입 ↓ query 실행 ↓ cache 저장 ↓ 다른 페이지 이동 ↓ 컴포넌트 remount ↓ refetch
또한 브라우저 탭을 전환했다가 돌아오는 것만으로도 request가 다시 발생했다.
그 결과 실제 데이터가 변경되지 않았는데도
- skeleton이 다시 나타나거나
- spinner가 노출되거나
- 버튼이 잠시 disabled 상태가 되거나
- 네트워크 요청이 반복되는
문제가 발생했다.
그래서 글로벌 기본값을 보수적으로 변경하고, 댓글이나 실시간 랭킹처럼 실제로 자주 갱신해야 하는 query에서만 개별적으로 refetch 정책을 활성화하는 방향으로 변경했다.
캐시의 핵심은 무조건 오래 저장하는 것이 아니라 데이터의 변경 빈도에 맞게 stale 정책을 설계하는 것이었다.
Lighthouse를 다시 돌리니 새로운 병목이 보였다
작업이 어느 정도 쌓인 뒤 Lighthouse를 다시 실행했다.
여기서 성능 최적화 작업의 특성을 다시 확인했다.
하나의 병목을 제거하면 다음 병목이 보인다.
대표적으로 LCP를 분석하면서 최상단 NEW 배너의 이미지가 발견됐다.
이미지가 지연 로딩되고 있었고
fetchpriority="high"도 적용되어 있지 않았다.LCP element가 이미지라면 브라우저가 해당 이미지를 가능한 빠르게 발견하고 요청할 수 있도록 만들어야 한다.
즉 단순히 이미지 용량만 줄이는 문제가 아니라
HTML 발견 ↓ LCP image 발견 ↓ Request 우선순위 ↓ Download ↓ Decode ↓ Paint
전체 경로를 봐야 했다.
SEO 쪽에서는 canonical도 다시 확인했다.
콘텐츠 페이지에서 canonical이 현재 페이지가 아니라 도메인 루트 URL을 가리키는 문제가 있었고, Lighthouse 개선 과정에서 오히려 SEO 점수가 떨어지는 구간도 있었다.
이처럼 성능 최적화는 Lighthouse의 Performance 점수만 보고 진행하면 안 됐다.
BFCache와 HTTP 캐시도 함께 확인했다
페이지 이동 성능을 확인하면서
Cache-Control도 확인했다.기존에는 HTML 응답에
no-store가 적용되어 있어 브라우저가 페이지 탐색 결과를 캐시할 수 있는 여지가 제한되어 있었다.그래서 HTML navigation에 한정해 다음과 같이 정책을 변경했다.
const accept = request.headers.get('accept') ?? ''; if (accept.includes('text/html')) { response.headers.set( 'Cache-Control', 'public, max-age=0, must-revalidate', ); }
여기서 중요한 것은 모든 응답에 캐시 정책을 적용한 것이 아니라는 점이다.
HTML navigation과 API, 정적 asset은 캐시 전략이 서로 다르다.
HTML → 최신 문서 확인 필요 Static Asset → immutable hash 기반 장기 캐시 가능 API → 데이터 특성에 따라 별도 정책 Image → CDN / immutable asset 전략
따라서 성능 최적화에서는 단순히
Cache-Control: max-age=...를 넣는 것보다 어떤 리소스를 어떤 생명주기로 캐시할 것인지를 구분해야 한다.작은 설정 하나도 초기 렌더 경로에 들어오면 비용이 된다
추가로 여러 가지 작은 병목도 확인했다.
Google Translate script가 초기 렌더링 경로에 들어가 있었기 때문에 사용자가 번역 기능을 실제로 요청했을 때만 로드하도록 dynamic import로 변경했다.
그 결과 해당 변경에서 LCP가 다음과 같이 감소했다.
항목 | Before | After |
LCP | 3,224ms | 584ms |
초기 렌더링에 필요하지 않은 third-party script 하나를 critical path에서 제거하는 것만으로도 큰 차이가 발생했다.
폰트도 비슷했다.
Noto Sans KR이 preload되어 있었는데 초기 다운로드 크기가 약 10,426KB였다.
이 폰트를 초기 렌더링에서 무조건 먼저 가져오는 것이 정말 필요한지 다시 확인했고 preload를 제거했다.
항목 | Before | After |
초기 다운로드 | 10,426KB | 0KB |
CSS 파싱 | 1,360ms | 50ms |
여기서 배운 것은 "중요한 리소스니까 preload"가 항상 정답은 아니라는 것이었다.
preload는 브라우저에게 해당 리소스의 우선순위를 높여달라는 의미다.
따라서 실제 LCP에 필요한 리소스가 아니라면 오히려 critical request 경쟁을 발생시킬 수 있다.
1차 성능 최적화 이후에는 빌드 구조를 봤다
성능 문제가 어느 정도 정리되자 장기적으로 유지 가능한 구조인지 확인할 필요가 있었다.
Webpack 설정에는 커스텀 설정이 존재했고 빌드 과정에서 캐시 직렬화 경고가 반복되고 있었다.
그래서 가능한 부분은 Next.js의 기본 SWC 경로를 유지하고
transpilePackages 기반으로 단순화했다.TypeScript도
typescript.ignoreBuildErrors를 제거했다.성능 최적화를 아무리 잘해도 빌드 단계에서 타입 오류가 통과할 수 있다면 장기적으로 안정적인 구조라고 보기 어려웠기 때문이다.
배포 artifact도 다음처럼 분리했다.
.next/ ├── standalone/ ├── static/ └── ... public/
이후 배포 시 실제 Node.js 서버 실행에 필요한 artifact와 정적 파일을 분리해 다룰 수 있도록 했다.
그런데 프로덕션에서는 성능보다 더 큰 문제가 발생했다
2026년 초부터는 문제가 브라우저 성능을 넘어 서버 안정성으로 이동했다.
프로덕션에서 다음과 같은 현상이 동시에 발생했다.
- 이미지 응답 timeout
- 해외 사용자의
Failed to fetch
- TanStack Query cache 이상 동작
- PM2 Cluster 환경에서 OOM
- 서비스 degradation
가장 치명적이였던건 메모리가 지속적으로 증가하다가 프로세스가 죽는 문제였다.
이미지 프록시 timeout은 네트워크 경로부터 확인했다
Next.js Image Optimization을 사용하는 과정에서 외부 이미지 응답이 지연되는 문제가 있었다.
브라우저에서 이미지를 요청하면 단순히
Browser → Origin Image Server
로 끝나는 것이 아니라 애플리케이션의 이미지 최적화 계층을 거칠 수 있다.
Browser ↓ Next.js Image Optimization ↓ External Image Server ↓ Response ↓ Image processing ↓ Browser
외부 이미지 서버의 응답이 느리면 이 전체 경로가 영향을 받는다.
그래서 외부 이미지에 대해서는 custom image loader를 사용해 CDN 경로로 직접 접근하도록 변경했다.
이후
upstream image response timed out 문제가 해결됐다.이 문제 역시 프론트엔드 코드 한 줄만의 문제가 아니라 브라우저 → Next.js → 외부 origin이라는 네트워크 경로 전체를 확인해야 원인을 찾을 수 있었던 사례였다.
TanStack Query의 gcTime에서는 JavaScript timer의 한계를 만났다
다음으로 이상한 refetch 현상을 추적했다.
설정에는 다음과 같이 30일의
gcTime이 적용되어 있었다.30일 = 2,592,000,000ms
문제는 JavaScript의 일반적인 timer delay가 32-bit signed integer 범위와 관련된다는 점이었다.
최대값은 다음과 같다.
2,147,483,647ms
30일은 이 범위를 초과한다.
그 결과
TimeoutOverflowWarning이 발생했고, cache garbage collection timer가 의도한 시간대로 동작하지 않았다.캐시를 오래 유지하기 위해 30일을 설정했지만 오히려 timer overflow라는 다른 문제가 생긴 것이다.
결국
gcTime을 30일에서 7일로 변경했다.이후 overflow warning과 비정상적인 refetch가 사라졌고 서버의 불필요한 부하도 안정화됐다.
이 경험은 숫자 하나를 설정할 때도 단순히 "30일이면 충분히 긴 시간"이라고 생각할 것이 아니라 해당 값이 실제 런타임에서 어떤 primitive로 구현되는지 확인해야 한다는 것을 보여줬다.
가장 큰 문제는 전역 QueryClient였다
그다음으로 PM2 Cluster 환경에서 OOM이 반복되는 문제를 추적했다.
상황은 이랬다.
요청 ↓ Server Component ↓ QueryClient ↓ cache 생성 ↓ 요청 종료
서버에서 QueryClient를 전역 singleton으로 유지하면 요청이 끝난 이후에도 query cache가 프로세스에 남을 수 있다.
즉 다음과 같은 구조가 된다.
Request A ─┐ Request B ─┼→ Global QueryClient → Cache Request C ─┘ ↑ │ 계속 누적
특히 서버 프로세스는 브라우저처럼 사용자 한 명의 lifecycle에 맞춰 종료되는 것이 아니다.
PM2 Cluster에서 여러 요청을 계속 처리하는 동안 전역 cache가 계속 살아 있을 수 있다.
실제로 프로덕션에서는 메모리 사용량이 지속적으로 증가했고 OOM이 45분에서 7시간 사이의 간격으로 발생했다.
GC 효율도 비정상적으로 낮아지는 구간이 확인됐다.
그래서 서버에서는 요청 단위로 QueryClient를 격리하는 방향으로 변경했다.
React.cache()를 활용해 request lifecycle 안에서 동일한 QueryClient를 재사용하되 요청 간에는 공유하지 않는 구조로 바꿨다.Request A └── QueryClient A └── cache A Request B └── QueryClient B └── cache B
이 구조에서는 A 요청의 cache가 B 요청의 cache로 계속 누적되지 않는다.
메모리 문제는 코드만 고쳐서는 충분하지 않았다
코드 수정과 함께 프로세스 레벨의 안전장치도 추가했다.
PM2에서는 memory threshold를 설정했다.
pm2-runtime -i 2 --max-memory-restart 1500M
Node.js heap도 제한했다.
--max-old-space-size=1024
Docker container에도 memory limit을 설정했다.
mem_limit: 4g
이 세 가지는 근본적인 메모리 문제를 해결하는 방법은 아니다.
다만 production에서는 메모리 누수가 발생했을 때 서비스 전체가 같이 죽지 않도록 실패 범위를 제한하는 것도 중요하다.
즉,
Code-level fix + Runtime limit + Process restart policy
를 함께 가져가는 것이 필요했다.
최종적으로 OOM 발생 주기는 사라졌고 서비스 degradation과 OOM으로 인한 5~10초 수준의 중단도 제거됐다.
메모리 증가율도 시간당 약 2GB 수준에서 안정화됐다.
중복 fetch도 서버 메모리와 렌더링 비용에 영향을 줬다
추가로 다음 문제도 함께 정리했다.
generateMetadata와 페이지에서 동일 데이터 중복 fetch
- preload 단계에서 불필요한 데이터 준비
- 서버에서 중복되는 fetch
- ECharts의 SSR 실행
- 요청 간 cache 공유
generateMetadata와 실제 페이지 렌더링이 동일 데이터를 필요로 한다면 두 경로에서 각각 fetch가 발생할 수 있다.이런 중복을
React.cache() 기반으로 dedupe했다.또한 필요하지 않은 preload를 제거하고 ECharts는
ssr: false로 분리했다.결과적으로 서버에서 불필요한 데이터 생성과 JavaScript 실행을 줄이고 요청 단위 lifecycle을 명확하게 만들 수 있었다.
최종 Lighthouse에서 다시 병목을 확인했다
2026년 4월 시점에서 전체 Lighthouse를 다시 측정했다.
페이지 | 점수 | LCP | TBT |
/ | 67 | 11.1s | 130ms |
/rank | 53 | 19.9s | 450ms |
/about/project-a | 62 | 11.7s | 310ms |
First Load JS도 다시 확인했다.
Route | First Load JS |
/rank | 427kB |
/song/[id] | 397kB |
/history | 368kB |
Shared | 215kB |
초기 최적화 이전에 비하면 JavaScript와 Main Thread 비용은 상당히 줄어들었지만, LCP는 여전히 해결해야 할 문제가 남아 있었다. 여기서 다시 Network waterfall을 확인했다.
가장 크게 남아 있던 것은 FontAwesome이었다.
fa-light-300.woff2 455KB fa-regular-400.woff2 417KB fa-solid-900.woff2 347KB fa-brands-400.woff2 117KB ---------------------------- 총합 1,336KB
전체 payload에서 약 42%를 차지하는 수준이었다. 즉 JavaScript를 많이 줄였더니 이번에는 폰트가 가장 큰 리소스로 보이기 시작했다. 이것이 성능 최적화 작업의 반복적인 특성이었다.
처음에는 ECharts가 가장 큰 문제였지만 ECharts를 제거하고 나면 UI library가 보였고, UI library를 정리하면 폰트가 보였다.
초기 Lighthouse 측정값은 다음과 같았다.
항목 | 초기 |
LCP | 49.8s |
TBT | 3,460ms |
First Load JS | 816KB |
Payload | 9.6MB |
Main Thread Blocking | 19.7s |
1차 집중 최적화 이후에는 다음 수준까지 내려왔다.
항목 | Before | After |
First Load JS | 816KB | 454KB |
/song/[id] | 217KB | 21KB |
LCP | 49.8s | 24.0s |
TBT | 3,460ms | 160ms |
Payload | 9.6MB | 3.5MB |
Main Thread Blocking | 19.7s | 2.4s |
전체 작업을 거치면서 원문에서 정리한 최종 성과는 다음과 같다.
항목 | 결과 |
LCP | 52% 감소 |
TBT | 95% 감소 |
First Load JS | 44% 감소 |
Payload | 63% 감소 |
Main Thread Blocking | 88% 감소 |
OOM 장애 | 제거 |
서비스 중단 | 무중단 전환 |
마치며
이번 작업을 시작할 때만 해도 성능 최적화는 비교적 단순한 문제라고 생각했다.
큰 JavaScript를 줄이고, 이미지 크기를 줄이고, Lighthouse에서 빨간 항목을 하나씩 없애면 된다고 생각했다.
하지만 실제로는 훨씬 여러 계층에 걸쳐 있었다.
Browser │ ├── Network │ ├── HTML │ ├── JS │ ├── CSS │ ├── Font │ └── Image │ ├── Next.js │ ├── RSC │ ├── Client Component │ ├── Dynamic Import │ └── Cache │ ├── React │ ├── Render │ ├── Hydration │ └── DOM │ ├── Data Layer │ └── TanStack Query │ └── Server Runtime ├── Node.js ├── V8 Heap ├── PM2 └── Docker
한 계층에서 문제가 해결되면 다음 계층의 병목이 드러났다.
ECharts를 제거하자 UI library가 보였고, UI library를 정리하자 FontAwesome이 보였다.
번들을 줄인 뒤에는 DOM 렌더링 비용을 확인했고, 브라우저 성능을 개선한 뒤에는 서버의 Query cache와 메모리 문제가 나타났다.
그래서 이번 작업에서 가장 중요했던 것은 특정 라이브러리의 사용법보다 문제를 어느 계층에서 찾아야 하는지를 판단하는 과정이었다.
1. Bundle Analyzer로 dependency graph를 확인한다
페이지 코드만 보지 않고 shared chunk까지 확인해야 했다.
특히 예상하지 못한 dependency가 shared chunk에 들어왔다면 해당 dependency가 어떤 import chain을 통해 들어왔는지 역추적했다.
2. Server / Client boundary를 확인한다
Next.js App Router에서는 Client Component 하나가 dependency graph 전체를 브라우저 쪽으로 끌고 갈 수 있다.
따라서 단순히 컴포넌트를
use client로 만드는 것보다 정말 클라이언트에서 실행되어야 하는 범위를 최소화하는 것이 중요했다.3. 라이브러리 크기보다 사용 시점을 확인한다
ECharts를 제거한 것이 아니라 필요한 시점에 로드하도록 만들었다.
Google Translate도 제거한 것이 아니라 interaction 이후에 로드하도록 만들었다.
성능에서 중요한 것은 "얼마나 많은 코드를 가지고 있는가"뿐만 아니라 "언제까지 그 코드를 다운로드하고 실행해야 하는가"였다.
4. 브라우저 성능과 서버 런타임을 분리해서 보지 않는다
TanStack Query의 cache 설정 하나가 네트워크 요청과 서버 메모리 문제로 이어질 수 있었다.
전역 QueryClient 하나가 SSR 환경에서 요청 간 cache를 공유하면서 OOM으로 이어질 수도 있었다.
프론트엔드 성능 문제를 해결하다 보면 결국 Node.js runtime과 인프라까지 내려가야 하는 경우가 있었다.
5. 모니터링은 Error만 보는 것이 아니다
실시간 차트 장애처럼 HTTP 요청 자체는 정상이어도 서비스 데이터가 잘못될 수 있다.
그래서 최종적으로는
Error Monitoring + Performance Monitoring + Business Data Freshness
를 함께 봐야 서비스의 실제 상태를 알 수 있다는 결론에 도달했다.
결국 이 작업은 단순히 Lighthouse 점수를 올린 작업이 아니었다.
Next.js App Router의 렌더링 구조부터 Webpack dependency graph, React hydration, TanStack Query cache, V8 메모리, PM2 Cluster, Docker, CDN과 이미지 프록시, 그리고 Sentry 기반 장애 감지까지 하나의 서비스가 실제로 동작하는 전체 경로를 따라가며 병목을 제거한 작업이었다.
성능 최적화에서 가장 중요한 것은 "무엇을 줄였는가"보다 "왜 느렸는지를 설명할 수 있는가"라고 생각한다.