개발기 Astro 최적화 DX Vite 블로그

블로그 빌드 속도 25% 단축기 — Mermaid 지연 로딩과 Astro 동시성

···
블로그 빌드 속도 25% 단축기 — Mermaid 지연 로딩과 Astro 동시성

얼마 전부터 블로그에 AI 챗봇도 개편하고 포스트도 하나둘 쌓여가다 보니, 문득 빌드를 돌릴 때마다 터미널이 꽤 묵직하게 멈춰있는 느낌이 들었습니다 🤔 초기엔 진짜 꽤 금방 끝났던 것 같은데 지금 몇 초나 걸리지? 하고 시간을 재보니 대략 15-17초 정도 걸리더군요.

사실 17초 남짓 기다리는 게 대규모 서비스 빌드에 비하면 귀여운 수준이지만, 글 하나 쓰거나 자잘한 오타 하나 고치고 푸시할 때마다 매번 배포를 기다리는 시간이 묘하게 거슬렸습니다 😅 개발자의 성격 급함은 어쩔 수 없나 봅니다.. 8282!

그래서 주말을 맞아 “도대체 어디서 시간을 다 잡아먹고 있는지?” 빌드 파이프라인 프로파일링부터 시작해 봤습니다:)


병목 찾기 — 17초의 주범은 누구인가?

일단 막연하게 추측으로 고치면 안 되니, time bun run build로 빌드 로그와 소요 시간부터 뜯어봤습니다.

총 빌드 소요 시간: 16.79초
├── 포스트 날짜 정합성 검사 (check:dates): ~0.2초
├── Astro 서버 엔트리포인트 생성: ~2.8초
├── 🚨 Vite 클라이언트 번들링 (1,500개 모듈 변환): ~11.8초  <-- 전체의 70%!
└── 135개 정적 HTML 페이지 렌더링: ~1.8초

135개나 되는 마크다운 글과 페이지를 정적 HTML로 찍어내는 건 고작 약 2초밖에 안 걸렸습니다. 역시 내 맥미니? 💪 진짜 병목은 전체 시간의 70% 이상을 혼자 먹어치우고 있던 Vite의 클라이언트 번들링 단계였습니다.

로그를 유심히 보니 transforming (1445) node_modules/... 하면서 무려 1,500개가 넘는 모듈을 하나하나 파싱하고 트랜스파일링하고 있더라구요

“정적 콘텐츠 중심 블로그인데 왜 클라이언트 번들 모듈이 이렇게 비대해졌지?” 하고 의존성을 추적해 보니, 주범이 바로 눈에 들어왔습니다. 바로 다이어그램 렌더링에 쓰고 있던 Mermaid.js였습니다.

번들 크기 폭증의 주범을 발견한 고양이
범인은 바로 너였구나...

모던 번들러(Vite/Rollup)는 번들 크기를 줄이기 위해 Tree Shaking을 수행합니다. 하지만 이 최적화를 거치려면 먼저 모든 모듈을 파싱해 AST(추상 구문 트리)를 만들고, 어떤 함수와 변수가 실제로 쓰이는지 의존성을 추적해야 합니다.

결국 트리 쉐이킹으로 불필요한 코드를 쳐내더라도, Mermaid처럼 거대한 라이브러리가 상단에 정적으로 물려 있으면 수천 개의 모듈을 하나하나 열어서 AST로 파싱하고 분석하는 작업 자체를 매 빌드마다 고스란히 치러야 했던 것입니다. 1,500개 모듈을 변환하느라 11초나 걸린 이유가 바로 여기에..


해결 1: 거대한 Mermaid를 동적 import로 떼어내기

블로그에서 아키텍처나 파이프라인 흐름을 도식화할 때 Mermaid를 정말 유용하게 쓰고 있었습니다. 그런데 기존 Mermaid 코드를 열어보니 이렇게 되어 있더라구요.

// AS-IS: 상단에서 정적으로 무식하게 import
import { useEffect, useRef, useState, useId } from 'react'
import mermaid from 'mermaid' // 🚨 수 MB짜리 거대 라이브러리

Mermaid는 브라우저 안에서 텍스트 문법을 파싱하고, 레이아웃을 계산하고, SVG를 그려내는 완전한 그래픽 엔진입니다. 그러다 보니 패키지 번들 용량만 수 MB에 달할 정도로 어마어마하게 무겁습니다 😩

