Next.js 서버 액션 알아보기

Next의 기가 막힌 서버 액션에 대해 알아봅니다.

2023-09-26

개요

서버 액션이 무엇인지 예제와 함께 정리합니다.

서버 액션

서버 액션은 폼의 뮤테이션(생성, 업데이트, 삭제)을 할 수 있게 해주는 넥스트의 아주 강력한 기능입니다. 서버 액션을 사용하면, API 엔드포인트를 생성하지 않고도 컴포넌트 내에서 비동기 함수를 직접 정의할 수 있습니다.

서버 액션을 사용하려면 next.config.js 파일 내부에 다음과 같이 설정을 추가해야 합니다:

next.config.js
module.exports = {
  experimental: {
    serverActions: true,
  },
};

서버 액션을 사용해 폼 다루기

아주 간단한 투두 앱을 만들며, 서버 액션을 이해해 봅니다.

프로젝트 설정

우선 디렉토리에 넥스트 앱을 생성합니다.

npx create-next-app@latest

간단한 앱을 만들기 위해서 prismasqlite를 사용합니다.

npm install prisma --save-dev
npx prisma init --datasource-provider sqlite

설치가 끝나면, 투두를 위한 prisma 스키마를 정의해 줍니다.

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}
 
// 투두 스키마
model Todos {
  id        Int      @id @default(autoincrement())
  title     String
  createTododAt DateTime @default(now())
}

스키마를 정의했으니 실제 데이터베이스 테이블에 스키마를 다음과 같은 명령어로 추가해 줍니다.

npx prisma db push

이제 앱에서 사용할 prisma의 인스턴스를 생성해 줍니다.

lib/prisma.ts
import { PrismaClient } from "@prisma/client";
 
declare global {
  var prisma: PrismaClient | undefined;
}
 
const client = globalThis.prisma || new PrismaClient();
 
if (process.env.NODE_ENV !== "production") globalThis.prisma = client;
 
export default client;

인스턴스가 존재한다면, 기존의 인스턴스를 사용하고 없을 시 새로 만들어줍니다.

UI 및 서버 액션 정의하기

이제 본격적으로 코드를 작성하며 서버 액션을 살펴봅니다. 우선 app/page.tsx의 보일러 플레이트 코드들을 제거한 후 폼을 위한 UI을 추가합니다.

page.tsx
import { createTodo } from "@/action";
 
const page = () => {
  return (
    <section className="px-16 py-12">
      <div className="container mx-auto flex flex-col gap-8">
        <h1 className="text-lg text-gray-900">Todos</h1>
        <form action={createTodo} className="flex max-w-sm flex-col gap-4">
          <input
            type="text"
            name="title"
            className="rounded-md border border-slate-300 px-2 py-1 outline-none"
            required
          />
          <button className="rounded-md bg-slate-300 py-2 hover:shadow-sm">
            submit
          </button>
        </form>
      </div>
    </section>
  );
};
export default page;

위의 코드에서 중요한 부분은 바로 폼 태그의 action입니다. 서버 액션은 기존 리액트에서 클릭 이벤트 또는 서브밋 이벤트로 폼을 제출하는 방식이 아닌, 폼의 action을 사용해 서버로 폼의 데이터를 제출합니다.

이때 <form action={createTodo}>은 데이터로 FormData 타입을 전달받습니다. FormData 타입을 사용하면 기존에 useState 또는 useRef로 인풋의 값을 가져올 필요 없이, formData.get(name) 메서드를 통해 인풋의 값을 가져올 수 있습니다.

이제 투두 생성을 위한 서버 액션을 정의합니다.

서버 액션을 정의하기 위해선 "use server" 지시어를 추가해야 합니다. 이때 인라인으로 함수 내부 최상단에 작성하거나, 별개의 파일로 액션을 분리해 파일 최상단에 작성할 수 있습니다.

액션을 담당하는 별도의 파일을 생성해, 액션을 관리합니다. 파일 최상단에 "use server"를 추가해 줍니다. 이렇게 하면 모든 함수에 지시어를 추가해야 하는 수고스러움을 덜 수 있습니다.

action/index.ts
"use server";
import client from "@/lib/prisma";
 
export const createTodo = async (formData: FormData) => {
  const title = formData.get("title") as string;
 
  await client.todos.createTodo({
    data: {
      title,
    },
  });
};

인풋의 name 속성과 get 메서드를 사용해 인풋의 title 값을 가져옵니다.

추가된 투두를 가져오는 getTodos 함수를 생성해 줍니다.

action/index.ts
// ...
 
export const getTodos = async () => {
  const todos = await client.todos.findMany();
 
  return todos;
};

