BrandVis Blog
  • Tech
  • Product
  • Insight
No Result
View All Result
  • Tech
  • Product
  • Insight
No Result
View All Result
BrandVis Blog
No Result
View All Result
Home Tech
Turbopack 환경의 번들 정리 — mock 코드 제거부터 청크 격리 재검증까지

Turbopack 환경의 번들 정리 — mock 코드 제거부터 청크 격리 재검증까지

Luna Yang by Luna Yang
2026/04/26

Next.js 16 · React 19 · MUI v7 프런트엔드에서 직접 들여다본 것들

안녕하세요. FlipB 개발팀 Luna 입니다.

Next.js 16 + Turbopack 환경에서 번들을 진단하고 줄이는 과정은 webpack 시절과 몇 가지 중요한 차이가 있습니다. 이 글은 brandvis 프런트엔드(Next.js 16.1, React 19.2, Turbopack, MUI v7, ECharts 6)에서 한 사이클의 번들 점검을 돌리면서 내렸던 기술적 결정들을 정리합니다. “얼마나 줄였는가”보다 “무엇을 측정해서 어떤 결정을 내렸는가”, 그리고 “왜 어떤 것은 손대지 않기로 했는가” 에 무게를 둡니다.


TL;DR

영역결과
Production 번들 내 MOCK_* 심볼80+ 종 → 2종 (-97%)
Mock 데이터 모듈 (총 5,424줄)모든 페이지 청크에서 제거, dev 전용 lazy chunk로 격리
플랫폼 로고 SVG 2장 (clova/youcom)1.4 MB → 11 KB (-99%)
WritePage의 모달·드로어 4개페이지 진입 청크에서 사용자 액션 시점 청크로 분리
ECharts 트리쉐이킹5개 파일의 echarts-for-react 정적 import 제거, 누락 컴포넌트 등록
작업 범위service 13개 · mock 13개 · 페이지 컴포넌트 22개 · 설정 4개

핵심 교훈 세 가지.

  1. 체감을 측정 가능한 형태로 옮기기 전엔 시작하지 않는다. “용량을 줄이자”는 끝이 없지만, “가장 큰 청크 5개의 정체를 파악하자”는 끝이 있다.
  2. 빌드 도구의 한계도 코드와 똑같이 측정 대상이다. 이 사이클에서 가장 큰 함정은 라이브러리가 아니라 Turbopack의 cross-module 상수 폴딩 약점이었다.
  3. “이미 되어 있는 것”을 발견하는 것도 작업이다. 손대지 않는 결정이 코드 절감만큼 가치 있을 수 있다.

1. 진단 관점 — 사이즈가 아니라 “정체”를 본다

번들 최적화를 “청크 총량을 몇 MB 줄였는가”로 측정하면 금방 한계에 부딪힙니다. Turbopack은 이미 꽤 적극적으로 트리쉐이킹을 하고 있기 때문에, 아무리 손을 대도 “총합”은 잘 안 줄어듭니다. 대신 다음 질문들로 측정 기준을 바꿨습니다.

  • 가장 큰 청크 5개 안에 무엇이 들어있는가? (라이브러리? 페이지 컴포넌트? 어떤 모듈?)
  • 특정 라이브러리가 실제로 어느 페이지에서 다운로드되는가? (라우트별 청크 매니페스트)
  • process.env.NEXT_PUBLIC_*가 빌드 타임에 정말 리터럴로 치환되었는가? (grep 검증)

이 질문들은 MB가 아니라 “건수”, “포함 여부”, “잔존 문자열 수” 로 답할 수 있습니다. 후자가 목표 설정에 훨씬 유리합니다.


2. Mock 데이터 — 가장 큰 발견은 도구의 한계

패턴

17개 service 파일이 모두 다음과 같은 정적 import + boolean 분기 패턴을 공유하고 있었습니다.

// src/lib/api/services/project.ts
import { MOCK_PROJECT_LIST, mockSuccess } from '../mocks';

async getProjectList(): Promise<ApiResponse<Project[]>> {
  if (USE_MOCK_DATA) {
    return mockSuccess(MOCK_PROJECT_LIST);
  }
  return this.get<Project[]>('/list');
}

