Skip to content

Conversation

@yoonc01
Copy link
Owner

@yoonc01 yoonc01 commented Nov 26, 2025

Server Actions vs API Routes 비교

Next.js에서 서버 로직을 처리하는 두 가지 방법에 대한 비교 문서입니다.

📌 개요

Server Actions (RPC 방식)

  • "use server" 지시어 사용
  • 클라이언트에서 함수처럼 직접 호출 가능
  • Next.js가 자동으로 RPC(Remote Procedure Call) 엔드포인트로 변환
  • HTTP 요청/응답 처리 불필요
  • 주로 폼 제출, 데이터 변경(mutations) 용도

RPC란?

  • Remote Procedure Call = 원격 프로시저 호출
  • 서버에 있는 함수를 마치 로컬 함수처럼 호출하는 방식
  • Next.js가 자동으로 네트워크 통신을 처리해줌
// 우리가 작성: 그냥 함수 호출
const user = await createUser("hyoyoon", "email@example.com");

// Next.js가 자동으로 변환:
// POST /api/__next_action__/abc123
// + fetch로 네트워크 요청
// → 하지만 우리는 함수처럼 사용!

API Routes (REST 방식)

  • 전통적인 RESTful API 엔드포인트
  • 외부 클라이언트(모바일 앱, 다른 서비스)에서도 접근 가능
  • HTTP 메서드(GET, POST, PUT, DELETE) 명시적 처리
  • fetch()로 호출해야 함

1️⃣ Server Actions 방식

파일 구조

app/
  actions/
    user-actions.ts  ← Server Actions

코드 예시

actions/user-actions.ts

"use server";

import { revalidatePath } from "next/cache";

// 좋아요 추가
export async function likeUser(userId: number) {
  // DB 작업
  await db.likes.create({ userId, likedAt: new Date() });

  // 캐시 자동 갱신
  revalidatePath("/users");

  return { success: true };
}

// 폼 데이터 처리 (폼 제출에 최적화)
export async function createUser(formData: FormData) {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  await db.users.create({ name, email });
  revalidatePath("/users");
}

// 사용자 검색
export async function searchUsers(name: string) {
  const DB = [
    { id: 1, name: "hyoyoon1" },
    { id: 2, name: "hyoyoon2" },
    { id: 3, name: "hyoyoon3" },
  ];

  return DB.filter(user => user.name.includes(name));
}

클라이언트에서 사용

// app/users/page.tsx
"use client";
import { likeUser, createUser, searchUsers } from "@/app/actions/user-actions";
import { useState, useEffect } from "react";

export default function UsersPage() {
  const [users, setUsers] = useState([]);

  // 방법 1: 일반 함수처럼 호출
  useEffect(() => {
    searchUsers("hyoyoon").then(data => setUsers(data));
  }, []);

  const handleLike = async (userId: number) => {
    await likeUser(userId); // 그냥 함수 호출! 간단!
  };

  // 방법 2: 폼에서 직접 사용
  return (
    <div>
      <h1>Users</h1>
      {users.map(user => (
        <div key={user.id}>
          <p>{user.name}</p>
          <button onClick={() => handleLike(user.id)}>좋아요</button>
        </div>
      ))}

      {/* 폼에서 action으로 직접 사용 가능! */}
      <form action={createUser}>
        <input name="name" placeholder="이름" />
        <input name="email" placeholder="이메일" />
        <button type="submit">생성</button>
      </form>
    </div>
  );
}

장점

  • ✅ 코드가 간단 (fetch 불필요)
  • ✅ TypeScript 타입 자동 체크
  • revalidatePath로 캐시 자동 갱신
  • ✅ 폼 제출에 최적화 (FormData 자동 처리, 파일 업로드 쉬움)
  • ✅ Progressive Enhancement 지원 (JS 없이도 폼 작동)

💡 Progressive Enhancement란?

Server Actions는 HTML <form> 태그의 기본 동작을 활용합니다:

JavaScript 없을 때:

<form action="/api/__next_action__/abc123" method="POST">
  <input name="username" />
  <button type="submit">제출</button>
