본문 바로가기
Front/NextJS

Next.js 14 Server Actions 이해하기

by Awesome-SH 2025. 8. 8.

 

Next.js 14 Server Actions 풀스택 개발의 새로운 패러다임

Next.js 14에서 정식 출시된 Server Actions는 React 서버 컴포넌트와 함께 풀스택 웹 개발의 패러다임을 완전히 바꾸고 있습니다. 기존의 API Routes를 대체하여 서버 사이드 로직을 컴포넌트 내부에서 직접 실행할 수 있게 해주는 이 혁신적인 기능에 대해 실무에 바로 적용할 수 있는 완벽한 가이드를 제공합니다.

Server Actions는 단순히 새로운 기능을 넘어서, 개발자 경험(DX) 향상성능 최적화를 동시에 달성할 수 있는 게임 체인저입니다. 이 포스팅을 통해 Next.js 14의 핵심 기능을 마스터하고 현대적인 풀스택 애플리케이션을 구축해보세요.


📋 목차

  1. Server Actions 기본 개념과 동작 원리
  2. 개발 환경 설정과 프로젝트 구조
  3. 기본적인 Server Actions 구현
  4. 폼 처리와 데이터 검증
  5. 데이터베이스 연동과 CRUD 작업
  6. 에러 핸들링과 사용자 피드백
  7. 보안과 최적화 전략
  8. 실전 프로젝트 예제
  9. 성능 모니터링과 디버깅
  10. 마이그레이션 가이드

🔍 Server Actions 기본 개념과 동작 원리

기존 API Routes vs Server Actions 비교

기존 API Routes 방식:

// pages/api/users.ts (Pages Router)
import { NextApiRequest, NextApiResponse } from 'next'
import { createUser } from '@/lib/database'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method === 'POST') {
    try {
      const user = await createUser(req.body)
      res.status(201).json(user)
    } catch (error) {
      res.status(500).json({ error: 'Failed to create user' })
    }
  }
}

// 클라이언트에서 API 호출
const handleSubmit = async (formData: FormData) => {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(formData)
  })
  const result = await response.json()
}

Next.js 14 Server Actions 방식:

// app/actions/users.ts
import { createUser } from '@/lib/database'
import { revalidatePath } from 'next/cache'

export async function createUserAction(formData: FormData) {
  'use server'
  
  try {
    const user = await createUser({
      name: formData.get('name') as string,
      email: formData.get('email') as string
    })
    
    revalidatePath('/users')
    return { success: true, user }
  } catch (error) {
    return { success: false, error: 'Failed to create user' }
  }
}

// 컴포넌트에서 직접 사용
export default function UserForm() {
  return (
    <form action={createUserAction}>
      <input name="name" placeholder="이름" required />
      <input name="email" type="email" placeholder="이메일" required />
      <button type="submit">사용자 생성</button>
    </form>
  )
}

Server Actions의 핵심 장점

  1. 코드 분리 없이 풀스택 개발: 컴포넌트와 서버 로직을 한 파일에서 관리
  2. 자동 타입 안전성: TypeScript로 클라이언트-서버 간 완전한 타입 보장
  3. 향상된 성능: 불필요한 네트워크 요청 및 직렬화 과정 제거
  4. 자동 캐시 무효화: revalidatePath, revalidateTag로 효율적인 캐시 관리
  5. Progressive Enhancement: JavaScript 없이도 동작하는 기본 폼 기능

⚙️ 개발 환경 설정과 프로젝트 구조

Next.js 14 프로젝트 초기화

# Next.js 14 프로젝트 생성
npx create-next-app@latest my-server-actions-app \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"

cd my-server-actions-app

# 필수 의존성 설치
npm install prisma @prisma/client zod
npm install -D prisma-client-js @types/node

# 개발 서버 실행
npm run dev

프로젝트 구조 최적화

src/
├── app/                    # App Router 디렉토리
│   ├── actions/           # Server Actions 모음
│   │   ├── auth.ts
│   │   ├── users.ts
│   │   └── posts.ts
│   ├── components/        # 컴포넌트
│   │   ├── ui/           # 재사용 UI 컴포넌트
│   │   └── forms/        # 폼 컴포넌트
│   ├── lib/              # 유틸리티 및 설정
│   │   ├── database.ts
│   │   ├── validation.ts
│   │   └── auth.ts
│   ├── types/            # TypeScript 타입 정의
│   └── (dashboard)/      # 라우트 그룹
│       ├── users/
│       └── posts/
├── prisma/               # 데이터베이스 스키마
│   └── schema.prisma
└── public/               # 정적 자원

TypeScript 설정 최적화

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": {
      "@/*": ["./src/*"],
      "@/components/*": ["./src/components/*"],
      "@/actions/*": ["./src/app/actions/*"],
      "@/lib/*": ["./src/lib/*"],
      "@/types/*": ["./src/types/*"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts"
  ],
  "exclude": ["node_modules"]
}

🎯 기본적인 Server Actions 구현

간단한 Server Action 생성

// app/actions/basic.ts
'use server'

import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

// 기본적인 Server Action
export async function simpleAction() {
  console.log('서버에서 실행됨!')
  
  // 캐시 무효화
  revalidatePath('/')
  
  return { message: '작업 완료!' }
}

// 매개변수가 있는 Server Action
export async function greetUser(name: string) {
  'use server'
  
  return { greeting: `안녕하세요, ${name}님!` }
}

// FormData를 받는 Server Action
export async function processForm(formData: FormData) {
  'use server'
  
  const name = formData.get('name') as string
  const email = formData.get('email') as string
  
  // 간단한 검증
  if (!name || !email) {
    return { error: '이름과 이메일을 모두 입력해주세요.' }
  }
  
  // 처리 로직
  console.log('받은 데이터:', { name, email })
  
  // 성공 시 리다이렉트
  redirect('/success')
}

컴포넌트에서 Server Actions 사용하기

// app/components/BasicForm.tsx
import { simpleAction, processForm } from '@/actions/basic'

export default function BasicForm() {
  return (
    <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
      <h2 className="text-2xl font-bold mb-6">기본 Server Actions 예제</h2>
      
      {/* 간단한 버튼 액션 */}
      <form action={simpleAction} className="mb-6">
        <button 
          type="submit"
          className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
        >
          간단한 액션 실행
        </button>
      </form>
      
      {/* 폼 데이터 처리 */}
      <form action={processForm} className="space-y-4">
        <div>
          <label htmlFor="name" className="block text-sm font-medium mb-1">
            이름
          </label>
          <input
            type="text"
            id="name"
            name="name"
            required
            className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
            placeholder="이름을 입력하세요"
          />
        </div>
        
        <div>
          <label htmlFor="email" className="block text-sm font-medium mb-1">
            이메일
          </label>
          <input
            type="email"
            id="email"
            name="email"
            required
            className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
            placeholder="이메일을 입력하세요"
          />
        </div>
        
        <button 
          type="submit"
          className="w-full bg-green-500 text-white py-2 rounded hover:bg-green-600"
        >
          폼 제출
        </button>
      </form>
    </div>
  )
}

