Ch 04. 버튼 하나로 데이터 저장 — Server Actions
API 없이 서버 함수 호출하기
전통적인 방식에서는 데이터를 저장하려면 두 가지가 필요했습니다. 서버에 API 엔드포인트를 만들고, 클라이언트에서 fetch로 그 엔드포인트를 호출하는 것입니다.
Server Actions는 이 과정을 단순화합니다. 서버 함수를 만들고, 클라이언트에서 직접 호출하면 됩니다. 프레임워크가 HTTP 통신을 대신 처리합니다. 개발자는 함수를 쓰고 호출할 뿐입니다.
"use server" 파일 분리 방법
Server Actions는 파일 최상단에 "use server"를 선언한 파일에 작성하는 것이 관리하기 좋습니다.
// 파일: app/actions/todo.ts'use server';import { revalidatePath } from 'next/cache';import { redirect } from 'next/navigation';interface TodoState { error?: string; success?: boolean;}export async function createTodo( prevState: TodoState, formData: FormData): Promise<TodoState> { const title = formData.get('title') as string; // 유효성 검사 if (!title || title.trim().length === 0) { return { error: '할 일을 입력해주세요.' }; } if (title.length > 100) { return { error: '할 일은 100자 이내로 입력해주세요.' }; } // 실제 저장 (예: 데이터베이스) await saveTodoToDatabase({ title: title.trim() }); revalidatePath('/todos'); return { success: true };}async function saveTodoToDatabase(data: { title: string }) { // 데이터베이스 저장 로직 // 실제 프로젝트에서는 Prisma 등을 사용합니다 console.log('저장:', data);}```text### `useActionState` 훅으로 결과 받기`useActionState`는 Server Action의 결과 상태를 관리하는 훅입니다. 성공/실패 메시지를 표시하는 데 유용합니다.```tsx// 파일: app/todos/components/AddTodoForm.tsx'use client';import { useActionState } from 'react';import { createTodo } from '@/actions/todo';const initialState = { error: undefined, success: false };export default function AddTodoForm() { const [state, formAction, isPending] = useActionState( createTodo, initialState ); return ( <form action={formAction}> <div> <input name="title" type="text" placeholder="할 일을 입력하세요" disabled={isPending} style={{ padding: '8px', width: '300px' }} /> <button type="submit" disabled={isPending}> {isPending ? '저장 중...' : '추가'} </button> </div> {state.error && ( <p style={{ color: 'red', marginTop: '8px' }}>{state.error}</p> )} {state.success && ( <p style={{ color: 'green', marginTop: '8px' }}> 할 일이 추가되었습니다. </p> )} </form> );}```text`useActionState`는 세 가지를 반환합니다.- `state`: 마지막 Server Action 실행 결과- `formAction`: form의 `action` prop에 전달할 함수- `isPending`: 제출 중 여부### 페이지에서 조합하기Server Component와 Client Component를 조합합니다.```tsx// 파일: app/todos/page.tsximport AddTodoForm from './components/AddTodoForm';interface Todo { id: number; title: string; done: boolean;}async function getTodos(): Promise<Todo[]> { // 실제로는 데이터베이스에서 가져옵니다 return [ { id: 1, title: 'Next.js 공부하기', done: false }, { id: 2, title: '블로그 글 쓰기', done: true }, ];}export default async function TodosPage() { const todos = await getTodos(); return ( <main> <h1>할 일 목록</h1> <AddTodoForm /> {/* Client Component */} <ul> {todos.map((todo) => ( <li key={todo.id} style={{ textDecoration: todo.done ? 'line-through' : 'none', color: todo.done ? '#999' : 'inherit', }} > {todo.title} </li> ))} </ul> </main> );}```text### 에러 처리Server Action에서 에러가 발생하면 가장 가까운 `error.tsx`가 처리합니다. 하지만 사용자 입력 오류(유효성 검사 실패)는 에러를 throw하는 대신 상태로 반환하는 것이 좋습니다. 사용자에게 친절한 메시지를 보여줄 수 있기 때문입니다.진짜 예외 상황(데이터베이스 연결 실패 등)은 throw해서 `error.tsx`가 잡도록 합니다.```ts// 파일: app/actions/todo.ts (에러 처리 추가)'use server';export async function createTodo( prevState: { error?: string; success?: boolean }, formData: FormData) { const title = formData.get('title') as string; // 유효성 검사 에러 → 상태로 반환 if (!title?.trim()) { return { error: '할 일을 입력해주세요.' }; } try { await saveTodoToDatabase({ title: title.trim() }); return { success: true }; } catch (err) { // 시스템 에러 → 에러 throw (error.tsx가 처리) throw new Error('저장 중 오류가 발생했습니다.'); }}async function saveTodoToDatabase(data: { title: string }) { console.log('저장:', data);}
다음 챕터에서는
다음 챕터에서는 API를 직접 만드는 방법, Route Handlers를 살펴봅니다. 외부 서비스나 모바일 앱이 호출할 API 엔드포인트가 필요할 때 사용합니다.