</form>
  • ✅ 브라우저의 기본 폼 제출 동작으로 작동
  • ✅ 제출 → 페이지 새로고침 → 결과 표시

JavaScript 있을 때:

  • ✅ Next.js가 자동으로 AJAX 요청으로 변환
  • ✅ 페이지 새로고침 없이 부드럽게 제출
  • ✅ 로딩 상태, 에러 처리 등 향상된 UX

결과: 모든 환경에서 작동하되, JavaScript가 있으면 더 나은 경험 제공!

핵심: Progressive Enhancement가 작동하려면 <form action="..."> 속성에 실제 작동하는 엔드포인트가 있어야 합니다. Next.js Server Actions는 이를 자동으로 생성해주므로, 우리는 함수만 작성하면 됩니다!


🤔 언제 HTML Form을 사용할까?

역사적 맥락: 왜 요즘 다시 Form을 쓰는가?

React 시대 (2013~2023): Form을 거의 안 씀

// 전통적인 React 방식
const [email, setEmail] = useState("");

const handleSubmit = async (e) => {
  e.preventDefault(); // ⚠️ HTML 기본 동작 차단!
  await fetch("/api/signup", {
    method: "POST",
    body: JSON.stringify({ email }),
  });
};

<form onSubmit={handleSubmit}>
  <input value={email} onChange={(e) => setEmail(e.target.value)} />
  <button>제출</button>
</form>

문제점: e.preventDefault()로 Form의 기본 동작을 완전히 무시 → JavaScript 필수

Next.js Server Actions (2023~): Form이 다시 유용해짐

<form action={signup}>
  <input name="email" />
  <button>제출</button>
</form>

이유: Next.js가 자동으로 엔드포인트를 생성하고 Progressive Enhancement 지원!


Form이 유용한 경우 (Server Actions 사용)

  • ✅ 회원가입, 로그인
  • ✅ 게시글/댓글 작성
  • ✅ 파일 업로드
  • ✅ 설정 저장
  • ✅ 여러 입력 필드를 한 번에 제출

Form이 불필요한 경우 (state 사용)

  • ❌ 검색 자동완성
  • ❌ 실시간 필터링
  • ❌ 좋아요/팔로우 버튼
  • ❌ 드래그 앤 드롭
  • ❌ 슬라이더/토글

Form의 장점

  1. 코드 간결화: state 관리 불필요
  2. 접근성: Enter 키, 스크린 리더, 자동 완성
  3. 브라우저 기능: 자동 유효성 검사
  4. FormData 자동 처리: 체크박스, 파일 업로드 쉬움
// Form 없이: state 지옥 (30줄+)
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [hobbies, setHobbies] = useState([]);
const [errors, setErrors] = useState({});

const handleSubmit = () => {
  // 유효성 검사
  if (!name) setErrors({ name: "필수" });
  if (!email) setErrors({ email: "필수" });
  // ... 복잡한 로직
};

// Form 사용: 간단! (5줄)
<form action={signup}>
  <input name="name" required />
  <input name="email" type="email" required />
  <input name="hobbies" type="checkbox" value="coding" />
  <button type="submit">제출</button>
</form>

⚠️ 중요: Form이 의미 없는 경우

일반 React (CRA, Vite 등):

<form onSubmit={handleSubmit}>  {/* action 속성 없음! */}
  <input name="email" />
</form>

→ JavaScript 없으면 완전히 작동 안 함. Form이 그냥 <div>랑 똑같음.

Next.js API Routes만 있는 경우:

// app/api/signup/route.ts 파일은 있지만...
<form onSubmit={handleSubmit}>  {/* action 속성에 연결 안 됨! */}
  <input name="email" />
</form>

→ API 엔드포인트가 있어도, Form의 action에 연결되지 않으면 의미 없음!

Next.js Server Actions:

<form action={signup}>  {/* ✅ action="/api/__next_action__/abc123" 자동 생성! */}
  <input name="email" />
</form>

→ Next.js가 자동으로 엔드포인트 생성 + Form과 연결 = Form이 의미 있음!


2️⃣ API Routes 방식

파일 구조