📝 폼 처리와 데이터 검증

Zod를 활용한 스키마 검증

// lib/validation.ts
import { z } from 'zod'

export const userSchema = z.object({
  name: z.string()
    .min(2, '이름은 2글자 이상이어야 합니다')
    .max(50, '이름은 50글자를 초과할 수 없습니다'),
  email: z.string()
    .email('올바른 이메일 형식이 아닙니다'),
  age: z.number()
    .min(0, '나이는 0 이상이어야 합니다')
    .max(150, '나이는 150 이하여야 합니다'),
  role: z.enum(['USER', 'ADMIN'], {
    errorMap: () => ({ message: '유효한 권한을 선택해주세요' })
  })
})

export const postSchema = z.object({
  title: z.string()
    .min(1, '제목은 필수입니다')
    .max(200, '제목은 200자를 초과할 수 없습니다'),
  content: z.string()
    .min(10, '내용은 10글자 이상이어야 합니다')
    .max(5000, '내용은 5000자를 초과할 수 없습니다'),
  category: z.string().min(1, '카테고리를 선택해주세요'),
  published: z.boolean().default(false)
})

export type UserInput = z.infer<typeof userSchema>
export type PostInput = z.infer<typeof postSchema>

고급 폼 처리 Server Actions

// app/actions/forms.ts
'use server'

import { userSchema, postSchema, type UserInput } from '@/lib/validation'
import { prisma } from '@/lib/database'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

// 타입 안전한 사용자 생성 Action
export async function createUser(formData: FormData) {
  try {
    // FormData를 객체로 변환
    const rawData = {
      name: formData.get('name') as string,
      email: formData.get('email') as string,
      age: parseInt(formData.get('age') as string),
      role: formData.get('role') as 'USER' | 'ADMIN'
    }
    
    // Zod로 데이터 검증
    const validatedData = userSchema.parse(rawData)
    
    // 이메일 중복 체크
    const existingUser = await prisma.user.findUnique({
      where: { email: validatedData.email }
    })
    
    if (existingUser) {
      return {
        success: false,
        error: '이미 존재하는 이메일입니다.',
        fieldErrors: { email: '이미 사용 중인 이메일입니다.' }
      }
    }
    
    // 사용자 생성
    const user = await prisma.user.create({
      data: validatedData
    })
    
    // 캐시 무효화
    revalidatePath('/users')
    
    return {
      success: true,
      message: '사용자가 성공적으로 생성되었습니다.',
      user: {
        id: user.id,
        name: user.name,
        email: user.email
      }
    }
  } catch (error) {
    if (error instanceof z.ZodError) {
      return {
        success: false,
        error: '입력 데이터가 올바르지 않습니다.',
        fieldErrors: error.flatten().fieldErrors
      }
    }
    
    console.error('User creation error:', error)
    return {
      success: false,
      error: '사용자 생성 중 오류가 발생했습니다.'
    }
  }
}

// 게시글 생성 Action (파일 업로드 포함)
export async function createPost(formData: FormData) {
  'use server'
  
  try {
    const rawData = {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
      category: formData.get('category') as string,
      published: formData.get('published') === 'true'
    }
    
    const validatedData = postSchema.parse(rawData)
    
    // 파일 처리
    const image = formData.get('image') as File | null
    let imageUrl: string | null = null
    
    if (image && image.size > 0) {
      // 파일 크기 제한 (5MB)
      if (image.size > 5 * 1024 * 1024) {
        return {
          success: false,
          error: '이미지 파일은 5MB 이하여야 합니다.',
          fieldErrors: { image: '파일 크기가 너무 큽니다.' }
        }
      }
      
      // 이미지 업로드 처리 (실제 구현에서는 클라우드 스토리지 사용)
      imageUrl = await uploadImage(image)
    }
    
    // 게시글 생성
    const post = await prisma.post.create({
      data: {
        ...validatedData,
        imageUrl,
        authorId: 1 // 실제로는 세션에서 가져옴
      }
    })
    
    revalidatePath('/posts')
    redirect(`/posts/${post.id}`)
  } catch (error) {
    if (error instanceof z.ZodError) {
      return {
        success: false,
        error: '입력 데이터를 확인해주세요.',
        fieldErrors: error.flatten().fieldErrors
      }
    }
    
    return {
      success: false,
      error: '게시글 생성 중 오류가 발생했습니다.'
    }
  }
}

// 이미지 업로드 헬퍼 함수
async function uploadImage(file: File): Promise<string> {
  // 실제 구현에서는 AWS S3, Cloudinary 등 사용
  const bytes = await file.arrayBuffer()
  const buffer = Buffer.from(bytes)
  
  // 임시 구현 (실제로는 클라우드 스토리지에 업로드)
  const filename = `${Date.now()}-${file.name}`
  // await fs.writeFile(`./public/uploads/${filename}`, buffer)
  
  return `/uploads/${filename}`
}

클라이언트 컴포넌트와 Server Actions 통합

// app/components/AdvancedForm.tsx
'use client'

import { useFormState } from 'react-dom'
import { createUser } from '@/actions/forms'

const initialState = {
  success: false,
  error: null,
  fieldErrors: {}
}

