[Next.js] Server Actions 실전 활용 사례




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의 핵심 장점

  1. 개발 생산성 향상
    • API 엔드포인트 작성 불필요
    • 타입 안정성 자동 보장
    • 코드 중복 제거
  2. 보안 강화
    • 서버 코드가 클라이언트로 노출되지 않음
    • 환경 변수와 비밀 키 안전하게 사용
  3. 성능 최적화
    • 클라이언트 번들 크기 감소
    • 자동 요청 최적화

폼 처리의 혁신

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 (권장 사항)

  1. 항상 입력값 검증하기 // ✅ 좋은 예 export async function createPost(formData: FormData) { const title = formData.get('title')?.toString().trim(); if (!title || title.length < 5) { return { error: '제목은 5자 이상이어야 합니다' }; } // ... }
  2. 적절한 에러 처리 // ✅ 좋은 예 try { const result = await riskyOperation(); return { success: true, data: result }; } catch (error) { console.error('Operation failed:', error); return { success: false, error: '처리 중 오류가 발생했습니다' }; }
  3. 트랜잭션 활용
    • 여러 데이터베이스 작업이 연관된 경우 트랜잭션 사용
    • 일관성 보장
  4. 캐시 재검증
    • 데이터 변경 후 관련 페이지 재검증
    • revalidatePath와 revalidateTag 적절히 활용

DON’Ts (주의 사항)

  1. 민감한 정보 노출 금지 // ❌ 나쁜 예 return { error: `Database error: ${error.message}` }; // ✅ 좋은 예 console.error('DB Error:', error); return { error: '처리 중 오류가 발생했습니다' };
  2. 과도한 데이터 반환 피하기
    • 필요한 데이터만 선택적으로 반환
    • 대용량 데이터는 페이지네이션 적용
  3. 동기 작업 피하기
    • 모든 Server Action은 async 함수여야 함
    • 블로킹 작업 피하기

결론

Server Actions는 Next.js 애플리케이션 개발을 혁신적으로 간소화하는 강력한 기능입니다. API 엔드포인트 없이도 서버 로직을 구현할 수 있고, 타입 안정성과 보안을 자동으로 보장받을 수 있습니다.

핵심 이점:

  • 개발 속도 향상: API 라우트 작성 불필요
  • 타입 안정성: TypeScript와 완벽 통합
  • Progressive Enhancement: JavaScript 없이도 작동
  • 보안 강화: 서버 코드가 클라이언트로 노출되지 않음
  • 번들 크기 최적화: 서버 코드가 클라이언트 번들에 포함되지 않음

Server Actions를 마스터하면 더 빠르고, 안전하며, 유지보수가 쉬운 Next.js 애플리케이션을 개발할 수 있습니다. 폼 처리, 파일 업로드, 실시간 기능, 보안 등 다양한 시나리오에서 Server Actions를 활용하여 개발 생산성을 극대화하세요.




댓글 남기기