Next.js Server Action: 서버와 클라이언트를 연결하는 새로운 방식

2025. 7. 2. 10:40·Frontend Development
728x90

Next.js의 App Router와 함께 등장한 Server Action은 풀스택 개발의 패러다임을 바꾸고 있습니다. 기존의 API Route를 거치지 않고도 서버 로직을 클라이언트에서 직접 호출할 수 있는 이 혁신적인 기능에 대해 자세히 알아보겠습니다.


Server Action이란?

기본 개념

Server Action은 Next.js 13.4 버전부터 도입된 기능으로, 서버에서 실행되지만 클라이언트에서 직접 호출할 수 있는 비동기 함수입니다. 마치 클라이언트 함수를 호출하는 것처럼 간단하게 서버 로직을 실행할 수 있어, 개발자 경험을 크게 향상시킵니다.

기존 방식과의 차이점

기존 방식: API Route 사용

// pages/api/create-review.ts (기존 방식)
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'POST') {
    const { content } = req.body;
    // 데이터베이스 처리
    await saveReview(content);
    res.status(200).json({ success: true });
  }
}

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

Server Action 방식

// app/actions.ts (Server Action)
"use server";

export async function createReviewAction(formData: FormData) {
  const content = formData.get("content") as string;
  // 데이터베이스 처리 (직접 접근)
  await saveReview(content);
}

// 컴포넌트에서 사용
<form action={createReviewAction}>
  <textarea name="content" required />
  <button type="submit">리뷰 작성</button>
</form>

Server Action 사용법

1. "use server" 디렉티브

Server Action을 정의하기 위해서는 반드시 "use server" 디렉티브를 사용해야 합니다.

// 파일 레벨에서 선언
"use server";

export async function updateUser(formData: FormData) {
  // 모든 함수가 Server Action이 됨
}

// 또는 함수 레벨에서 선언
export async function deletePost(id: string) {
  "use server";
  // 이 함수만 Server Action이 됨
}

2. 폼과 함께 사용하기

가장 일반적인 사용 방법은 HTML 폼의 action 속성과 함께 사용하는 것입니다.

// app/actions/user.ts
"use server";

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

export async function createUser(formData: FormData) {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;

  // 유효성 검사
  if (!name || !email) {
    throw new Error('필수 필드가 누락되었습니다.');
  }

  // 데이터베이스 저장
  const user = await prisma.user.create({
    data: { name, email }
  });

  // 캐시 재검증 및 리다이렉트
  revalidatePath('/users');
  redirect(`/users/${user.id}`);
}
// app/users/create/page.tsx
import { createUser } from '@/app/actions/user';

export default function CreateUserPage() {
  return (
    <form action={createUser} className="space-y-4">
      <div>
        <label htmlFor="name">이름</label>
        <input 
          type="text" 
          id="name" 
          name="name" 
          required 
          className="border rounded px-3 py-2"
        />
      </div>

      <div>
        <label htmlFor="email">이메일</label>
        <input 
          type="email" 
          id="email" 
          name="email" 
          required 
          className="border rounded px-3 py-2"
        />
      </div>

      <button type="submit" className="bg-blue-500 text-white px-4 py-2 rounded">
        사용자 생성
      </button>
    </form>
  );
}

3. 프로그래매틱 호출

JavaScript로 직접 Server Action을 호출할 수도 있습니다.

"use client";

import { useState, useTransition } from 'react';
import { updateProfile } from '@/app/actions/profile';

export default function ProfileForm() {
  const [isPending, startTransition] = useTransition();
  const [message, setMessage] = useState('');

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    const formData = new FormData(event.currentTarget);

    startTransition(async () => {
      try {
        await updateProfile(formData);
        setMessage('프로필이 업데이트되었습니다.');
      } catch (error) {
        setMessage('오류가 발생했습니다.');
      }
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* 폼 필드들 */}
      <button type="submit" disabled={isPending}>
        {isPending ? '저장 중...' : '저장'}
      </button>
      {message && <p>{message}</p>}
    </form>
  );
}

