NextJS Server Actions로 TODO앱 만들기

2024-10-20조회수 0
NextJs

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의 서버액션을 이용해서 다양한 서버요청을 처리 해보는것을 추천드립니다.