Frontend/Next.js

[Next.js/AppRouter] 10. 서버 액션 (Server Actions)

ayeongjin 2025. 2. 11. 00:20

서버 액션이란?

Next.js의 서버 액션(Server Actions) 은 브라우저에서 호출할 수 있지만, 서버에서 실행되는 비동기 함수입니다. 이를 통해 API 엔드포인트를 따로 만들지 않고도 브라우저와 서버 간 데이터 통신을 손쉽게 구현할 수 있습니다.

  • 기존에는 브라우저에서 서버로 데이터를 보내려면 API 라우트를 따로 구현해야 했습니다.
  • 하지만 서버 액션을 사용하면 일반 함수처럼 서버에서 실행되는 코드를 작성할 수 있습니다.
  • 즉, 기존 API 라우트를 대체하는 더 간편한 방식

 

1) 서버 액션 vs 기존 API 라우트 비교

구분 기존 API 라우트 방식 서버 액션 방식
사용 방식 pages/api 또는 app/api에 API 엔드포인트 작성 use server를 명시하여 함수 정의
호출 방식 브라우저 → API 라우트 → 백엔드 처리 → 응답 반환 브라우저 → 서버 액션 함수 호출 → 처리 후 응답
보안 API 라우트에서 요청을 검증해야 함 서버 액션은 클라이언트에서 코드가 노출되지 않음
코드 복잡성 API 엔드포인트 + 클라이언트 요청 코드 필요 클라이언트에서 직접 서버 액션 호출 가능
데이터 갱신 클라이언트에서 router.refresh() 또는 SWR 활용 revalidatePath() 또는 revalidateTag() 사용 가능

 

✅ 기존 API 라우트 방식

// src/app/api/review/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const body = await req.json();

  // 데이터 저장 로직
  await saveReview(body.bookId, body.content, body.author);

  return NextResponse.json({ message: "리뷰 저장 완료" });
}

API 라우트 사용 시 클라이언트에서 요청을 보내는 방식

// 클라이언트 컴포넌트
async function submitReview(bookId, content, author) {
  const res = await fetch("/api/review", {
    method: "POST",
    body: JSON.stringify({ bookId, content, author }),
    headers: { "Content-Type": "application/json" },
  });

  if (!res.ok) {
    throw new Error("리뷰 저장 실패");
  }
}

 

✅ 서버 액션 방식

"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;
  }

  // 백엔드 API 호출 없이 서버에서 바로 처리 가능
  await saveReview(bookId, content, author);
}

서버 액션을 사용하면 클라이언트 코드도 단순해짐

// 클라이언트 컴포넌트
<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>

API 라우트가 필요 없으며, fetch 요청도 직접 작성할 필요 없음

 

2) 서버 액션 호출 후 개발자 도구 확인

서버 액션이 실행되었는지 확인하기 위해 개발자 도구 → Network 탭을 확인한다.

 

✅ Request Headers 확인

서버 액션을 호출하면 브라우저에서 POST 요청을 자동으로 생성해준다.

  • Request URL : Next.js 내부에서 처리되므로 별도의 API URL 필요 없음
  • Request Method : POST
  • Status Code : 200 OK (정상적으로 요청 성공)

 

✅ Payload 확인 (전송된 데이터)

  • formData로 전송된 값이 확인됨 (content, author 필드)

 

3) 서버 액션을 사용해야 하는 이유

  1. 코드가 간결함
    • 기존 API 라우트에서는 fetch 요청을 직접 작성해야 했지만, 서버 액션을 사용하면 form 태그의 action 속성에 바로 연결할 수 있습니다.
  2. 보안이 강화됨
    • API 라우트는 클라이언트에서 API 엔드포인트를 직접 호출하는 방식이기 때문에 보안 문제가 발생할 수 있습니다.
    • 하지만 서버 액션은 클라이언트에서 실행 코드가 보이지 않기 때문에 민감한 데이터를 다루기에 적합합니다.
  3. 성능 최적화 가능
    • revalidatePath(), revalidateTag()를 활용하여 페이지를 자동으로 업데이트할 수 있습니다.
    • 기존에는 router.refresh()를 사용해야 했지만, 서버 액션을 사용하면 더 쉽게 최신 데이터를 유지할 수 있습니다.
  4. Next.js의 새로운 데이터 페칭 패턴과 잘 맞음
    • 기존에는 router.refresh()를 사용해야 했지만, 서버 액션을 사용하면 더 쉽게 최신 데이터를서버 액션은 getServerSideProps나 getStaticProps 없이도 서버에서 데이터를 처리하고 UI를 업데이트할 수 있습니다.

 


 

서버 액션 예제

 

1) 기본적인 서버 액션 사용 방법

// src/actions/create-review.action.ts

"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;
  }
}

서버 액션 함수에는 "use server"지시어를 추가해야 합니다.

  • 이렇게 하면 Next.js가 해당 함수가 클라이언트에서 실행되지 않고, 서버에서만 실행되도록 보장합니다.

body: JSON.stringify({ bookId, content, author })

  • 문자열로 직렬화하여 전송
<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>

<input name="bookId" value={bookId} hidden readOnly/>

  • bookId를 props로 전달받을 수 없기 떄문에 form으로 직접 전달한다.
  • hidden input 태그에는 readOnly 옵션 적용

 

💡 등록된 데이터 백엔드 서버에서 확인하기
npm prisma studio
: 현재 데이터베이스의 데이터를 볼 수 있는 데시보드 열기

 

 