app/
  api/
    users/
      route.ts           ← /api/users
      [id]/
        like/
          route.ts       ← /api/users/[id]/like

코드 예시

api/users/[id]/like/route.ts

import { NextResponse } from "next/server";

export async function POST(request: Request, { params }: { params: { id: string } }) {
  const userId = parseInt(params.id);

  // 인증 헤더 체크 (외부 요청 대응)
  const authHeader = request.headers.get("authorization");
  if (!authHeader) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  await db.likes.create({ userId, likedAt: new Date() });

  return NextResponse.json({ success: true });
}

api/users/route.ts

import { NextResponse } from "next/server";

const DB = [
  { id: 1, name: "hyoyoon1" },
  { id: 2, name: "hyoyoon2" },
  { id: 3, name: "hyoyoon3" },
];

// GET /api/users?name=hyoyoon
export async function GET(request: Request) {
  const searchParams = new URL(request.url).searchParams;
  const name = searchParams.get("name") as string;

  return NextResponse.json({
    users: DB.filter(user => user.name.includes(name)),
  });
}

// POST /api/users
export async function POST(request: Request) {
  const body = await request.json();
  const { name, email } = body;

  const user = await db.users.create({ name, email });

  return NextResponse.json({ user }, { status: 201 });
}

클라이언트에서 사용

Next.js 앱에서:

// app/users/page.tsx
"use client";
import { useState, useEffect } from "react";

export default function UsersPage() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch("/api/users?name=hyoyoon")
      .then(res => res.json())
      .then(data => setUsers(data.users));
  }, []);

  const handleLike = async (userId: number) => {
    const res = await fetch(`/api/users/${userId}/like`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": "Bearer token123"
      },
    });
    const data = await res.json();
  };

  const createUser = async (name: string, email: string) => {
    await fetch("/api/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name, email }),
    });
  };

  return (
    <div>
      <h1>Users</h1>
      {users.map(user => (
        <div key={user.id}>
          <p>{user.name}</p>
          <button onClick={() => handleLike(user.id)}>좋아요</button>
        </div>
      ))}
    </div>
  );
}

외부 클라이언트(React Native, iOS, Android)에서:

// React Native 앱, iOS/Android 앱 등
fetch("https://yoursite.com/api/users?name=hyoyoon", {
  method: "GET",
  headers: {
    Authorization: "Bearer token123",
    "Content-Type": "application/json",
  },
})
  .then(res => res.json())
  .then(data => console.log(data.users));

장점

  • ✅ 외부 클라이언트(모바일 앱, Postman)에서 접근 가능
  • ✅ RESTful API 표준
  • ✅ 인증/헤더 세밀하게 제어
  • ✅ API 문서화 가능 (Swagger 등)
  • ✅ CORS 설정 가능

🎯 실전 사용 예시

예시 1: 블로그 댓글 작성 (Next.js 앱 내부만 사용)

✅ Server Action 사용

// app/actions/comment-actions.ts
"use server";

export async function addComment(postId: number, content: string) {
  await db.comments.create({ postId, content });
  revalidatePath(`/posts/${postId}`);
}

// app/posts/[id]/page.tsx
"use client";

export default function PostPage({ params }: { params: { id: string } }) {
  return (
    <form action={async (formData) => {
      await addComment(
        parseInt(params.id),
        formData.get("content") as string
      );
    }}>
      <textarea name="content" placeholder="댓글을 입력하세요" />
      <button type="submit">댓글 작성</button>
    </form>
  );
}

예시 2: 결제 웹훅 (외부 서비스 → 내 서버)

✅ API Route 사용

// app/api/webhooks/payment/route.ts
import { NextResponse } from "next/server";

export async function POST(request: Request) {
  // Stripe, Toss 등 결제 서비스에서 호출
  const signature = request.headers.get("stripe-signature");

  const event = await verifyWebhook(request.body, signature);

  if (event.type === "payment.success") {
    await processPayment(event.data);
  }

  return NextResponse.json({ received: true });
}

외부 서비스 (Stripe)에서 호출:

POST https://yoursite.com/api/webhooks/payment
Headers:
  - stripe-signature: xyz123
  - content-type: application/json
