Files
Gnuboard7/docs/backend/service-repository.md
T
HeuJung 5f4a733b3b feat(core,board,ecommerce,page): 성능 계측 인프라 4축 확장 및 계측 대상 선언 지점 신설
계측 축이 "목록 SELECT" 하나였고 프로파일 12종이 커맨드 파일에 하드코딩돼
있어 확장이 자기 대상을 추가할 수 없었다. 확장이 설치·제거되는 설치본마다
"실제로 존재하는 대상"이 달라지므로, 대상을 소유자가 선언하고 코어가 수집하도록
뒤집었다.

- 선언 지점 두 곳, 스키마 하나: 코어 config/benchmark.php,
 확장 getBenchmarkProfiles (모듈/플러그인). 수집은 method_exists 기반이라
 인터페이스를 직접 구현한 서드파티 확장이 깨지지 않는다.
- 축 4종(목록/화면/쓰기/배치)을 실행기로 분리하고 컨테이너 태그로 주입 —
 축이 늘 때 커맨드를 고치지 않는다.
- 화면 축은 HTTP 커널로 내부 요청을 처리한다. 라우트만 dispatch 하면 전역
 미들웨어를 건너뛰어 화면과 다른 것을 재게 된다. 인증은 프로파일이 선언한
 권한만 가진 임시 계정이 기본이며 --as 로 기존 계정을 지목할 수 있다.
- 화면/쓰기 축은 계측 계정 생성부터 처리까지 롤백되는 트랜잭션 안에서 실행해
 운영 DB 에도 잔여 데이터를 남기지 않는다. 배치 축은 내부 커밋·락 보유 때문에
 감싸지 않고 --allow-write 로만 통제한다.
- 잘못된 선언은 조용히 버리지 않고 사유를 경고로 남긴다. 버려진 선언이 곧
 계측 사각이 되기 때문이다. 전부 건너뛴 실행은 종료 코드 1 로 끝난다.
- 리포트는 환경 정보를 함께 적어 저장소 밖(storage/app/benchmarks)에 남긴다.
 측정값이 실행 머신에 종속되므로 저장소에 축적하면 비교 불가능한 수치가 섞인다.

작업 중 발견해 함께 고친 것:

- 이관 프로파일 3종의 soft_delete 선언이 실제 스키마와 어긋나 있었다
 (users/product_inquiries/pages 에 deleted_at 없음).
- 주문 목록이 상태 미지정 시 임시 주문 상태를 NOT IN 으로 제외하는데 선언에
 빠져 있었다. filters 가 등가 비교만 지원한 것이 근본 원인이라 연산자 형태를
 받도록 넓히고, 미허용 연산자는 측정 전에 거부한다.
- 댓글 프로파일은 지배적 술어가 상관 서브쿼리라 선언형으로 재현할 수 없어
 제거했다. 맨 테이블 스캔은 어느 화면도 내지 않는 수치가 된다.

g7:bench:pagination → g7:bench 로 개명(4축을 재므로). 이전 이름은 별칭으로 남겼다.

테스트 61건 통과, 라이브 18종 전건 측정.
2026-08-02 14:44:33 +09:00

47 KiB
Raw Blame History

Service-Repository 패턴

백엔드 가이드 | 목차로 돌아가기


TL;DR (5초 요약)

1. RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지)
2. CoreServiceProvider에서 Interface-구현체 바인딩
3. Service에서 훅 실행: before_create → applyFilters → create → after_create
4. 검증 로직은 FormRequest에서 (Service에 검증 금지)
5. 다중 검색은 HasMultipleSearchFilters Trait 사용
6. Service 에서 Model 직접 인스턴스화 금지 — Repository 의 build/factory 메서드 위임 (가상 모델 합성 포함)
7. 목록 조회는 컬럼 목록 명시 + 깊은 OFFSET 이 가능한 목록은 PaginatesWithDeferredJoin Trait 사용

목차


개요

Service-Repository 패턴은 비즈니스 로직과 데이터 액세스 로직을 분리하는 아키텍처 패턴입니다.

Controller → Request → Service → RepositoryInterface → Repository → Model
계층 역할
Service 비즈니스 로직, 훅 실행, 트랜잭션 관리
RepositoryInterface Repository 추상화 계약 정의
Repository 데이터 액세스 구현, 쿼리 로직 캡슐화

Repository 인터페이스

핵심 원칙

필수: Repository 인터페이스를 통한 DI (구체 클래스 직접 타입힌트 금지)
필수: Repository 인터페이스를 통한 DI
✅ 필수: CoreServiceProvider에서 인터페이스-구현체 바인딩

인터페이스 위치

app/Contracts/Repositories/
├── LayoutRepositoryInterface.php
├── ModuleRepositoryInterface.php
├── MenuRepositoryInterface.php
├── RoleRepositoryInterface.php
├── TemplateRepositoryInterface.php
├── PluginRepositoryInterface.php
├── UserRepositoryInterface.php
├── PermissionRepositoryInterface.php
├── LayoutVersionRepositoryInterface.php
└── SystemConfigRepositoryInterface.php

인터페이스 정의 패턴

<?php

namespace App\Contracts\Repositories;

use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;

interface UserRepositoryInterface
{
    /**
     * ID로 사용자 조회
     *
     * @param int $id
     * @return User|null
     */
    public function findById(int $id): ?User;

    /**
     * 사용자 목록 페이지네이션 조회
     *
     * @param array $filters 검색 조건
     * @return LengthAwarePaginator
     */
    public function getPaginated(array $filters = []): LengthAwarePaginator;

    /**
     * 사용자 생성
     *
     * @param array $data
     * @return User
     */
    public function create(array $data): User;
}

Repository 구현체 패턴

<?php

namespace App\Repositories;

use App\Contracts\Repositories\UserRepositoryInterface;
use App\Models\User;

class UserRepository implements UserRepositoryInterface
{
    /**
     * ID로 사용자 조회
     */
    public function findById(int $id): ?User
    {
        return User::find($id);
    }

    // ... 인터페이스 메서드 구현
}

Service Provider 바인딩

app/Providers/CoreServiceProvider.php에서 인터페이스와 구현체를 바인딩합니다:

<?php

namespace App\Providers;

use App\Contracts\Repositories\UserRepositoryInterface;
use App\Repositories\UserRepository;
use Illuminate\Support\ServiceProvider;

class CoreServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->registerRepositoryBindings();
    }

    private function registerRepositoryBindings(): void
    {
        // bind() 사용 - Repository는 상태가 없으므로 매번 새 인스턴스
        $this->app->bind(UserRepositoryInterface::class, UserRepository::class);
        $this->app->bind(ModuleRepositoryInterface::class, ModuleRepository::class);
        // ... 나머지 Repository 바인딩
    }
}

의존성 주입 패턴

Service에서 사용 (권장)

<?php

namespace App\Services;

use App\Contracts\Repositories\UserRepositoryInterface;

class UserService
{
    public function __construct(
        private UserRepositoryInterface $userRepository
    ) {}

    public function getUser(int $id): ?User
    {
        return $this->userRepository->findById($id);
    }
}

Controller에서 사용

<?php

namespace App\Http\Controllers\Api\Admin;

use App\Contracts\Repositories\UserRepositoryInterface;

class UserController extends AdminBaseController
{
    public function __construct(
        private UserRepositoryInterface $userRepository
    ) {
        parent::__construct();
    }
}

Console Command에서 사용

<?php

namespace App\Console\Commands;

use App\Contracts\Repositories\ModuleRepositoryInterface;
use Illuminate\Console\Command;

class ListModuleCommand extends Command
{
    public function __construct(
        private ModuleRepositoryInterface $moduleRepository
    ) {
        parent::__construct();
    }
}

인터페이스 장점

장점 설명
테스트 용이성 Mock 객체로 쉽게 대체 가능
유연한 구현체 교체 바인딩만 변경하면 다른 구현체 사용 가능
명확한 계약 인터페이스가 Repository의 공개 API 명세 역할
의존성 역전 고수준 모듈이 저수준 모듈에 의존하지 않음

Service 클래스

역할

  • 비즈니스 로직 구현
  • 훅 실행 (before/after)
  • 트랜잭션 관리
  • 여러 리포지토리 조율

Model 인스턴스화는 Repository 책임

Service 에서 Model 을 직접 인스턴스화(new EloquentModel, $model->forceFill, $model->setAttribute) 하지 않는다. 모든 Model 인스턴스 생성은 Repository 의 build/factory 메서드에 위임한다. 영속성 있는 행(create/update) 뿐 아니라 가상 모델(미설치 가상 행, DTO-style 합성) 도 동일.

❌ 잘못된 패턴 (Service) ✅ 올바른 패턴 (Service)
$pack = new LanguagePack(); $pack->forceFill([...]); $pack->exists = false; $pack = $this->repository->buildVirtualFromManifest($manifest, $id);
Service 안에서 직접 setAttribute('virtual_field', ...) Repository 의 build 메서드가 가상 속성도 함께 채움

이유:

  • Service 가 Model 생성 세부(컬럼, 캐스트, 가상 속성 등)를 알게 되면 영속 계층 변경(컬럼 추가/제거, 모델 클래스 교체)이 Service 까지 파급된다.
  • Repository 가 단일 진입점이 되어야 Resource/Test 가 의존하는 가상 행 합성 패턴이 일관 유지된다.
  • 동일 합성을 여러 Service 메서드에서 반복할 때 DRY 위반을 방지한다.

Repository Interface 에 합성 메서드 시그니처를 선언:

// app/Contracts/Repositories/LanguagePackRepositoryInterface.php
public function buildVirtualFromManifest(array $manifest, string $bundledIdentifier): LanguagePack;

Repository 구현체는 Model 인스턴스화 + 가상 속성 채움 + exists=false 설정 모두 책임:

// app/Repositories/LanguagePackRepository.php
public function buildVirtualFromManifest(array $manifest, string $bundledIdentifier): LanguagePack
{
    $pack = new LanguagePack;
    $pack->identifier = (string) $manifest['identifier'];
    // ... 모든 필드 채움 ...
    $pack->setAttribute('bundled_identifier', $bundledIdentifier);
    $pack->exists = false;
    return $pack;
}

Service 는 호출만:

// app/Services/LanguagePackService.php
public function findOrBundled(int|string $id): ?LanguagePack
{
    if (is_numeric($id)) return $this->find((int) $id);
    $manifest = json_decode(File::get(...), true);
    return $this->repository->buildVirtualFromManifest($manifest, (string) $id);
}

패턴

<?php

namespace Modules\Sirsoft\Ecommerce\Services;

use App\Hooks\HookManager;
use Modules\Sirsoft\Ecommerce\Contracts\Repositories\ProductRepositoryInterface;

class ProductService
{
    public function __construct(
        private ProductRepositoryInterface $productRepository
    ) {}

    /**
     * 상품 생성
     *
     * @param array $data 상품 데이터
     * @return Product
     */
    public function createProduct(array $data): Product
    {
        // Before 훅 - 데이터 검증, 전처리
        HookManager::doAction('sirsoft-ecommerce.product.before_create', $data);

        // 필터 훅 - 데이터 변형
        $data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_create_data', $data);

        // 비즈니스 로직 실행
        $product = $this->productRepository->create($data);

        // After 훅 - 후처리, 알림, 캐시 등
        HookManager::doAction('sirsoft-ecommerce.product.after_create', $product, $data);

        return $product;
    }

    /**
     * 상품 수정
     *
     * @param int $id 상품 ID
     * @param array $data 수정할 데이터
     * @return Product
     */
    public function updateProduct(int $id, array $data): Product
    {
        HookManager::doAction('sirsoft-ecommerce.product.before_update', $id, $data);

        $data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_update_data', $data, $id);

        $product = $this->productRepository->update($id, $data);

        HookManager::doAction('sirsoft-ecommerce.product.after_update', $product, $data);

        return $product;
    }

    /**
     * 상품 목록 조회
     *
     * @param array $filters 검색 필터
     * @return Collection
     */
    public function getProducts(array $filters = []): Collection
    {
        // Before 훅 - 검색 조건 전처리
        HookManager::doAction('sirsoft-ecommerce.product.before_list', $filters);

        // 필터 훅 - 검색 조건 변형
        $filters = HookManager::applyFilters('sirsoft-ecommerce.product.filter_list_query', $filters);

        $products = $this->productRepository->getAll($filters);

        // 필터 훅 - 결과 데이터 변형
        $products = HookManager::applyFilters('sirsoft-ecommerce.product.filter_list_result', $products, $filters);

        // After 훅 - 조회 후처리 (로깅, 캐싱 등)
        HookManager::doAction('sirsoft-ecommerce.product.after_list', $products, $filters);

        return $products;
    }

