Skip to Content
BlogNextra 블로그 CI 빌드 시간 6분 → 4분 줄이기
  • #Nextra
  • #Next.js
  • #CI
  • #Docker
  • #pnpm
  • #Optimization

Nextra 블로그 CI 빌드 시간 6분 → 4분 줄이기

MDX 한 줄 바꿨을 뿐인데 CI가 6분을 돌고 있었다. 그 빌드 파이프라인을 뜯어보고, 최적화할 수 있는 건 다 밀어넣고, 결국 “이 이상은 Nextra upstream 기다려야 한다”는 지점에 도달하기까지의 기록.

시작점: 왜 6분이나 걸렸나

동일 저장소에 있는 다른 Next.js 앱(kanna-the-finale)은 같은 러너에서 1~2분이면 끝나는데, nextra-blog만 6분+. 구조를 비교해보니:

OptionTypeDescription
구조단일 Next.js 앱pnpm 모노레포 (vendored Nextra)
outputexport (정적 HTML)standalone
MDX 파일2개98개
빌드 체인next buildnextra 패키지 빌드 + docs 빌드 + pagefind 색인

원인 진단:

  1. 멀티아키 빌드 (linux/amd64,linux/arm64) — arm64는 GHA x86 러너에서 QEMU 에뮬레이션으로 Node/webpack이 2~4배 느림
  2. Dockerfile 레이어 캐시 무효화COPY . .pnpm install 앞에 둬서 소스 한 줄만 바뀌어도 install 레이어부터 전부 재실행
  3. postinstall: pnpm buildpnpm install만 해도 nextra 워크스페이스 패키지 전부 재컴파일
  4. --no-frozen-lockfile — 의존성 그래프 재해석
  5. Nextra 패키지를 vendoring 중이었지만 실질 수정 거의 없음 — 프레임워크를 매번 재빌드하는 비용이 불필요
  6. pagefind 전량 재색인 — 98개 MDX 매번 전부

Phase 1: Docker/CI 위생 정리

리스크 낮은 것부터.

1-1. Dockerfile 레이어 분리

# Before: 소스 한 줄 바뀌면 install부터 재실행 COPY . . RUN pnpm install --no-frozen-lockfile # After: 의존성 해석에 필요한 파일만 먼저 복사 COPY pnpm-lock.yaml pnpm-workspace.yaml .npmrc package.json ./ COPY patches/ ./patches/ COPY docs/package.json ./docs/package.json # ... (각 workspace package.json) RUN --mount=type=cache,target=/pnpm/store,id=pnpm-store \ pnpm install --frozen-lockfile # 이 시점까지 MDX 변경에 영향 안 받음 COPY . .

COPY 순서를 의존성 해석 → 소스로 바꾸면 MDX만 바꾼 빌드에서 install 레이어가 캐시 히트.

1-2. 멀티아키 제거

k8s 노드가 x86인데 arm64까지 빌드할 이유가 없었다.

# Before platforms: linux/amd64,linux/arm64 # After platforms: linux/amd64

이 한 줄 변경만으로 체감 40~50% 단축.

1-3. .dockerignore 정비

node_modules, .next, .turbo, .git, .github, 테스트 픽스처 등을 build context에서 제외. 컨텍스트 크기 축소 = daemon 전송 시간 단축.

Phase 2: 모노레포 캐시 안정화

2-1. postinstall: pnpm build 제거

pnpm install만 돌려도 Nextra, nextra-theme-docs를 재컴파일하고 있었다. install과 build를 분리.

2-2. BuildKit 캐시 마운트

.next/cache, /pnpm/store에 cache mount 추가:

RUN --mount=type=cache,target=/pnpm/store,id=pnpm-store \ pnpm install --frozen-lockfile RUN --mount=type=cache,target=/app/docs/.next/cache,id=next-cache \ pnpm build

(스포일러: 이 중 .next/cache 마운트는 나중에 실제로 효과가 없다는 걸 확인하게 된다. GHA 러너가 ephemeral이라 마운트가 run-to-run 유지 안 됨)

Phase 3: 모노레포 해체 (가장 큰 한 방)

git log를 뒤져보니 packages/nextra/는 실질 수정이 거의 없었다. 프레임워크를 vendoring할 이유가 없는데 빌드 때마다 재컴파일하고 있었다.

작업 내용

  • docs/package.json: "nextra": "workspace:*""nextra": "^4.2.16"
  • 삭제: packages/, pnpm-workspace.yaml, turbo.json, patches/
  • 루트 package.json: 모노레포 전용 필드 제거, 슬림 wrapper로 변경
  • Dockerfile: 단일 앱 기준 재작성 (cd docs 없이, turbo 없이, 바로 next build)
// 루트 package.json, after { "name": "nextra-blog", "private": true, "packageManager": "pnpm@10.26.2", "scripts": { "dev": "cd docs && pnpm dev", "build": "cd docs && pnpm build", "start": "cd docs && pnpm start" } }

docs/pnpm-lock.yaml은 로컬에서 재생성해서 커밋.

이 시점에서 4m57s.

여기서부터 빌드 회귀 잡기

퍼블리시 Nextra로 바꿨더니 빌드가 깨졌다. 잡은 것들:

  • docs/global.d.ts: declare module '*.css' 추가 (vendored nextra-theme-docs가 제공하던 ambient 선언이 사라짐)
  • docs/components/example-code.tsx: '../../packages/nextra/dist/client/mdx-remote''nextra/mdx-remote'
  • docs/next.config.ts: rehypePlugins: [false | Function] 타입 오류 수정 — 삼항 연산자로 변경
  • docs/tsconfig.json: TS 6.0.3이 전이적으로 끌려와서 baseUrl deprecation이 에러로 뜸 → typescript: "^5.9.3" 을 docs devDep에 고정
  • docs/package.json: pnpm.onlyBuiltDependencies: ["sharp"] — sharp 빌드 스크립트 승인

