Next.js 서버 액션으로 API routes와 이별하기
17 min read
서버 액션(Server Actions)
서버 액션의 정의
서버 액션(Server Actions)은 브라우저에서 호출할 수 있는 서버에서 실행되는 비동기 함수를 의미합니다. Next.js 문서에서는 다음과 같이 정의하고 있습니다.
Server Actions are asynchronous functions that are executed on the server. They can be called in Server and Client Components to handle form submissions and data mutations in Next.js applications.
이러한 서버 액션은 별도로 서버 API를 호출하지 않고 정의한 서버 액션 함수 내에서 DB 수정 등의 서버 사이드 로직을 수행할 수 있습니다.
서버 액션 활용하기
서버 액션의 이해를 돕기 위해 실제 블로그 개발 시나리오를 기반으로 서버 액션을 적용해보겠습니다.
API Routes vs Server Actions
AS-IS : API Routes
Next.js로 블로그를 개발하던 김길동은 댓글 작성 기능을 구현하려고 합니다. 아래와 같이 댓글을 작성하기 위한 CommentForm 컴포넌트를 개발하였습니다.
export default function CommentForm() {
return (
<form>
<textarea
id="comment"
name="comment"
/>
<button type="submit">댓글 등록</button> // 버튼 클릭 시 댓글이 작성되어야 함.
</form>
);
}
이때 김길동은 Drizzle ORM을 활용하여 데이터베이스 작업을 수행하려고 합니다(Vercel Storage 관련 포스트 참고). 데이터베이스 작업은 서버에서 수행되어야 하는데에 반해 현재 댓글 폼은 클라이언트 컴포넌트로 설계되었습니다. 따라서 서버 액션에 '서' 자도 모르던 김길동은 별도로 API Routes를 설계하였습니다.
export async function POST(request: Request) {
const { content } = await request.json();
await db.insert(CommentTable).values({ content });
return NextResponse.json({ success: true });
}
export default function CommentForm() {
const handleSubmit = async (e) => {
e.preventDefault();
await fetch('/api/comments', {
method: 'POST',
body: JSON.stringify(commentRequest),
});
};
return (...);
}
TO-BE : Server Actions
API Routes에 대한 코드를 지속적으로 작성하고 관리하는 것이 비효율적이라고 생각했던 김길동은 드디어 Next.js의 서버 액션에 대해 알게되었습니다. 이제 김길동은 기존에 구현했던 API Routes를 서버 액션으로 통합하려고 합니다. 클라이언트 컴포넌트에서 사용할 경우 아래와 같이 액션 함수에 대한 별도의 파일을 정의한 뒤 최상단에 'use server' 지시문을 추가하는 방식으로 이용할 수 있습니다. 또한 클라이언트 측에서 직렬화된 FormData 객체를 통해 입력한 값을 가져올 수 있습니다.
'use server';
export async function createCommentAction(formData: FormData) {
const comment = formData.get('comment'); // form 내에 입력 값 추출 가능
await db.insert(CommentTable);
};
export default function CommentForm() {
return (
<form action={createCommentAction}>
<textarea
id="comment"
name="comment"
/>
<button type="submit">댓글 등록</button>
</form>
);
}
서버 액션 실전 활용하기
이제 김길동은 서버 액션으로 폼 요청을 처리할 수 있게 되었습니다. 그러나 프로덕션 배포에 앞서 사용자와의 상호작용을 늘리고 더욱 완성도 있는 웹을 만들고자 다음과 같은 요구사항을 추가하고자 합니다.
요구사항 | 솔루션 |
---|---|
댓글 작성 시 Markdown 적용 | Hidden Input |
댓글의 대댓글 작성 기능 | bind() |
작성 요청에 대한 결과 값(성공, 실패) 다이얼로그로 보여주기 | useFormState() |
댓글 작성 요청 중에 등록 버튼 비활성화 | useFormStatus() |
댓글 요청 값 유효성 검증 | Drizzle Zod |
댓글 작성 시 댓글 reload | revalidatePath() revalidateTag() |
Hidden Input
따라서 김길동은 아래와 같이 CommentForm 컴포넌트를 수정하였습니다. 댓글 작성 영역은 react-md-editor를 활용하였으며 하단에 요청 결과를 보여줄 InfoDialog 컴포넌트를 추가하였습니다.

export default function CommentForm() {
const [infoDialog, setInfoDialog] = useState({
isOpen: false,
title: '',
});
return (
<>
<form action={createCommentAction}>
<MDEditor />
<SubmitButton />
</form>
<InfoDialog
<InfoDialog
title={infoDialog.title}
isOpen={infoDialog.isOpen}
onClose={() => setInfoDialog(prev => ({ ...prev, isOpen: false }))}
/>
/>
</>
);
}
이때 MDEditor와 같이 입력 값을 외부 라이브러리에 의존하는 경우 form 내에 input을 넣고 hidden css 속성을 넣어 관리할 수 있습니다.
const [content, setContent] = useState('');
...
<form action={createCommentAction}>
<input type="hidden" name="content" value={content} />
<MDEditor value={content} onChange={handleContent} />
<SubmitButton />
</form>
bind()
앞서 formData만 필요했던 경우와 달리 대댓글 작성 등을 위해 아래와 같이 postId, parentId가 추가로 필요하게 되었습니다.
export async function createCommentAction(
postId: number, // post ID에 해당하는 게시물에 댓글 작성
parentId: number | undefined, // parent ID가 존재할 시 해당 parent의 대댓글로 작성
prevState: ServerActionState<null> | null, // server action의 이전 state
formData: FormData, // form 데이터
): Promise<ServerActionState<null>> { ... }
여기서 postId와 parentId는 사용자가 댓글을 입력하는 시점이 아닌 컴포넌트가 렌더링되는 시점에 이미 결정되는 값입니다. 이러한 경우 hidden input을 사용하기 보단 자바스크립트 bind 메서드를 통해 함수 일부 인수를 미리 고정할 수 있습니다.
const createComment = createCommentAction.bind(null, postId, parentId);
useFormState()
댓글 작성에 대한 서버 액션이 호출되고 이에 대한 성공 혹은 실패 응답을 받아 Dialog를 통해 사용자에게 보여주어야 합니다.
이때 useFormState
훅을 이용해 폼 제출 후 서버에서 반환된 상태를 클라이언트 컴포넌트 내에서 사용할 수 있습니다.
다음과 같이 creatComment
액션 함수를 useFormState 훅에 전달하면 해당 액션의 실행 결과를 상태값으로 받아올 수 있습니다.
const [state, formAction] = useFormState(createComment, null);
return (
<>
<form >
...(생략)
</form>
</>
);
서버 액션의 실행 결과는 state
값으로 받아오게 되며 useEffect를 통해 상태값의 변화에 대응하여 작업을 수행할 수 있습니다.
useEffect(() => {
if (state) {
setInfoDialog({
isOpen: true,
title: state.message,
});
setContent('');
}
}, [state]);
이에 따라 서버 액션에서 반환해주는 값을 <InfoDialog />
를 통해 보여줄 수 있게 되었습니다.
export async function createCommentAction() {
...(생략)
return {
message: '댓글이 등록되었어요',
success: true,
};
}

useFormStatus()
댓글 작성을 요청이 지연 되는 경우 유저가 댓글 등록 버튼을 다시 클릭하는 것을 방지해야합니다.
이때 useFormStatus()
훅을 이용하면 폼 제출 상태를 추적할 수 있습니다.
다음과 같이 pending 상태 값을 통해 버튼의 활성화 여부와 버튼 텍스트를 동적으로 변경할 수 있습니다.
function SubmitButton({ content }: { content: string; }) {
const { pending } = useFormStatus();
return (
<Button disabled={pending} type="submit" size="sm">
{pending ? '등록중...' : '댓글 등록'}
</Button>
);
}
테스트를 위해 createCommentAction
함수 내에 의도적으로 delay
함수를 이용하여 요청을 지연해주었습니다.
이때 아래와 같이 버튼이 비활성화되며 '등록중...'으로 텍스트가 변경되는 것을 확인할 수 있습니다.

