블로그로 돌아가기

Next.js에서 TanStack Start로 옮긴 회고

Next.js → TanStack Start + Cloudflare Workers 마이그레이션 실전 회고. 미리 알았으면 좋았을 함정 4가지와 Subagent-Driven Development 메타 워크플로우.

TanStack StartCloudflare WorkersFumadocsVite마이그레이션회고

Next.js 16 + Fumadocs 스택이 잘 돌아가고 있었어요. 빌드는 빠르고, 컨텐츠 파이프라인도 안정적이고, 배포도 문제없었죠. 그런데도 마이그레이션을 결심했어요.

이 글은 그 선택의 이유와 과정, 그리고 미리 알았으면 좋았을 함정 4가지를 정리한 회고예요. TanStack Start로의 전환을 검토하고 있다면 이 글이 시행착오를 줄여줄 거예요.

결과부터 말씀드리면: 30커밋, 22개의 prerendered 페이지, TypeScript·테스트·FSD lint 모두 클린. 라이브 URL은 https://potato-log.zrhdnl.workers.dev에서 확인하실 수 있어요.


왜 잘 되는 걸 굳이 바꿨는가

개인 블로그라는 맥락에서 이 질문은 실제로 쉬워요. "비용 대비 효과"가 아니라 "쓰고 싶은 도구"가 우선순위가 되어도 정당한 맥락이니까요.

그래도 명확한 이유가 몇 가지 있었어요.

명시적 로더에 대한 선호. Next.js의 App Router는 RSC(React Server Component)가 암묵적으로 데이터를 페치하는 구조예요. 컴포넌트 안에서 async/await를 쓰면 마법처럼 작동하죠. 처음엔 편했는데, 시간이 지날수록 "이 컴포넌트가 언제 렌더링되는가"를 추적하기가 피로해졌어요. TanStack Start의 로더는 라우트 파일에 명시적으로 선언돼요. 어떤 데이터가 어떤 시점에 페치되는지 한눈에 보여요.

Cloudflare Workers 친화성. Next.js도 Cloudflare Pages에 배포할 수 있지만, Edge Runtime 제약이 까다로워요. TanStack Start는 Nitro를 통해 cloudflare-module 프리셋을 바로 사용할 수 있어요. Workers Static Assets도 기본 지원이고요.

학습/탐험 가치. 솔직히 말하면 이게 가장 컸어요. 새 도구를 실제 프로젝트에서 써보는 것과 데모 앱에서 써보는 건 배울 수 있는 게 전혀 달라요. 개인 블로그는 실패해도 괜찮은 안전한 실험 공간이에요.

기존 스택은 Next.js 16.2.4 + Fumadocs 16.8.7이었고, 새 스택은 TanStack Start v1.167+ + Vite + Fumadocs(동일 버전) + Tailwind v4 + Biome + Steiger로 교체했어요.


사전 분석에서 잡은 4가지 위험

무작정 시작하기 전에 기존 프로젝트에서 어떤 요소가 마이그레이션을 어렵게 만들지 먼저 정리했어요.

Vite+ alpha 단계

당시 TanStack Start는 Vite+(@vitejs/vite-plus)에 의존하고 있었어요. Vite+는 실험적 포크로, alpha 단계라는 건 예고 없는 API 변경과 불안정한 에코시스템을 의미해요. 핵심 의존성이 불안정하면 마이그레이션 완료 이후에도 꾸준히 추적해야 해요.

heavy client 라이브러리 4종

기존 블로그에는 Mermaid, Three.js, p5.js, Nivo, GSAP 총 5종의 시각화·애니메이션 라이브러리가 있었어요. 이 중 Mermaid, Three.js, p5.js, Nivo는 SSR(서버 사이드 렌더링) 환경에서 windowdocument에 직접 접근해서 빌드 오류를 일으켜요. Next.js에서는 dynamic(() => import(...), { ssr: false })로 간단히 처리했지만, TanStack Start에서는 접근 방식이 달라요.

MDX 진입점 패턴 차이

Next.js + Fumadocs 조합에서는 next.config에서 MDX 플러그인을 선언하면 Fumadocs가 RSC를 통해 콘텐츠를 불러와요. TanStack Start에서는 Vite 플러그인으로 선언하고, 명시적인 로더를 통해 콘텐츠를 연결해야 해요. 동일한 Fumadocs 버전이지만 연결 방식이 완전히 달라요.

빌드 타임 폭발 가능성

prerender 설정을 잘못하면 빌드 시 없는 라우트를 탐색하다 fatal error로 멈춰요. 콘텐츠가 많을수록 이 위험이 커지죠.

