Next.js의 App Router와 함께 등장한 Server Action은 풀스택 개발의 패러다임을 바꾸고 있습니다. 기존의 API Route를 거치지 않고도 서버 로직을 클라이언트에서 직접 호출할 수 있는 이 혁신적인 기능에 대해 자세히 알아보겠습니다.
Server Action이란?
기본 개념
Server Action은 Next.js 13.4 버전부터 도입된 기능으로, 서버에서 실행되지만 클라이언트에서 직접 호출할 수 있는 비동기 함수입니다. 마치 클라이언트 함수를 호출하는 것처럼 간단하게 서버 로직을 실행할 수 있어, 개발자 경험을 크게 향상시킵니다.
기존 방식과의 차이점
기존 방식: API Route 사용
// pages/api/create-review.ts (기존 방식)
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
const { content } = req.body;
// 데이터베이스 처리
await saveReview(content);
res.status(200).json({ success: true });
}
}
// 클라이언트에서 호출
const handleSubmit = async (data) => {
const response = await fetch('/api/create-review', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
};
Server Action 방식
// app/actions.ts (Server Action)
"use server";
export async function createReviewAction(formData: FormData) {
const content = formData.get("content") as string;
// 데이터베이스 처리 (직접 접근)
await saveReview(content);
}
// 컴포넌트에서 사용
<form action={createReviewAction}>
<textarea name="content" required />
<button type="submit">리뷰 작성</button>
</form>
Server Action 사용법
1. "use server" 디렉티브
Server Action을 정의하기 위해서는 반드시 "use server"
디렉티브를 사용해야 합니다.
// 파일 레벨에서 선언
"use server";
export async function updateUser(formData: FormData) {
// 모든 함수가 Server Action이 됨
}
// 또는 함수 레벨에서 선언
export async function deletePost(id: string) {
"use server";
// 이 함수만 Server Action이 됨
}
2. 폼과 함께 사용하기
가장 일반적인 사용 방법은 HTML 폼의 action
속성과 함께 사용하는 것입니다.
// app/actions/user.ts
"use server";
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
export async function createUser(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
// 유효성 검사
if (!name || !email) {
throw new Error('필수 필드가 누락되었습니다.');
}
// 데이터베이스 저장
const user = await prisma.user.create({
data: { name, email }
});
// 캐시 재검증 및 리다이렉트
revalidatePath('/users');
redirect(`/users/${user.id}`);
}
// app/users/create/page.tsx
import { createUser } from '@/app/actions/user';
export default function CreateUserPage() {
return (
<form action={createUser} className="space-y-4">
<div>
<label htmlFor="name">이름</label>
<input
type="text"
id="name"
name="name"
required
className="border rounded px-3 py-2"
/>
</div>
<div>
<label htmlFor="email">이메일</label>
<input
type="email"
id="email"
name="email"
required
className="border rounded px-3 py-2"
/>
</div>
<button type="submit" className="bg-blue-500 text-white px-4 py-2 rounded">
사용자 생성
</button>
</form>
);
}
3. 프로그래매틱 호출
JavaScript로 직접 Server Action을 호출할 수도 있습니다.
"use client";
import { useState, useTransition } from 'react';
import { updateProfile } from '@/app/actions/profile';
export default function ProfileForm() {
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState('');
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(async () => {
try {
await updateProfile(formData);
setMessage('프로필이 업데이트되었습니다.');
} catch (error) {
setMessage('오류가 발생했습니다.');
}
});
};
return (
<form onSubmit={handleSubmit}>
{/* 폼 필드들 */}
<button type="submit" disabled={isPending}>
{isPending ? '저장 중...' : '저장'}
</button>
{message && <p>{message}</p>}
</form>
);
}
Server Action의 핵심 장점
1. 개발 생산성 향상
기존 방식 | Server Action |
---|---|
API Route 생성 → 클라이언트 fetch 코드 → 에러 처리 | Server Action 함수 하나로 완료 |
3개 파일 수정 필요 | 1개 파일 수정으로 충분 |
보일러플레이트 코드 많음 | 최소한의 코드로 구현 |
// 기존: 여러 단계 필요
// 1. API Route 생성
// 2. 클라이언트 fetch 함수 작성
// 3. 에러 처리 로직 구현
// Server Action: 한 번에 해결
"use server";
export async function likePost(postId: string) {
await prisma.like.create({
data: { postId, userId: getCurrentUserId() }
});
revalidatePath('/posts');
}
2. 보안성 강화
Server Action의 로직은 클라이언트 번들에 포함되지 않습니다. 이는 마치 은행의 금고실 같은 역할을 합니다.
"use server";
export async function processPayment(amount: number) {
// 이 로직은 클라이언트에 전송되지 않음
const apiKey = process.env.PAYMENT_SECRET_KEY; // 안전!
const result = await paymentGateway.charge({
amount,
apiKey
});
// 민감한 로직도 숨겨짐
if (amount > 1000000) {
await notifyAdmin('Large payment detected');
}
return { success: true };
}
3. Progressive Enhancement
JavaScript가 비활성화되어도 폼이 동작합니다. 이는 웹의 기본 원칙을 따르는 것입니다.
// JavaScript 없이도 동작하는 폼
<form action={subscribeNewsletter}>
<input type="email" name="email" required />
<button type="submit">구독하기</button>
</form>
// JavaScript가 로드되면 더 나은 UX 제공
// - 로딩 상태 표시
// - 실시간 유효성 검사
// - 페이지 새로고침 없이 처리
4. 성능 최적화
// 네트워크 요청 최소화
"use server";
export async function getPostsWithComments(userId: string) {
// 서버에서 직접 데이터베이스 접근
// API 왕복 없이 한 번에 처리
const posts = await prisma.post.findMany({
where: { authorId: userId },
include: {
comments: true,
likes: true
}
});
return posts;
}
실제 사용 사례와 패턴
1. 댓글 시스템 구현
// app/actions/comments.ts
"use server";
import { revalidatePath } from 'next/cache';
export async function addComment(postId: string, formData: FormData) {
const content = formData.get('content') as string;
const authorId = await getCurrentUserId();
if (!content.trim()) {
throw new Error('댓글 내용을 입력해주세요.');
}
await prisma.comment.create({
data: {
content,
postId,
authorId
}
});
revalidatePath(`/posts/${postId}`);
}
export async function deleteComment(commentId: string) {
const userId = await getCurrentUserId();
const comment = await prisma.comment.findUnique({
where: { id: commentId }
});
if (comment?.authorId !== userId) {
throw new Error('권한이 없습니다.');
}
await prisma.comment.delete({
where: { id: commentId }
});
revalidatePath('/posts');
}
2. 좋아요 기능
// components/LikeButton.tsx
import { toggleLike } from '@/app/actions/likes';
interface LikeButtonProps {
postId: string;
isLiked: boolean;
likeCount: number;
}
export default function LikeButton({ postId, isLiked, likeCount }: LikeButtonProps) {
return (
<form action={toggleLike.bind(null, postId)}>
<button
type="submit"
className={`flex items-center space-x-2 ${
isLiked ? 'text-red-500' : 'text-gray-500'
}`}
>
<span>{isLiked ? '❤️' : '🤍'}</span>
<span>{likeCount}</span>
</button>
</form>
);
}
3. 파일 업로드
// app/actions/upload.ts
"use server";
import { writeFile } from 'fs/promises';
import { join } from 'path';
export async function uploadFile(formData: FormData) {
const file = formData.get('file') as File;
if (!file) {
throw new Error('파일을 선택해주세요.');
}
// 파일 검증
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
throw new Error('파일 크기는 5MB를 초과할 수 없습니다.');
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
throw new Error('지원하지 않는 파일 형식입니다.');
}
// 파일 저장
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const fileName = `${Date.now()}-${file.name}`;
const path = join(process.cwd(), 'public/uploads', fileName);
await writeFile(path, buffer);
return { fileName, url: `/uploads/${fileName}` };
}
주의사항과 제한사항
1. 보안 고려사항
// ❌ 잘못된 예시 - 클라이언트 데이터를 그대로 신뢰
"use server";
export async function updateUser(formData: FormData) {
const userId = formData.get('userId'); // 위험!
// 클라이언트에서 전송된 userId를 그대로 사용
}
// ✅ 올바른 예시 - 서버에서 인증 확인
"use server";
export async function updateUser(formData: FormData) {
const userId = await getCurrentUserId(); // 서버에서 확인
const name = formData.get('name') as string;
// 추가 권한 검사
const hasPermission = await checkUpdatePermission(userId);
if (!hasPermission) {
throw new Error('권한이 없습니다.');
}
}
2. 에러 처리
// app/actions/safe-actions.ts
"use server";
export async function safeCreatePost(formData: FormData) {
try {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// 유효성 검사
if (!title || title.length < 3) {
return { error: '제목은 3글자 이상이어야 합니다.', data: null };
}
const post = await prisma.post.create({
data: { title, content, authorId: await getCurrentUserId() }
});
revalidatePath('/posts');
return { error: null, data: post };
} catch (error) {
console.error('Post creation failed:', error);
return { error: '게시글 생성에 실패했습니다.', data: null };
}
}
3. TypeScript 타입 안전성
// types/actions.ts
export type ActionResult<T> = {
success: boolean;
data?: T;
error?: string;
};
// app/actions/typed-actions.ts
"use server";
export async function createPost(
formData: FormData
): Promise<ActionResult<{ id: string; title: string }>> {
try {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const post = await prisma.post.create({
data: { title, content, authorId: await getCurrentUserId() }
});
return {
success: true,
data: { id: post.id, title: post.title }
};
} catch (error) {
return {
success: false,
error: '게시글 생성에 실패했습니다.'
};
}
}
성능 최적화 팁
1. revalidatePath 사용
"use server";
export async function updatePost(postId: string, formData: FormData) {
// 포스트 업데이트
await prisma.post.update({
where: { id: postId },
data: { title: formData.get('title') as string }
});
// 관련 페이지들의 캐시 무효화
revalidatePath('/posts');
revalidatePath(`/posts/${postId}`);
revalidatePath('/'); // 홈페이지에 최신 포스트가 표시되는 경우
}
2. 선택적 리렌더링
"use client";
import { useOptimistic } from 'react';
export default function PostList({ posts }: { posts: Post[] }) {
const [optimisticPosts, addOptimisticPost] = useOptimistic(
posts,
(state, newPost: Post) => [...state, newPost]
);
const handleSubmit = async (formData: FormData) => {
// 낙관적 업데이트
const newPost = {
id: 'temp-' + Date.now(),
title: formData.get('title') as string,
content: formData.get('content') as string
} as Post;
addOptimisticPost(newPost);
// 실제 서버 액션 실행
await createPost(formData);
};
return (
<div>
{optimisticPosts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
실무 도입 가이드
단계별 마이그레이션
1단계: 간단한 폼부터 시작
// 기존 API Route 대신 Server Action으로 교체
// 예: 연락처 폼, 뉴스레터 구독 등
2단계: CRUD 작업 적용
// 게시글 작성, 댓글 추가 등
// 기존 API들을 점진적으로 교체
3단계: 복합적인 작업으로 확장
// 파일 업로드, 복잡한 비즈니스 로직 등
// Server Action의 모든 기능 활용
팀 도입 시 고려사항
고려사항 | 해결방안 |
---|---|
학습 곡선 | 간단한 예시부터 시작, 점진적 도입 |
기존 코드와의 호환성 | API Route와 병행 사용 가능 |
디버깅 | 서버 로그 모니터링 체계 구축 |
테스트 | 서버 컴포넌트 테스트 도구 활용 |
결론
Server Action은 Next.js 개발의 새로운 패러다임을 제시합니다. 마치 프론트엔드와 백엔드 사이의 벽을 허무는 것처럼, 개발자가 더 직관적이고 효율적으로 풀스택 애플리케이션을 구축할 수 있게 해줍니다.
핵심 포인트 요약:
- 생산성: API Route 없이도 서버 로직 구현 가능
- 보안: 서버 로직이 클라이언트에 노출되지 않음
- 사용자 경험: JavaScript 없이도 동작하는 Progressive Enhancement
- 성능: 불필요한 네트워크 요청 감소
Server Action을 도입할 때는 보안과 에러 처리에 특별히 주의하고, 점진적으로 적용하는 것이 좋습니다. 이 혁신적인 기능을 통해 더 나은 웹 애플리케이션을 만들어보세요.
'Frontend Development' 카테고리의 다른 글
CSS 쌓임 맥락의 모든 것: Z-Index가 작동하지 않는 이유 (2) | 2025.07.03 |
---|---|
JavaScript 매개변수 전달의 비밀: Call by Value와 참조의 모든 것 (8) | 2025.07.02 |
CSS 위치 조정: Transform vs Position, 언제 무엇을 써야 할까? (5) | 2025.06.27 |
CORS 없이 SOP 우회하기: 프록시 서버를 활용한 스마트한 해결책 (0) | 2025.06.24 |
JavaScript 프로토타입 상속: 객체 간 상속의 핵심 메커니즘 완전 정복 (2) | 2025.06.20 |