Files
sam-docs/dev/guides/api-request-lifecycle.md
김보곤 943892e004 docs: [guides] API 요청 생명주기 학습 가이드 추가
- Client API(거래처)를 예제로 8단계 전체 흐름 해설
- 미들웨어→라우트→FormRequest→Controller→Service→Model→Response
- 코드 기반 상세 해설 + CRUD 전체 비교표
2026-03-16 17:19:47 +09:00

16 KiB

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의 핵심 동작:

// 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 메서드에 매핑

// 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 타입힌트를 발견하면 자동으로 검증을 실행한다.

// 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에 도달하지 않음):

{
    "success": false,
    "message": "입력값 검증에 실패했습니다.",
    "errors": {
        "name": ["name 필드는 필수입니다."],
        "email": ["email 필드는 올바른 이메일 주소여야 합니다."]
    }
}

2.4 Controller — 연결만 담당하는 3줄 코드

// 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가 상속하는 추상 클래스이다.

// 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 — 비즈니스 로직 집중

// 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 표현

// 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의 효과:

// 이 코드를 작성하면:
Client::where('is_active', true)->get();

// 실제 실행되는 SQL:
SELECT * FROM clients WHERE tenant_id = 1 AND is_active = 1;
//                         ↑ 자동 추가됨 (Global Scope)

2.8 ApiResponse::handle() — 통일된 응답 포맷

// 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 API 개발 규칙 (Service-First, i18n 등)
api-code-quality-audit.md 정석 패턴 R1~R6, 보안 감사
api-structure.md API 디렉토리 구조 현황
swagger-guide.md Swagger 문서 작성법

최종 업데이트: 2026-03-15