본문으로 건너뛰기

Next.js 서버 액션 알아보기


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

개요#

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

서버 액션#

서버 액션은 폼의 뮤테이션(생성, 업데이트, 삭제)을 할 수 있게 해주는 넥스트의 아주 강력한 기능입니다. 서버 액션을 사용하면, 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를 쉽게 구현할 수 있도록 해줍니다.


출처