본문으로 건너뛰기

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

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

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

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

학습할 내용

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

사전 요구 사항

  • Solid와 TypeScript에 대한 기본 지식
  • 컴퓨터에 Node.js와 pnpm 설치

알아두면 좋은 내용

TanStack Start 프로젝트 설정

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

pnpx @tanstack/cli@latest create --framework solid 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/ # Solid 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/solid-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 class="text-gray-500 italic">No jokes found. Add some!</p>
}

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

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

// src/routes/index.tsx
import { createFileRoute } from '@tanstack/solid-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 class="max-w-2xl mx-auto py-12 px-4 space-y-6">
<h1 class="text-4xl font-bold text-center mb-10">DevJokes</h1>
<JokesList jokes={jokes} />
</div>
)
}

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

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

DevJoke 5개가 표시된 DevJoke 앱

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

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

2.1단계: 파일에 쓰는 서버 함수 만들기

이제 새로운 농담을 추가할 수 있도록 jokes.json 파일을 수정할 차례입니다. 이번에는 동일한 파일에 쓰기 위해 POST 메서드를 사용하는 또 다른 서버 함수를 만들어 보겠습니다.

// src/serverActions/jokesActions.ts
import { createServerFn } from '@tanstack/solid-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 { createSignal } from 'solid'
import { useRouter } from '@tanstack/solid-router'
import { addJoke } from '../serverActions/jokesActions'

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

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

<div class="flex flex-col sm:flex-row gap-4 mb-8">
<input
id="question"
type="text"
placeholder="Enter joke question"
class="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"
class="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()}
class="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 { createSignal } from 'solid'
import { useRouter } from '@tanstack/solid-router'
import { addJoke } from '../serverActions/jokesActions'

export function JokeForm() {
const router = useRouter()
const [question, setQuestion] = createSignal('')
const [answer, setAnswer] = createSignal('')
const [isSubmitting, setIsSubmitting] = createSignal(false)
const [error, setError] = createSignal<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} class="mb-8">
{error && (
<div class="bg-red-100 text-red-700 p-2 rounded mb-4">{error}</div>
)}
<div class="flex flex-col sm:flex-row gap-4 mb-8">
<input
id="question"
type="text"
placeholder="Enter joke question"
class="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"
class="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()}
class="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. Solid 컴포넌트: 애플리케이션의 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를 위한 Solid 컴포넌트를 구축하는 방법
  • 라우팅 및 데이터 페칭에 TanStack Router를 사용하는 방법

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

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

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