본문으로 건너뛰기

TanStack Start로 풀 스택 DevJokes 앱 구축하기

이 튜토리얼에서는 TanStack Start를 사용해 완전한 풀 스택 애플리케이션을 구축하는 방법을 안내합니다. 사용자가 개발자 테마의 농담을 보고 추가할 수 있는 DevJokes 앱을 만들면서 서버 함수, 파일 기반 데이터 저장소 및 React 컴포넌트를 비롯한 TanStack Start의 핵심 개념을 살펴봅니다.

다음은 앱이 실제로 작동하는 데모입니다:

이 튜토리얼의 전체 코드는 GitHub에서 확인할 수 있습니다.

학습할 내용

  1. TanStack Start 프로젝트 설정하기
  2. 서버 함수 구현하기
  3. 파일에서 데이터 읽고 쓰기
  4. React 컴포넌트로 완전한 UI 구축하기
  5. 데이터 가져오기와 탐색에 TanStack Router 사용하기

사전 요구 사항

  • React와 TypeScript에 대한 기본 지식이 필요합니다.
  • 컴퓨터에 Node.js와 pnpm가 설치되어 있어야 합니다

알아두면 좋은 내용

TanStack Start 프로젝트 설정하기

먼저 새로운 TanStack Start 프로젝트를 생성합니다:

pnpx @tanstack/cli@latest create devjokes
cd devjokes

이 스크립트를 실행하면 몇 가지 설정 관련 질문이 표시됩니다. 필요에 맞는 항목을 선택하거나 Enter 키를 눌러 기본값을 적용할 수 있습니다.

선택적으로 --add-on 플래그를 전달하여 Shadcn, Clerk, Convex, TanStack Query 등의 옵션을 사용할 수 있습니다.

설정이 완료되면 종속성을 설치하고 개발 서버를 시작합니다:

pnpm i
pnpm dev

이 프로젝트에는 uuid 패키지가 필요합니다:

# Install uuid for generating unique IDs
pnpm add uuid

프로젝트 구조 이해하기

이 시점의 프로젝트 구조는 다음과 같습니다 -

/devjokes
├── src/
│ ├── routes/
│ │ ├── demo/ # Demo routes
│ │ ├── __root.tsx # Root layout
│ │ └── index.tsx # Home page
│ ├── components/ # React components
│ ├── data/ # Data files
│ ├── router.tsx # Router configuration
│ ├── routeTree.gen.ts # Generated route tree
│ └── styles.css # Global styles
├── public/ # Static assets
├── vite.config.ts or rsbuild.config.ts # TanStack Start configuration
├── package.json # Project dependencies
└── tsconfig.json # TypeScript configuration

이 구조가 처음에는 복잡해 보일 수 있지만, 중점적으로 살펴봐야 할 핵심 파일은 다음과 같습니다:

  1. src/router.tsx - 애플리케이션의 라우팅을 설정합니다
  2. src/routes/__root.tsx - 전역 스타일과 컴포넌트를 추가할 수 있는 루트 레이아웃 컴포넌트입니다
  3. src/routes/index.tsx - 홈페이지입니다

프로젝트 설정이 완료되면 localhost:3000에서 앱에 접근할 수 있습니다. 기본 TanStack Start 시작 페이지가 표시됩니다.

이 시점의 앱은 다음과 같습니다:

설정 후 TanStack Start 시작 페이지

1단계: 파일에서 데이터 읽기

먼저 농담을 위한 파일 기반 저장소 시스템을 만듭니다.

1.1단계: 농담이 포함된 JSON 파일 만들기

페이지에 렌더링할 농담 목록을 설정합니다. src/data 내부에 jokes.json 파일을 만듭니다:

touch src/data/jokes.json

이제 이 파일에 몇 가지 예시 농담을 추가합니다:

[
{
"id": "1",
"question": "Why don't keyboards sleep?",
"answer": "Because they have two shifts"
},
{
"id": "2",
"question": "Are you a RESTful API?",
"answer": "Because you GET my attention, PUT some love, POST the cutest smile, and DELETE my bad day"
},
{
"id": "3",
"question": "I used to know a joke about Java",
"answer": "But I ran out of memory."
},
{
"id": "4",
"question": "Why do Front-End Developers eat lunch alone?",
"answer": "Because, they don't know how to join tables."
},
{
"id": "5",
"question": "I am declaring a war.",
"answer": "var war;"
}
]

1.2단계: 데이터 타입 만들기

데이터 타입을 정의할 파일을 만듭니다. src/types/index.ts에 새 파일을 만듭니다:

// src/types/index.ts
export interface Joke {
id: string
question: string
answer: string
}

export type JokesData = Joke[]

1.3단계: 파일을 읽는 서버 함수 만들기

읽기-쓰기 작업을 수행하는 서버 함수를 만들기 위해 새 파일 src/serverActions/jokesActions.ts을 만듭니다. createServerFn을 사용하여 서버 함수를 만듭니다.

// src/serverActions/jokesActions.ts
import { createServerFn } from '@tanstack/react-start'
import * as fs from 'node:fs'
import type { JokesData } from '../types'

const JOKES_FILE = 'src/data/jokes.json'

export const getJokes = createServerFn({ method: 'GET' }).handler(async () => {
const jokes = await fs.promises.readFile(JOKES_FILE, 'utf-8')
return JSON.parse(jokes) as JokesData
})

이 코드에서는 createServerFn을 사용하여 JSON 파일에서 농담을 읽는 서버 함수를 만듭니다. handler 함수에서는 fs 모듈을 사용하여 파일을 읽습니다.

1.4단계: 클라이언트 측에서 서버 함수 사용하기

이제 TanStack Start에 이미 포함된 TanStack Router를 사용하여 코드에서 이 서버 함수를 간단히 호출할 수 있습니다!

이제 약간의 Tailwind 스타일링을 더해 페이지에 농담을 렌더링하는 새 컴포넌트 JokesList을 생성하겠습니다.

// src/components/JokesList.tsx
import { Joke } from '../types'

interface JokesListProps {
jokes: Joke[]
}

export function JokesList({ jokes }: JokesListProps) {
if (!jokes || jokes.length === 0) {
return <p className="text-gray-500 italic">No jokes found. Add some!</p>
}

return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Jokes Collection</h2>
{jokes.map((joke) => (
<div
key={joke.id}
className="bg-white p-4 rounded-lg shadow-md border border-gray-200"
>
<p className="font-bold text-lg mb-2">{joke.question}</p>
<p className="text-gray-700">{joke.answer}</p>
</div>
))}
</div>
)
}

이제 TanStack Start에 이미 포함된 TanStack Router를 사용하여 index.tsx 내부에서 서버 함수를 호출하겠습니다!

// src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { getJokes } from './serverActions/jokesActions'
import { JokesList } from './JokesList'

export const Route = createFileRoute('/')({
loader: async () => {
// Load jokes data when the route is accessed
return getJokes()
},
component: App,
})

const App = () => {
const jokes = Route.useLoaderData() || []

return (
<div className="max-w-2xl mx-auto py-12 px-4 space-y-6">
<h1 className="text-4xl font-bold text-center mb-10">DevJokes</h1>
<JokesList jokes={jokes} />
</div>
)
}

페이지가 로드되면 jokes에는 이미 jokes.json 파일의 데이터가 들어 있습니다!

약간의 Tailwind 스타일링을 적용하면 앱은 다음과 같이 표시됩니다:

DevJoke 5개가 있는 DevJoke 앱

2단계: 파일에 데이터 쓰기

지금까지 파일에서 성공적으로 데이터를 읽었습니다! 같은 방식을 사용하여 createServerFunction으로 jokes.json 파일에 데이터를 쓸 수 있습니다.

2.1단계: 파일에 쓰는 서버 함수 생성