Server Action의 핵심 장점

1. 개발 생산성 향상

기존 방식 Server Action
API Route 생성 → 클라이언트 fetch 코드 → 에러 처리 Server Action 함수 하나로 완료
3개 파일 수정 필요 1개 파일 수정으로 충분
보일러플레이트 코드 많음 최소한의 코드로 구현
// 기존: 여러 단계 필요
// 1. API Route 생성
// 2. 클라이언트 fetch 함수 작성  
// 3. 에러 처리 로직 구현

// Server Action: 한 번에 해결
"use server";

export async function likePost(postId: string) {
  await prisma.like.create({
    data: { postId, userId: getCurrentUserId() }
  });
  revalidatePath('/posts');
}

2. 보안성 강화

Server Action의 로직은 클라이언트 번들에 포함되지 않습니다. 이는 마치 은행의 금고실 같은 역할을 합니다.

"use server";

export async function processPayment(amount: number) {
  // 이 로직은 클라이언트에 전송되지 않음
  const apiKey = process.env.PAYMENT_SECRET_KEY; // 안전!
  const result = await paymentGateway.charge({
    amount,
    apiKey
  });

  // 민감한 로직도 숨겨짐
  if (amount > 1000000) {
    await notifyAdmin('Large payment detected');
  }

  return { success: true };
}

3. Progressive Enhancement

JavaScript가 비활성화되어도 폼이 동작합니다. 이는 웹의 기본 원칙을 따르는 것입니다.

// JavaScript 없이도 동작하는 폼
<form action={subscribeNewsletter}>
  <input type="email" name="email" required />
  <button type="submit">구독하기</button>
</form>

// JavaScript가 로드되면 더 나은 UX 제공
// - 로딩 상태 표시
// - 실시간 유효성 검사
// - 페이지 새로고침 없이 처리

4. 성능 최적화

// 네트워크 요청 최소화
"use server";

export async function getPostsWithComments(userId: string) {
  // 서버에서 직접 데이터베이스 접근
  // API 왕복 없이 한 번에 처리
  const posts = await prisma.post.findMany({
    where: { authorId: userId },
    include: {
      comments: true,
      likes: true
    }
  });

  return posts;
}

실제 사용 사례와 패턴

1. 댓글 시스템 구현

// app/actions/comments.ts
"use server";

import { revalidatePath } from 'next/cache';

export async function addComment(postId: string, formData: FormData) {
  const content = formData.get('content') as string;
  const authorId = await getCurrentUserId();

  if (!content.trim()) {
    throw new Error('댓글 내용을 입력해주세요.');
  }

  await prisma.comment.create({
    data: {
      content,
      postId,
      authorId
    }
  });

  revalidatePath(`/posts/${postId}`);
}

export async function deleteComment(commentId: string) {
  const userId = await getCurrentUserId();
  const comment = await prisma.comment.findUnique({
    where: { id: commentId }
  });

  if (comment?.authorId !== userId) {
    throw new Error('권한이 없습니다.');
  }

  await prisma.comment.delete({
    where: { id: commentId }
  });

  revalidatePath('/posts');
}

2. 좋아요 기능

// components/LikeButton.tsx
import { toggleLike } from '@/app/actions/likes';

interface LikeButtonProps {
  postId: string;
  isLiked: boolean;
  likeCount: number;
}

export default function LikeButton({ postId, isLiked, likeCount }: LikeButtonProps) {
  return (
    <form action={toggleLike.bind(null, postId)}>
      <button
        type="submit"
        className={`flex items-center space-x-2 ${
          isLiked ? 'text-red-500' : 'text-gray-500'
        }`}
      >
        <span>{isLiked ? '❤️' : '🤍'}</span>
        <span>{likeCount}</span>
      </button>
    </form>
  );
}

3. 파일 업로드

// app/actions/upload.ts
"use server";

import { writeFile } from 'fs/promises';
import { join } from 'path';

