Skip to content

TanStack Start 는 TanStack Router 를 기반으로 하는 풀스택 프레임워크입니다. TanStack Router 와 Vite 를 기반으로 전체 문서 SSR, 스트리밍, 서버 함수, 번들링 등을 지원합니다.


새 TanStack Start 앱 만들기

대화형 CLI 를 사용하여 새 TanStack Start 앱을 만듭니다.

sh
bun create @tanstack/start@latest my-tanstack-app

개발 서버 시작하기

프로젝트 디렉터리로 이동하고 Bun 으로 개발 서버를 실행하세요.

sh
cd my-tanstack-app
bun --bun run dev

이렇게 하면 Bun 으로 Vite 개발 서버가 시작됩니다.

package.json 의 스크립트 수정하기

package.json 의 scripts 필드를 수정하여 Vite CLI 명령 앞에 bun --bun 을 붙이세요. 이렇게 하면 dev, build, preview 와 같은 일반적인 작업에 Bun 이 Vite CLI 를 실행합니다.

json
{
  "scripts": {
    "dev": "bun --bun vite dev", 
    "build": "bun --bun vite build", 
    "serve": "bun --bun vite preview"
  }
}

호스팅

TanStack Start 앱을 호스팅하려면 프로덕션 배포를 위해 Nitro 또는 사용자 지정 Bun 서버를 사용할 수 있습니다.

Nitro

프로젝트에 Nitro 추가하기

프로젝트에 Nitro 를 추가하세요. 이 도구를 사용하면 TanStack Start 앱을 다양한 플랫폼에 배포할 수 있습니다.

sh
bun add nitro

vite.config.ts 파일 업데이트하기

vite.config.ts 파일을 업데이트하여 Bun 과 함께 TanStack Start 에 필요한 플러그인을 포함하세요.

ts
// 기타 import...
import { nitro } from "nitro/vite"; 

const config = defineConfig({
  plugins: [
    tanstackStart(),
    nitro({ preset: "bun" }), 
    // 기타 플러그인...
  ],
});

export default config;

NOTE

`bun` 프리셋은 선택 사항이지만 Bun 런타임을 위해 빌드 출력을 특별히 구성합니다.

시작 명령 업데이트하기

package.json 파일에 buildstart 스크립트가 있는지 확인하세요.

json
  {
    "scripts": {
      "build": "bun --bun vite build", 
      // .output 파일은 `bun run build` 를 실행할 때 Nitro 에 의해 생성됩니다.
      // Vercel 에 배포할 때는 필요하지 않습니다.
      "start": "bun run .output/server/index.mjs"
    }
  }

NOTE

Vercel 에 배포할 때는 사용자 지정 `start` 스크립트가 **필요하지 않습니다**.

앱 배포하기

앱을 호스팅 제공업체에 배포하는 방법에 대한 가이드를 확인하세요.

NOTE

Vercel 에 배포할 때는 `vercel.json` 파일에 `"bunVersion": "1.x"` 를 추가하거나 `vite.config.ts` 파일의 `nitro` 구성에 추가할 수 있습니다.

경고

Vercel 에 배포할 때는 bun Nitro 프리셋을 사용해서는 안 됩니다.

ts
export default defineConfig({
  plugins: [
    tanstackStart(),
    nitro({
      preset: "bun", 
      vercel: { 
        functions: { 
          runtime: "bun1.x", 
        }, 
    }, 
    }),
  ],
});

사용자 지정 서버

NOTE