    /**
     * 상품 상세 조회
     *
     * @param int $id 상품 ID
     * @return Product|null
     */
    public function getProduct(int $id): ?Product
    {
        // Before 훅 - 조회 전처리
        HookManager::doAction('sirsoft-ecommerce.product.before_show', $id);

        $product = $this->productRepository->findById($id);

        if ($product) {
            // 필터 훅 - 조회 결과 변형 (조회수 증가, 관련 데이터 추가 등)
            $product = HookManager::applyFilters('sirsoft-ecommerce.product.filter_show_result', $product);

            // After 훅 - 조회 후처리
            HookManager::doAction('sirsoft-ecommerce.product.after_show', $product);
        }

        return $product;
    }
}

훅 실행 순서

1. Before Action Hook  →  사전 처리, 검증
2. Filter Hook         →  데이터 변형
3. Repository 호출     →  실제 데이터 작업
4. After Action Hook   →  후처리, 알림, 캐시

훅 네이밍 규칙

[vendor-module].[entity].[action]_[timing]

예시:
sirsoft-ecommerce.product.before_create
sirsoft-ecommerce.product.after_update
sirsoft-ecommerce.product.filter_create_data

서비스 내부 조건부 권한 체크

미들웨어가 아닌 서비스 내부에서 추가적인 권한 체크가 필요한 경우 PermissionHelper를 사용합니다. 대표적인 예: 역할(role) 변경처럼 데이터의 일부 필드만 별도 권한이 필요한 경우.

use App\Helpers\PermissionHelper;
use Illuminate\Support\Facades\Auth;

public function updateUser(User $user, array $data): User
{
    $roleIds = $data['role_ids'] ?? null;
    unset($data['role_ids'], $data['roles']);

    // 훅 실행 (생략)...

    $user = $this->userRepository->update($user->id, $data);

    // 역할 변경은 별도 권한으로 보호
    if ($roleIds !== null) {
        $authUser = Auth::user();

        // 자기 자신의 역할은 항상 변경 불가 (보안)
        if ($authUser && $authUser->id === $user->id) {
            $roleIds = null;
        }

        // core.permissions.update 권한 없으면 역할 변경 무시
        if ($roleIds !== null && ! PermissionHelper::check('core.permissions.update', $authUser)) {
            $roleIds = null;
        }

        if ($roleIds !== null) {
            $user->roles()->sync($roleIds);
        }
    }

    return $user;
}
필수: 역할/권한 변경은 미들웨어 권한과 별개로 서비스에서 명시적 체크 필수
필수: 자기 자신의 역할 변경은 항상 불가 (관리자 실수 방지)
패턴: 민감 필드 분리 → 별도 권한 체크 → 권한 없으면 해당 필드 무시 (403이 아닌 무시)

트랜잭션 및 관계 삭제 패턴

CASCADE 방지 — 명시적 관계 삭제

필수: Service에서 명시적 삭제 (DB CASCADE 의존 금지)
필수: Service에서 모든 관계를 명시적으로 삭제 (훅/파일/로깅 보장)

엔티티 삭제 시 관련된 모든 관계를 Service에서 명시적으로 제거합니다:

public function deleteUser(User $user): bool
{
    // Before 훅
    HookManager::doAction('core.user.before_delete', $user);

    // 원본 데이터 보관 (after 훅에서 사용)
    $userData = $user->toArray();

    // 관계 명시적 해제 (CASCADE 의존 금지)
    $user->roles()->detach();       // 다대다 관계 해제
    $user->consents()->delete();    // 일대다 관계 삭제
    $user->tokens()->delete();      // 인증 토큰 삭제

    // Attachment 삭제 (비즈니스 로직 통한 삭제)
    if ($user->avatarAttachment) {
        $this->attachmentService->delete($user->avatarAttachment->id);
    }

    $result = $this->userRepository->delete($user);

    // After 훅 (원본 데이터 전달)
    HookManager::doAction('core.user.after_delete', $userData);

    return $result;
}

일괄 업데이트 트랜잭션 패턴

public function bulkUpdateStatus(array $ids, string $status): int
{
    $statusEnum = UserStatus::from($status);

    $updatedCount = DB::transaction(function () use ($ids, $statusEnum) {
        $count = User::whereIn('id', $ids)->update([
            'status' => $statusEnum->value,
        ]);

        // 트랜잭션 내에서 관계형 데이터 명시적 삭제
        if ($statusEnum !== UserStatus::Active) {
            PersonalAccessToken::where('tokenable_type', User::class)
                ->whereIn('tokenable_id', $ids)
                ->delete();
        }

        return $count;
    });

    // 트랜잭션 외부에서 훅 실행 (실패 시 롤백 방지)
    HookManager::doAction('core.user.after_bulk_update', $ids, $status, $updatedCount);

    return $updatedCount;
}

핵심 규칙

원칙 설명
CASCADE 금지 DB 외래키 CASCADE 대신 Service에서 명시적 삭제
원본 데이터 보관 삭제 전 toArray()로 캡처 → after 훅에서 사용
관계 유형별 처리 detach() (다대다), delete() (일대다), Service 호출 (복합)
트랜잭션 내 훅 금지 훅은 트랜잭션 외부에서 실행 (롤백 시 훅 부작용 방지)
AttachmentService 사용 파일 삭제는 직접 DB 삭제 대신 AttachmentService 통해 처리

상태 변경 자동 처리

상태(status) 변경 시 관련 타임스탬프와 토큰을 자동으로 처리하는 패턴입니다.

상태 변경 타임스탬프 자동 설정

상태 변경 시 관련 타임스탬프를 match 표현식으로 자동 설정합니다:

// 상태 변경 감지 및 타임스탬프 자동 설정
$oldStatus = $user->status;
$newStatus = $data['status'] ?? null;

if ($newStatus && $newStatus !== $oldStatus) {
    $newStatusEnum = UserStatus::from($newStatus);
    $data = match ($newStatusEnum) {
        UserStatus::Blocked => array_merge($data, ['blocked_at' => now()]),
        UserStatus::Withdrawn => array_merge($data, ['withdrawn_at' => now()]),
        UserStatus::Active => array_merge($data, ['blocked_at' => null, 'withdrawn_at' => null]),
        UserStatus::Inactive => $data,  // 타임스탬프 변경 없음
    };
}

