Next.js
강의
#2 - BFF를 이해하면 절대 포기할 수 없는 Next.js Server Actions
중년개발자
@loxo
29일 전
35
BFF를 이해하면 절대 포기할 수 없는 Next.js Server Actions
프론트엔드가 단순히 “API를 호출하는 화면”이던 시절은 이미 지났습니다.
오늘날의 웹 애플리케이션은 인증, 권한, 캐시, 상태 동기화까지 사실상 하나의 서버 역할을 요구받고 있습니다.
이 흐름 속에서 등장한 개념이 바로 BFF(Backend For Frontend) 입니다.
프론트엔드에 최적화된 전용 백엔드를 두고, UI와 비즈니스 로직 사이의 간극을 최소화하는 패턴이죠.
Next.js Server Actions는 이 BFF 패턴을 가장 단순하고 강력하게 구현한 기능입니다.
별도의 API 레이어를 만들지 않아도,
- 서버 로직을 안전하게 실행하고
- 쿠키와 세션을 다루며
- 캐시를 무효화하고
- 클라이언트에서는 함수처럼 호출할 수 있습니다.
- 좋아요, 북마크, 신고하기 등 사용자와 인터랙션이 바로 발생하는 부분에서 빛을 발합니다.
이 글을 끝까지 읽고 나면,
**“이걸 알고도 API Routes부터 만드는 건 낭비다”**라는 생각이 들게 될 겁니다.
Server Actions vs API Routes 완벽 가이드
📌 Server Actions란?
Server Actions는 Next.js 13+에서 도입된 서버에서 실행되는 비동기 함수로, 클라이언트에서 직접 호출할 수 있습니다.
핵심 특징
tsx
// actions/user.action.ts
'use server'; // ⭐ 이 디렉티브가 핵심!
import { cookies, headers } from 'next/headers';
export async function updateUserProfile(formData: FormData) {
// ✅ 쿠키 접근 가능
const cookieStore = await cookies();
const token = cookieStore.get('auth-token');
// ✅ 헤더 접근 가능
const headersList = await headers();
const userAgent = headersList.get('user-agent');
// ✅ DB 접근 가능
await db.update('users', formData);
// ✅ 캐시 무효화 가능
revalidatePath('/profile');
return { success: true };
}작동 원리:
plaintext
[클라이언트]
↓ updateUserProfile(formData) 호출
↓
[Next.js 자동 처리]
- POST /api/__next_action 요청 생성
- FormData를 body에 포함
↓
[서버]
- Server Action 함수 실행
- DB 업데이트
- 결과 반환
↓
[클라이언트]
- 결과 수신🆚 Server Actions vs API Routes 비교
기본 구조 비교
Server Actions
tsx
// actions/posts.action.ts
'use server';
import { cookies } from 'next/headers';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const cookieStore = await cookies();
const userId = cookieStore.get('user-id')?.value;
await db.insert('posts', {
title: formData.get('title'),
userId,
});
revalidatePath('/posts');
return { success: true };
}사용:
tsx
'use client';
export default function CreatePostForm() {
const handleSubmit = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const result = await createPost(formData); // ✅ 함수처럼 호출
};
return <form onSubmit={handleSubmit}>...</form>;
}API Routes
tsx
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const cookies = request.cookies;
const userId = cookies.get('user-id')?.value;
const body = await request.json();
await db.insert('posts', {
title: body.title,
userId,
});
return NextResponse.json({ success: true });
}사용:
tsx
'use client';
export default function CreatePostForm() {
const handleSubmit = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
// ✅ HTTP 요청으로 호출
const response = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify({ title: formData.get('title') }),
});
const result = await response.json();
};
return <form onSubmit={handleSubmit}>...</form>;
}🎯 언제 무엇을 사용할까?
✅ Server Actions를 사용해야 할 때
1. 폼 제출 및 데이터 변경 (Mutations)
tsx
// ✅ 권장: Server Actions
'use server';
export async function submitContactForm(formData: FormData) {
const name = formData.get('name');
const email = formData.get('email');
await db.insert('contacts', { name, email });
revalidatePath('/contacts');
return { success: true };
}이유:
- 폼 데이터 자동 처리
revalidatePath자동 캐시 무효화- 타입 안전성 (TypeScript)
- 보일러플레이트 코드 최소화
2. 사용자 인터랙션 (좋아요, 북마크 등)
tsx
// ✅ 권장: Server Actions
'use server';
export async function toggleLike(postId: string) {
const userId = await getAuthUserId();
await db.toggleLike(userId, postId);
revalidatePath('/posts');
return { success: true };
}이유:
- 간단한 mutations
- 빠른 구현
- 캐시 무효화 간편
3. 인증이 필요한 Private Actions
tsx
// ✅ 권장: Server Actions
'use server';
import { cookies } from 'next/headers';
export async function deletePost(postId: string) {
const cookieStore = await cookies();
const session = cookieStore.get('session')?.value;
if (!session) {
return { error: 'Unauthorized' };
}
await db.delete('posts', postId);
revalidatePath('/posts');
return { success: true };
}이유:
- 쿠키/헤더 접근 가능
- 타입 안전한 에러 처리
- 클라이언트에서 간편하게 호출
4. 서버 상태 변경 후 UI 자동 업데이트
tsx
// ✅ 권장: Server Actions
'use server';
export async function updateSettings(settings: Settings) {
await db.update('settings', settings);
// ✅ Next.js 캐시 자동 무효화
revalidatePath('/settings');
revalidateTag('user-settings');
return { success: true };
}이유:
revalidatePath,revalidateTag사용 가능- Server Component 자동 재렌더링
✅ API Routes를 사용해야 할 때
1. GET 요청 (데이터 조회)
tsx
// ✅ 권장: API Routes
// app/api/posts/route.ts
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const category = searchParams.get('category');
const limit = parseInt(searchParams.get('limit') || '10');
const posts = await PostService.getPosts({ category, limit });
return NextResponse.json(posts);
}이유:
- Server Actions는 POST만 지원 (GET 불가)
- URL로 데이터 전달 가능
- 브라우저에서 직접 접근 가능 (디버깅 편함)
- React Query와 자연스러운 통합
2. Webhook 및 외부 시스템 통합
tsx
// ✅ 권장: API Routes
// app/api/webhooks/stripe/route.ts
export async function POST(request: NextRequest) {
const signature = request.headers.get('stripe-signature');
const body = await request.text();
// Stripe webhook 검증
const event = stripe.webhooks.constructEvent(body, signature, secret);
// 비즈니스 로직 처리
await handleStripeEvent(event);
return NextResponse.json({ received: true });
}이유:
- 외부 시스템이 직접 호출
- 커스텀 헤더 검증 필요
- Raw body 접근 필요
3. Public API Endpoints
tsx
// ✅ 권장: API Routes
// app/api/public/stats/route.ts
export async function GET() {
const stats = await db.getPublicStats();
return NextResponse.json(stats, {
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=3600',
},
});
}이유:
- CORS 설정 필요
- 커스텀 헤더 설정
- 퍼블릭 접근
4. 파일 업로드/다운로드
tsx
// ✅ 권장: API Routes
// app/api/upload/route.ts
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get('file') as File;
const buffer = Buffer.from(await file.arrayBuffer());
await uploadToS3(buffer, file.name);
return NextResponse.json({ url: s3Url });
}이유:
- 파일 처리에 최적화
- Progress 이벤트 처리 가능
- 스트리밍 지원
5. 복잡한 HTTP 응답 제어
tsx
// ✅ 권장: API Routes
// app/api/download/route.ts
export async function GET() {
const csvData = await generateCSV();
return new NextResponse(csvData, {
status: 200,
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename=export.csv',
},
});
}이유:
- 커스텀 응답 타입
- 파일 다운로드
- 스트리밍 응답
📊 의사결정 플로우차트
plaintext
데이터 페칭 시작
↓
[질문 1] 읽기(GET) vs 쓰기(POST/PUT/DELETE)?
↓
읽기 → API Routes ✅
↓
쓰기
↓
[질문 2] 외부 시스템에서 호출? (Webhook 등)
↓
Yes → API Routes ✅
↓
No
↓
[질문 3] 파일 업로드/다운로드?
↓
Yes → API Routes ✅
↓
No
↓
[질문 4] Public API (CORS 필요)?
↓
Yes → API Routes ✅
↓
No
↓
[질문 5] 커스텀 HTTP 헤더/응답 타입 필요?
↓
Yes → API Routes ✅
↓
No
↓
Server Actions ✅ (가장 간단!)🔑 Cookie 및 Headers 접근
🚨 중요: 둘 다 쿠키/헤더 접근 가능!
Server Actions에서 쿠키 접근
tsx
// ✅ Server Actions도 쿠키 접근 가능!
'use server';
import { cookies, headers } from 'next/headers';
export async function getAuthUser() {
const cookieStore = await cookies();
const session = cookieStore.get('session')?.value;
const headersList = await headers();
const userAgent = headersList.get('user-agent');
return { session, userAgent };
}API Routes에서 쿠키 접근
tsx
// ✅ API Routes도 쿠키 접근 가능!
export async function GET(request: NextRequest) {
const session = request.cookies.get('session')?.value;
const userAgent = request.headers.get('user-agent');
return NextResponse.json({ session, userAgent });
}결론: 쿠키/헤더 접근은 둘 다 가능하므로 선택 기준이 아닙니다!
📋 실전 사용 예시
postgresql.co.kr 프로젝트 패턴
✅ Server Actions 사용
tsx
// actions/interaction/like.action.ts
'use server';
export async function toggleLikeAction(postId: string) {
const userId = await getAuthUserId(); // 쿠키에서 인증 정보
await LikeService.toggleLike(userId, postId);
revalidatePath('/posts');
return { success: true };
}이유:
- 단순한 mutation
- 폼 데이터 없음
- 캐시 무효화 필요
✅ API Routes 사용
tsx
// app/api/studio/[category]/route.ts
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const category = searchParams.get('category');
const filter = searchParams.get('filter');
const posts = await PostService.getPosts({ category, filter });
return NextResponse.json(posts);
}이유:
- GET 요청
- React Query로 데이터 페칭
- 무한 스크롤 구현
🎯 최종 결정 가이드
| 상황 | 권장 방법 | 이유 |
|---|---|---|
| 폼 제출 | Server Actions | 간단, 타입 안전 |
| 좋아요, 북마크 | Server Actions | Mutation + 캐시 무효화 |
| 데이터 조회 (GET) | API Routes | Server Actions는 POST만 지원 |
| 무한 스크롤 | API Routes | React Query 통합 |
| Webhook | API Routes | 외부 시스템 호출 |
| 파일 업로드 | API Routes | 파일 처리 최적화 |
| Public API | API Routes | CORS, 커스텀 헤더 |
| Private Mutations | Server Actions | 간단하고 안전 |
✨ 권장 사항 요약
Server Actions를 기본으로 사용하세요
tsx
// ✅ 간단하고 직관적
'use server';
export async function updateUser(data: FormData) {
await db.update('users', data);
revalidatePath('/profile');
return { success: true };
}다음 경우에만 API Routes 사용
- GET 요청이 필요할 때
- 외부 시스템 통합 (Webhook 등)
- 파일 업로드/다운로드
- Public API 제공
- 커스텀 HTTP 응답 제어
🚀 마이그레이션 가이드
API Routes → Server Actions
Before (API Routes):
tsx
// app/api/posts/like/route.ts
export async function POST(request: NextRequest) {
const { postId } = await request.json();
const userId = await getAuthUserId();
await db.toggleLike(userId, postId);
return NextResponse.json({ success: true });
}
// Client
await fetch('/api/posts/like', {
method: 'POST',
body: JSON.stringify({ postId }),
});After (Server Actions):
tsx
// actions/like.action.ts
'use server';
export async function toggleLike(postId: string) {
const userId = await getAuthUserId();
await db.toggleLike(userId, postId);
return { success: true };
}
// Client
await toggleLike(postId); // ✅ 훨씬 간단!이점:
- 50% 코드 감소
- 타입 안전성 향상
- 자동 직렬화/역직렬화
- 보일러플레이트 제거
📚 참고 자료
목차
#Next.js#Server Actions#BFF#Frontend Development#Web Architecture
댓글 1
Ctrl + Enter를 눌러 등록할 수 있습니다※ AI 다듬기는 내용을 정제하는 보조 기능이며, 최종 내용은 사용자가 확인해야 합니다.