문제는 블로그의 모든 글에 다이어그램이 들어가는 게 아니라는 점이었습니다. 다이어그램이 들어간 글은 전체의 10%도 안 되는데, 상단에서 정적으로 import해두니 Vite는 “모든 페이지 번들”을 빌드할 때마다 Mermaid의 방대한 AST 파서와 모듈들을 전부 검사하고 트리셰이킹하느라 CPU를 갈아 넣고 있었던 것이죠.

// TO-BE: 다이어그램이 렌더링되는 시점에만 dynamic import
useEffect(() => {
  let isCancelled = false

  const renderChart = async () => {
    try {
      // 컴포넌트 렌더링 시점에 비동기로 청크 로드!
      const mermaid = (await import('mermaid')).default
      
      mermaid.initialize({
        startOnLoad: false,
        theme: isDark ? 'dark' : 'default',
        securityLevel: 'loose',
      })

      const { svg: renderedSvg } = await mermaid.render(`mermaid_${uniqueId}`, chart.trim())
      if (!isCancelled) {
        setSvg(preprocessSvg(renderedSvg))
      }
    } catch (err) {
      if (!isCancelled) setError('Failed to render diagram')
    }
  }

  renderChart()
  return () => { isCancelled = true }
}, [chart, isDark, uniqueId])

해결 방법은 간단했습니다. 상단 import를 과감히 날리고, 실제로 다이어그램이 브라우저에 마운트되는 순간에만 Dynamic Import하도록 코드를 고쳤습니다.

이렇게 바꾸니 Vite가 Mermaid를 별도의 lazy chunk로 완전히 분리해 주었습니다. 메인 번들 빌드 파이프라인이 한결 가벼워진 건 당연했고요 👍


해결 2: Astro 5 정적 페이지 병렬 렌더링 켜기

클라이언트 번들을 다이어트했으니, 내친김에 135개 정적 페이지를 HTML로 렌더링하는 속도도 조금 더 쥐어짜 보기로 했습니다.

영혼이 빠져나간 표정으로 마우스를 잡고 있는 케로로
더 빠르게... 1ms라도 더 쥐어짜야 해...

제 로컬 맥미니는 10코어 M4 칩인데, Astro의 기본 빌드는 이 멀티 코어를 완전히 다 쓰지 않는 느낌이 있었습니다. 찾아보니 Astro 5 설정에 동시성 옵션(concurrency)이 있더라구요?

astro.config.mjs에 4개 스레드로 페이지를 동시 렌더링하도록 옵션을 얹어주었습니다.

export default defineConfig({
  // ... 기존 설정
  build: {
    concurrency: 4, // 135개 페이지를 4개 워커 스레드로 병렬 렌더링
  },
})

Cloudflare Workers Builds 컨테이너 환경도 멀티 vCPU를 제공하므로, 로컬뿐만 아니라 원격 CI/CD 빌드 머신에서도 유의미하게 효과를 볼 수 있는 옵션이었습니다 !


결과: 16.8초 ➔ 12.6초 (25% 단축)

두 가지 수정을 마치고 다시 빌드 시간을 측정해 봤습니다.

구분 / 지표최적화 전 (AS-IS)최적화 후 (TO-BE)변화량 (단축률)
전체 빌드 시간16.79초12.61초약 25% 단축
Vite 번들링 단계11.80초8.50초약 28% 단축
135개 HTML 렌더링1.89초1.09초약 42% 단축

별도의 복잡한 인프라 작업 없이, 무거운 라이브러리 지연 로딩 1개 + 설정 옵션 1줄만으로 빌드 시간이 4.2초나 날아갔습니다.

시상식장에서 열렬히 기립 박수를 치며 환호하는 관객들
딸-깍

이전에는 다이어그램이 아예 없는 평범한 기술 글을 읽으러 온 방문자도 브라우저가 수 MB짜리 Mermaid 번들을 함께 내려받아야 했습니다. 데이터를 낭비시키다니.. 죄송함미다.. 하지만 이제는 다이어그램이 있는 글을 열었을 때만 비동기로 해당 청크를 가져오기 때문에, 대다수 일반 글의 초기 페이지 로딩 속도가 꽤 쾌적해졌습니다 😮‍💨


정리

여기에 추가로 패키지 매니저까지 Bun으로 통일했으니, 이제 로컬 작업부터 Cloudflare 배포 완료까지 걸리는 피드백 루프가 훨씬 경쾌해졌겠죠??