$this->userRepository->update($user, $data);

상태 변경 시 토큰 자동 삭제

Active 외 상태로 변경 시 해당 사용자의 토큰을 삭제하여 즉시 로그아웃시킵니다:

// 단일 사용자: 상태가 Active 외로 변경되었으면 토큰 삭제
if ($newStatus && $newStatus !== $oldStatus && $newStatus !== UserStatus::Active->value) {
    $user->tokens()->delete();
}

// 일괄 업데이트: PersonalAccessToken 직접 삭제
if ($statusEnum !== UserStatus::Active) {
    PersonalAccessToken::where('tokenable_type', User::class)
        ->whereIn('tokenable_id', $ids)
        ->delete();
}

패턴 요약

원칙 설명
변경 감지 $oldStatus !== $newStatus 비교 후 처리 (불필요한 업데이트 방지)
match 표현식 Enum case별 타임스탬프 자동 매핑 (array_merge로 기존 데이터 보존)
Active 복귀 시 초기화 blocked_at, withdrawn_at 등 관련 타임스탬프를 null로 리셋
토큰 자동 삭제 비활성 상태 전환 시 즉시 로그아웃 (단일: tokens()->delete(), 일괄: PersonalAccessToken 직접 삭제)

Repository 클래스

역할

  • 데이터 액세스 추상화
  • 쿼리 로직 캡슐화
  • N+1 문제 방지 (Eager Loading)

데이터베이스 독립성 원칙

필수: 표준 SQL만 사용 (MySQL 전용 함수 금지)
필수: Laravel 쿼리빌더 또는 Eloquent 문법 사용
✅ 필수: 데이터베이스 추상화 계층을 통한 쿼리 작성

특정 데이터베이스에 의존하는 Raw 쿼리를 사용하면 다른 데이터베이스로 마이그레이션이 어려워집니다.

JSON 컬럼 처리

// ❌ DON'T: MySQL 전용 함수 사용
$query->whereRaw("JSON_SEARCH(name, 'one', ?) IS NOT NULL", ["%{$keyword}%"]);
$query->orderByRaw("JSON_EXTRACT(name, '$.\"$locale\"') $sortOrder");

// ✅ DO: Laravel JSON 문법 사용
$locales = config('app.translatable_locales', ['ko', 'en']);
foreach ($locales as $locale) {
    $query->orWhere("name->{$locale}", 'like', "%{$keyword}%");
}
$query->orderBy("name->{$locale}", $sortOrder);

다국어 필드 fallback chain

Model 의 getLocalizedX() / Resource 의 다국어 컬럼 출력 / Service 의 다국어 매핑 등에서 현재 locale 값을 반환할 때는 config('app.fallback_locale', 'ko') 기반 fallback chain 을 사용합니다.

// ✅ DO: app.fallback_locale config 기반
public function getLocalizedName(?string $locale = null): string
{
    $locale = $locale ?? app()->getLocale();
    if (! is_array($this->name)) {
        return (string) $this->name;
    }
    return $this->name[$locale]
        ?? $this->name[config('app.fallback_locale', 'ko')]
        ?? (! empty($this->name) ? array_values($this->name)[0] : '')
        ?? '';
}

// ❌ DON'T: ko / en 하드코딩
return $this->name[$locale]
    ?? $this->name['ko']
    ?? $this->name['en']
    ?? '';

운영자가 APP_FALLBACK_LOCALE 환경변수로 폴백 locale 을 변경할 수 있도록 보장하는 정합성 정책입니다.

다국어 라벨이 들어간 비즈니스 로직 (copy 접미사 등)

Repository / Service 가 데이터를 복제하면서 이름에 locale 별 접미사를 붙이는 경우, lang key 를 사용 하고 locale === 'ko' / locale === 'en' 같은 분기는 사용하지 않습니다.

// ✅ DO: locale 별 lang key (모듈 자체 lang/{locale}/messages.php + 활성 언어팩 ja 가 보완)
$previousLocale = app()->getLocale();
try {
    foreach ($name as $locale => $value) {
        app()->setLocale($locale);
        $suffix = trans('vendor-module::messages.copy_suffix');
        if ($suffix === 'vendor-module::messages.copy_suffix') {
            $suffix = ' (Copy)'; // 미정의 시 영어 폴백
        }
        $name[$locale] = $value.$suffix;
    }
} finally {
    app()->setLocale($previousLocale);
}

// ❌ DON'T: ko/en 분기 — ja 등 추가 locale 이 영어 폴백으로 떨어짐
$suffix = $locale === 'ko' ? ' (복사)' : ' (Copy)';

허용되는 Raw 쿼리

복잡한 집계나 Laravel이 지원하지 않는 기능에 한해 Raw 쿼리를 허용하되, 가능한 표준 SQL을 사용합니다:

// ✅ 표준 SQL 집계 (대부분의 DB에서 호환)
$query->selectRaw('sales_status, COUNT(*) as count');
$query->whereColumn('stock_quantity', '<=', 'safe_stock_quantity');
Raw 안에 테이블명·별칭을 쓰지 않는다

Raw 를 쓰더라도 테이블명·별칭·프리픽스를 문자열로 조립하지 않는다. 조립한 순간 프리픽스 처리를 사람이 떠안게 되고, 빌더가 만든 SQL 과 어긋나 조용히 깨진다.

Laravel 은 별칭에도 프리픽스를 붙인다(Grammar::wrapAliasedTable). join('board_comments as uc', ...) 는 g7_board_comments as g7_uc 가 되므로 where('uc.content', ...) 도 g7_uc.content 로 맞아떨어진다. 별칭을 DB::raw 로 직접 만들면 그 별칭만 프리픽스가 빠져, 같은 별칭을 참조하는 빌더 조건이 전부 어긋난다.

// ❌ DON'T: 별칭을 raw 로 만들고 프리픽스를 손으로 처리 — where('uc.content') 가 g7_uc 를 찾아 실패한다
$prefix = DB::getTablePrefix();
$query->join(DB::raw("{$prefix}board_comments AS uc"), function ($join) {
    $join->on(DB::raw("{$prefix}board_posts.id"), '=', DB::raw('uc.post_id'));
});