Body: { ... payment data ... }

예시 3: 관리자 대시보드

✅ 둘 다 사용 가능

// 내부용: Server Action (간단한 CRUD)
// app/actions/admin-actions.ts
"use server";

export async function deleteUser(userId: number) {
  await db.users.delete(userId);
  revalidatePath("/admin/users");
}

// 외부 API 제공: API Route (통계 데이터)
// app/api/admin/stats/route.ts
export async function GET(request: Request) {
  const authHeader = request.headers.get("authorization");
  if (!authHeader) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const stats = await db.getStats();
  return NextResponse.json(stats);
}

예시 4: 사용자 인증

Server Action (폼 기반 로그인):

// app/actions/auth-actions.ts
"use server";

export async function login(formData: FormData) {
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  const user = await authenticateUser(email, password);

  if (!user) {
    return { error: "Invalid credentials" };
  }

  await createSession(user.id);
  redirect("/dashboard");
}

// app/login/page.tsx
<form action={login}>
  <input name="email" type="email" />
  <input name="password" type="password" />
  <button type="submit">로그인</button>
</form>

API Route (JWT 기반 인증):

// app/api/auth/login/route.ts
export async function POST(request: Request) {
  const { email, password } = await request.json();

  const user = await authenticateUser(email, password);

  if (!user) {
    return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
  }

  const token = generateJWT(user);

  return NextResponse.json({ token, user });
}

// 모바일 앱에서 호출
fetch("https://yoursite.com/api/auth/login", {
  method: "POST",
  body: JSON.stringify({ email, password }),
});

🔍 Server Actions vs API Routes 상세 비교

코드 복잡도 비교

사용자 생성 예시

Server Actions (간단):

// actions/user.ts (15줄)
"use server";
export async function createUser(name: string, email: string) {
  if (!name || !email) throw new Error("Required fields");
  const user = await db.users.create({ name, email });
  revalidatePath("/users");
  return user;
}

// page.tsx
const user = await createUser("hyoyoon", "email@example.com");

API Routes (복잡):

// app/api/users/route.ts (30줄)
export async function POST(request: Request) {
  try {
    const body = await request.json();
    if (!body.name || !body.email) {
      return NextResponse.json({ error: "Required" }, { status: 400 });
    }
    const user = await db.users.create(body);
    return NextResponse.json({ user }, { status: 201 });
  } catch (error) {
    return NextResponse.json({ error: "Error" }, { status: 500 });
  }
}

// page.tsx
const response = await fetch("/api/users", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "hyoyoon", email: "email@example.com" }),
});
const data = await response.json();
const user = data.user;

코드 양 비교: Server Actions 15줄 vs API Routes 30줄 (50% 감소!)


타입 안전성 비교

Server Actions:

// ✅ 타입 자동 체크
await createUser("hyoyoon", "email@example.com");

// ❌ 컴파일 에러!
await createUser(123, true);
//                ^^^ Type 'number' is not assignable to type 'string'

API Routes:

// ⚠️ 런타임에야 에러 발견
await fetch("/api/users", {
  body: JSON.stringify({ name: 123, email: true }), // 타입 체크 안 됨
});
// → 서버에서 실행해봐야 에러 발견

RPC의 장단점

✅ Server Actions (RPC)의 장점:

  1. 코드 간결: fetch 불필요, 그냥 함수 호출
  2. 타입 안전: TypeScript가 자동으로 체크
  3. 리팩토링 쉬움: IDE의 Rename 기능 사용 가능
  4. 자동완성: 파라미터 자동 제안

❌ Server Actions의 단점:

  1. 외부 접근 불가: 모바일 앱에서 사용 불가
  2. HTTP 제어 제한: 헤더, 상태 코드 세밀한 제어 어려움
  3. API 문서화 제한: Swagger 등 도구 사용 어려움

✅ API Routes (REST)의 장점:

  1. 외부 접근: 모든 클라이언트에서 사용 가능
  2. 표준 준수: RESTful API 설계
  3. 세밀한 제어: HTTP 헤더, 상태 코드 완전 제어
  4. 문서화: Swagger, OpenAPI 등 도구 사용 가능

