Skip to Content
Blog기술 블로그 빌드 시간 단축기
  • #Nextra
  • #Next.js
  • #CI
  • #Docker
  • #Turbopack
  • #Optimization

기술 블로그 빌드 시간 단축기

기술 블로그 글에서 MDX 한 줄을 고쳤는데 CI가 5분 가까이 돌고 있었습니다.

블로그가 엄청 큰 서비스도 아니고, 바뀐 것도 글 한 줄이었습니다. 그런데 매번 Docker 이미지를 만들고, 검색 인덱스를 만들고, 배포 매니페스트까지 갱신하는 동안 꽤 오래 기다려야 했습니다.

이미 한 번 줄인 적은 있었습니다. 모노레포를 걷어내고, 멀티아키 빌드를 끄고, 레이어 캐시를 정리해서 6분대였던 빌드를 4분대까지 줄여뒀습니다.

그래도 여전히 글 하나 고칠 때마다 기다리는 시간이 길었습니다.

이번에는 FE 빌드에 엄청 깊게 들어가서 튜닝했다기보다는, 시간을 쓰는 구간을 레이어별로 나눠서 하나씩 확인했습니다.

결론부터 말하면, 이 블로그에서는 캐시를 더 넣는 것보다 빌드 경로를 단순하게 만들고, 비싼 캐시 I/O를 걷어내는 것이 더 효과적이었습니다.


먼저 빌드 시간을 레이어로 나눴다

처음에는 막연히 “CI가 느리다”였습니다. 그 상태에서 바로 캐시를 넣으면, 좋아질 수도 있지만 더 복잡해질 수도 있습니다. 그래서 먼저 어디서 시간이 쓰이는지 나눠봤습니다.

대략 이런 레이어가 있었습니다.

레이어하는 일확인한 것
콘텐츠 빌드MDX를 Next/Nextra 페이지로 빌드대부분의 시간이 여기서 사용됨
postbuildsitemap, pagefind 생성합쳐서 약 1초, 병목 아님
Docker I/O이미지 빌드, 레이어 pull/push캐시를 넣을수록 오히려 커짐
배포 연결이미지 태그를 매니페스트에 반영캐시 export가 앞을 막고 있었음
베이스 이미지빌더 이미지 pullfull 이미지가 불필요하게 큼

로컬에서 webpack 빌드를 콜드/웜으로 나눠 재보면 이랬습니다.

단계시간
콜드 next build (webpack, 115페이지)67.6s
웜 (.next/cache 유지, MDX 1줄 변경)41.5s
next-sitemap0.5s
pagefind0.5s

sitemap이나 pagefind는 문제가 아니었습니다. 시간의 대부분은 Next/Nextra 페이지를 빌드하는 쪽, 정확히는 webpack 컴파일에 있었습니다.

그래서 첫 번째 방향은 분명했습니다.

콘텐츠 빌드 레이어를 먼저 줄인다.


1. 콘텐츠 빌드 레이어: webpack에서 turbopack으로

Next 16부터 next build는 turbopack이 기본입니다. 그런데 이 블로그는 next build --webpack으로 webpack을 강제하고 있었습니다.

이유는 next.config.ts에 있었습니다. production 빌드에서만 rehype 플러그인 하나를 넣고 있었습니다.

mdxOptions: { rehypePlugins: process.env.NODE_ENV === "production" ? [rehypeOpenGraphImage] : [], },

이 플러그인은 각 글의 frontmatter title을 읽어서 Open Graph 이미지 URL을 자동으로 넣어주는 역할이었습니다.

turbopack은 loader 옵션을 Rust 프로세스 경계 너머로 넘기기 때문에 직렬화 가능한 값만 받습니다. 함수 인스턴스인 rehype 플러그인을 그대로 넘길 수 없었습니다. 그래서 dev는 turbopack으로 돌고 있었지만, production 빌드는 webpack으로 물러나 있었습니다.

처음에는 플러그인을 별도 모듈로 빼면 될 줄 알았습니다. 하지만 Nextra 4.6의 MDX 로더는 문자열 경로 플러그인을 다시 import해주는 구조가 아니었습니다. 설치된 nextra/dist/server/compile.js를 보면 플러그인 배열을 그대로 @mdx-js/mdx에 넘깁니다.

