서버 액션 이해하기
이전 절에서 클라이언트 컴포넌트의 기본적인 상태 관리 방법을 다루었습니다. 전통적인 웹 개발에서 클라이언트가 서버에 데이터를 전송하고 상태를 변경하는 가장 흔한 방법은 API 엔드포인트(API Routes)를 호출하는 것이었습니다. 그러나 Next.js 15의 App Router는 이러한 패러다임을 혁신적으로 변화시키는 강력한 기능인 서버 액션(Server Actions) 을 도입했습니다.
서버 액션은 클라이언트에서 직접 서버의 함수를 호출하여 데이터를 업데이트하고, 캐시를 재검증하며, 리다이렉트하는 등 다양한 서버 측 로직을 실행할 수 있게 해줍니다. 이 절에서는 서버 액션의 개념, 작동 방식, 그리고 폼 처리에 어떻게 활용되는지 자세히 알아보겠습니다.
서버 액션이란 무엇인가요?
서버 액션(Server Actions) 은 클라이언트 측 JavaScript 없이도 클라이언트 컴포넌트나 폼에서 직접 서버 측 코드를 실행할 수 있도록 해주는 비동기 함수입니다. 이는 React 서버 컴포넌트와 Next.js App Router의 깊은 통합을 통해 가능해진 기능입니다.
서버 액션의 핵심 특징
- 제로(0) 클라이언트 JavaScript: 서버 액션의 코드는 클라이언트 번들에 포함되지 않습니다. 이는 클라이언트 측 JavaScript 번들 크기를 줄여 페이지 로딩 속도를 향상시킵니다.
- 간소화된 데이터 변경: 기존에는 폼 제출 시 API 라우트를 만들고,
fetch
등으로 호출하며, 응답을 처리하는 복잡한 과정이 필요했지만, 서버 액션은 이 모든 것을 단일 함수 호출로 추상화합니다. - 보안 강화: 서버에서 직접 실행되므로 민감한 로직이나 데이터베이스 접근 코드를 클라이언트에 노출할 위험이 없습니다.
- 자동 재검증 및 리다이렉트: 서버 액션이 완료된 후, Next.js 캐시를 자동으로 재검증하거나(revalidate) 또는 페이지를 리다이렉트하는 등의 추가 작업을 수행할 수 있습니다.
- 폼 처리의 간소화: HTML
form
요소의action
속성 또는formAction
속성에 직접 서버 액션을 바인딩하여 사용할 수 있어, 폼 제출 로직을 매우 간결하게 만듭니다.
서버 액션의 작동 방식
서버 액션은 마치 RPC(Remote Procedure Call)처럼 동작합니다.
- 클라이언트에서 서버 액션 호출: 클라이언트 컴포넌트에서 서버 액션이 호출됩니다 (예: 폼 제출 시, 버튼 클릭 시).
- 네트워크 요청 전송: Next.js는 이 호출을 인터셉트하여, 서버 액션에 대한 특별한 네트워크 요청을 생성하고 서버로 전송합니다.
- 서버에서 함수 실행: 서버는 요청을 받아 해당 서버 액션 함수를 실행합니다. 이 함수는 데이터베이스 작업, 파일 시스템 접근 등 모든 서버 측 로직을 수행할 수 있습니다.
- 응답 반환: 서버 액션 실행이 완료되면, 그 결과(반환 값)가 클라이언트로 다시 전송됩니다.
- 클라이언트에서 응답 처리: 클라이언트는 서버 액션의 반환 값을 받아서 UI를 업데이트하거나, 자동으로 캐시를 재검증하는 등의 후속 작업을 수행합니다.
이 모든 과정은 Next.js가 내부적으로 처리하므로, 개발자는 서버에서 실행될 함수를 정의하고 클라이언트에서 호출하기만 하면 됩니다.
서버 액션 사용법: use server
지시어
서버 액션을 정의하는 방법은 매우 간단합니다. "use server"
지시어를 함수 본문 상단에 추가하거나, 별도의 파일 상단에 추가하여 해당 파일의 모든 내보내기 함수를 서버 액션으로 만듭니다.
파일 단위로 선언하기
"use server"
지시어를 파일의 맨 위에 추가하면, 해당 파일 내에서 내보내는 모든 함수는 서버 액션이 됩니다.
// src/app/actions/itemActions.ts (새로 생성할 파일)
"use server"; // 이 파일의 모든 내보내기 함수는 서버 액션입니다.
import { revalidatePath } from 'next/cache'; // Next.js 캐시 재검증 유틸리티
interface Item {
id: string;
name: string;
}
// 더미 데이터 저장소 (실제로는 DB에 저장)
const items: Item[] = [{ id: '1', name: '초기 아이템' }];
let nextId = 2;
export async function createItem(formData: FormData) {
// 서버에서 실행되는 로직
const name = formData.get('itemName') as string;
if (!name) {
console.error('아이템 이름이 비어있습니다.');
return { success: false, message: '아이템 이름이 필요합니다.' };
}
const newItem: Item = { id: (nextId++).toString(), name };
items.push(newItem);
console.log(`서버: 아이템 생성됨 - ${name}, 현재 아이템 수: ${items.length}`);
// 특정 경로의 Next.js 캐시를 재검증하여 최신 데이터를 반영
revalidatePath('/server-action');
return { success: true, message: `아이템 '${name}'이(가) 성공적으로 추가되었습니다.` };
}
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 initialLength = items.length;
const index = items.findIndex(item => item.id === itemId);
if (index > -1) {
items.splice(index, 1);
console.log(`서버: 아이템 ${itemId} 삭제됨. 현재 아이템 수: ${items.length}`);
revalidatePath('/server-action');
return { success: true, message: `아이템 ${itemId}이(가) 삭제되었습니다.` };
}
console.error(`아이템 ${itemId}를 찾을 수 없습니다.`);
return { success: false, message: `아이템 ${itemId}를 찾을 수 없습니다.` };
}
함수 단위로 선언하기 (Action 클로저)
특정 함수만 서버 액션으로 만들고 싶다면, 함수 본문 내부에 "use server"
를 선언할 수 있습니다. 이는 서버 액션을 클로저로 만들어 클라이언트 컴포넌트 내에서 정의하거나 전달받은 값을 캡처할 때 유용합니다.
// src/app/server-action/page.tsx (예시)
// 이 페이지 컴포넌트 자체는 서버 컴포넌트입니다.
import { createItem, getItems, deleteItem } from '../actions/itemActions'; // 서버 액션 임포트
import ClientForm from './ClientForm'; // 클라이언트 컴포넌트 임포트
export const dynamic = 'force-dynamic'; // 이 페이지는 항상 SSR로 동작하도록 강제 (선택 사항)
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={createItem} deleteItemAction={deleteItem} />
</div>
);
}
// src/app/server-action/ClientForm.tsx (새로 생성할 클라이언트 컴포넌트)
"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" // name 속성은 FormData에서 값을 추출하는 데 사용됨
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
으로 접속합니다. - "새 아이템 이름" 입력 필드에 이름을 입력하고 "아이템 추가" 버튼을 클릭합니다.
- 터미널(서버 콘솔)에 아이템이 생성되었다는 로그가 찍히고, 페이지가 자동으로 최신 아이템 목록으로 업데이트됩니다.
- 폼 제출 중에는 버튼 텍스트가 "추가 중..."으로 바뀌고 비활성화됩니다.
- "삭제할 아이템 ID" 필드에 목록에 있는 아이템 ID를 입력하고 "아이템 삭제" 버튼을 클릭합니다.
- 터미널(서버 콘솔)에 아이템이 삭제되었다는 로그가 찍히고, 목록에서 해당 아이템이 사라집니다.
서버 액션의 장점과 고려사항
장점
- 향상된 개발자 경험: API 라우트를 직접 생성하고
fetch
를 수동으로 호출하는 번거로움 없이, 일반 함수처럼 서버 로직을 호출할 수 있습니다. - 성능: 불필요한 클라이언트 측 JavaScript를 줄여 초기 로딩 성능을 최적화합니다.
- 보안: 서버 전용 코드를 클라이언트에 노출하지 않아 안전합니다.
- 데이터 일관성:
revalidatePath
,revalidateTag
등을 사용하여 데이터 변경 후 Next.js 캐시를 자동으로 업데이트하여 UI에 최신 정보를 반영하기 쉽습니다. - Progressive Enhancement: JavaScript가 비활성화된 환경에서도 폼의
action
을 통해 서버 액션이 작동합니다.
고려사항
- 실시간 업데이트 부족: 서버 액션은 폼 제출과 같은 "한 번의 액션"에 적합합니다. 웹소켓처럼 실시간으로 데이터를 주고받아야 하는 경우에는 서버 액션이 적합하지 않을 수 있습니다.
- 에러 처리: 서버 액션 내에서 발생한 예외는 클라이언트로 전파되어
try-catch
블록으로 잡을 수 있습니다. 하지만 사용자에게 친숙한 에러 메시지를 제공하기 위한 추가적인 로직이 필요할 수 있습니다. - 폼 검증: 클라이언트 측에서 즉각적인 사용자 피드백을 위한 폼 검증(Validation)은 여전히 클라이언트 컴포넌트에서 이루어져야 합니다. 서버 액션은 최종적인 서버 측 검증을 담당합니다.
- 클라이언트 측 상태 업데이트: 서버 액션 실행 후 클라이언트 측 상태(예: 로딩 스피너, 성공 메시지)를 업데이트하려면
useState
와useEffect
를 조합하거나useFormStatus
훅을 활용해야 합니다.
서버 액션은 Next.js App Router의 폼 처리 및 데이터 변경 방식을 혁신적으로 변화시키는 강력한 기능입니다. 이를 통해 개발자는 더 적은 코드로 더 안전하고 효율적인 서버-클라이언트 통신을 구현할 수 있게 되었습니다.