❌ API Routes의 단점:

  1. 코드 복잡: 수동으로 요청/응답 처리
  2. 타입 안전 없음: 런타임 에러 가능성
  3. 보일러플레이트: 반복적인 코드 많음

🔄 함께 사용하는 경우

실제 프로젝트에서는 두 가지를 함께 사용하는 경우가 많습니다:

// Server Actions: 내부 데이터 변경
// app/actions/product-actions.ts
"use server";
export async function updateProduct(id: number, data: ProductData) {
  await db.products.update(id, data);
  revalidatePath("/admin/products");
}

// API Routes: 외부 API 제공
// app/api/products/route.ts
export async function GET() {
  const products = await db.products.findAll();
  return NextResponse.json({ products });
}

📝 현재 프로젝트 구조

현재 next-nest 프로젝트에는 두 가지 방식이 모두 구현되어 있습니다:

  1. Server Action: apps/web/app/actions/user-actions.ts

    • searchUsers() 함수
    • 클라이언트에서 직접 호출
  2. API Route: apps/web/app/api/users/route.ts

    • GET /api/users?name=xxx 엔드포인트
    • fetch로 호출

이것은 학습 목적으로 두 가지 방식을 모두 보여주기 위한 것입니다. 실제 프로젝트에서는 용도에 맞게 하나를 선택하거나, 각각의 장점을 살려 함께 사용하면 됩니다.


🤔 선택 기준 요약

상황 사용 방법 이유
폼 제출, 데이터 변경 Server Actions 코드 간결, 타입 안전, Progressive Enhancement
모바일 앱도 사용 API Routes 외부 접근 필요
웹훅 수신 API Routes 외부 서비스가 호출
Next.js 앱 내부만 Server Actions 간단하고 빠름
공개 API 제공 API Routes REST 표준, 문서화, CORS
인증 헤더 세밀 제어 API Routes HTTP 헤더 완전 제어
간단한 CRUD Server Actions 보일러플레이트 최소
복잡한 HTTP 처리 API Routes 세밀한 제어 가능

🎯 최종 정리

핵심 개념 4가지

1. RPC (Remote Procedure Call)

  • 서버 함수를 로컬 함수처럼 호출
  • Next.js가 자동으로 엔드포인트 생성
  • 타입 안전, 코드 간결

2. Progressive Enhancement

  • JavaScript 없어도 기본 기능 작동
  • JavaScript 있으면 더 나은 UX
  • 핵심: <form action="..."> + 실제 엔드포인트 필요

3. HTML Form의 가치

  • state 관리 불필요 (코드 간결)
  • 접근성 자동 제공 (Enter 키, 자동 완성)
  • 브라우저 기능 활용 (유효성 검사)
  • FormData 자동 처리 (파일 업로드, 체크박스)

4. Form이 의미 있으려면

  • 일반 React: Form이 의미 없음 (action 속성 없음)
  • API Routes만: 의미 없음 (Form과 연결 안 됨)
  • Server Actions: 의미 있음 (자동으로 엔드포인트 생성 + 연결)

권장사항

Next.js 앱만 사용하는 경우

Server Actions 우선 사용

  • 코드가 간결하고 타입 안전
  • Progressive Enhancement 자동 지원
  • 빠른 개발 속도

모바일 앱이나 외부 서비스와 연동하는 경우

API Routes 사용

  • 표준 REST API로 외부 접근 가능
  • 다양한 클라이언트 지원
  • API 문서화 가능

하이브리드 접근 (실전에서 가장 많이 사용)

두 가지를 함께 사용

  • 내부 로직: Server Actions
  • 외부 API: API Routes
  • 각각의 장점을 최대한 활용

@yoonc01 yoonc01 self-assigned this Nov 26, 2025
@yoonc01 yoonc01 added the enhancement New feature or request label Nov 26, 2025
@yoonc01 yoonc01 merged commit 83b4f67 into main Nov 26, 2025
@yoonc01 yoonc01 deleted the section2-6 branch November 26, 2025 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants