NextJS Server Actions로 TODO앱 만들기
1. NextJS Server Actions이란?
서버 액션(Server Actions)은 서버에서 실행되는 비동기 함수입니다. Next.js 애플리케이션에서 서버 컴포넌트와 클라이언트 컴포넌트 모두에서 사용할 수 있으며, 주로 폼 제출이나 데이터 변경과 같은 작업을 처리하는 데 활용됩니다.
2. 서버 액션의 이점
향상된 보안: 민감한 작업들을 서버에서 처리함으로써 보안을 강화할 수 있습니다. 클라이언트 측에 노출되지 않아야 할 로직이나 데이터를 서버에서 안전하게 다룰 수 있습니다. 또한 CSRF보호 기능이 내장되어 있어 추가적인 보안 장치를 제공합니다.
간편한 데이터관리: 서버 액션을 사용하면 데이터를 가져오고 수정하는 과정이 훨씬 쉬워집니다. 기존에는 데이터 처리를 위해 별도의 API를 만들어야 했지만, 서버 액션을 사용하면 그럴 필요가 없어집니다.
다양한 환경지원: 서버 액션은 Javascript가 작동하지 않는 환경에서도 기본적인 기능을 제공합니다. 예를 들어, 사용자의 브라우저에서 Javascript가 비활성되어 있어도 폼 제출과 같은 기본적인 기능은 여전히 작동합니다. 또는 자바스크립트가 로드되기전, 즉 하이드레이션이 완료되기 전에도 동작한다는 점입니다. 이는 사용자가 페이지에 접속하자마자 폼 제출 등의 기능을 즉시 사용할 수 있게 해줍니다.
3. 사용방법
NextJS에서 서버 액션을 사용하려면 어떻게 해야 할까요? React는 이를 위해 특별한 지시어를 제공합니다. "use server"라는 지시어입니다. 서버 액션을 정의 하는 방법에는 두 가지가 있습니다.
첫째, async 함수의 본문 맨 위에 "use server"지시어를 배치하면 해당 함수가 서버 액션으로 동작합니다.
둘째, 별도의 파일 맨 위에 이 지시어를 놓으면, 해당 파일에서 내보내는(export) 모든 함수가 서버 액션으로 처리합니다.
3.1 서버 컴포넌트에서의 서버 액션
서버 컴포넌트에서는 함수 수준 또는 모듈 수준에서 "use server" 지시어를 사용 할 수 있습니다. 함수 내에서 직접 서버 액션을 정의 하려면, 함수 본문 맨 위에 "use server"를 추가하면 됩니다.
// 서버 컴포넌트 export default function Page() { // 서버 액션 async function create() { 'use server' // ... 서버에서 실행될 로직 ... } return ( // ... JSX 내용 ... ) }
3.2 클라이언트 컴포넌트에서의 서버 액션
클라이언트 컴포넌트에서는 모듈 수준의 "use server" 지시어를 사용한 액션만 가져올 수 있습니다. 클라이언트 컴포넌트에서 서버 액션을 호출할려면, 새 파일을 만들고 그 파일의 맨 위에 "use server" 지시어를 추가하면 됩니다. 이 파일 내의 모든 함수는 서버 액션으로 표시되며, 클라이언트 및 서버컴포넌트 모두에서 재사용 할 수 있습니다.
// action.ts 'use server' export async function create() { // ... 서버 액션 로직 ... }
또한 서버 액션을 클라이언트 컴포넌트에 prop으로 전달할 수도 있습니다.
이러한 방식으로 서버 액션을 사용하면, 서버 컴포넌트와 클라이언트 컴포넌트 사이의 데이터 흐름을 효과적으로 관리 할 수 있습니다.
<ClientComponent updateItem={updateItem} /> //app/client-component.jsx 'use client' export default function ClientComponent({ updateItem }) { return <form action={updateItem}>{/* ... */}</form> }
4. TODO APP을 만들어보면서 서버액션 익혀보기
자세한 내용은 어떻게 쓰는지는 간단한 TODO APP을 만들어보면서 익혀보겠습니다. TODO앱은 가장 상단에 있는 썸네일처럼 구현이 됩니다.
4.1 UI 확인 및 useFormState, useFormStatus를 이용해서 상태 관리
"use client"; import { useFormState } from "react-dom"; import { useFormStatus } from "react-dom"; import { Trash2, Plus, Edit2, Check, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { addTodo, deleteTodo, updateTodo } from "@/app/actions/todo-action"; import { motion, AnimatePresence } from "framer-motion"; type Todo = { id: number; text: string; }; export type ActionState = { message?: string; error?: string; todo?: Todo; }; const initialState: ActionState = { message: "", }; function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" className="bg-purple-600 text-white px-4 py-2 rounded-r-lg hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-600 focus:ring-opacity-50 disabled:opacity-50" disabled={pending} > <Plus size={24} /> </button> ); } export default function TodoList({ todos }: { todos: Todo[] }) { const [state, formAction] = useFormState(addTodo, initialState); const [editingId, setEditingId] = useState<number | null>(null); const [editText, setEditText] = useState(""); const formRef = useRef<HTMLFormElement>(null); const handleEdit = (id: number, text: string) => { setEditingId(id); setEditText(text); }; const handleUpdate = async (id: number) => { await updateTodo(id, editText); setEditingId(null); }; const handleDelete = async (id: number) => { await deleteTodo(id); }; useEffect(() => { if (state?.message && state.message === "Todo added successfully") { formRef.current?.reset(); console.log(state.message); } }, [state]); return ( <div className="min-h-screen bg-gradient-to-br from-purple-400 to-indigo-600 flex items-center justify-center p-4"> <motion.div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md" initial={{ opacity: 0, y: -50 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} > <motion.h1 className="text-5xl font-bold mb-6 text-center text-gray-500" initial={{ scale: 0.5 }} animate={{ scale: 1 }} transition={{ duration: 0.5 }} > My Todo List </motion.h1> <form action={formAction} ref={formRef} className="flex mb-4"> <motion.input whileFocus={{ scale: 1.05 }} type="text" name="text" className="flex-grow px-4 py-2 text-gray-700 bg-gray-200 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-purple-600" placeholder="Add a new todo..." required /> <SubmitButton /> </form> {state.message && ( <motion.p className="text-sm text-green-600 mb-4" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} > {state.message} </motion.p> )} <AnimatePresence> {todos.map((todo) => ( <motion.li key={todo.id} layout initial={{ opacity: 0, y: 50 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -50 }} transition={{ duration: 0.3 }} className="bg-gray-100 rounded-lg p-4 flex items-center justify-between mb-2" > {editingId === todo.id ? ( <> <motion.input initial={{ scale: 0.8 }} animate={{ scale: 1 }} type="text" value={editText} onChange={(e) => setEditText(e.target.value)} className="flex-grow px-2 py-1 text-gray-700 bg-white rounded focus:outline-none focus:ring-2 focus:ring-purple-600" /> <div className="flex space-x-2"> <motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => handleUpdate(todo.id)} className="text-green-600 hover:text-green-800" > <Check size={20} /> </motion.button> <motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => setEditingId(null)} className="text-red-600 hover:text-red-800" > <X size={20} /> </motion.button> </div> </> ) : ( <> <span className="flex-grow text-gray-800">{todo.text}</span> <div className="flex space-x-2"> <motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => handleEdit(todo.id, todo.text)} className="text-blue-600 hover:text-blue-800" > <Edit2 size={20} /> </motion.button> <motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => handleDelete(todo.id)} className="text-red-600 hover:text-red-800" > <Trash2 size={20} /> </motion.button> </div> </> )} </motion.li> ))} </AnimatePresence> </motion.div> </div> ); }
4.1.1 useFormState
useFormState는 React의 Canary 및 실험적 채널에서 사용 가능한 훅으로, 폼 액션의 결과를 기반으로 상태를 업데이트할 수 있게 해줍니다.(클라이언트 컴포넌트에서만 사용이 가능)
import { useFormState } from "react-dom"; export type ActionState = { message: string; error?: string; todo?: Todo; }; const initialState: ActionState = { message: "", }; const [state, formAction] = useFormState(addTodo, initialState); <form action={formAction} ref={formRef} className="flex mb-4"> <motion.input whileFocus={{ scale: 1.05 }} type="text" name="text" className="flex-grow px-4 py-2 text-gray-700 bg-gray-200 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-purple-600" placeholder="Add a new todo..." required /> <SubmitButton /> </form>
useFormState를 사용하지 않고 addTodo 함수를 바로 form element에 연결해서 사용해도 되지만 그렇게 사용했을때는 loading, error 상태를 관리하기가 쉽지 않기 때문에 useFormState 사용하였습니다.
// useFormState 사용 ❌ <form action={addTodo}> <input name="text" /> </form>
useActionState에 기존 폼 액션 함수와 초기 상태를 전달하면, 폼에서 사용할 새 액션과 함께 최신 form state 및 액션이 여전히 대기 중인지 여부를 반환합니다. state는 제공한 함수(addTodo)에도 전달됩니다. 지금 코드에서는 사용을 안했지만
[state, formAction, isPending] = useFormState(addTodo, initialState)
isPending으로 로딩상태를 UI로 표시가 가능합니다.
state는 폼이 마지막으로 제출되었을 때 액션이 반환한 값입니다. 폼이 아직 제출되지 않았다면, 이는 전달한 초기 상태입니다. 서버 액션과 함께 사용될 경우, useActionState는 하이드레이션이 완료되기 전에도 폼 제출에 대한 서버의 응답을 보여줄 수 있게 합니다.
4.2 useFormStatus
function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" className="bg-purple-600 text-white px-4 py-2 rounded-r-lg hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-600 focus:ring-opacity-50 disabled:opacity-50" disabled={pending} > <Plus size={24} /> </button> )
useFormStatus 훅은 가장 최근의 폼 제출에 대한 상태 정보를 제공합니다. 상태 정보를 얻으려면 SubmitButton 컴포넌트가 <form> 내부에서 랜더링되어야 합니다. 이 훅은 pending 속성과 같은 정보를 반환하는데, 이를 통해 폼이 현재 제출 중인지 여부를 알 수 있습니다.
4.3 서버 액션 파일 만들기
"use server"; import { Pool, QueryResult } from "pg"; import { revalidatePath } from "next/cache"; const pool = new Pool({ user: process.env.DB_USER, host: process.env.DB_HOST, database: process.env.DB_NAME, password: process.env.DB_PASSWORD, port: parseInt(process.env.DB_PORT || "5432"), }); export type Todo = { id: number; text: string; }; async function executeQuery<T extends any[]>( query: string, params: any[] = [], ): Promise<T> { const client = await pool.connect(); try { const result: QueryResult<T[number]> = await client.query(query, params); return result.rows as T; } finally { client.release(); } } export async function addTodo(prevState: any, formData: FormData) { const text = formData.get("text"); if (typeof text !== "string" || text.trim() === "") { return { error: "Invalid todo text" }; } try { const [newTodo] = await executeQuery<Todo[]>( "INSERT INTO todos (text) VALUES ($1) RETURNING id, text", [text.trim()], ); revalidatePath("/"); return { message: "Todo added successfully", todo: newTodo }; } catch (error) { console.error("Failed to add todo:", error); return { error: "Failed to add todo" }; } } export async function getTodos(): Promise<Todo[]> { try { return await executeQuery<Todo[]>( "SELECT id, text FROM todos ORDER BY id DESC", ); } catch (error) { console.error("Error fetching todos:", error); return []; } } export async function updateTodo(id: number, text: string) { if (typeof text !== "string" || text.trim() === "") { return { error: "Invalid todo text" }; } try { await executeQuery("UPDATE todos SET text = $1 WHERE id = $2", [ text.trim(), id, ]); revalidatePath("/"); return { message: "Todo updated successfully" }; } catch (error) { console.error("Failed to update todo:", error); return { error: "Failed to update todo" }; } } export async function deleteTodo(id: number) { try { await executeQuery("DELETE FROM todos WHERE id = $1", [id]); revalidatePath("/"); return { message: "Todo deleted successfully" }; } catch (error) { console.error("Failed to delete todo:", error); return { error: "Failed to delete todo" }; } }
서버 액션 함수는 Next.js에서 서버 사이드 로직을 클라이언트와 쉽게 연결할 수 있게 해주는 강력한 기능입니다.
addTodo 함수를 중심으로 서버 액션의 구조와 특징을 살펴보겠습니다. 서버 액션 파일의 최상단에는 "use server"; 지시문을 배치하여, 이 파일 내의 모든 함수가 서버에서 실행됨을 명시합니다(서버컴포넌트, 클라이언트 컴포넌트 전부 사용가능).
서버 액션 함수의 첫번째 매개변수는 useFormState에서 지정한 state값이 넣어 오긴 하지만, 해당함수에서 사용하지 않아서 _ 표시하였고, 두번째 매개변수는 FormData 객체를 파라미터로 받아 클라이언트에서 전송된 데이터를 처리합니다. 데이터가 유효하다면, 'executeQuery' 함수를 사용하여 PostgreSQL 데이터베이스에 새로운 할 일을 추가합니다. 이 과정에서 서버 액션의 큰 장점이 드러나는데, 바로 데이터베이스와 직접 연동할 수 있다는 점입니다. 서버에서 실행되기 때문에 데이터베이스 연결 정보를 안전하게 관리할 수 있으며, 클라이언트 사이드 코드에 노출될 걱정 없이 데이터베이스 작업을 수행할 수 있습니다. 작업이 성공적으로 완료되면 'revalidatePath' 함수를 호출하여 관련 경로의 캐시를 무효화합니다. revalidatePath 메소드는 '/'(여기서는 홈) 에 관련된 경로와 관련된 캐시 데이터 및 풀라우더 캐시를 날려서 새롭게 페이지를 생성해서 서버에서 내려준다고 생각하면 됩니다.(새로고침이 되어진다고 생각하면 됩니다.).
마지막으로 return문을 보면 { message, message, todo } 이런식으로 반환을 하는데 이 값이 아까 위에서 사용했던 useFormState
의 state 값으로 넘어갑니다. 이 값을 가지고 error 상태 및 성공 상태등을 처리하면 됩니다.
useEffect(() => { if (state?.message && state.message === "Todo added successfully") { formRef.current?.reset(); } }, [state]);
서버 액션(Server Actions)은 매우 유연한 기능으로, <form> 요소에만 국한되지 않고 다양한 방식으로 호출될 수 있습니다.
- 이벤트 처리
- useEffect훅 내부
- 서드파티 라이브러리
- <button> 등의 다른 폼 요소
Nextjs의 서버액션을 이용해서 다양한 서버요청을 처리 해보는것을 추천드립니다.