그래서 다른 방향으로 봤습니다. 이 플러그인이 정말 빌드 중에 필요할까?

하는 일은 단순했습니다.

https://makgol.com/og?title=...

이 값을 frontmatter에 미리 넣으면 결과는 같습니다. Nextra는 frontmatter를 어차피 export const metadata로 바꿔주기 때문입니다.

--- title: 왜 트래픽은 받았지만 스케일링은 못 했을까? + openGraph: + images: "https://makgol.com/og?title=왜 트래픽은 받았지만 스케일링은 못 했을까?" ---

87개 MDX에 이 값을 넣고, 플러그인을 지우고, --webpack을 제거했습니다.

Nextra 4.6.1 + Next 16.1 조합에서 turbopack으로 빌드할 때 걸리는 버그 두 개는 resolveAlias로 우회했습니다. 가상 모듈 next-mdx-import-source-fileremark-mermaid 서브패스 문제였습니다.

이 우회는 계속 신경 써야 합니다. 특히 remark-mermaid 쪽은 .pnpm 내부 경로를 직접 가리키는 형태라, 관련 의존성을 올릴 때 alias 경로도 같이 확인해야 합니다.

그렇게 넘기자 turbopack 빌드가 통과했습니다.

▲ Next.js 16.1.7 (Turbopack) ✓ Compiled successfully in 33.3s

콜드 컴파일은 67.6초에서 33초 정도로 줄었습니다. 여기서 가장 큰 단축이 나왔습니다.


2. 캐시 레이어: 넣을수록 느려졌다

컴파일이 줄었으니 다음에는 캐시를 보고 싶어집니다.

콜드 빌드를 웜 빌드처럼 만들면 더 빨라지지 않을까?

그래서 세 가지를 시도했습니다. 결론부터 말하면 전부 빼는 게 나았습니다.

.next/cache 저장

GitHub Actions 러너는 매번 새로 뜹니다. BuildKit cache mount도 run-to-run으로 남지 않습니다. 그래서 buildkit-cache-dance.next/cacheactions/cache에 넣고 빼게 했습니다.

결과는 반대였습니다.

  • 캐시를 post phase에서 추출하는 데 매번 76초가 들었습니다.
  • 그 캐시로 아끼는 turbopack 컴파일 시간은 대략 10초였습니다.

76초를 써서 10초를 아끼는 구조였습니다. 바로 롤백했습니다.

Docker 레이어 캐시를 GHA로 옮기기

다음에는 cache-to: type=gha,mode=max를 시도했습니다. Docker 레이어 캐시를 GitHub Actions 캐시로 일원화하려는 시도였습니다.

결과는 더 나빴습니다.

빌드 스텝이 199초에서 366초로 늘었습니다. mode=max는 중간 레이어, 특히 node_modules가 들어간 레이어까지 전부 GHA 캐시로 올립니다. 그 업로드 비용이 이미지 push보다 무거웠습니다.

이것도 롤백했습니다.

의존성 base 이미지

마지막으로 pnpm install을 아예 base 이미지에 넣어봤습니다.

별도 Dockerfile.deps를 만들고, nextra-blog-base:deps-<lockfile해시> 이미지를 push했습니다. 메인 빌드는 그 이미지를 FROM으로 받아서 install을 스킵하게 했습니다.

숫자는 이랬습니다.

스텝시간
Ensure deps base (매 런)64~84s
Build and push (install 스킵)118~135s
~3m35s

처음에는 이상했습니다. install을 스킵했는데 왜 더 빠르지 않을까?

원인은 레이어 전송이었습니다. cache-from은 캐시 히트를 판단하려고 node_modules 레이어를 매번 다시 풉니다. 그리고 메인 빌드가 base 이미지를 또 풉니다. 한 런 안에서 node_modules를 두 번 전송한 셈입니다.

캐시를 안 쓰고 매번 install하는 빌드가 3분 9초였는데, base 이미지를 쓰면 3분 35초가 걸렸습니다. 이것도 롤백했습니다.

이 실험에서 얻은 결론은 단순했습니다.

이 프로젝트에서는 node_modules를 캐시로 옮기는 비용이, 그냥 다시 설치하는 비용보다 비쌌다.