mocks/ 디렉토리는 총 5,424줄, 가장 큰 mention.mock.ts 하나만 1,210줄. import가 정적이라 USE_MOCK_DATA가 false로 평가되어도 MOCK_PROJECT_LIST는 production 번들에 포함되는 구조였습니다.

1차 작업: dynamic import로 전환

async getProjectList(): Promise<ApiResponse<Project[]>> {
  if (USE_MOCK_DATA) {
    const { mockSuccess, MOCK_PROJECT_LIST } = await import('../mocks/project.mock');
    return mockSuccess(MOCK_PROJECT_LIST);
  }
  return this.get<Project[]>('/list');
}

13개 service 파일, 85개 분기를 변환. 각 mock 파일에는 export { mockSuccess } from './helpers';를 추가해 한 번의 dynamic import로 데이터와 헬퍼를 같은 청크에서 가져오도록 했습니다.

production 빌드 후 검증 — mock 심볼 67종이 그대로 잔류.

Turbopack의 cross-module 상수 폴딩 약점

빌드 산출물을 grep 하면 이런 패턴이 보였습니다.

if(USE_MOCK_DATA){let{X}=await e.A(NUMBER)...}

USE_MOCK_DATA가 변수 이름 그대로 런타임에 남아 있었습니다. config.ts에서

export const USE_MOCK_DATA = process.env.NEXT_PUBLIC_USE_MOCK_DATA === 'true';

이렇게 정의했고 .env에 NEXT_PUBLIC_USE_MOCK_DATA=false도 있었지만, Turbopack은 다른 모듈에서 import한 const를 cross-module로 폴딩하지 않고 있었습니다. service 파일 입장에서 USE_MOCK_DATA는 외부 const 바인딩일 뿐이라 dead code 제거가 작동하지 않았던 것.

해결: 같은 파일 안에서 process.env 직접 비교

// before
import { USE_MOCK_DATA } from '../config';
if (USE_MOCK_DATA) { ... }

// after
if (process.env.NEXT_PUBLIC_USE_MOCK_DATA === 'true') { ... }

process.env.NEXT_PUBLIC_*는 Next.js의 DefinePlugin이 빌드 타임에 리터럴 문자열로 치환합니다. 그러면 'false' === 'true'가 즉시 false 리터럴이 되고, dead code 제거가 정상 작동합니다.

검증 단계빌드 산출물 내 잔존 MOCK_* 심볼 종
작업 전80+
dynamic import만 적용67
인라인 env 비교 추가2

남은 2종은 컴포넌트 로컬 mock 파일로 별도 범위.

부산물: 페이지 컴포넌트의 fallback 22곳 정리

서비스 레이어를 정리하는 과정에서 22개 페이지 컴포넌트가 다음 패턴을 공유한다는 것을 발견했습니다.

const data = apiData?.data ?? (USE_MOCK_DATA ? MOCK_X : null);

이 코드는 두 가지를 가정합니다.

  1. mock 모드에서 service가 데이터를 못 줄 수도 있다 — 실은 항상 준다
  2. mock 데이터를 컴포넌트에서 직접 alias 할 필요가 있다 — 없다

서비스가 mock 모드에서 항상 데이터를 반환하므로 fallback은 죽은 안전망이었고, MOCK_X의 정적 참조가 production 번들로 새는 통로였습니다. 22개 컴포넌트에서 모두 제거.


3. SVG 두 장 — 코드 0줄 변경, 1.4MB 절감

public/ 디렉토리를 검토하다가 발견한 이상치:

797,400  public/images/platforms/clova.svg
631,097  public/images/platforms/youcom.svg

16×16 픽셀 아이콘으로 쓰이는 플랫폼 로고가 779KB와 631KB. SVG 안을 들여다보면:

<svg width="16" height="16" ...>
  <image width="1500" height="1500"
         href="data:image/png;base64,iVBORw0KGgoAAAA..." />
</svg>