Drizzle Zod
서버 액션은 Next 내부 서버를 통해 서버 사이드에서 실행됨으로 서버 사이드에서 안전하게 요청 데이터에 대한 유효성을 검증할 수 있습니다.
if (!content) {
return {
message: '댓글 내용을 입력해주세요',
success: false,
};
}
위와 같이 간단하게 조건문을 통해 검증할 수 있지만 복잡한 폼 데이터 혹은 지금과 같이 ORM을 사용중인 경우 Zod를 통해 체계적으로 유효성을 검증할 수 있습니다. 예를 들어 블로그 게시물 작성 요청을 할 때 게시물에 대한 유효성을 Drizzle Zod를 활용하여 유효성을 검증할 수 있습니다.
import { createInsertSchema } from "drizzle-zod";
const createPostFormSchema = createInsertSchema(PostTable, {
title: z.string()
.min(1, "제목은 필수 입력 항목입니다.")
.max(150, "제목은 최대 150자까지 입력 가능합니다."),
content: z.string()
.min(1, "내용은 필수 입력 항목입니다."),
description: z.string()
.min(1, "설명은 필수 입력 항목입니다.")
.max(150, "설명은 최대 150자까지 입력 가능합니다."),
categories: z.array(z.string()).default([]),
slug: z.string()
.regex(/^[a-z0-9-]+$/, "slug는 영문 소문자, 숫자, 하이픈(-)만 포함할 수 있습니다.")
});
export async function createPostAction() {
...(생략)
const validatedData = createPostFormSchema.parse(input);
}
revalidatePath(), revalidateTag()
앞서 살펴본 댓글 작성, 게시물 작성 뿐만 아니라 서버 액션을 호출 한 뒤 UI를 갱신해야하는 경우가 존재합니다.
이때 revalidatePath()
함수를 이용하면 해당 경로의 캐시를 무효화해주어 요청이 반영된, 즉 새로운 댓글이 작성된 상태로 페이지를 갱신할 수 있습니다.
export async function createCommentAction(formData: FormData) {
...
(생략)
await db.insert(CommentTable).value({ content: content });
revalidatePath(`/post/${postId}/${postSlug}`);
};
그러나 게시물 페이지는 게시물 요청에 대한 캐싱과 댓글 요청에 대한 캐싱이 분리되어있습니다.
따라서 댓글을 작성한다해서 게시물 요청에 대한 캐싱까지 무효화할 필요는 없습니다.
이렇게 특정 캐싱에 대해서만 무효화하고싶은 경우 revalidateTag()
를 활용할 수 있습니다.
revalidateTag(`comment-${postId}`);
서버 액션을 사용하는 이유
앞서 서버 액션을 어떻게 사용할 수 있을지에 대한 구체적인 활용 예제를 함께 살펴보았습니다. 단순히 이렇게 사용하면 되는구나를 넘어서 "왜" 서버 액션을 사용해야하는가에 대해 살펴보고자 합니다.
점진적 향상 (Progressive Enhancement)
대표적으로 서버액션을 사용함으로써 웹 개발의 중요한 설계 철학 중 하나인 점진적 향상(Progressive Enhancement)을 이룰 수 있습니다.
점진적 향상은 가능한 모든 사용자에게 기본적인 기능을 제공하되 추가적으로 가용한 기능을 점진적으로 늘려가면서 사용자 경험을 향상시키는 것을 의미합니다. 쉽게 말해 오래된 브라우저나 구형 디바이스 등 기능적 제약이 있는 대상에게도 기본적인 기능을 제공하고 현대적인 브라우저를 사용하는 유저에겐 단계적으로 더 향상된 사용자 경험을 제공하는 것입니다. 특히 웹 개발에서 점진적 향상은 HTML → CSS → Javascript 와 같이 각각의 계층 순으로 활성화됨으로써 "점진적"으로 기능을 확장해나가는 방식입니다.
서버 액션의 경우 클라이언트 액션과는 달리 서버 사이드에서 동작하기 때문에 자바스크립트가 비활성화된 상태에서도 동작 가능합니다. 즉 자바스크립트가 비활성화된 상태에서도 유저는 기본적인 기능(폼 제출 등)을 모두 수행 가능하며 하이드레이션(Hydration) 이후 자바스크립트가 점진적으로 활성화됨에 따라 React를 기반으로 한 사용자와의 상호작용(실시간 데이터 검증, 인라인 피드백 등)을 이용할 수 있습니다.
보안 향상
CSRF 보호
서버 액션은 Next.js에서 자동으로 내부 메커니즘을 통해 Cross-Site Request Forgery(CSRF) 보호 기능을 제공합니다.
CSRF(Cross-Site Request Forgery)는 웹 보안 취약점 중 하나로 인증된 사용자가 자신의 의도와 다르게 공격자가 의도한 행동을 수행하도록 만드는 공격 기법입니다.
자동 POST 변환
모든 서버 액션은 URL에 데이터를 노출시키지 않도록 POST 요청으로 자동 변환됩니다.
또한 application/x-www-form-urlencoded
또는 multipart/form-data
Content-Type을 사용하여 데이터를 전송함으로써 CORS 프리플라이트 요청을 강제하고 Cross-Origin 요청을 제한합니다.
Secure Context 격리
서버 환경과 클라이언트 환경을 명확히 분리된다는 장점이 있습니다. 서버 액션은 서버에서만 실행되기 때문에 클라이언트 번들에 포함되지 않으며 API 키, 데이터베이스 자격 증명과 같은 정보를 안전하게 유지할 수 있습니다. 또한 클라이언트에서 전송받은 데이터를 앞서 다룬 Zod 등을 활용하여 서버 사이드에서 데이터를 검증할 수 있습니다.
난독화된 함수 참조
클라이언트에서 서버 액션이 호출될 경우 Next 서버 내부적으로 이를 API화 하여 처리하게 됩니다.
<form action={'/comment'}>
...
</form>
또한 실제로 처리할때는 보안을 위해 실제 함수 이름 대신 해시된 ID를 사용하여 함수를 참조합니다.
<form action={$$ACTION_d3e5a4f9}> // 예시
...
</form>
댓글 0개