Next.js - 서버 액션

Stella·2026년 1월 6일

Next.js

목록 보기
6/8

서버 액션이란?

로그인, 회원 가입, 로그아웃 등 서버에서 이루어지는 작업을 쉽고 간단하게 구현할 수 있다.
서버에서 실행되는 비동기 함수 가운데 클라이언트가 직접 호출할 수 있는 함수를 말한다.

로그인, 회원 가입, 데이터 생성처럼 서버에서 이루어지는 작업을 쉽고 간단하게 구현할 수 있다.

export default function Page() {
    const loginAction = async () => {
        "use server"
        console.log("서버 액션 loginAction 호출!");
    };

    return (
        <form action={loginAction}>
            <input type="text" name="id" />
            <input type="password" name="password" />
            <button type="submit">로그인</button>
        </form>
    )
}

// 비동기 함수 loginAction을 만든다. -> use server라는 지시자를 작성 (서버액션)으로 설정
// 폼을 제출했을 때 loginAction을 호출하도록 설정한다.

사용자가 폼에 작성한 아이디와 패스워드는 서버 액션에 폼 데이터 형태의 인수로 전달된다. (폼 데이터)
따라서 서버 액션에서 매개변수로 사용자가 입력한 값을 꺼내 사용할 수 있다.

const loginAction = async (formData: FormData) => {
        "use server"
        
        const id = formData.get("id");
        const password = formData.get("password");

        console.log({ id, password });
        
    };

서버 액션은 브라우저에서 호출할 수 있는 Next.js 서버의 비동기 함수이다.
로그인이나 회원 가입 등과 같은 서버 동작을 쉽고 간단하게 구현할 수 있다.

= 복잡한 과정을 줄일 수 있다. 개발 생산성이 높아진다. 브라우저에 코드가 노출되지 않아 보안이 중요한 작업에 적합하다.

로직이 복잡하거나 대규모 통합 작업이 필요한 경우, 전통적인 API 설계 방식이 적합할 수 있다.
서버 액션은 간단한 서버 동작 or 클아이언트와 서버의 상호작용을 빠르게 구현할 때 유용한 선택이 될 수 있다.

  • DB와 연결해 SQL문을 직접 실행
// SQL문을 직접 호출하는 액션 예시
const createPostAction = async(formData:FormData) => {
	'use server'
    const content = formData.get("content");
    await sql 'INSERT INTO BOARD (content) VALUES (content)';
}

서버 액션 동작

Next.js의 서버 액션은 클라이언트가 POST메서드를 이용해 Next.js서버에게 HTTP요청을 보내는 방식으로 동작한다.

개발자도구 -> 네트워크탭 -> Fetch/XHR필터 활성화 -> 폼 제출

Headers탭 -> Request Headers에서 Next-Action에

next-action (호출하려는 서버 액션의 고유 아이디) Next.js가 자동생성
407ca5dd41991d4004ed0cd806aef03b2d052310c2

클라이언트가 서버에 요청할 때 함께 전송되어 어떤 서버 액션을 실행할지 Next.js 서버가 정확히 판단할 수 있도록 한다.

복잡한 HTTP요청 코드를 직접 작성하지 않고 마치 일반 함수처럼 서버 액션 선언 및 호출 -> 비동기 로직 손쉽게 실행할 수 있다.

서버 액션에서 주의할 사항

1) 꼭 form태그를 이용해야 하는 것은 아니다.
서버 액션을 별도의 파일로 분리, 페이지 컴포넌트를 클라이언트 컴포넌트로 설정한다.
이벤트 핸들러를 사용해 서버 액션 호출

"use client";

import { useRef } from "react";
import { loginAction } from "./login.action";