1500×1500 PNG가 base64로 인코딩되어 있었습니다. 16×16에 표시되는 이미지인데.

처리

Node 스크립트로 base64를 추출 → macOS 기본 도구 sips로 64×64 리샘플 → 다시 base64로 임베드한 작은 SVG로 교체. 기존 파일 경로를 유지했기 때문에 사용처(ClovaIcon.tsx, YouComIcon.tsx, getPlatformIconPath.ts) 코드 수정은 0건.

clova.svg:  779 KB → 6.9 KB  (-99.1%)
youcom.svg: 631 KB → 4.1 KB  (-99.4%)

비슷한 후보를 보존한 이유

Pretendard 폰트 .otf 두 개(약 3MB)도 제거 후보였지만, @react-pdf/renderer가 PDF에서 .otf만 지원하기 때문에 보존. .woff2(브라우저용)와 .otf(PDF용)는 역할이 다르다는 점을 @font-face 선언과 Font.register 설정 양쪽에서 확인한 뒤 내린 결정입니다.


4. PDF 라이브러리 — 손대지 않는 결정

@react-pdf/renderer는 무거운 라이브러리입니다(vendor 청크 합 약 1.24MB). CLAUDE.md에는 /pdf-test 페이지에서 dynamic({ ssr: false })로 처리한다고 적혀 있었지만, 실제 코드는 그렇지 않았습니다.

// app/pdf-test/PDFTestContent.tsx
'use client';
import { PDFViewer } from '@react-pdf/renderer';  // 정적 import

“이걸 dynamic으로 바꾸자”가 첫 후보였지만, 실제로 다른 페이지에까지 영향을 주는지를 먼저 확인했습니다. Next.js App Router는 라우트별 청크 매니페스트를 빌드 산출물에 남깁니다.

# 각 페이지의 client reference manifest에서 PDF 관련 모듈 참조 검색
grep -oE "react-pdf|pdfkit|fontkit" \
  .next/server/app/dashboard/page_client-reference-manifest.js
# → 출력 없음

grep -oE "react-pdf|pdfkit|fontkit" \
  .next/server/app/pdf-test/page_client-reference-manifest.js
# → react-pdf, pdfkit, fontkit 모두 참조

라우트별 코드 스플리팅이 자동으로 격리해주고 있었습니다. dynamic import를 안 한 것은 사실이지만, PDF 청크는 /pdf-test에 들어갈 때만 다운로드됩니다. 일반 사용자에는 영향 0.

이 발견 덕분에 “PDF dynamic import” 후보가 사라졌습니다. 대신 /pdf-test 라우트 자체가 production에 필요한지 재검토 — 메뉴 노출 없음, 라우트 가드 없음, CLAUDE.md에 테스트 용도로만 언급. 협의 후 라우트 자체를 제거했습니다. 결과는 react-pdf vendor 청크가 빌드 산출물에서 emit되지 않는 것. PurchaseReceipt/* 코드는 PaymentHistoryTableRow.tsx에 주석 처리된 잠재 사용처가 있어 보존(entry point가 사라졌으므로 bundler가 자동 트리쉐이킹).

자매 사례: react-quill-new

같은 패턴으로 react-quill-new도 확인. shared/ui/form/QuillEditor/QuillEditor.tsx가 이미 dynamic(() => import('react-quill-new')) 패턴을 쓰고 있었고, useQuillModules.ts에서 모든 blot 모듈도 await import()로 lazy 처리되어 있었습니다. WritePage 페이지 청크가 무거운 진짜 이유는 Quill이 아니라 페이지 자체 컴포넌트들이었다는 것을 treemap 분석으로 확인.

교훈: 라이브러리가 무거워 보인다고 자동으로 모든 페이지가 그 비용을 무는 것은 아닙니다. 빌드 출력의 페이지별 청크 매니페스트를 한 번이라도 들여다봐야 진짜 비용을 알 수 있습니다.


5. bundle analyzer — Turbopack과 webpack을 다른 목적으로

@next/bundle-analyzer는 Turbopack 빌드와 호환되지 않습니다. 다음 스크립트로 우회:

// package.json
"analyze": "ANALYZE=true next build --webpack",
"analyze:turbo": "next experimental-analyze"

webpack treemap은 “어떤 모듈이 어느 청크에 들어있는지” 를 시각적으로 보여주므로 구조 파악에 유용합니다. 다만 webpack과 Turbopack의 dead code 제거 결과가 다릅니다(mock 심볼 기준 webpack 67종 잔류 vs Turbopack 2종). 따라서 두 도구를 다른 목적으로 분리했습니다.

  • 구조 파악: pnpm analyze (webpack treemap) — “어떤 라이브러리가 어느 청크에 들어있는가”
  • 실제 크기/DCE 검증: Turbopack 빌드 산출물 직접 측정 — find .next/static/chunks -name '*.js' -exec ls -laS {} +

한 도구의 결과만 신뢰하지 않는 것도 진단의 일부입니다.


6. ECharts — “거의 다 되어 있는” 모듈러 import의 단일 누수

ECharts는 필요한 차트 종류만 선택해서 등록하는 패턴을 지원합니다. 프로젝트에는 이미 shared/lib/echarts-loader.ts와 shared/lib/ReactEChartsOptimized.tsx가 있었습니다.

// shared/lib/echarts-loader.ts
import { BarChart, LineChart, PieChart, RadarChart, ... } from 'echarts/charts.js';
import { GridComponent, TooltipComponent, ... } from 'echarts/components.js';
import { use } from 'echarts/core';
import { SVGRenderer } from 'echarts/renderers.js';

use([BarChart, LineChart, /* ... */, GridComponent, /* ... */]);