이제 새 농담을 추가할 수 있도록 jokes.json 파일을 수정할 차례입니다. 이번에는 같은 파일에 쓰기 위해 POST 메서드를 사용하는 또 다른 서버 함수를 생성하겠습니다.

// src/serverActions/jokesActions.ts
import { createServerFn } from '@tanstack/react-start'
import * as fs from 'node:fs'
import { v4 as uuidv4 } from 'uuid' // Add this import
import type { Joke, JokesData } from '../types'

const JOKES_FILE = 'src/data/jokes.json'

export const getJokes = createServerFn({ method: 'GET' }).handler(async () => {
const jokes = await fs.promises.readFile(JOKES_FILE, 'utf-8')
return JSON.parse(jokes) as JokesData
})

// Add this new server function
export const addJoke = createServerFn({ method: 'POST' })
.validator((data: { question: string; answer: string }) => {
// Validate input data
if (!data.question || !data.question.trim()) {
throw new Error('Joke question is required')
}
if (!data.answer || !data.answer.trim()) {
throw new Error('Joke answer is required')
}
return data
})
.handler(async ({ data }) => {
try {
// Read the existing jokes from the file
const jokesData = await getJokes()

// Create a new joke with a unique ID
const newJoke: Joke = {
id: uuidv4(),
question: data.question,
answer: data.answer,
}

// Add the new joke to the list
const updatedJokes = [...jokesData, newJoke]

// Write the updated jokes back to the file
await fs.promises.writeFile(
JOKES_FILE,
JSON.stringify(updatedJokes, null, 2),
'utf-8',
)

return newJoke
} catch (error) {
console.error('Failed to add joke:', error)
throw new Error('Failed to add joke')
}
})

이 코드에서는 다음을 수행합니다:

  • 서버에서 실행되지만 클라이언트에서 호출할 수 있는 서버 함수를 생성하기 위해 createServerFn을 사용합니다. 이 서버 함수는 파일에 데이터를 쓰는 데 사용됩니다.
  • 먼저 validator을 사용하여 입력 데이터의 유효성을 검사합니다. 수신한 데이터가 올바른 형식인지 확인하는 좋은 방법입니다.
  • 실제 쓰기 작업은 handler 함수에서 수행합니다.
  • getJokes은 JSON 파일에서 농담을 읽습니다.
  • addJoke은 입력 데이터의 유효성을 검사하고 파일에 새 농담을 추가합니다.
  • 농담의 고유 ID를 생성하기 위해 uuidv4()을 사용합니다.

2.2단계: JSON 파일에 농담을 추가하는 폼 추가

이제 홈 페이지를 수정하여 농담을 표시하고 새 농담을 추가하는 폼을 제공하겠습니다. JokeForm.jsx이라는 새 컴포넌트를 생성하고 다음 폼을 추가하겠습니다:

// src/components/JokeForm.tsx
import { useState } from 'react'
import { useRouter } from '@tanstack/react-router'
import { addJoke } from '../serverActions/jokesActions'

export function JokeForm() {
const router = useRouter()
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)

return (
<form onSubmit={handleSubmit} className="mb-8">
{error && (
<div className="bg-red-100 text-red-700 p-2 rounded mb-4">{error}</div>
)}

<div className="flex flex-col sm:flex-row gap-4 mb-8">
<input
id="question"
type="text"
placeholder="Enter joke question"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1"
value={question}
onChange={(e) => setQuestion(e.target.value)}
required
/>

<input
id="answer"
type="text"
placeholder="Enter joke answer"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1 py-4"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
required
/>

<button
type="submit"
disabled={isSubmitting}
className="bg-blue-500 hover:bg-blue-600 text-white font-medium rounded disabled:opacity-50 px-4"
>
{isSubmitting ? 'Adding...' : 'Add Joke'}
</button>
</div>
</form>
)
}

2.3단계: 폼을 서버 함수에 연결

