Files
sam-react-prod/sam-docs/frontend/v1/04-server-actions.md
유병철 c309ac479f feat: [vehicle] 법인차량 관리 모듈 + MES 분석 보고서 + 프론트엔드 문서
- 법인차량 관리 3개 페이지 (차량등록, 운행일지, 정비이력)
- MES 데이터 정합성 분석 보고서 v1/v2
- sam-docs 프론트엔드 기술문서 v1 (9개 챕터)
- claudedocs 가이드/테스트URL 업데이트
2026-03-13 17:52:57 +09:00

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 변환 (개발자 작성)