본문으로 건너뛰기

실행 모델

코드가 어디에서 실행되는지 이해하는 것은 TanStack Start 애플리케이션을 구축하는 데 필수적입니다. 이 가이드에서는 TanStack Start의 실행 모델과 코드가 실행되는 위치를 제어하는 방법을 설명합니다.

핵심 원칙: 기본적으로 동형​

TanStack Start의 모든 코드는 기본적으로 동형입니다. 명시적으로 제한하지 않는 한 서버와 클라이언트 모두에서 실행되며 양쪽 번들에 포함됩니다.

// ✅ This runs on BOTH server and client
function formatPrice(price: number) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price)
}

// ✅ Route loaders are ISOMORPHIC
export const Route = createFileRoute('/products')({
loader: async () => {
// This runs on server during SSR AND on client during navigation
const response = await fetch('/api/products')
return response.json()
},
})

중요한 이해 사항: 라우트 loader는 동형이므로 서버에서만 실행되는 것이 아니라 서버와 클라이언트 모두에서 실행됩니다.

실행 경계​

TanStack Start 애플리케이션은 두 가지 환경에서 실행됩니다:

서버 환경​

  • Node.js 런타임에서 파일 시스템, 데이터베이스, 환경 변수에 접근할 수 있습니다
  • SSR 중 - 초기 페이지가 서버에서 렌더링됩니다
  • API 요청 - 서버 함수가 서버 측에서 실행됩니다
  • 빌드 시점 - 정적 생성 및 사전 렌더링이 수행됩니다

클라이언트 환경​

  • 브라우저 런타임에서 DOM, localStorage, 사용자 상호작용에 접근할 수 있습니다
  • 하이드레이션 후 - 초기 서버 렌더링 후 클라이언트가 제어권을 넘겨받습니다
  • 탐색 - 탐색 중에 라우트 로더가 클라이언트 측에서 실행됩니다
  • 사용자 상호작용 - 이벤트 핸들러, 양식 제출 등이 처리됩니다

실행 제어 API​

서버 전용 실행​

API사용 사례클라이언트 동작
createServerFn()RPC 호출, 데이터 변경서버로 네트워크 요청
createServerOnlyFn(fn)유틸리티 함수오류 발생
import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'

// RPC: Server execution, callable from client
const updateUser = createServerFn({ method: 'POST' })
.validator((data: UserData) => data)
.handler(async ({ data }) => {
// Only runs on server, but client can call it
return await db.users.update(data)
})

// Utility: Server-only, client crashes if called
const getEnvVar = createServerOnlyFn(() => process.env.DATABASE_URL)

클라이언트 전용 실행​

API사용 사례서버 동작
createClientOnlyFn(fn)브라우저 유틸리티오류 발생
<ClientOnly>브라우저 API가 필요한 컴포넌트폴백 렌더링
import { createClientOnlyFn } from '@tanstack/react-start'
import { ClientOnly } from '@tanstack/react-router'

// Utility: Client-only, server crashes if called
const saveToStorage = createClientOnlyFn((key: string, value: any) => {
localStorage.setItem(key, JSON.stringify(value))
})

// Component: Only renders children after hydration
function Analytics() {
return (
<ClientOnly fallback={null}>
<GoogleAnalyticsScript />
</ClientOnly>
)
}

useHydrated 훅​

하이드레이션에 의존하는 동작을 더 세밀하게 제어하려면 useHydrated 훅을 사용합니다. 이 훅은 클라이언트가 하이드레이션되었는지를 나타내는 불리언 값을 반환합니다:

import { useHydrated } from '@tanstack/react-router'

function TimeZoneDisplay() {
const hydrated = useHydrated()
const timeZone = hydrated
? Intl.DateTimeFormat().resolvedOptions().timeZone
: 'UTC'

return <div>Your timezone: {timeZone}</div>
}