이제 handleSubmit 함수에서 폼을 addJoke 서버 함수에 연결해 보겠습니다. 서버 액션 호출은 간단합니다! 그저 함수를 호출하면 됩니다.

//JokeForm.tsx
import { useState } from 'react'
import { useRouter } from '@tanstack/react-router'
import { addJoke } from '../serverActions/jokesActions'

export function JokeForm() {
const router = useRouter()
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)

const handleSubmit = async () => {
if (!question || !answer || isSubmitting) return
try {
setIsSubmitting(true)
await addJoke({
data: { question, answer },
})

// Clear form
setQuestion('')
setAnswer('')

// Refresh data
router.invalidate()
} catch (error) {
console.error('Failed to add joke:', error)
setError('Failed to add joke')
} finally {
setIsSubmitting(false)
}
}

return (
<form onSubmit={handleSubmit} className="mb-8">
{error && (
<div className="bg-red-100 text-red-700 p-2 rounded mb-4">{error}</div>
)}
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<input
id="question"
type="text"
placeholder="Enter joke question"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1"
value={question}
onChange={(e) => setQuestion(e.target.value)}
required
/>
<input
id="answer"
type="text"
placeholder="Enter joke answer"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1 py-4"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
required
/>
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-500 hover:bg-blue-600 text-white font-medium rounded disabled:opacity-50 px-4"
>
{isSubmitting ? 'Adding...' : 'Add Joke'}
</button>
</div>
</form>
)
}

이제 UI는 다음과 같이 표시됩니다: 농담 추가 폼이 있는 DevJoke 앱

모든 요소가 함께 작동하는 방식 이해하기

애플리케이션의 여러 부분이 함께 작동하는 방식을 살펴보겠습니다:

  1. 서버 함수: 서버에서 실행되며 데이터 작업을 처리합니다

    • getJokes: JSON 파일에서 농담을 읽습니다
    • addJoke: JSON 파일에 새 농담을 추가합니다
  2. TanStack Router: 라우팅과 데이터 로딩을 처리합니다

    • 로더 함수는 라우트에 접근할 때 농담 데이터를 가져옵니다
    • useLoaderData는 컴포넌트에서 이 데이터를 사용할 수 있게 합니다
    • router.invalidate()는 새 농담을 추가할 때 데이터를 새로고침합니다
  3. React 컴포넌트: 애플리케이션의 UI를 구성합니다

    • JokesList: 농담 목록을 표시합니다
    • JokeForm: 새 농담을 추가하는 폼을 제공합니다
  4. 파일 기반 저장소: 농담을 JSON 파일에 저장합니다

    • 읽기와 쓰기는 Node.js fs 모듈에서 처리합니다
    • 서버를 재시작해도 데이터가 유지됩니다

애플리케이션을 통한 데이터 흐름

데이터 흐름

sequenceDiagram
autonumber
actor User
participant UI as Browser (HomePage + Form)
participant Loader as Route Loader (loader)
participant Server
participant Store as jokes.json

%% Visiting the Home Page
User ->> UI: Visit /
UI ->> Loader: loader() calls getJokes()
Loader ->> Server: getJokes()
Server ->> Store: Read jokes.json
Store -->> Server: jokes data
Server -->> Loader: jokes[]
Loader -->> UI: useLoaderData() → jokes[]

%% Adding a New Joke
User ->> UI: Fill form and submit
UI ->> Server: handleSubmit → addJoke(newJoke)
Server ->> Store: Read jokes.json
Server ->> Store: Write updated jokes.json
Server -->> UI: addJoke() resolved
UI ->> Loader: router.invalidate() (re-run loader)
Loader ->> Server: getJokes()
Server ->> Store: Read jokes.json
Store -->> Server: updated jokes[]
Server -->> Loader: updated jokes[]
Loader -->> UI: useLoaderData() → updated jokes[]

