
Next.js 14 Server Actions 풀스택 개발의 새로운 패러다임
Next.js 14에서 정식 출시된 Server Actions는 React 서버 컴포넌트와 함께 풀스택 웹 개발의 패러다임을 완전히 바꾸고 있습니다. 기존의 API Routes를 대체하여 서버 사이드 로직을 컴포넌트 내부에서 직접 실행할 수 있게 해주는 이 혁신적인 기능에 대해 실무에 바로 적용할 수 있는 완벽한 가이드를 제공합니다.
Server Actions는 단순히 새로운 기능을 넘어서, 개발자 경험(DX) 향상과 성능 최적화를 동시에 달성할 수 있는 게임 체인저입니다. 이 포스팅을 통해 Next.js 14의 핵심 기능을 마스터하고 현대적인 풀스택 애플리케이션을 구축해보세요.
📋 목차
- Server Actions 기본 개념과 동작 원리
- 개발 환경 설정과 프로젝트 구조
- 기본적인 Server Actions 구현
- 폼 처리와 데이터 검증
- 데이터베이스 연동과 CRUD 작업
- 에러 핸들링과 사용자 피드백
- 보안과 최적화 전략
- 실전 프로젝트 예제
- 성능 모니터링과 디버깅
- 마이그레이션 가이드
🔍 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의 핵심 장점
- 코드 분리 없이 풀스택 개발: 컴포넌트와 서버 로직을 한 파일에서 관리
- 자동 타입 안전성: TypeScript로 클라이언트-서버 간 완전한 타입 보장
- 향상된 성능: 불필요한 네트워크 요청 및 직렬화 과정 제거
- 자동 캐시 무효화: revalidatePath, revalidateTag로 효율적인 캐시 관리
- 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는 단순한 새로운 기능을 넘어서 웹 개발 패러다임의 전환점입니다. 클라이언트와 서버의 경계를 허물며, 더욱 직관적이고 효율적인 풀스택 개발 경험을 제공합니다.
🚀 핵심 혜택 요약
- 개발자 경험 향상: API Routes 없이도 서버 로직을 직접 컴포넌트에서 사용
- 타입 안전성: TypeScript로 클라이언트-서버 간 완벽한 타입 보장
- 성능 최적화: 불필요한 네트워크 요청 제거와 자동 캐시 관리
- 보안 강화: 서버에서만 실행되는 안전한 데이터 처리
- Progressive Enhancement: JavaScript 없이도 동작하는 기본 기능
📈 실무 적용을 위한 체크리스트
- ✅ 프로젝트 구조 최적화: Actions 디렉토리 분리 및 모듈화
- ✅ 데이터 검증: Zod 스키마를 활용한 견고한 검증 시스템
- ✅ 에러 핸들링: 포괄적인 에러 처리 및 사용자 피드백
- ✅ 보안 구현: 인증, 권한 검사, Rate Limiting 적용
- ✅ 성능 모니터링: 응답 시간 측정 및 최적화 포인트 파악
- ✅ 캐시 전략: revalidatePath, revalidateTag를 활용한 효율적인 캐시 관리
🔮 미래 전망과 발전 방향
Server Actions는 React Server Components와 함께 서버 중심 아키텍처의 르네상스를 이끌고 있습니다. 앞으로는 더 많은 프레임워크에서 이런 패턴을 채택할 것으로 예상되며, 특히 AI 통합, 실시간 협업 기능, 마이크로서비스 아키텍처와의 결합에서 새로운 가능성을 보여줄 것입니다.
🎓 다음 학습 단계
이 가이드를 통해 Next.js 14 Server Actions의 기초를 탄탄히 다졌다면, 다음 단계로 나아가보세요:
- Streaming과 Suspense - 대용량 데이터 처리 최적화
- WebSocket 통합 - 실시간 기능 구현
- Docker 컨테이너화 - 프로덕션 배포 준비
- 모니터링 시스템 - Sentry, DataDog 연동
- 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 |
댓글