본문으로 건너뛰기

실행 모델

코드가 어디에서 실행되는지 이해하는 것은 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를 사용하면 필요할 때 정밀하게 제어할 수 있습니다.