Next.js App Router 서버 액션 에러 핸들링: 결과 객체, Zod 검증, useActionState 패턴
예상된 에러는 반환하고 예외는 error.tsx로 — redirect 위치와 인증 확인까지
핵심 요약
- 사용자가 고칠 수 있는 예상된 에러는 throw하지 않고 결과 객체로 반환한다.
- 서버 액션마다 인증·권한을 다시 확인하고, 입력은 Zod 같은 스키마로 서버에서 검증한다.
- redirect()와 notFound()는 내부적으로 에러를 던지므로 try 블록 밖에서 호출한다.
- error.tsx는 렌더링 중 처리되지 않은 예외의 안전망이며 이벤트 핸들러 에러는 잡지 않는다.
Next.js App Router의 서버 액션(Server Actions)은 폼 제출을 API 라우트 없이 서버 함수로 처리하게 해 준다. 그런데 서버 액션 안에서 검증 실패나 DB 오류를 모두 throw로 처리하면, 사용자는 입력 오류 하나 때문에 에러 화면을 보게 된다. Next.js 공식 문서의 Error Handling 페이지(16.3 기준)는 에러를 예상된 에러와 처리되지 않은 예외로 나누고, 예상된 에러는 던지지 말고 반환값으로 모델링하라고 안내한다. 이 글은 그 원칙을 따라 결과 타입 정의, Zod 검증, useActionState 연결, redirect 위치, error.tsx 역할을 순서대로 구성한다. 16.3의 라우팅 변경은 Next.js 16.3 Instant Navigations 글에서 다뤘다.
예상된 에러와 처리되지 않은 예외를 나누는 기준
| 구분 | 예 | 처리 방식 |
|---|---|---|
| 예상된 에러 | 필수 입력 누락, 이메일 형식 오류, 중복 가입, 외부 API의 4xx 응답 | 서버 액션이 결과 객체로 반환하고 폼에서 메시지로 표시 |
| 처리되지 않은 예외 | DB 연결 끊김, 코드 버그, 예상하지 못한 null 참조 | throw해서 가장 가까운 error.tsx 경계가 대체 UI를 표시 |
| 흐름 제어 | 저장 후 상세 페이지로 이동, 대상 없음 | redirect(), notFound()를 try 블록 밖에서 호출 |
이 구분이 서버 액션 코드의 뼈대가 된다. 사용자가 고칠 수 있는 문제는 반환하고, 사용자가 고칠 수 없는 문제는 던진다.
결과 타입을 먼저 정의하기
성공과 실패를 하나의 유니온 타입으로 정의하면 클라이언트에서 분기할 때 타입 검사가 빠진 경우를 잡아 준다. 필드별 메시지를 담는 자리를 따로 두면 입력란 옆에 오류를 표시하기 쉽다.
// app/lib/action-result.ts
export type ActionResult<T = undefined> =
| { ok: true; data: T; message?: string }
| {
ok: false
message: string
fieldErrors?: Record<string, string[] | undefined>
}
export const initialResult: ActionResult = { ok: false, message: '' }Zod로 검증하고 결과 객체로 돌려주는 서버 액션
Next.js Forms 가이드는 서버 쪽 검증에 Zod 같은 스키마 라이브러리를 쓰는 예시를 보여 주고, useActionState와 함께 쓰면 서버 액션의 첫 번째 인자로 이전 상태가 들어온다고 설명한다. 같은 가이드는 폼이 로그인한 사용자에게만 보이더라도 각 서버 액션 안에서 인증과 권한을 다시 확인하라고 경고한다. 서버 액션은 화면을 거치지 않고 직접 호출될 수 있기 때문이다.
// app/actions/create-post.ts
'use server'
import { z } from 'zod'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
import { insertPost } from '@/lib/posts/repository'
import type { ActionResult } from '@/app/lib/action-result'
const schema = z.object({
title: z.string().min(1, '제목을 입력하세요').max(100, '제목은 100자 이하'),
content: z.string().min(10, '본문은 10자 이상 입력하세요'),
})
export async function createPost(
prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const session = await auth()
if (!session?.user) {
return { ok: false, message: '로그인이 필요합니다' }
}
const parsed = schema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
})
if (!parsed.success) {
return {
ok: false,
message: '입력값을 확인하세요',
fieldErrors: parsed.error.flatten().fieldErrors,
}
}
let postId: string
try {
postId = await insertPost(session.user.id, parsed.data)
} catch (e) {
console.error('insertPost failed', e)
return { ok: false, message: '저장 중 문제가 발생했습니다. 잠시 후 다시 시도하세요' }
}
revalidatePath('/posts')
redirect('/posts/' + postId) // try 블록 밖에서 호출
}DB 호출만 try로 감싸고, 실패 시 내부 오류 내용 대신 사용자용 문장을 반환한다. 상세 원인은 서버 로그에만 남긴다. flatten().fieldErrors는 Next.js 가이드 예시에 나온 방식이며, 사용하는 Zod 버전의 문서에서 에러 평탄화 API가 바뀌었는지 함께 확인한다.
useActionState로 폼에 결과 연결하기
useActionState는 [state, formAction, pending]을 돌려준다. state에 서버 액션의 반환값이 들어오고, pending으로 제출 중 버튼을 비활성화한다.
// app/posts/new/post-form.tsx
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions/create-post'
import { initialResult } from '@/app/lib/action-result'
export function PostForm() {
const [state, formAction, pending] = useActionState(createPost, initialResult)
const fieldErrors = !state.ok ? state.fieldErrors : undefined
return (
<form action={formAction}>
<label htmlFor="title">제목</label>
<input id="title" name="title" required />
{fieldErrors?.title && <p className="error">{fieldErrors.title[0]}</p>}
<label htmlFor="content">본문</label>
<textarea id="content" name="content" required />
{fieldErrors?.content && <p className="error">{fieldErrors.content[0]}</p>}
{!state.ok && state.message && <p aria-live="polite">{state.message}</p>}
<button disabled={pending}>{pending ? '저장 중…' : '저장'}</button>
</form>
)
}입력란의 required 같은 HTML 속성은 브라우저 단계의 1차 검증일 뿐이므로, 서버의 Zod 검증을 빼면 안 된다. 메시지 영역에 aria-live="polite"를 두면 화면 낭독기 사용자도 오류를 듣게 된다.
redirect와 notFound를 try 블록 밖에 두는 이유
redirect API 문서에 따르면 redirect()는 내부적으로 NEXT_REDIRECT 에러를 던져 동작하므로, 서버 액션에서는 try 블록 밖에서 호출해야 한다. 안에 두면 catch가 이동 신호를 일반 오류로 삼켜 "저장 중 문제가 발생했습니다"가 표시되고 페이지 이동도 일어나지 않는다. 문서는 서버 액션에서 JavaScript가 동작하면 클라이언트 측 이동을, 자바스크립트 없이 제출된 폼에는 303 응답을 사용한다고 설명한다. notFound()도 같은 방식으로 흐름을 끊으므로 같은 위치 규칙을 따른다.
error.tsx는 처리되지 않은 예외의 마지막 안전망
예상하지 못한 예외는 가장 가까운 상위 error.tsx 경계로 올라간다. 오류 경계는 클라이언트 컴포넌트여야 하고, 16.3 문서의 예시는 다시 시도할 때 retry 함수를 받는다. 이전 버전 문서나 템플릿에는 다른 이름(reset)으로 되어 있을 수 있으니 설치한 버전의 문서를 확인한다.
// app/posts/error.tsx
'use client'
import { useEffect } from 'react'
export default function ErrorPage({
error,
retry,
}: {
error: Error & { digest?: string }
retry: () => void
}) {
useEffect(() => {
console.error(error) // 에러 수집 서비스로 전송
}, [error])
return (
<div>
<h2>글 목록을 불러오지 못했습니다</h2>
<button onClick={() => retry()}>다시 시도</button>
</div>
)
}문서는 오류 경계가 렌더링 중 발생한 에러를 잡기 위한 장치이며 이벤트 핸들러나 비동기 코드의 에러는 잡지 않는다고 설명한다. 반면 useTransition의 startTransition 안에서 처리되지 않은 에러는 가장 가까운 오류 경계로 전달된다. 루트 레이아웃의 오류까지 대비하려면 자체 <html>, <body>를 포함한 global-error.tsx를 둔다.
에러 처리가 의도대로 동작하는지 확인하는 순서
- 제목을 비운 채 제출해 필드 옆에 Zod 메시지가 표시되는지 확인한다(예상된 에러 경로).
- 로그아웃 상태에서 서버 액션을 호출해 "로그인이 필요합니다"가 반환되는지 확인한다(인증 경로).
insertPost가 일시적으로 예외를 던지게 바꿔 사용자용 문장만 보이고 내부 오류가 노출되지 않는지 확인한다.- 정상 제출 시 상세 페이지로 이동하는지 확인해
redirect가catch에 걸리지 않았음을 검증한다. - 목록 페이지의 데이터 함수에서 일부러 예외를 던져
error.tsx의 다시 시도 버튼이 동작하는지 확인한다.
사내 도구처럼 개인정보를 다루는 폼이라면 인증·권한 확인과 오류 메시지 노출 범위를 함께 점검해야 한다. 점검 항목은 바이브 코딩 사내 도구 보안 점검 글에 정리해 두었다.
초보자가 자주 실수하는 포인트
- 검증 실패까지 throw해서 입력 오류 하나에 에러 화면이 뜨는 경우
- redirect()를 try 블록 안에서 호출해 catch가 이동 신호를 삼키는 경우
- 로그인한 사람만 폼을 볼 수 있다는 이유로 서버 액션 안의 인증 확인을 생략하는 경우
- DB 오류 메시지를 그대로 반환해 내부 구조가 노출되는 경우
체크리스트
- 서버 액션의 반환 타입이 성공·실패 유니온으로 정의되어 있는가
- 각 서버 액션 시작 부분에서 인증과 권한을 확인하는가
- 입력값을 서버에서 스키마로 검증하는가
- redirect()와 notFound()가 try 블록 밖에 있는가
- 라우트 세그먼트에 error.tsx가 있고 다시 시도 동작을 확인했는가
자주 묻는 질문
서버 액션에서 throw하면 클라이언트에 어떤 메시지가 보이나요?
처리되지 않은 예외는 가장 가까운 error.tsx 경계로 전달되어 대체 UI가 표시된다. 사용자가 고칠 수 있는 오류라면 throw 대신 결과 객체로 반환해 폼에서 보여 주는 것이 공식 문서의 권장 방식이다.
useFormStatus와 useActionState의 pending은 무엇이 다른가요?
useActionState의 pending은 폼을 정의한 컴포넌트에서 바로 쓸 수 있다. useFormStatus는 폼 안에 들어가는 별도 컴포넌트(예: 제출 버튼)에서 상위 폼의 상태를 읽을 때 쓴다.
클라이언트에서 이미 검증했는데 서버 검증이 또 필요한가요?
필요하다. 서버 액션은 화면을 거치지 않고 직접 호출될 수 있으므로, 클라이언트 검증은 사용성 개선용이고 서버 검증이 실제 방어선이다.
참고 자료 · 검증 기준
- Next.js Docs — Error Handling
- Next.js Docs — How to create forms with Server Actions
- Next.js Docs — redirect
위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.
이 글은 위 참고 자료를 바탕으로 정리했으며, 내용은 운영 과정에서 순차적으로 보완될 수 있습니다.