Files
Gnuboard7/docs/backend/service-repository.md
T
HeuJung b505ac7ba7 fix(core,board,ecommerce,page,gdpr): 목록 컨텍스트 왕복·엔진 렌더 파이프 결함 일괄 수정 + 공개문서 정리
세 갈래의 결함을 한 브랜치에서 정리한다.

## 목록 컨텍스트 왕복 시 URL 상태 소실 ( @jiwonpapa 님께서 제보해주셨습니다.)

목록에서 상세·형제 상세(이전/다음)·작성/수정 폼에 다녀오면 보고 있던
page/search/category/filters 가 사라지던 문제를 전 도메인에서 수정했다.

- 엔진(engine-v1.54.2): `mergeQuery: true` 만 적고 `query` 를 생략하면 병합이
 통째로 건너뛰어지던 함정을 교정 — `ActionDispatcher.handleNavigate`/`handleReplaceUrl`.
- 게시판·이커머스·페이지·회원·마이페이지·gdpr 등 9개 확장 레이아웃의 왕복 leg 전수
 적용(mergeQuery: true). 의도적 리셋(검색/필터 초기화·탭 전환·프리셋)은 면제 주석으로 구분.
- 무한스크롤 목록(브랜드·상품 공통정보·고시정보)의 새로고침이 URL 검색·정렬을 떨구던
 결함 수정.
- 재발 차단: audit 룰 `layout-list-context-navigate-merge-query`(목록 클러스터 자동 도출,
 page/필터 URL 신호 4종) + `layout-navigate-path-absolute`(navigate path 동작 키워드 금지).

## cellChildren 등 반복 렌더에서 단일 바인딩 파이프 미적용 ( @glitter-gim 님께서 제보해주셨습니다.)

목록 표의 각 칸에 넣은 날짜·숫자 서식(`{{row.x | datetime(...)}}`)이 빈 값이 되거나
서식 없는 원본으로 나오던 문제를, 표현식 판정 로직이 엔진 전역에 복제되며 갈라진
구조적 결함으로 진단하고 판정 경로를 단일화했다(engine-v1.54.3).

- `RenderHelpers`(renderItemChildren·evaluateIfCondition)·`ConditionEvaluator`·
 `DataBindingEngine.resolveObject`·`DynamicRenderer` props 5곳에 단일 바인딩 파이프 분기 추가.
- 계획: `g7-scalable-lobster.md`(렌더 경로 비대칭 결함 일괄 수정).

## 공개 문서 내부 도구 귀속 제거

release 에 포함되는 공개 문서(`docs/**`)에서 내부 audit 룰 ID 귀속 서술을
도구 비귀속 표현("정적 검사")으로 정리. 재발 차단 룰 `public-no-internal-audit-reference` 신설.

전 계층 테스트(PHPUnit·Vitest·Playwright)·회귀 테스트 동반, 버전/CHANGELOG/활성 디렉토리 동기 완료.
2026-07-26 15:17:56 +09:00

33 KiB

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 메서드 위임 (가상 모델 합성 포함)

목차


개요

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');

패턴

<?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();
}

다중 검색 필터 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). 코드 리뷰에서 리터럴 상한을 볼 때마다 "이 값을 정하는 설정이 따로 있는가" 를 확인한다.


관련 문서