Phase 4: 추가 튜닝

Registry cache 복원

GHA 캐시 대신 DockerHub registry cache를 primary로:

cache-from: | type=registry,ref=<user>/nextra-blog:buildcache type=gha # fallback cache-to: type=registry,ref=<user>/nextra-blog:buildcache,mode=max

GHA 캐시는 7일 TTL, 10GB 쿼터 제약이 있지만 registry는 없다. cache-from에 둘 다 넣어 fault-tolerant.

Webpack 병렬 컴파일

// docs/next.config.ts experimental: { webpackBuildWorker: true, // webpack을 워커 프로세스로 parallelServerCompiles: true, // 서버/클라이언트 동시 컴파일 parallelServerBuildTraces: true, // NFT trace 병렬 },

Typecheck를 빌드 경로에서 분리

typescript: { ignoreBuildErrors: true, },

타입 체크는 별도 PR check에서 하고, 빌드 자체는 타입 검증 없이 진행 — 30~60s 절약.

기타

  • productionBrowserSourceMaps: false
  • rehypeOpenGraphImage rehype 플러그인을 별도 모듈로 분리 (모듈화하면 turbopack 호환 가능성)
  • Node 24 base image

이 시점에서 4m08s. 시작 대비 약 35% 단축.

시도해봤지만 안 된 것들

Turbopack production build (실패)

Next 15.5는 next build --turbopack을 지원한다. Webpack 대비 2~5배 빠르다는 benchmark도 있다. 시도했더니:

Error: loader /app/docs/node_modules/.pnpm/nextra@.../node_modules/nextra/loader.cjs for match "./{src/,}app/**/page.{md,mdx}" does not have serializable options.

Nextra 4.6.1 내부에서 MDX loader에 rehype/remark 플러그인을 함수 참조로 직접 pass 한다. Turbopack은 loader options 직렬화 필수라 거부. 우리가 rehypeOpenGraphImage를 모듈로 뺐어도 해결 안 됨 — Nextra 내부 배선이 그대로라서. Nextra upstream 패치 없이는 우회 불가.

롤백.

.next/cache 영속화 via actions/cache (실패)

BuildKit --mount=type=cache는 GHA 러너가 ephemeral이라 run-to-run 유지가 안 된다. 이걸 해결하려고:

  1. actions/cache@v4docs-next-cache/ 디렉토리를 run-to-run 유지
  2. Dockerfile에서 COPY docs-next-cache/ ./.next/cache/ 로 시드
  3. 별도 FROM scratch AS next-cache-export stage로 새 cache 추출

이론적으로 webpack 증분 빌드가 되어야 했다. 실제로도 compile은 130s → 31s (4배 빠름). 그런데:

OptionTypeDescription
캐시 infra 없음4m08s130s
캐시 infra 있음4m42s31s

전체 시간이 오히려 34초 느려졌다. 이유:

  • .next/cache 크기가 1.4GB → build context 업로드 시간 ↑
  • 두 번째 buildx call(cache-export stage 추출) 오버헤드 ~60-90s
  • actions/cache 자체 save/restore 오버헤드 ~20-30s

bind mount도 시도했지만 동일. 롤백.

교훈: compile 속도 최적화가 항상 total wall-clock 최적화는 아니다. 인프라 오버헤드까지 포함해서 측정해야 한다.

최종 숫자

단계전체 빌드
시작~6분+
Phase 1+2+3 (모노레포 해체)4m57s
+ registry cache, webpack workers, sourcemap off4m08s

30~35% 단축. MDX 한 줄 추가에 6분이던 게 4분으로.

남은 큰 한 방

Nextra upstream turbopack 지원

Nextra가 MDX loader options를 serializable하게 재작성하면 turbopack prod 빌드가 열리고, webpack compile 130s가 30~60s로 떨어질 가능성이 있다. 주기적으로 Nextra release note 체크할 가치 있음.

컨텐츠 분리 (구조 변경)

98개 MDX를 모두 build-time에 prerender할 필요가 있는지 재검토. 일부를 ISR/runtime으로 돌리면 빌드 그래프 축소 가능. 다만 정적 호스팅/CDN 전략과 충돌 가능성 있음.

유료 러너

ubuntu-latest-xlarge 같은 더 큰 러너. CPU/메모리 늘어나 compile이 빨라질 수 있지만 월 비용 발생.

교훈 정리

  1. 먼저 계측. “왜 느린가”를 단계별 타이밍으로 쪼개서 보지 않으면 엉뚱한 곳을 고친다.
  2. 모노레포는 이유가 있을 때만. 프레임워크를 수정하지 않으면서 vendoring하면 빌드만 두 배가 된다.
  3. Docker 레이어 순서는 캐시 전략이다. COPY . .을 install 앞에 두는 것 하나로 install 레이어가 매번 날아간다.
  4. GHA 러너는 ephemeral. BuildKit cache mount는 같은 데몬 안에서만 유지됨 — GHA에서는 run마다 비어있다. 이걸 모르고 --mount=type=cache 깔아두면 아무 일도 안 하는 코드를 추가한 셈.
  5. 모든 최적화가 wall-clock 단축은 아니다. compile을 100초 줄여도 인프라 오버헤드로 120초 쓰면 손해. 총 시간으로 검증.
  6. Turbopack은 아직 Nextra와 잘 안 맞는다 (2026-04 기준, Nextra 4.6.1). MDX loader가 serializable options를 요구하는 turbopack 계약을 충족 못 함.
  7. Registry cache > GHA cache. 기간/쿼터 제약이 없고 cache-from에 여러 소스를 겹쳐 쓰면 안정성도 확보.