서버 액션
use server로 데이터 변경 함수를 선언하고 폼 제출·캐시 재검증·리다이렉트를 서버에서 처리하는 흐름을 익힙니다.
이전 절에서 클라이언트 컴포넌트의 기본적인 상태 관리 방법을 다루었습니다.
전통적인 웹 개발에서는 클라이언트가 Route Handler 같은 HTTP 엔드포인트를 호출해 서버 상태를 변경했습니다.
Next.js 16의 App Router에서는 폼 제출과 데이터 변경 로직을 서버 함수로 연결하는 서버 액션(Server Actions)을 사용할 수 있습니다.
서버 액션은 클라이언트에서 서버 함수를 호출해 데이터를 변경하는 흐름을 단순화합니다.
데이터 변경 뒤에는 revalidatePath, revalidateTag(tag, 'max'), updateTag, redirect 같은 API를 필요한 시점에 명시적으로 호출합니다.
이 절에서는 서버 액션의 개념, 작동 방식, 그리고 폼 처리에 어떻게 활용되는지 자세히 알아보겠습니다.
서버 액션이란 무엇인가요?
서버 액션(Server Actions)은 클라이언트 측 JavaScript 없이도 클라이언트 컴포넌트나 폼에서 직접 서버 측 코드를 실행할 수 있도록 해주는 비동기 함수입니다.
이는 React 서버 컴포넌트와 Next.js App Router의 깊은 통합을 통해 가능해진 기능입니다.
서버 액션의 핵심 특징- 제로(0) 클라이언트 JavaScript: 서버 액션의 코드는 클라이언트 번들에 포함되지 않습니다. 이는 클라이언트 측 JavaScript 번들 크기를 줄여 페이지 로딩 속도를 향상시킵니다.
- 간소화된 데이터 변경: 기존에는 폼 제출용 Route Handler와
fetch, 응답 처리를 따로 만들었지만 서버 액션은 이 흐름을 함수 호출로 연결합니다. - 서버 전용 구현: 데이터베이스 접근 코드는 클라이언트 번들에 포함되지 않습니다. 다만 서버 액션은 외부 요청으로 호출할 수 있는 공개 HTTP 진입점이므로 입력 검증, 인증, 권한 확인을 반드시 수행해야 합니다.
- 명시적 후속 처리: 데이터 변경 뒤에는
revalidatePath, SWR 방식의revalidateTag(tag, 'max'), 즉시 만료하는updateTag,redirect를 목적에 맞게 호출합니다. - 폼 처리의 간소화: HTML
form요소의action속성 또는formAction속성에 직접 서버 액션을 바인딩하여 사용할 수 있어, 폼 제출 로직을 매우 간결하게 만듭니다.
서버 액션의 작동 방식
서버 액션은 마치 RPC(Remote Procedure Call)처럼 동작합니다.
클라이언트에서 서버 액션 호출: 클라이언트 컴포넌트에서 서버 액션이 호출됩니다 (예: 폼 제출 시, 버튼 클릭 시).
네트워크 요청 전송: Next.js는 이 호출을 인터셉트하여, 서버 액션에 대한 특별한 네트워크 요청을 생성하고 서버로 전송합니다.
서버에서 함수 실행: 서버는 요청을 받아 해당 서버 액션 함수를 실행합니다.
이 함수는 데이터베이스 작업, 파일 시스템 접근 등 모든 서버 측 로직을 수행할 수 있습니다.
응답 반환: 서버 액션 실행이 완료되면, 그 결과(반환 값)가 클라이언트로 다시 전송됩니다.
클라이언트에서 응답 처리: 클라이언트는 서버 액션의 반환 값을 받아 UI를 업데이트합니다.
서버 액션이 재검증 API를 호출했다면 해당 경로나 태그의 데이터도 새로 가져옵니다.
이 모든 과정은 Next.js가 내부적으로 처리하므로, 개발자는 서버에서 실행될 함수를 정의하고 클라이언트에서 호출하기만 하면 됩니다.
서버 액션 사용법: use server 지시어
서버 액션을 정의하는 방법은 매우 간단합니다.
"use server" 지시어를 함수 본문 상단에 추가하거나, 별도의 파일 상단에 추가하여 해당 파일의 모든 내보내기 함수를 서버 액션으로 만듭니다.
파일 단위로 선언하기
"use server" 지시어를 파일의 맨 위에 추가하면, 해당 파일 내에서 내보내는 모든 함수는 서버 액션이 됩니다.
아래 배열은 Server Action의 입력·검증·재검증 흐름만 확인하는 로컬 단일 프로세스 데모입니다.
모듈 메모리는 프로세스 재시작, 개발 서버 재로딩, 서버리스 콜드 스타트나 여러 인스턴스 사이에서 공유·보존되지 않습니다.
운영 코드와 지속성을 확인하는 실습에서는 같은 함수 안의 items 접근을 데이터베이스 저장·조회로 교체해야 합니다.
"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"를 선언할 수 있습니다.
이는 서버 액션을 클로저로 만들어 클라이언트 컴포넌트 내에서 정의하거나 전달받은 값을 캡처할 때 유용합니다.
// 이 페이지 컴포넌트 자체는 서버 컴포넌트입니다.
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) => {
// 폼 액션에 직접 서버 액션 함수를 전달
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: JavaScript가 비활성화된 환경에서도 폼의
action을 통해 서버 액션이 작동합니다.
- 실시간 업데이트 부족: 서버 액션은 폼 제출과 같은 한 번의 액션에 적합합니다. 웹소켓처럼 실시간으로 데이터를 주고받아야 하는 경우에는 서버 액션이 적합하지 않을 수 있습니다.
- 에러 처리: 서버 액션 내에서 발생한 예외는 클라이언트로 전파되어
try-catch블록으로 잡을 수 있습니다. 하지만 사용자에게 친숙한 에러 메시지를 제공하기 위한 추가적인 로직이 필요할 수 있습니다. - 폼 검증: 클라이언트 측에서 즉각적인 사용자 피드백을 위한 폼 검증(Validation)은 여전히 클라이언트 컴포넌트에서 이루어져야 합니다. 서버 액션은 최종적인 서버 측 검증을 담당합니다.
- 클라이언트 측 상태 업데이트: 서버 액션 실행 후 클라이언트 측 상태(예: 로딩 스피너, 성공 메시지)를 업데이트하려면
useState와useEffect를 조합하거나useFormStatus훅을 활용해야 합니다.
아래 다이어그램은 폼 제출을 서버 액션으로 설계할 때 책임이 어디로 이동하는지와 누락하면 안 되는 검증 지점을 함께 정리한 것입니다.
서버 액션은 Next.js App Router에서 폼 처리와 데이터 변경을 서버 함수 단위로 묶는 기능입니다.
적용할 때는 입력 검증, 인증, 캐시 무효화, 오류 응답 경계를 함께 설계해야 합니다.
실무에서는 서버 액션을 쓰기 전에 호출 위치, 검증 책임, 캐시 재검증, 배포 런타임 제약을 함께 점검해야 합니다.
서버 액션 이해하기의 판단 흐름을 화면 결과, 서버 비용, 운영 신호 기준으로 다시 묶었습니다.
마지막으로 서버 액션이 form 제출, use server 지시어, 데이터 변경 로직을 어떻게 연결하는지 정리합니다.