// ✅ DO: 빌더가 별칭과 프리픽스를 함께 처리하게 둔다
$query->join((new Comment)->getTable().' as uc', function ($join) use ($postsTable) {
    $join->on("{$postsTable}.id", '=', 'uc.post_id');
});

부득이 raw 안에 테이블명이 필요하면(예: LEFT(...), SUBSTRING(...)) 모델에서 얻은 이름과 연결 설정에서 읽은 프리픽스를 쓴다.

// ❌ DON'T
DB::raw("LEFT(`{$prefix}board_posts`.`content`, 300) as excerpt")

// ✅ DO
$postsTable = (new Post)->getTable();
DB::raw('LEFT(`'.DB::getTablePrefix().$postsTable.'`.`content`, 300) as excerpt')
빌더로 대체 가능한 raw 관용구
Raw 빌더 대체 비고
whereRaw('1 = 0') / whereRaw('0 = 1') whereIn($model->getKeyName(), []) 빈 whereIn 이 정확히 0 = 1 로 컴파일된다
where($col, DB::raw("({$sub->toSql()})")) + mergeBindings where($col, '=', $sub) 빌더를 값으로 넘기면 바인딩이 자동 병합된다
addSelect(DB::raw("({$subSql}) as alias")) addSelect(['alias' => $sub]) 서브쿼리 select 는 빌더를 그대로 받는다
whereRaw("... CASE ... IN (?,?)") 갈래별 orWhere(fn ($q) => $q->where(...)->whereIn(...)) 대상 종류에 따라 갈리는 조건은 갈래별 빌더 조건으로
상관 서브쿼리 문자열 whereColumn() 으로 외부 컬럼 참조 값 비교는 where() 로 바인딩
조인 + groupBy 로 존재 판정 whereHas() 행 증폭이 없어 inner 스캔이 가벼워지고 그룹 COUNT 도 불필요

집계 함수(COUNT/SUM/COALESCE)와 DB 고유 함수(MATCH ... AGAINST, JSON_CONTAINS, LEFT, SUBSTRING)는 빌더에 대응 표현이 없으므로 raw 로 남긴다. 이때도 값은 바인딩하고 테이블명은 넣지 않는다.

패턴

<?php

namespace Modules\Sirsoft\Ecommerce\Repositories;

use Modules\Sirsoft\Ecommerce\Models\Product;
use Illuminate\Database\Eloquent\Collection;

class ProductRepository
{
    /**
     * 모든 상품 조회
     *
     * @return Collection
     */
    public function getAll(): Collection
    {
        return Product::with(['category', 'images'])
            ->orderBy('created_at', 'desc')
            ->get();
    }

    /**
     * ID로 상품 조회
     *
     * @param int $id
     * @return Product|null
     */
    public function findById(int $id): ?Product
    {
        return Product::with(['category', 'images'])->find($id);
    }

    /**
     * 상품 생성
     *
     * @param array $data
     * @return Product
     */
    public function create(array $data): Product
    {
        return Product::create($data);
    }

    /**
     * 상품 수정
     *
     * @param int $id
     * @param array $data
     * @return Product
     */
    public function update(int $id, array $data): Product
    {
        $product = $this->findById($id);
        $product->update($data);
        return $product->fresh();
    }
}

Eager Loading으로 N+1 방지

// ❌ DON'T: N+1 문제 발생
public function getAll(): Collection
{
    return Product::all();  // 관계 로딩 없음
}

// ✅ DO: Eager Loading 사용
public function getAll(): Collection
{
    return Product::with(['category', 'images'])->get();
}

목록 조회 컬럼 프루닝과 지연 조인

규칙

  • 목록 조회는 paginate($perPage) 처럼 컬럼 인자를 생략하지 않는다. 화면이 실제로 쓰는 컬럼만 명시한다 (인자를 생략하면 select * 로 폴백해 넓은 컬럼까지 전부 읽는다).
  • 행이 계속 늘어나는 테이블(게시글·주문·활동 로그·알림 발송 이력 등)의 목록은 PaginatesWithDeferredJoin Trait 을 사용한다.

배경

OFFSET n 은 건너뛸 n 건을 읽지 않고 넘기는 것이 아니라 실제로 읽은 뒤 버린다. 목록 SELECT 에 text/longText/JSON 컬럼이 포함돼 있으면 버릴 n 건의 넓은 컬럼까지 함께 읽힌다. 게다가 InnoDB 는 행에 다 담기지 않는 값을 오버플로 페이지에 따로 저장하므로, 앞부분만 필요해도 그 페이지를 읽는다.

게시글 테이블 20만 행 기준 실측 (각 3회 중앙값, 첫 회 버림):

php artisan g7:bench --profile=sirsoft-board/board_posts --seed=200000 --offsets=0,20000,50000,199980 --runs=3 --explain
OFFSET 목록 컬럼 ID 만 조회 배수
0 4.1ms 3.9ms 1.0×
20,000 1,682.6ms 139.8ms 12.0×
50,000 1,623.8ms 90.3ms 18.0×
199,980 1,811.2ms 93.9ms 19.3×

같은 계측의 실행 계획을 보면 원인이 하나 더 드러난다. OFFSET 0 은 (board_id, created_at) 인덱스를 역방향으로 훑어 정렬 없이 끝나지만, OFFSET 이 커지면 옵티마이저가 소프트 삭제 단독 인덱스(deleted_at)로 갈아타면서 filesort 가 붙는다. 넓은 컬럼을 걷어내는 것과 정렬을 인덱스로 덮는 것은 함께 가야 한다.

SUBSTRING(content, 1, N) 을 목록 컬럼에 두는 것도 프루닝이 아니다. 앞 N 바이트를 얻기 위해 오버플로 페이지를 그대로 읽는다.

SUBSTRING(content, 1, 200) 을 목록 컬럼에 두는 것은 프루닝이 아니다. 앞 200바이트를 얻기 위해 오버플로 페이지를 그대로 읽기 때문이다. 잘라내기는 지연 조인의 outer 로 옮긴다.

계측 대상 프로파일 선언 방법과 나머지 3축(화면 응답·쓰기·배치)은 benchmark.md 를 참조한다.

사용

use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;

class OrderRepository implements OrderRepositoryInterface
{
    use PaginatesWithDeferredJoin;
    use ResolvesSortSpec;

