docs: [frontend] 프론트엔드 아키텍처/가이드 문서 v1 작성
- _index.md: 문서 목록 및 버전 관리 - 01~09: 아키텍처, API패턴, 컴포넌트, 폼, 스타일, 인증, 대시보드, 컨벤션 - 10: 문서 API 연동 스펙 (api-specs에서 이관) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
279
frontend/v1/02-api-pattern.md
Normal file
279
frontend/v1/02-api-pattern.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# 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` 사용.
|
||||
|
||||
```typescript
|
||||
// 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: '어음 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
컴포넌트에서 호출:
|
||||
```typescript
|
||||
// 컴포넌트 내부
|
||||
const result = await getBills({ search: '', page: 1 });
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
}
|
||||
```
|
||||
|
||||
### 방법 B: 프록시 직접 호출 (특수한 경우)
|
||||
|
||||
대시보드 훅, 파일 다운로드 등 Server Action이 부적합한 경우에만 사용.
|
||||
|
||||
```typescript
|
||||
// hooks/useCEODashboard.ts
|
||||
const response = await fetch('/api/proxy/daily-report/summary');
|
||||
const result = await response.json();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 핵심 유틸리티
|
||||
|
||||
### 3.1 buildApiUrl — URL 빌더 (필수)
|
||||
|
||||
```typescript
|
||||
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 })
|
||||
```
|
||||
|
||||
**금지 패턴:**
|
||||
```typescript
|
||||
// ❌ 직접 URLSearchParams 사용 금지
|
||||
const params = new URLSearchParams();
|
||||
params.set('search', value);
|
||||
url: `${API_URL}/api/v1/items?${params.toString()}`
|
||||
```
|
||||
|
||||
### 3.2 executeServerAction — 단건 조회/CUD
|
||||
|
||||
```typescript
|
||||
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: '삭제에 실패했습니다.',
|
||||
});
|
||||
```
|
||||
|
||||
**반환 구조:**
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[]>; // Laravel validation 에러
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 executePaginatedAction — 페이지네이션 목록
|
||||
|
||||
```typescript
|
||||
import { executePaginatedAction } from '@/lib/api';
|
||||
|
||||
return executePaginatedAction({
|
||||
url: buildApiUrl('/api/v1/items', {
|
||||
search: params.search,
|
||||
page: params.page,
|
||||
}),
|
||||
transform: transformItemApiToFrontend,
|
||||
errorMessage: '목록 조회에 실패했습니다.',
|
||||
});
|
||||
```
|
||||
|
||||
**반환 구조:**
|
||||
```typescript
|
||||
{
|
||||
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
|
||||
```
|
||||
|
||||
### 필수 규칙
|
||||
|
||||
```typescript
|
||||
'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 표준 응답 구조를 기대합니다:
|
||||
|
||||
```json
|
||||
// 단건
|
||||
{
|
||||
"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/` 참조
|
||||
Reference in New Issue
Block a user