export default function Page() {
    const idRef = useRef<HTMLInputElement>(null);
    const pwRef = useRef<HTMLInputElement>(null);

    const onCLickLogin = async () => {
        if (!idRef.current || !pwRef.current) return;
        const id = idRef.current.value;
        const password = pwRef.current.value;

        const formData = new FormData();
        formData.set("id", id);
        formData.set("password", password);
        await loginAction(formData);
    };

    return (
        <div>
            <input ref={idRef} type="text" name="id" />
            <input ref={pwRef} type="password" name="password" />
            <button onClick={onCLickLogin}>로그인</button>
        </div>
    )
}

아이디와 패스워드를 받은 input태그를 idRef와 pwRef가 참조하도록 설정,

2) 비동기 함수로 만들어야 한다.
서버 액션은 반드시 비동기 함수로 구현해야 한다. 안그러면 오류 발생!!

서버 액션으로 리뷰 기능 구현

특정 도서의 리뷰를 작성하는 기능 구현
도서 상세 페이지 컴포넌트에서 백엔드 서버 도서 데이터를 불러와 렌더링하는 기능을 별도의 컴포넌트로 분리
기능을 분리하면 이후에 리뷰를 손쉽게 추가하고 관리할 수 있다.

// 리뷰를 작성하는 컴포넌트, 서버 액션을 사용해 새로운 리뷰 추가 
function ReviewEditor() {
  const createReviewAction = async (formData: FormData) => {
    "use server";

    const content = formData.get("content");
    const author = formData.get("author");

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

비동기 함수 createReviewAction을 만들고 함수의 최상단에 "use server"지시자를 작성해 함수를 서버 액션으로 설정한다.
input태그에 required속성을 추가하면 내용이 없으면 폼을 제출하지 못한다.

localhost:8080/api로 접속 API문서를 살펴보면
/review탭에서 새 리뷰를 생성하는 API가 있음을 확인할 수 있다.

HTTP메서드 : POST
요청 Body필드 : bookId(도서아이디), content(리뷰 내용), author(리뷰 작성자 이름)

- 서버 액션, 도서 아이디를 서버 액션에 포함된 폼 데이터와 함께 전달

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

페이지에서 보이지 않도록 hidden으로 설정한다. readOnly속성으로 값이 수정되지 않도록 한다.

{ content: '아 좋타', author: 'stella', bookId: '1' } 

form태그에 추가해서 서버 액션에 도서 아이디 값을 전달하는지 확인

- DB에 새로운 리뷰를 추가한다.

API에러 예외상황 처리 try-catch문 작성JSON으로 직렬화 전송한다.

try {
      if (!bookId || !content || !author) throw new Error("잘못된 요청입니다.");
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}/review`, {
          method: "POST",
          body: JSON.stringify({ bookId, content, author}),
        })
        if(!response.ok) throw new Error(response.statusText);
    } catch (e) {
      console.error(e);
    }

server에서 npx prisma studio에서 확인 가능
createdAt 두번 클릭하면 최신순 정렬

리뷰 조회 및 갱신 기능 구현

DB에 등록한 리뷰를 불러와 리스트로 렌더링 -> 컴포넌트의 자식으로 배치
HTTP 메서드 : GET
요청주소 /review/book/[도서 아이디]
응답 데이터

[
  {
    "id": 0,
    "content": "string",
    "author": "string",
    "createdAt": "2026-01-06T05:21:08.879Z",
    "bookId": 0
  }
]

types.ts에 타입정의한 뒤 ReviewList에서 review data를 불러오는 기능 추가

async function ReviewList({ bookId }: { bookId: string }) {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_URL}/review/book/${bookId}`);
    if (!response.ok) throw new Error(response.statusText);

    const reviews: ReviewData[] = await response.json();
    console.log(reviews);
    
  return <section></section>;
}

~/book/1로 접속하면 백엔드 서버에서 해당 도서의 모든 리뷰 데이터를 성공적으로 가져온다.

리뷰 리스트 렌더링하기

  • reviewItem 컴포넌트 만들기
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>
            <div className={style.delete_btn}>삭제하기</div>
        </div>
    )
}
  • reviewItem 컴포넌트 reviewList에 불러오기