사용자가 홈페이지를 방문하면 다음과 같이 진행됩니다:

  1. 라우트의 loader 함수가 getJokes() 서버 함수를 호출합니다
  2. 서버가 jokes.json를 읽고 농담 데이터를 반환합니다
  3. 이 데이터는 useLoaderData()를 통해 HomePage 컴포넌트에 전달됩니다
  4. HomePage 컴포넌트가 데이터를 JokesList 컴포넌트에 전달합니다

사용자가 새 농담을 추가하면 다음과 같이 진행됩니다:

  1. 폼을 작성하고 제출합니다
  2. handleSubmit 함수가 addJoke() 서버 함수를 호출합니다
  3. 서버가 현재 농담을 읽고 새 농담을 추가한 다음, 업데이트된 데이터를 jokes.json에 다시 씁니다
  4. 작업이 완료되면 router.invalidate()를 호출하여 데이터를 새로고침합니다
  5. 그러면 로더가 다시 실행되어 업데이트된 농담을 가져옵니다
  6. UI가 업데이트되어 목록에 새 농담이 표시됩니다

다음은 실제로 작동하는 앱의 데모입니다:

일반적인 문제 및 디버깅

TanStack Start 애플리케이션을 빌드할 때 흔히 발생할 수 있는 몇 가지 문제와 해결 방법은 다음과 같습니다:

서버 함수가 작동하지 않음

서버 함수가 예상대로 작동하지 않는 경우:

  1. 올바른 HTTP 메서드(GET, POST 등)를 사용하고 있는지 확인합니다
  2. 파일 경로가 올바르고 서버에서 접근할 수 있는지 확인합니다
  3. 서버 콘솔에서 오류 메시지를 확인합니다
  4. 서버 함수에서 클라이언트 전용 API를 사용하고 있지 않은지 확인합니다

라우트 데이터가 로드되지 않음

라우트 데이터가 제대로 로드되지 않는 경우:

  1. 로더 함수가 올바르게 구현되었는지 확인합니다
  2. useLoaderData()을 올바르게 사용하고 있는지 확인합니다
  3. 브라우저 콘솔에서 오류를 확인합니다
  4. 서버 함수가 올바르게 작동하는지 확인합니다

폼 제출 문제

폼 제출이 작동하지 않는 경우:

  1. 서버 함수에 유효성 검사 오류가 있는지 확인합니다
  2. 폼 이벤트 방지(e.preventDefault())가 작동하는지 확인합니다
  3. 상태 업데이트가 올바르게 이루어지는지 확인합니다
  4. 브라우저의 개발자 도구에서 네트워크 오류를 확인합니다

파일 읽기/쓰기 문제

파일 기반 저장소를 사용하는 경우:

  1. 파일 경로가 올바른지 확인합니다
  2. 파일 권한을 확인합니다
  3. await을 사용하여 비동기 작업을 올바르게 처리하고 있는지 확인합니다
  4. 파일 작업에 적절한 오류 처리를 추가합니다

마무리

축하합니다! TanStack Start를 사용하여 풀스택 DevJokes 앱을 빌드했습니다. 이 튜토리얼에서 다음 내용을 배웠습니다:

  • TanStack Start 프로젝트를 설정하는 방법
  • 데이터 작업을 위한 서버 함수를 구현하는 방법
  • 파일에서 데이터를 읽고 파일에 데이터를 쓰는 방법
  • UI를 위한 React 컴포넌트를 빌드하는 방법
  • 라우팅 및 데이터 가져오기에 TanStack Router를 사용하는 방법

이 간단한 애플리케이션은 최소한의 코드로 풀스택 애플리케이션을 빌드할 수 있는 TanStack Start의 강력한 기능을 보여 줍니다. 다음과 같은 기능을 추가하여 이 앱을 확장할 수 있습니다:

  • 농담 카테고리
  • 농담 편집 및 삭제 기능
  • 사용자 인증
  • 좋아하는 농담에 투표하는 기능

이 튜토리얼의 전체 코드는 GitHub에서 확인할 수 있습니다.