서버 액션
use server로 데이터 변경 함수를 선언하고 폼 제출·캐시 재검증·리다이렉트를 서버에서 처리하는 흐름을 익힙니다.
이전 글에서 클라이언트 컴포넌트의 기본적인 상태 관리 방법을 다루었습니다. 전통적인 웹 개발에서는 클라이언트가 Route Handler 같은 HTTP 엔드포인트를 호출해 서버의 상태를 바꿨습니다. Next.js 16의 App Router에서는 폼 제출과 데이터 변경 로직을 서버 함수로 연결하는 서버 액션(Server Actions)을 쓸 수 있으며, 서버 액션은 클라이언트에서 서버 함수를 호출해 데이터를 바꾸는 흐름을 단순하게 만듭니다. 데이터를 바꾼 뒤에는 revalidatePath, revalidateTag(tag, 'max'), updateTag, redirect 같은 API를 필요한 시점에 명시적으로 호출합니다. 이 글에서는 서버 액션의 개념과 작동 방식, 그리고 폼 처리에 어떻게 쓰는지 알아봅니다.
서버 액션이란 무엇인가요?
서버 액션은 데이터 변경 등을 위해 서버에서 실행하는 비동기 함수입니다. 서버 컴포넌트가 서버 함수를 form의 action에 직접 연결하면 JavaScript가 로드되기 전에도 제출할 수 있지만, onClick이나 클라이언트의 action 콜백에서 부르는 경우에는 브라우저 JavaScript가 필요합니다. 이는 React 서버 컴포넌트와 Next.js App Router가 깊이 통합되어 가능해진 기능입니다.
서버 액션의 본문은 클라이언트 번들에 들어가지 않고 서버에 남습니다. 다만 그 함수를 호출하는 클라이언트 UI의 JavaScript까지 없어지는 것은 아닙니다. 예전에는 폼 제출용 Route Handler와 fetch, 응답 처리를 따로 만들었지만, 서버 액션은 이 흐름을 함수 호출 하나로 연결합니다.
데이터베이스에 접근하는 코드도 클라이언트 번들에 들어가지 않습니다. 하지만 서버 액션은 외부 요청으로 호출할 수 있는 공개 HTTP 진입점이므로, 입력 검증, 인증, 권한 확인을 반드시 해야 합니다. 데이터를 바꾼 뒤에는 경로를 다시 검증하는 revalidatePath, stale-while-revalidate 방식의 revalidateTag(tag, 'max'), 곧바로 만료하는 updateTag, 다른 경로로 보내는 redirect를 목적에 맞게 호출합니다. HTML form 요소의 action 속성이나 버튼의 formAction 속성에 서버 액션을 직접 연결할 수 있어 폼 제출 로직도 간결해집니다.
서버 액션의 작동 방식
서버 액션은 원격 프로시저 호출(Remote Procedure Call, RPC)처럼 동작합니다. 폼을 제출하거나 버튼을 누를 때 클라이언트 컴포넌트에서 서버 액션을 호출하면, Next.js는 이 호출을 가로채 서버 액션을 위한 특별한 네트워크 요청을 만들어 서버로 보냅니다. 서버는 요청을 받아 해당 서버 액션 함수를 실행하며, 이 함수는 데이터베이스 작업이나 파일 시스템 접근 같은 서버 쪽 로직을 수행할 수 있습니다. 실행이 끝나면 그 결과인 반환값이 클라이언트로 다시 전송되고, 클라이언트는 반환값을 받아 UI를 갱신합니다.
필요한 캐시 갱신은 서버 액션이 명시적으로 요청합니다. 이 예제의 revalidatePath와, 태그를 오래된(stale) 상태로 표시해 다음 읽기에서 오래된 값과 백그라운드 갱신을 허용하는 revalidateTag(tag, 'max')는 갱신되는 시점이 다릅니다. 이 차이는 revalidateTag의 재검증 동작에서 확인할 수 있습니다.
이 과정은 Next.js가 내부에서 처리하므로, 개발자는 서버에서 실행할 함수를 정의하고 클라이언트에서 호출하기만 하면 됩니다. 호출을 연결하는 전송은 프레임워크가 맡지만, 요청이 업무 규칙을 만족하는지와 실제 데이터 변경은 개발자가 정합니다. 타입 선언도 네트워크로 받은 값의 런타임 검증을 대신하지 않습니다. 함수를 브라우저에서 호출할 때는 인수와 반환값이 React가 지원하는 직렬화 가능한 값이어야 하며, 아래 결과 객체의 불리언과 문자열은 그 범위에 들어갑니다.
더 깊이: 서버 액션 요청이 실제로 오가는 방식
빌드할 때 'use server' 지시어를 만난 컴파일러는 클라이언트 번들 안의 함수 구현을 참조로 바꿉니다. 이 참조는 액션 ID와, 그 ID로 서버에 POST 요청을 보내는 전송 코드로 이루어져 있습니다. 서버 액션은 그 액션을 쓰는 페이지 경로로 보내는 POST 요청이며, 서버 액션을 실행할 수 있는 HTTP 메서드는 POST뿐입니다. 액션 ID는 추측하기 어렵게 암호화된 값으로, 새로 배포하면 보통 새 ID가 만들어지고 원본이 그대로여도 최대 14일이 지나면 바뀝니다. 그래서 배포 직전의 화면을 열어 둔 사용자가 이전 빌드의 ID로 요청하면 "Failed to find Server Action" 오류가 날 수 있고, 어디에서도 쓰지 않는 액션은 빌드에서 클라이언트 번들로부터 빠져 공개된 진입점이 생기지 않습니다.
응답 하나에 결과와 화면이 함께 실릴 수도 있습니다. 액션이 revalidatePath나 updateTag, refresh를 부르거나 쿠키를 바꾸거나 redirect를 부르면, Next.js는 같은 요청 안에서 현재 라우트를 서버에서 다시 렌더링해 액션의 반환값과 새 RSC Payload를 한 응답으로 돌려줍니다. 그래서 화면을 갱신하려고 클라이언트가 다시 요청할 필요가 없습니다. 반면 revalidateTag(tag, 'max')처럼 stale-while-revalidate 방식으로 재검증하면 이 즉시 렌더링이 응답에 들어가지 않고, 바뀐 내용은 나중의 읽기에서 반영됩니다(Next.js의 서버 액션 안내).
클라이언트는 한 브라우저 안에서 서버 액션을 한 번에 하나씩 보냅니다. 사용자가 액션 세 개를 연달아 일으키면 둘째는 첫째가 끝날 때까지, 셋째는 둘째가 끝날 때까지 기다리므로, 클라이언트에서 Promise.all로 서버 액션을 병렬로 실행할 수는 없습니다. 병렬 작업이 필요하다면 하나의 서버 액션 안에서 병렬로 처리합니다. 프레임워크가 기본으로 더하는 보호 장치도 있습니다. 요청의 Origin과 Host(또는 X-Forwarded-Host) 헤더가 다르면 요청을 거부하고, 요청 본문은 기본 1MB로 제한하며(serverActions.bodySizeLimit), 인라인 액션이 캡처한 변수는 암호화한 뒤 클라이언트로 보냅니다.
서버 액션 사용법: use server 지시어
서버 액션을 정의하는 방법은 간단합니다. "use server" 지시어를 함수 본문의 맨 위에 추가하거나, 별도 파일의 맨 위에 추가해 그 파일에서 내보내는 모든 함수를 서버 액션으로 만듭니다.
파일 단위로 선언하기
"use server" 지시어를 파일 맨 위에 추가하면, 그 파일에서 내보내는 모든 함수가 서버 액션이 됩니다. 다음 예제의 배열은 서버 액션의 입력, 검증, 재검증 흐름만 확인하는 로컬 단일 프로세스 데모입니다. 모듈 메모리는 프로세스를 다시 시작하거나 개발 서버가 다시 로드될 때, 서버리스의 콜드 스타트나 여러 인스턴스 사이에서 공유되거나 보존되지 않습니다. 운영 코드나 지속성을 확인하는 실습에서는 같은 함수 안의 items 접근을 데이터베이스 저장과 조회로 바꾸어야 합니다. 뒤의 force-dynamic 예제는 Cache Components를 켜지 않은 구성을 전제로 합니다.
같은 App Router 프로젝트에 itemActions.ts를 먼저 만들고, 그 파일을 가져오는 ClientForm.tsx와 page.tsx를 준비합니다. 페이지의 getItems는 서버 렌더링에서 실행하는 조회이고, createItemWithResult는 브라우저 폼이 요청하는 변경 함수입니다. createItem은 다음 글에서 form에 직접 연결할 때, createItemWithResult는 이 글에서 결과를 받는 폼에 씁니다. 이름이 비슷해도 반환값을 쓰는 방법이 다릅니다.
이 데모는 인증과 권한 확인을 구현하지 않았습니다. 실제 사용자용 액션에서는 클라이언트가 보낸 값을 믿지 말고 서버에서 사용자와 허용된 작업을 검증해야 하며, 메모리 배열을 DB로 옮기는 것만으로 그 확인이나 중복 생성 방지가 더해지지는 않습니다. 버튼을 화면에서 숨겼더라도 서버는 작업 권한을 다시 확인해야 합니다. 예를 들어 사용자 A가 로그인했더라도 사용자 B의 아이템 ID를 삭제 요청으로 보낼 수 있습니다. 서버가 검증한 세션에서 A의 사용자 ID를 얻고, 삭제할 대상이 A에게 허용된 아이템인지 확인한 뒤 변경해야 합니다. 가능하면 저장소의 변경 조건에도 itemId와 허용된 소유자 ID를 함께 넣어, 확인한 대상과 실제로 바뀌는 대상이 달라지지 않게 합니다. 폼의 숨은 ownerId, 클라이언트의 prop, 입력한 ID 자체는 사용자의 신원이나 소유권을 확인해 주지 않습니다.
이 원칙은 데이터를 읽어 돌려주는 함수에도 적용됩니다. 이 데모의 getItems()는 공용 목록이지만, 실제 개인 목록이라면 서버가 확인한 사용자에게 허용된 데이터만 조회해 돌려주어야 합니다. 페이지에서 await getItems()를 직접 실행하는 조회와, 브라우저가 서버 액션을 호출하는 네트워크 요청은 구분해야 합니다. "use server"는 구현이 실행될 위치를 정할 뿐 인증을 자동으로 더하지 않으며, 액션 안에서 무엇을 검증해야 하는지는 공식 데이터 보안 안내에서 설명합니다.
"use server"; // 이 파일의 모든 내보내기 함수는 서버 액션입니다.
import { revalidatePath } from 'next/cache'; // Next.js 캐시 재검증 유틸리티
interface Item {
id: string;
name: string;
}
// 로컬 단일 프로세스 흐름 확인용입니다. 운영에서는 데이터베이스를 사용합니다.
const items: Item[] = [{ id: '1', name: '초기 아이템' }];
let nextId = 2;
interface ItemActionResult {
success: boolean;
message: string;
}
async function createItemRecord(formData: FormData): Promise<ItemActionResult> {
const rawName = formData.get('itemName');
const name = typeof rawName === 'string' ? rawName.trim() : '';
if (!name) {
return { success: false, message: '아이템 이름이 필요합니다.' };
}
const newItem: Item = { id: (nextId++).toString(), name };
items.push(newItem);
console.log(`서버: 아이템 생성됨 - ${name}, 현재 아이템 수: ${items.length}`);
// 현재 프로세스의 데모 데이터를 읽는 두 예제 화면의 캐시를 무효화
revalidatePath('/server-action');
revalidatePath('/form-submit');
return { success: true, message: `아이템 '${name}'이(가) 성공적으로 추가되었습니다.` };
}
export async function createItem(formData: FormData): Promise<void> {
await createItemRecord(formData);
}
export async function createItemWithResult(formData: FormData): Promise<ItemActionResult> {
return createItemRecord(formData);
}
export async function getItems(): Promise<Item[]> {
console.log('서버: 모든 아이템 조회 중...');
// 실제 DB에서 조회하는 것을 시뮬레이션
await new Promise(resolve => setTimeout(resolve, 500));
return items;
}
export async function deleteItem(itemId: string) {
const index = items.findIndex(item => item.id === itemId);
if (index > -1) {
items.splice(index, 1);
console.log(`서버: 아이템 ${itemId} 삭제됨. 현재 아이템 수: ${items.length}`);
revalidatePath('/server-action');
revalidatePath('/form-submit');
return { success: true, message: `아이템 ${itemId}이(가) 삭제되었습니다.` };
}
console.error(`아이템 ${itemId}를 찾을 수 없습니다.`);
return { success: false, message: `아이템 ${itemId}를 찾을 수 없습니다.` };
}파일 안의 createItemRecord는 내보내지 않았으므로, 브라우저가 부를 수 있는 서버 액션이 아니라 이 파일 안에서만 쓰는 도우미입니다. 내보낸 createItem과 createItemWithResult는 같은 도우미를 부르되, 앞의 것은 결과를 버리고 뒤의 것은 결과 객체를 돌려줍니다. 생성과 삭제는 성공했을 때 revalidatePath로 두 예제 화면의 캐시를 무효화해, 다음 렌더링에서 바뀐 목록이 보이게 합니다.
함수 단위로 선언하기 (Action 클로저)
특정 함수만 서버 액션으로 만들고 싶다면 함수 본문 안의 맨 위에 "use server"를 선언할 수 있습니다. 이 선언은 서버 컴포넌트 안에서 쓸 수 있습니다. 클라이언트 컴포넌트 안에는 인라인 "use server" 함수를 정의할 수 없으며, 맨 위에 "use server"를 둔 모듈에서 함수를 가져오거나 서버가 넘겨준 함수 참조를 씁니다. 자세한 규칙은 공식 use server 규칙을 따릅니다. 다음 페이지는 목록을 조회해 표시하고, 서버 액션을 클라이언트 폼에 넘기며, 함수 단위 서버 액션의 모양도 함께 보여 줍니다.
// 이 페이지 컴포넌트 자체는 서버 컴포넌트입니다.
import { createItemWithResult, getItems, deleteItem } from '../actions/itemActions'; // 서버 액션 임포트
import ClientForm from './ClientForm'; // 클라이언트 컴포넌트 임포트
export const dynamic = 'force-dynamic'; // 요청마다 동적으로 렌더링하도록 지정 (선택 사항)
export default async function ServerActionPage() {
const currentItems = await getItems(); // 서버 렌더링 중의 직접 함수 호출 (force-dynamic이므로 요청마다 실행)
// 함수 단위 서버 액션 예시 (이 페이지에서는 직접 사용하지 않음)
async function privateServerAction() {
"use server"; // 이 함수만 서버 액션이 됨
console.log('Private server action executed!');
return { success: true };
}
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', border: '1px solid #007bff', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)' }}>
<h1 style={{ textAlign: 'center', color: '#007bff' }}>서버 액션 예제</h1>
<p style={{ textAlign: 'center', marginBottom: '30px', color: '#666' }}>
클라이언트에서 서버 함수를 직접 호출하여 데이터를 처리하고 UI를 업데이트합니다.
</p>
<h2 style={{ color: '#333', marginBottom: '15px' }}>아이템 목록 (서버에서 가져옴)</h2>
{currentItems.length > 0 ? (
<ul style={{ listStyleType: 'decimal', paddingLeft: '20px' }}>
{currentItems.map(item => (
<li key={item.id} style={{ marginBottom: '8px' }}>
{item.name} (ID: {item.id})
</li>
))}
</ul>
) : (
<p>현재 아이템이 없습니다.</p>
)}
<hr style={{ margin: '30px 0', borderColor: '#eee' }} />
<h2 style={{ color: '#333', marginBottom: '15px' }}>새 아이템 추가 (클라이언트 폼에서 서버 액션 호출)</h2>
<ClientForm createItemAction={createItemWithResult} deleteItemAction={deleteItem} />
</div>
);
}페이지는 getItems()를 서버에서 직접 불러 목록을 그리고, createItemWithResult와 deleteItem은 실행하지 않은 채 함수 참조로 ClientForm에 넘깁니다. export const dynamic = 'force-dynamic'은 이 페이지를 빌드할 때 한 번 만들어 두지 않고 요청마다 렌더링하게 하므로, 아이템을 추가한 뒤 새로고침하면 메모리 배열의 현재 내용이 보입니다. 클라이언트 폼은 받은 서버 액션을 action 콜백과 삭제 버튼에서 호출하고, useFormStatus로 제출 중인 상태를 표시합니다. SubmitButton을 <form> 안의 별도 컴포넌트로 둔 것은, useFormStatus가 자신을 감싼 상위 <form>의 제출 상태만 읽기 때문입니다.
"use client";
import { useFormStatus } from 'react-dom'; // 폼 상태 훅
import { useRef } from 'react';
interface ClientFormProps {
createItemAction: (formData: FormData) => Promise<{ success: boolean; message: string }>;
deleteItemAction: (itemId: string) => Promise<{ success: boolean; message: string }>;
}
function SubmitButton() {
const { pending } = useFormStatus(); // 폼 제출 상태 확인
return (
<button type="submit" disabled={pending} style={{
padding: '10px 20px',
backgroundColor: pending ? '#a0a0a0' : '#28a745',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: pending ? 'not-allowed' : 'pointer',
transition: 'background-color 0.3s'
}}>
{pending ? '추가 중...' : '아이템 추가'}
</button>
);
}
export default function ClientForm({ createItemAction, deleteItemAction }: ClientFormProps) {
const formRef = useRef<HTMLFormElement>(null);
const deleteInputRef = useRef<HTMLInputElement>(null);
// createItemAction은 서버 액션이므로 클라이언트 컴포넌트에 prop으로 전달 가능
// 호출 인자는 서버로 전달되며, 함수 구현 자체가 브라우저로 복사되지는 않음.
const handleDelete = async () => {
const itemId = deleteInputRef.current?.value;
if (!itemId) {
alert('삭제할 아이템 ID를 입력해주세요.');
return;
}
const result = await deleteItemAction(itemId);
alert(result.message);
if (result.success && deleteInputRef.current) {
deleteInputRef.current.value = ''; // 성공 시 입력 필드 초기화
}
};
return (
<div>
<form
ref={formRef}
action={async (formData: FormData) => {
// 클라이언트 action 콜백에서 서버 액션을 호출
const result = await createItemAction(formData);
alert(result.message);
if (result.success) {
formRef.current?.reset(); // 폼 성공 시 초기화
}
}}
style={{ marginBottom: '30px', padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}
>
<label htmlFor="itemName" style={{ display: 'block', marginBottom: '10px', fontWeight: 'bold' }}>새 아이템 이름:</label>
<input
type="text"
id="itemName"
name="itemName"
required
style={{ width: 'calc(100% - 20px)', padding: '10px', marginBottom: '15px', borderRadius: '5px', border: '1px solid #ddd' }}
/>
<SubmitButton />
</form>
<h2 style={{ color: '#333', marginBottom: '15px' }}>아이템 삭제</h2>
<div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
<label htmlFor="deleteItemId" style={{ display: 'block', marginBottom: '10px', fontWeight: 'bold' }}>삭제할 아이템 ID:</label>
<input
type="text"
id="deleteItemId"
ref={deleteInputRef}
placeholder="예: 1"
style={{ width: 'calc(100% - 20px)', padding: '10px', marginBottom: '15px', borderRadius: '5px', border: '1px solid #ddd' }}
/>
<button onClick={handleDelete} style={{
padding: '10px 20px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
transition: 'background-color 0.3s'
}}>
아이템 삭제
</button>
</div>
</div>
);
}src/app/actions 폴더를 만들고 그 안에 itemActions.ts를, src/app/server-action 폴더를 만들고 그 안에 page.tsx와 ClientForm.tsx를 만듭니다. 개발 서버(npm run dev)를 실행한 뒤 http://localhost:3000/server-action에 접속합니다. 다음 결과는 개발 서버의 같은 프로세스와 모듈 인스턴스가 유지되는 동안에만 확인할 수 있으며, revalidatePath는 화면 캐시를 무효화할 뿐 메모리 데이터를 영속화하거나 여러 서버 인스턴스에 복제하지 않습니다.
"새 아이템 이름" 입력 필드에 이름을 넣고 "아이템 추가" 버튼을 누르면, 터미널의 서버 콘솔에 아이템이 생성되었다는 로그가 찍히고 페이지가 최신 아이템 목록으로 갱신됩니다. 제출하는 동안에는 버튼 문구가 "추가 중..."으로 바뀌고 비활성화됩니다. "삭제할 아이템 ID" 필드에 목록에 있는 ID를 넣고 "아이템 삭제" 버튼을 누르면, 서버 콘솔에 아이템이 삭제되었다는 로그가 찍히고 목록에서 그 아이템이 사라집니다.
ClientForm에서 같은 서버 프로세스의 아이템을 추가하는 흐름
Client Form 제출이 같은 서버의 Action 검증·메모리 변경·두 경로 revalidate·결과 반환으로 이동합니다. 서버 변경과 클라이언트 상태의 경계를 추적하는 실제 동작입니다.
NEXT · CH8-2
유효한 이름을 서버에서 추가하고 결과를 돌려줍니다
ClientForm의 createItemAction은 createItemWithResult에 연결됩니다. 위에서 아래는 유효한 이름의 처리 순서, 실선은 호출, 점선은 반환입니다.
trim 검증 실패는 배열 변경·재검증 전에 false 결과를 반환합니다. 그림의 items는 같은 프로세스의 메모리이며 DB·인증·중복 방지는 구현하지 않았습니다. 클라이언트 콜백·alert에는 JavaScript가 필요하고, 정상 반환 뒤 비제어 입력 초기화는 success 값과 별개입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 시간은 위에서 아래로 흐릅니다. 서버가 이름을 검증하고 메모리를 바꾼 뒤 두 예제 경로를 재검증합니다. 점선 화살표는 함수 반환값입니다.
- 기존 데스크톱과 같은 유효한 이름 한 번의 요청·반환 시퀀스입니다. 시간은 위에서 아래로 흐르며 실선은 호출, 점선은 반환입니다. createItemAction의 참조는 createItemWithResult에 연결되고 이름 검사 뒤 배열에 추가한 다음 두 경로를 재검증합니다. 결과 알림과 갱신 목록 반영 뒤의 입력 초기화는 정상 완료 규칙을 따릅니다. 빈 이름의 정상 실패 반환·서버 예외·DB 지속성은 이 성공 시퀀스와 별도입니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | ClientForm에서 같은 서버 프로세스의 아이템을 추가하는 흐름 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| Client 제출 Client Form이 이름 입력을 Server Action에 전달합니다. | 입력: 이름 · 호출: Server Action | Client 입력이 서버 업무 경계를 넘습니다. |
| 검증과 메모리 변경 서버가 이름을 검증하고 예제 메모리를 바꿉니다. | 검증: 이름 · 변경: 예제 메모리 | 유효한 입력이 서버 예제 상태를 바꿉니다. |
| 두 경로 재검증 변경 뒤 두 예제 경로를 revalidate합니다. | 대상: 예제 두 경로 · 순서: 변경 뒤 | 화면 cache를 재검증할 근거를 전달합니다. |
| 함수 결과 반환 Action 결과가 Client로 돌아옵니다. | 응답: 함수 반환값 · 지속 DB: 예제 메모리와 별도 | 제출한 Client가 업무 결과를 얻습니다. |
ClientForm에서 같은 서버 프로세스의 아이템을 추가하는 흐름
Client Form 제출이 같은 서버의 Action 검증·메모리 변경·두 경로 revalidate·결과 반환으로 이동합니다. 서버 변경과 클라이언트 상태의 경계를 추적하는 실제 동작입니다.
NEXT · CH8-2
유효한 이름을 서버에서 추가하고 결과를 돌려줍니다
ClientForm의 createItemAction은 createItemWithResult에 연결됩니다. 위에서 아래는 유효한 이름의 처리 순서, 실선은 호출, 점선은 반환입니다.
trim 검증 실패는 배열 변경·재검증 전에 false 결과를 반환합니다. 그림의 items는 같은 프로세스의 메모리이며 DB·인증·중복 방지는 구현하지 않았습니다. 클라이언트 콜백·alert에는 JavaScript가 필요하고, 정상 반환 뒤 비제어 입력 초기화는 success 값과 별개입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 시간은 위에서 아래로 흐릅니다. 서버가 이름을 검증하고 메모리를 바꾼 뒤 두 예제 경로를 재검증합니다. 점선 화살표는 함수 반환값입니다.
- 기존 데스크톱과 같은 유효한 이름 한 번의 요청·반환 시퀀스입니다. 시간은 위에서 아래로 흐르며 실선은 호출, 점선은 반환입니다. createItemAction의 참조는 createItemWithResult에 연결되고 이름 검사 뒤 배열에 추가한 다음 두 경로를 재검증합니다. 결과 알림과 갱신 목록 반영 뒤의 입력 초기화는 정상 완료 규칙을 따릅니다. 빈 이름의 정상 실패 반환·서버 예외·DB 지속성은 이 성공 시퀀스와 별도입니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | ClientForm에서 같은 서버 프로세스의 아이템을 추가하는 흐름 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| Client 제출 Client Form이 이름 입력을 Server Action에 전달합니다. | 입력: 이름 · 호출: Server Action | Client 입력이 서버 업무 경계를 넘습니다. |
| 검증과 메모리 변경 서버가 이름을 검증하고 예제 메모리를 바꿉니다. | 검증: 이름 · 변경: 예제 메모리 | 유효한 입력이 서버 예제 상태를 바꿉니다. |
| 두 경로 재검증 변경 뒤 두 예제 경로를 revalidate합니다. | 대상: 예제 두 경로 · 순서: 변경 뒤 | 화면 cache를 재검증할 근거를 전달합니다. |
| 함수 결과 반환 Action 결과가 Client로 돌아옵니다. | 응답: 함수 반환값 · 지속 DB: 예제 메모리와 별도 | 제출한 Client가 업무 결과를 얻습니다. |
위 그림은 ClientForm에서 유효한 이름 하나를 제출했을 때 같은 서버 프로세스의 아이템이 추가되는 흐름을 보여 줍니다. 시간은 위에서 아래로 흐르며, 실선은 호출이고 점선은 반환입니다. 브라우저의 ClientForm이 createItemAction(FormData)을 부르면, 이 참조는 서버의 createItemWithResult에 연결됩니다. 서버는 이름을 trim해 검사하고, items 배열에 추가한 뒤 revalidatePath로 두 예제 경로의 캐시를 무효화하고, { success: true, message }를 돌려줍니다. 클라이언트는 결과를 알림으로 보여 주고 갱신된 목록을 화면에 반영합니다. 그림의 단계도 클라이언트 제출, 검증과 메모리 변경, 두 경로 재검증, 함수 결과 반환의 순서입니다.
그림의 아래 설명처럼 trim 검증에 실패하면 배열을 바꾸거나 재검증하기 전에 false 결과를 돌려주며, 빈 이름의 정상 실패 반환이나 서버 예외, DB 지속성은 이 성공 흐름과 따로 생각해야 합니다. 그림의 items는 같은 프로세스의 메모리이며 DB, 인증, 중복 방지는 구현하지 않았습니다. 클라이언트 콜백과 alert에는 JavaScript가 필요하고, 정상 반환 뒤에 비제어 입력이 초기화되는 것은 success 값과는 별개입니다.
이 마지막 점을 조금 더 살펴보겠습니다. ClientForm은 결과의 success를 확인해 직접 reset을 부르지만, React의 함수형 form action은 정상적으로 끝나면 비제어 입력을 자동으로 초기화합니다. 따라서 success: false를 돌려주었다는 이유만으로 입력이 남아 있다고 생각하면 안 됩니다. 이 규칙은 React form의 초기화 규칙에서 확인할 수 있으며, 다음 글에서 결과를 표시할 때 다시 다룹니다.
결과 반환, 저장, 재검증, 이동은 각각 다른 단계입니다
createItemWithResult는 검증 결과를 돌려주지만, createItem은 그 결과를 기다린 뒤 반환값을 버리고 void로 끝납니다. 따라서 form action={createItem}으로 직접 연결해 공백을 보내면 저장은 거절되지만, 이 함수만으로는 실패 메시지를 화면에 표시할 수 없습니다. 다음 글의 상태 어댑터가 필요한 이유입니다.
재검증은 저장한 뒤 화면 데이터를 갱신하는 단계이며, 앞서 실행한 변경을 되돌리는 트랜잭션이 아닙니다. 저장은 성공했는데 응답 전달이나 후속 처리에 문제가 생겼다면, 다시 제출하기 전에 저장 상태를 확인해야 합니다. 중복을 허용하지 않는 업무라면 서버의 고유 제약이나 요청 식별자를 이용한 중복 판정 등을 따로 설계합니다.
저장한 뒤 다른 경로로 이동하려면 redirect()를 호출할 수 있습니다. 이 함수는 제어 흐름을 끝내는 예외를 쓰므로, 데이터 저장용 try/catch 바깥에서 호출해야 합니다. 실패를 돌려줄 때는 재검증이나 이동이 필요하지 않은 경우가 많습니다. 두 내용은 예상 오류 처리와 redirect 규칙에서 나누어 확인하세요.
서버 액션의 장점과 고려사항
서버 액션의 장점부터 보겠습니다. Route Handler를 직접 만들고 fetch를 손으로 호출하는 번거로움 없이 일반 함수처럼 서버 로직을 호출할 수 있어 개발 경험이 좋아지고, 불필요한 클라이언트 JavaScript를 줄여 초기 로딩 성능을 높입니다. 서버 전용 코드를 클라이언트 번들에 넣지 않아 구현을 숨길 수 있지만, 이것만으로 요청이 안전해지지는 않으므로 모든 액션에서 입력, 인증, 권한을 다시 검사합니다. 데이터를 바꾼 뒤에는 경로를 revalidatePath로, 태그의 stale-while-revalidate 갱신을 revalidateTag(tag, 'max')로, 서버 액션 안의 즉시 만료를 updateTag로 나누어 데이터의 일관성을 맞춥니다. 또한 서버 컴포넌트의 form에 서버 함수를 직접 연결하면 JavaScript 없이도 제출할 수 있는 점진적 향상(progressive enhancement)을 얻습니다. 다만 위 ClientForm의 알림과 삭제 버튼처럼 클라이언트 콜백에 의존하는 UI는 이와 구분합니다.
고려할 점도 있습니다. 서버 액션은 폼 제출 같은 한 번의 동작에 알맞으며, WebSocket처럼 실시간으로 데이터를 주고받아야 하는 경우에는 맞지 않을 수 있습니다. 오류 처리에서는 입력 검증 실패처럼 예상할 수 있는 결과를 { success: false, message } 같은 반환값으로 넘겨 화면에서 처리하고, 예상하지 못한 예외는 별도의 오류 경로로 처리합니다. 직접 await한 호출은 호출한 쪽에서 잡을 수 있지만, form action과 useActionState에서 처리되지 않은 예외는 가장 가까운 Error Boundary(error.js)로 전달될 수 있으므로, 모든 오류가 같은 try-catch나 결과 메시지로 처리된다고 가정하지 않습니다.
required 같은 HTML 검증은 클라이언트 컴포넌트 없이도 동작합니다. 별도의 클라이언트 검증은 선택 사항이며, 서버는 입력과 권한을 다시 확인합니다. 서버 액션을 실행한 뒤 로딩 스피너나 성공 메시지 같은 클라이언트 상태를 갱신하려면 useState와 useEffect를 조합하거나 useFormStatus 훅을 활용해야 하며, 다음 글에서는 useActionState로 결과를 표시하는 방법도 다룹니다.
정리
- 서버 액션은
"use server"로 선언하는 서버의 비동기 함수이며, 파일 맨 위에 두면 그 파일의 모든 내보내기 함수가, 함수 본문 맨 위에 두면 그 함수만 서버 액션이 됩니다. - 클라이언트 컴포넌트 안에는 인라인 서버 액션을 정의할 수 없고,
"use server"파일에서 가져오거나 서버가 넘긴 참조를 씁니다. - 서버 액션은 외부에서 호출할 수 있는 공개 진입점이므로, 서버에서 입력, 인증, 소유권을 다시 검증해야 합니다.
- 데이터를 바꾼 뒤의 재검증과 이동은
revalidatePath,revalidateTag(tag, 'max'),updateTag,redirect로 명시적으로 호출하며,redirect는try/catch바깥에서 부릅니다. - 예상할 수 있는 실패는 결과 객체로 돌려주고, 처리되지 않은 예외는 Error Boundary로 전달될 수 있음을 구분합니다.
연습 문제
1. 같은 입력의 두 제출을 구별하기
프로세스가 막 시작되어 초기 아이템으로 id=1 하나만 있습니다. 같은 모듈 인스턴스가 유지되고 다른 요청이나 서버 재로딩은 없다고 가정합니다. 공백 세 칸을 제출한 뒤, "노트"라는 유효한 이름을 두 번 따로 제출하고, 마지막에 id=1을 두 번 따로 삭제합니다. 각 요청이 앞 요청의 처리를 마친 뒤 시작한다면 최종 목록의 ID와 개수는 무엇일까요? 제출 중에 버튼을 비활성화한 것이 "노트"의 두 생성 요청을 같은 업무로 묶어 주는지도 설명해 보세요.
해설 보기
공백은 trim 뒤에 빈 값이 되므로 success: false를 돌려주며 items와 nextId를 바꾸지 않습니다. 첫 번째 "노트"는 id=2, 두 번째 "노트"는 id=3이 되어 목록은 세 개가 됩니다. 첫 번째 id=1 삭제는 성공해 두 개가 남고, 두 번째 삭제는 이미 없는 ID이므로 success: false를 돌려주며 목록을 바꾸지 않습니다. 최종 ID는 2와 3입니다.
이 액션은 같은 이름을 두 번 받으면 아이템을 두 개 만듭니다. disabled={pending}은 현재 폼의 대기 UI를 제어할 뿐이며, 서버의 업무 중복 판정은 구현하지 않았습니다. 결과 객체의 실패는 정상적으로 반환된 업무 결과이고, 함수가 예외를 던진 경우와는 다릅니다. 서버 재로딩, 여러 인스턴스, 동시 요청의 결과는 이 순차 가정으로 보장되지 않습니다.
2. 반환값이 없는 액션의 실패
같은 프로세스의 초기 배열에 ID 1 하나만 있습니다. createItem에 공백만 담은 FormData를 직접 넘깁니다. 함수의 반환값, 배열의 개수, 재검증 호출 여부를 적어 보세요. 이 반환값으로 실패 메시지를 표시할 수 있을까요?
해설 보기
내부 검증 결과는 실패지만 createItem의 최종 반환값은 undefined입니다. 배열은 한 개 그대로이고 재검증도 호출하지 않습니다. 실패 메시지가 필요하다면 결과를 돌려주는 createItemWithResult와 결과를 표시하는 경로를 써야 합니다.
3. 클라이언트가 보낸 소유자 ID
서버가 세션에서 사용자 A를 확인했습니다. 삭제할 ID 7은 B의 소유이며, 정책은 자신의 아이템만 삭제하는 것입니다. 클라이언트가 ownerId=A를 추가로 보냈다는 이유로 삭제해도 될까요? 서버에서 확인해야 할 값을 적어 보세요.
해설 보기
삭제하면 안 됩니다. 신원은 서버가 검증한 A이고, 요청의 ownerId는 누구나 바꿀 수 있는 입력입니다. 서버는 실제 저장소에서 ID 7의 소유자나 허용 범위를 확인하고, 정책에 맞는 변경 조건을 적용해야 합니다.
4. 재검증이 해 주지 않는 일
메모리 데모에서 유효한 이름을 저장한 뒤 재검증을 수행합니다. 재검증이 DB의 영속성, 업무의 중복 방지, 다른 서버 인스턴스로의 메모리 복제를 제공하는지 각각 답해 보세요.
해설 보기
모두 제공하지 않습니다. 이 코드의 배열 변경과 캐시 무효화는 같은 프로세스 안의 데모 흐름이며, 지속 저장, 업무의 중복 판정, 여러 인스턴스가 함께 쓰는 저장소는 따로 구현해야 합니다.
5. try/catch 안의 redirect
저장에 성공한 뒤 redirect('/items')를 실행하려고 합니다. 저장 오류를 처리하는 try/catch 안에 redirect를 두고, 모든 예외를 실패 결과로 바꾸면 어떤 문제가 생길 수 있을까요?
해설 보기
redirect가 제어 흐름을 끝내려고 던지는 예외를 catch가 오류로 잡아 실패 결과로 바꾸면, 정상적인 이동이 막힐 수 있습니다. 저장 오류의 처리를 마친 뒤 catch 바깥에서 재검증과 redirect 같은 성공 후속 처리를 실행합니다.
다음 글 폼 제출 및 데이터 처리에서는 서버 액션을 폼에 직접 연결하고 useActionState로 결과를 표시하는 방법을 알아봅니다.