- 법인차량 관리 3개 페이지 (차량등록, 운행일지, 정비이력) - MES 데이터 정합성 분석 보고서 v1/v2 - sam-docs 프론트엔드 기술문서 v1 (9개 챕터) - claudedocs 가이드/테스트URL 업데이트
6.3 KiB
6.3 KiB
Server Action 패턴
개요
모든 백엔드 API 호출은 Server Action을 통해 처리합니다. 공통 유틸리티를 사용하여 보일러플레이트를 제거하고 일관된 패턴을 유지합니다.
핵심 유틸리티
buildApiUrl - URL 빌더 (필수)
import { buildApiUrl } from '@/lib/api/query-params';
// 기본 사용
buildApiUrl('/api/v1/items')
// → "https://api.example.com/api/v1/items"
// 쿼리 파라미터
buildApiUrl('/api/v1/items', {
search: 'test',
status: 'active',
page: 1,
})
// → "https://api.example.com/api/v1/items?search=test&status=active&page=1"
// undefined/null/'' 자동 필터링
buildApiUrl('/api/v1/items', {
search: '', // 제외됨
status: undefined, // 제외됨
page: 1,
})
// → "https://api.example.com/api/v1/items?page=1"
// 동적 경로 + 파라미터
buildApiUrl(`/api/v1/items/${id}`, { with_details: true })
금지:
new URLSearchParams()직접 사용,${API_URL}직접 조립
executeServerAction - 단건/목록 조회
import { executeServerAction } from '@/lib/api/execute-server-action';
const result = await executeServerAction<ApiType, FrontendType>({
url: buildApiUrl('/api/v1/items', { search: params.search }),
method: 'GET', // 기본값: GET
transform: (data) => ..., // snake_case → camelCase 변환
errorMessage: '조회에 실패했습니다.',
});
// 반환 타입
interface ActionResult<T> {
success: boolean;
data?: T;
error?: string;
fieldErrors?: Record<string, string[]>; // Laravel validation errors
__authError?: boolean; // 401 감지
}
executePaginatedAction - 페이지네이션 조회
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
const result = await executePaginatedAction<ApiType, FrontendType>({
url: buildApiUrl('/api/v1/items', {
search: params.search,
status: params.status !== 'all' ? params.status : undefined,
page: params.page,
}),
transform: transformApiToFrontend, // 개별 아이템 변환 함수
errorMessage: '목록 조회에 실패했습니다.',
});
// 반환 타입
interface PaginatedActionResult<T> {
success: boolean;
data: T[]; // 변환된 아이템 배열
pagination: PaginationMeta; // 페이지네이션 정보
error?: string;
__authError?: boolean;
}
interface PaginationMeta {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
}
Server Action 작성 패턴
표준 예시
// src/components/{domain}/actions.ts
'use server';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
import { buildApiUrl } from '@/lib/api/query-params';
// ===== 1. API 원본 타입 (snake_case) =====
interface ItemApi {
id: number;
item_name: string;
item_code: string;
created_at: string;
}
// ===== 2. 프론트엔드 타입 (camelCase) =====
export interface Item {
id: string;
itemName: string;
itemCode: string;
createdAt: string;
}
// ===== 3. Transform 함수 =====
function transformItem(api: ItemApi): Item {
return {
id: String(api.id),
itemName: api.item_name,
itemCode: api.item_code,
createdAt: api.created_at,
};
}
// ===== 4. 목록 조회 (페이지네이션) =====
export async function getItems(params: {
search?: string;
status?: string;
page?: number;
}) {
return executePaginatedAction({
url: buildApiUrl('/api/v1/items', {
search: params.search,
status: params.status !== 'all' ? params.status : undefined,
page: params.page,
}),
transform: transformItem,
errorMessage: '품목 목록 조회에 실패했습니다.',
});
}
// ===== 5. 단건 조회 =====
export async function getItem(id: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/items/${id}`),
transform: (data: { item: ItemApi }) => transformItem(data.item),
errorMessage: '품목 조회에 실패했습니다.',
});
}
// ===== 6. 생성 =====
export async function createItem(formData: Partial<Item>) {
return executeServerAction({
url: buildApiUrl('/api/v1/items'),
method: 'POST',
body: {
item_name: formData.itemName,
item_code: formData.itemCode,
},
errorMessage: '품목 등록에 실패했습니다.',
});
}
// ===== 7. 수정 =====
export async function updateItem(id: string, formData: Partial<Item>) {
return executeServerAction({
url: buildApiUrl(`/api/v1/items/${id}`),
method: 'PUT',
body: {
item_name: formData.itemName,
item_code: formData.itemCode,
},
errorMessage: '품목 수정에 실패했습니다.',
});
}
// ===== 8. 삭제 =====
export async function deleteItems(ids: string[]) {
return executeServerAction({
url: buildApiUrl('/api/v1/items/bulk-delete'),
method: 'POST',
body: { ids: ids.map(Number) },
errorMessage: '품목 삭제에 실패했습니다.',
});
}
컴포넌트에서 Server Action 호출
'use client';
import { useEffect, useState } from 'react';
import { getItems, type Item } from '@/components/{domain}/actions';
export default function ItemList() {
const [data, setData] = useState<Item[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
getItems({ page: 1 })
.then(result => {
if (result.success) {
setData(result.data);
}
})
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <div>로딩 중...</div>;
return <>{/* 렌더링 */}</>;
}
주의사항
'use server' 파일에서 타입 re-export 금지
// ❌ 금지 - Next.js Turbopack 제한 (async 함수만 export 허용)
export type { Item } from './types';
export { type Item } from './types';
// ✅ 허용 - 인라인 타입 정의
export interface Item { ... }
export type Item = { ... };
// ✅ 허용 - 컴포넌트에서 원본 타입 파일 직접 import
// 컴포넌트에서: import type { Item } from './types';
데이터 변환 체인
Backend (snake_case) → safeResponseJson() → transform() → Frontend (camelCase)
safeResponseJson: PHP 백엔드가 JSON 뒤에 경고 텍스트를 붙여 보내는 경우 방어transform: snake_case → camelCase 변환 (개발자 작성)