웹 개발을 하다 보면 ‘사용자 등록’, ‘게시글 수정’, ‘상품 삭제’와 같이 데이터를 변경하는 기능은 필수적입니다. 전통적으로 이런 기능을 만들려면, 우리는 다음과 같은 복잡한 과정을 거쳐야 했습니다.
- 백엔드(서버)에 데이터를 처리할 API 엔드포인트(예:
/api/users/create
)를 만듭니다. - 프론트엔드(클라이언트)에서
fetch
나axios
를 사용해 해당 API로 요청을 보냅니다. - 로딩 상태, 성공/실패 상태를 직접 관리하고 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
를 사용해 클라이언트에서 관리합니다. 서버 액션은 서버 데이터와 관련된 작업만 처리합니다.