# 플러그인 개발 가이드 > **관련 문서**: [index.md](index.md) | [extension-manager.md](extension-manager.md) | [hooks.md](hooks.md) --- ## TL;DR (5초 요약) ```text 1. 디렉토리: plugins/vendor-plugin (예: sirsoft-payment) 2. 네임스페이스: Plugins\Vendor\Plugin\ 3. AbstractPlugin 상속 권장 (PluginInterface 직접 구현 가능) 4. 필수: plugin.json (메타데이터 SSoT), plugin.php, composer.json 5. getName()/getVersion()/getDescription()은 plugin.json에서 자동 파싱 (하드코딩 불필요) ``` --- ## 목차 1. [플러그인 네이밍 규칙](#플러그인-네이밍-규칙) 2. [PluginInterface 구현](#plugininterface-구현) 3. [composer.json 작성법](#composerjson-작성법) 4. [플러그인 디렉토리 구조](#플러그인-디렉토리-구조) 5. [플러그인 설정 시스템](#플러그인-설정-시스템) 6. [설정 레이아웃 파일 시스템](#설정-레이아웃-파일-시스템) 7. [defaults.json과 frontend_schema](#defaultsjson을-통한-설정-기본값-및-프론트엔드-노출-제어) 8. [플러그인 원칙](#플러그인-원칙) --- ## 플러그인 네이밍 규칙 ### 디렉토리명 `vendor-plugin` 형식 (GitHub 스타일) - 소문자 사용 - 하이픈(-): vendor와 plugin 구분 - 언더스코어(_): 플러그인명 내 단어 구분 (선택적) - 예: `sirsoft-payment`, `sirsoft-daum_postcode`, `johndoe-analytics` ### 네임스페이스 `Plugins\Vendor\Plugin\` 형식 - PascalCase 사용 - 백슬래시(\) 구분 - 예: `Plugins\Sirsoft\Payment\`, `Plugins\Sirsoft\DaumPostcode\` ### 디렉토리명 → 네임스페이스 변환 규칙 ```text 필수: 변환 규칙 이해 하이픈(-) → 네임스페이스 구분자(\) 언더스코어(_) → PascalCase 결합 예시: sirsoft-payment → Sirsoft\Payment sirsoft-daum_postcode → Sirsoft\DaumPostcode vendor-my_plugin_name → Vendor\MyPluginName ``` ### Composer 패키지명 `plugins/vendor-plugin` - 예: `plugins/sirsoft-payment`, `plugins/sirsoft-daum_postcode` ### 네이밍 규칙 요약 | 항목 | 형식 | 예시 | |------|------|------| | 디렉토리명 | `vendor-plugin` | `sirsoft-payment`, `sirsoft-daum_postcode` | | 네임스페이스 | `Plugins\Vendor\Plugin\` | `Plugins\Sirsoft\Payment\`, `Plugins\Sirsoft\DaumPostcode\` | | Composer | `plugins/vendor-plugin` | `plugins/sirsoft-payment`, `plugins/sirsoft-daum_postcode` | ### 식별자 검증 규칙 모듈/플러그인/템플릿 공통 식별자 검증 규칙은 [extension-manager.md](./extension-manager.md#식별자-검증-규칙-validextensionidentifier)를 참조하세요. --- ## PluginInterface 구현 ### 기본 구조 **중요**: plugin.php는 **루트 레벨**에 위치 (src/ 내부 아님) ```php '>=1.0.0', // vendor-module 형식 ]; } /** * 훅 리스너 목록 */ public function getHookListeners(): array { return [ \Plugins\Sirsoft\Payment\Listeners\PaymentProcessListener::class, ]; } } ``` ### manifest 자동 파싱 메서드 (오버라이드 불필요) `plugin.json`에서 자동으로 값을 읽어오는 메서드입니다. 하드코딩 오버라이드가 불필요합니다. | 메서드 | 반환 타입 | 소스 (plugin.json) | 기본값 | |--------|----------|-------------------|--------| | `getName()` | `string\|array` | `name` | identifier | | `getVersion()` | `string` | `version` | `'0.0.0'` | | `getDescription()` | `string\|array` | `description` | `''` | | `getRequiredCoreVersion()` | `?string` | `g7_version` | `null` | | `getLicense()` | `?string` | `license` | `null` | | `getGithubUrl()` | `?string` | `github_url` | `null` | | `getAssets()` | `array` | `assets` | `[]` | | `getAssetLoadingConfig()` | `array` | `loading` | strategy: global, priority: 100 | > **`license` 필드**: `plugin.json`에 `"license": "MIT"` 등의 라이선스 정보를 포함합니다. `getLicense()` 메서드로 값을 읽으며, API 리소스의 `license` 필드로 노출됩니다. 또한 각 플러그인 루트에 `LICENSE` 파일을 포함하여 라이선스 전문을 제공해야 합니다. ### hidden 필드 (선택) `plugin.json` 에 `"hidden": true` 를 설정하면 관리자 UI 의 플러그인 목록에서 기본 제외됩니다. 학습용 샘플 플러그인, 내부 운영 전용 플러그인을 일반 사용자에게 감출 때 사용합니다. - 제외 대상: 관리자 UI (`GET /api/admin/plugins` 기본 응답) - 제외 대상 아님: artisan CLI (`plugin:list`, `plugin:install`, `plugin:activate` 등), 설치/제거/업데이트 감지 - 슈퍼관리자는 "숨김 포함" 토글로 일시 조회 가능 (`?include_hidden=1`) - artisan CLI 에서도 기본 목록에서는 숨기고, `php artisan plugin:list --hidden` 으로 숨김 포함 목록을 조회할 수 있습니다 - 사용 사례: 학습용 샘플 플러그인(예: `gnuboard7-hello_plugin`), 내부 전용 연동 플러그인 ### 자동 추론 메서드 (final - 오버라이드 불가) | 메서드 | 반환 타입 | 설명 | |--------|----------|------| | `getIdentifier()` | `string` | 디렉토리명에서 자동 추론 (예: `sirsoft-payment`) | | `getVendor()` | `string` | `plugin.json` 의 `vendor` 필드를 우선 사용. 값이 없으면 디렉토리명의 첫 단어로 폴백 (예: `sirsoft`) | ### 기본값 제공 메서드 (필요시 오버라이드) | 메서드 | 기본값 | 설명 | |--------|--------|------| | `install()` | `true` | 설치 로직 | | `uninstall()` | `true` | 제거 로직 | | `activate()` | `true` | 활성화 로직 | | `deactivate()` | `true` | 비활성화 로직 | | `getDynamicTables()` | `[]` | 런타임 동적 생성 테이블 목록 (언인스톨 시 Manager가 삭제) | | `getDynamicPermissionIdentifiers()` | `[]` | 런타임 생성 권한 식별자 — stale cleanup 보존 대상 | | `getDynamicRoleIdentifiers()` | `[]` | 런타임 생성 역할 식별자 — stale cleanup 보존 대상 | | `getDependencies()` | `[]` | 의존하는 모듈/플러그인 목록 | | `getHookListeners()` | `[]` | 훅 리스너 클래스 목록 | | `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) | | `getBenchmarkProfiles()` | `[]` | 성능 계측 대상 선언 (`g7:bench` 가 수집) — 목록/화면/쓰기/배치 4축 ([benchmark.md](../backend/benchmark.md)) | | `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 플러그인은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `PluginManager::runUpgradeSteps` 가 `RuntimeException` throw | > **동적 식별자 보존 규칙**: `Permission::updateOrCreate()` / `Role::firstOrCreate()` 등으로 런타임에 생성한 엔티티는 업데이트 시 `cleanupStalePluginEntries` 에 의해 "정적 정의에 없는 고아 레코드" 로 판정되어 삭제될 위험이 있습니다. 이를 방지하려면 동적 식별자 목록을 위 3개 훅에서 반환하세요 — 정적 정의 + 동적 식별자가 병합된 expected 목록을 기준으로 판정되어 보존됩니다. 상세는 [extension-update-system.md](extension-update-system.md) 참조. --- ## composer.json 작성법 **중요**: autoload에 `["src/", "./"]` 포함 (루트의 plugin.php 로딩을 위해) ```json { "name": "plugins/sirsoft-payment", "description": "Payment gateway plugin for Gnuboard7 platform by sirsoft", "type": "library", "authors": [ { "name": "sirsoft", "email": "contact@sirsoft.com" } ], "require": { "php": "^8.2" }, "autoload": { "psr-4": { "Plugins\\Sirsoft\\Payment\\": ["src/", "./"] } } } ``` ### autoload 설정 상세 ```json "autoload": { "psr-4": { "Plugins\\Sirsoft\\Payment\\": ["src/", "./"] } } ``` - `"src/"`: src 디렉토리 내 클래스 로딩 - `"./"`: 루트의 plugin.php 로딩 --- ## 플러그인 디렉토리 구조 ```text plugins/_bundled/sirsoft-payment/ ├── plugin.json # 메타데이터 (이름, 버전, 설명 등 SSoT) ├── plugin.php # PluginInterface 구현 (루트) ├── LICENSE # 라이선스 전문 (MIT) ├── composer.json # PSR-4 오토로딩 + 외부 패키지 의존성 (Git 추적) ├── composer.lock # Composer 락 파일 (Git 추적 — vendor-bundle.json 의 composer_lock_sha256 가 이 파일을 검증) ├── vendor-bundle.json # 번들 메타파일: SHA256, 패키지 목록 (Git 추적) ├── vendor-bundle.zip # 압축된 vendor 디렉토리 (Git 추적) ├── vendor/ # Composer 의존성 (자동 생성, gitignore 대상) ├── upgrades/ # 버전 업그레이드 스텝 (AbstractUpgradeStep 상속 — g7_version >= 7.0.0-beta.5 플러그인 의무) │ ├── Upgrade_1_1_0.php # 1.1.0 버전 업그레이드 스텝 (extends AbstractUpgradeStep) │ └── data/ # 버전별 데이터 스냅샷 — 카탈로그 delta / Applier / Migration 동결 │ └── 1.1.0/ │ ├── manifest.json # kind → delta JSON 매핑 │ ├── *.delta.json # added/removed/renamed 시드 │ ├── appliers/ # SnapshotApplier 구현 (버전 namespace) │ └── migrations/ # DataMigration 구현 (변환/핫픽스, 버전 namespace) ├── config/ # 플러그인 설정 │ ├── payment.php │ └── settings/ │ └── defaults.json # 기본값 + frontend_schema (선택) ├── database/ │ ├── migrations/ # 마이그레이션 │ └── seeders/ # 시더 │ └── Sample/ # 샘플(개발용) 시더 ├── resources/ │ ├── lang/ # 다국어 파일 │ │ ├── ko.json │ │ └── en.json │ └── layouts/ # 레이아웃 파일 │ └── admin/ # 관리자 레이아웃 (admin 템플릿 등록) │ └── plugin_settings.json # 설정 페이지 레이아웃 ├── src/ │ ├── Listeners/ # 이벤트(훅)를 처리하는 리스너 │ └── Providers/ # 서비스 컨테이너에 기능을 등록하는 프로바이더 └── tests/ # 테스트 파일 ├── Feature/ # 기능 테스트 └── Unit/ # 단위 테스트 ``` ### 디렉토리 설명 | 디렉토리 | 설명 | |----------|------| | `plugin.json` | 메타데이터 SSoT (이름, 버전, 설명, 의존성, 라이선스 등) — 버전 제약 정책은 [changelog-rules.md](changelog-rules.md#8-코어-버전-제약-정책) 참조 | | `plugin.php` | PluginInterface 구현 파일 (루트에 위치) | | `LICENSE` | 라이선스 전문 (MIT 등) — API 엔드포인트 `GET /api/admin/plugins/{identifier}/license`로 제공 | | `composer.json` | PSR-4 오토로딩 + 외부 패키지 의존성 설정 | | `vendor/` | Composer 의존성 디렉토리 (자동 생성, gitignore 대상) | | `config/` | 플러그인 전용 설정 파일 | | `config/settings/defaults.json` | 기본값 + frontend_schema 정의 (선택) | | `database/migrations/` | 플러그인 마이그레이션 파일 | | `database/seeders/` | 플러그인 시더 파일 | | `resources/lang/` | 다국어 JSON 파일 (ko.json, en.json) | | `resources/layouts/admin/` | 관리자 레이아웃 JSON 파일 (plugin_settings.json - 설정 페이지 전용) | | `src/Listeners/` | 훅 이벤트 리스너 클래스 | | `src/Providers/` | 서비스 프로바이더 클래스 | | `tests/` | 테스트 파일 (Feature/Unit) | --- ## 서비스 프로바이더 작성 모든 플러그인 ServiceProvider 는 `App\Extension\BasePluginServiceProvider` 를 상속한다. Laravel 기본 `ServiceProvider` 를 직접 상속하면 Repository / Storage / Cache 자동 바인딩과 다국어 로드를 직접 구현해야 하며, 글로벌 컨테이너 바인딩 누수 결함의 위험이 발생한다. ### 표준 구조 ```php namespace Plugins\Vendor\Foo\Providers; use App\Extension\BasePluginServiceProvider; use Plugins\Vendor\Foo\Repositories\FooRepository; use Plugins\Vendor\Foo\Repositories\FooRepositoryInterface; use Plugins\Vendor\Foo\Services\FooService; class FooPluginServiceProvider extends BasePluginServiceProvider { /** 플러그인 식별자 (manifest 와 일치) */ protected string $pluginIdentifier = 'vendor-foo'; /** Repository 인터페이스 ↔ 구현체 매핑 */ protected array $repositories = [ FooRepositoryInterface::class => FooRepository::class, ]; /** CacheInterface 가 필요한 서비스 (contextual binding) */ protected array $cacheServices = [ FooService::class, ]; /** StorageInterface 가 필요한 서비스 (contextual binding) */ protected array $storageServices = [ // FooFileService::class, ]; } ``` `register()` / `boot()` 오버라이드가 필요한 경우 `parent::register()` / `parent::boot()` 를 먼저 호출한 뒤 추가 로직을 작성한다. ### 금지 사항 - **글로벌 `CacheInterface` / `StorageInterface` 바인딩 재정의 금지** — `$this->app->singleton(CacheInterface::class, ...)` 또는 `app()->bind(StorageInterface::class, ...)` 는 코어/타 확장 도메인을 누수시킨다. 정적 검사가 자동 차단한다. - **인라인 contextual binding 복제 금지** — `$this->app->when([X::class])->needs(StorageInterface::class)->give(...)` 패턴을 직접 작성하지 말고 `$storageServices` 배열에 등록한다. - **Listener 의 동적 인스턴스 생성에서 `app(CacheInterface::class)` 직접 호출 금지** — `app()->makeWith(MyService::class, [...])` 로 컨테이너 해석에 위임해야 contextual binding 이 적용된다. - **HTTP Kernel 미들웨어 그룹 직접 조작 금지** — SP `boot()` 에서 `HttpKernel::append/prepend/pushMiddlewareToGroup()` / `aliasMiddleware()`, 또는 라우트 파일에서 자기 `Http\Middleware\` FQCN 을 `->middleware(Foo::class)` 로 부착하지 않는다. 대신 `Plugin::getMiddleware()` 로 미들웨어와 부착 대상(targets)을 선언하면 코어 self-gate 게이트가 요청 시점에 실행한다. 정적 검사가 자동 차단한다. 상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../backend/middleware.md#확장-미들웨어-선언-self-gate). 상세: [cache-driver.md `## 확장에서 cache 바인딩`](cache-driver.md) --- ## 플러그인 설정 시스템 플러그인이 관리자 설정 UI를 제공하려면 `AbstractPlugin`을 상속하고 설정 관련 메서드를 구현합니다. ### AbstractPlugin 상속 (권장) > **참고**: `getName()`, `getVersion()`, `getDescription()`은 `plugin.json`에서 자동 파싱되므로 오버라이드가 불필요합니다. ```php [ 'type' => 'enum', 'options' => ['popup', 'layer'], 'default' => 'layer', 'label' => [ 'ko' => '표시 방식', 'en' => 'Display Mode', ], 'hint' => [ 'ko' => '주소 검색 창을 표시하는 방식을 선택합니다.', 'en' => 'Select how to display the address search window.', ], 'required' => false, ], 'popup_width' => [ 'type' => 'integer', 'default' => 500, 'label' => [ 'ko' => '팝업 너비 (px)', 'en' => 'Popup Width (px)', ], 'required' => false, ], ]; } /** * 플러그인 설정 기본값 반환 */ public function getConfigValues(): array { return [ 'display_mode' => 'layer', 'popup_width' => 500, ]; } } ``` ### 설정 스키마 필드 타입 | 타입 | 설명 | 추가 속성 | |------|------|----------| | `string` | 텍스트 입력 | `sensitive`: 민감 정보 마스킹 | | `integer` | 숫자 입력 | `min`, `max` | | `boolean` | 체크박스 | - | | `enum` | 선택 박스 | `options`: 선택지 배열 | ### 설정 스키마 공통 속성 | 속성 | 타입 | 설명 | |------|------|------| | `type` | `string` | 필드 타입 (필수) | | `default` | `mixed` | 기본값 | | `label` | `array` | 다국어 레이블 (`ko`, `en`) | | `hint` | `array` | 다국어 힌트 텍스트 | | `required` | `bool` | 필수 여부 (기본: false) | | `sensitive` | `bool` | 민감 정보 여부 (마스킹, 암호화) | | `readonly` | `bool` | 읽기 전용 여부 | ### 설정 API 엔드포인트 플러그인 설정은 다음 API를 통해 관리됩니다: | 메서드 | 엔드포인트 | 설명 | |--------|-----------|------| | GET | `/api/admin/plugins/{identifier}/settings` | 설정 조회 | | PUT | `/api/admin/plugins/{identifier}/settings` | 설정 저장 | | GET | `/api/admin/plugins/{identifier}/settings/layout` | 설정 UI 레이아웃 | ### defaults.json을 통한 설정 기본값 및 프론트엔드 노출 제어 플러그인도 모듈과 동일하게 `config/settings/defaults.json`을 통해 기본값을 정의하고, `frontend_schema`로 `window.G7Config.plugins`에 노출할 설정을 제어할 수 있습니다. **파일 경로**: `plugins/{identifier}/config/settings/defaults.json` ```json { "_meta": { "version": "1.0.0", "description": "플러그인 환경설정" }, "defaults": { "display_mode": "layer", "popup_width": 500, "popup_height": 600, "api_secret": "sk_xxx" }, "frontend_schema": { "display_mode": { "expose": true }, "popup_width": { "expose": true }, "popup_height": { "expose": true }, "api_secret": { "expose": false } } } ``` #### frontend_schema 동작 규칙 | 조건 | 동작 | |---------------------------------------------------|-------------------------------------------------------| | `defaults.json`에 `frontend_schema` 있음 | 스키마 기반 필터링 (`expose: true`만 G7Config에 노출) | | `defaults.json` 없거나 `frontend_schema` 비어있음 | 기존 동작 유지 (`sensitive: true`만 제외) | #### 카테고리 구조 설정에서의 필터링 카테고리 구조 설정을 사용하는 플러그인은 카테고리/필드 레벨로 제어할 수 있습니다: ```json { "frontend_schema": { "general": { "expose": true, "fields": { "site_name": { "expose": true }, "secret_key": { "expose": false } } }, "internal": { "expose": false } } } ``` - `expose: false`인 카테고리 → 해당 카테고리 전체 미노출 - `expose: true` + `fields` 정의 → 필드별 제어 - `expose: true` + `fields` 없음 → 카테고리 전체 노출 #### 설정 초기화 우선순위 플러그인 설치 시 `initializePluginSettings()`에서 다음 순서로 기본값을 결정합니다: 1. `defaults.json`의 `defaults` 섹션 (1순위) 2. `getConfigValues()` 반환값 (하위 호환성) 3. 빈 값이면 초기화 스킵 ```text 하위 호환성: defaults.json이 없는 기존 플러그인은 기존 동작을 유지합니다. ✅ 권장: 새 플러그인은 defaults.json + frontend_schema를 사용하세요. ``` ### 플러그인이 제공하는 훅 정의 외부 연동 플러그인은 다른 모듈/플러그인이 구독할 수 있는 훅을 정의할 수 있습니다: ```php /** * 플러그인이 제공하는 훅 정보 반환 */ public function getHooks(): array { return [ [ 'name' => 'sirsoft-daum_postcode.address.selected', 'type' => 'action', 'description' => [ 'ko' => '주소 선택 완료 시 실행되는 액션 훅', 'en' => 'Action hook executed when address selection is complete', ], 'parameters' => [ 'zonecode' => 'string - 우편번호', 'address' => 'string - 기본 주소', 'roadAddress' => 'string - 도로명 주소', ], ], ]; } ``` --- ## 설정 레이아웃 파일 시스템 플러그인 설정 UI는 JSON 레이아웃 파일을 통해 정의합니다. `AbstractPlugin`의 `getSettingsLayout()` 메서드가 레이아웃 파일 경로를 반환하며, 파일이 존재하면 자동으로 설정 UI를 제공합니다. ### 핵심 원칙 ```text 필수: 설정 UI는 resources/layouts/settings.json으로 정의 (PHP 코드에 하드코딩 금지) 필수: resources/layouts/admin/plugin_settings.json 파일로 레이아웃 정의 ✅ 필수: 템플릿 오버라이드 우선순위 준수 ``` ### 레이아웃 파일 경로 기본 경로: `plugins/{identifier}/resources/layouts/admin/plugin_settings.json` ```php // AbstractPlugin 기본 구현 public function getSettingsLayout(): ?string { $path = $this->getBasePath() . '/resources/layouts/admin/plugin_settings.json'; return file_exists($path) ? $path : null; } // hasSettings()는 레이아웃 파일 존재 여부로 판단 public function hasSettings(): bool { return $this->getSettingsLayout() !== null; } ``` ### 템플릿 오버라이드 우선순위 설정 레이아웃은 다음 순서로 로드됩니다: | 우선순위 | 경로 | 설명 | |----------|------|------| | 1순위 | `templates/{template}/layouts/plugins/{identifier}/admin/plugin_settings.json` | 템플릿 오버라이드 | | 2순위 | `plugins/{identifier}/resources/layouts/admin/plugin_settings.json` | 플러그인 기본 | ### 레이아웃 JSON 구조 (간소화 버전) `schema`만 정의하면 설정 UI가 자동 생성됩니다: ```json { "version": "1.0.0", "meta": { "title": "$t:settings.page_title", "description": "$t:settings.page_description" }, "pageConfig": { "notice": "$t:settings.notice", "guide": { "title": "$t:settings.guide_title", "items": [ "$t:settings.guide_item_1", "$t:settings.guide_item_2" ] } }, "schema": { "display_mode": { "type": "enum", "options": ["popup", "layer"], "default": "layer", "label": "$t:settings.display_mode.label", "hint": "$t:settings.display_mode.hint" }, "popup_width": { "type": "integer", "default": 500, "label": "$t:settings.popup_width.label", "hint": "$t:settings.popup_width.hint" } } } ``` ### 레이아웃 필드 설명 | 필드 | 타입 | 설명 | |------|------|------| | `version` | `string` | 레이아웃 버전 | | `meta.title` | `string` | 페이지 제목 (다국어 바인딩 지원) | | `meta.description` | `string` | 페이지 설명 | | `pageConfig.notice` | `string` | 상단 알림 메시지 | | `pageConfig.guide` | `object` | 가이드 박스 설정 | | `schema` | `object` | 설정 필드 스키마 (getSettingsSchema()와 동일 구조) | ### 다국어 바인딩 레이아웃에서 `$t:` 접두사를 사용하여 플러그인 다국어 파일을 참조합니다: ```json { "label": "$t:settings.display_mode.label" } ``` 다국어 파일 (`resources/lang/ko.json`): ```json { "settings": { "page_title": "Daum 우편번호 설정", "display_mode": { "label": "표시 방식", "hint": "주소 검색 창을 표시하는 방식을 선택합니다." } } } ``` ### 커스텀 레이아웃 (slots 사용) 기본 UI 대신 직접 UI를 구성하려면 `slots.content`를 정의합니다: ```text 주의: slots 사용 시 Form 자동 바인딩 규칙 필수: dataKey와 trackChanges를 컨테이너에 설정 필수: Input/Select에 name prop 추가 (자동 바인딩 활성화) 주의: 수동 value 바인딩과 onChange 액션은 자동 바인딩 시 불필요 ``` **자동 바인딩 패턴 (권장)**: ```json { "slots": { "content": [ { "id": "settings_container", "type": "basic", "name": "Div", "dataKey": "form", "trackChanges": true, "children": [ { "type": "basic", "name": "Input", "props": { "name": "popup_width", "type": "number" } }, { "type": "basic", "name": "Select", "props": { "name": "display_mode" }, "children": [ { "type": "basic", "name": "Option", "props": { "value": "popup" }, "text": "popup" }, { "type": "basic", "name": "Option", "props": { "value": "layer" }, "text": "layer" } ] } ] } ] } } ``` **자동 바인딩 동작 원리**: 1. `dataKey: "form"` → 모든 입력이 `_local.form.{name}`에 저장 2. `trackChanges: true` → 값 변경 시 `_local.hasChanges = true` 자동 설정 3. `name: "popup_width"` → `_local.form.popup_width`에 자동 바인딩 4. 저장 버튼의 `disabled: "{{!_local.hasChanges}}"` → 변경 시 활성화 **수동 바인딩 패턴 (사용 금지)**: ```json { "type": "basic", "name": "Input", "props": { "type": "number", "value": "{{_local.form?.popup_width ?? 500}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "form": "{{Object.assign({}, _local.form, { popup_width: parseInt($event.target.value) })}}" } } ] } ``` **수동 바인딩의 문제점**: - `trackChanges`가 작동하지 않음 (저장 버튼이 활성화되지 않음) - 코드가 장황하고 유지보수 어려움 - 실수로 hasChanges 업데이트를 빠뜨릴 수 있음 ### 전체 예시: Daum 우편번호 플러그인 **파일 구조:** ```text plugins/sirsoft-daum_postcode/ ├── plugin.php ├── composer.json └── resources/ ├── lang/ │ ├── ko.json │ └── en.json └── layouts/ └── admin/ └── plugin_settings.json ``` **plugin_settings.json:** ```json { "version": "1.0.0", "meta": { "title": "$t:settings.page_title", "description": "$t:settings.page_description" }, "pageConfig": { "notice": "$t:settings.notice", "guide": { "title": "$t:settings.guide_title", "items": [ "$t:settings.guide_item_1", "$t:settings.guide_item_2", "$t:settings.guide_item_3" ] } }, "schema": { "display_mode": { "type": "enum", "options": ["popup", "layer"], "default": "layer", "label": "$t:settings.display_mode.label", "hint": "$t:settings.display_mode.hint", "required": false }, "popup_width": { "type": "integer", "default": 500, "label": "$t:settings.popup_width.label", "hint": "$t:settings.popup_width.hint", "required": false }, "popup_height": { "type": "integer", "default": 600, "label": "$t:settings.popup_height.label", "hint": "$t:settings.popup_height.hint", "required": false }, "theme_color": { "type": "string", "default": "#1976D2", "label": "$t:settings.theme_color.label", "hint": "$t:settings.theme_color.hint", "required": false } } } ``` **ko.json:** ```json { "name": "Daum 우편번호", "description": "Daum 우편번호 서비스를 통해 주소 검색 기능을 제공합니다.", "settings": { "page_title": "Daum 우편번호 설정", "page_description": "주소 검색 팝업 표시 방식을 설정합니다.", "notice": "Daum 우편번호 서비스는 API 키 없이 무료로 사용할 수 있습니다.", "guide_title": "사용 안내", "guide_item_1": "표시 방식: 팝업 또는 레이어 중 선택할 수 있습니다.", "guide_item_2": "팝업 크기: 팝업 방식 선택 시 너비와 높이를 지정합니다.", "guide_item_3": "테마 색상: 우편번호 검색 UI의 강조 색상을 지정합니다.", "display_mode": { "label": "표시 방식", "hint": "주소 검색 창을 표시하는 방식을 선택합니다." }, "popup_width": { "label": "팝업 너비 (px)", "hint": "팝업 창의 너비를 픽셀 단위로 지정합니다." }, "popup_height": { "label": "팝업 높이 (px)", "hint": "팝업 창의 높이를 픽셀 단위로 지정합니다." }, "theme_color": { "label": "테마 색상", "hint": "우편번호 검색 UI의 강조 색상을 HEX 코드로 지정합니다." } } } ``` --- ## 플러그인 권한 구조 ### 모듈 vs 플러그인 권한 구조 비교 플러그인도 모듈과 동일한 **3레벨 계층적 권한 구조**(`플러그인 → 카테고리 → 개별 권한`)를 사용합니다. | 항목 | 모듈 | 플러그인 | | ---- | ---- | ---- | | 정의 구조 | `categories` → `permissions` | `categories` → `permissions` (동일) | | DB 저장 구조 | 모듈 노드 → 카테고리 → 개별 권한 | 플러그인 노드 → 카테고리 → 개별 권한 | | identifier 패턴 | `vendor-module.category.action` | `vendor-plugin.category.action` | | 등록 메서드 | `getPermissions()` | `getPermissions()` | ### 플러그인 권한 정의 방법 `AbstractPlugin::getPermissions()`를 오버라이드합니다. 모듈과 동일한 `categories` 구조를 사용합니다: ```php public function getPermissions(): array { return [ 'name' => ['ko' => '본인인증', 'en' => 'Identity Verification'], 'description' => ['ko' => '본인인증 권한', 'en' => 'Identity verification permissions'], 'categories' => [ [ 'identifier' => 'settings', 'name' => ['ko' => '설정 관리', 'en' => 'Settings Management'], 'description' => ['ko' => '본인인증 설정 관리', 'en' => 'Verification settings'], 'permissions' => [ [ 'action' => 'view', 'name' => ['ko' => '본인인증 설정 조회', 'en' => 'View Settings'], 'description' => ['ko' => '설정 조회 권한', 'en' => 'View settings'], 'type' => 'admin', // admin 또는 user (기본값: admin) 'roles' => ['admin'], // 기본 할당 역할 ], [ 'action' => 'update', 'name' => ['ko' => '본인인증 설정 수정', 'en' => 'Update Settings'], 'description' => ['ko' => '설정 수정 권한', 'en' => 'Update settings'], 'roles' => ['admin'], ], ], ], ], ]; } ``` ### 계층 구조 동작 `PluginManager`는 설치 시 3레벨 계층 구조를 자동 생성합니다: 1. **1레벨 (플러그인 노드)**: 플러그인 식별자로 루트 노드 생성, 이름은 `name` 필드 또는 `plugin.json`의 `name` 사용 2. **2레벨 (카테고리 노드)**: `categories` 각 항목을 플러그인 노드의 자식으로 생성 3. **3레벨 (개별 권한)**: 각 카테고리의 `permissions` 항목을 카테고리 노드의 자식으로 생성 4. **리프 노드만 역할에 할당 가능** (그룹/카테고리 노드는 UI 그룹화 전용) 5. `categories`가 없는 플러그인은 권한 노드를 생성하지 않음 권한 설정 UI에서 모듈과 동일하게 "플러그인명 → 카테고리명 → 개별 권한" 구조로 표시됩니다. > **참고**: 모듈 권한 구조 상세는 [permissions.md](permissions.md)를 참조하세요. --- ## 플러그인 원칙 ### 플러그인 격리 원칙 ```text 필수: 플러그인은 모듈에만 의존 (플러그인 간 직접 의존 금지) ✅ 필수: 다른 플러그인과는 API로만 상호작용 ``` ### 5대 원칙 #### 1. 격리성 (Isolation) 플러그인 간 직접 의존 금지 ```php // ❌ DON'T: 다른 플러그인 직접 참조 use Plugins\Other\Analytics\Service\AnalyticsService; // ✅ DO: API를 통한 통신 $response = Http::get('/api/analytics/data'); ``` #### 2. 의존성 (Dependency) 모듈에만 의존 가능 ```php public function getDependencies(): array { return [ // ✅ 모듈 의존성만 허용 'sirsoft-ecommerce' => '>=1.0.0', // ❌ 다른 플러그인 의존성 금지 // 'other-payment' => '>=1.0.0', ]; } ``` #### 3. API 통신 (API Communication) 다른 플러그인과는 API로만 상호작용 ```php // 플러그인 간 통신은 HTTP API 사용 $response = Http::withToken($token) ->get('/api/plugins/analytics/report'); ``` #### 4. Lazy Loading 필요 시에만 로딩 ```php // PluginManager가 플러그인을 필요할 때만 로딩 // 활성화된 플러그인만 메모리에 로드 ``` #### 5. 캐싱 (Caching) 플러그인 목록 캐싱으로 성능 최적화 ```php // PluginManager가 플러그인 목록을 캐싱 // 변경 시에만 캐시 갱신 ``` #### 6. 레이아웃 소유와 라우트 네임스페이스 (Layout Ownership) 플러그인도 자기 화면(완전한 레이아웃)을 가질 수 있습니다. 다만 그 화면의 라우트는 플러그인 네임스페이스 안에 선언해야 합니다. ```text 필수: 플러그인이 소유하는 화면의 라우트는 plugins/{identifier}/ 아래에 선언 예) "*/admin/plugins/sirsoft-gdpr/consent-log" 필수: 다른 확장이 소유한 화면에 UI 를 끼워 넣을 때는 layout_extensions(확장 지점/Overlay) 사용 ``` 라우트 네임스페이스를 지키지 않으면, 같은 경로를 소유한 모듈/템플릿과 어느 쪽이 이기는지가 설치 순서에 좌우됩니다. 화면이 조용히 다른 것으로 바뀌고, 어느 확장 때문인지 화면만 봐서는 알 수 없습니다. 정적 검사가 이 위반을 차단합니다. | 방식 | 모듈 | 플러그인 | |------|------|---------| | 자기 네임스페이스 화면 (`plugins/{id}/...`) | 해당 없음 | 가능 | | 임의 경로 페이지 레이아웃 (`admin/users` 등) | 가능 | 금지 (경로 침범) | | admin/plugin_settings.json (환경설정) | 가능 | 가능 | | layout_extensions (확장 지점) | 가능 | 가능 | --- ## 플러그인 Artisan 커맨드 플러그인 관리는 모듈과 동일한 패턴의 Artisan 커맨드로 수행합니다. ### 주요 커맨드 | 커맨드 | 설명 | |--------|------| | `php artisan plugin:list` | 플러그인 목록 조회 | | `php artisan plugin:install [identifier]` | 플러그인 설치 (Composer 의존성 자동 설치 포함) | | `php artisan plugin:activate [identifier]` | 플러그인 활성화 | | `php artisan plugin:deactivate [identifier]` | 플러그인 비활성화 | | `php artisan plugin:uninstall [identifier]` | 플러그인 삭제 (--delete-data 시 vendor/ 삭제 포함) | | `php artisan plugin:composer-install [identifier]` | 플러그인 Composer 의존성 설치 | | `php artisan plugin:build [identifier]` | 플러그인 에셋 빌드 | | `php artisan plugin:refresh-layout [identifier?]` | 플러그인 레이아웃 갱신 | | `php artisan plugin:cache-clear [identifier?]` | 플러그인 캐시 삭제 | | `php artisan plugin:seed [identifier]` | 플러그인 시더 실행 | ### 플러그인 에셋 빌드 ```bash php artisan plugin:build [identifier] ``` **옵션**: - `--all`: 모든 플러그인의 에셋 빌드 - `--watch`: 파일 변경 감시 모드 (개발용) - `--production`: 프로덕션 최적화 빌드 **수행 작업**: - npm 의존성 설치 (node_modules 없는 경우) - Vite를 통한 IIFE 번들 빌드 - `dist/js/plugin.iife.js` 및 `dist/css/plugin.css` 생성 - 빌드 결과 파일 크기 출력 - **extension_cache_version 증가 (프론트엔드 캐시 무효화)** - 주의: `--watch` 모드에서는 캐시 버전이 증가하지 않음 ### 플러그인 시더 실행 ```bash php artisan plugin:seed [identifier] ``` **옵션**: - `--class=SeederClass`: 특정 시더 클래스만 실행 - `--sample`: 샘플 데이터 시더도 함께 실행 (기본: 설치 시더만) - `--count=key=value`: 시더에 카운트 옵션 전달 (반복 가능) - `--force`: 프로덕션 환경에서 강제 실행 **예시**: ```bash # 단일 플러그인 시더 실행 (설치 시더만) php artisan plugin:seed sirsoft-payment # 설치 + 샘플 시더 실행 php artisan plugin:seed sirsoft-payment --sample # 모든 활성 플러그인 시더 실행 php artisan plugin:seed # 특정 시더 클래스 실행 php artisan plugin:seed sirsoft-payment --class=PaymentMethodSeeder # 프로덕션 환경에서 강제 실행 php artisan plugin:seed sirsoft-payment --force ``` **수행 작업**: - 활성화된 플러그인만 실행 가능 (비활성 플러그인은 에러) - `plugins/{identifier}/database/seeders/DatabaseSeeder.php` 실행 - 기본 실행 시 설치 필수 시더만 실행, `--sample` 옵션 시 Sample/ 하위 시더도 실행 - `--class` 옵션 사용 시 해당 시더만 실행 **주의사항**: - 활성화되지 않은 플러그인은 시더 실행 불가 - 프로덕션 환경에서는 `--force` 옵션 필수 - 샘플 시더는 `database/seeders/Sample/` 하위 디렉토리에 위치 ### 캐시 무효화 플러그인 활성화/비활성화/삭제 시 다음 캐시가 자동으로 무효화됩니다: ```text ✅ 템플릿 언어 캐시 (template.language.*) ✅ 템플릿 routes 캐시 (template.routes.*) ✅ extension_cache_version 증가 (프론트엔드 캐시 무효화) ✅ 플러그인 상태 캐시 무효화 ``` 이를 통해 플러그인 상태 변경 시 프론트엔드가 즉시 새로운 데이터를 받아올 수 있습니다. --- ## 번들 디렉토리 작업 규칙 ```text 필수: 플러그인 수정/개발은 _bundled 디렉토리에서만 작업 (활성 디렉토리 직접 수정 금지) 필수: _bundled 작업 완료 후 반영/검증은 업데이트 프로세스 사용 ``` ### 개발 워크플로우 ```text 1. plugins/_bundled/{identifier}/ 에서 코드 수정 2. plugin.json 버전 올리기 3. php artisan plugin:update {identifier} 로 활성 디렉토리에 반영 4. 테스트 실행으로 검증 ``` ### 왜 활성 디렉토리 직접 수정이 금지되는가? - 활성 디렉토리는 `.gitignore` 대상 → Git에 변경 기록 불가 - 다음 업데이트 시 `_bundled` 소스로 덮어쓰기 → 직접 수정 사항 유실 - 업데이트 프로세스 미수행 시 마이그레이션/레이아웃 갱신 누락 ### 예외: 초기 개발 (아직 _bundled에 미등록) ```text ✅ 허용: 신규 플러그인 초기 개발 시 활성 디렉토리에서 직접 작업 전환점: _bundled에 최초 반영한 이후부터는 반드시 _bundled에서만 작업 ``` > 상세: [extension-update-system.md](./extension-update-system.md) "번들 디렉토리 개발 워크플로우" 참조 --- ## 코드 변경 시 버전/업그레이드 필수 ```text 필수: 플러그인 코드를 변경한 경우 버전을 올리고 필요 시 업그레이드 스텝을 작성해야 합니다. 버전 변경 없이 _bundled에 반영하면, 이미 설치된 환경에서 업데이트가 감지되지 않습니다. ``` ### 필수 작업 1. **`plugin.json` 버전 올리기**: `version` 필드를 Semantic Versioning에 따라 증가 2. **`_bundled` 동기화**: `plugins/_bundled/{identifier}/` 디렉토리에 변경 사항 반영 3. **업그레이드 스텝 작성** (조건부): DB 스키마/환경설정 구조/데이터 마이그레이션이 필요한 경우 ### 업그레이드 스텝 작성 기준 | 변경 유형 | 업그레이드 스텝 필요 | 비고 | |----------|-------------------|------| | DB 스키마 변경 | ✅ (+ 마이그레이션) | 컬럼/테이블 구조 변경 | | 환경설정 구조 변경 | ✅ (SettingsMigrator) | 설정 키 이름/구조 변경 | | 기존 데이터 변환 | ✅ | 데이터 형식 변환, 기본값 | | 레이아웃 JSON 변경 | ❌ (자동 갱신) | refresh-layout에서 처리 | | PHP 코드만 변경 | ❌ | 버전만 올리면 됨 | > 상세: [extension-update-system.md](extension-update-system.md) "개발자 버전 업데이트 가이드" 참조 --- ## SEO 변수 선언 (seoVariables) 플러그인이 SEO 메타 데이터(제목/설명)에 동적 변수를 제공하려면 `seoVariables()` 메서드를 오버라이드합니다. 모듈과 동일한 API입니다. ### 오버라이드 시점 - 플러그인이 SEO 대상 페이지(예: 결제 완료 페이지)를 layout_extensions로 확장하는 경우 - 플러그인 설정의 SEO 템플릿에서 `{key}` 변수 치환이 필요한 경우 ### 구조 ```php public function seoVariables(): array { return [ '_common' => [ 'site_name' => ['source' => 'core_setting', 'key' => 'general.site_name'], ], 'checkout' => [ 'payment_name' => ['source' => 'setting', 'key' => 'basic.payment_name'], ], ]; } ``` ### 소스 타입 | source | 설명 | 자동 해석 | |--------|------|----------| | `setting` | 해당 플러그인의 설정 값 | ✅ | | `core_setting` | 코어 설정 값 | ✅ | | `query` | URL 쿼리 파라미터 | ✅ | | `route` | URL 라우트 파라미터 | ✅ | | `data` | 데이터소스 응답 데이터 (레이아웃 `vars`에서 매핑 필요) | ❌ | ### 레이아웃에서 사용 레이아웃 JSON의 `meta.seo.extensions`에 플러그인을 선언합니다. ```json { "meta": { "seo": { "extensions": [{ "type": "plugin", "id": "sirsoft-payment" }], "page_type": "checkout" } } } ``` - `_common` 키: 모든 page_type에 공통 적용 (page_type별 선언이 우선) - 설치 시 `ValidatesSeoVariables` 트레이트가 변수명 고유성 검증 - `data` 소스 변수만 `vars`에서 표현식 매핑 필요, 나머지 소스는 자동 해석 > 상세: [seo-system.md](../backend/seo-system.md) "SEO 변수 시스템" 참조 --- ## 폼 상태 정합성 — 이중 저장소 전제와 자동 동기화 (engine-v1.43.0+) 플러그인이 폼(Form) 내부에서 `G7Core.state.setLocal({render: false})` 패턴을 사용하는 경우, 엔진의 **이중 저장소 구조**를 이해하고 있어야 한다. 이 구조는 "단일화 실패 이력"의 결과이며 엔진 설계 전제이다. ### 이중 저장소 구조 개요 엔진은 폼 데이터를 두 저장소에 분리 관리한다: | 저장소 | 실체 | 쓰는 곳 | 읽는 곳 | | ------ | ---- | ------- | ------- | | **A** | React `localDynamicState` (useState) | Form 자동바인딩 (Input/Textarea onChange) | DOM value, 부분 리렌더 | | **B** | `globalState._local` (TemplateApp 싱글톤) | `G7Core.state.setLocal/getLocal` | apiCall body 바인딩, 플러그인 동기화 | 두 저장소는 engine-v1.43.0부터 엔진이 자동으로 동기화한다. 일반 플러그인은 이 구조를 의식할 필요가 없다. ### 왜 저장소를 하나로 통합하지 않는가 B로 단일화하면 매 keystroke마다 TemplateApp 전체 리렌더가 발생해 대형 폼에서 타이핑 지연이 생긴다. 이를 완화할 구독 기반 선택적 리렌더(`StateSubscriptionManager`)는 2026-01에 필터 체크박스 253~295ms 지연으로 롤백된 실패 경로다. 이중 저장소 전제는 엔진 설계 결과이지 개선 대상이 아니다. ### 엔진 자동 동기화 메커니즘 1. **A→B 방향**: 자동바인딩의 `performStateUpdate`가 A에 쓸 때 B에도 `setLocal({render: false})`로 동기 기록 2. **B→A 방향**: 엔진이 자동바인딩 활성 경로를 `__g7AutoBindingPaths: Map`에 추적. 플러그인이 `setLocal({render: false})`로 그 경로를 건드리면 엔진이 **자동으로 `render: true`로 승격**하여 A 동기화 유도 3. **예외**: `selfManaged: true`를 명시한 호출은 자동 승격에서 제외 (CKEditor5 등 자체 DOM 관리 플러그인용) ### `G7Core.state.setLocal` 옵션 레퍼런스 ```typescript G7Core.state.setLocal(updates, options?: { scope?: 'current' | 'parent' | 'root'; // 스코프 지정 merge?: 'replace' | 'shallow' | 'deep'; // 병합 방식 (기본 'deep') debounce?: number; // 디바운스 ms debounceKey?: string; // 디바운스 키 render?: boolean; // React 리렌더 여부 (기본 true) selfManaged?: boolean; // 자동 승격 opt-out (기본 undefined/false) }); ``` ### `selfManaged: true` 옵션 사용 규칙 (중요) **역할**: 엔진의 자동 리렌더 승격 보호를 끄는 opt-out 스위치. "이 플러그인은 React 없이 자기가 DOM을 직접 관리하니 엔진은 자동 승격 오지랖을 끄고 원래 `render: false`를 유지해 달라"는 의도적 선언. **기본값**: `undefined` (사실상 `false`). **옵션을 생략하면 엔진이 자동 승격 보호를 적용**한다. **언제 `true`로 설정하는가**: - 플러그인이 React 컴포넌트가 아니라 JavaScript로 직접 DOM 요소를 조작하는 독립 위젯을 쓸 때 (CKEditor5, Monaco Editor 등) - 매 keystroke마다 전체 트리 리렌더가 성능상 허용 안 되는 경우 - 플러그인이 자체 `setData()`/`setValue()` 방식으로 UI를 직접 갱신하고 React 재렌더에 의존하지 않는 구조 **언제 생략하는가 (기본)**: - 일반적인 모든 플러그인. 엔진이 자동으로 정합성 확보 - **초보자는 `selfManaged`를 몰라도 되고 모르는 편이 안전** (safe-by-default) ### 플러그인 개발 체크리스트 새 플러그인이 폼 내부에서 `setLocal`을 쓰는 경우 다음을 확인: - [ ] `setLocal({render: false})`를 호출하는가? - [ ] 해당 경로의 React 컴포넌트(자동바인딩 대상 Input/Textarea 등)가 폼 안에 존재하는가? - [ ] 플러그인이 React 밖에서 자체 DOM을 직접 관리하는가? - [ ] 위 세 질문 모두 YES → `selfManaged: true` 명시 (엔진 승격 예외, 성능 보존) - [ ] 위 중 하나라도 NO/모르겠음 → **옵션 생략** (엔진이 자동 승격으로 정합성 확보 — 안전한 기본값) - [ ] `merge: 'replace'` 사용 시 자동바인딩 값 유실 가능성 있음 → 필요 시 `getLocal()` 후 병합하여 `merge: 'deep'` 사용 - [ ] `requires.g7_version`을 engine-v1.43.0 이상 내포 코어 버전으로 상향 ### 예시 **일반 플러그인 (대다수)**: ```typescript G7Core.state.setLocal({ "form.category": "news" }); // render 옵션 생략 → 엔진이 A↔B 동기화 자동 처리 ``` **WYSIWYG 에디터 등 자체 DOM 관리 플러그인**: ```typescript G7Core.state.setLocal( { "form.content": editorInstance.getData() }, { debounce: 300, debounceKey: `editor-sync-${name}`, render: false, selfManaged: true, // ← 자체 DOM 관리 명시. 자동 승격 제외 } ); ``` ### 절대 금지 사항 - `parentFormContext.setState`를 커스텀 핸들러에서 직접 호출하는 경로 우회 금지 — 엔진이 자동바인딩으로 호출하는 내부 API - 자동 승격 무력화를 위해 `selfManaged: true`를 남용 금지 — 의도적으로 자체 DOM 관리하는 경우에만 사용 > 상세 배경: [`docs/frontend/state-management.md`](../frontend/state-management.md) "이중 저장소 구조" 섹션 참조 --- ## 관련 문서 - [index.md](index.md) - 확장 시스템 전체 개요 - [extension-manager.md](extension-manager.md) - Composer 오토로드 관리 - [hooks.md](hooks.md) - 훅 시스템 및 리스너 구현 - [활동 로그 시스템](../backend/activity-log.md) - 활동 로그 Listener, Per-Item Bulk 로깅 규칙 - [module-basics.md](module-basics.md) - 모듈 개발 기초 (비교용) - [extension-update-system.md](extension-update-system.md) - 확장 업데이트 시스템