next.js 서버 액션: 프론트엔드와 백엔드를 하나로 묶는 가장 쉬운 방법

1 min read

웹 개발을 하다 보면 ‘사용자 등록’, ‘게시글 수정’, ‘상품 삭제’와 같이 데이터를 변경하는 기능은 필수적입니다. 전통적으로 이런 기능을 만들려면, 우리는 다음과 같은 복잡한 과정을 거쳐야 했습니다.

  1. 백엔드(서버)에 데이터를 처리할 API 엔드포인트(예: /api/users/create)를 만듭니다.
  2. 프론트엔드(클라이언트)에서 fetchaxios를 사용해 해당 API로 요청을 보냅니다.
  3. 로딩 상태, 성공/실패 상태를 직접 관리하고 UI를 업데이트합니다.

이 과정은 번거롭고 반복적인 코드가 많이 필요했습니다. 하지만 Next.js의 서버 액션(Server Actions)은 이 모든 과정을 놀랍도록 단순화시켜 줍니다.

이번 글에서는 서버 액션이 무엇인지, 왜 강력한지 알아보고, 실제 사용자 관리 데이터 그리드 예제를 통해 초보자도 쉽게 따라 할 수 있도록 단계별로 설명하겠습니다.

📚 주요 개념 설명

서버 액션을 이해하기 위한 핵심 개념들을 알기 쉽게 풀어보겠습니다.

  • 서버 액션 (Server Action): 프론트엔드 컴포넌트에서 마치 일반 함수처럼 직접 호출할 수 있는 특별한 서버 함수입니다. 중간에 API를 만들 필요 없이, 클라이언트의 버튼 클릭 한 번으로 서버의 데이터베이스 작업을 바로 실행할 수 있는 ‘직통 전화’와 같습니다.
  • 데이터 변경 (Mutation): 사용자를 생성, 수정, 삭제하는 것처럼 서버의 데이터를 바꾸는 모든 작업을 의미합니다. 서버 액션은 이러한 데이터 변경 작업을 안전하게 처리하기 위해 만들어졌습니다.
  • 재검증 (Revalidation): 데이터가 변경된 후 가장 중요한 것은 화면에도 최신 데이터를 보여주는 것입니다. revalidatePath라는 마법 같은 함수를 호출하면, Next.js가 알아서 데이터를 새로고침하여 화면을 자동으로 업데이트해 줍니다. 더 이상 수동으로 상태를 관리할 필요가 없습니다.
  • 타입 안전성 (Type Safety): Zod와 같은 라이브러리를 함께 사용하면, 사용자가 엉뚱한 데이터를 입력하는 것을 서버에서 미리 막을 수 있습니다. 예를 들어, 이메일 필드에 일반 텍스트를 입력하는 것을 막아 데이터의 신뢰성을 높여주는 ‘문지기’ 역할을 합니다.

💡 실제 사용 예제: 서버 액션을 활용한 데이터 그리드

이제 실제 코드를 통해 서버 액션이 어떻게 동작하는지 살펴보겠습니다. 사용자를 생성, 수정, 삭제할 수 있는 관리자 페이지를 만든다고 상상해 보세요.

1단계: 서버의 두뇌, 액션 파일 만들기 (app/actions/users.ts)

이 파일은 서버에서만 실행되는 실제 로직을 담고 있습니다. 가장 중요한 것은 파일 맨 위에 'use server';를 적어주는 것입니다. 이것이 바로 “이 파일 안의 함수들은 서버 액션이야!”라고 Next.js에 알려주는 신호입니다.

// app/actions/users.ts
'use server'; // 이 파일은 서버에서만 동작함을 명시!

import { revalidatePath } from 'next/cache'; // 자동 새로고침을 위한 마법 함수
import { z } from 'zod'; // 데이터 유효성 검사를 위한 문지기 라이브러리
// import { db } from '@/lib/db'; // 실제 데이터베이스 연결 (예시)

// 1. 사용자 데이터 형식을 정의 (설계도)
const UserSchema = z.object({
  id: z.number().optional(),
  name: z.string().min(2, "이름은 2자 이상이어야 합니다."), // 이름 규칙
  email: z.string().email("올바른 이메일 형식이 아닙니다."), // 이메일 규칙
  role: z.enum(['admin', 'user', 'guest']),
  status: z.enum(['active', 'inactive'])
});

// 2. 사용자 생성 액션
export async function createUser(formData: FormData) {
  try {
    // 폼에서 넘어온 데이터를 객체로 변환
    const data = Object.fromEntries(formData.entries());

    // Zod를 사용해 데이터가 올바른지 검증 (문지기 통과!)
    const validatedData = UserSchema.parse(data);

    // 데이터베이스에 사용자 정보 저장 (실제 작업)
    // const user = await db.user.create({ data: validatedData });

    // "/users" 경로의 데이터를 최신으로 업데이트하라고 명령!
    revalidatePath('/users');

    return { success: true };
  } catch (error) {
    // 실패 시 에러 메시지 반환
    return { success: false, error: "사용자 생성에 실패했습니다." };
  }
}

// 3. 사용자 수정 및 삭제 액션 (생성과 유사한 패턴)
export async function updateUser(id: number, formData: FormData) {
  // ... (생성과 유사한 로직)
  revalidatePath('/users');
  return { success: true };
}

