서버 액션
use server로 데이터 변경 함수를 선언하고 폼 제출·캐시 재검증·리다이렉트를 서버에서 처리하는 흐름을 익힙니다.
이전 절에서 클라이언트 컴포넌트의 기본적인 상태 관리 방법을 다루었습니다.
전통적인 웹 개발에서는 클라이언트가 Route Handler 같은 HTTP 엔드포인트를 호출해 서버 상태를 변경했습니다.
Next.js 16의 App Router에서는 폼 제출과 데이터 변경 로직을 서버 함수로 연결하는 서버 액션(Server Actions)을 사용할 수 있습니다.
서버 액션은 클라이언트에서 서버 함수를 호출해 데이터를 변경하는 흐름을 단순화합니다.
데이터 변경 뒤에는 revalidatePath, revalidateTag(tag, 'max'), updateTag, redirect 같은 API를 필요한 시점에 명시적으로 호출합니다.
이 절에서는 서버 액션의 개념, 작동 방식, 그리고 폼 처리에 어떻게 활용되는지 자세히 알아보겠습니다.
서버 액션이란 무엇인가요?
서버 액션(Server Actions)은 데이터 변경 등을 위해 서버에서 실행하는 비동기 함수입니다. Server Component가 서버 함수를 form의 action에 직접 연결하면 JavaScript가 로드되기 전에도 제출할 수 있습니다. onClick이나 클라이언트 action 콜백은 브라우저 JavaScript가 필요합니다.
이는 React 서버 컴포넌트와 Next.js App Router의 깊은 통합을 통해 가능해진 기능입니다.
서버 액션의 핵심 특징- 서버에 남는 함수 구현: 서버 액션의 본문은 클라이언트 번들에 포함되지 않습니다. 함수를 호출하는 클라이언트 UI의 JavaScript까지 없어지는 것은 아닙니다.
- 간소화된 데이터 변경: 기존에는 폼 제출용 Route Handler와
fetch, 응답 처리를 따로 만들었지만 서버 액션은 이 흐름을 함수 호출로 연결합니다. - 서버 전용 구현: 데이터베이스 접근 코드는 클라이언트 번들에 포함되지 않습니다. 다만 서버 액션은 외부 요청으로 호출할 수 있는 공개 HTTP 진입점이므로 입력 검증, 인증, 권한 확인을 반드시 수행해야 합니다.
- 명시적 후속 처리: 데이터 변경 뒤에는
revalidatePath, SWR 방식의revalidateTag(tag, 'max'), 즉시 만료하는updateTag,redirect를 목적에 맞게 호출합니다. - 폼 처리의 간소화: HTML
form요소의action속성 또는formAction속성에 직접 서버 액션을 바인딩하여 사용할 수 있어, 폼 제출 로직을 매우 간결하게 만듭니다.
서버 액션의 작동 방식
서버 액션은 마치 RPC(Remote Procedure Call)처럼 동작합니다.
클라이언트에서 서버 액션 호출: 클라이언트 컴포넌트에서 서버 액션이 호출됩니다 (예: 폼 제출 시, 버튼 클릭 시).
네트워크 요청 전송: Next.js는 이 호출을 인터셉트하여, 서버 액션에 대한 특별한 네트워크 요청을 생성하고 서버로 전송합니다.
서버에서 함수 실행: 서버는 요청을 받아 해당 서버 액션 함수를 실행합니다.
이 함수는 데이터베이스 작업, 파일 시스템 접근 등 모든 서버 측 로직을 수행할 수 있습니다.
응답 반환: 서버 액션 실행이 완료되면, 그 결과(반환값)가 클라이언트로 다시 전송됩니다.
클라이언트에서 응답 처리: 클라이언트는 서버 액션의 반환값을 받아 UI를 업데이트합니다.
서버 액션은 필요한 캐시 갱신을 명시적으로 요청합니다. 이 예제의 revalidatePath와, 태그를 stale로 표시해 다음 읽기에서 오래된 값과 백그라운드 갱신을 허용하는 revalidateTag(tag, 'max')는 갱신 시점이 다릅니다. revalidateTag의 재검증 동작을 구분해 읽습니다.
이 모든 과정은 Next.js가 내부적으로 처리하므로, 개발자는 서버에서 실행될 함수를 정의하고 클라이언트에서 호출하기만 하면 됩니다.
호출을 연결하는 전송 과정은 프레임워크가 맡지만, 요청이 업무 규칙을 만족하는지와 실제 데이터 변경은 개발자가 정합니다. 타입 선언도 네트워크로 받은 값의 런타임 검증을 대신하지 않습니다. 함수가 브라우저에서 호출될 때에는 인수와 반환값이 React가 지원하는 직렬화 가능한 값이어야 하며, 아래 결과 객체의 boolean·문자열은 그 범위에 들어갑니다.
서버 액션 사용법: use server 지시어
서버 액션을 정의하는 방법은 매우 간단합니다.
"use server" 지시어를 함수 본문 상단에 추가하거나, 별도의 파일 상단에 추가하여 해당 파일의 모든 내보내기 함수를 서버 액션으로 만듭니다.
파일 단위로 선언하기
"use server" 지시어를 파일의 맨 위에 추가하면, 해당 파일 내에서 내보내는 모든 함수는 서버 액션이 됩니다.
아래 배열은 Server Action의 입력·검증·재검증 흐름만 확인하는 로컬 단일 프로세스 데모입니다.
모듈 메모리는 프로세스 재시작, 개발 서버 재로딩, 서버리스 콜드 스타트나 여러 인스턴스 사이에서 공유·보존되지 않습니다. 아래 force-dynamic 예제는 Cache Components를 활성화하지 않은 구성을 전제로 합니다.
운영 코드와 지속성을 확인하는 실습에서는 같은 함수 안의 items 접근을 데이터베이스 저장·조회로 교체해야 합니다.
파일과 호출 경로를 먼저 연결하기
같은 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()를 직접 실행하는 조회와 브라우저가 Server Action을 호출하는 네트워크 요청은 구분하세요. "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}를 찾을 수 없습니다.` };
}함수 단위로 선언하기 (Action 클로저)
특정 함수만 서버 액션으로 만들고 싶다면, 함수 본문 내부에 "use server"를 선언할 수 있습니다.
이 선언은 Server Component 안에서 사용할 수 있습니다. Client Component 안에 inline 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(); // 서버 컴포넌트에서 서버 액션 호출 (빌드/요청 시)
// 함수 단위 서버 액션 예시 (이 페이지에서는 직접 사용하지 않음)
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>
);
}"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>
);
}아래 결과는 개발 서버의 같은 프로세스와 모듈 인스턴스가 유지되는 동안에만 확인할 수 있습니다.
revalidatePath는 화면 캐시를 무효화할 뿐 메모리 데이터를 영속화하거나 여러 서버 인스턴스 사이에 복제하지 않습니다.
src/app/actions 폴더를 만들고 그 안에 itemActions.ts 파일을 생성합니다.
src/app/server-action 폴더를 만들고 그 안에 page.tsx와 ClientForm.tsx 파일을 생성합니다.
개발 서버(npm run dev)를 실행한 후, http://localhost:3000/server-action으로 접속합니다.
새 아이템 이름 입력 필드에 이름을 입력하고 아이템 추가 버튼을 클릭합니다.
- 터미널(서버 콘솔)에 아이템이 생성되었다는 로그가 찍히고, 페이지가 자동으로 최신 아이템 목록으로 업데이트됩니다.
- 폼 제출 중에는 버튼 텍스트가 추가 중...으로 바뀌고 비활성화됩니다.
삭제할 아이템 ID 필드에 목록에 있는 아이템 ID를 입력하고 아이템 삭제 버튼을 클릭합니다.
- 터미널(서버 콘솔)에 아이템이 삭제되었다는 로그가 찍히고, 목록에서 해당 아이템이 사라집니다.
서버 액션의 장점과 고려사항
장점- 향상된 개발자 경험: Route Handler를 직접 생성하고
fetch를 수동으로 호출하는 번거로움 없이, 일반 함수처럼 서버 로직을 호출할 수 있습니다. - 성능: 불필요한 클라이언트 측 JavaScript를 줄여 초기 로딩 성능을 최적화합니다.
- 구현 은닉: 서버 전용 코드를 클라이언트 번들에 포함하지 않습니다. 이것만으로 요청이 안전해지는 것은 아니므로 모든 액션에서 입력, 인증, 권한을 다시 검사합니다.
- 데이터 일관성: 데이터 변경 후 경로는
revalidatePath, 태그의 SWR 갱신은revalidateTag(tag, 'max'), Server Action의 즉시 만료는updateTag로 구분합니다. - Progressive Enhancement: Server Component의 form에 서버 함수를 직접 연결하면 JavaScript 없이 제출할 수 있습니다. 위 ClientForm의 알림·삭제 버튼처럼 클라이언트 콜백에 의존하는 UI는 별도로 구분합니다.
- 실시간 업데이트 부족: 서버 액션은 폼 제출과 같은 한 번의 액션에 적합합니다. 웹소켓처럼 실시간으로 데이터를 주고받아야 하는 경우에는 서버 액션이 적합하지 않을 수 있습니다.
- 에러 처리: 입력 검증 실패처럼 예상 가능한 결과는
{ success: false, message }같은 반환값으로 전달하고 화면에서 처리합니다. 예상하지 못한 예외는 별도의 오류 경로로 처리합니다. 직접await한 호출은 호출자에서 잡을 수 있지만, form action과useActionState에서 처리되지 않은 예외는 오류 경계로 전달될 수 있으므로 모든 오류가 같은try-catch나 결과 메시지로 처리된다고 가정하지 않습니다. - 폼 검증: required 같은 HTML 검증은 Client Component 없이도 동작합니다. 별도의 클라이언트 검증은 선택 사항이며, 서버는 입력과 권한을 다시 확인합니다.
- 클라이언트 측 상태 업데이트: 서버 액션 실행 후 클라이언트 측 상태(예: 로딩 스피너, 성공 메시지)를 업데이트하려면
useState와useEffect를 조합하거나useFormStatus훅을 활용해야 합니다.
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가 업무 결과를 얻습니다. |
같은 입력의 두 제출을 구별하기
프로세스가 막 시작되어 초기 아이템 id=1 한 개만 있습니다. 같은 모듈 인스턴스를 유지하고 다른 요청·서버 재로딩은 없다고 가정합니다. 공백 세 칸을 제출한 뒤 노트라는 유효한 이름을 두 번 각각 제출하고, 마지막에 id=1을 두 번 각각 삭제합니다. 각 요청이 앞 요청의 처리를 마친 뒤 시작한다면 최종 목록의 ID와 개수는 무엇일까요? pending 버튼을 비활성화한 것이 노트의 두 생성 요청을 같은 업무로 묶는지도 설명하세요.
검증·변경·결과를 순서대로 따라가는 해설
공백은 trim 뒤 빈 값이므로 success:false를 반환하며 items와 nextId를 바꾸지 않습니다. 첫 노트는 id=2, 두 번째 노트는 id=3이므로 목록은 세 개가 됩니다. 첫 id=1 삭제는 성공해 두 개를 남기고, 다음 삭제는 이미 없으므로 success:false이며 목록을 바꾸지 않습니다. 최종 ID는 2·3입니다.
이 액션은 같은 이름을 두 번 받으면 두 아이템을 만듭니다. disabled={pending}은 현재 폼의 대기 UI를 제어하며 서버의 업무 중복 판정은 구현하지 않습니다. 결과 객체의 실패는 여기서 정상 반환된 업무 결과이고, 함수가 예외를 던진 경로와는 다릅니다. 서버 재로딩·여러 인스턴스·동시 요청의 결과를 이 순차 가정으로 보장하지 않습니다.
ClientForm은 결과의 success를 확인해 직접 reset을 호출하지만, React의 함수형 form action은 정상 완료 뒤 비제어 입력을 자동으로 초기화합니다. 따라서 success: false를 반환했다는 이유만으로 입력이 보존된다고 가정하면 안 됩니다. React form의 초기화 규칙을 다음 절의 결과 표시와 함께 확인합니다.
결과 반환, 저장, 재검증, 이동은 각각 다른 단계입니다
createItemWithResult는 검증 결과를 반환하지만 createItem은 그 결과를 기다린 뒤 반환값을 버리고 void로 끝납니다. 따라서 직접 form action={createItem}에 공백을 보내면 저장은 거절되지만 이 함수만으로 실패 메시지를 화면에 표시할 수는 없습니다. 다음 절의 상태 어댑터가 필요한 이유입니다.
재검증은 저장 이후의 화면 데이터 갱신이며, 앞서 실행한 변경을 되돌리는 트랜잭션은 아닙니다. 저장은 성공했는데 응답 전달이나 후속 처리에 문제가 생겼다면 다시 제출하기 전에 저장 상태를 확인할 필요가 있습니다. 중복을 허용하지 않는 업무는 서버의 고유 제약이나 요청 식별자를 이용한 중복 판정 등을 별도로 설계합니다.
저장 후 다른 경로로 이동하려면 redirect()를 호출할 수 있습니다. 이 함수는 제어 흐름을 끝내는 예외를 사용하므로 데이터 저장용 try/catch 바깥에서 호출해야 합니다. 실패 반환에는 재검증이나 이동이 필요하지 않은 경우가 많습니다. 예상 오류 처리와 redirect 규칙을 나누어 확인하세요.
연습: 검증·권한·후속 처리를 따로 판단하기
- 동일 프로세스의 초기 배열에는 ID 1 하나만 있습니다.
createItem에 공백만 담은 FormData를 직접 전달합니다. 함수 반환값, 배열 개수, 재검증 호출 여부를 적으세요. 이 반환값으로 실패 메시지를 표시할 수 있을까요? - 서버가 세션에서 A를 확인했습니다. 삭제할 ID 7은 B의 소유이며 정책은 자신의 아이템만 삭제하는 것입니다. 클라이언트가
ownerId=A를 추가해 보냈다는 이유로 삭제해도 될까요? 서버에서 확인할 값을 적으세요. - 메모리 데모에서 유효한 이름을 저장한 뒤 재검증을 수행합니다. 재검증이 DB의 영속성이나 업무 중복 방지, 다른 서버 인스턴스의 메모리 복제를 제공하는지 각각 답하세요.
- 저장에 성공한 뒤
redirect('/items')를 실행하려고 합니다. 저장 오류를 처리하는try/catch안에 redirect를 두고 모든 예외를 실패 결과로 바꾸면 어떤 문제가 생길 수 있나요?
반환값·소유권·재검증·이동 풀이
- 내부 검증 결과는 실패지만
createItem의 최종 반환값은undefined입니다. 배열은 한 개 그대로이고 재검증 호출도 없습니다. 실패 메시지가 필요하면 결과를 반환하는createItemWithResult와 상태 표시 경로를 사용해야 합니다. - 삭제하면 안 됩니다. 신원은 서버가 검증한 A이고, 요청의 ownerId는 바꿀 수 있는 입력입니다. 실제 저장소에서 ID 7의 소유자 또는 허용 범위를 확인하고 정책에 맞는 변경 조건을 적용해야 합니다.
- 모두 제공하지 않습니다. 이 코드의 배열 변경과 캐시 무효화는 같은 프로세스의 데모 흐름이며, 지속 저장·업무 중복 판정·여러 인스턴스의 공유 저장소는 별도로 구현해야 합니다.
- redirect가 던지는 제어 흐름을 오류로 잡아 실패 결과로 바꾸면 정상 이동을 막을 수 있습니다. 저장 오류 처리를 마친 뒤 catch 밖에서 재검증·redirect 등 성공 후속 처리를 실행합니다.