export default function AdvancedUserForm() {
  const [state, formAction] = useFormState(createUser, initialState)
  
  return (
    <div className="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow-lg">
      <h2 className="text-3xl font-bold mb-6 text-gray-800">
        사용자 등록
      </h2>
      
      {/* 전역 에러 메시지 */}
      {state?.error && (
        <div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
          <p className="text-red-600">{state.error}</p>
        </div>
      )}
      
      {/* 성공 메시지 */}
      {state?.success && (
        <div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-md">
          <p className="text-green-600">{state.message}</p>
        </div>
      )}
      
      <form action={formAction} className="space-y-6">
        {/* 이름 입력 */}
        <div>
          <label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
            이름 <span className="text-red-500">*</span>
          </label>
          <input
            type="text"
            id="name"
            name="name"
            required
            className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
              state?.fieldErrors?.name ? 'border-red-500' : 'border-gray-300'
            }`}
            placeholder="이름을 입력하세요"
          />
          {state?.fieldErrors?.name && (
            <p className="mt-1 text-sm text-red-600">
              {state.fieldErrors.name[0]}
            </p>
          )}
        </div>
        
        {/* 이메일 입력 */}
        <div>
          <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
            이메일 <span className="text-red-500">*</span>
          </label>
          <input
            type="email"
            id="email"
            name="email"
            required
            className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
              state?.fieldErrors?.email ? 'border-red-500' : 'border-gray-300'
            }`}
            placeholder="이메일을 입력하세요"
          />
          {state?.fieldErrors?.email && (
            <p className="mt-1 text-sm text-red-600">
              {state.fieldErrors.email[0]}
            </p>
          )}
        </div>
        
        {/* 나이 입력 */}
        <div>
          <label htmlFor="age" className="block text-sm font-medium text-gray-700 mb-2">
            나이 <span className="text-red-500">*</span>
          </label>
          <input
            type="number"
            id="age"
            name="age"
            min="0"
            max="150"
            required
            className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
              state?.fieldErrors?.age ? 'border-red-500' : 'border-gray-300'
            }`}
            placeholder="나이를 입력하세요"
          />
          {state?.fieldErrors?.age && (
            <p className="mt-1 text-sm text-red-600">
              {state.fieldErrors.age[0]}
            </p>
          )}
        </div>
        
        {/* 권한 선택 */}
        <div>
          <label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-2">
            권한 <span className="text-red-500">*</span>
          </label>
          <select
            id="role"
            name="role"
            required
            className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
              state?.fieldErrors?.role ? 'border-red-500' : 'border-gray-300'
            }`}
          >
            <option value="">권한을 선택하세요</option>
            <option value="USER">일반 사용자</option>
            <option value="ADMIN">관리자</option>
          </select>
          {state?.fieldErrors?.role && (
            <p className="mt-1 text-sm text-red-600">
              {state.fieldErrors.role[0]}
            </p>
          )}
        </div>
        
        {/* 제출 버튼 */}
        <button
          type="submit"
          className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-semibold hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition duration-200"
        >
          사용자 등록
        </button>
      </form>
    </div>
  )
}

🗄️ 데이터베이스 연동과 CRUD 작업

Prisma 스키마 설정

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql" // 또는 "mysql", "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  age       Int
  role      Role     @default(USER)
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("users")
}

model Post {
  id          Int      @id @default(autoincrement())
  title       String
  content     String
  imageUrl    String?
  category    String
  published   Boolean  @default(false)
  author      User     @relation(fields: [authorId], references: [id])
  authorId    Int
  views       Int      @default(0)
  likes       Int      @default(0)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  @@map("posts")
}

enum Role {
  USER
  ADMIN
}

데이터베이스 연결 설정

// lib/database.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: ['query', 'error', 'warn'],
  })

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

// 데이터베이스 연결 테스트
export async function testConnection() {
  try {
    await prisma.$connect()
    console.log('✅ 데이터베이스 연결 성공')
  } catch (error) {
    console.error('❌ 데이터베이스 연결 실패:', error)
    process.exit(1)
  }
}

완전한 CRUD Server Actions

// app/actions/crud.ts
'use server'

import { prisma } from '@/lib/database'
import { userSchema, postSchema } from '@/lib/validation'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'

// 사용자 관련 CRUD Actions
export async function getUsers(page = 1, limit = 10) {
  try {
    const skip = (page - 1) * limit
    
    const [users, total] = await Promise.all([
      prisma.user.findMany({
        skip,
        take: limit,
        orderBy: { createdAt: 'desc' },
        include: {
          _count: { select: { posts: true } }
        }
      }),
      prisma.user.count()
    ])
    
    return {
      users,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit)
      }
    }
  } catch (error) {
    console.error('사용자 목록 조회 실패:', error)
    throw new Error('사용자 목록을 불러올 수 없습니다.')
  }
}

export async function getUserById(id: number) {
  try {
    const user = await prisma.user.findUnique({
      where: { id },
      include: {
        posts: {
          orderBy: { createdAt: 'desc' },
          take: 5
        },
        _count: { select: { posts: true } }
      }
    })
    
    if (!user) {
      throw new Error('사용자를 찾을 수 없습니다.')
    }
    
    return user
  } catch (error) {
    console.error('사용자 조회 실패:', error)
    throw error
  }
}

export async function updateUser(id: number, formData: FormData) {
  'use server'
  
  try {
    const rawData = {
      name: formData.get('name') as string,
      email: formData.get('email') as string,
      age: parseInt(formData.get('age') as string),
      role: formData.get('role') as 'USER' | 'ADMIN'
    }
    
    const validatedData = userSchema.parse(rawData)
    
    // 이메일 중복 체크 (자기 자신 제외)
    const existingUser = await prisma.user.findFirst({
      where: {
        email: validatedData.email,
        NOT: { id }
      }
    })
    
    if (existingUser) {
      return {
        success: false,
        error: '이미 사용 중인 이메일입니다.'
      }
    }
    
    const user = await prisma.user.update({
      where: { id },
      data: validatedData
    })
    
    revalidatePath('/users')
    revalidatePath(`/users/${id}`)
    
    return {
      success: true,
      message: '사용자 정보가 업데이트되었습니다.',
      user
    }
  } catch (error) {
    if (error instanceof z.ZodError) {
      return {
        success: false,
        error: '입력 데이터를 확인해주세요.',
        fieldErrors: error.flatten().fieldErrors
      }
    }
    
    console.error('사용자 업데이트 실패:', error)
    return {
      success: false,
      error: '사용자 정보 업데이트 중 오류가 발생했습니다.'
    }
  }
}

export async function deleteUser(id: number) {
  'use server'
  
  try {
    // 사용자의 게시글이 있는지 확인
    const postsCount = await prisma.post.count({
      where: { authorId: id }
    })
    
    if (postsCount > 0) {
      return {
        success: false,
        error: `삭제할 수 없습니다. 이 사용자가 작성한 게시글이 ${postsCount}개 있습니다.`
      }
    }
    
    await prisma.user.delete({
      where: { id }
    })
    
    revalidatePath('/users')
    
    return {
      success: true,
      message: '사용자가 삭제되었습니다.'
    }
  } catch (error) {
    console.error('사용자 삭제 실패:', error)
    return {
      success: false,
      error: '사용자 삭제 중 오류가 발생했습니다.'
    }
  }
}