return (
    <section>
      {reviews.map((review) => (
        <ReviewItem key={`review-item-${review.id}`} {...review}/>
      ))}
    </section>
  )

리뷰 갱신 기능 구현하기

새로운 리뷰를 추가해도 실시간으로 데이터를 페이지에 반영하지 않는다. -> 새로고침 필요
ReviewEditor 컴포넌트에서 호출한 서버 액션이 수행 -> DB에 리뷰가 추가되면 페이지 컴포넌트 Next.js서버
-> 다시 렌더링해서 데이터 반영 -> 화면 갱신

= 서버 컴포넌트이기 때문에

- revalidatePath

특정 경로의 캐시 데이터를 모두 제거하는 기능 = 풀 라우트 캐시와 데이터 캐시를 무효화 한다.
주로 Next.js 서버에서만 호출할 수 있다.

import { revalidatePath } from "next/cache";

revalidatePath("/") // "/"경로에 해당하는 인덱스 페이지의 풀 라우트 캐시와 데이터 캐시를 무효화

동적 경로의 모든 캐시 또는 앱 전체의 모든 캐시를 무효화하는 확장 기능도 제공한다.

- revalidatePath(path, type) = 특정 경로의 모든 캐시

캐시 무효화의 범위를 설정하는 두 번째 인수 type
revalidatePath("book/[id]", "page") // "book/[id]" 경로의 캐시 무효화
첫번째 경로에 포함되는

revalidatePath('/(with-searchbar)', 'layout')
// (with-searchbar)/layout.tsx 파일의 레이아웃 컴포넌트가 적용되는
// 모든 경로의 캐시를 무효화한다.

/search 페이지의 캐시도 모두 무효화된다.

revalidatePath('.', 'layout')
// Next.js 앱의 캐시를 모두 무효화한다.

- revalidateTag = 특정 태그가 있는 데이터 캐시

특정 태그가 있는 데이터 캐시를 무효화한다.

fetch(url, { next: {tags: ["a"]} });
// "a" 태그를 설정
revalidateTag("a") // a태그 데이터 캐시를 모두 무효화

리뷰 갱신 기능 업그레이드

  1. BookDetail 컴포넌트 : 도서의 상세 정보를 불러오는 fetch 메서드
    변경되지 않으므로 캐시 무효화 불필요
  2. ReviewList 컴포넌트 : 리뷰 데이터를 불러오는 fetch 메서드
    새 리뷰를 추가했을 때 무효화할 캐시 -> 리뷰 데이터 캐시

둘 중 하나만 캐시를 무효화하기 위해 revalidateTag를 사용
reviewList 컴포넌트의 fetch 메서드에서 캐시 옵션 추가

async function ReviewList({ bookId }: { bookId: string }) {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_URL}/review/book/${bookId}`,
    { next: { tags: [`review`]}}); // 리뷰 캐시의 데이터 옵션 

reviewEditor에 revalidateTag 추가

revalidateTag(`review-${bookId}`); 

review-${bookId}태그가 있는 데이터 캐시를 무효화하도록 변경한다. 갱신 범위를 최소화하면서 필요한 부분은 정확히 갱신한다.

리뷰 추가 및 갱신 기능 업그레이드 (로딩, 오류 출력)

ReviewEditor 컴포넌트 -> 클라이언트 컴포넌트로 전환

서버 컴포넌트 -> client 컴포넌트 전환 -> 서버 액션 별도의 파일로 분리

create-review.action.js

"use server";
import { revalidateTag } from "next/cache";

const createReviewAction = async (formData: FormData) => {

    const bookId = formData.get("bookId");
    const content = formData.get("content");
    const author = formData.get("author");

    // api를 호출 DB에 리뷰 추가
    try {
      if (!bookId || !content || !author) throw new Error("잘못된 요청입니다.");
      const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/review`,
        {
          method: "POST",
          body: JSON.stringify({ bookId, content, author }),
        }
      );
      if (!response.ok) throw new Error(response.statusText);
      revalidateTag(`review-${bookId}`); // 특정 경로의 캐시 데이터 제거
    } catch (e) {
      console.error(e);
    }
  };
  
