# Server Action 패턴 ## 개요 모든 백엔드 API 호출은 Server Action을 통해 처리합니다. 공통 유틸리티를 사용하여 보일러플레이트를 제거하고 일관된 패턴을 유지합니다. ## 핵심 유틸리티 ### buildApiUrl - URL 빌더 (필수) ```typescript 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 - 단건/목록 조회 ```typescript import { executeServerAction } from '@/lib/api/execute-server-action'; const result = await executeServerAction({ url: buildApiUrl('/api/v1/items', { search: params.search }), method: 'GET', // 기본값: GET transform: (data) => ..., // snake_case → camelCase 변환 errorMessage: '조회에 실패했습니다.', }); // 반환 타입 interface ActionResult { success: boolean; data?: T; error?: string; fieldErrors?: Record; // Laravel validation errors __authError?: boolean; // 401 감지 } ``` ### executePaginatedAction - 페이지네이션 조회 ```typescript import { executePaginatedAction } from '@/lib/api/execute-paginated-action'; const result = await executePaginatedAction({ url: buildApiUrl('/api/v1/items', { search: params.search, status: params.status !== 'all' ? params.status : undefined, page: params.page, }), transform: transformApiToFrontend, // 개별 아이템 변환 함수 errorMessage: '목록 조회에 실패했습니다.', }); // 반환 타입 interface PaginatedActionResult { success: boolean; data: T[]; // 변환된 아이템 배열 pagination: PaginationMeta; // 페이지네이션 정보 error?: string; __authError?: boolean; } interface PaginationMeta { currentPage: number; lastPage: number; perPage: number; total: number; } ``` ## Server Action 작성 패턴 ### 표준 예시 ```typescript // 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) { 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) { 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 호출 ```tsx 'use client'; import { useEffect, useState } from 'react'; import { getItems, type Item } from '@/components/{domain}/actions'; export default function ItemList() { const [data, setData] = useState([]); const [isLoading, setIsLoading] = useState(true); useEffect(() => { getItems({ page: 1 }) .then(result => { if (result.success) { setData(result.data); } }) .finally(() => setIsLoading(false)); }, []); if (isLoading) return
로딩 중...
; return <>{/* 렌더링 */}; } ``` ## 주의사항 ### 'use server' 파일에서 타입 re-export 금지 ```typescript // ❌ 금지 - 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 변환 (개발자 작성)