// 게시글 관련 CRUD Actions
export async function getPosts(filters: {
  category?: string
  published?: boolean
  authorId?: number
  page?: number
  limit?: number
  search?: string
} = {}) {
  const {
    category,
    published,
    authorId,
    page = 1,
    limit = 10,
    search
  } = filters
  
  const skip = (page - 1) * limit
  
  const where = {
    ...(category && { category }),
    ...(published !== undefined && { published }),
    ...(authorId && { authorId }),
    ...(search && {
      OR: [
        { title: { contains: search, mode: 'insensitive' as const } },
        { content: { contains: search, mode: 'insensitive' as const } }
      ]
    })
  }
  
  try {
    const [posts, total] = await Promise.all([
      prisma.post.findMany({
        where,
        skip,
        take: limit,
        orderBy: { createdAt: 'desc' },
        include: {
          author: {
            select: { id: true, name: true, email: true }
          }
        }
      }),
      prisma.post.count({ where })
    ])
    
    return {
      posts,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit)
      }
    }
  } catch (error) {
    console.error('게시글 목록 조회 실패:', error)
    throw new Error('게시글 목록을 불러올 수 없습니다.')
  }
}

export async function togglePostLike(postId: number) {
  'use server'
  
  try {
    const post = await prisma.post.findUnique({
      where: { id: postId },
      select: { likes: true }
    })
    
    if (!post) {
      return { success: false, error: '게시글을 찾을 수 없습니다.' }
    }
    
    const updatedPost = await prisma.post.update({
      where: { id: postId },
      data: { likes: { increment: 1 } }
    })
    
    revalidatePath('/posts')
    revalidatePath(`/posts/${postId}`)
    
    return { success: true, likes: updatedPost.likes }
  } catch (error) {
    console.error('좋아요 토글 실패:', error)
    return { success: false, error: '좋아요 처리 중 오류가 발생했습니다.' }
  }
}

export async function incrementPostViews(postId: number) {
  'use server'
  
  try {
    await prisma.post.update({
      where: { id: postId },
      data: { views: { increment: 1 } }
    })
    
    revalidateTag(`post-${postId}`)
  } catch (error) {
    console.error('조회수 증가 실패:', error)
  }
}

🛡️ 에러 핸들링과 사용자 피드백

포괄적인 에러 처리 시스템

// lib/error-handling.ts
export class AppError extends Error {
  public readonly statusCode: number
  public readonly isOperational: boolean

  constructor(message: string, statusCode: number = 500, isOperational: boolean = true) {
    super(message)
    this.statusCode = statusCode
    this.isOperational = isOperational
    
    Error.captureStackTrace(this, this.constructor)
  }
}

export class ValidationError extends AppError {
  public readonly fieldErrors: Record<string, string[]>

