# 모듈 환경설정 시스템 개발 가이드 > 이 문서는 모듈에서 환경설정 시스템을 구현하는 방법을 설명합니다. --- ## 목차 1. [개요](#1-개요) 2. [디렉토리 구조](#2-디렉토리-구조) 3. [defaults.json 작성](#3-defaultsjson-작성) 4. [SettingsService 구현](#4-settingsservice-구현) 5. [SettingsController 구현](#5-settingscontroller-구현) 6. [API 라우트 설정](#6-api-라우트-설정) 7. [레이아웃 연동](#7-레이아웃-연동) 8. [백엔드에서 설정 조회](#8-백엔드에서-설정-조회) 9. [카탈로그 병합 설정의 공개 응답](#9-카탈로그-병합-설정의-공개-응답) 10. [관련 문서](#10-관련-문서) --- ## 1. 개요 ### 1.1 핵심 원칙 ``` 중요: 모듈 환경설정은 모듈의 책임 ✅ 코어는 ModuleSettingsService + 헬퍼 함수(module_setting/module_settings)를 제공 ✅ 모듈이 ModuleSettingsInterface를 구현하면 코어가 자동 검색하여 위임 ✅ ModuleSettingsInterface 미구현 모듈은 코어 기본 구현(단일 setting.json) 사용 ✅ 설정 파일은 모듈별 격리된 경로에 저장 ``` ### 1.2 설정 조회 흐름 ``` module_setting('vendor-module', 'key') → ModuleSettingsService::get() → resolveModuleService() — 모듈별 설정 서비스 자동 검색 → 1순위: Modules\{Vendor}\{Module}\Contracts\{Module}SettingsServiceInterface (바인딩) → 2순위: Modules\{Vendor}\{Module}\Services\{Module}SettingsService (클래스) → 모듈 서비스 발견 시: 위임 (getSetting/getAllSettings) → 미발견 시: 코어 기본 구현 (defaults.json + setting.json) ``` ### 1.3 설계 원칙 - **격리성**: 모듈별로 독립적인 설정 저장 경로 - **일관성**: 코어 환경설정과 동일한 패턴 - **유연성**: 카테고리별 설정 분리 가능 - **보안성**: frontend_schema를 통한 민감정보 제어 --- ## 2. 디렉토리 구조 ### 2.1 모듈 설정 파일 위치 ``` modules/{vendor-module}/ ├── config/ │ └── settings/ │ └── defaults.json ← 기본 설정값 정의 ├── src/ │ ├── Services/ │ │ └── {Module}SettingsService.php ← 설정 서비스 │ ├── Http/ │ │ └── Controllers/ │ │ └── Admin/ │ │ └── {Module}SettingsController.php ← 설정 API │ └── routes/ │ └── api.php ← API 라우트 └── resources/ └── layouts/ └── admin/ └── admin_{module}_settings.json ← 설정 UI ``` ### 2.2 설정 저장 경로 ``` storage/app/modules/{vendor-module}/settings/ ├── basic_info.json ├── language_currency.json └── seo.json ``` --- ## 3. defaults.json 작성 ### 3.1 기본 구조 ```json { "_meta": { "version": "1.0.0", "description": "모듈 설명", "categories": ["basic_info", "language_currency", "seo"] }, "defaults": { "basic_info": { "field_name": "default_value" } }, "frontend_schema": { "basic_info": { "expose": true, "fields": { "field_name": { "expose": true } } } } } ``` ### 3.2 _meta 섹션 | 필드 | 타입 | 설명 | |------|------|------| | version | string | 설정 스키마 버전 | | description | string | 모듈 설명 | | categories | array | 설정 카테고리 목록 | ### 3.3 defaults 섹션 카테고리별 기본값을 정의합니다. ```json { "defaults": { "basic_info": { "shop_name": "", "route_path": "shop", "no_route": false }, "seo": { "meta_main_title": "{site_name} - {commerce_name}", "seo_site_main": true } } } ``` ### 3.4 frontend_schema 섹션 프론트엔드에 노출할 필드를 제어합니다. ```json { "frontend_schema": { "basic_info": { "expose": true, "fields": { "shop_name": { "expose": true }, "api_key": { "expose": false, "sensitive": true } } }, "payment": { "expose": false } } } ``` | 속성 | 설명 | |------|------| | expose | 프론트엔드 노출 여부 | | sensitive | 민감 정보 여부 | --- ## 4. SettingsService 구현 ### 4.1 ModuleSettingsInterface 구현 ```php getModulePath().'/config/settings/defaults.json'; return file_exists($path) ? $path : null; } public function getSetting(string $key, mixed $default = null): mixed { $settings = $this->getAllSettings(); return Arr::get($settings, $key, $default); } public function setSetting(string $key, mixed $value): bool { $settings = $this->getAllSettings(); Arr::set($settings, $key, $value); $parts = explode('.', $key); $category = $parts[0]; return $this->saveCategorySettings($category, $settings[$category] ?? []); } public function getAllSettings(): array { if ($this->settings !== null) { return $this->settings; } $defaults = $this->getDefaults(); $categories = $defaults['_meta']['categories'] ?? []; $defaultValues = $defaults['defaults'] ?? []; $settings = []; foreach ($categories as $category) { $categoryDefaults = $defaultValues[$category] ?? []; $savedSettings = $this->loadCategorySettings($category); $settings[$category] = array_merge($categoryDefaults, $savedSettings); } $this->settings = $settings; return $settings; } public function getSettings(string $category): array { $allSettings = $this->getAllSettings(); return $allSettings[$category] ?? []; } public function saveSettings(array $settings): bool { $success = true; foreach ($settings as $category => $categorySettings) { if (str_starts_with($category, '_')) { continue; } if (!$this->saveCategorySettings($category, $categorySettings)) { $success = false; } } $this->settings = null; return $success; } public function getFrontendSettings(): array { $defaults = $this->getDefaults(); $frontendSchema = $defaults['frontend_schema'] ?? []; $allSettings = $this->getAllSettings(); $frontendSettings = []; foreach ($frontendSchema as $category => $schema) { if (!($schema['expose'] ?? false)) { continue; } $categorySettings = $allSettings[$category] ?? []; $fields = $schema['fields'] ?? []; if (empty($fields)) { $frontendSettings[$category] = $categorySettings; continue; } $exposedFields = []; foreach ($fields as $fieldName => $fieldSchema) { if ($fieldSchema['expose'] ?? false) { $exposedFields[$fieldName] = $categorySettings[$fieldName] ?? null; } } if (!empty($exposedFields)) { $frontendSettings[$category] = $exposedFields; } } return $frontendSettings; } private function getModulePath(): string { return base_path('modules/'.self::MODULE_IDENTIFIER); } private function getStoragePath(): string { return storage_path('app/modules/'.self::MODULE_IDENTIFIER.'/settings'); } // 나머지 private 메서드 구현... } ``` ### 4.2 분리 입력 필드 처리 전화번호, 사업자번호 등 분리 입력 필드 처리: ```php private function processSplitFields(string $category, array $settings): array { if ($category !== 'basic_info') { return $settings; } // 사업자등록번호 병합 if (isset($settings['business_number_1'])) { $parts = [ $settings['business_number_1'] ?? '', $settings['business_number_2'] ?? '', $settings['business_number_3'] ?? '', ]; $settings['business_number'] = implode('-', array_filter($parts)); unset( $settings['business_number_1'], $settings['business_number_2'], $settings['business_number_3'] ); } return $settings; } ``` --- ## 5. SettingsController 구현 ```php settingsService->getFrontendSettings(); return $this->success('settings.fetch_success', $settings); } public function show(string $category): JsonResponse { $settings = $this->settingsService->getSettings($category); return $this->success('settings.fetch_success', [ 'category' => $category, 'settings' => $settings, ]); } public function store(Request $request): JsonResponse { $result = $this->settingsService->saveSettings($request->all()); if ($result) { $updatedSettings = $this->settingsService->getFrontendSettings(); return $this->success('settings.save_success', $updatedSettings); } return $this->error('settings.save_failed'); } } ``` --- ## 6. API 라우트 설정 ### 6.1 라우트 파일 위치 ``` modules/{vendor-module}/src/routes/api.php ``` ### 6.2 라우트 정의 ```php middleware(['auth:sanctum', 'admin'])->group(function () { Route::get('settings', [ModuleSettingsController::class, 'index']) ->name('admin.settings.index'); Route::put('settings', [ModuleSettingsController::class, 'store']) ->name('admin.settings.store'); Route::get('settings/{category}', [ModuleSettingsController::class, 'show']) ->name('admin.settings.show'); }); ``` ### 6.3 최종 API 경로 | Method | 경로 | 설명 | |--------|------|------| | GET | `/api/modules/{id}/admin/settings` | 전체 설정 조회 | | PUT | `/api/modules/{id}/admin/settings` | 설정 저장 | | GET | `/api/modules/{id}/admin/settings/{category}` | 카테고리별 조회 | --- ## 7. 레이아웃 연동 ### 7.1 data_sources 설정 ```json { "data_sources": [ { "id": "settings", "type": "api", "endpoint": "/api/modules/{module-id}/admin/settings", "method": "GET", "auto_fetch": true, "auth_required": true, "initLocal": "form", "refetchOnMount": true } ], "state": { "isSaving": false, "hasChanges": false, "form": {} } } ``` ### 7.2 저장 버튼 액션 ```json { "handler": "apiCall", "auth_required": true, "target": "/api/modules/{module-id}/admin/settings", "params": { "method": "PUT", "body": "{{_local.form}}" } } ``` --- ## 8. 백엔드에서 설정 조회 ### 8.1 헬퍼 함수 사용 (권장) `module_setting()` / `module_settings()`는 내부적으로 `ModuleSettingsService`를 사용합니다. 모듈별 설정 서비스(`{Module}SettingsService`)가 존재하면 자동으로 위임합니다. ```php // 단일 설정값 조회 (도트 노테이션 지원) $shopName = module_setting('vendor-module', 'basic_info.shop_name', '기본값'); // 전체 설정 조회 $allSettings = module_setting('vendor-module'); // 카테고리 전체 조회 $seoSettings = module_settings('vendor-module', 'seo'); // 전체 설정 조회 $allSettings = module_settings('vendor-module'); ``` ### 8.2 ModuleSettingsService 직접 사용 ```php use App\Services\ModuleSettingsService; $service = app(ModuleSettingsService::class); // 단일 설정값 조회 $value = $service->get('vendor-module', 'basic_info.shop_name', '기본값'); // 전체 설정 조회 $allSettings = $service->get('vendor-module'); ``` ### 8.3 모듈 내부에서 자체 서비스 사용 모듈 내부 Service/Controller에서는 자체 설정 서비스를 직접 주입하여 사용할 수 있습니다. 모듈 전용 메서드(예: `getStockDeductionTiming()`)가 필요한 경우 이 방식을 사용합니다. ```php // 모듈 내부 — 모듈 전용 메서드 접근이 필요한 경우 use Modules\Vendor\Module\Services\ModuleSettingsService; public function __construct( private ModuleSettingsService $settingsService ) {} // ModuleSettingsInterface 메서드 + 모듈 전용 메서드 모두 사용 가능 $value = $this->settingsService->getSetting('basic_info.shop_name'); $timing = $this->settingsService->getStockDeductionTiming($method); ``` > **판단 기준**: `getSetting()` 단일 호출만 필요하면 `module_setting()` 헬퍼 사용, 모듈 전용 메서드가 필요하면 서비스 직접 주입 --- ## 9. 카탈로그 병합 설정의 공개 응답 설정 항목의 목록을 다른 확장이 훅으로 등록하는 경우(결제수단, 배송사, 발급 제공자 등), 저장된 값은 그 카탈로그와 병합되어 응답에 실린다. 운영자가 저장한 값(`is_active`, 정렬 순서 등)과 확장이 제공하는 정의(이름, 아이콘, 지원 범위)가 항목마다 합쳐지는 구조다. ### 9.1 고아 항목이 생기는 조건 병합의 두 입력은 생명주기가 다르다. 저장값은 파일에 남지만 카탈로그는 요청 시점에 훅으로 다시 만들어지므로, 다음 상황에서 **저장값만 남고 카탈로그에서는 사라진 항목**이 생긴다. - 공급 확장을 삭제했다 - 공급 확장을 비활성화했다 - 확장이 살아 있지만 자기 기능 토글을 꺼서 해당 항목 등록을 중단했다 병합부는 이런 항목에 `_orphaned` 표시를 붙여 돌려준다. 이때 저장값의 `is_active` 는 참 그대로 남아 있다 — 운영자가 끈 적이 없기 때문이다. ### 9.2 관리자 응답과 공개 응답의 처리가 다르다 | 응답 | 고아 항목 | 이유 | | --- | --- | --- | | 관리자 설정 조회 | **포함** | 운영자가 그 항목이 남아 있음을 확인하고 지울 수 있어야 한다. 화면은 `_orphaned` 를 읽어 편집 불가로 표시한다 | | 공개(소비) 조회 | **제거** | 공급 확장이 더 이상 제공하지 않는 항목이다. 내보내면 사용자가 선택할 수 있게 된다 | ```php public function getPublicPaymentSettings(): array { $orderSettings = $this->getSettings('order_settings'); if (isset($orderSettings['payment_methods']) && is_array($orderSettings['payment_methods'])) { $orderSettings['payment_methods'] = array_values(array_filter( $orderSettings['payment_methods'], fn ($method) => ! ($method['_orphaned'] ?? false) )); } return $orderSettings; } ``` `array_values()` 로 인덱스를 재정렬한다. 중간 항목을 지우면 키가 비연속이 되고, 그 배열은 JSON 객체로 직렬화되어 화면의 반복 렌더가 깨진다. ### 9.3 차단 지점은 공개 API 한 곳이다 소비 화면(레이아웃 JSON)의 필터에 맡기지 않는다. 같은 데이터를 그리는 템플릿이 여러 개면 필터가 화면 수만큼 복제되고, 한 곳만 빠져도 같은 결함이 그 화면에서만 남는다. 같은 데이터를 내보내는 공개 엔드포인트가 둘 이상이면 **전부 같은 공개 게터를 경유**해야 한다. 한쪽이 raw `getSettings()` 를 쓰면 그 경로만 조용히 뚫린다. ```php // ❌ 엔드포인트마다 다른 게터 — checkout 경로만 고아 항목이 새어 나간다 $orderSettings = $this->settingsService->getSettings('order_settings'); // ✅ 공개 엔드포인트는 모두 공개 게터를 경유 $orderSettings = $this->settingsService->getPublicPaymentSettings(); ``` ### 9.4 진단 이 결함은 예외도 경고도 로그도 남기지 않는다. 관리자 화면은 고아 표시로 정상 차단하고 있어, 관리자 응답과 공개 응답을 나란히 놓고 항목 수를 비교하기 전에는 드러나지 않는다. ```bash # 두 응답의 항목 id 목록이 다르면, 그 차이가 고아 항목이다 GET /api/modules/{id}/admin/settings # 고아 포함 (정상) GET /api/modules/{id}/settings/payment # 고아 제외 (정상) ``` --- ## 10. 관련 문서 - [모듈 기초](module-basics.md) - 모듈 구조, AbstractModule - [모듈 라우트](module-routing.md) - API 라우트 규칙 - [모듈 레이아웃](module-layouts.md) - 레이아웃 등록 - [모듈 다국어](module-i18n.md) - 다국어 지원 --- ## 체크리스트 모듈 환경설정 구현 시 확인 사항: - [ ] `config/settings/defaults.json` 생성 - [ ] `ModuleSettingsInterface` 구현 서비스 생성 - [ ] 설정 컨트롤러 생성 - [ ] API 라우트 등록 - [ ] 레이아웃에 `data_sources` 추가 - [ ] 저장 버튼 액션 연결 - [ ] `frontend_schema`로 민감정보 필터링 설정