# Service-Repository 패턴 > **백엔드 가이드** | [목차로 돌아가기](index.md) --- ## TL;DR (5초 요약) ```text 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 메서드 위임 (가상 모델 합성 포함) ``` --- ## 목차 - [개요](#개요) - [Repository 인터페이스](#repository-인터페이스) - [Service 클래스](#service-클래스) - [트랜잭션 및 관계 삭제 패턴](#트랜잭션-및-관계-삭제-패턴) - [상태 변경 자동 처리](#상태-변경-자동-처리) - [Repository 클래스](#repository-클래스) - [다중 검색 필터 Trait](#다중-검색-필터-trait-hasmultiplesearchfilters) - [모듈에서 Repository 인터페이스 바인딩](#모듈에서-repository-인터페이스-바인딩) - [관련 문서](#관련-문서) --- ## 개요 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 registerRepositoryBindings(); } private function registerRepositoryBindings(): void { // bind() 사용 - Repository는 상태가 없으므로 매번 새 인스턴스 $this->app->bind(UserRepositoryInterface::class, UserRepository::class); $this->app->bind(ModuleRepositoryInterface::class, ModuleRepository::class); // ... 나머지 Repository 바인딩 } } ``` ### 의존성 주입 패턴 #### Service에서 사용 (권장) ```php userRepository->findById($id); } } ``` #### Controller에서 사용 ```php 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 에 합성 메서드 시그니처를 선언: ```php // app/Contracts/Repositories/LanguagePackRepositoryInterface.php public function buildVirtualFromManifest(array $manifest, string $bundledIdentifier): LanguagePack; ``` Repository 구현체는 Model 인스턴스화 + 가상 속성 채움 + `exists=false` 설정 모두 책임: ```php // 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 는 호출만: ```php // 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 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) 변경처럼 데이터의 일부 필드만 별도 권한이 필요한 경우. ```php 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에서 명시적으로** 제거합니다: ```php 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; } ``` ### 일괄 업데이트 트랜잭션 패턴 ```php 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` 표현식으로 자동 설정합니다: ```php // 상태 변경 감지 및 타임스탬프 자동 설정 $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 외 상태로 변경 시 해당 사용자의 토큰을 삭제하여 즉시 로그아웃시킵니다: ```php // 단일 사용자: 상태가 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 컬럼 처리 ```php // ❌ 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 을 사용합니다. ```php // ✅ 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'` 같은 분기는 사용하지 않습니다. ```php // ✅ 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을 사용합니다: ```php // ✅ 표준 SQL 집계 (대부분의 DB에서 호환) $query->selectRaw('sales_status, COUNT(*) as count'); $query->whereColumn('stock_quantity', '<=', 'safe_stock_quantity'); ``` ### 패턴 ```php 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 방지 ```php // ❌ 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 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 '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 사용 예시 ```bash # 단일 필터 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에서 재사용 ```php class ProductRepository { use HasMultipleSearchFilters; private const SEARCHABLE_FIELDS = ['name', 'sku', 'description']; // 동일한 패턴으로 검색 구현 } ``` --- ## 모듈에서 Repository 인터페이스 바인딩 모듈 내에서 Repository를 사용할 때도 반드시 인터페이스를 통해 DI해야 합니다. ### 모듈 Repository 구조 ```text modules/sirsoft-ecommerce/src/ ├── Contracts/ │ └── Repositories/ │ └── ProductRepositoryInterface.php ├── Repositories/ │ └── ProductRepository.php ├── Services/ │ └── ProductService.php └── Providers/ └── EcommerceServiceProvider.php ``` ### 모듈 인터페이스 정의 ```php registerRepositoryBindings(); } private function registerRepositoryBindings(): void { $this->app->bind(ProductRepositoryInterface::class, ProductRepository::class); // 추가 Repository 바인딩... } } ``` ### 모듈 Service에서 인터페이스 사용 ```php