  constructor(message: string, fieldErrors: Record<string, string[]>) {
    super(message, 400)
    this.fieldErrors = fieldErrors
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource}를 찾을 수 없습니다.`, 404)
  }
}

export class DatabaseError extends AppError {
  constructor(operation: string) {
    super(`${operation} 중 데이터베이스 오류가 발생했습니다.`, 500)
  }
}

에러 처리가 포함된 Server Actions

// app/actions/error-safe.ts
'use server'

import { prisma } from '@/lib/database'
import { AppError, ValidationError, NotFoundError, DatabaseError } from '@/lib/error-handling'
import { userSchema } from '@/lib/validation'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'

interface ActionResult<T = any> {
  success: boolean
  data?: T
  error?: string
  fieldErrors?: Record<string, string[]>
  statusCode?: number
}

export async function safeCreateUser(formData: FormData): Promise<ActionResult> {
  try {
    const rawData = {
      name: formData.get('name') as string,
      email: formData.get('email') as string,
      age: parseInt(formData.get('age') as string),
      role: formData.get('role') as 'USER' | 'ADMIN'
    }

    // 데이터 검증
    const validatedData = userSchema.parse(rawData)

    // 비즈니스 로직 검증
    const existingUser = await prisma.user.findUnique({
      where: { email: validatedData.email }
    })

    if (existingUser) {
      throw new ValidationError('이미 존재하는 이메일입니다.', {
        email: ['이미 사용 중인 이메일입니다.']
      })
    }

    // 사용자 생성
    const user = await prisma.user.create({
      data: validatedData
    })

    revalidatePath('/users')

    return {
      success: true,
      data: {
        id: user.id,
        name: user.name,
        email: user.email
      }
    }

  } catch (error) {
    console.error('사용자 생성 실패:', error)

    if (error instanceof z.ZodError) {
      return {
        success: false,
        error: '입력 데이터가 올바르지 않습니다.',
        fieldErrors: error.flatten().fieldErrors,
        statusCode: 400
      }
    }

    if (error instanceof ValidationError) {
      return {
        success: false,
        error: error.message,
        fieldErrors: error.fieldErrors,
        statusCode: error.statusCode
      }
    }

    if (error instanceof AppError) {
      return {
        success: false,
        error: error.message,
        statusCode: error.statusCode
      }
    }

    // 예상치 못한 에러
    return {
      success: false,
      error: '예기치 않은 오류가 발생했습니다.',
      statusCode: 500
    }
  }
}

export async function safeDeleteUser(id: number): Promise<ActionResult> {
  try {
    if (!id || id <= 0) {
      throw new ValidationError('유효하지 않은 사용자 ID입니다.', {
        id: ['올바른 사용자 ID를 제공해주세요.']
      })
    }

    // 사용자 존재 확인
    const user = await prisma.user.findUnique({
      where: { id },
      include: { _count: { select: { posts: true } } }
    })

    if (!user) {
      throw new NotFoundError('사용자')
    }

    // 연관 데이터 확인
    if (user._count.posts > 0) {
      throw new AppError(
        `사용자를 삭제할 수 없습니다. ${user._count.posts}개의 게시글이 있습니다.`,
        409
      )
    }

    // 삭제 실행
    await prisma.user.delete({
      where: { id }
    })

    revalidatePath('/users')

    return {
      success: true,
      data: { message: '사용자가 성공적으로 삭제되었습니다.' }
    }

  } catch (error) {
    console.error('사용자 삭제 실패:', error)

    if (error instanceof AppError) {
      return {
        success: false,
        error: error.message,
        statusCode: error.statusCode
      }
    }

    return {
      success: false,
      error: '사용자 삭제 중 오류가 발생했습니다.',
      statusCode: 500
    }
  }
}

사용자 피드백 컴포넌트

// app/components/UserFeedback.tsx
'use client'

import { useState, useEffect } from 'react'
import { safeCreateUser, safeDeleteUser } from '@/actions/error-safe'
import { useFormState, useFormStatus } from 'react-dom'

interface ToastProps {
  message: string
  type: 'success' | 'error' | 'warning'
  onClose: () => void
}

function Toast({ message, type, onClose }: ToastProps) {
  useEffect(() => {
    const timer = setTimeout(() => {
      onClose()
    }, 5000)

    return () => clearTimeout(timer)
  }, [onClose])

  const bgColor = {
    success: 'bg-green-50 border-green-200 text-green-800',
    error: 'bg-red-50 border-red-200 text-red-800',
    warning: 'bg-yellow-50 border-yellow-200 text-yellow-800'
  }[type]

  return (
    <div className={`fixed top-4 right-4 p-4 rounded-lg border ${bgColor} shadow-lg z-50`}>
      <div className="flex justify-between items-center">
        <p>{message}</p>
        <button
          onClick={onClose}
          className="ml-4 text-gray-500 hover:text-gray-700"
        >
          ✕
        </button>
      </div>
    </div>
  )
}

function SubmitButton() {
  const { pending } = useFormStatus()
  
  return (
    <button
      type="submit"
      disabled={pending}
      className={`w-full py-3 px-4 rounded-lg font-semibold transition duration-200 ${
        pending
          ? 'bg-gray-400 cursor-not-allowed'
          : 'bg-blue-600 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500'
      } text-white`}
    >
      {pending ? '처리 중...' : '사용자 생성'}
    </button>
  )
}

export default function UserFeedbackForm() {
  const [state, formAction] = useFormState(safeCreateUser, {
    success: false
  })
  const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null)

  useEffect(() => {
    if (state?.success) {
      setToast({
        message: '사용자가 성공적으로 생성되었습니다!',
        type: 'success'
      })
    } else if (state?.error) {
      setToast({
        message: state.error,
        type: 'error'
      })
    }
  }, [state])

  const handleDeleteUser = async (id: number) => {
    if (!confirm('정말로 이 사용자를 삭제하시겠습니까?')) {
      return
    }

    const result = await safeDeleteUser(id)
    
    if (result.success) {
      setToast({
        message: '사용자가 성공적으로 삭제되었습니다.',
        type: 'success'
      })
    } else {
      setToast({
        message: result.error || '삭제 중 오류가 발생했습니다.',
        type: 'error'
      })
    }
  }

  return (
    <div className="max-w-2xl mx-auto p-6">
      {/* Toast 알림 */}
      {toast && (
        <Toast
          message={toast.message}
          type={toast.type}
          onClose={() => setToast(null)}
        />
      )}

      <div className="bg-white rounded-lg shadow-lg p-6">
        <h2 className="text-2xl font-bold mb-6 text-gray-800">
          안전한 사용자 생성
        </h2>

        <form action={formAction} className="space-y-4">
          {/* 이름 입력 */}
          <div>
            <label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
              이름 <span className="text-red-500">*</span>
            </label>
            <input
              type="text"
              id="name"
              name="name"
              required
              className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
                state?.fieldErrors?.name ? 'border-red-500' : 'border-gray-300'
              }`}
              placeholder="이름을 입력하세요"
            />
            {state?.fieldErrors?.name && (
              <p className="mt-1 text-sm text-red-600">
                {state.fieldErrors.name[0]}
              </p>
            )}
          </div>

          {/* 이메일 입력 */}
          <div>
            <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
              이메일 <span className="text-red-500">*</span>
            </label>
            <input
              type="email"
              id="email"
              name="email"
              required
              className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
                state?.fieldErrors?.email ? 'border-red-500' : 'border-gray-300'
              }`}
              placeholder="이메일을 입력하세요"
            />
            {state?.fieldErrors?.email && (
              <p className="mt-1 text-sm text-red-600">
                {state.fieldErrors.email[0]}
              </p>
            )}
          </div>

          {/* 나이 입력 */}
          <div>
            <label htmlFor="age" className="block text-sm font-medium text-gray-700 mb-1">
              나이 <span className="text-red-500">*</span>
            </label>
            <input
              type="number"
              id="age"
              name="age"
              min="0"
              max="150"
              required
              className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
                state?.fieldErrors?.age ? 'border-red-500' : 'border-gray-300'
              }`}
              placeholder="나이를 입력하세요"
            />
            {state?.fieldErrors?.age && (
              <p className="mt-1 text-sm text-red-600">
                {state.fieldErrors.age[0]}
              </p>
            )}
          </div>

          {/* 권한 선택 */}
          <div>
            <label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-1">
              권한 <span className="text-red-500">*</span>
            </label>
            <select
              id="role"
              name="role"
              required
              className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
                state?.fieldErrors?.role ? 'border-red-500' : 'border-gray-300'
              }`}
            >
              <option value="">권한을 선택하세요</option>
              <option value="USER">일반 사용자</option>
              <option value="ADMIN">관리자</option>
            </select>
            {state?.fieldErrors?.role && (
              <p className="mt-1 text-sm text-red-600">
                {state.fieldErrors.role[0]}
              </p>
            )}
          </div>

          <SubmitButton />
        </form>
      </div>
    </div>
  )
}

🔒 보안과 최적화 전략

인증 및 권한 검사

// lib/auth.ts
import { cookies } from 'next/headers'
import { verify } from 'jsonwebtoken'
import { prisma } from '@/lib/database'

export interface User {
  id: number
  email: string
  name: string
  role: 'USER' | 'ADMIN'
}

export async function getCurrentUser(): Promise<User | null> {
  try {
    const token = cookies().get('auth-token')?.value
    
    if (!token) {
      return null
    }
    
    const payload = verify(token, process.env.JWT_SECRET!) as any
    
    const user = await prisma.user.findUnique({
      where: { id: payload.userId },
      select: {
        id: true,
        email: true,
        name: true,
        role: true
      }
    })
    
    return user
  } catch {
    return null
  }
}

export async function requireAuth(): Promise<User> {
  const user = await getCurrentUser()
  
  if (!user) {
    throw new Error('인증이 필요합니다.')
  }
  
  return user
}

export async function requireAdmin(): Promise<User> {
  const user = await requireAuth()
  
  if (user.role !== 'ADMIN') {
    throw new Error('관리자 권한이 필요합니다.')
  }
  
  return user
}

보안이 강화된 Server Actions

// app/actions/secure.ts
'use server'

import { requireAuth, requireAdmin } from '@/lib/auth'
import { prisma } from '@/lib/database'
import { rateLimit } from '@/lib/rate-limit'
import { sanitize } from '@/lib/sanitize'
import { revalidatePath } from 'next/cache'

// Rate Limiting 구현
const createUserLimiter = rateLimit({
  interval: 60 * 1000, // 1분
  uniqueTokenPerInterval: 500, // 고유 토큰당
})

export async function secureCreateUser(formData: FormData) {
  try {
    // 인증 확인
    const currentUser = await requireAdmin()
    
    // Rate Limiting
    await createUserLimiter.check(10, currentUser.id.toString()) // 분당 10회
    
    // 입력 데이터 정제
    const rawData = {
      name: sanitize(formData.get('name') as string),
      email: sanitize(formData.get('email') as string).toLowerCase(),
      age: parseInt(formData.get('age') as string),
      role: formData.get('role') as 'USER' | 'ADMIN'
    }
    
    // 관리자만 다른 관리자 생성 가능
    if (rawData.role === 'ADMIN' && currentUser.role !== 'ADMIN') {
      return {
        success: false,
        error: '관리자만 다른 관리자를 생성할 수 있습니다.'
      }
    }
    
    // 검증 및 생성 로직
    const validatedData = userSchema.parse(rawData)
    
    const user = await prisma.user.create({
      data: {
        ...validatedData,
        createdBy: currentUser.id // 생성자 추적
      }
    })
    
    // 로그 기록
    await prisma.auditLog.create({
      data: {
        userId: currentUser.id,
        action: 'CREATE_USER',
        resourceType: 'USER',
        resourceId: user.id,
        details: { userName: user.name, userEmail: user.email }
      }
    })
    
    revalidatePath('/admin/users')
    
    return {
      success: true,
      message: '사용자가 성공적으로 생성되었습니다.',
      user: {
        id: user.id,
        name: user.name,
        email: user.email
      }
    }
    
  } catch (error) {
    console.error('Secure user creation failed:', error)
    
    if (error.message.includes('Rate limit exceeded')) {
      return {
        success: false,
        error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.'
      }
    }
    
    return {
      success: false,
      error: '사용자 생성 중 오류가 발생했습니다.'
    }
  }
}

export async function secureDeleteUser(id: number) {
  'use server'
  
  try {
    const currentUser = await requireAdmin()
    
    // 자기 자신은 삭제 불가
    if (currentUser.id === id) {
      return {
        success: false,
        error: '자신의 계정은 삭제할 수 없습니다.'
      }
    }
    
    // 마지막 관리자 삭제 방지
    const adminCount = await prisma.user.count({
      where: { role: 'ADMIN' }
    })
    
    if (adminCount <= 1) {
      const targetUser = await prisma.user.findUnique({
        where: { id },
        select: { role: true }
      })
      
      if (targetUser?.role === 'ADMIN') {
        return {
          success: false,
          error: '마지막 관리자는 삭제할 수 없습니다.'
        }
      }
    }
    
    await prisma.user.delete({
      where: { id }
    })
    
    // 감사 로그
    await prisma.auditLog.create({
      data: {
        userId: currentUser.id,
        action: 'DELETE_USER',
        resourceType: 'USER',
        resourceId: id
      }
    })
    
    revalidatePath('/admin/users')
    
    return {
      success: true,
      message: '사용자가 삭제되었습니다.'
    }
    
  } catch (error) {
    console.error('Secure user deletion failed:', error)
    return {
      success: false,
      error: '사용자 삭제 중 오류가 발생했습니다.'
    }
  }
}

성능 최적화 전략

// lib/cache.ts
import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/database'

// 캐싱된 데이터 조회 함수들
export const getCachedUsers = unstable_cache(
  async (page: number = 1, limit: number = 10) => {
    const skip = (page - 1) * limit
    
    return await prisma.user.findMany({
      skip,
      take: limit,
      orderBy: { createdAt: 'desc' },
      select: {
        id: true,
        name: true,
        email: true,
        role: true,
        createdAt: true,
        _count: { select: { posts: true } }
      }
    })
  },
  ['users-list'],
  {
    revalidate: 300, // 5분 캐시
    tags: ['users']
  }
)

export const getCachedUserStats = unstable_cache(
  async () => {
    const [totalUsers, activeUsers, adminUsers] = await Promise.all([
      prisma.user.count(),
      prisma.user.count({
        where: {
          updatedAt: {
            gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // 30일 내 활성
          }
        }
      }),
      prisma.user.count({ where: { role: 'ADMIN' } })
    ])
    
    return { totalUsers, activeUsers, adminUsers }
  },
  ['user-stats'],
  {
    revalidate: 3600, // 1시간 캐시
    tags: ['users', 'stats']
  }
)

// 대용량 데이터 스트리밍
export async function* streamUsers(batchSize: number = 100) {
  let skip = 0
  let hasMore = true
  
  while (hasMore) {
    const batch = await prisma.user.findMany({
      skip,
      take: batchSize,
      orderBy: { id: 'asc' }
    })
    
    if (batch.length === 0) {
      hasMore = false
    } else {
      yield batch
      skip += batchSize
    }
  }
}

🎯 실전 프로젝트 예제

완전한 사용자 관리 대시보드

// app/(dashboard)/admin/users/page.tsx
import { Suspense } from 'react'
import { getCachedUsers, getCachedUserStats } from '@/lib/cache'
import UserTable from '@/components/admin/UserTable'
import UserStats from '@/components/admin/UserStats'
import CreateUserButton from '@/components/admin/CreateUserButton'

interface Props {
  searchParams: {
    page?: string
    search?: string
    role?: string
  }
}

export default async function AdminUsersPage({ searchParams }: Props) {
  const page = Number(searchParams.page) || 1
  const search = searchParams.search || ''
  const role = searchParams.role as 'USER' | 'ADMIN' | undefined
  
  return (
    <div className="space-y-6">
      {/* 헤더 */}
      <div className="flex justify-between items-center">
        <h1 className="text-3xl font-bold text-gray-900">사용자 관리</h1>
        <CreateUserButton />
      </div>
      
      {/* 통계 카드 */}
      <Suspense fallback={<StatsLoading />}>
        <UserStats />
      </Suspense>
      
      {/* 필터 및 검색 */}
      <UserFilters />
      
      {/* 사용자 테이블 */}
      <Suspense fallback={<TableLoading />}>
        <UserTable
          page={page}
          search={search}
          role={role}
        />
      </Suspense>
    </div>
  )
}

function StatsLoading() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
      {[1, 2, 3].map((i) => (
        <div key={i} className="bg-white p-6 rounded-lg shadow animate-pulse">
          <div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
          <div className="h-8 bg-gray-200 rounded w-1/3"></div>
        </div>
      ))}
    </div>
  )
}

function TableLoading() {
  return (
    <div className="bg-white shadow rounded-lg">
      <div className="animate-pulse">
        <div className="h-12 bg-gray-200 rounded-t-lg"></div>
        {[1, 2, 3, 4, 5].map((i) => (
          <div key={i} className="h-16 bg-gray-100 border-t"></div>
        ))}
      </div>
    </div>
  )
}

실시간 데이터 업데이트 컴포넌트

// app/components/admin/UserTable.tsx
import { getCachedUsers } from '@/lib/cache'
import { deleteUser } from '@/actions/users'
import DeleteUserButton from './DeleteUserButton'
import EditUserButton from './EditUserButton'

interface Props {
  page: number
  search: string
  role?: 'USER' | 'ADMIN'
}

export default async function UserTable({ page, search, role }: Props) {
  const users = await getCachedUsers(page, 10)
  
  return (
    <div className="bg-white shadow rounded-lg overflow-hidden">
      <table className="min-w-full divide-y divide-gray-200">
        <thead className="bg-gray-50">
          <tr>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
              사용자
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
              권한
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
              게시글 수
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
              가입일
            </th>
            <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
              작업
            </th>
          </tr>
        </thead>
        <tbody className="bg-white divide-y divide-gray-200">
          {users.map((user) => (
            <tr key={user.id} className="hover:bg-gray-50">
              <td className="px-6 py-4 whitespace-nowrap">
                <div className="flex items-center">
                  <div className="flex-shrink-0 h-10 w-10">
                    <div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center">
                      {user.name.charAt(0).toUpperCase()}
                    </div>
                  </div>
                  <div className="ml-4">
                    <div className="text-sm font-medium text-gray-900">
                      {user.name}
                    </div>
                    <div className="text-sm text-gray-500">
                      {user.email}
                    </div>
                  </div>
                </div>
              </td>
              <td className="px-6 py-4 whitespace-nowrap">
                <span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
                  user.role === 'ADMIN'
                    ? 'bg-purple-100 text-purple-800'
                    : 'bg-green-100 text-green-800'
                }`}>
                  {user.role === 'ADMIN' ? '관리자' : '사용자'}
                </span>
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
                {user._count.posts}
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
                {new Date(user.createdAt).toLocaleDateString('ko-KR')}
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
                <EditUserButton user={user} />
                <DeleteUserButton userId={user.id} userName={user.name} />
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

