React 서버 액션(Server Actions)은 서버에서 실행되는 비동기 서버 함수(Server Functions)입니다. 이 함수들은 폼 제출(form submissions)을 처리하기 위해 서버 컴포넌트(Server Components)와 클라이언트 컴포넌트(Client Components) 양쪽에서 모두 호출될 수 있어요. 이 가이드에서는 Next.js에서 서버 액션을 활용해 어떻게 폼을 만드는지 처음부터 끝까지 자세히 안내해 드릴게요.
React는 기존 HTML의 <form> 요소를 확장해서, action 속성에 서버 액션을 직접 연결(invoke)할 수 있게 만들었어요.
폼에서 이 서버 액션 함수를 사용하게 되면, 함수는 자동으로 FormData 객체를 인자로 받게 됩니다. 그러면 우리는 자바스크립트의 기본 FormData 메서드들을 사용해서 폼에 입력된 데이터를 아주 쉽게 꺼내 쓸 수 있어요.
//filename="app/invoices/page.tsx" switcher
export default function Page() {
async function createInvoice(formData: FormData) {
'use server'
const rawFormData = {
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
}
// 데이터 변경(mutate) 로직
// 캐시 재검증(revalidate) 로직
}
return <form action={createInvoice}>...</form>
}
//filename="app/invoices/page.js" switcher
export default function Page() {
async function createInvoice(formData) {
'use server'
const rawFormData = {
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
}
// 데이터 변경(mutate) 로직
// 캐시 재검증(revalidate) 로직
}
return <form action={createInvoice}>...</form>
}
알아두면 좋은 정보 (Good to know): 여러 개의 입력 필드를 가진 폼을 다룰 때는, 하나하나
.get()으로 가져오는 것보다 자바스크립트의Object.fromEntries()를 사용하는 것이 훨씬 편합니다. 예를 들어,const rawFormData = Object.fromEntries(formData)처럼 쓰시면 돼요. 다만, 이렇게 만들어진 객체에는$ACTION_으로 시작하는 추가 속성(Next.js 내부 처리를 위한 값들)이 포함될 수 있다는 점을 기억해두세요.
폼 필드에 있는 데이터 외에도, 서버 함수에 추가적인 인자(argument)를 전달해야 할 때가 꼭 생깁니다. 이럴 때는 자바스크립트의 bind 메서드를 사용하시면 돼요.
예를 들어, updateUser라는 서버 함수에 특정 유저의 ID(userId)를 넘겨주고 싶다면 아래처럼 할 수 있습니다.
//filename="app/client-component.tsx" highlight={6} switcher
'use client'
import { updateUser } from './actions'
export function UserProfile({ userId }: { userId: string }) {
const updateUserWithId = updateUser.bind(null, userId)
return (
<form action={updateUserWithId}>
<input type="text" name="name" />
<button type="submit">Update User Name</button>
</form>
)
}
//filename="app/client-component.js" highlight={6} switcher
'use client'
import { updateUser } from './actions'
export function UserProfile({ userId }) {
const updateUserWithId = updateUser.bind(null, userId)
return (
<form action={updateUserWithId}>
<input type="text" name="name" />
<button type="submit">Update User Name</button>
</form>
)
}
이렇게 바인딩을 해주면, 서버 함수는 userId를 첫 번째 인자로, 그리고 폼의 데이터를 담은 FormData를 그 다음 인자로 받게 됩니다:
//filename="app/actions.ts" switcher
'use server'
export async function updateUser(userId: string, formData: FormData) {}
//filename="app/actions.js" switcher
'use server'
export async function updateUser(userId, formData) {}
💡 강사의 팁: .bind()가 조금 낯설게 느껴지실 수 있어요. 간단하게 말해서 "원래 있던 함수에 미리 인자 하나를 고정시켜서 새로운 함수를 만들어내는 과정"이라고 이해하시면 됩니다. 첫 번째 인자가 null인 이유는 this 컨텍스트를 사용하지 않기 때문이에요.
알아두면 좋은 정보 (Good to know):
- 또 다른 대안으로는 폼 안에 숨겨진 인풋 필드(hidden input field)를 사용해서 인자를 전달하는 방법이 있습니다 (예:
<input type="hidden" name="userId" value={userId} />). 하지만 이 방식은 렌더링된 HTML에 값이 그대로 노출되고 인코딩되지 않기 때문에 보안에 민감한 데이터라면 주의해야 합니다.bind방식은 서버 컴포넌트와 클라이언트 컴포넌트 양쪽에서 모두 잘 작동하며, 자바스크립트가 로드되기 전에도 기능이 동작하는 점진적 향상(progressive enhancement)을 지원합니다. (숨겨진 인풋 필드보다bind를 적극 추천드려요!)
폼 데이터는 클라이언트(브라우저)와 서버 양쪽에서 모두 유효성 검사(Validation)를 할 수 있습니다.
required나 type="email" 같은 HTML 기본 속성을 사용해서 간단하게 검증할 수 있어요.예를 들어볼까요?
//filename="app/actions.ts" switcher
'use server'
import { z } from 'zod'
const schema = z.object({
email: z.string({
invalid_type_error: 'Invalid Email',
}),
})
export default async function createUser(formData: FormData) {
const validatedFields = schema.safeParse({
email: formData.get('email'),
})
// 폼 데이터가 유효하지 않으면 일찍 반환(Return early)합니다.
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
// 이후 데이터베이스에 저장하는 등의 mutate 로직 실행
}
//filename="app/actions.js" switcher
'use server'
import { z } from 'zod'
const schema = z.object({
email: z.string({
invalid_type_error: 'Invalid Email',
}),
})
export default async function createsUser(formData) {
const validatedFields = schema.safeParse({
email: formData.get('email'),
})
// 폼 데이터가 유효하지 않으면 일찍 반환(Return early)합니다.
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
// 이후 데이터베이스에 저장하는 등의 mutate 로직 실행
}
💡 강사의 팁: "프론트엔드에서 이미 검사했는데 굳이 서버에서 또 해야 하나요?"라고 많이들 물어보십니다. 정답은 "무조건 양쪽 다 해야 합니다!"입니다. 악의적인 사용자는 브라우저의 클라이언트 유효성 검사를 쉽게 우회할 수 있어요. Zod를 활용한 서버 측 유효성 검사는 내 집의 현관문을 잠그는 것과 같이 필수적인 보안 조치입니다.
서버에서 유효성 검사에 실패했을 때, 그 에러 메시지를 화면에 보여주려면 어떻게 해야 할까요? <form>을 정의하는 컴포넌트를 클라이언트 컴포넌트(Client Component)로 바꾸고, React의 useActionState 훅(Hook)을 사용해야 합니다.
useActionState를 사용하게 되면, 서버 함수의 형태(signature)가 조금 바뀝니다. 함수의 첫 번째 인자로 이전 상태(prevState) 또는 초기 상태(initialState)를 받게 되거든요.
//filename="app/actions.ts" highlight={5} switcher
'use server'
import { z } from 'zod'
export async function createUser(initialState: any, formData: FormData) {
const validatedFields = schema.safeParse({
email: formData.get('email'),
})
// ...
}
//filename="app/actions.ts" highlight={7} switcher
'use server'
import { z } from 'zod'
// ...
export async function createUser(initialState, formData) {
const validatedFields = schema.safeParse({
email: formData.get('email'),
})
// ...
}
이제 클라이언트 쪽에서는 state 객체를 바탕으로 조건부 렌더링을 통해 에러 메시지를 보여줄 수 있습니다.
//filename="app/ui/signup.tsx" highlight={11,18-20} switcher
'use client'
import { useActionState } from 'react'
import { createUser } from '@/app/actions'
const initialState = {
message: '',
}
export function Signup() {
const [state, formAction, pending] = useActionState(createUser, initialState)
return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input type="text" id="email" name="email" required />
{/* ... */}
<p aria-live="polite">{state?.message}</p>
<button disabled={pending}>Sign up</button>
</form>
)
}
//filename="app/ui/signup.js" highlight={11,18-20} switcher
'use client'
import { useActionState } from 'react'
import { createUser } from '@/app/actions'
const initialState = {
message: '',
}
export function Signup() {
const [state, formAction, pending] = useActionState(createUser, initialState)
return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input type="text" id="email" name="email" required />
{/* ... */}
<p aria-live="polite">{state?.message}</p>
<button disabled={pending}>Sign up</button>
</form>
)
}
사용자가 폼을 제출하고 서버에서 처리가 끝날 때까지 무언가 로딩 중이라는 걸 보여주는 건 아주 중요한 사용자 경험(UX)입니다. useActionState 훅은 pending이라는 boolean(참/거짓) 값을 제공하는데요, 액션이 실행되는 동안 로딩 인디케이터(스피너 등)를 보여주거나 제출 버튼을 비활성화(disable)하는 데 아주 유용하게 쓰입니다.
//filename="app/ui/signup.tsx" highlight={7,12} switcher
'use client'
import { useActionState } from 'react'
import { createUser } from '@/app/actions'
export function Signup() {
const [state, formAction, pending] = useActionState(createUser, initialState)
return (
<form action={formAction}>
{/* 다른 폼 요소들 */}
<button disabled={pending}>Sign up</button>
</form>
)
}
//filename="app/ui/signup.js" highlight={7,12} switcher
'use client'
import { useActionState } from 'react'
import { createUser } from '@/app/actions'
export function Signup() {
const [state, formAction, pending] = useActionState(createUser, initialState)
return (
<form action={formAction}>
{/* 다른 폼 요소들 */}
<button disabled={pending}>Sign up</button>
</form>
)
}
또 다른 방법으로는 useFormStatus 훅을 사용해서 로딩 상태를 보여주는 방법이 있어요. 이 훅을 사용할 때는 로딩 인디케이터를 렌더링하기 위한 별도의 자식 컴포넌트를 만들어야 합니다.
예를 들어, 폼이 제출되는 동안 버튼을 비활성화하고 싶다면 다음과 같이 버튼 컴포넌트를 따로 만듭니다:
//filename="app/ui/button.tsx" highlight={6} switcher
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button disabled={pending} type="submit">
Sign Up
</button>
)
}
//filename="app/ui/button.js" highlight={6} switcher
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button disabled={pending} type="submit">
Sign Up
</button>
)
}
그런 다음 이 SubmitButton 컴포넌트를 폼 내부에 중첩해서 사용하면 됩니다:
//filename="app/ui/signup.tsx" switcher
import { SubmitButton } from './button'
import { createUser } from '@/app/actions'
export function Signup() {
return (
<form action={createUser}>
{/* 다른 폼 요소들 */}
<SubmitButton />
</form>
)
}
//filename="app/ui/signup.js" switcher
import { SubmitButton } from './button'
import { createUser } from '@/app/actions'
export function Signup() {
return (
<form action={createUser}>
{/* 다른 폼 요소들 */}
<SubmitButton />
</form>
)
}
💡 강사의 팁: useActionState의 pending과 useFormStatus의 차이가 헷갈리시죠?
폼 컴포넌트 전체를 클라이언트 컴포넌트로 만들고 상태를 직접 관리할 거면 useActionState가 편해요. 반면, 폼 자체는 서버 컴포넌트로 두고 내부의 '제출 버튼'만 클라이언트 컴포넌트로 분리해서 상태를 알고 싶을 때는 useFormStatus를 쓰는 것이 아키텍처 상 훨씬 깔끔합니다. useFormStatus는 반드시 <form>의 자식 컴포넌트 안에서 호출해야 한다는 점, 절대 잊지 마세요!
알아두면 좋은 정보 (Good to know): React 19 환경에서
useFormStatus는 단순히pending상태뿐만 아니라data,method,action같은 추가적인 속성들도 객체 형태로 반환해 줍니다. 만약 React 19를 사용하지 않으신다면pending속성만 사용할 수 있습니다.
사용자 경험(UX)을 극대화하려면 React의 useOptimistic 훅을 사용해 보세요. 서버 함수가 실행을 완료하기를 기다리지 않고, 서버 요청이 성공할 것이라고 "낙관적으로(optimistically)" 가정하여 UI를 즉시 업데이트해주는 마법 같은 기능입니다.
채팅 메시지를 보내는 상황을 예로 들어볼게요:
//filename="app/page.tsx" switcher
'use client'
import { useOptimistic } from 'react'
import { send } from './actions'
type Message = {
message: string
}
export function Thread({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic<
Message[],
string
>(messages, (state, newMessage) => [...state, { message: newMessage }])
const formAction = async (formData: FormData) => {
const message = formData.get('message') as string
addOptimisticMessage(message)
await send(message)
}
return (
<div>
{optimisticMessages.map((m, i) => (
<div key={i}>{m.message}</div>
))}
<form action={formAction}>
<input type="text" name="message" />
<button type="submit">Send</button>
</form>
</div>
)
}
//filename="app/page.js" switcher
'use client'
import { useOptimistic } from 'react'
import { send } from './actions'
export function Thread({ messages }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(state, newMessage) => [...state, { message: newMessage }]
)
const formAction = async (formData) => {
const message = formData.get('message')
addOptimisticMessage(message)
await send(message)
}
return (
<div>
{optimisticMessages.map((m) => (
<div>{m.message}</div>
))}
<form action={formAction}>
<input type="text" name="message" />
<button type="submit">Send</button>
</form>
</div>
)
}
💡 강사의 팁: 카카오톡이나 인스타그램 좋아요 버튼을 상상해보세요! 누르자마자 서버 응답을 기다리지 않고 화면에 바로 반영되죠? useOptimistic이 바로 그걸 가능하게 해줍니다. 만약 서버 처리가 실패한다면? React가 알아서 원래 상태로 롤백(rollback)해주는 기특한 기능까지 내장되어 있어요. 유저들이 여러분의 웹앱을 "엄청 빠르다!"라고 느끼게 만들고 싶다면 적극 도입해 보세요.
서버 액션은 <form> 태그에만 붙일 수 있는 게 아니에요. <form> 내부에 중첩된 <button>, <input type="submit">, <input type="image"> 같은 요소들 안에서도 직접 서버 액션을 호출할 수 있습니다. 이런 요소들은 formAction prop(속성)이나 이벤트 핸들러를 허용하거든요.
이 방식은 하나의 폼 안에서 여러 개의 각기 다른 서버 액션을 호출해야 할 때 아주 유용합니다. 예를 들어, 블로그 글쓰기 폼에서 "출판하기(Publish)" 버튼 외에 "임시저장(Save Draft)"을 위한 특별한 <button>을 따로 만들 수 있죠. 더 자세한 정보는 React <form> 공식 문서를 참고해주세요.
버튼을 누르지 않고 코드로 직접 폼 제출을 트리거하고 싶을 때는 requestSubmit() 메서드를 사용할 수 있습니다.
예를 들어, 사용자가 Cmd + Enter (또는 Ctrl + Enter) 단축키를 눌렀을 때 폼이 제출되게 하려면 onKeyDown 이벤트를 감지(listen)해서 처리하면 됩니다:
//filename="app/entry.tsx" switcher
'use client'
export function Entry() {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (
(e.ctrlKey || e.metaKey) &&
(e.key === 'Enter' || e.key === 'NumpadEnter')
) {
e.preventDefault()
e.currentTarget.form?.requestSubmit()
}
}
return (
<div>
<textarea name="entry" rows={20} required onKeyDown={handleKeyDown} />
</div>
)
}
//filename="app/entry.js" switcher
'use client'
export function Entry() {
const handleKeyDown = (e) => {
if (
(e.ctrlKey || e.metaKey) &&
(e.key === 'Enter' || e.key === 'NumpadEnter')
) {
e.preventDefault()
e.currentTarget.form?.requestSubmit()
}
}
return (
<div>
<textarea name="entry" rows={20} required onKeyDown={handleKeyDown} />
</div>
)
}
이렇게 하면, 이벤트가 발생한 요소와 가장 가까운 상위(ancestor) <form>의 제출 작업이 트리거되고, 결국 연결된 서버 함수가 호출됩니다.
💡 강사의 팁: 자바스크립트에 익숙하신 분들은 "어? 그냥 .submit() 쓰면 안 되나?" 하실 수 있습니다. 하지만 .submit()은 HTML5 유효성 검사(HTML validation)를 건너뛰고 submit 이벤트 자체를 발생시키지 않는 문제가 있어요. 반면 .requestSubmit()은 사용자가 진짜 제출 버튼을 누른 것처럼 유효성 검사와 이벤트를 모두 정상적으로 거치기 때문에, 리액트 및 Next.js 환경에서는 반드시 .requestSubmit()을 사용하셔야 에러 없이 안전하게 동작합니다!