export default createReviewAction;

client 컴포넌트로 전환하기 위해 서버 액션 createReviewAction을 별도의 파일로 분리했다.

useActionState를 이용해 form 액션 상태 관리

ReviewEditor 컴포넌트를 클라이언트 컴포넌트로 전환
서버 액션 결과에 따라 사용자에게 적절한 피드백을 제공한다.

ReviewEditor 컴포넌트에서 서버 액션의 결괏값을 추적할 수 있어야 한다.
useActionState훅을 사용해 구한다. form 액션의 실행 결과, 로딩 상태 State로 불러와 사용 가능

useActionState

const [state, action, isPending] = useActionState(fn, initialState, permalink?);

fn : function 액션 함수 전달
initialState : 액션 상태의 초깃값 전달
permalink : form 제출 또는 액션 실행 이후 이동하려는 URL을 전달한다.

state : 첫 번째 요소 form 액션의 결괏값을 저장하는 상태
action : 액션을 발생시키는 함수이다. 전달한 액션 함수(fn)을 실행하여 결괏값 추적
isPending : 현재 액션의 진행 여부 로딩 상태 반환

const [state, action, isPending] = useActionState(createReviewAction, null);
    // state : 현재 상태, action 발생시키는 함수, isPending : 액션의 로딩 상태

prevState: unknown 이전 상태를 나타내는 값, 특정 로직에서 활용 하지 않으면 unknown 타입으로 설정

"use server";
import { revalidateTag } from "next/cache";

const createReviewAction = async (prevState: unknown, formData: FormData) => {

    const bookId = formData.get("bookId");
    const content = formData.get("content");
    const author = formData.get("author");

    // api를 호출 DB에 리뷰 추가
    try {
      if (!bookId || !content || !author) throw new Error("잘못된 요청입니다.");
      const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/review`,
        {
          method: "POST",
          body: JSON.stringify({ bookId, content, author }),
        }
      );
      if (!response.ok) throw new Error(response.statusText);
      revalidateTag(`review-${bookId}`); // 특정 경로의 캐시 데이터 제거
    } catch (e) {
      console.error(e);
    }
  };
  
export default createReviewAction;

서버 액션의 결괏값 이용하기

서버 액션의 결괏값이 State에 저장되는지 확인

"use server";
import { revalidateTag } from "next/cache";

const createReviewAction = async (prevState: unknown, formData: FormData) => {

    const bookId = formData.get("bookId");
    const content = formData.get("content");
    const author = formData.get("author");

    // api를 호출 DB에 리뷰 추가
    try {
      if (!bookId || !content || !author) throw new Error("잘못된 요청입니다.");
      const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/review`,
        {
          method: "POST",
          body: JSON.stringify({ bookId, content, author }),
        }
      );
      if (!response.ok) throw new Error(response.statusText);
      revalidateTag(`review-${bookId}`); // 특정 경로의 캐시 데이터 제거

      return {
        status: true,
        message: "리뷰를 성공적으로 추가했습니다.",
      };
    } catch (e) {
      return {
        status: false,
        message: `새로운 리뷰를 추가하지 못했습니다: ${e}`,
      };
    }
  };
  
export default createReviewAction;

review-editor.tsx에서
useEffect를 사용하여 state의 값이 변경될 때마다 브라우저 콘솔에 출력하도록 설정한다.

"use client";

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

export default function ReviewEditor({ bookId }: { bookId: string }) {
    // useActionState 사용
    const [state, action, isPending] = useActionState(createReviewAction, null);
    // state : 현재 상태, action 발생시키는 함수, isPending : 액션의 로딩 상태

    useEffect(() => {
        console.log(state);
        
    }, [state]); // state값이 변경될 때마다 콘솔에 출력
    return (
    <section className={style.container}>
      <form action={action}>
        <input name="bookId" value={bookId} type="hidden" readOnly />
        <textarea required name="content" placeholder="리뷰 내용" />
        {/* bookId값으로 설정 => 서버 액션에 폼을 제출할 때 도서 아이디를 bookId로 전달 가능 */}
        <div className={style.submit_container}>
          <input required name="author" placeholder="작성자" />
          <button type="submit">작성하기</button>
        </div>
      </form>
    </section>
  );
}