동작:

  • SSR 중: 항상 false를 반환합니다
  • 첫 번째 클라이언트 렌더링: false를 반환합니다
  • 하이드레이션 후: true를 반환하며 이후 모든 렌더링에서도 true로 유지됩니다

브라우저 시간대, 로케일 또는 localStorage와 같은 클라이언트 측 데이터를 기반으로 콘텐츠를 조건부 렌더링하면서 서버 렌더링을 위한 적절한 폴백을 제공해야 할 때 유용합니다.

환경별 구현​

import { createIsomorphicFn } from '@tanstack/react-start'

// Different implementation per environment
const getDeviceInfo = createIsomorphicFn()
.server(() => ({ type: 'server', platform: process.platform }))
.client(() => ({ type: 'client', userAgent: navigator.userAgent }))

아키텍처 패턴​

점진적 향상​

JavaScript 없이도 작동하고 클라이언트 측 기능으로 향상되는 컴포넌트를 구축합니다:

function SearchForm() {
const [query, setQuery] = useState('')

return (
<form action="/search" method="get">
<input
name="q"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ClientOnly fallback={<button type="submit">Search</button>}>
<SearchButton onSearch={() => search(query)} />
</ClientOnly>
</form>
)
}

환경 인식 스토리지​

const storage = createIsomorphicFn()
.server((key: string) => {
// Server: File-based cache
const fs = require('node:fs')
return JSON.parse(fs.readFileSync('.cache', 'utf-8'))[key]
})
.client((key: string) => {
// Client: localStorage
return JSON.parse(localStorage.getItem(key) || 'null')
})

RPC와 직접 함수 호출 비교​

서버 함수와 서버 전용 함수를 각각 언제 사용해야 하는지 알아봅니다:

// createServerFn: RPC pattern - server execution, client callable
const fetchUser = createServerFn().handler(async () => await db.users.find())

// Usage from client component:
const user = await fetchUser() // ✅ Network request

// createServerOnlyFn: Crashes if called from client
const getSecret = createServerOnlyFn(() => process.env.SECRET)

// Usage from client:
const secret = getSecret() // ❌ Throws error

일반적인 안티패턴​

모듈 수준의 process.env 읽기​

모듈 범위에서 process.env을 읽는 것은 한 가지가 아니라 두 가지 이유로 잘못되었습니다:

  1. 보안: 값이 클라이언트 번들에 인라인될 수 있습니다.
  2. 런타임 정확성: Cloudflare Workers와 기타 엣지 SSR 런타임에서는 env가 요청별로 주입됩니다. 모듈 수준 코드는 env가 존재하기 전인 모듈 로드 시점에 실행되므로, 서버에서도 읽기 결과가 undefined로 평가됩니다.
// ❌ Leaks to client AND is undefined under Worker SSR
const apiKey = process.env.SECRET_KEY

// ✅ Wrap in a server-only function — read happens per call, on the server
const apiKey = createServerOnlyFn(() => process.env.SECRET_KEY)

// ✅ Or read directly inside `.handler()` / middleware `.server()` / server-route handlers
const fetchData = createServerFn().handler(async () => {
const apiKey = process.env.SECRET_KEY
// ...
})

잘못된 로더 가정​

// ❌ Assuming loader is server-only
export const Route = createFileRoute('/users')({
loader: () => {
// This runs on BOTH server and client!
const secret = process.env.SECRET // Exposed to client
return fetch(`/api/users?key=${secret}`)
},
})

// ✅ Use server function for server-only operations
const getUsersSecurely = createServerFn().handler(() => {
const secret = process.env.SECRET // Server-only
return fetch(`/api/users?key=${secret}`)
})

export const Route = createFileRoute('/users')({
loader: () => getUsersSecurely(), // Isomorphic call to server function
})

하이드레이션 불일치​

// ❌ Different content server vs client
function CurrentTime() {
return <div>{new Date().toLocaleString()}</div>
}