export async function deleteUser(id: number) {
  // ... (생성과 유사한 로직)
  revalidatePath('/users');
  return { success: true };
}

2단계: 사용자와 상호작용할 UI 컴포넌트 (components/DataGrid/ServerActionDataGrid.tsx)

이 컴포넌트는 사용자가 실제로 보는 화면입니다. 사용자가 버튼을 누르면 위에서 만든 서버 액션을 직접 호출합니다.

// components/DataGrid/ServerActionDataGrid.tsx
'use client'; // 이 컴포넌트는 사용자와 상호작용하므로 클라이언트 컴포넌트!

import { useState } from 'react';
import { ReactTabulator } from 'react-tabulator';
import { Button, Dialog, TextField, ...etc } from '@mui/material';
import { createUser, updateUser, deleteUser } from '@/app/actions/users';

// ... (UI 상태 및 테이블 컬럼 설정은 생략)

export function ServerActionDataGrid({ initialData }: { initialData: User[] }) {
  // 다이얼로그(팝업창) 열림 여부 등 UI 상태 관리
  const [dialogOpen, setDialogOpen] = useState(false);
  const [selectedUser, setSelectedUser] = useState<User | null>(null);

  // ... (삭제 핸들러 등)

  return (
    <Paper>
      {/* 사용자 목록을 보여주는 데이터 테이블 */}
      <ReactTabulator data={initialData} columns={columns} />

      {/* 사용자 생성/수정 팝업창 */}
      <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
        <DialogTitle>
          {selectedUser ? '사용자 수정' : '새 사용자'}
        </DialogTitle>

        {/* 여기가 바로 서버 액션의 마법이 일어나는 곳입니다! */}
        <Box
          component="form"
          action={async (formData: FormData) => {
            // 조건에 따라 생성 또는 수정 액션을 직접 호출!
            const result = selectedUser
              ? await updateUser(selectedUser.id, formData)
              : await createUser(formData);

            // 결과에 따라 알림 메시지를 띄우고 팝업창을 닫음
            if (result.success) {
              setDialogOpen(false);
              // revalidatePath가 호출되었기 때문에 테이블 데이터는 자동으로 업데이트됩니다!
            }
          }}
        >
          <DialogContent>
            {/* 이름, 이메일 등 입력 필드들 */}
            <TextField name="name" label="이름" />
            <TextField name="email" label="이메일" />
            {/* ... */}
          </DialogContent>
          <DialogActions>
            <Button onClick={() => setDialogOpen(false)}>취소</Button>
            <Button type="submit" variant="contained">저장</Button>
          </DialogActions>
        </Box>
      </Dialog>
      {/* ... (알림 메시지 컴포넌트) */}
    </Paper>
  );
}

🎓 주니어 개발자를 위한 서버 액션 패턴 핵심 정리

위 예제를 통해 우리가 배울 수 있는 핵심은 다음과 같습니다.

  • 놀랍도록 간단해진 코드: <form>action 속성에 서버 함수(createUser, updateUser)를 그냥 넣어주기만 하면 끝입니다. fetch도, API 주소도, axios도 필요 없습니다.
  • 서버와 클라이언트의 완벽한 조화: 'use server''use client'를 사용해 코드의 실행 위치를 명확히 구분하면서도, 마치 하나의 파일에서 작업하는 것처럼 자연스럽게 함수를 호출할 수 있습니다.
  • 자동으로 업데이트되는 UI: 데이터가 변경된 후 revalidatePath('/users') 한 줄만 호출하면, Next.js가 나머지 복잡한 화면 업데이트를 모두 처리해 줍니다. 개발자는 비즈니스 로직에만 집중하면 됩니다.
  • 안전장치는 기본: Zod를 이용한 데이터 검증을 액션의 첫 단계에 넣어두면, 잘못된 데이터가 시스템에 들어오는 것을 원천적으로 차단하여 안정성을 크게 높일 수 있습니다.
  • 상태 관리는 여전히 클라이언트의 몫: 팝업창을 열고 닫거나, 알림 메시지를 잠시 보여주는 것과 같은 순수한 UI 상태는 여전히 React의 useState를 사용해 클라이언트에서 관리합니다. 서버 액션은 서버 데이터와 관련된 작업만 처리합니다.

next.js 클라이언트 상태 관리 웹 캐싱 전략의 모든 것…

매번 똑같은 책을 빌리기 위해 도서관에 가는 것과, 자주 보는 책 몇 권은 아예 책상 위에 꺼내두고 보는 것 중 어느 쪽이 더 빠를까요?...
eve
1 min read

React 클라이언트 상태 관리 (전역 상태 관리와 Context API)

우리가 큰 건물을 짓는다고 상상해 봅시다. 건물 전체의 ‘중앙 난방 온도’나 ‘비상벨 작동 여부’ 같은 정보는 1층 로비, 10층 사무실, 지하 주차장 등 건물의...
eve
1 min read

 React 19 자동 메모이제이션 (Automatic Memoization)

혹시 개발 공부를 하다가 useMemo, useCallback 같은 훅(Hook)들을 만나고 “이건 왜 써야 하지? 너무 복잡해…”라고 생각했던 적 있으신가요? 이런 훅들은 React의 성능을 최적화하기 위해,...
eve
46 sec read