로딩 UI 설정하기

useActionState의 isPending에는 현재 액션의 로딩 상태를 저장한다.
isPending을 이용해 로딩 UI를 설정할 수 있다.

<textarea required={isPending} name="content" placeholder="리뷰 내용" />
  {/* bookId값으로 설정 => 서버 액션에 폼을 제출할 때 도서 아이디를 bookId로 전달 가능 */}
	<div className={style.submit_container}>
	<input required={isPending} name="author" placeholder="작성자" />
<button disabled={isPending}type="submit">작성하기</button>

리뷰 내용, 작성자, 작성하기 disabled={isPending}으로 설정

리뷰 삭제 기능 구현하기

서버 액션을 호출해 DB에서 특정 리뷰 데이터를 삭제하도록 설정

<form>
	<div className={style.delete_btn}>삭제하기</div>
</form>

form 태그로 감싸야 useActionState와 같은 훅으로 서버 액션의 결과를 추적하고 로딩 상태를 효율적으로 관리

= 삭제하기 버튼만 클라이언트 컴포넌트로 전환하는 게 효율적인 문제 해결 방법이다.

"use client";

export default function DeleteReviewItemButton() {
    return (
        <form>
            <div>삭제하기</div>
        </form>
    );
}
  • DB리뷰 삭제할 서버 액션
"use server";

import { revalidateTag } from "next/cache";

const deleteReviewAction = async (prevState: unknown, formData: FormData) => {
    const reviewId = formData.get("reviewId");
    const bookId = formData.get("bookId");
    // 삭제할 아이디, 도서 아이디 추출 

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

    try {
        const response = await fetch(
            `${process.env.NEXT_PUBLIC_API_URL}/review/${reviewId}`, // 실제 리뷰 삭제 API
            {
                method: "DELETE",
            }
        );

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

        }

        revalidateTag(`review-${bookId}`); // 페이지에 반영 review-${bookId} 태그가 있는 데이터 캐시 무효화
        return {
            status: true,
            message: "",
        };
    } catch(err) {
        return {
            status: false,
            message: `리뷰 삭제에 실패했습니다: ${err}`,
        };
    }
};

export default deleteReviewAction;
  • delete-review-item-button에
    도서 아이디와 리뷰 아이디를 props로 모두 받아온다.
"use client";

export default function DeleteReviewItemButton({
    bookId,
    reviewId,
}: {
    bookId: number;
    reviewId: number;
}) {
    return (
        <form>
            <div>삭제하기</div>
            <input name="bookId" value={bookId} type="hidden" readOnly />
            <input name="reviewId" value={reviewId} type="hidden" readOnly />
        </form>
    )
}

서버 액션을 호출할 때 formData에 포함시켜 전달할 값을 설정할 수 있다.

  • div 태그를 유지해야 하는 경우
    div를 클릭했을 때 form 태그를 프로그래매틱하게 제출할 수 있다.
    const formRef = useRef<HTMLFormElement>(null);
    return (
        <form ref={formRef}>
            <div onClick={() => {
                if (formRef.current) formRef.current.requestSubmit(); // useRef를 불러온다. 객체를 생성하고 변수에 저장한다.
            }}>삭제하기</div>
  • requestSubmit
    사용자가 submit 버튼을 클릭한 것과 동일하게 동작, 유효성 검사도 정상적으로 수행한다.

  • form 태그를 제출할 때 서버 액션을 호출하도록 설정
    useActionState를 사용하여 서버 액션을 호출해 리뷰를 삭제한다. 이 과정에서 로딩 상태와 에러 처리도 함께 구현한다.

profile
공부 기록

0개의 댓글