이 4가지 위험 중 3번째와 4번째는 두 개의 헬퍼 함수로 90% 이상 해소했어요.


두 헬퍼로 위험을 흡수하다

clientHeavy — SSR 비호환 라이브러리를 한 줄로

SSR 비호환 라이브러리를 감싸는 패턴은 언제나 비슷해요: ClientOnly 래퍼 + lazy import + Suspense. 이걸 22개의 컴포넌트마다 반복하는 건 비효율적이에요. 그래서 이 패턴을 단일 헬퍼로 추상화했어요.

src/shared/lib/client-heavy.tsx
import { lazy, Suspense, type ComponentType } from 'react'

/**
 * SSR 비호환 컴포넌트를 클라이언트 전용으로 래핑합니다.
 * - lazy + Suspense로 코드 스플리팅
 * - fallback은 선택적으로 주입 가능
 */
export function clientHeavy<T extends object>(
  factory: () => Promise<{ default: ComponentType<T> }>,
  fallback: React.ReactNode = null
) {
  const LazyComponent = lazy(factory)

  return function ClientHeavyWrapper(props: T) {
    return (
      <Suspense fallback={fallback}>
        <LazyComponent {...(props as any)} />
      </Suspense>
    )
  }
}

사용할 때는 이렇게 한 줄로 끝나요:

// Mermaid 다이어그램
export const MermaidDiagram = clientHeavy(
  () => import('@/widgets/mermaid/ui/MermaidDiagram')
)

// p5.js 스케치
export const P5Sketch = clientHeavy(
  () => import('@/widgets/p5/ui/P5Sketch'),
  <div className="h-64 animate-pulse bg-fd-muted rounded-lg" />
)

// Three.js 뷰어
export const ThreeViewer = clientHeavy(
  () => import('@/widgets/three/ui/ThreeViewer')
)

Mermaid, Three.js, Nivo 차트 22개가 이 패턴 하나로 일괄 처리됐어요.

createDocsRoute — splat 라우트 80줄을 15줄로

Fumadocs 기반의 문서/블로그 라우트는 splat(와일드카드) 라우트를 써요. TanStack Start에서 이를 구현하면 로더, prerender 설정, 메타데이터 생성이 한 파일에 뒤섞여서 80줄 가까이 됐어요. 블로그와 갤러리 두 곳에 동일한 구조가 반복됐고요.

createDocsRoute는 이 구조를 팩토리 함수로 추상화한 헬퍼예요.

src/shared/lib/create-docs-route.ts
// 축약 인터페이스만 보여드려요.
// collection: Fumadocs의 getPages()가 반환하는 컬렉션
// basePath: '/blog' | '/gallery' 같은 base URL
// getPage: slug -> 페이지 데이터 반환 함수

export function createDocsRoute({ collection, basePath, getPage }) {
  return createFileRoute(`${basePath}/$`)({
    loader: async ({ params }) => { /* ... */ },
    component: DocsPage,
  })
}

블로그 라우트는 이렇게 한 줄로 선언해요:

src/routes/blog/$.tsx
export const Route = createDocsRoute({
  collection: blogCollection,
  basePath: '/blog',
  getPage: (slug) => getPage(blogPages, slug),
})

갤러리도 동일한 헬퍼를 재사용해서 중복 코드를 제거했어요.


미리 알았으면 좋았을 함정 4가지

헬퍼로 해소한 것들은 있었지만, 예상하지 못한 곳에서 막히는 게 마이그레이션의 본질이에요. 다음 4가지는 실제로 막혀서 원인을 파악하는 데 시간이 걸렸어요.

함정 1: Fumadocs server collection은 Vite 런타임 안에서만 풀린다

fumadocs-mdx의 server collection은 vite.config.ts가 평가되는 시점에 파일 시스템을 읽어서 콘텐츠 트리를 구성해요. Vite 런타임 안에서는 완벽하게 작동하죠.

문제는 빌드 스크립트나 외부 Node.js 스크립트에서 이 컬렉션을 직접 가져오려 할 때 발생해요. vite.config 평가 없이 collection을 import하면 빈 배열이 반환돼요.

// ❌ 빌드 스크립트에서 직접 import하면 빈 배열 반환
import { allPosts } from '../content/generated'
console.log(allPosts.length) // 0

// ✅ Vite 컨텍스트 밖에서는 fs.readdirSync로 우회
import { readdirSync } from 'fs'
import { join } from 'path'

const contentDir = join(process.cwd(), 'content/blog')
const slugs = readdirSync(contentDir)
  .filter(f => f.endsWith('.mdx'))
  .map(f => f.replace('.mdx', ''))

