Server Actions는 Next.js 13.4에서 도입된 혁신적인 기능으로, 클라이언트와 서버 간의 통신을 획기적으로 단순화합니다. API 라우트를 만들지 않고도 서버에서 직접 함수를 실행할 수 있어, 개발 속도와 코드 유지보수성이 크게 향상됩니다. 이 가이드에서는 Server Actions의 개념부터 실제 프로덕션에서 활용할 수 있는 다양한 패턴까지 상세히 알아보겠습니다.
Server Actions란 무엇인가?
기본 개념 이해하기
Server Actions는 서버에서만 실행되는 비동기 함수입니다. 클라이언트에서 직접 호출할 수 있지만, 실제 코드는 서버에서 실행되어 데이터베이스 접근, 파일 시스템 조작 등 서버 전용 작업을 안전하게 수행할 수 있습니다.
// 전통적인 방식: API 라우트 생성
// app/api/users/route.ts
export async function POST(request: Request) {
const data = await request.json();
const user = await db.user.create({ data });
return Response.json(user);
}
// 클라이언트에서 호출
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(userData)
});
const user = await response.json();
// Server Actions 방식: 직접 함수 호출
// app/_actions/user-actions.ts
'use server'; // 이 지시어가 핵심! 서버에서만 실행됨을 명시
export async function createUser(userData: any) {
// 서버에서만 실행되는 코드
const user = await db.user.create({ data: userData });
return user;
}
// 클라이언트에서 직접 호출
const user = await createUser(userData);
// 마법처럼 보이지만, Next.js가 자동으로 HTTP 요청으로 변환!
Server Actions의 핵심 장점
- 개발 생산성 향상
- API 엔드포인트 작성 불필요
- 타입 안정성 자동 보장
- 코드 중복 제거
- 보안 강화
- 서버 코드가 클라이언트로 노출되지 않음
- 환경 변수와 비밀 키 안전하게 사용
- 성능 최적화
- 클라이언트 번들 크기 감소
- 자동 요청 최적화
폼 처리의 혁신
Progressive Enhancement를 활용한 폼 처리
Server Actions의 가장 강력한 기능 중 하나는 JavaScript가 비활성화된 상태에서도 폼이 작동한다는 것입니다.
// app/_actions/form-actions.ts
'use server';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
// 사용자 등록 Server Action
export async function registerUser(
prevState: any,
formData: FormData
) {
// FormData에서 값 추출
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const name = formData.get('name') as string;
// 입력값 검증
if (!email || !email.includes('@')) {
return {
error: '올바른 이메일을 입력해주세요.',
values: { email, name } // 입력값 유지
};
}
if (password.length < 8) {
return {
error: '비밀번호는 8자 이상이어야 합니다.',
values: { email, name }
};
}
try {
// 데이터베이스에 사용자 생성
const user = await prisma.user.create({
data: {
email,
password: await hashPassword(password),
name
}
});
console.log('✅ 새 사용자 등록:', user.email);
// 캐시 재검증 - 사용자 목록이 있는 페이지 새로고침
revalidatePath('/admin/users');
// 성공 시 리다이렉트
redirect(`/welcome?name=${encodeURIComponent(name)}`);
} catch (error) {
console.error('❌ 사용자 등록 실패:', error);
// 중복 이메일 체크
if (error.code === 'P2002') {
return {
error: '이미 등록된 이메일입니다.',
values: { email, name }
};
}
return {
error: '등록 중 오류가 발생했습니다. 다시 시도해주세요.',
values: { email, name }
};
}
}
이제 이 Server Action을 사용하는 폼 컴포넌트를 만들어보겠습니다:
// app/_components/RegistrationForm.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { registerUser } from '@/actions/form-actions';
// 제출 버튼 컴포넌트 (로딩 상태 표시)
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className={`btn ${pending ? 'opacity-50' : ''}`}
>
{pending ? (
<>
<span className="spinner" /> 등록 중...
</>
) : (
'회원가입'
)}
</button>
);
}
// 메인 폼 컴포넌트
export function RegistrationForm() {
// useFormState로 서버 응답 관리
const [state, formAction] = useFormState(registerUser, {
error: null,
values: { email: '', name: '' }
});
return (
<form action={formAction} className="registration-form">
{/* 에러 메시지 표시 */}
{state?.error && (
<div className="error-banner">
⚠️ {state.error}
</div>
)}
<div className="form-group">
<label htmlFor="email">이메일</label>
<input
id="email"
name="email"
type="email"
required
defaultValue={state?.values?.email}
placeholder="you@example.com"
/>
</div>
<div className="form-group">
<label htmlFor="name">이름</label>
<input
id="name"
name="name"
type="text"
required
defaultValue={state?.values?.name}
placeholder="홍길동"
/>
</div>
<div className="form-group">
<label htmlFor="password">비밀번호</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
placeholder="8자 이상 입력"
/>
<span className="helper-text">
비밀번호는 8자 이상이어야 합니다
</span>
</div>
<SubmitButton />
</form>
);
}
다단계 폼 처리
복잡한 폼을 여러 단계로 나누어 처리하는 패턴:
// app/_actions/multi-step-form.ts
'use server';
import { cookies } from 'next/headers';
// 세션에 임시 데이터 저장
async function saveStepData(step: number, data: any) {
const cookieStore = cookies();
const sessionId = cookieStore.get('form-session')?.value || crypto.randomUUID();
// Redis 또는 데이터베이스에 임시 저장
await redis.setex(
`form:${sessionId}:step${step}`,
3600, // 1시간 TTL
JSON.stringify(data)
);
// 세션 쿠키 설정
cookieStore.set('form-session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 3600
});
return sessionId;
}
// Step 1: 기본 정보
export async function submitStep1(formData: FormData) {
console.log('📝 Step 1: 기본 정보 저장 중...');
const data = {
name: formData.get('name'),
email: formData.get('email'),
phone: formData.get('phone')
};
// 유효성 검증
if (!data.email?.includes('@')) {
return { error: '올바른 이메일을 입력하세요' };
}
// 데이터 임시 저장
const sessionId = await saveStepData(1, data);
console.log('✅ Step 1 완료, 세션 ID:', sessionId);
return { success: true, nextStep: 2 };
}
// Step 2: 상세 정보
export async function submitStep2(formData: FormData) {
const cookieStore = cookies();
const sessionId = cookieStore.get('form-session')?.value;
if (!sessionId) {
return { error: '세션이 만료되었습니다. 처음부터 다시 시작해주세요.' };
}
console.log('📝 Step 2: 상세 정보 저장 중...');
const data = {
address: formData.get('address'),
city: formData.get('city'),
zipCode: formData.get('zipCode')
};
await saveStepData(2, data);
console.log('✅ Step 2 완료');
return { success: true, nextStep: 3 };
}
// Final Step: 모든 데이터 제출
export async function submitFinalStep(formData: FormData) {
const cookieStore = cookies();
const sessionId = cookieStore.get('form-session')?.value;
if (!sessionId) {
return { error: '세션이 만료되었습니다.' };
}
console.log('📝 최종 단계: 모든 데이터 수집 중...');
// 모든 단계의 데이터 가져오기
const [step1Data, step2Data] = await Promise.all([
redis.get(`form:${sessionId}:step1`),
redis.get(`form:${sessionId}:step2`)
]);
if (!step1Data || !step2Data) {
return { error: '저장된 데이터를 찾을 수 없습니다.' };
}
// 최종 데이터 결합
const finalData = {
...JSON.parse(step1Data),
...JSON.parse(step2Data),
preferences: formData.getAll('preferences'),
terms: formData.get('terms') === 'on'
};
console.log('💾 최종 데이터:', finalData);
// 데이터베이스에 저장
const result = await prisma.application.create({
data: finalData
});
// 임시 데이터 정리
await redis.del([
`form:${sessionId}:step1`,
`form:${sessionId}:step2`
]);
cookieStore.delete('form-session');
console.log('✅ 신청 완료! ID:', result.id);
redirect(`/success/${result.id}`);
}
파일 업로드 처리
이미지 업로드와 최적화
Server Actions로 파일 업로드를 처리하는 방법:
// app/_actions/upload-actions.ts
'use server';
import sharp from 'sharp'; // 이미지 처리 라이브러리
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
// S3 클라이언트 설정
const s3Client = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!
}
});
// 파일 크기와 타입 제한
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
export async function uploadProfileImage(formData: FormData) {
console.log('🖼️ 프로필 이미지 업로드 시작...');
try {
const file = formData.get('image') as File;
const userId = formData.get('userId') as string;
// 파일 유효성 검사
if (!file) {
return { error: '파일을 선택해주세요' };
}
console.log(`📁 파일 정보: ${file.name} (${file.size} bytes)`);
if (file.size > MAX_FILE_SIZE) {
return { error: '파일 크기는 5MB 이하여야 합니다' };
}
if (!ALLOWED_TYPES.includes(file.type)) {
return { error: '이미지 파일만 업로드 가능합니다 (JPEG, PNG, WebP)' };
}
// 파일을 Buffer로 변환
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
console.log('🔄 이미지 최적화 중...');
// Sharp를 사용한 이미지 리사이징
const optimizedImages = await Promise.all([
// 썸네일 (150x150)
sharp(buffer)
.resize(150, 150, {
fit: 'cover',
position: 'center'
})
.webp({ quality: 80 })
.toBuffer(),
// 중간 크기 (400x400)
sharp(buffer)
.resize(400, 400, {
fit: 'cover',
position: 'center'
})
.webp({ quality: 85 })
.toBuffer(),
// 큰 크기 (1200x1200, 원본보다 크지 않게)
sharp(buffer)
.resize(1200, 1200, {
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: 90 })
.toBuffer()
]);
console.log('☁️ S3에 업로드 중...');
// S3에 업로드
const timestamp = Date.now();
const uploadPromises = optimizedImages.map(async (imageBuffer, index) => {
const sizes = ['thumb', 'medium', 'large'];
const key = `users/${userId}/profile-${sizes[index]}-${timestamp}.webp`;
await s3Client.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: imageBuffer,
ContentType: 'image/webp',
CacheControl: 'max-age=31536000' // 1년 캐싱
}));
console.log(`✅ ${sizes[index]} 이미지 업로드 완료`);
return key;
});
const uploadedKeys = await Promise.all(uploadPromises);
// URL 생성
const imageUrls = {
thumbnail: `${process.env.CDN_URL}/${uploadedKeys[0]}`,
medium: `${process.env.CDN_URL}/${uploadedKeys[1]}`,
large: `${process.env.CDN_URL}/${uploadedKeys[2]}`
};
console.log('💾 데이터베이스 업데이트 중...');
// 데이터베이스 업데이트
await prisma.user.update({
where: { id: userId },
data: {
profileImage: imageUrls.large,
profileImageThumb: imageUrls.thumbnail,
profileImageMedium: imageUrls.medium
}
});
console.log('🎉 프로필 이미지 업로드 완료!');
revalidatePath(`/users/${userId}`);
return {
success: true,
urls: imageUrls
};
} catch (error) {
console.error('❌ 업로드 실패:', error);
return {
error: '이미지 업로드에 실패했습니다. 다시 시도해주세요.'
};
}
}
업로드 UI 컴포넌트:
// app/_components/ImageUpload.tsx
'use client';
import { useState } from 'react';
import { uploadProfileImage } from '@/actions/upload-actions';
export function ImageUpload({ userId }: { userId: string }) {
const [preview, setPreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState('');
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 미리보기 생성
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(file);
};
const handleUpload = async (formData: FormData) => {
setUploading(true);
setMessage('');
// userId 추가
formData.append('userId', userId);
const result = await uploadProfileImage(formData);
if (result.error) {
setMessage(`❌ ${result.error}`);
} else {
setMessage('✅ 이미지가 성공적으로 업로드되었습니다!');
// 3초 후 메시지 제거
setTimeout(() => setMessage(''), 3000);
}
setUploading(false);
};
return (
<form action={handleUpload} className="image-upload">
<div className="upload-area">
{preview ? (
<img src={preview} alt="미리보기" className="preview" />
) : (
<div className="placeholder">
📷 이미지를 선택하세요
</div>
)}
</div>
<input
type="file"
name="image"
accept="image/*"
onChange={handleFileSelect}
disabled={uploading}
/>
<button
type="submit"
disabled={!preview || uploading}
className="upload-button"
>
{uploading ? '업로드 중...' : '프로필 이미지 변경'}
</button>
{message && (
<div className={message.startsWith('✅') ? 'success' : 'error'}>
{message}
</div>
)}
</form>
);
}
실시간 기능과 낙관적 업데이트
낙관적 업데이트 구현
사용자 경험을 향상시키는 낙관적 업데이트 패턴:
// app/_actions/like-actions.ts
'use server';
export async function toggleLike(postId: string, userId: string) {
console.log(`💝 좋아요 토글: Post ${postId}, User ${userId}`);
try {
// 현재 좋아요 상태 확인
const existingLike = await prisma.like.findUnique({
where: {
userId_postId: {
userId,
postId
}
}
});
let action: 'liked' | 'unliked';
if (existingLike) {
// 좋아요 취소
await prisma.like.delete({
where: { id: existingLike.id }
});
action = 'unliked';
console.log('💔 좋아요 취소됨');
} else {
// 좋아요 추가
await prisma.like.create({
data: { userId, postId }
});
action = 'liked';
console.log('❤️ 좋아요 추가됨');
}
// 좋아요 수 다시 계산
const likeCount = await prisma.like.count({
where: { postId }
});
// 포스트 업데이트
await prisma.post.update({
where: { id: postId },
data: { likeCount }
});
revalidatePath(`/posts/${postId}`);
return {
success: true,
action,
likeCount
};
} catch (error) {
console.error('❌ 좋아요 처리 실패:', error);
return {
success: false,
error: '처리 중 오류가 발생했습니다'
};
}
}
낙관적 업데이트를 적용한 좋아요 버튼:
// app/_components/LikeButton.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleLike } from '@/actions/like-actions';
import { Heart } from 'lucide-react';
export function LikeButton({
postId,
userId,
initialLiked,
initialCount
}: {
postId: string;
userId: string;
initialLiked: boolean;
initialCount: number;
}) {
const [isPending, startTransition] = useTransition();
// useOptimistic으로 즉각적인 UI 업데이트
const [optimisticState, addOptimistic] = useOptimistic(
{ liked: initialLiked, count: initialCount },
(state, newLiked: boolean) => ({
liked: newLiked,
count: newLiked ? state.count + 1 : state.count - 1
})
);
const handleClick = () => {
startTransition(async () => {
// 1. 먼저 UI를 낙관적으로 업데이트 (즉시 반영)
addOptimistic(!optimisticState.liked);
// 2. 서버에 실제 요청 보내기
const result = await toggleLike(postId, userId);
// 3. 실패하면 자동으로 원래 상태로 롤백됨
if (!result.success) {
console.error('좋아요 처리 실패');
}
});
};
return (
<button
onClick={handleClick}
disabled={isPending}
className={`like-button ${optimisticState.liked ? 'liked' : ''}`}
aria-label={optimisticState.liked ? '좋아요 취소' : '좋아요'}
>
<Heart
className={optimisticState.liked ? 'fill-red-500 text-red-500' : ''}
size={20}
/>
<span className="like-count">{optimisticState.count}</span>
{isPending && <span className="spinner" />}
</button>
);
}
실시간 댓글 시스템
// app/_actions/comment-actions.ts
'use server';
export async function addComment(
postId: string,
content: string,
userId: string
) {
console.log('💬 새 댓글 추가 중...');
// 입력값 검증
const trimmedContent = content.trim();
if (!trimmedContent) {
return { error: '댓글 내용을 입력해주세요' };
}
if (trimmedContent.length > 500) {
return { error: '댓글은 500자 이내로 작성해주세요' };
}
try {
// 댓글 생성
const comment = await prisma.comment.create({
data: {
postId,
userId,
content: trimmedContent
},
include: {
user: {
select: {
id: true,
name: true,
profileImage: true
}
}
}
});
console.log('✅ 댓글 생성 완료:', comment.id);
// 포스트 작성자에게 알림 생성
const post = await prisma.post.findUnique({
where: { id: postId },
select: { authorId: true, title: true }
});
if (post && post.authorId !== userId) {
await prisma.notification.create({
data: {
userId: post.authorId,
type: 'COMMENT',
title: '새 댓글',
message: `${comment.user.name}님이 "${post.title}"에 댓글을 남겼습니다`,
link: `/posts/${postId}#comment-${comment.id}`
}
});
console.log('🔔 알림 전송 완료');
}
// 페이지 재검증
revalidatePath(`/posts/${postId}`);
return {
success: true,
comment
};
} catch (error) {
console.error('❌ 댓글 추가 실패:', error);
return {
error: '댓글 추가에 실패했습니다. 다시 시도해주세요.'
};
}
}
export async function deleteComment(
commentId: string,
userId: string
) {
console.log('🗑️ 댓글 삭제 요청:', commentId);
try {
// 권한 확인
const comment = await prisma.comment.findUnique({
where: { id: commentId }
});
if (!comment) {
return { error: '댓글을 찾을 수 없습니다' };
}
if (comment.userId !== userId) {
return { error: '삭제 권한이 없습니다' };
}
// 소프트 삭제 (내용만 변경)
await prisma.comment.update({
where: { id: commentId },
data: {
content: '삭제된 댓글입니다',
deletedAt: new Date()
}
});
console.log('✅ 댓글 삭제 완료');
revalidatePath(`/posts/${comment.postId}`);
return { success: true };
} catch (error) {
console.error('❌ 댓글 삭제 실패:', error);
return {
error: '댓글 삭제에 실패했습니다'
};
}
}
보안과 권한 관리
인증과 권한 검증
Server Actions에서 안전하게 사용자 인증과 권한을 관리하는 방법:
// app/_lib/auth-wrapper.ts
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
// 인증 래퍼 함수
export function withAuth<T extends any[], R>(
action: (userId: string, ...args: T) => Promise<R>
) {
return async (...args: T): Promise<R> => {
const session = await auth();
if (!session?.user?.id) {
redirect('/login');
}
console.log(`🔐 인증된 사용자: ${session.user.email}`);
return action(session.user.id, ...args);
};
}
// 역할 기반 접근 제어
export function withRole<T extends any[], R>(
allowedRoles: string[],
action: (userId: string, userRole: string, ...args: T) => Promise<R>
) {
return async (...args: T): Promise<R> => {
const session = await auth();
if (!session?.user?.id) {
redirect('/login');
}
if (!allowedRoles.includes(session.user.role)) {
console.warn(`⛔ 권한 거부: ${session.user.email} (${session.user.role})`);
throw new Error('권한이 없습니다');
}
console.log(`👮 권한 확인: ${session.user.email} (${session.user.role})`);
return action(session.user.id, session.user.role, ...args);
};
}
// app/_actions/secure-actions.ts
'use server';
import { withAuth, withRole } from '@/lib/auth-wrapper';
// 인증이 필요한 액션
export const updateProfile = withAuth(async (userId, formData: FormData) => {
console.log(`📝 프로필 업데이트: User ${userId}`);
const name = formData.get('name') as string;
const bio = formData.get('bio') as string;
await prisma.user.update({
where: { id: userId },
data: { name, bio }
});
revalidatePath(`/users/${userId}`);
return { success: true };
});
// 관리자 권한이 필요한 액션
export const deleteUser = withRole(
['ADMIN', 'SUPER_ADMIN'],
async (adminId, adminRole, targetUserId: string) => {
console.log(`🗑️ 사용자 삭제 요청: ${targetUserId} by ${adminId}`);
// 추가 권한 검증
if (adminRole === 'ADMIN') {
const targetUser = await prisma.user.findUnique({
where: { id: targetUserId }
});
// ADMIN은 일반 사용자만 삭제 가능
if (targetUser?.role !== 'USER') {
throw new Error('일반 사용자만 삭제할 수 있습니다');
}
}
// 사용자 삭제
await prisma.user.delete({
where: { id: targetUserId }
});
// 감사 로그
await prisma.auditLog.create({
data: {
action: 'DELETE_USER',
performedBy: adminId,
targetId: targetUserId,
timestamp: new Date()
}
});
console.log(`✅ 사용자 ${targetUserId} 삭제 완료`);
revalidatePath('/admin/users');
return { success: true };
}
);
Rate Limiting
과도한 요청을 방지하는 Rate Limiting 구현:
// app/_lib/rate-limiter.ts
import { LRUCache } from 'lru-cache';
const rateLimitCache = new LRUCache<string, number>({
max: 500, // 최대 500개 키 저장
ttl: 60 * 1000 // 1분 TTL
});
export async function rateLimit(
identifier: string,
limit: number = 10
): Promise<{ success: boolean; remaining: number }> {
const current = rateLimitCache.get(identifier) || 0;
if (current >= limit) {
console.warn(`⚠️ Rate limit 초과: ${identifier}`);
return { success: false, remaining: 0 };
}
rateLimitCache.set(identifier, current + 1);
return {
success: true,
remaining: limit - current - 1
};
}
// app/_actions/rate-limited-actions.ts
'use server';
export async function sendMessage(formData: FormData) {
const session = await auth();
const userId = session?.user?.id || 'anonymous';
// Rate limiting 적용
const { success, remaining } = await rateLimit(
`message:${userId}`,
5 // 분당 5개 메시지
);
if (!success) {
return {
error: '너무 많은 요청입니다. 잠시 후 다시 시도해주세요.'
};
}
console.log(`📨 메시지 전송 (남은 횟수: ${remaining})`);
// 메시지 처리 로직...
return { success: true, remaining };
}
실제 구현 예시: 전자상거래 주문 시스템
모든 개념을 종합한 실제 주문 처리 시스템:
// app/_actions/order-actions.ts
'use server';
import { withAuth } from '@/lib/auth-wrapper';
import { prisma } from '@/lib/prisma';
export const createOrder = withAuth(async (
userId: string,
items: Array<{ productId: string; quantity: number }>
) => {
console.log('🛒 주문 생성 시작...');
console.log(`사용자: ${userId}, 상품 수: ${items.length}`);
try {
// 트랜잭션으로 주문 처리
const order = await prisma.$transaction(async (tx) => {
console.log('🔍 재고 확인 중...');
// 1. 재고 확인 및 상품 정보 조회
const products = await Promise.all(
items.map(item =>
tx.product.findUnique({
where: { id: item.productId },
select: {
id: true,
name: true,
price: true,
stock: true
}
})
)
);
// 재고 부족 체크
for (let i = 0; i < items.length; i++) {
const product = products[i];
const item = items[i];
if (!product) {
throw new Error(`상품을 찾을 수 없습니다: ${item.productId}`);
}
if (product.stock < item.quantity) {
throw new Error(
`재고 부족: ${product.name} (남은 수량: ${product.stock})`
);
}
console.log(`✅ ${product.name}: ${item.quantity}개 구매 가능`);
}
// 2. 총 금액 계산
const totalAmount = items.reduce((sum, item, index) => {
return sum + (products[index].price * item.quantity);
}, 0);
console.log(`💰 총 금액: ${totalAmount.toLocaleString()}원`);
// 3. 주문 생성
console.log('📝 주문 정보 생성 중...');
const order = await tx.order.create({
data: {
userId,
status: 'PENDING',
totalAmount,
orderNumber: `ORD-${Date.now()}`,
orderItems: {
create: items.map((item, index) => ({
productId: item.productId,
quantity: item.quantity,
price: products[index].price
}))
}
},
include: {
orderItems: {
include: {
product: true
}
}
}
});
console.log(`✅ 주문 생성 완료: ${order.orderNumber}`);
// 4. 재고 차감
console.log('📦 재고 업데이트 중...');
await Promise.all(
items.map(item =>
tx.product.update({
where: { id: item.productId },
data: {
stock: {
decrement: item.quantity
}
}
})
)
);
// 5. 장바구니 비우기
await tx.cartItem.deleteMany({
where: { userId }
});
console.log('✅ 장바구니 비움');
return order;
});
// 6. 후처리 작업 (트랜잭션 외부)
console.log('📧 주문 확인 이메일 전송 중...');
// 이메일 전송 (백그라운드)
sendOrderConfirmationEmail(order.id).catch(console.error);
// 재고 알림 확인 (백그라운드)
checkLowStockAlert(items.map(i => i.productId)).catch(console.error);
// 캐시 재검증
revalidatePath('/orders');
revalidatePath('/cart');
console.log('🎉 주문 처리 완료!');
return {
success: true,
orderId: order.id,
orderNumber: order.orderNumber,
totalAmount: order.totalAmount
};
} catch (error) {
console.error('❌ 주문 실패:', error);
return {
success: false,
error: error.message || '주문 처리 중 오류가 발생했습니다'
};
}
});
// 보조 함수들
async function sendOrderConfirmationEmail(orderId: string) {
console.log(`📧 주문 ${orderId} 확인 이메일 전송`);
// 실제 이메일 전송 로직
}
async function checkLowStockAlert(productIds: string[]) {
const products = await prisma.product.findMany({
where: {
id: { in: productIds },
stock: { lte: 10 } // 재고 10개 이하
}
});
if (products.length > 0) {
console.log('⚠️ 재고 부족 알림:', products.map(p => p.name));
// 관리자에게 알림 전송
}
}
모범 사례와 주의사항
DO’s (권장 사항)
- 항상 입력값 검증하기
// ✅ 좋은 예 export async function createPost(formData: FormData) { const title = formData.get('title')?.toString().trim(); if (!title || title.length < 5) { return { error: '제목은 5자 이상이어야 합니다' }; } // ... } - 적절한 에러 처리
// ✅ 좋은 예 try { const result = await riskyOperation(); return { success: true, data: result }; } catch (error) { console.error('Operation failed:', error); return { success: false, error: '처리 중 오류가 발생했습니다' }; } - 트랜잭션 활용
- 여러 데이터베이스 작업이 연관된 경우 트랜잭션 사용
- 일관성 보장
- 캐시 재검증
- 데이터 변경 후 관련 페이지 재검증
- revalidatePath와 revalidateTag 적절히 활용
DON’Ts (주의 사항)
- 민감한 정보 노출 금지
// ❌ 나쁜 예 return { error: `Database error: ${error.message}` }; // ✅ 좋은 예 console.error('DB Error:', error); return { error: '처리 중 오류가 발생했습니다' }; - 과도한 데이터 반환 피하기
- 필요한 데이터만 선택적으로 반환
- 대용량 데이터는 페이지네이션 적용
- 동기 작업 피하기
- 모든 Server Action은 async 함수여야 함
- 블로킹 작업 피하기
결론
Server Actions는 Next.js 애플리케이션 개발을 혁신적으로 간소화하는 강력한 기능입니다. API 엔드포인트 없이도 서버 로직을 구현할 수 있고, 타입 안정성과 보안을 자동으로 보장받을 수 있습니다.
핵심 이점:
- 개발 속도 향상: API 라우트 작성 불필요
- 타입 안정성: TypeScript와 완벽 통합
- Progressive Enhancement: JavaScript 없이도 작동
- 보안 강화: 서버 코드가 클라이언트로 노출되지 않음
- 번들 크기 최적화: 서버 코드가 클라이언트 번들에 포함되지 않음
Server Actions를 마스터하면 더 빠르고, 안전하며, 유지보수가 쉬운 Next.js 애플리케이션을 개발할 수 있습니다. 폼 처리, 파일 업로드, 실시간 기능, 보안 등 다양한 시나리오에서 Server Actions를 활용하여 개발 생산성을 극대화하세요.