Next.js - 서버 액션 기능

RumbleBi·2024년 11월 20일
0

next.js 14v 정리

목록 보기
11/12
post-thumbnail

Next.js의 서버 액션이란?

클라이언트에서 특정 form의 제출 이벤트가 발생했을 때, 서버에서만 실행되는 함수를 브라우저가 직접 호출하면서 데이터까지 폼 데이터 형식으로 전달해줄 수 있도록하는 것이다.

기존에는 API를 통한 통신이 필요했지만 이러한 방법이 Next.js기능에 추가되었다.

요약하면, 기존에는 API를 통해서만 진행되는 브라우저와 서버간의 데이터 통신을 next.js의 API 기능과 같이 network에 호출 URL를 폴더위치로, payload에는 FormData 내부에 submit함수로 전달된 form data와 서버액션의 해쉬 값이 들어가게된다. 그리고 header에 Next-Action, Next-Router-State-Tree 값을 추가하여 넥스트 서버에서 요청하여 JS함수로 쉽고 간결하게 설정할 수 있도록 하는 기능이다.

여기서 주의할 점이 있다. 만약 form input 입력에 아무것도 들어가지 않은 경우에는

async function createReviewAction(formData: FormData) {
    "use server"; // server action
    const content = formData.get("content");
    const author = formData.get("author");
  }

content의 내부 타입스트립트 정의가 FormDataEntryValue | null 로 나오게 되는데, FormDataEntryValue 는 스트링이나 파일 타입을 의미하기 때문에 값이 있을 경우에만 ?.toString(); 을 붙여서 문자열 타입으로 변환하도록 설정을 해줘야한다.

이렇게 추가해주면, const content: string | undefined 타입 추론이 일어나게 된다.
왜 이런 기능이 생겼냐면, 단순한 기능만 만들 경우, 굳이 API호출 경로나 추가적인 에러 핸들링을 해야될 경우 꽤 귀찮을 수 있기 때문에 이런 기능이 생긴 것이다. 조금 더 간결하고 간단하게 동작을 만드는데 의의가 있다.

"use server";

export async function createReviewAction(formData: FormData) {
  const bookId = formData.get("bookId")?.toString();
  const content = formData.get("content")?.toString();
  const author = formData.get("author")?.toString();

  if (!bookId || !content || !author) {
    return;
  }

  try {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/review`, {
      method: "POST",
      body: JSON.stringify({
        bookId,
        content,
        author,
      }),
    });
  } catch (err) {
    console.error(err);
    return;
  }
}

이러한 서버액션 함수도 따로 파일로 빼서 위에 "use server"를 선언하고 사용할 수 있다.

import { BookData } from "@/types";
import style from "./page.module.css";
import Image from "next/image";
import { notFound } from "next/navigation";
import { createReviewAction } from "@/actions/create-review.action";

export function generateStaticParams() {
  return [{ id: "1" }, { id: "2" }, { id: "3" }];
}

async function BookDetail({ bookId }: { bookId: string }) {
  const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/${bookId}`);
  if (!response.ok) {
    if (response.status === 404) {
      notFound();
    }
  }
  const book: BookData = await response.json();
  const { title, coverImgUrl, author, subTitle, publisher, description } = book;

  return (
    <section>
      <div
        className={style.cover_img_container}
        style={{ backgroundImage: `url('${coverImgUrl}')` }}
      >
        <Image alt="123" src={coverImgUrl} width={200} height={100} />
      </div>
      <div className={style.title}>{title}</div>
      <div className={style.subTitle}>{subTitle}</div>
      <div className={style.author}>
        {author} | {publisher}
      </div>
      <div className={style.description}>{description}</div>
    </section>
  );
}

function ReviewEditor({ bookId }: { bookId: string }) {
  return (
    <section>
      <form action={createReviewAction}>
        <input name="bookId" value={bookId} hidden readOnly />
        <input required name="content" placeholder="리뷰 내용" />
        <input required name="author" placeholder="작성자" />
        <button type="submit">작성하기</button>
      </form>
    </section>
  );
}

export default function Page({ params }: { params: { id: string } }) {
  return (
    <div className={style.container}>
      <BookDetail bookId={params.id} />
      <ReviewEditor bookId={params.id} />
    </div>
  );
}
"use server";

import { revalidatePath } from "next/cache";

