Section 8. Server Action

OlMinJe·2025년 10월 21일

Next.js

목록 보기
18/20
post-thumbnail

인프런 "한 입 크기로 잘라먹는 Next.js" 수강

Server Action

클라이언트에서 서버 함수를 직접 호출한다!

Server Action이란?
Server Action은 브라우저에서 직접 호출할 수 있는, 서버에서만 실행되는 비동기 함수이다.

Next.js의 서버 액션은 클라이언트에서 특정 Form이 제출될 때, 그 이벤트를 서버 함수로 바로 연결해주는 기능을 제공한다.
즉, API 라우트를 따로 만들 필요 없이 단 한 줄의 자바스크립트 함수로 폼 데이터를 서버에서 처리할 수 있다.

function ReviewEditor() {
  async function createReviewAction(formData: FormData) {
    'use server';
    console.log('server action called');
  }

  return (
    <section>
      <form action={createReviewAction}>
        <input name="content" placeholder="리뷰 내용" />
        <input name="author" placeholder="작성자" />
        <button type="submit">작성하기</button>
      </form>
    </section>
  );
}

폼이 제출되면 브라우저가 서버 액션 함수를 직접 호출한다.
이떄, 입력한 값들은 자동으로 FormData 객체로 전달된다.
이 방식은 오직 자바스크립트 함수 하나만으로 쉽고 간결하게 설정할 수 있다는 강점을 갖고 있다.

서버 액션 함수를 실행할 컴포넌트에 콘솔로 어떻게 출력되는지 확인해보자.

콘솔 출력 결과

작성하기 버튼을 클릭하면 사진과 같은 결과를 확인할 수 있다.

브라우저 네트워크 확인 결과

브라우저 네트워크 페이로드 확인 결과


(1) 리뷰 추가 기능 구현하기

서버 관련 로직은 app/actions 폴더에 정리하는 게 좋다.
리뷰 추가 기능을 위한 서버 액션은 아래처럼 작성할 수 있다.👇

'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 }),
    });
    console.log(response.status);
  } catch (err) {
    console.error(err);
    return;
  }
}

그리고 컴포넌트에서는 이렇게 연결한다.👇

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

⚠️ 참고

You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.

inputvalue 속성을 지정하고 onChange가 없으면 "읽기 전용" 경고가 발생한다.
이런 경우 동작에는 문제가 없지만 readonly 속성을 추가하면 해결된다.


(2) 리뷰 조회 기능 구현하기

리뷰 데이터를 가져오는 컴포넌트는 서버 컴포넌트로 작성하면 된다.

//...
async function ReviewList({ bookId }: { bookId: string }) {
  const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/review/book/${bookId}`);

  if (!response.ok) {
    throw new Error(`Review fetch failed : ${response.statusText}`);
  }

  const reviews: ReviewData[] = await response.json();

  return (
    <section>
      {reviews.map((review) => (
        <RevioewItem key={`review-item-${review.id}`} {...review} />
      ))}
    </section>
  );
}

리뷰 조회 기능 결과


(3) 서버 액션으로 페이지 재검증하기

리뷰를 작성한 뒤 새로고침하지 않아도 목록이 자동으로 갱신되도록 만들 수 있다.
이때 사용하는 함수가 바로 revalidatePath이다.

서버 액션이 실시간으로 완료되었을 떄, 사용자가 보고있는 페이지를 재검증하여 사용자 경험을 높여보자.

'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 }),
    });
    console.log(response.status);
    revalidatePath(`/book/${bookId}`);
  } catch (err) {
    console.error(err);
    return;
  }
}

revalidatePath()는 특정 경로의 페이지 캐시를 무효화하고 새로 생성한다. (=페이지 재검증)
즉, 리뷰를 등록하면 /book/[id] 페이지가 자동으로 갱신!

재검증 로직

*PURGE: 숙청하다

⚠️ revalidatePath 메서드 주의할 점
1. 서버 액션 내부에서만 호출이 가능하다
2. 경로의 재검증을 진행하기 떄문에, 해당 경로에 있는 페이지의 모든 캐시들을 전부 다 무효화 시킨다.
3.cache: ‘force-cache’로 설정해도 무효화되어 캐시가 삭제된다.
4. 데이터 캐시뿐만 아니라 Pull Route Cache도 함께 사라진다. 즉, Pull Route Cache로써 저장되어 있던 페이지도 삭제되기 때문에, 해당 페이지에 접속하면 Next의 서버는 실시간으로 새롭게 페이지를 다시 생성하게 된다.

revalidatePath 결과 화면


다양한 재검증 방식 살펴보기

revalidatePat의 옵션을 활용하여 다양한 재검증 방식을 살펴보자

종류코드 예시설명
특정 페이지만revalidatePath('/book/1')해당 페이지만 갱신
특정 경로의 모든 동적 페이지revalidatePath('/book/[id]', 'page')모든 도서 페이지 재검증
특정 레이아웃의 모든 페이지revalidatePath('/(with-searchbar)', 'layout')레이아웃 단위 재검증
전체 사이트revalidatePath('/', 'layout')전체 페이지 재생성
특정 데이터만revalidateTag('review-1')태그가 지정된 데이터 캐시만 무효화

태그 기반 캐시 재검증 (revalidateTag)

데이터 패칭 시 { next: { tags: ['review-1'] } } 옵션을 부여하면,
서버 액션에서 revalidateTag('review-1') 호출만으로 해당 데이터만 갱신할 수 있

// page.tsx
const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/review/book/${bookId}`, {
    next: { tags: [`review-${bookId}`] },
});