export async function uploadFile(formData: FormData) {
  const file = formData.get('file') as File;

  if (!file) {
    throw new Error('파일을 선택해주세요.');
  }

  // 파일 검증
  const maxSize = 5 * 1024 * 1024; // 5MB
  if (file.size > maxSize) {
    throw new Error('파일 크기는 5MB를 초과할 수 없습니다.');
  }

  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
  if (!allowedTypes.includes(file.type)) {
    throw new Error('지원하지 않는 파일 형식입니다.');
  }

  // 파일 저장
  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);
  const fileName = `${Date.now()}-${file.name}`;
  const path = join(process.cwd(), 'public/uploads', fileName);

  await writeFile(path, buffer);

  return { fileName, url: `/uploads/${fileName}` };
}

주의사항과 제한사항

1. 보안 고려사항

// ❌ 잘못된 예시 - 클라이언트 데이터를 그대로 신뢰
"use server";

export async function updateUser(formData: FormData) {
  const userId = formData.get('userId'); // 위험!
  // 클라이언트에서 전송된 userId를 그대로 사용
}

// ✅ 올바른 예시 - 서버에서 인증 확인
"use server";

export async function updateUser(formData: FormData) {
  const userId = await getCurrentUserId(); // 서버에서 확인
  const name = formData.get('name') as string;

  // 추가 권한 검사
  const hasPermission = await checkUpdatePermission(userId);
  if (!hasPermission) {
    throw new Error('권한이 없습니다.');
  }
}

2. 에러 처리

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

export async function safeCreatePost(formData: FormData) {
  try {
    const title = formData.get('title') as string;
    const content = formData.get('content') as string;

    // 유효성 검사
    if (!title || title.length < 3) {
      return { error: '제목은 3글자 이상이어야 합니다.', data: null };
    }

    const post = await prisma.post.create({
      data: { title, content, authorId: await getCurrentUserId() }
    });

    revalidatePath('/posts');
    return { error: null, data: post };

  } catch (error) {
    console.error('Post creation failed:', error);
    return { error: '게시글 생성에 실패했습니다.', data: null };
  }
}

3. TypeScript 타입 안전성

// types/actions.ts
export type ActionResult<T> = {
  success: boolean;
  data?: T;
  error?: string;
};

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

export async function createPost(
  formData: FormData
): Promise<ActionResult<{ id: string; title: string }>> {
  try {
    const title = formData.get('title') as string;
    const content = formData.get('content') as string;

    const post = await prisma.post.create({
      data: { title, content, authorId: await getCurrentUserId() }
    });

    return {
      success: true,
      data: { id: post.id, title: post.title }
    };
  } catch (error) {
    return {
      success: false,
      error: '게시글 생성에 실패했습니다.'
    };
  }
}

성능 최적화 팁

1. revalidatePath 사용

"use server";

export async function updatePost(postId: string, formData: FormData) {
  // 포스트 업데이트
  await prisma.post.update({
    where: { id: postId },
    data: { title: formData.get('title') as string }
  });

  // 관련 페이지들의 캐시 무효화
  revalidatePath('/posts');
  revalidatePath(`/posts/${postId}`);
  revalidatePath('/'); // 홈페이지에 최신 포스트가 표시되는 경우
}

2. 선택적 리렌더링

"use client";

import { useOptimistic } from 'react';