이 사용자 지정 서버 구현은 [TanStack 의 Bun 템플릿](https://github.com/TanStack/router/blob/main/examples/react/start-bun/server.ts) 을 기반으로 합니다. 이는 작은 파일을 메모리에 미리 로드하여 빠르게 제공하고 큰 파일은 온디맨드로 제공하는 구성 가능한 메모리 관리를 포함하여 정적 자산 제공에 대한 세밀한 제어를 제공합니다. 이 방식은 프로덕션 배포에서 리소스 사용량 및 자산 로드 동작에 대한 정확한 제어가 필요할 때 유용합니다.

프로덕션 서버 만들기

프로젝트 루트에 다음 사용자 지정 서버 구현을 포함한 server.ts 파일을 만듭니다.

ts
/**
* Bun 을 위한 TanStack Start 프로덕션 서버
*
* 구성 가능한 메모리 관리와 함께 지능형 정적 자산 로딩을 구현하는
* TanStack Start 애플리케이션을 위한 고성능 프로덕션 서버.
*
* 기능:
* - 하이브리드 로딩 전략 (작은 파일 미리 로드, 큰 파일 온디맨드 제공)
* - 포함/제외 패턴을 통한 구성 가능한 파일 필터링
* - 메모리 효율적인 응답 생성
* - 프로덕션 준비 완료 캐싱 헤더
*
* 환경 변수:
*
* PORT (number)
*   - 서버 포트 번호
*   - 기본값: 3000
*
* ASSET_PRELOAD_MAX_SIZE (number)
*   - 메모리에 미리 로드할 최대 파일 크기 (바이트)
*   - 이보다 큰 파일은 디스크에서 온디맨드로 제공됨
*   - 기본값: 5242880 (5MB)
*   - 예: ASSET_PRELOAD_MAX_SIZE=5242880 (5MB)
*
* ASSET_PRELOAD_INCLUDE_PATTERNS (string)
*   - 포함할 파일의 쉼표로 구분된 glob 패턴 목록
*   - 지정된 경우 일치하는 파일만 미리 로드 가능
*   - 패턴은 전체 경로가 아닌 파일 이름과만 일치함
*   - 예: ASSET_PRELOAD_INCLUDE_PATTERNS="*.js,*.css,*.woff2"
*
* ASSET_PRELOAD_EXCLUDE_PATTERNS (string)
*   - 제외할 파일의 쉼표로 구분된 glob 패턴 목록
*   - 포함 패턴 이후 적용됨
*   - 패턴은 전체 경로가 아닌 파일 이름과만 일치함
*   - 예: ASSET_PRELOAD_EXCLUDE_PATTERNS="*.map,*.txt"
*
* ASSET_PRELOAD_VERBOSE_LOGGING (boolean)
*   - 로드되고 건너뛴 파일의 상세 로깅 활성화
*   - 기본값: false
*   - 상세 출력 활성화를 위해 "true" 로 설정
*
* ASSET_PRELOAD_ENABLE_ETAG (boolean)
*   - 미리 로드된 자산에 대한 ETag 생성 활성화
*   - 기본값: true
*   - ETag 지원 비활성화를 위해 "false" 로 설정
*
* ASSET_PRELOAD_ENABLE_GZIP (boolean)
*   - 적합한 자산에 대한 Gzip 압축 활성화
*   - 기본값: true
*   - Gzip 압축 비활성화를 위해 "false" 로 설정
*
* ASSET_PRELOAD_GZIP_MIN_SIZE (number)
*   - Gzip 압축에 필요한 최소 파일 크기 (바이트)
*   - 이보다 작은 파일은 압축되지 않음
*   - 기본값: 1024 (1KB)
*
* ASSET_PRELOAD_GZIP_MIME_TYPES (string)
*   - Gzip 압축 대상 MIME 유형의 쉼표로 구분된 목록
*   - "/" 로 끝나는 유형에 대한 부분 일치 지원
*   - 기본값: text/,application/javascript,application/json,application/xml,image/svg+xml
*
* 사용법:
*   bun run server.ts
*/

import path from 'node:path'

// 구성
const SERVER_PORT = Number(process.env.PORT ?? 3000)
const CLIENT_DIRECTORY = './dist/client'
const SERVER_ENTRY_POINT = './dist/server/server.js'

// 전문적인 출력을 위한 로깅 유틸리티
const log = {
  info: (message: string) => {
    console.log(`[INFO] ${message}`)
  },
  success: (message: string) => {
    console.log(`[SUCCESS] ${message}`)
  },
  warning: (message: string) => {
    console.log(`[WARNING] ${message}`)
  },
  error: (message: string) => {
    console.log(`[ERROR] ${message}`)
  },
  header: (message: string) => {
    console.log(`\n${message}\n`)
  },
}

// 환경 변수에서 프리로딩 구성
const MAX_PRELOAD_BYTES = Number(
  process.env.ASSET_PRELOAD_MAX_SIZE ?? 5 * 1024 * 1024, // 기본 5MB
)

// 쉼표로 구분된 포함 패턴 구문 분석 (기본값 없음)
const INCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
  .split(',')
  .map((s) => s.trim())
  .filter(Boolean)
  .map((pattern: string) => convertGlobToRegExp(pattern))

// 쉼표로 구분된 제외 패턴 구문 분석 (기본값 없음)
const EXCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? '')
  .split(',')
  .map((s) => s.trim())
  .filter(Boolean)
  .map((pattern: string) => convertGlobToRegExp(pattern))

