diff --git a/INDEX.md b/INDEX.md index 009a2ef..ffee6ff 100644 --- a/INDEX.md +++ b/INDEX.md @@ -26,6 +26,7 @@ | 바로빌 회계 API | `frontend/api-specs/barobill-api.md` | 카드/은행/홈택스 REST API (42개 엔드포인트) | | 결재관리 | `dev/dev_plans/approval-system-unification-plan.md` | MNG→API 결재 통합 계획 | | API 품질 | `system/api-code-quality-audit.md` | 정석 패턴 R1~R6, 안티패턴, 보안, 체크리스트 | +| API 학습 | `dev/guides/api-request-lifecycle.md` | Client API로 배우는 요청 생명주기 8단계 | | API 개선 | `dev/dev_plans/api-route-improvement-plan.md` | API 라우트 구조 개선 계획 (1,099개 분석) | | 운영 배포 | `dev/dev_plans/production-deployment-plan.md` | 배포 계획 | | 서버 운영 | `dev/deploys/ops-manual/README.md` | 서버 운영 매뉴얼 | @@ -191,6 +192,7 @@ DB 도메인별: | 문서 | 설명 | |------|------| +| [api-request-lifecycle.md](dev/guides/api-request-lifecycle.md) | API 요청 생명주기 — Client API로 배우는 8단계 전체 흐름 | | [swagger-guide.md](dev/guides/swagger-guide.md) | Swagger 작성법 | | [file-storage-guide.md](dev/guides/file-storage-guide.md) | 파일 업로드/다운로드 | | [item-management-migration.md](dev/guides/item-management-migration.md) | Item 전환 가이드 | diff --git a/dev/guides/api-request-lifecycle.md b/dev/guides/api-request-lifecycle.md new file mode 100644 index 0000000..4b4f1b3 --- /dev/null +++ b/dev/guides/api-request-lifecycle.md @@ -0,0 +1,401 @@ +# API 요청 생명주기 — Client API로 배우는 전체 흐름 + +> **작성일**: 2026-03-15 +> **목적**: HTTP 요청이 JSON 응답이 되기까지 8단계를 코드 기반으로 해설 +> **대상 독자**: API 구조를 처음 학습하는 개발자 +> **예제 API**: `POST /api/v1/clients` (거래처 등록) + +--- + +## 1. 전체 흐름도 + +``` +클라이언트 (React/Postman) + │ + │ POST /api/v1/clients + │ Headers: X-API-KEY: xxx, Authorization: Bearer yyy + │ Body: { "name": "삼성전자", "client_type": "SALES" } + │ + ▼ +┌──────────────────────────────────────────────────────┐ +│ ① 미들웨어 체인 (4개 순차 실행) │ +│ CORS → RateLimit → ApiKey검증 → API버전선택 │ +│ → tenant_id, api_user를 app() 컨테이너에 바인딩 │ +├──────────────────────────────────────────────────────┤ +│ ② 라우팅 │ +│ routes/api/v1/sales.php → ClientController@store │ +├──────────────────────────────────────────────────────┤ +│ ③ FormRequest 검증 │ +│ ClientStoreRequest → 30개 필드 규칙 자동 검증 │ +│ 실패 시 → 422 ValidationException (Controller 진입X)│ +├──────────────────────────────────────────────────────┤ +│ ④ Controller (3줄) │ +│ DI주입 → validated() 추출 → Service 호출 │ +│ ApiResponse::handle()로 감싸서 예외 자동 포착 │ +├──────────────────────────────────────────────────────┤ +│ ⑤ Base Service │ +│ tenantId() → 없으면 400 예외 │ +│ apiUserId() → 없으면 401 예외 │ +├──────────────────────────────────────────────────────┤ +│ ⑥ ClientService (비즈니스 로직) │ +│ client_code 자동 생성 → Client::create() │ +├──────────────────────────────────────────────────────┤ +│ ⑦ Model + Trait │ +│ $fillable 체크 → $casts 변환 → $hidden 제외 │ +│ BelongsToTenant → 이후 쿼리에 tenant_id 자동 필터 │ +│ Auditable → audit_logs 테이블에 생성 이력 기록 │ +├──────────────────────────────────────────────────────┤ +│ ⑧ ApiResponse │ +│ 성공: { success: true, message: "...", data: {...} }│ +│ 실패: 예외 타입별 자동 분기 (422/400/404/500) │ +└──────────────────────────────────────────────────────┘ + │ + ▼ + 200 OK { "success": true, "data": { "id": 42, ... } } +``` + +--- + +## 2. 단계별 상세 해설 + +### 2.1 미들웨어 체인 — 모든 요청의 관문 + +`bootstrap/app.php`에서 전역 등록된 미들웨어가 순서대로 실행된다. + +| 순서 | 미들웨어 | 역할 | 실패 시 | +|:----:|---------|------|--------| +| 1 | `CorsMiddleware` | `Access-Control-Allow-Origin` 헤더 추가 | 브라우저가 요청 차단 | +| 2 | `ApiRateLimiter` | 분당 요청 수 제한 | 429 Too Many Requests | +| 3 | `ApiKeyMiddleware` | `X-API-KEY` 검증 + Bearer 토큰 검증 | 401 Unauthorized | +| 4 | `ApiVersionMiddleware` | `Accept-Version` 헤더로 v1/v2 선택 | 기본값 v1 | + +**ApiKeyMiddleware의 핵심 동작:** + +```php +// 1. X-API-KEY로 api_keys 테이블 조회 +$apiKey = ApiKey::where('key', $request->header('X-API-KEY'))->first(); + +// 2. Bearer 토큰으로 사용자 인증 (Sanctum) +$token = PersonalAccessToken::findToken($bearerToken); + +// 3. tenant_id와 api_user를 앱 컨테이너에 바인딩 +app()->instance('tenant_id', $tenantId); // ← Service에서 읽음 +app()->instance('api_user', $userId); // ← Service에서 읽음 +$request->attributes->set('tenant_id', $tenantId); // ← TenantScope에서 읽음 +``` + +> **핵심**: 이후 모든 Service와 Model이 `app('tenant_id')`로 현재 테넌트를 알 수 있다. + +--- + +### 2.2 라우팅 — URL을 Controller 메서드에 매핑 + +```php +// routes/api/v1/sales.php:27~37 +Route::prefix('clients')->group(function () { + Route::get('', [ClientController::class, 'index']); // 목록 + Route::post('', [ClientController::class, 'store']); // 생성 ← 이번 예제 + Route::get('/{id}', [ClientController::class, 'show']) // 상세 + ->whereNumber('id'); // ← id가 숫자인지 라우트 레벨에서 강제 + Route::put('/{id}', [ClientController::class, 'update'])->whereNumber('id'); + Route::delete('/{id}', [ClientController::class, 'destroy'])->whereNumber('id'); + Route::patch('/{id}/toggle', [ClientController::class, 'toggle'])->whereNumber('id'); +}); +``` + +**RESTful 규칙:** + +| HTTP 메서드 | URL | Controller | 의미 | +|:-----------:|-----|-----------|------| +| GET | `/clients` | `index()` | 목록 조회 | +| POST | `/clients` | `store()` | 신규 생성 | +| GET | `/clients/42` | `show(42)` | 단건 조회 | +| PUT | `/clients/42` | `update(42)` | 전체 수정 | +| DELETE | `/clients/42` | `destroy(42)` | 삭제 | +| PATCH | `/clients/42/toggle` | `toggle(42)` | 부분 수정 (활성/비활성) | + +> **`whereNumber('id')`**: `/clients/abc` 같은 잘못된 요청을 라우트 레벨에서 404로 차단한다. Controller까지 도달하지 않는다. + +--- + +### 2.3 FormRequest — Controller 진입 전 자동 검증 + +Laravel이 Controller 파라미터에서 `ClientStoreRequest` 타입힌트를 발견하면 **자동으로** 검증을 실행한다. + +```php +// ClientStoreRequest.php — 3단계로 동작 +class ClientStoreRequest extends FormRequest +{ + // [1단계] 권한 확인 — "이 사용자가 이 요청을 할 수 있는가?" + public function authorize(): bool { return true; } + + // [2단계] 전처리 — 검증 전에 데이터를 정리/변환 + protected function prepareForValidation(): void + { + // "SALES"라는 문자열이 common_codes의 name인지 code인지 자동 판별 + $this->convertCommonCodeNameToCode('client_type'); + } + + // [3단계] 규칙 정의 — 각 필드의 검증 조건 + public function rules(): array + { + return [ + 'name' => 'required|string|max:100', // 필수, 문자열, 100자 이하 + 'email' => 'nullable|email|max:100', // 선택, 이메일 형식 + 'client_type' => ['nullable', Rule::exists(...)], // DB에 존재하는 코드만 + 'tax_end_date' => 'nullable|date|after_or_equal:tax_start_date', + // ↑ 종료일이 시작일 이후인지 자동 검증 + ]; + } +} +``` + +**검증 실패 시 자동 응답 (Controller에 도달하지 않음):** + +```json +{ + "success": false, + "message": "입력값 검증에 실패했습니다.", + "errors": { + "name": ["name 필드는 필수입니다."], + "email": ["email 필드는 올바른 이메일 주소여야 합니다."] + } +} +``` + +--- + +### 2.4 Controller — 연결만 담당하는 3줄 코드 + +```php +// ClientController.php +class ClientController extends Controller +{ + // DI(의존성 주입): Laravel이 ClientService 인스턴스를 자동 생성하여 주입 + public function __construct(private ClientService $service) {} + + public function store(ClientStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + // ↑ 검증 통과한 데이터만 추출 + }, __('message.client.created')); + // ↑ i18n 메시지 키 (lang/ko/message.php에 정의) + } +} +``` + +**Controller가 하는 일 (3가지만):** +1. `ClientStoreRequest`로 검증 (자동) +2. `$request->validated()`로 안전한 데이터만 추출 +3. `$this->service->store()`에 전달 + +**Controller가 하지 않는 일:** +- DB 쿼리 직접 실행 +- 비즈니스 로직 (코드 생성, 중복 검사 등) +- try-catch 에러 처리 (ApiResponse::handle()이 대신함) +- 응답 포맷 조립 + +--- + +### 2.5 Base Service — 멀티테넌트 컨텍스트 강제 + +모든 Service가 상속하는 추상 클래스이다. + +```php +// Service.php +abstract class Service +{ + // tenant_id가 없으면 400 Bad Request 예외 + protected function tenantId(): int + { + $id = app('tenant_id'); // ← ApiKeyMiddleware가 설정한 값 + if (! $id) { + throw new BadRequestHttpException(__('error.tenant_id')); + } + return (int) $id; + } + + // api_user가 없으면 401 Unauthorized 예외 + protected function apiUserId(): int + { + $uid = app('api_user'); // ← ApiKeyMiddleware가 설정한 값 + if (! $uid) { + throw new AuthenticationException(__('auth.unauthenticated')); + } + return (int) $uid; + } +} +``` + +> **역할**: "tenant_id 없이는 비즈니스 로직을 실행할 수 없다"는 규칙을 강제한다. + +--- + +### 2.6 ClientService — 비즈니스 로직 집중 + +```php +// ClientService.php +class ClientService extends Service +{ + public function store(array $data) + { + $tenantId = $this->tenantId(); // ← 없으면 여기서 400 예외 + + // 비즈니스 규칙 1: client_code 자동 생성 + $data['client_code'] = $this->generateClientCode($tenantId); + + // 비즈니스 규칙 2: 테넌트 귀속 + $data['tenant_id'] = $tenantId; + + // 비즈니스 규칙 3: 기본값 설정 + $data['is_active'] = $data['is_active'] ?? true; + + // DB 저장 (Model을 통해서만) + return Client::create($data); + } +} +``` + +**Service가 담당하는 것:** +- `tenantId()` 호출로 멀티테넌트 컨텍스트 확인 +- 비즈니스 규칙 적용 (코드 생성, 기본값, 연관 데이터 처리) +- 예외 발생 (`NotFoundHttpException`, `BadRequestHttpException`) +- 트랜잭션 관리 (복잡한 작업 시 `DB::transaction()`) + +--- + +### 2.7 Model — DB 테이블의 PHP 표현 + +```php +// Client.php +class Client extends Model +{ + use Auditable, // → 생성/수정/삭제 시 audit_logs에 기록 + BelongsToTenant, // → 모든 쿼리에 WHERE tenant_id = ? 자동 추가 + ModelTrait; // → scopeActive(), 날짜 포맷 + + // Mass Assignment 보호: 이 필드만 create()/update()로 설정 가능 + protected $fillable = ['tenant_id', 'name', 'client_code', ...]; + + // PHP↔DB 타입 자동 변환 + protected $casts = [ + 'is_active' => 'boolean', // DB: 0/1 → PHP: true/false + 'tax_amount' => 'decimal:2', // DB: 1000.5 → PHP: "1000.50" + 'tax_start_date' => 'date', // DB: "2026-03-15" → Carbon 객체 + ]; + + // JSON 직렬화 시 제외 (API 응답에 절대 노출되지 않음) + protected $hidden = ['account_password']; + + // Eloquent Relationship 정의 + public function orders() { return $this->hasMany(Order::class, 'client_id'); } + public function badDebts() { return $this->hasMany(BadDebt::class); } + + // 쿼리 스코프 (재사용 가능한 WHERE 조건) + public function scopeActive($query) { return $query->where('is_active', true); } +} +``` + +**`Client::create($data)` 실행 시 내부 동작:** + +``` +① $fillable 체크 → 허용되지 않은 필드 무시 (Mass Assignment 방지) +② INSERT INTO clients (tenant_id, name, ...) VALUES (1, '삼성전자', ...) +③ $casts 적용 → is_active: 0 → true 변환 +④ Auditable 트리거 → audit_logs에 "created" 이벤트 기록 +⑤ 생성된 Model 인스턴스 반환 (id 포함) +``` + +**BelongsToTenant의 효과:** + +```php +// 이 코드를 작성하면: +Client::where('is_active', true)->get(); + +// 실제 실행되는 SQL: +SELECT * FROM clients WHERE tenant_id = 1 AND is_active = 1; +// ↑ 자동 추가됨 (Global Scope) +``` + +--- + +### 2.8 ApiResponse::handle() — 통일된 응답 포맷 + +```php +// ApiResponse.php:203~300 +public static function handle(callable $callback, string $responseTitle): JsonResponse +{ + try { + $result = $callback(); // ← Service 호출 결과 + + // 날짜 포맷 변환: "2026-03-15T00:00:00.000000Z" → "2026-03-15" + $formattedData = self::formatDates($result); + + // 성공 응답 조립 + return response()->json([ + 'success' => true, + 'message' => $responseTitle, + 'data' => $formattedData, + ], 200); + + } catch (\Throwable $e) { + // 예외 타입별 자동 분기 + } +} +``` + +**예외 타입별 HTTP 응답:** + +| 예외 클래스 | HTTP | 발생 상황 | +|-----------|:----:|---------| +| `ValidationException` | 422 | FormRequest 검증 실패 | +| `NotFoundHttpException` | 404 | Service에서 `find()` 결과 없음 | +| `BadRequestHttpException` | 400 | 비즈니스 규칙 위반 (주문 있는 거래처 삭제 등) | +| `AuthenticationException` | 401 | tenant_id/api_user 없음 | +| `DuplicateCodeException` | 400 | 중복 코드 (duplicate_id 포함) | +| 기타 `Throwable` | 500 | 예상치 못한 서버 에러 | + +--- + +## 3. CRUD 전체 흐름 비교 + +| 작업 | HTTP | Controller | Service 핵심 로직 | +|------|------|-----------|------------------| +| 목록 | `GET /clients` | `index()` | 검색/필터/페이징 + 미수금 집계 | +| 생성 | `POST /clients` | `store()` | client_code 자동 생성 | +| 상세 | `GET /clients/42` | `show()` | 미수금/악성채권 정보 추가 | +| 수정 | `PUT /clients/42` | `update()` | client_code 변경 불가 + 악성채권 동기화 | +| 삭제 | `DELETE /clients/42` | `destroy()` | 주문 존재 시 삭제 거부 | +| 토글 | `PATCH /clients/42/toggle` | `toggle()` | is_active 반전 | +| 통계 | `GET /clients/stats` | `stats()` | 유형별/악성채권 집계 | +| 일괄삭제 | `DELETE /clients/bulk` | `bulkDestroy()` | 주문 있는 건 건너뛰기 | + +--- + +## 4. 파일 역할 요약 (외울 것) + +``` +routes/api/v1/sales.php "어디로 가는가" — URL → Controller 매핑 +ClientStoreRequest.php "올바른 데이터인가" — 입력 검증 (Controller 진입 전) +ClientController.php "누구에게 시킬 것인가" — 연결만 (3줄) +Service.php (Base) "자격이 있는가" — tenant_id 존재 강제 +ClientService.php "무엇을 할 것인가" — 비즈니스 로직 +Client.php (Model) "어떻게 저장하는가" — DB 매핑, 타입 변환, 관계 +ApiResponse.php "어떻게 포장하는가" — 성공/실패 JSON 통일 +``` + +--- + +## 관련 문서 + +| 문서 | 용도 | +|------|------| +| [api-rules.md](../standards/api-rules.md) | API 개발 규칙 (Service-First, i18n 등) | +| [api-code-quality-audit.md](../../system/api-code-quality-audit.md) | 정석 패턴 R1~R6, 보안 감사 | +| [api-structure.md](../../system/api-structure.md) | API 디렉토리 구조 현황 | +| [swagger-guide.md](swagger-guide.md) | Swagger 문서 작성법 | + +--- + +**최종 업데이트**: 2026-03-15