- 법인차량 관리 3개 페이지 (차량등록, 운행일지, 정비이력) - MES 데이터 정합성 분석 보고서 v1/v2 - sam-docs 프론트엔드 기술문서 v1 (9개 챕터) - claudedocs 가이드/테스트URL 업데이트
246 lines
6.3 KiB
Markdown
246 lines
6.3 KiB
Markdown
# 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<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 - 페이지네이션 조회
|
|
|
|
```typescript
|
|
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 작성 패턴
|
|
|
|
### 표준 예시
|
|
|
|
```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<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 호출
|
|
|
|
```tsx
|
|
'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 금지
|
|
|
|
```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 변환 (개발자 작성)
|