🔍 성능 모니터링과 디버깅

Server Actions 성능 모니터링

// lib/monitoring.ts
interface PerformanceMetric {
  actionName: string
  duration: number
  success: boolean
  timestamp: Date
  userId?: number
  error?: string
}

class ActionMonitor {
  private metrics: PerformanceMetric[] = []
  
  async measure<T>(
    actionName: string,
    action: () => Promise<T>,
    userId?: number
  ): Promise<T> {
    const startTime = performance.now()
    
    try {
      const result = await action()
      
      const duration = performance.now() - startTime
      this.recordMetric({
        actionName,
        duration,
        success: true,
        timestamp: new Date(),
        userId
      })
      
      return result
    } catch (error) {
      const duration = performance.now() - startTime
      this.recordMetric({
        actionName,
        duration,
        success: false,
        timestamp: new Date(),
        userId,
        error: error instanceof Error ? error.message : 'Unknown error'
      })
      
      throw error
    }
  }
  
  private recordMetric(metric: PerformanceMetric) {
    this.metrics.push(metric)
    
    // 개발 환경에서 느린 작업 경고
    if (process.env.NODE_ENV === 'development' && metric.duration > 1000) {
      console.warn(`⚠️ Slow action detected: ${metric.actionName} took ${metric.duration.toFixed(2)}ms`)
    }
    
    // 메트릭을 외부 서비스로 전송 (예: DataDog, New Relic)
    if (process.env.NODE_ENV === 'production') {
      this.sendToAnalytics(metric)
    }
  }
  