// 상세 로깅 플래그
const VERBOSE = process.env.ASSET_PRELOAD_VERBOSE_LOGGING === 'true'

// 선택적 ETag 기능
const ENABLE_ETAG = (process.env.ASSET_PRELOAD_ENABLE_ETAG ?? 'true') === 'true'

// 선택적 Gzip 기능
const ENABLE_GZIP = (process.env.ASSET_PRELOAD_ENABLE_GZIP ?? 'true') === 'true'
const GZIP_MIN_BYTES = Number(process.env.ASSET_PRELOAD_GZIP_MIN_SIZE ?? 1024) // 기본 1KB
const GZIP_TYPES = (
  process.env.ASSET_PRELOAD_GZIP_MIME_TYPES ??
  'text/,application/javascript,application/json,application/xml,image/svg+xml'
)
  .split(',')
  .map((v) => v.trim())
  .filter(Boolean)

/**
* 간단한 glob 패턴을 정규식으로 변환
* * 와일드카드를 지원하여 모든 문자 매칭
*/
function convertGlobToRegExp(globPattern: string): RegExp {
  // * 를 제외한 정규식 특수 문자 이스케이프, затем * 를 .* 로 바꾸기
  const escapedPattern = globPattern
    .replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&')
    .replace(/\*/g, '.*')
  return new RegExp(`^${escapedPattern}$`, 'i')
}

/**
* 주어진 데이터 버퍼에 대한 ETag 계산
*/
function computeEtag(data: Uint8Array): string {
  const hash = Bun.hash(data)
  return `W/"${hash.toString(16)}-${data.byteLength.toString()}"`
}

/**
* 미리 로드된 정적 자산의 메타데이터
*/
interface AssetMetadata {
  route: string
  size: number
  type: string
}

/**
* ETag 및 Gzip 지원이 포함된 인메모리 자산
*/
interface InMemoryAsset {
  raw: Uint8Array
  gz?: Uint8Array
  etag?: string
  type: string
  immutable: boolean
  size: number
}

/**
* 정적 자산 프리로딩 프로세스의 결과
*/
interface PreloadResult {
  routes: Record<string, (req: Request) => Response | Promise<Response>>
  loaded: AssetMetadata[]
  skipped: AssetMetadata[]
}

/**
* 구성된 패턴을 기반으로 파일이 프리로딩 대상인지 확인
*/
function isFileEligibleForPreloading(relativePath: string): boolean {
  const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath

  // 포함 패턴이 지정된 경우 파일은 최소 하나와 일치해야 함
  if (INCLUDE_PATTERNS.length > 0) {
    if (!INCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
      return false
    }
  }

  // 제외 패턴이 지정된 경우 파일은 어느 것과도 일치하지 않아야 함
  if (EXCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
    return false
  }

  return true
}

/**
* MIME 유형이 압축 가능한지 확인
*/
function isMimeTypeCompressible(mimeType: string): boolean {
  return GZIP_TYPES.some((type) =>
    type.endsWith('/') ? mimeType.startsWith(type) : mimeType === type,
  )
}