이제 투두를 보여주기 위한 UI을 추가합니다. 이때, 넥스트는 기본적으로 서버 컴포넌트이므로 컴포넌트 자체에서 비동기를 처리할 수 있습니다.

page.tsx
import { createTodo, getTodos } from "@/action";
 
const page = () => {
  const todos = await getTodos();
 
  return (
    <section className="px-16 py-12">
      <div className="container mx-auto flex flex-col gap-8">
        {/* ... */}
 
        <ul className="max-w-sm">
          {todos.map((todo) => (
            <li key={todo.id} className="mb-4 flex justify-between">
              {todo.title} <DeleteButton id={todo.id} />
            </li>
          ))}
        </ul>
      </div>
    </section>
  );
};
export default page;

이제 서버 액션을 사용해 투두를 추가해 봅시다.

새로고침

혹시 몇 가지 개선할 사항을 찾으셨나요? 좀 더 나은 유저 경험을 주기 위해 개선할 사항들은 다음과 같습니다:

1. 새로고침 대신, 데이터 재검증을 통한 캐시 무효화

데이터 재검증을 하기 위해서 가장 쉬운 방법은 전체 라우트 세그먼트를 무효화하는 revalidatePath를 사용하는 것입니다. createTodo 함수에 revalidatePath를 추가해 줍니다.

action/index.ts
"use server";
import client from "@/lib/prisma";
 
export const createTodo = async (formData: FormData) => {
  const title = formData.get("title") as string;
 
  await client.todos.createTodo({
    data: {
      title,
    },
  });
 
  revalidatePath("/todos");
};
새로고침 없이

revalidatePath를 추가함으로써 자동으로 데이터가 재검증 되어 추가된 투두를 즉시 확인할 수 있게 되었습니다.

2. 유저에게 현재 폼의 상태 알리기

현재 UI에서는 유저가 폼의 로딩 상태를 알 수 없습니다. 리액트의 실험적인 훅인 useFormStatus를 사용하면, 폼의 로딩 상태를 알 수 있습니다.

훅을 사용하기 위해선, 클라이언트 컴포넌트여야 하므로 제출 버튼을 <SubmitButton /> 컴포넌트로 분리해 줍니다.

클라이언트 컴포넌트를 생성할 시, 트리 최하단의 컴포넌트를 클라이언트 컴포넌트로 만드는 것이 좋습니다.

components/SubmitButton.tsx
"use client";
import { experimental_useFormStatus as useFormStatus } from "react-dom";
 
const SubmitButton = () => {
  const { pending } = useFormStatus();
 
  return (
    <button
      aria-disabled={pending}
      disabled={pending}
      className="rounded-md bg-slate-300 py-2 hover:shadow-sm disabled:opacity-70"
    >
      {pending ? "submitting..." : "submit"}
    </button>
  );
};
export default SubmitButton;

useFormStatus 훅은 pending 상태를 반환해 줍니다. 이제 폼 내부에 컴포넌트를 불러온 후 투두를 추가해 봅시다.

제출 상태 표시

pending을 통해 현재 폼이 서버에 제출 중인 것을 알 수 있게 되었습니다.

useFormStatus 훅은 오직 서버 액션을 사용하는 폼의 자식 요소에서만 사용 가능합니다.
이외에도 useFormState 훅을 사용한 에러 핸들링, useOptimistic 훅을 사용한 옵티미스틱 UI가 가능합니다.

서버 액션의 장점

서버 액션의 장점은 다음과 같습니다:

  1. 기존에 리액트에서 폼을 다루는 방식에 비해, 훨씬 간결하게 폼을 다룰 수 있습니다.

서버 액션을 사용하면 useStateuseRef 없이 FormData만을 사용해 폼을 다룰 수 있습니다.

  1. 로딩 상태를 쉽게 알 수 있습니다.

기존 리액트에서 로딩 상태를 다루기 위해서는 로딩 상태를 만들어, 직접 로딩의 상태를 다루어야 했는데, useFormStatus 훅을 사용하면 폼이 현재 제출 중인 로딩 상태인지 쉽게 알 수 있습니다.

  1. 자바스크립트 없이도 서버 액션을 사용할 수 있습니다.
자바스크립트 없이

서버 액션을 사용하면 기존 리액트의 방식보다 효율적으로 폼을 다룰 수 있으며, 로딩 상태와 옵티미스틱 UI를 쉽게 구현할 수 있도록 해줍니다.


출처

  • https://nextjs.org/docs/app/building-your-application/data-fetching/forms-and-mutations
  • https://www.youtube.com/watch?v=dDpZfOQBMaU&ab_channel=LeeRobinson
  • 예제