  private async sendToAnalytics(metric: PerformanceMetric) {
    // 외부 분석 서비스로 메트릭 전송
    try {
      await fetch('/api/analytics/metrics', {
        method: 'POST',
        body: JSON.stringify(metric)
      })
    } catch (error) {
      console.error('Failed to send metrics:', error)
    }
  }
  
  getMetrics(): PerformanceMetric[] {
    return this.metrics
  }
  
  getAverageResponseTime(actionName: string): number {
    const actionMetrics = this.metrics.filter(m => m.actionName === actionName)
    if (actionMetrics.length === 0) return 0
    
    const totalDuration = actionMetrics.reduce((sum, m) => sum + m.duration, 0)
    return totalDuration / actionMetrics.length
  }
}

export const actionMonitor = new ActionMonitor()

모니터링이 적용된 Server Action 예제

// app/actions/monitored.ts
'use server'

import { actionMonitor } from '@/lib/monitoring'
import { getCurrentUser } from '@/lib/auth'
import { prisma } from '@/lib/database'
import { userSchema } from '@/lib/validation'

export async function monitoredCreateUser(formData: FormData) {
  const currentUser = await getCurrentUser()
  
  return await actionMonitor.measure(
    'createUser',
    async () => {
      // 실제 사용자 생성 로직
      const validatedData = userSchema.parse({
        name: formData.get('name') as string,
        email: formData.get('email') as string,
        age: parseInt(formData.get('age') as string),
        role: formData.get('role') as 'USER' | 'ADMIN'
      })
      
      const user = await prisma.user.create({
        data: validatedData
      })
      
      return {
        success: true,
        user: {
          id: user.id,
          name: user.name,
          email: user.email
        }
      }
    },
    currentUser?.id
  )
}

디버깅 및 로깅 시스템

// lib/logger.ts
enum LogLevel {
  DEBUG = 0,
  INFO = 1,
  WARN = 2,
  ERROR = 3
}

interface LogEntry {
  level: LogLevel
  message: string
  timestamp: Date
  context?: Record<string, any>
  userId?: number
  actionName?: string
  requestId?: string
}

class Logger {
  private logs: LogEntry[] = []
  private minLevel: LogLevel
  
  constructor() {
    this.minLevel = process.env.NODE_ENV === 'development' 
      ? LogLevel.DEBUG 
      : LogLevel.INFO
  }
  
  private log(level: LogLevel, message: string, context?: Record<string, any>) {
    if (level < this.minLevel) return
    
    const entry: LogEntry = {
      level,
      message,
      timestamp: new Date(),
      context,
      requestId: this.generateRequestId()
    }
    
    this.logs.push(entry)
    
    // 콘솔 출력
    const levelNames = ['DEBUG', 'INFO', 'WARN', 'ERROR']
    const levelColors = ['\x1b[36m', '\x1b[32m', '\x1b[33m', '\x1b[31m']
    
    console.log(
      `${levelColors[level]}[${levelNames[level]}]\x1b[0m ${entry.timestamp.toISOString()} - ${message}`,
      context ? '\n' + JSON.stringify(context, null, 2) : ''
    )
    
    // 프로덕션에서 외부 로깅 서비스로 전송
    if (process.env.NODE_ENV === 'production' && level >= LogLevel.ERROR) {
      this.sendToLoggingService(entry)
    }
  }
  
  debug(message: string, context?: Record<string, any>) {
    this.log(LogLevel.DEBUG, message, context)
  }
  