대부분의 차트 컴포넌트가 이 모듈러 로더를 쓰고 있었습니다. 다만 5개 파일이 여전히 import ReactECharts from 'echarts-for-react'를 직접 import 하고 있었고, 단 한 곳이라도 echarts-for-react를 쓰면 그 의존성으로 ECharts 전체가 따라옵니다. 모듈러 setup이 다른 모든 곳에서 무효가 됩니다.

5개 파일을 변환했습니다.

// before
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';

// after
import type { EChartsCoreOption } from 'echarts/core';
import ReactECharts from '@/shared/lib/ReactEChartsOptimized';

누락 컴포넌트 두 건

  • VennDiagram: series.type: 'custom' 사용 → loader에 CustomChart 추가
  • RadarChart: 커스텀 라벨 위치 지정에 graphic 컴포넌트 사용 → dev 콘솔 경고로 발견
[ECharts] Component graphic is used but not imported.
import { GraphicComponent } from 'echarts/components';

→ loader에 GraphicComponent 추가. 다른 누락 컴포넌트가 있는지 코드베이스 전체 grep으로 확인(markLine, visualMap, polar, geo, calendar, brush, timeline 모두 미사용).

교훈: 모듈러 import는 “거의 다 했음” 상태로는 효과가 없습니다. 단 한 곳의 누수가 전체 노력을 무효화합니다. 누수를 찾으려면 도구가 아니라 grep이 필요합니다.


7. WritePage의 모달·드로어를 사용자 액션 시점으로

가장 무거웠던 페이지 청크의 treemap을 분석하니, 그 안에 4개의 모달/드로어가 정적으로 import되어 있었습니다.

컴포넌트트리거정적 import 위치
ContentOptimizationModal“최적화” 버튼WritePage.tsx
UnifiedDrawer (히스토리)“히스토리” 버튼WritePageHeader.tsx
TrackingCodeModal“트래킹 코드” 버튼WritePageHeader.tsx
StyleExamplesModal“스타일 예시”LeftSidebar.tsx

모두 open prop으로 조건부 렌더링되는데, 코드 자체는 정적 import라 페이지 진입 즉시 다운로드되고 있었습니다. next/dynamic으로 전환:

const ContentOptimizationModal = dynamic(
  () =>
    import('./components/ContentOptimizationModal').then((m) => ({
      default: m.ContentOptimizationModal,
    })),
  { ssr: false }
);