/**
* 크기 및 MIME 유형에 따라 데이터 조건부 압축
*/
function compressDataIfAppropriate(
  data: Uint8Array,
  mimeType: string,
): Uint8Array | undefined {
  if (!ENABLE_GZIP) return undefined
  if (data.byteLength < GZIP_MIN_BYTES) return undefined
  if (!isMimeTypeCompressible(mimeType)) return undefined
  try {
    return Bun.gzipSync(data.buffer as ArrayBuffer)
  } catch {
    return undefined
  }
}

/**
* ETag 및 Gzip 지원이 포함된 응답 핸들러 함수 생성
*/
function createResponseHandler(
  asset: InMemoryAsset,
): (req: Request) => Response {
  return (req: Request) => {
    const headers: Record<string, string> = {
      'Content-Type': asset.type,
      'Cache-Control': asset.immutable
        ? 'public, max-age=31536000, immutable'
        : 'public, max-age=3600',
    }

    if (ENABLE_ETAG && asset.etag) {
      const ifNone = req.headers.get('if-none-match')
      if (ifNone && ifNone === asset.etag) {
        return new Response(null, {
          status: 304,
          headers: { ETag: asset.etag },
        })
      }
      headers.ETag = asset.etag
    }

    if (
      ENABLE_GZIP &&
      asset.gz &&
      req.headers.get('accept-encoding')?.includes('gzip')
    ) {
      headers['Content-Encoding'] = 'gzip'
      headers['Content-Length'] = String(asset.gz.byteLength)
      const gzCopy = new Uint8Array(asset.gz)
      return new Response(gzCopy, { status: 200, headers })
    }

    headers['Content-Length'] = String(asset.raw.byteLength)
    const rawCopy = new Uint8Array(asset.raw)
    return new Response(rawCopy, { status: 200, headers })
  }
}

/**
* 포함 패턴에서 복합 glob 패턴 생성
*/
function createCompositeGlobPattern(): Bun.Glob {
  const raw = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
    .split(',')
    .map((s) => s.trim())
    .filter(Boolean)
  if (raw.length === 0) return new Bun.Glob('**/*')
  if (raw.length === 1) return new Bun.Glob(raw[0])
  return new Bun.Glob(`{${raw.join(',')}}`)
}