  info(message: string, context?: Record<string, any>) {
    this.log(LogLevel.INFO, message, context)
  }
  
  warn(message: string, context?: Record<string, any>) {
    this.log(LogLevel.WARN, message, context)
  }
  
  error(message: string, context?: Record<string, any>) {
    this.log(LogLevel.ERROR, message, context)
  }
  
  private generateRequestId(): string {
    return Math.random().toString(36).substring(2, 15)
  }
  
  private async sendToLoggingService(entry: LogEntry) {
    // Sentry, LogRocket 등 외부 로깅 서비스 연동
    console.error('Sending to logging service:', entry)
  }
}

export const logger = new Logger()

🔄 마이그레이션 가이드

API Routes에서 Server Actions로 마이그레이션

// Before: pages/api/users/index.ts (Pages Router)
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/database'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'POST') {
    try {
      const { name, email, age, role } = req.body
      
      const user = await prisma.user.create({
        data: { name, email, age, role }
      })
      
      res.status(201).json({ success: true, user })
    } catch (error) {
      res.status(500).json({ success: false, error: 'Failed to create user' })
    }
  } else {
    res.setHeader('Allow', ['POST'])
    res.status(405).end(`Method ${req.method} Not Allowed`)
  }
}

// After: app/actions/users.ts (App Router)
'use server'

import { prisma } from '@/lib/database'
import { userSchema } from '@/lib/validation'
import { revalidatePath } from 'next/cache'

export async function createUser(formData: FormData) {
  try {
    const rawData = {
      name: formData.get('name') as string,
      email: formData.get('email') as string,
      age: parseInt(formData.get('age') as string),
      role: formData.get('role') as 'USER' | 'ADMIN'
    }
    
    const validatedData = userSchema.parse(rawData)
    
    const user = await prisma.user.create({
      data: validatedData
    })
    
    revalidatePath('/users')
    
    return { success: true, user }
  } catch (error) {
    return { success: false, error: 'Failed to create user' }
  }
}

클라이언트 코드 마이그레이션

// Before: 클라이언트에서 API 호출
'use client'

import { useState } from 'react'

export default function UserForm() {
  const [loading, setLoading] = useState(false)
  
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    setLoading(true)
    
    const formData = new FormData(e.currentTarget)
    
    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(Object.fromEntries(formData))
      })
      
      const result = await response.json()
      
      if (result.success) {
        alert('사용자가 생성되었습니다!')
      } else {
        alert('에러: ' + result.error)
      }
    } catch (error) {
      alert('네트워크 에러가 발생했습니다.')
    } finally {
      setLoading(false)
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      {/* 폼 필드들 */}
      <button type="submit" disabled={loading}>
        {loading ? '생성 중...' : '사용자 생성'}
      </button>
    </form>
  )
}

// After: Server Actions 사용
import { createUser } from '@/actions/users'

export default function UserForm() {
  return (
    <form action={createUser}>
      {/* 폼 필드들 */}
      <button type="submit">
        사용자 생성
      </button>
    </form>
  )
}

// 또는 useFormState로 상태 관리
'use client'

import { useFormState } from 'react-dom'
import { createUser } from '@/actions/users'

const initialState = { success: false, error: null }

export default function UserFormWithState() {
  const [state, formAction] = useFormState(createUser, initialState)
  
  return (
    <form action={formAction}>
      {/* 폼 필드들 */}
      
      {state.error && (
        <p className="text-red-500">{state.error}</p>
      )}
      
      {state.success && (
        <p className="text-green-500">사용자가 생성되었습니다!</p>
      )}
      
      <button type="submit">
        사용자 생성
      </button>
    </form>
  )
}

🎯 결론 및 Next.js 14 Server Actions 마스터하기

Next.js 14 Server Actions는 단순한 새로운 기능을 넘어서 웹 개발 패러다임의 전환점입니다. 클라이언트와 서버의 경계를 허물며, 더욱 직관적이고 효율적인 풀스택 개발 경험을 제공합니다.

🚀 핵심 혜택 요약

  1. 개발자 경험 향상: API Routes 없이도 서버 로직을 직접 컴포넌트에서 사용
  2. 타입 안전성: TypeScript로 클라이언트-서버 간 완벽한 타입 보장
  3. 성능 최적화: 불필요한 네트워크 요청 제거와 자동 캐시 관리
  4. 보안 강화: 서버에서만 실행되는 안전한 데이터 처리
  5. Progressive Enhancement: JavaScript 없이도 동작하는 기본 기능

📈 실무 적용을 위한 체크리스트

  • 프로젝트 구조 최적화: Actions 디렉토리 분리 및 모듈화
  • 데이터 검증: Zod 스키마를 활용한 견고한 검증 시스템
  • 에러 핸들링: 포괄적인 에러 처리 및 사용자 피드백
  • 보안 구현: 인증, 권한 검사, Rate Limiting 적용
  • 성능 모니터링: 응답 시간 측정 및 최적화 포인트 파악
  • 캐시 전략: revalidatePath, revalidateTag를 활용한 효율적인 캐시 관리

🔮 미래 전망과 발전 방향

Server Actions는 React Server Components와 함께 서버 중심 아키텍처의 르네상스를 이끌고 있습니다. 앞으로는 더 많은 프레임워크에서 이런 패턴을 채택할 것으로 예상되며, 특히 AI 통합, 실시간 협업 기능, 마이크로서비스 아키텍처와의 결합에서 새로운 가능성을 보여줄 것입니다.


🎓 다음 학습 단계

이 가이드를 통해 Next.js 14 Server Actions의 기초를 탄탄히 다졌다면, 다음 단계로 나아가보세요:

  1. Streaming과 Suspense - 대용량 데이터 처리 최적화
  2. WebSocket 통합 - 실시간 기능 구현
  3. Docker 컨테이너화 - 프로덕션 배포 준비
  4. 모니터링 시스템 - Sentry, DataDog 연동
  5. E2E 테스트 - Playwright, Cypress 테스트 자동화

💡 마무리 팁

Server Actions를 도입할 때는 기존 API Routes와 단계적으로 병행하며 마이그레이션하는 것을 추천합니다. 특히 복잡한 비즈니스 로직이나 외부 API 통합이 많은 부분은 충분한 테스트 후에 전환하시기 바랍니다.

Next.js 14 Server Actions로 더 나은 웹 애플리케이션을 구축하고, 현대적인 풀스택 개발의 새로운 기준을 경험해보세요! 🚀

'Front > NextJS' 카테고리의 다른 글

[Next.js] getStaticProps, getServerSideProps  (0) 2022.01.19
[NextJS] Access Token 관리  (0) 2021.10.01

댓글