서버 액션이란?
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) 서버 액션을 사용해야 하는 이유
- 코드가 간결함
- 기존 API 라우트에서는 fetch 요청을 직접 작성해야 했지만, 서버 액션을 사용하면 form 태그의 action 속성에 바로 연결할 수 있습니다.
- 보안이 강화됨
- API 라우트는 클라이언트에서 API 엔드포인트를 직접 호출하는 방식이기 때문에 보안 문제가 발생할 수 있습니다.
- 하지만 서버 액션은 클라이언트에서 실행 코드가 보이지 않기 때문에 민감한 데이터를 다루기에 적합합니다.
- 성능 최적화 가능
- revalidatePath(), revalidateTag()를 활용하여 페이지를 자동으로 업데이트할 수 있습니다.
- 기존에는 router.refresh()를 사용해야 했지만, 서버 액션을 사용하면 더 쉽게 최신 데이터를 유지할 수 있습니다.
- 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});
- 넥스트 서버가 자동으로 인수로 전달한 이 경로에 해당하는 페이지를 재검증한다.
❗️ 주의할 점
- revalidatePath는 오직 서버에서만 호출 가능
- 서버 액션 내부 또는 서버 컴포넌트에서만 실행 가능
- 클라이언트 컴포넌트에서는 호출할 수 없음
- revalidatePath는 해당 페이지의 모든 캐시를 무효화
- 예를 들어 force-cache 설정을 해도 해당 데이터 캐시가 삭제됨
- 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) 을 통해 최신 데이터를 유지할 수 있다.
- 클라이언트 상태 관리와도 쉽게 통합 가능하다.
'Frontend > Next.js' 카테고리의 다른 글
[Next.js/App Router] 12. 최적화 및 SEO 설정 및 배포하기 (0) | 2025.02.14 |
---|---|
[Next.js/App Router] 11. 패럴렐 라우트 (Parallel Route) (0) | 2025.02.12 |
[Next.js/App Router] 9. 스트리밍과 에러 핸들링 (0) | 2025.02.10 |
[Next.js/App Router] 8. 풀 라우트 캐시 (Full Route Cache) (0) | 2025.02.09 |
[Next.js/App Router] 7. 데이터 페칭 (0) | 2025.02.08 |