export default function PostList({ posts }: { posts: Post[] }) {
  const [optimisticPosts, addOptimisticPost] = useOptimistic(
    posts,
    (state, newPost: Post) => [...state, newPost]
  );

  const handleSubmit = async (formData: FormData) => {
    // 낙관적 업데이트
    const newPost = {
      id: 'temp-' + Date.now(),
      title: formData.get('title') as string,
      content: formData.get('content') as string
    } as Post;

    addOptimisticPost(newPost);

    // 실제 서버 액션 실행
    await createPost(formData);
  };

  return (
    <div>
      {optimisticPosts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

실무 도입 가이드

단계별 마이그레이션

1단계: 간단한 폼부터 시작

// 기존 API Route 대신 Server Action으로 교체
// 예: 연락처 폼, 뉴스레터 구독 등

2단계: CRUD 작업 적용

// 게시글 작성, 댓글 추가 등
// 기존 API들을 점진적으로 교체

3단계: 복합적인 작업으로 확장

// 파일 업로드, 복잡한 비즈니스 로직 등
// Server Action의 모든 기능 활용

팀 도입 시 고려사항

고려사항 해결방안
학습 곡선 간단한 예시부터 시작, 점진적 도입
기존 코드와의 호환성 API Route와 병행 사용 가능
디버깅 서버 로그 모니터링 체계 구축
테스트 서버 컴포넌트 테스트 도구 활용

결론

Server Action은 Next.js 개발의 새로운 패러다임을 제시합니다. 마치 프론트엔드와 백엔드 사이의 벽을 허무는 것처럼, 개발자가 더 직관적이고 효율적으로 풀스택 애플리케이션을 구축할 수 있게 해줍니다.

핵심 포인트 요약:

  • 생산성: API Route 없이도 서버 로직 구현 가능
  • 보안: 서버 로직이 클라이언트에 노출되지 않음
  • 사용자 경험: JavaScript 없이도 동작하는 Progressive Enhancement
  • 성능: 불필요한 네트워크 요청 감소

Server Action을 도입할 때는 보안과 에러 처리에 특별히 주의하고, 점진적으로 적용하는 것이 좋습니다. 이 혁신적인 기능을 통해 더 나은 웹 애플리케이션을 만들어보세요.

728x90
저작자표시 비영리 변경금지 (새창열림)

'Frontend Development' 카테고리의 다른 글

CSS 쌓임 맥락의 모든 것: Z-Index가 작동하지 않는 이유  (2) 2025.07.03
JavaScript 매개변수 전달의 비밀: Call by Value와 참조의 모든 것  (8) 2025.07.02
CSS 위치 조정: Transform vs Position, 언제 무엇을 써야 할까?  (5) 2025.06.27
CORS 없이 SOP 우회하기: 프록시 서버를 활용한 스마트한 해결책  (0) 2025.06.24
JavaScript 프로토타입 상속: 객체 간 상속의 핵심 메커니즘 완전 정복  (2) 2025.06.20
'Frontend Development' 카테고리의 다른 글
  • CSS 쌓임 맥락의 모든 것: Z-Index가 작동하지 않는 이유
  • JavaScript 매개변수 전달의 비밀: Call by Value와 참조의 모든 것
  • CSS 위치 조정: Transform vs Position, 언제 무엇을 써야 할까?
  • CORS 없이 SOP 우회하기: 프록시 서버를 활용한 스마트한 해결책
Kun Woo Kim
Kun Woo Kim
안녕하세요, 김건우입니다! 웹과 앱 개발에 열정적인 전문가로, React, TypeScript, Next.js, Node.js, Express, Flutter 등을 활용한 프로젝트를 다룹니다. 제 블로그에서는 개발 여정, 기술 분석, 실용적 코딩 팁을 공유합니다. 창의적인 솔루션을 실제로 적용하는 과정의 통찰도 나눌 예정이니, 궁금한 점이나 상담은 언제든 환영합니다.
  • Kun Woo Kim
    WhiteMouseDev
    김건우
  • 깃허브
    포트폴리오
    velog
  • 전체
    오늘
    어제
  • 공지사항

    • [인사말] 이제 티스토리에서도 만나요! WhiteMouse⋯
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 분류 전체보기 (108) N
      • Frontend Development (44) N
      • Backend Development (24) N
      • Algorithm (33)
        • 백준 (11)
        • 프로그래머스 (17)
        • 알고리즘 (5)
      • Infra (1)
      • 자료구조 (3)
  • 링크

    • Github
    • Portfolio
    • Velog
  • 인기 글

  • 태그

    tailwindcss
    frontend development
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Kun Woo Kim
Next.js Server Action: 서버와 클라이언트를 연결하는 새로운 방식
상단으로

티스토리툴바