// ✅ Consistent rendering
function CurrentTime() {
const [time, setTime] = useState<string>()

useEffect(() => {
setTime(new Date().toLocaleString())
}, [])

return <div>{time || 'Loading...'}</div>
}

수동 환경 감지와 API 기반 환경 감지 비교​

// Manual: You handle the logic
function logMessage(msg: string) {
if (typeof window === 'undefined') {
console.log(`[SERVER]: ${msg}`)
} else {
console.log(`[CLIENT]: ${msg}`)
}
}

// API: Framework handles it
const logMessage = createIsomorphicFn()
.server((msg) => console.log(`[SERVER]: ${msg}`))
.client((msg) => console.log(`[CLIENT]: ${msg}`))

전체 파일을 서버 전용 또는 클라이언트 전용으로 표시하기​

.server.* 및 .client.* 파일명 접미사를 사용하면 파일에 Start의 가져오기 보호가 자동으로 적용됩니다. 파일명을 바꿀 수 없거나 바꾸고 싶지 않다면 파일 상단의 부수 효과 가져오기로 동일한 효과를 구현합니다:

// src/lib/secrets.ts (filename can't be *.server.ts)
import '@tanstack/react-start/server-only'

export function getApiKey() {
return process.env.API_KEY
}
// src/lib/storage.ts
import '@tanstack/react-start/client-only'

export function savePreferences(prefs: Record<string, string>) {
localStorage.setItem('prefs', JSON.stringify(prefs))
}

동일한 파일에 두 마커를 모두 사용하면 오류가 발생합니다. 타입 전용 가져오기는 무시됩니다. 개발 환경과 빌드 환경의 동작, 거부 규칙 구성, 위반 추적 확인을 포함한 전체 참조는 가져오기 보호 가이드를 참고합니다.

아키텍처 결정 프레임워크​

다음과 같은 경우 서버 전용을 선택합니다:

  • 민감한 데이터(환경 변수, 시크릿)에 접근하는 경우
  • 파일 시스템 작업을 수행하는 경우
  • 데이터베이스에 연결하는 경우
  • 외부 API 키를 사용하는 경우

다음과 같은 경우 클라이언트 전용을 선택합니다:

  • DOM 조작
  • 브라우저 API(localStorage, geolocation)
  • 사용자 상호작용 처리
  • 분석/추적

다음 경우에는 Isomorphic을 선택합니다:

  • 데이터 형식 지정/변환
  • 비즈니스 로직
  • 공유 유틸리티
  • 라우트 로더(본질적으로 isomorphic입니다)

보안 고려 사항​

번들 분석​

서버 전용 코드가 클라이언트 번들에 포함되지 않았는지 항상 확인합니다:

# Analyze client bundle
npm run build
# Check dist/client for any server-only imports

환경 변수 전략​

  • 클라이언트에 노출: 클라이언트에서 접근 가능한 변수에는 VITE_ 접두사를 사용합니다
  • 서버 전용: createServerOnlyFn() 또는 createServerFn()을 통해 접근합니다
  • 절대 노출 금지: 데이터베이스 URL, API 키, 시크릿

오류 경계​

서버/클라이언트 실행 오류를 원활하게 처리합니다:

function ErrorBoundary({ children }: { children: React.ReactNode }) {
return (
<ErrorBoundaryComponent
fallback={<div>Something went wrong</div>}
onError={(error) => {
if (typeof window === 'undefined') {
console.error('[SERVER ERROR]:', error)
} else {
console.error('[CLIENT ERROR]:', error)
}
}}
>
{children}
</ErrorBoundaryComponent>
)
}

안전하고 성능이 뛰어나며 유지 관리하기 쉬운 애플리케이션을 구축하려면 TanStack Start의 실행 모델을 이해하는 것이 중요합니다. 기본적으로 isomorphic인 접근 방식은 유연성을 제공하며, 실행 제어 API를 사용하면 필요할 때 정밀하게 제어할 수 있습니다.