    public function paginate(array $filters, int $perPage): LengthAwarePaginator
    {
        $query = $this->model->newQuery();
        $this->applyFilters($query, $filters);   // where 만 적용

        return $this->paginateWithDeferredJoin(
            query: $query,
            columns: ['id', 'order_number', 'status', 'total_amount', 'ordered_at'],
            sort: $this->resolveSortSpec($filters, ['id', 'ordered_at', 'total_amount'], 'ordered_at'),
            perPage: $perPage,
            relations: ['user', 'items'],
        );
    }
}

계약

항목 규칙
$query 필터/where 만 적용한다. orderBy/with/select 는 Trait 이 담당한다
whereHas 결과 집합을 결정하는 조건이므로 그대로 둔다. Trait 은 with 만 inner 에서 제거한다
정렬 닫힌 집합이어야 한다. 인덱스 없는 임의 컬럼 정렬은 inner 도 전체 스캔하므로 개선 폭이 보장되지 않는다
키 컬럼 정렬 스펙 끝에 자동으로 덧붙는다. 동률 행의 순서가 흔들려 페이지 경계에서 행이 중복·누락되는 것을 막는다

paginate / simplePaginate / 캐시 total 주입은 인자로 구분한다 — inner 쿼리는 동일하고 COUNT 수행 여부와 반환 클래스만 달라진다.

호출자 상황 $simple $total 반환
일반 목록 false null LengthAwarePaginator (COUNT 1회)
COUNT 를 피하고 다음 페이지 유무만 필요 true — Paginator
총 건수를 이미 캐시해 둠 false 캐시값 LengthAwarePaginator (COUNT 없음)

정렬 순서 복원은 whereIn + 같은 정렬 스펙 재적용으로 한다. 키 컬럼이 정렬에 포함돼 전순서가 성립하므로 결과가 inner 순서와 동일하다. MySQL 전용 FIELD() 는 쓰지 않는다. outer 에 존재하지 않는 표현식(집계·조인 서브쿼리)으로 정렬해 재현이 불가능한 경우에만 $preserveIdOrder = true 로 표준 SQL CASE WHEN 경로를 쓴다.

정렬 컬럼 화이트리스트

요청 값을 그대로 orderBy() 에 넘기지 않는다. Laravel 이 컬럼명을 백틱으로 감싸므로 SQL 인젝션은 성립하지 않지만, (a) 없는 컬럼으로 인한 SQL 오류가 스키마 정보를 노출하고 (b) 인덱스 없는 넓은 컬럼 정렬을 강제해 DoS 표면이 된다.

정렬 선택지가 4개 이하로 고정된 곳은 match 표현식으로 이미 닫혀 있으므로 그대로 두고, 요청 값이 동적 컬럼으로 흘러 들어가는 곳만 ResolvesSortSpec 을 쓴다.

허용 컬럼 집합은 대응하는 FormRequest 의 Rule::in(...) 과 같은 값으로 맞추고, 그 사실을 상수 주석에 남긴다. 요청이 아닌 경로(콘솔 커맨드·내부 호출)로도 같은 Repository 메서드가 불릴 수 있으므로, 화이트리스트는 FormRequest 하나에만 두지 않고 쿼리를 만드는 자리에도 둔다.

게이트보다 좁게 두지 않는다. 허용 값의 SSoT 는 요청을 실제로 거절하는 FormRequest 이고, Repository 상수는 그 아래에 깔리는 안전망이다. 상수가 게이트의 부분집합이면 검증을 통과한 정렬이 조용히 기본값으로 되돌아간다 — 422 도 로그도 남지 않아 "정렬 버튼이 안 먹는다" 로만 관측된다. FormRequest 가 HookManager::applyFilters 로 확장에 열려 있는 목록은 확장이 정렬 컬럼을 늘릴 수 있으므로, 상수를 게이트보다 넓게 두는 편이 안전하다. 상수를 새로 만들거나 고칠 때는 대응 FormRequest 의 in: 값을 눈으로 대조하고, 컬럼명이 실재하는지 마이그레이션에서 확인한다.

방향 검증은 컬럼 검증이 아니다. 다음은 정렬 방향만 닫아 두고 컬럼은 그대로 흘려보낸 형태이며, 사람이 읽어도 in_array 가 있어 안전해 보이지만 실제로는 무방비다.

// ❌ $sortOrder 만 검사 — $sortBy 는 그대로 orderBy 로 들어간다
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortOrder = strtolower($filters['sort_order'] ?? 'desc');

if (! in_array($sortOrder, ['asc', 'desc'])) {
    $sortOrder = 'desc';
}

$query->orderBy($sortBy, $sortOrder);
// ✅ 컬럼·방향을 한 번에 해석 (방향 정규화는 Trait 이 담당)
foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at') as $sort) {
    $query->orderBy($sort['column'], $sort['direction']);
}

repository-sort-column-whitelist 룰은 정렬 컬럼 변수 자체를 인자로 받는 in_array/match/array_key_exists 또는 resolveSortSpec 만 화이트리스트로 인정한다.

예외

설정/정의성 테이블처럼 행 수가 고정이고 넓은 컬럼이 없으면 컬럼 목록 없이 페이지네이션해도 된다. 다만 그 판단 근거를 코드 옆에 남긴다.

// audit:allow repository-paginate-column-pruning reason: 역할 정의 테이블 — 행 수 고정(<100), 넓은 컬럼 없음
return $query->paginate($perPage);

면제 주석은 위반 줄에 인접해야 인식된다. 메서드 첫 줄에 두면 체인 중간의 ->paginate(...) 줄과 사이에 코드가 끼어 인식되지 않는다.

// ✅ 체인 안에서는 ->paginate 바로 위에 둔다
return $this->model->newQuery()
    ->where('user_id', $userId)
    ->orderByDesc('created_at')
    // audit:allow repository-paginate-column-pruning reason: 사용자 1명에 종속된 목록 — OFFSET 이 깊어질 수 없다
    ->paginate($perPage);

면제 사유로 인정되는 판단 근거는 다음 세 가지다. 어느 것도 아니면 지연 조인으로 전환한다.

근거 성립 조건 예
정의/템플릿 테이블 행 수가 운영자가 등록한 수에 묶이고 넓은 컬럼이 없다 게시판 정의, 배송정책, 상품 안내 템플릿
부모 1건에 종속 where(부모FK) 로 좁혀져 OFFSET 이 깊어질 수 없다 상품 1건의 문의, 사용자 1명의 찜
ID 집합으로 선한정 whereIn('id', $matchedIds) 로 스캔 대상이 이미 묶였다 FULLTEXT 매칭 후 조회하는 통합검색