결과: 분리된 lazy 청크들의 합 약 250KB가 “콘텐츠 작성 페이지에 들어왔지만 아직 아무 버튼도 안 누른 사용자”의 초기 로드에서 빠집니다.


8. 누적되는 작은 작업들

  • shared/ui/icons barrel (260개 아이콘이 하나의 index.ts): next.config.mjs의 optimizePackageImports에 @/ui/icons, @/ui, @/hooks/api를 추가. Next.js가 빌드 시 자동으로 직접 경로 import로 변환해줍니다. 즉각적 절감보다는 새 barrel import가 추가되어도 안전한 default를 만든 작업.
  • pnpm clean:cache 스크립트 추가: .next + tsconfig.tsbuildinfo 정리용. 빌드가 비정상적으로 느려질 때만 사용.
  • bundle analyzer 상시 설치: 다음번 누군가 무거운 라이브러리를 추가했을 때 빠르게 알아차릴 수 있도록.

안 하기로 한 것들과 그 이유

정직하게 적습니다. 다음 사람이 같은 후보를 다시 떠올렸을 때 재평가 비용을 줄이기 위한 기록입니다.

  1. Dashboard 12개 차트의 렌더 본문 정리 — [...arr].sort().map()을 React Query select로 옮기는 작업. React Compiler가 이미 자동 메모이제이션을 처리 중이라 측정 가능한 임팩트가 불확실. CWV 측정 이후 재검토.
  2. StrictMode 비활성화 — dev 모드 이중 렌더링은 정합성 검증 가치가 더 큼.
  3. 가상화 라이브러리(react-window) 도입 — 100개 이상 아이템 단일 리스트 사례 미확인. 실 데이터 볼륨 확인 후 결정.
  4. PurchaseReceipt/ 코드 삭제 — /pdf-test만 참조했으나 PaymentHistoryTableRow.tsx에 주석 처리된 잠재 사용처가 있어 보존. 현재 entry point가 없어 bundler가 자동 트리쉐이킹.
  5. MUI v7 → 다른 UI 라이브러리 교체 — 영향 범위가 너무 큼.

일하는 방식에 대한 메모

이번 사이클을 거치며 확인한 원칙들입니다.

측정 가능한 형태로 옮기지 않으면 무한 루프에 빠진다. “용량을 줄이자”는 끝이 없지만, “production 빌드의 가장 큰 청크 5개를 분석하고 정체를 모두 파악하자”는 끝이 있습니다. 후자가 목표 설정에 훨씬 유리합니다.

도구의 한계도 측정 대상이다. Turbopack의 cross-module 상수 폴딩 약점을 발견하지 못했다면 mock 트리쉐이킹은 절반만 성공했을 것입니다. 두 도구(webpack analyzer, Turbopack 산출물)를 다른 목적으로 분리해서 쓴 것이 핵심이었습니다.

“이미 되어 있는 것”을 발견하는 것도 작업이다. PDF 라이브러리는 라우트별로 격리되어 있었고, Quill 본체와 모든 blot은 이미 dynamic import 되어 있었습니다. 손댔다면 시간만 쓰고 사용자 영향은 없는 변경을 했을 것입니다. 손대지 않는 결정도 코드 절감만큼 가치 있습니다.

시간이 가장 많이 든 작업이 가장 큰 효과를 낸 것은 아니다. 1.4MB 절감의 SVG 작업은 30분이 안 걸렸습니다. 동등한 효과를 코드 레벨에서 내려고 했다면 며칠이 걸렸을 것입니다.

“안 하기로 한 결정”도 기록한다. 재평가는 생각보다 자주 필요합니다. 왜 미뤘는지가 남아 있어야 다음 사이클이 짧아집니다.


다음 사이클의 후보

번들 사이즈에서 CWV(Core Web Vitals) 와 사용자 체감으로 시선을 옮기면 다음이 후보입니다.

  • Dashboard 차트들의 viewport-based lazy mounting (IntersectionObserver)
  • React Query select로 데이터 변환을 캐시 레벨로 이동
  • Sentry tracesSampleRate 재조정
  • 차트 option 객체의 inline 재생성 패턴 개선