/**
* 지능형 프리로딩 전략으로 정적 라우트 초기화
* 작은 파일은 메모리에 로드되고 큰 파일은 온디맨드로 제공됨
*/
async function initializeStaticRoutes(
  clientDirectory: string,
): Promise<PreloadResult> {
  const routes: Record<string, (req: Request) => Response | Promise<Response>> =
    {}
  const loaded: AssetMetadata[] = []
  const skipped: AssetMetadata[] = []

  log.info(`${clientDirectory} 에서 정적 자산 로딩 중...`)
  if (VERBOSE) {
    console.log(
      `최대 프리로드 크기: ${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`,
    )
    if (INCLUDE_PATTERNS.length > 0) {
      console.log(
        `포함 패턴: ${process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? ''}`,
      )
    }
    if (EXCLUDE_PATTERNS.length > 0) {
      console.log(
        `제외 패턴: ${process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? ''}`,
      )
    }
  }

  let totalPreloadedBytes = 0

  try {
    const glob = createCompositeGlobPattern()
    for await (const relativePath of glob.scan({ cwd: clientDirectory })) {
      const filepath = path.join(clientDirectory, relativePath)
      const route = `/${relativePath.split(path.sep).join(path.posix.sep)}`

      try {
        // 파일 메타데이터 가져오기
        const file = Bun.file(filepath)

        // 파일이 존재하지 않거나 비어있으면 건너뛰기
        if (!(await file.exists()) || file.size === 0) {
          continue
        }

        const metadata: AssetMetadata = {
          route,
          size: file.size,
          type: file.type || 'application/octet-stream',
        }

        // 파일이 프리로딩 대상인지 확인
        const matchesPattern = isFileEligibleForPreloading(relativePath)
        const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES

        if (matchesPattern && withinSizeLimit) {
          // 작은 파일을 ETag 및 Gzip 지원과 함께 메모리에 프리로드
          const bytes = new Uint8Array(await file.arrayBuffer())
          const gz = compressDataIfAppropriate(bytes, metadata.type)
          const etag = ENABLE_ETAG ? computeEtag(bytes) : undefined
          const asset: InMemoryAsset = {
            raw: bytes,
            gz,
            etag,
            type: metadata.type,
            immutable: true,
            size: bytes.byteLength,
          }
          routes[route] = createResponseHandler(asset)

          loaded.push({ ...metadata, size: bytes.byteLength })
          totalPreloadedBytes += bytes.byteLength
        } else {
          // 크거나 필터링된 파일은 온디맨드로 제공
          routes[route] = () => {
            const fileOnDemand = Bun.file(filepath)
            return new Response(fileOnDemand, {
              headers: {
                'Content-Type': metadata.type,
                'Cache-Control': 'public, max-age=3600',
              },
            })
          }

          skipped.push(metadata)
        }
      } catch (error: unknown) {
        if (error instanceof Error && error.name !== 'EISDIR') {
          log.error(`${filepath} 로드 실패: ${error.message}`)
        }
      }
    }

    // 상세 파일 개요는 상세 모드 활성화 시에만 표시
    if (VERBOSE && (loaded.length > 0 || skipped.length > 0)) {
      const allFiles = [...loaded, ...skipped].sort((a, b) =>
        a.route.localeCompare(b.route),
      )

      // 정렬을 위한 최대 경로 길이 계산
      const maxPathLength = Math.min(
        Math.max(...allFiles.map((f) => f.route.length)),
        60,
      )

      // 파일 크기를 KB 및 실제 gzip 크기로 포맷
      const formatFileSize = (bytes: number, gzBytes?: number) => {
        const kb = bytes / 1024
        const sizeStr = kb < 100 ? kb.toFixed(2) : kb.toFixed(1)

        if (gzBytes !== undefined) {
          const gzKb = gzBytes / 1024
          const gzStr = gzKb < 100 ? gzKb.toFixed(2) : gzKb.toFixed(1)
          return {
            size: sizeStr,
            gzip: gzStr,
          }
        }

        // 실제 gzip 데이터가 없는 경우 대략적인 gzip 추정 (일반적으로 30-70% 압축)
        const gzipKb = kb * 0.35
        return {
          size: sizeStr,
          gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1),
        }
      }

      if (loaded.length > 0) {
        console.log('\n📁 메모리에 프리로드됨:')
        console.log(
          '경로                                          │    크기 │ Gzip 크기',
        )
        loaded
          .sort((a, b) => a.route.localeCompare(b.route))
          .forEach((file) => {
            const { size, gzip } = formatFileSize(file.size)
            const paddedPath = file.route.padEnd(maxPathLength)
            const sizeStr = `${size.padStart(7)} kB`
            const gzipStr = `${gzip.padStart(7)} kB`
            console.log(`${paddedPath} │ ${sizeStr} │  ${gzipStr}`)
          })
      }

      if (skipped.length > 0) {
        console.log('\n💾 온디맨드 제공:')
        console.log(
          '경로                                          │    크기 │ Gzip 크기',
        )
        skipped
          .sort((a, b) => a.route.localeCompare(b.route))
          .forEach((file) => {
            const { size, gzip } = formatFileSize(file.size)
            const paddedPath = file.route.padEnd(maxPathLength)
            const sizeStr = `${size.padStart(7)} kB`
            const gzipStr = `${gzip.padStart(7)} kB`
            console.log(`${paddedPath} │ ${sizeStr} │  ${gzipStr}`)
          })
      }
    }

    // 상세 모드 활성화 시 상세 정보 표시
    if (VERBOSE) {
      if (loaded.length > 0 || skipped.length > 0) {
        const allFiles = [...loaded, ...skipped].sort((a, b) =>
          a.route.localeCompare(b.route),
        )
        console.log('\n📊 상세 파일 정보:')
        console.log(
          '상태         │ 경로                            │ MIME 유형                    │ 이유',
        )
        allFiles.forEach((file) => {
          const isPreloaded = loaded.includes(file)
          const status = isPreloaded ? 'MEMORY' : 'ON-DEMAND'
          const reason =
            !isPreloaded && file.size > MAX_PRELOAD_BYTES
              ? '너무 큼'
              : !isPreloaded
                ? '필터됨'
                : '프리로드됨'
          const route =
            file.route.length > 30
              ? file.route.substring(0, 27) + '...'
              : file.route
          console.log(
            `${status.padEnd(12)} │ ${route.padEnd(30)} │ ${file.type.padEnd(28)} │ ${reason.padEnd(10)}`,
          )
        })
      } else {
        console.log('\n📊 표시할 파일을 찾을 수 없음')
      }
    }

    // 파일 목록 이후 요약 로그
    console.log() // 구분용 빈 줄
    if (loaded.length > 0) {
      log.success(
        `${String(loaded.length)} 개의 파일을 메모리에 프리로드함 (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB)`,
      )
    } else {
      log.info('메모리에 프리로드된 파일 없음')
    }

    if (skipped.length > 0) {
      const tooLarge = skipped.filter((f) => f.size > MAX_PRELOAD_BYTES).length
      const filtered = skipped.length - tooLarge
      log.info(
        `${String(skipped.length)} 개의 파일이 온디맨드로 제공됨 (${String(tooLarge)} 개 너무 큼, ${String(filtered)} 개 필터됨)`,
      )
    }
  } catch (error) {
    log.error(
      `${clientDirectory} 에서 정적 파일 로드 실패: ${String(error)}`,
    )
  }

  return { routes, loaded, skipped }
}