"부모 1건에 종속" 은 그 부모가 가질 수 있는 자식 수를 실제로 따져 본 뒤에만 쓴다. 대량 발급 쿠폰 1건의 발급 이력처럼 부모가 하나여도 수십만 행이 되는 경우는 이 근거가 성립하지 않는다.

컨트롤러가 Service 에 목록 조회를 위임하는 형태($this->service->paginate(...))는 컬럼 선택 책임이 위임받은 Repository 에 있으므로 룰이 검사하지 않는다.

outer 의 columns: ['*'] 는 위반이 아니다

지연 조인의 목적인 OFFSET 구간의 넓은 컬럼 읽기는 inner 가 키 컬럼만 읽는 것으로 이미 사라진다. outer 가 읽는 행 수는 페이지 크기로 고정되므로 columns: ['*'] 여도 뒤쪽 페이지의 비용 증가는 없다. 넓은 컬럼을 가진 테이블에서 목록 컬럼을 좁히는 것은 그 위에 얹는 추가 최적화이고, 화면이 실제로 쓰는 컬럼이 분명할 때 적용한다.

다만 columns: 자체를 생략하면 outer 가 무엇을 읽는지 호출처에서 드러나지 않으므로 항상 명시한다. DeferredJoinCallSiteContractTest 가 호출처를 스캔해 columns:/sort: 명시 여부를 전수 검사한다.

정적 검사 대상

검출 대상 처리
->paginate($perPage) / ->paginate($perPage, ['*']) — 컬럼 목록 미지정 또는 전체 컬럼 차단
요청 값에서 온 정렬 컬럼을 닫힌 집합 검증 없이 orderBy 에 전달 차단

검사 범위는 app/Repositories/**, modules|plugins/_bundled/**/Repositories/** 와 컨트롤러 3계열이다. 미수정 파일까지 확인하려면 변경분이 아니라 저장소 전체를 대상으로 검사한다 — 변경 파일만 보는 방식으로는 이미 저장소에 있던 위반이 드러나지 않는다.


다중 검색 필터 Trait (HasMultipleSearchFilters)

목록 API에서 다중 검색 조건을 지원해야 할 때 HasMultipleSearchFilters Trait을 사용합니다.

파일 위치

app/Repositories/Concerns/HasMultipleSearchFilters.php

지원 연산자

연산자 설명 SQL 변환
like (기본) 부분 일치 LIKE %value%
eq 정확히 일치 = value
starts_with 시작 일치 LIKE value%
ends_with 끝 일치 LIKE %value

Trait 제공 메서드

메서드 설명
applyMultipleSearchFilters() 다중 검색 조건을 AND로 적용
applySearchFilter() 개별 검색 필터 적용 (연산자 처리)
applyOrSearchAcrossFields() 단일 검색어로 여러 필드 OR 검색

Repository에서 사용

<?php

namespace App\Repositories;

use App\Models\User;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use Illuminate\Database\Eloquent\Builder;

class UserRepository
{
    use HasMultipleSearchFilters;

    /**
     * 검색 가능한 필드 목록 (보안을 위해 허용 필드 명시)
     */
    private const SEARCHABLE_FIELDS = ['name', 'email', 'username'];

    /**
     * 필터링 및 페이지네이션 적용
     */
    public function getPaginatedUsers(array $filters = []): LengthAwarePaginator
    {
        $query = User::query();
        $this->applyFilters($query, $filters);
        return $query->paginate($filters['per_page'] ?? 15);
    }

    /**
     * 쿼리에 필터 조건 적용
     */
    private function applyFilters(Builder $query, array $filters): void
    {
        // 다중 검색 조건 적용
        if (! empty($filters['filters']) && is_array($filters['filters'])) {
            $this->applyMultipleSearchFilters($query, $filters['filters'], self::SEARCHABLE_FIELDS);
        }
    }
}

FormRequest 검증 규칙

<?php

namespace App\Http\Requests\User;

use Illuminate\Foundation\Http\FormRequest;

class UserListRequest extends FormRequest
{
    /**
     * 검색 가능한 필드 목록 (보안을 위해 허용 필드 명시)
     */
    public const SEARCHABLE_FIELDS = ['name', 'email', 'username'];

    public function rules(): array
    {
        // 'all'은 전체 필드 검색을 의미하는 특수 값
        $searchableFields = implode(',', array_merge(['all'], self::SEARCHABLE_FIELDS));

        return [
            // 다중 검색 조건
            'filters' => 'nullable|array|max:10',
            'filters.*.field' => "required_with:filters|string|in:{$searchableFields}",
            'filters.*.value' => 'required_with:filters|string|max:255',
            'filters.*.operator' => 'nullable|string|in:like,eq,starts_with,ends_with',
        ];
    }

    /**
     * 검증 전 데이터 전처리
     *
     * 프론트엔드에서 빈 필터가 전송되는 경우를 처리합니다.
     */
    protected function prepareForValidation(): void
    {
        // value가 비어있는 filters 자동 제거
        $filters = $this->filters;
        if (is_array($filters)) {
            $filters = array_filter($filters, function ($filter) {
                return ! empty($filter['value']);
            });
            // 인덱스 재정렬
            $filters = array_values($filters);
            $this->merge(['filters' => $filters ?: null]);
        }
    }
}

빈 필터 자동 제거 패턴

프론트엔드(DataSourceManager)에서 파라미터 치환 시 빈 값이 전송될 수 있습니다. 이를 처리하기 위해 prepareForValidation()에서 빈 필터를 자동 제거합니다:

상황 처리 방식
filters[0][value]가 빈 문자열 해당 filter 항목 제거
모든 filters가 제거됨 filters를 null로 설정
유효한 필터만 존재 인덱스 재정렬 후 검증 진행

'all' 필드 검색

field가 'all'인 경우 모든 SEARCHABLE_FIELDS를 OR 조건으로 검색합니다. HasMultipleSearchFilters Trait에서 이를 처리합니다.

API 사용 예시

# 단일 필터
GET /api/admin/users?filters[0][field]=name&filters[0][value]=홍