prerender용 경로 목록을 생성하는 스크립트를 따로 작성하거나, Vite 플러그인 안에서 경로를 수집해야 해요.

collection은 Vite 플러그인 맥락에서만 유효합니다

fumadocs-mdx의 server collection을 Vite 컨텍스트 밖에서 직접 import하면 빈 컬렉션이 반환됩니다. prerender 경로 생성이나 빌드 스크립트에서는 fs.readdirSync로 경로를 직접 읽어야 합니다.

함정 2: TanStack Start prerender의 pages는 top-level 옵션이다

TanStack Start 문서를 보면 prerender 설정이 defineConfig 아래의 server 옵션 안에 있는 것처럼 보여요. 실제로 적용해보니 pages 키가 server 하위가 아니라 top-level 옵션이에요.

// ❌ 문서 예제를 그대로 따라갔을 때
export default defineConfig({
  server: {
    prerender: {
      pages: ['/blog/hello-world'], // 인식 안 됨
    },
  },
})

// ✅ 실제로 동작하는 구조
export default defineConfig({
  server: {
    preset: 'cloudflare-module',
  },
  routers: {
    ssr: {
      prerender: {
        pages: ['/blog/hello-world'], // top-level of routers.ssr
      },
    },
  },
})

이 차이가 빌드 로그에 아무 경고 없이 조용히 무시되기 때문에 더 찾기 어려웠어요. prerender가 되어야 할 페이지가 런타임에 SSR되고 있는데 이유를 모르는 상황이 한동안 이어졌어요.

prerender 설정 위치를 꼭 확인하세요

TanStack Start v1.x 기준으로 prerender.pagesrouters.ssr 하위에 있습니다. server 하위에 두면 조용히 무시됩니다.

함정 3: prerender 404는 경고가 아니라 fatal error다

prerender 대상으로 등록된 URL이 빌드 시점에 실제로 접근 가능하지 않으면, TanStack Start는 경고를 출력하지 않아요. 빌드 자체가 멈춰요.

개발 중에 새로운 블로그 글을 추가했는데, 해당 라우트의 enabled 플래그를 먼저 올리지 않은 채로 prerender 목록에 경로를 추가하면 이런 일이 생겨요.

// ❌ 라우트 활성화 전에 경로를 prerender 목록에 추가하면 fatal error
const Route = createFileRoute('/blog/wip-post')({
  // 아직 개발 중 — 라우트 컴포넌트 미완성
})

// tanstack.config.ts
prerender: {
  pages: ['/blog/wip-post'], // → 빌드 fatal error
}

// ✅ enabled 가드로 방어
const Route = createFileRoute('/blog/wip-post')({
  loader: async () => {
    if (!FEATURE_FLAGS.wipPost) {
      throw notFound() // prerender 시 이 경로를 건너뜀
    }
    return loadPost('wip-post')
  },
})

초안 단계의 콘텐츠가 있을 때는 prerender 목록에서 제외하거나, notFound()를 던지는 가드를 먼저 추가해야 해요.

함정 4: p5.js는 clientHeavy로 감싸도 SSR 모듈 그래프에 잡힌다

이게 가장 까다로웠어요. clientHeavy로 감쌌고, lazy import도 했는데 빌드하면 p5.js 관련 모듈이 SSR 번들에 포함돼서 window is not defined 오류가 발생했어요.

원인은 p5.js가 모듈 최상단에서 window에 접근하는 방식 때문이에요. Vite의 모듈 그래프 분석 단계에서 import chain을 따라 올라가다 SSR 번들에 포함되어버려요. lazy import로도 이 분석을 완전히 차단할 수 없었어요.

// ❌ clientHeavy만으로는 p5.js SSR 번들 포함을 막지 못함
export const P5Sketch = clientHeavy(
  () => import('@/widgets/p5/ui/P5Sketch')
)
// → 빌드 시 p5가 SSR 번들에 포함됨 → window is not defined

// ✅ 해당 라우트 자체를 ssr:false로 설정
// routes/gallery/p5/$.tsx
export const Route = createFileRoute('/gallery/p5/$')({
  ssr: false, // 이 라우트는 SPA로 렌더링
  component: P5GalleryPage,
})

ssr: false로 설정하면 해당 라우트 전체가 클라이언트 전용 SPA가 돼요. p5.js 갤러리는 인터랙티브 데모라서 SSR이 크게 필요하지 않아요. hydration이 본질이니까요. 이 경우엔 트레이드오프가 충분히 수용 가능했어요.

p5.js, Three.js: 라우트 단위 ssr:false가 유일한 해법일 수 있습니다

