Files
sam-docs/frontend/v1/02-api-pattern.md
유병철 8f939d3609 docs: [frontend] 프론트엔드 아키텍처/가이드 문서 v1 작성
- _index.md: 문서 목록 및 버전 관리
- 01~09: 아키텍처, API패턴, 컴포넌트, 폼, 스타일, 인증, 대시보드, 컨벤션
- 10: 문서 API 연동 스펙 (api-specs에서 이관)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:24:25 +09:00

6.4 KiB

02. API 통신 패턴

대상: 프론트엔드/백엔드 개발자 버전: 1.0.0 최종 수정: 2026-03-09


1. 전체 흐름

클라이언트(브라우저)
    ↓ fetch('/api/proxy/items?page=1')  ← 토큰 없이
Next.js API Proxy (/api/proxy/[...path])
    ↓ HttpOnly 쿠키에서 access_token 읽기
    ↓ Authorization: Bearer {token} 헤더 추가
PHP Laravel Backend (https://api.xxx.com/api/v1/items?page=1)
    ↓ 응답
Next.js → 클라이언트 (응답 전달)

왜 프록시?

  • HttpOnly 쿠키는 JavaScript에서 읽을 수 없음 (XSS 방지)
  • 서버(Next.js)에서만 쿠키 읽어서 백엔드에 전달 가능
  • 토큰 갱신(refresh)도 프록시에서 자동 처리

2. API 호출 방법 2가지

방법 A: Server Action (대부분의 경우)

Server Action에서 serverFetch / executeServerAction 사용.

// components/accounting/Bills/actions.ts
'use server';

import { buildApiUrl } from '@/lib/api/query-params';
import { executePaginatedAction } from '@/lib/api';

export async function getBills(params: BillSearchParams) {
  return executePaginatedAction({
    url: buildApiUrl('/api/v1/bills', {
      search: params.search,
      bill_type: params.billType !== 'all' ? params.billType : undefined,
      page: params.page,
    }),
    transform: transformBillApiToFrontend,
    errorMessage: '어음 목록 조회에 실패했습니다.',
  });
}

컴포넌트에서 호출:

// 컴포넌트 내부
const result = await getBills({ search: '', page: 1 });
if (result.success) {
  setData(result.data);
  setPagination(result.pagination);
}

방법 B: 프록시 직접 호출 (특수한 경우)

대시보드 훅, 파일 다운로드 등 Server Action이 부적합한 경우에만 사용.

// hooks/useCEODashboard.ts
const response = await fetch('/api/proxy/daily-report/summary');
const result = await response.json();

3. 핵심 유틸리티

3.1 buildApiUrl — URL 빌더 (필수)

import { buildApiUrl } from '@/lib/api/query-params';

// 기본 사용
buildApiUrl('/api/v1/items')
// → "https://api.xxx.com/api/v1/items"

// 쿼리 파라미터 (undefined는 자동 제외)
buildApiUrl('/api/v1/items', {
  search: '볼트',
  status: undefined,     // ← 자동 제외됨
  page: 1,               // ← 숫자 → 문자 자동 변환
})
// → "https://api.xxx.com/api/v1/items?search=볼트&page=1"

// 동적 경로 + 파라미터
buildApiUrl(`/api/v1/items/${id}`, { with_details: true })

금지 패턴:

// ❌ 직접 URLSearchParams 사용 금지
const params = new URLSearchParams();
params.set('search', value);
url: `${API_URL}/api/v1/items?${params.toString()}`

3.2 executeServerAction — 단건 조회/CUD

import { executeServerAction } from '@/lib/api';

// 단건 조회
return executeServerAction({
  url: buildApiUrl(`/api/v1/items/${id}`),
  transform: transformItemApiToFrontend,
  errorMessage: '품목 조회에 실패했습니다.',
});

// 생성 (POST)
return executeServerAction({
  url: buildApiUrl('/api/v1/items'),
  method: 'POST',
  body: JSON.stringify(payload),
  errorMessage: '등록에 실패했습니다.',
});

// 삭제 (DELETE)
return executeServerAction({
  url: buildApiUrl(`/api/v1/items/${id}`),
  method: 'DELETE',
  errorMessage: '삭제에 실패했습니다.',
});

반환 구조:

{
  success: boolean;
  data?: T;
  error?: string;
  fieldErrors?: Record<string, string[]>;  // Laravel validation 에러
}

3.3 executePaginatedAction — 페이지네이션 목록

import { executePaginatedAction } from '@/lib/api';

return executePaginatedAction({
  url: buildApiUrl('/api/v1/items', {
    search: params.search,
    page: params.page,
  }),
  transform: transformItemApiToFrontend,
  errorMessage: '목록 조회에 실패했습니다.',
});

반환 구조:

{
  success: boolean;
  data: T[];
  pagination: {
    currentPage: number;
    lastPage: number;
    perPage: number;
    total: number;
  };
  error?: string;
}

4. Server Action 규칙

파일 위치

각 도메인 컴포넌트 폴더 내 actions.ts:

src/components/accounting/Bills/actions.ts
src/components/hr/EmployeeList/actions.ts

필수 규칙

'use server';  // 첫 줄 필수

// ✅ 타입은 인라인 정의 (export interface/type 허용)
export interface BillSearchParams { ... }

// ❌ 타입 re-export 금지 (Next.js Turbopack 제한)
// export type { BillType } from './types';  ← 컴파일 에러
// → 컴포넌트에서 원본 파일에서 직접 import할 것

actions.ts 패턴 요약

작업 유틸리티 HTTP
목록 조회 (페이지네이션) executePaginatedAction GET
단건 조회 executeServerAction GET
등록 executeServerAction POST
수정 executeServerAction PUT/PATCH
삭제 executeServerAction DELETE

5. 인증 토큰 흐름

[로그인]
  ↓ POST /api/proxy/auth/login
  ↓ 백엔드 → access_token + refresh_token 반환
  ↓ 프록시에서 HttpOnly 쿠키로 설정
    - access_token (HttpOnly, Max-Age=2h)
    - refresh_token (HttpOnly, Max-Age=7d)
    - is_authenticated (non-HttpOnly, 프론트 상태 확인용)

[API 호출]
  ↓ 프록시가 쿠키에서 토큰 읽어 헤더 주입

[401 발생 시]
  ↓ authenticatedFetch가 자동 감지
  ↓ refresh_token으로 새 access_token 발급
  ↓ 재시도 (1회)
  ↓ 실패 시 → 쿠키 삭제 → 로그인 페이지 이동

6. 백엔드 개발자 참고

API 응답 규격

프론트엔드는 Laravel 표준 응답 구조를 기대합니다:

// 단건
{
  "success": true,
  "data": { ... }
}

// 페이지네이션 목록
{
  "success": true,
  "data": [ ... ],
  "current_page": 1,
  "last_page": 5,
  "per_page": 20,
  "total": 93
}

// 에러
{
  "success": false,
  "message": "에러 메시지"
}

// Validation 에러
{
  "message": "The given data was invalid.",
  "errors": {
    "name": ["이름은 필수입니다."],
    "amount": ["금액은 0보다 커야 합니다."]
  }
}

API 엔드포인트 기본 규칙

  • 기본 경로: /api/v1/{resource}
  • RESTful: GET(조회), POST(생성), PUT/PATCH(수정), DELETE(삭제)
  • 페이지네이션: ?page=1&per_page=20
  • 검색: ?search=키워드
  • 개별 기능 API 스펙은 sam-docs/frontend/api-specs/ 참조