# 다중 필터 (AND 조건)
GET /api/admin/users?filters[0][field]=name&filters[0][value]=홍&filters[1][field]=email&filters[1][value]=example

# 연산자 지정
GET /api/admin/users?filters[0][field]=name&filters[0][value]=홍길동&filters[0][operator]=eq

다른 Repository에서 재사용

class ProductRepository
{
    use HasMultipleSearchFilters;

    private const SEARCHABLE_FIELDS = ['name', 'sku', 'description'];

    // 동일한 패턴으로 검색 구현
}

모듈에서 Repository 인터페이스 바인딩

모듈 내에서 Repository를 사용할 때도 반드시 인터페이스를 통해 DI해야 합니다.

모듈 Repository 구조

modules/sirsoft-ecommerce/src/
├── Contracts/
│   └── Repositories/
│       └── ProductRepositoryInterface.php
├── Repositories/
│   └── ProductRepository.php
├── Services/
│   └── ProductService.php
└── Providers/
    └── EcommerceServiceProvider.php

모듈 인터페이스 정의

<?php

namespace Modules\Sirsoft\Ecommerce\Contracts\Repositories;

use Modules\Sirsoft\Ecommerce\Models\Product;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;

interface ProductRepositoryInterface
{
    public function findById(int $id): ?Product;
    public function getPaginated(array $filters = []): LengthAwarePaginator;
    public function create(array $data): Product;
    public function update(int $id, array $data): Product;
    public function delete(int $id): bool;
}

모듈 ServiceProvider에서 바인딩

모듈의 ServiceProvider에서 인터페이스와 구현체를 바인딩합니다:

<?php

namespace Modules\Sirsoft\Ecommerce\Providers;

use Illuminate\Support\ServiceProvider;
use Modules\Sirsoft\Ecommerce\Contracts\Repositories\ProductRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\ProductRepository;

class EcommerceServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->registerRepositoryBindings();
    }

    private function registerRepositoryBindings(): void
    {
        $this->app->bind(ProductRepositoryInterface::class, ProductRepository::class);
        // 추가 Repository 바인딩...
    }
}

모듈 Service에서 인터페이스 사용

<?php

namespace Modules\Sirsoft\Ecommerce\Services;

use Modules\Sirsoft\Ecommerce\Contracts\Repositories\ProductRepositoryInterface;

class ProductService
{
    public function __construct(
        private ProductRepositoryInterface $productRepository  // ✅ 인터페이스 타입힌트
    ) {}
}

올바른 사용 vs 잘못된 사용

// ❌ DON'T: 구체 클래스 직접 타입힌트
use Modules\Sirsoft\Ecommerce\Repositories\ProductRepository;

class ProductService
{
    public function __construct(
        private ProductRepository $productRepository  // 구체 클래스 직접 의존
    ) {}
}

// ✅ DO: 인터페이스 타입힌트
use Modules\Sirsoft\Ecommerce\Contracts\Repositories\ProductRepositoryInterface;

class ProductService
{
    public function __construct(
        private ProductRepositoryInterface $productRepository  // 인터페이스에 의존
    ) {}
}

핵심 원칙 요약

원칙 설명
인터페이스 의존 Service/Controller는 Repository 인터페이스에 의존
ServiceProvider 바인딩 인터페이스-구현체 매핑은 ServiceProvider에서 수행
테스트 용이성 Mock 객체로 쉽게 대체 가능
유연한 교체 바인딩만 변경하면 다른 구현체 사용 가능

중첩 리소스 스코프

라우트에 상위 리소스 ID 가 있는 중첩 엔드포인트(/posts/{postId}/comments/{id}, /products/{productId}/images/{imageId} 등)에서는 Repository 의 where 절이 스코프의 SSoT 다. 컨트롤러에서 조회 후 if ($comment->post_id !== $postId) 로 비교하는 방식은 신규 호출처가 생기면 그대로 무력화된다.

// ❌ 게시판 범위로만 조회 — 다른 게시글의 댓글도 잡힌다
public function findOrFail(string $slug, int $id): Comment

// ✅ 상위 스코프를 선택 파라미터로 받아 where 절에 반영
public function findOrFail(string $slug, int $id, ?int $postId = null): Comment
{
    return Comment::query()
        ->where('board_id', $board->id)
        ->when($postId !== null, fn ($q) => $q->where('post_id', $postId))
        ->findOrFail($id);
}

규율

항목 규칙
방어 위치 Repository where 절 (SSoT) + Service 가 상위 ID 를 전달
파라미터 형태 선택 파라미터(?int $postId = null) — 필수화는 Interface breaking 이라 이 확장에 의존하는 확장을 전부 흔든다
전달 누락 방지 컨트롤러가 라우트 파라미터를 실제로 소비하는지 정적 검사가 감시 (경고)
응답 코드 조회 실패이므로 404. 요청 본문 배열 항목의 스코프 위반은 422 (validation.md "배열 항목의 상위 스코프")
공통 추상화 도입하지 않는다 — 모듈마다 상위 키와 조회 경로가 다르고(board_id+post_id / product_id / page_id / order_id), 코어 표면이 늘면 확장 버전 제약 동기화가 연쇄된다

설정 기반 한계값

설정값으로 정해지는 한계(최대 깊이, 최대 개수 등)의 검증 책임은 검증 계층 단일이다. Service 에서 리터럴로 다시 클램프하지 않는다.

// ❌ Service 재클램프 — 설정값이 10 이어도 5 를 넘지 못한다
$data['depth'] = min(($parent->depth ?? 0) + 1, 5);

// ✅ 계산만 하고, 상한 검증은 Rule 이 게시판 설정으로 판정
$data['depth'] = ($parent->depth ?? 0) + 1;

이중 클램프는 두 가지를 동시에 망가뜨린다. 저장값이 설정과 무관하게 고정되고(계층 표시 붕괴), 검증 계층의 depth + 1 > max 조건이 영원히 거짓이 되어 깊이 제한 자체가 무력화된다. 값이 상한을 넘으면 조용히 깎지 말고 422 로 거절해야 한다.

이 패턴은 리터럴과 설정 키를 잇는 의미 추론이 필요해 정적 검출이 어렵다 (coverage.json 의 service-hardcoded-limit-vs-settings = not-applicable). 코드 리뷰에서 리터럴 상한을 볼 때마다 "이 값을 정하는 설정이 따로 있는가" 를 확인한다.


관련 문서