- #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분+. 구조를 비교해보니:
| Option | Type | Description |
|---|---|---|
| 구조 | 단일 Next.js 앱 | pnpm 모노레포 (vendored Nextra) |
| output | export (정적 HTML) | standalone |
| MDX 파일 | 2개 | 98개 |
| 빌드 체인 | next build | nextra 패키지 빌드 + docs 빌드 + pagefind 색인 |
원인 진단:
- 멀티아키 빌드 (
linux/amd64,linux/arm64) — arm64는 GHA x86 러너에서 QEMU 에뮬레이션으로 Node/webpack이 2~4배 느림 - Dockerfile 레이어 캐시 무효화 —
COPY . .을pnpm install앞에 둬서 소스 한 줄만 바뀌어도 install 레이어부터 전부 재실행 postinstall: pnpm build—pnpm install만 해도 nextra 워크스페이스 패키지 전부 재컴파일--no-frozen-lockfile— 의존성 그래프 재해석- Nextra 패키지를 vendoring 중이었지만 실질 수정 거의 없음 — 프레임워크를 매번 재빌드하는 비용이 불필요
- 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이 전이적으로 끌려와서baseUrldeprecation이 에러로 뜸 →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=maxGHA 캐시는 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: falserehypeOpenGraphImagerehype 플러그인을 별도 모듈로 분리 (모듈화하면 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 유지가 안 된다. 이걸 해결하려고:
actions/cache@v4로docs-next-cache/디렉토리를 run-to-run 유지- Dockerfile에서
COPY docs-next-cache/ ./.next/cache/로 시드 - 별도
FROM scratch AS next-cache-exportstage로 새 cache 추출
이론적으로 webpack 증분 빌드가 되어야 했다. 실제로도 compile은 130s → 31s (4배 빠름). 그런데:
| Option | Type | Description |
|---|---|---|
| 캐시 infra 없음 | 4m08s | 130s |
| 캐시 infra 있음 | 4m42s | 31s |
전체 시간이 오히려 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 off | 4m08s |
약 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이 빨라질 수 있지만 월 비용 발생.
교훈 정리
- 먼저 계측. “왜 느린가”를 단계별 타이밍으로 쪼개서 보지 않으면 엉뚱한 곳을 고친다.
- 모노레포는 이유가 있을 때만. 프레임워크를 수정하지 않으면서 vendoring하면 빌드만 두 배가 된다.
- Docker 레이어 순서는 캐시 전략이다.
COPY . .을 install 앞에 두는 것 하나로 install 레이어가 매번 날아간다. - GHA 러너는 ephemeral. BuildKit cache mount는 같은 데몬 안에서만 유지됨 — GHA에서는 run마다 비어있다. 이걸 모르고
--mount=type=cache깔아두면 아무 일도 안 하는 코드를 추가한 셈. - 모든 최적화가 wall-clock 단축은 아니다. compile을 100초 줄여도 인프라 오버헤드로 120초 쓰면 손해. 총 시간으로 검증.
- Turbopack은 아직 Nextra와 잘 안 맞는다 (2026-04 기준, Nextra 4.6.1). MDX loader가 serializable options를 요구하는 turbopack 계약을 충족 못 함.
- Registry cache > GHA cache. 기간/쿼터 제약이 없고 cache-from에 여러 소스를 겹쳐 쓰면 안정성도 확보.