export async function createReviewAction(formData: FormData) {
  const bookId = formData.get("bookId")?.toString();
  const content = formData.get("content")?.toString();
  const author = formData.get("author")?.toString();

  if (!bookId || !content || !author) {
    return;
  }

  try {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/review`, {
      method: "POST",
      body: JSON.stringify({
        bookId,
        content,
        author,
      }),
    });
    revalidatePath(`/book/${bookId}`);
  } catch (err) {
    console.error(err);
    return;
  }
}

만약 create 함수가 동작하고 나서 등록된 글을 바로 업데이트 하려면,

import { revalidatePath } from "next/cache";

기능을 import 하여 인수로 전달한 경로에 해당하는 페이지를 다시 재검증하게 할 수 있다. 즉 다시 페이지를 재생성하게 next.js 서버측에 요청할 수 있는 것이다.

여기서 주의해야할 점은,
서버측에서만 호출할 수 있는 기능이다 클라이언트 단에서 호출 할 수 없다.
revalidatePath에 정의된 해당 경로의 페이지를 전부 재검증하기 때문에, 이 페이지의 모든 캐시를 무효화시킨다. 그렇기 때문에 다른 컴포넌트에서 캐싱을 설정해도 무효화가 된다. 데이터 캐시뿐만 아니라 페이지 자체를 캐싱하는 풀 라우트 캐시까지 모두 무효화시키며 서버에서 캐싱된 페이지를 가져오는 것이 아니라 서버에서 다시 페이지를 생성하게 되며 새로 생성된 페이지를 다시 풀라우트 캐시에 저장하지 않는다. 새로고침을 해서 다시 재생성하는 것이 아니면 업데이트가 되지 않는다.

사용할 경우 퍼지(purge)되어 빌드타임에 저장된 캐시 데이터를 삭제하여 다시 데이터 fetch가 일어나게 된다.

Next.js에서 제공하는 추가적인 재검증기능 옵션

revalidatePath(`/book/${bookId}`);
// 특정 주소의 해당하는 페이지만 재검증

revalidatePath("/book/[id]", "page");
// 특정 경로의 모든 동적 페이지를 재검증

revalidatePath("/(with-seachbar)", "layout");
// 특정 레이아웃을 갖는 모든 페이지를 재검증

revalidatePath("/", "layout");
// 모든 데이터 재검증 인덱스 경로에 있는 루트레이아웃에서부터 하위 레이아웃까지 재검증

revalidateTag('tag-${bookId}')
// 태그 기준, 데이터 캐시 재검증
// 여기서 tag란 next.js 에서 캐싱할 때, 특정 데이터 fetch에 태그를 붙일 수 있는 옵션이 있다.

fetch(api, {next: {tag: [`tag-${bookId}`]}})
// tanstack-query 의 고유한 id 값을 갖는 방식과 같다.

revalidateTag 방식을 사용하면 새로운 fetch가 필요한 데이터만 재검증을 시도하기 때문에 효율적으로 캐싱이 필요한 부분은 캐싱하고, 재검증이 필요한 데이터만 새로 데이터를 불러오는 효율적으로 활용할 수 있다.

클라이언트 컴포넌트에서의 서버액션

useActionState react 19v 부터 추가된 새로운 기능이 있다.
이 hook은 form tag들의 상태를 쉽게 관리해주는 hook이다.

첫번째는 함수, 두번째 인수로는 상태의 초기 값을 설정해야 한다.

"use client";

import style from "./review-editor.module.css";
import { createReviewAction } from "@/actions/create-review.action";
import { useActionState, useEffect } from "react";

export default function ReviewEditor({ bookId }: { bookId: string }) {
  const [state, formAction, isPending] = useActionState(createReviewAction, null);

  useEffect(() => {
    if (state && !state.status) {
      alert(state.error);
    }
  }, [state]);

  return (
    <section>
      <form className={style.form_container} action={formAction}>
        <input name="bookId" value={bookId} hidden readOnly />
        <textarea required name="content" placeholder="리뷰 내용" disabled={isPending} />
        <div className={style.submit_container}>
          <input required name="author" placeholder="작성자" disabled={isPending} />
          <button type="submit" disabled={isPending}>
            {isPending ? "..." : "작성하기"}
          </button>
        </div>
      </form>
    </section>
  );
}
"use server";

import { revalidateTag } from "next/cache";

export async function createReviewAction(_: any, formData: FormData) {
  const bookId = formData.get("bookId")?.toString();
  const content = formData.get("content")?.toString();
  const author = formData.get("author")?.toString();

  if (!bookId || !content || !author) {
    return {
      status: false,
      error: "리뷰 내용과 작성자를 입력해주세요.",
    };
  }

  try {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/review`, {
      method: "POST",
      body: JSON.stringify({
        bookId,
        content,
        author,
      }),
    });
    if (!response.ok) {
      throw new Error(response.statusText);
    }
    revalidateTag(`review-${bookId}`);
    return {
      status: true,
      error: "",
    };
  } catch (err) {
    console.error(err);
    return {
      status: false,
      error: `리뷰 저장에 실패했습니다: ${err}`,
    };
  }
}

첫번째 인수로 들어온 함수에 처음 useActionState 안의 state가 들어가기 때문에, 함수에서 state에 대한 인수를 받아야 한다. 필요없다면 _ 로 안받아도 된다.

profile
기억보다는 기록하는 개발자

0개의 댓글