/**
* 서버 초기화
*/
async function initializeServer() {
  log.header('프로덕션 서버 시작')

  // TanStack Start 서버 핸들러 로드
  let handler: { fetch: (request: Request) => Response | Promise<Response> }
  try {
    const serverModule = (await import(SERVER_ENTRY_POINT)) as {
      default: { fetch: (request: Request) => Response | Promise<Response> }
    }
    handler = serverModule.default
    log.success('TanStack Start 애플리케이션 핸들러 초기화됨')
  } catch (error) {
    log.error(`서버 핸들러 로드 실패: ${String(error)}`)
    process.exit(1)
  }

  // 지능형 프리로딩과 함께 정적 라우트 구축
  const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY)

  // Bun 서버 생성
  const server = Bun.serve({
    port: SERVER_PORT,

    routes: {
      // 정적 자산 제공 (프리로드 또는 온디맨드)
      ...routes,

      // 기타 모든 라우트는 TanStack Start 핸들러로 폴백
      '/*': (req: Request) => {
        try {
          return handler.fetch(req)
        } catch (error) {
          log.error(`서버 핸들러 오류: ${String(error)}`)
          return new Response('Internal Server Error', { status: 500 })
        }
      },
    },

    // 전역 오류 핸들러
    error(error) {
      log.error(
        `처리되지 않은 서버 오류: ${error instanceof Error ? error.message : String(error)}`,
      )
      return new Response('Internal Server Error', { status: 500 })
    },
  })

  log.success(`서버가 http://localhost:${String(server.port)} 에서 수신 중`)
}

// 서버 초기화
initializeServer().catch((error: unknown) => {
  log.error(`서버 시작 실패: ${String(error)}`)
  process.exit(1)
})

package.json 스크립트 업데이트하기

사용자 지정 서버를 실행하는 start 스크립트를 추가하세요.

json
{
  "scripts": {
    "build": "bun --bun vite build",
    "start": "bun run server.ts"
  }
}

빌드 및 실행

애플리케이션을 빌드하고 서버를 시작하세요.

sh
bun run build
bun run start

서버는 기본적으로 포트 3000 에서 시작됩니다 (PORT 환경 변수로 구성 가능).



템플릿


호스팅에 대한 자세한 내용은 → TanStack Start 공식 문서 를 참조하세요.

Bun by www.bunjs.com.cn 편집