콜드 pnpm install은 약 47초였습니다. 반면 node_modules 크기의 레이어를 Docker Hub나 GHA 캐시로 왕복시키는 데 100~167초가 들었습니다. 캐시가 빌드를 도와주는 게 아니라, 매번 큰 짐을 하나 더 실어 나르는 모양이 됐습니다.


3. 배포 대기 레이어: 캐시 export가 앞을 막고 있었다

캐시를 걷어내면서 다른 문제도 보였습니다.

이미지 태그를 매니페스트에 써넣는 배포 스텝은 Build and push 다음에 실행됩니다. 그런데 cache-to export는 Build and push 스텝 안에서 같이 끝나야 합니다.

즉 이미지는 이미 push됐는데도, 캐시 export가 끝날 때까지 배포가 기다리고 있었습니다.

cache-to를 없애자 빌드 스텝은 이미지 push 직후 끝났고, 배포는 1초 만에 따라붙었습니다.

이건 컴파일 단축과는 다른 레이어의 단축이었습니다. 빌드가 끝난 뒤 배포로 넘어가는 대기 시간이 줄었습니다.


4. 베이스 이미지 레이어: 빌더만 slim으로

마지막으로 빌더 베이스 이미지를 줄였습니다.

기존 빌더는 node:24였습니다. 런타임 이미지는 이미 node:24-alpine이었지만, 빌드 단계에서는 full 이미지를 쓰고 있었습니다.

node:24는 약 1.13GB였고, node:24-slim은 약 246MB였습니다. 빌더 베이스만 바꿔도 가져와야 하는 이미지 크기가 약 78% 줄었습니다.

걱정한 건 sharppagefind였습니다. 둘 다 빌드 중에 필요합니다. 다행히 둘 다 prebuilt glibc 바이너리라 Debian slim에서 별도 빌드툴 없이 그대로 동작했습니다.

런타임 이미지는 그대로 node:24-alpine을 유지했습니다. slim은 빌드 단계에서 베이스를 당겨오는 비용만 줄였습니다.


최종적으로 어디서 줄었나

정리하면 단축은 한 군데에서만 나온 게 아니었습니다.

레이어변경효과
콘텐츠 빌드webpack → turbopack콜드 컴파일 67.6s → 33s
메타데이터 생성rehype 플러그인 제거, frontmatter에 OG 이미지 명시turbopack 사용 가능
캐시/Docker I/O.next/cache, GHA cache, deps base 제거불필요한 100초대 전송 제거
배포 대기cache-to 제거이미지 push 직후 배포 진행
베이스 이미지builder node:24node:24-slim빌드 단계 이미지 pull 부담 감소

전체 숫자는 이렇습니다.

구성Build 스텝배포
원래 (webpack, 이전 글 시작점)~6분
이전 글 종료 (모노레포 해체 등)~4분
turbopack + .next/cache dance199s+76s 뒷정리5m11s
turbopack + gha mode=max366sexport가 막음~6분
turbopack + base 이미지 굽기ensure 64s + 129s푸시 직후~3m35s
turbopack + 캐시 없음 (full node:24)163s푸시 직후3m9s
turbopack + slim, 캐시 없음 (최종)151s푸시 직후 1s~2m54s

MDX 한 줄 바꾸는 데 5분 가까이 기다리던 파이프라인이 3분 아래로 내려왔습니다.


안 쓰기로 한 것들

이번 작업에서 남긴 결론은 “무엇을 썼다”보다 “무엇을 안 쓰기로 했다”에 더 가깝습니다.

  • .next/cache를 CI에 영속화하지 않는다. 추출 비용이 절약분보다 컸다.
  • cache-to: type=gha,mode=max를 쓰지 않는다. node_modules 레이어 업로드가 너무 무거웠다.
  • deps base 이미지를 매 런 확인하는 구조를 쓰지 않는다. install 스킵보다 레이어 전송 비용이 더 컸다.
  • 이미지 push 이후 배포가 캐시 export를 기다리게 두지 않는다.

다시 시도하려면 “캐시니까 빠르겠지”가 아니라, install 비용과 캐시 전송 비용을 같은 기준으로 재고 들어가야 합니다.

결국 이번 빌드 최적화는 FE 빌드를 깊게 튜닝했다기보다, CI에서 기다리는 시간을 레이어별로 나눠 본 일이었습니다. 그리고 이 블로그에서는 더 복잡한 캐시보다 단순한 파이프라인이 더 빨랐습니다.