모두 “현재 사용자의 LCP/INP가 얼마인가” 를 먼저 측정한 뒤에 시작할 일입니다. 가장 큰 비용이 무엇인지 먼저 알지 않고 손대면, 이번 사이클 초반의 모습처럼 시간만 쓰고 청구서는 그대로일 가능성이 높습니다.


참고 자료

Next.js / Turbopack

  • Optimizing Package Bundling — Next.js 공식 가이드 — optimizePackageImports, dynamic import, Turbopack 분석 도구 등 번들 최적화 옵션 정리
  • optimizePackageImports API Reference — barrel 파일을 자동으로 직접 경로로 변환하는 동작과 한계
  • Dynamic Imports — next/dynamic — ssr: false 옵션, 로딩 상태 처리, named export 패턴
  • Turbopack Configuration — Next.js 16의 기본 번들러로서의 Turbopack 설정
  • @next/bundle-analyzer — webpack-bundle-analyzer 래퍼, Turbopack 빌드와의 호환성 caveat 포함

ECharts 트리쉐이킹

  • Shrinking Bundle Size — ECharts Handbook — echarts/core + 차트/컴포넌트 개별 등록 패턴
  • API Reference: charts / components / renderers — 등록 가능한 모든 차트·컴포넌트 목록

Webpack / 번들러 일반

  • Webpack Tree Shaking 가이드 — sideEffects 필드, ES 모듈 정적 분석, dead code 제거 동작
  • Code Splitting — webpack 공식 — 라우트/컴포넌트 단위 청크 분리 전략

React 19 / React Compiler

  • React Compiler 공식 문서 — 자동 메모이제이션, useMemo/useCallback 사용 변화

측정 / 모니터링

  • Core Web Vitals — web.dev — LCP, INP, CLS의 정의와 임계값
  • INP (Interaction to Next Paint) — 2024년 3월 FID를 대체한 상호작용 응답성 지표

Stack: Next.js 16.1, React 19.2, Turbopack, MUI v7, ECharts 6.0, TypeScript 5
Tools: @next/bundle-analyzer, next build --webpack, pnpm

다른 콘텐츠

설치한 Sentry와 일하는 Sentry는 다르다

설치한 Sentry와 일하는 Sentry는 다르다

by Luna Yang
2026년 04월 23일

Next.js 16 + React 19 프런트엔드 모니터링 실전기 안녕하세요. FlipB 개발팀 Luna 입니다. "설치는 5분, 운영은 평생"이라는 말이 있죠. Sentry도 그렇습니다. npx @sentry/wizard만 돌리면 설치는 끝나지만, 그 상태로 놔두면 한 달 뒤엔...

프론트엔드 관점에서의 SEO와 GEO: 검색 엔진을 넘어 AI 답변의 시대로

프론트엔드 관점에서의 SEO와 GEO: 검색 엔진을 넘어 AI 답변의 시대로

by Luna Yang
2026년 01월 26일

안녕하세요. FlipB 개발팀 Luna 입니다. 프론트엔드 개발자로서 서비스의 성장을 위해 꼭 챙겨야 할 SEO(Search Engine Optimization)와 생성형 AI 시대의 새로운 표준 GEO(Generative Engine Optimization)에 대해 이야기해보려 합니다. 이...

  • About BrandVis
  • Contact Us

* 본 블로그의 일부 콘텐츠 초안은 AI를 활용하여 작성되었으며, 담당자의 검토를 거쳤습니다.
AI-powered GEO & Brand Discovery Platform by FlipB | True value, truly discovered.
© 2025 FlipB Co.,Ltd. All rights reserved.

Welcome Back!

Login to your account below

Forgotten Password?

Retrieve your password

Please enter your username or email address to reset your password.

Log In
No Result
View All Result
  • About BrandVis
  • Contact Us

* 본 블로그의 일부 콘텐츠 초안은 AI를 활용하여 작성되었으며, 담당자의 검토를 거쳤습니다.
AI-powered GEO & Brand Discovery Platform by FlipB | True value, truly discovered.
© 2025 FlipB Co.,Ltd. All rights reserved.