모듈 최상단에서 브라우저 전역 객체에 접근하는 라이브러리는 lazy import나 ClientOnly 래퍼만으로는 SSR 번들에서 제외되지 않을 수 있습니다. 라우트 단위로 ssr: false를 적용하는 것이 근본적인 해법입니다.


Subagent-Driven Development라는 메타 회고

이 마이그레이션을 진행하면서 워크플로우 자체에 대해서도 배운 게 있어요.

30커밋을 1세션에서 완료했어요. 제가 controller 역할을 했고, 매 태스크마다 fresh context를 가진 implementer(subagent)가 작업을 맡았어요. 태스크 완료 후에는 두 단계 리뷰를 거쳤어요: spec review(요구사항 충족 여부)와 code quality review(타입·테스트·린트 통과 여부).

핵심은 "DONE이라고 보고했지만 실제로는 깨진" 케이스가 0건이었다는 거예요. 매 단계마다 빌드와 타입 체크를 실제로 실행했어요. subagent가 "완료했습니다"라고 보고해도, 검증 단계에서 빌드가 깨지면 그 태스크는 완료가 아니에요.

첫 번째 태스크에서 BLOCKING 이슈(Vercel preset이 cloudflare-module과 충돌)가 바로 잡힌 게 좋은 신호였어요. 작은 태스크부터 시작해서 실제로 막히는 지점을 찾아내는 구조가 효과적으로 작동한다는 걸 확인했어요.

흥미로운 부분은 subagent의 fresh context가 제 stale plan을 보정하는 일이 여러 번 있었다는 거예요. 저는 "이렇게 하면 될 것 같다"는 plan을 가지고 있었는데, implementer가 실제로 구현하다 보면 그 plan의 전제가 틀린 경우가 나왔어요. 특히 함정 2(prerender pages 위치)와 함정 4(p5.js SSR 번들)가 그런 경우였어요.

controller가 전체 맥락을 유지하고, implementer가 각 태스크에서 독립적으로 검증하는 구조. 이게 혼자 작업하면서도 review cycle을 만드는 방법이에요.


트레이드오프와 결론

모든 결정에는 트레이드오프가 있어요.

p5.js 갤러리를 SPA-only로 전환. SSR을 포기하고 ssr: false로 설정했어요. p5.js 기반의 인터랙티브 데모는 SEO 가치가 거의 없고, 사용자 경험의 핵심은 인터랙션이에요. hydration이 본질인 페이지에서 SSR을 강제하는 건 불필요한 복잡성을 더하는 거라고 판단했어요.

Vite+는 지금은 순정 Vite로 교체 가능. 마이그레이션 시점에는 Vite+를 써야 했지만, 이후 TanStack Start가 순정 Vite로 이전했어요. alpha 단계의 포크에 의존하는 위험은 해소됐어요. 새로 시작한다면 Vite+를 걱정할 필요 없어요.

DNS 컷오버는 며칠 베이크 후. potatolog.dev 도메인의 DNS 컷오버는 바로 하지 않았어요. 다른 컴퓨터에서 PM2 + Cloudflare Proxy로 기존 Next.js 사이트가 운영 중이었기 때문에 충돌을 피하려고 했어요. 새 배포는 https://potato-log.zrhdnl.workers.dev에서 먼저 베이크하고, 안정성을 확인한 후에 컷오버할 계획이에요.

결과 요약. 30커밋, 22개 prerendered 페이지, TypeScript·테스트·FSD lint 모두 클린. 개인 블로그 규모에서 TanStack Start + Cloudflare Workers 조합은 충분히 실용적이에요. 명시적인 로더와 직관적인 라우팅 모델 덕분에 코드 흐름을 추적하기가 훨씬 수월해졌어요.

RSC의 암묵적 마법에서 벗어나고 싶다면, 그리고 Cloudflare 인프라에 친화적인 스택을 원한다면 검토해볼 만한 선택이에요.

TL;DR

  • fumadocs-mdx server collection은 Vite 런타임 안에서만 유효해요. 빌드 스크립트에서는 fs.readdirSync로 우회하세요.
  • TanStack Start prerender.pagesrouters.ssr 하위에 있어요. server 하위에 두면 조용히 무시됩니다.
  • prerender 대상 라우트가 빌드 시점에 없으면 fatal error예요. notFound() 가드로 방어하세요.
  • p5.js처럼 모듈 최상단에서 window에 접근하는 라이브러리는 라우트 단위 ssr: false가 근본적인 해법이에요.
  • clientHeavy + createDocsRoute 두 헬퍼로 SSR 비호환 라이브러리와 반복 라우트 코드를 대부분 흡수할 수 있어요.

참고 자료