지난 세션에서 요청 기반 재검증을 배우면서, revalidatePath와 함께 createPost라는 Server Action 코드를 잠깐 봤습니다. 데이터를 변경한 뒤 캐시를 무효화하는 흐름이었는데, 정작 Server Action 자체는 자세히 다루지 않았습니다.
이번 세션에서 본격적으로 다룹니다. 먼저, 기존 React에서 데이터를 변경하려면 어떻게 했는지 비교해 보겠습니다:
// 기존 방식: 서버 API를 만들고, 클라이언트에서 fetch
const handleSubmit = async (data: FormData) => {
await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify({ title: data.get('title') }),
});
};
서버에 API를 별도로 만들고, 클라이언트에서 fetch로 호출해야 했습니다. Next.js의 Server Actions는 이 과정을 훨씬 간단하게 만듭니다.
Next.js 공식 문서와 React 문서는 더 넓은 개념을 Server Function이라고 부르고, 폼 제출이나 데이터 변경처럼 action 맥락에서 사용할 때 흔히 Server Action이라고 부릅니다. 이 세션에서는 폼 예제를 다루므로 편의상 Server Action이라고 부르겠습니다.
Server Action은 'use server' 지시어로 표시된 서버에서 실행되는 함수입니다. Ch.2에서 배운 'use client'의 대칭이라고 생각하면 됩니다.
'use client' → 이 코드는 브라우저에서 실행됩니다'use server' → 이 코드는 서버에서 실행됩니다별도의 API 라우트를 만들 필요 없이, 서버 함수를 클라이언트에서 직접 호출할 수 있습니다. 보통 별도 파일에 정의합니다:
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const slug = formData.get('slug') as string;
// 서버에서 실행 - DB 접근, API 호출 등 가능
await fetch('http://localhost:4000/posts', {
method: 'POST',
body: JSON.stringify({ title, slug }),
headers: { 'Content-Type': 'application/json' },
});
}
이것이 가능한 이유는 'use server' 지시어 때문입니다. 파일 맨 위에 'use server'를 선언하면 Next.js는 이 파일의 export된 함수들을 서버에서 실행되는 함수(Server Function)로 처리하고, 이를 폼 action이나 데이터 변경 흐름에 연결해 사용할 수 있게 합니다.
그래서 클라이언트에서 이 함수를 호출하면 실제로는 브라우저에서 실행되는 것이 아니라, 요청이 서버로 전달되어 서버에서 함수가 실행됩니다. 즉 브라우저에서는 함수 호출처럼 보이지만, 내부적으로는 서버에 요청을 보내고 결과를 받아오는 구조입니다.
CSR 환경에서는 브라우저에서 JavaScript가 실행되기 때문에, API를 호출할 때 사용하는 키도 함께 클라이언트 코드에 포함될 수 있습니다. 무료 API라면 큰 문제가 없을 수도 있지만, 요청할 때마다 비용이 발생하는 유료 API라면 상황이 달라집니다. 배포된 사이트에 접속한 사용자는 개발자 도구를 통해 네트워크 요청이나 번들 코드를 확인할 수 있기 때문에, API 키가 노출되면 누구나 그 키를 사용할 수 있습니다. 이렇게 되면 의도하지 않은 사용량이 발생해 비용이 크게 늘어날 수 있습니다.
예를 들어, ChatGPT와 같은 AI 서비스를 만들기 위해 OpenAI API를 사용해 글을 생성하는 기능을 만든다고 가정해 보겠습니다. 사용자가 버튼을 누르면 API를 호출해 답변을 받아오는 기능입니다. 만약 이 API 키가 브라우저 코드 안에 들어 있다면, 다른 사람이 개발자 도구에서 그 키를 확인하고 마음대로 요청을 보낼 수도 있습니다. 이 경우 실제 비용은 API 키를 발급한 계정에 청구됩니다.
Server Action은 이런 문제를 해결하는 방법 중 하나입니다. Server Action은 브라우저가 아니라 서버에서 실행되는 함수이기 때문에, API 키를 process.env.OPENAI_API_KEY 같은 환경 변수에 안전하게 저장해 두고 사용할 수 있습니다. 이 값은 서버 내부에서만 사용되고 브라우저로 전달되지 않습니다. 클라이언트에는 API 키가 아니라 서버에서 실행된 결과만 전달되기 때문에, 민감한 정보를 노출하지 않고도 외부 API를 사용할 수 있습니다.
Server Action은 HTML <form>의 action 속성에 바로 전달할 수 있습니다:
// app/blog/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPost() {
return (
<div>
<h1>새 글 작성</h1>
<form action={createPost}>
<div>
<label htmlFor="title">제목</label>
<input id="title" name="title" placeholder="제목을 입력하세요" />
</div>
<div>
<label htmlFor="slug">Slug</label>
<input id="slug" name="slug" placeholder="slug를 입력하세요" />
</div>
<button type="submit">작성하기</button>
</form>
</div>
);
}
폼이 제출되면:
FormData를 서버로 전송createPost 함수 실행 (데이터 저장)별도의 API 라우트도, 클라이언트 fetch 코드도, onSubmit 핸들러도 필요 없습니다. 이 페이지는 서버 컴포넌트이므로 'use client' 선언도 없습니다.
<form action>뿐 아니라 이벤트 핸들러에서도 호출할 수 있습니다. 하지만 폼과 함께 사용할 때 가장 자연스럽고, JavaScript가 비활성화된 환경에서도 기본 폼 동작으로 작동합니다.
폼 제출 중 로딩 표시를 하거나, 서버에서 에러 메시지를 반환하고 싶다면 어떻게 해야 할까요? useActionState Hook이 이 문제를 해결합니다.
위의 createPost를 수정하여 성공/실패 상태를 반환하도록 만듭니다:
// app/actions.ts
'use server';
export async function createPost(
prevState: { error: string | null },
formData: FormData
) {
const title = formData.get('title') as string;
const slug = formData.get('slug') as string;
if (title.length < 2) {
return { error: '제목은 2글자 이상이어야 합니다.' };
}
if (!/^[a-z0-9-]+$/.test(slug)) {
return { error: 'slug는 영문 소문자, 숫자, 하이픈(-)만 사용할 수 있습니다.' };
}
await fetch('http://localhost:4000/posts', {
method: 'POST',
body: JSON.stringify({ title, slug }),
headers: { 'Content-Type': 'application/json' },
});
}
그리고 클라이언트 컴포넌트에서 useActionState를 사용합니다:
// app/components/PostForm.tsx
'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions';
export default function PostForm() {
const [state, formAction, isPending] = useActionState<
{ error: string | null },
FormData
>(createPost, { error: null });
return (
<form action={formAction}>
<div>
<label htmlFor="title">제목</label>
<input id="title" name="title" placeholder="제목을 입력하세요" />
</div>
<div>
<label htmlFor="slug">Slug</label>
<input id="slug" name="slug" placeholder="slug를 입력하세요" />
</div>
{state.error && (
<p style={{ color: 'red' }}>{state.error}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? '작성 중...' : '작성하기'}
</button>
</form>
);
}
useActionState는 세 가지 값을 반환합니다:
state - Server Action이 반환한 상태 (에러 메시지 등)formAction - <form action>에 전달할 함수isPending - 제출 진행 중 여부 (로딩 표시에 활용)useActionState는 React Hook이므로 'use client' 컴포넌트에서만 사용할 수 있습니다. 폼 UI를 별도 클라이언트 컴포넌트로 분리하고, 페이지에서 import하는 패턴이 일반적입니다.
다음 세션에서는 지금까지 배운 개념들을 종합하여 Ch.2의 블로그 프로젝트에 실제 데이터 연동을 적용하는 실습을 진행합니다.