// create-review.tsx
'use server';

import { revalidatePath, revalidateTag } from 'next/cache';

export async function createReviewAction(formData: FormData) {
//...
  try {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/review`, {
      method: 'POST',
      body: JSON.stringify({ bookId, content, author }),
    });
    
    revalidateTag(`review-${bookId}`);
  } catch (err) {
//...

이 방식은 페이지 전체를 다시 렌더링하지 않아도 되기 떄문에, 성능 면에서 훨씬 효율적이다.


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

클라이언트에서도 서버 액션을 제어할 수 있다.
로딩 상태나 에러 메시지를 관리할 때 유용하다.

서버는 아래와 같이 구성할 수 있다.👇

'use server';

import { revalidateTag } from 'next/cache';

export async function createReviewAction(state: 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}`,
    };
  }
}

컴포넌트는 아래와 같이 구성할 수 있다.👇

'use client';

import { createReviewAction } from '@/actions/create-review.actions';
import style from '@/components/review-editor.module.css';
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 disabled={isPending} required name="content" placeholder="리뷰 내용" />
        <div className={style.submit_container}>
          <input disabled={isPending} required name="author" placeholder="작성자" />
          <button disabled={isPending} type="submit">
            {isPending ? '...' : '작성하기'}
          </button>
        </div>
      </form>
    </section>
  );
}

에러 발생 확인 결과


(4) 리뷰 삭제 기능 구현하기

requestSubmit로 사용자가 버튼을 클릭한 것과 동일하게 동작하도록 이벤트 핸들러를 추가한다.

'use client';

import { deleteReviewAction } from '@/actions/delete-review.action';
import { useActionState, useEffect, useRef } from 'react';

export default function ReviewItemDeleteButton({
  reviewId,
  bookId,
}: {
  reviewId: number;
  bookId: number;
}) {
  const formRef = useRef<HTMLFormElement>(null);
  const [state, formAction, isPending] = useActionState(deleteReviewAction, null);

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

  return (
    <form ref={formRef} action={formAction}>
      <input name="reviewId" value={reviewId} hidden readOnly />
      <input name="bookId" value={bookId} hidden readOnly />
      {isPending ? (
        <div>...</div>
      ) : (
        <div onClick={() => formRef.current?.requestSubmit()}>삭제하기</div>
      )}
    </form>
  );
}

⚠️ submit을 사용하지 않나요?
submit은 메서드 유효성 검사나 이벤트 핸들러 등을 다 무시하고 강제로 폼 제출을 발생시키기 때문에 원하지 않은 동작으로 이어질 수 있는 위험성이 있다.
이러한 문제를 예방하기 위해, 폼 유효성 검사와 이벤트를 유지한 채로 제출할 수 있는 requestSubmit 메서드를 사용한다.

 <ReviewItemDeleteButton reviewId={id} bookId={bookId} />

브라우저에서 확인하면 아래와 같다.

삭제 기능 결과 화면

profile
큐트걸

0개의 댓글