2) 서버 액션 + 데이터 재검증 (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 {
    await saveReview(bookId, content, author);

    // ✅ 해당 페이지를 다시 불러오도록 설정
    revalidatePath(`/book/${bookId}`);
  } catch (err) {
    console.error(err);
  }
}

revalidatePath(/book/${bookId});

  • 넥스트 서버가 자동으로 인수로 전달한 이 경로에 해당하는 페이지를 재검증한다.

 

❗️ 주의할 점

  1. revalidatePath는 오직 서버에서만 호출 가능
    • 서버 액션 내부 또는 서버 컴포넌트에서만 실행 가능
    • 클라이언트 컴포넌트에서는 호출할 수 없음
  2. revalidatePath는 해당 페이지의 모든 캐시를 무효화
    • 예를 들어 force-cache 설정을 해도 해당 데이터 캐시가 삭제됨
  3. revalidatePath 실행 시 페이지 전체가 캐싱 해제됨
    • 기존 풀 라우트 캐시가 제거되지만, 다시 업데이트되지는 않음
    • 해결하려면 새로고침을 통해 다시 페이지를 생성해야 함

→ 무조건 최신의 데이터를 보장하기 위함

 

✅ revalidatePath 요청 동작

 

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

"use server";

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

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

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

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

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

// 3. 특정 레이아웃을 갖는 모든 페이지 재검증
revalidatePath("/(with-searchbar)", "layout");
"use server";

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

// 4. 모든 데이터 재검증
revalidatePath("/", "layout");
"use server";

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

// 5. 태그 기준, 데이터 캐시 재검증
// 캐시 옵션으로 { next: { tags: [`review-${bookId}`] } } 이렇게 태그를 지정하면 해당 태그만 재검증할 수 있다.
revalidateTag(`review-${bookId}`);

5번 방식을 사용하면 훨씬 더 효율적으로 불필요한 캐시를 삭제하지 않고 재검증할 수 있다.

 

4) 서버 액션 + 클라이언트 상태 관리

서버 액션을 클라이언트 컴포넌트에서 활용하면 상태 관리도 가능합니다.

// src/actions/create-review.action.ts

"use server";

import { delay } from "@/util/delay";
import { revalidatePath, 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 {
    await delay(2000);
    const response = await fetch(
      `${process.env.NEXT_PUBLIC_API_SERVER_URL}/review/1`,
      {
        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) {
    return {
      status: false,
      error: `리뷰 저장에 실패했습니다 : ${err}`,
    };
  }
}
// src/components/review-editor.tsx

"use client";

import style from "./review-editor.module.css";
import { createReviewAction } from "@/actions/create-review.action";
import { useActionState, useEffect } from "react"; // form 태그의 상태를 쉽게 핸들링할 수 있음

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 />
        <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>
  );
}
  • 로딩 상태 관리 가능 (isPending 활용)
  • 폼이 여러 번 제출되지 않도록 설정 가능

 

5) 리뷰 삭제기능 구현하기

// src/actions/delete-review.action.ts

"use server";

import { revalidateTag } from "next/cache";

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

  if (!reviewId) {
    return {
      status: false,
      error: "삭제할 리뷰가 없습니다",
    };
  }

  try {
    const response = await fetch(
      `${process.env.NEXT_PUBLIC_API_SERVER_URL}/review/${reviewId}`,
      {
        method: "DELETE",
      }
    );

    if (!response.ok) {
      throw new Error(response.statusText);
    }

    revalidateTag(`review-${bookId}`);
    return {
      status: true,
      error: "",
    };
  } catch (err) {
    return {
      status: false,
      error: `리뷰 삭제에 실패했습니다 : ${err}`,
    };
  }
}
// src/components/review-item.tsx

import { ReviewData } from "@/types";
import style from "./review-item.module.css";
import ReviewItemDeleteButton from "./review-item-delete-button";

export default function ReviewItem({
  id,
  content,
  author,
  createdAt,
  bookId,
}: ReviewData) {
  return (
    <div className={style.container}>
      <div className={style.author}>{author}</div>
      <div className={style.content}>{content}</div>
      <div className={style.bottom_container}>
        <div className={style.date}>
          {new Date(createdAt).toLocaleString()}
        </div>
        <div className={style.delete_btn}>
          <ReviewItemDeleteButton reviewId={id} bookId={bookId} />
        </div>
      </div>
    </div>
  );
}
// src/components/review-item-delete-button.tsx

"use client";

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

export default function ReviewItemDeleteButton({
  reviewId, // reviewId와 bookId props로 받아오기
  bookId,
}: {
  reviewId: number;
  bookId: number;
}) {
  const formRef = useRef<HTMLFormElement>(null); // HTMLFormElement를 저장하는 레퍼런스 생성
  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 메서드는 유효성검사나 이벤트핸들러를 다 무시하고 그냥 강제로 제출하기 떄문에 requestSubmit사용

 


 

배운점

  • Next.js 서버 액션은 기존 API 라우트보다 훨씬 더 간결하고 강력한 방식이다.
  • 보안이 강화되며, 클라이언트에서 서버 코드가 노출되지 않는다.
  • 자동 데이터 재검증 (revalidatePath, revalidateTag) 을 통해 최신 데이터를 유지할 수 있다.
  • 클라이언트 상태 관리와도 쉽게 통합 가능하다.