Files
Gnuboard7/docs/extension/plugin-development.md
T
HeuJung b10e89bef0 fix(core,board,ecommerce,page,gdpr,ckeditor5,kginicis,admin_basic,basic): 목록 조회·검증 계층 정비 및 감사 지적 전건 처리
목록 조회 성능 축(깊은 OFFSET·정렬·색인)과 그 검증 계층에서 계획서 전수검수와 3회에 걸친
감사가 지적한 항목을 처리했다. 세부 경위는 의 각 회차 문서에 있다.

검증 계층 — 컨트롤러가 base Request 를 직접 주입받던 확장 13곳을 전용 FormRequest 로 옮겼다.
옮기면서 기존 동작 계약은 그대로 두었다: 상한 초과 limit 을 거부하지 않고 상한까지 반환하던
공개 API, per_page 를 범위로 조정하던 관리자 목록, 미지원 period 를 year 로 해석하던 인기글
목록 모두 종전과 같은 응답을 낸다. 상한/폴백을 rules 로 승격시키면 200 이던 응답이 422 가
되어 기존 링크가 깨지므로, 규칙은 타입만 닫고 클램프·폴백은 접근자가 맡는다. period 는
접근자가 닫힌 집합만 반환해 캐시 키 공간도 함께 닫힌다.

ckeditor5 이미지 업로드만 ResponseHelper 봉투를 쓰지 않는다. 응답을 파싱하는 주체가 CDN 으로
로드되는 상위 CKEditor5 43.3.1 의 SimpleUploadAdapter 라 규약을 바꿀 수 없어, 각 응답 지점에
사유를 명시한 면제를 부착하고 근거를 API 문서에 남겼다.

하네스 — 룰 5개의 대상 경로 패턴이 매처와 맞지 않아 번들 확장 컨트롤러가 검사 대상에서
통째로 빠져 있었다. 패턴을 고치자 확장 위반 17건이 드러나 전건 처리했다. severity 오타가
요약 집계 양쪽에 안 잡혀 "0 error" 로 보고되던 문제도 런너 사전 검증으로 막았다.

정렬 게이트 대조 하네스는 관계 정렬 변형만 쓰는 저장소를 탐지하지 못한 채 통과시키고 있었다.
탐지·제외·인자 파싱을 함께 고치고 단위 테스트를 신설했다.
2026-08-02 15:29:29 +09:00

1392 lines
49 KiB
Markdown

# 플러그인 개발 가이드
> **관련 문서**: [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
<?php
namespace Plugins\Sirsoft\Payment;
use App\Contracts\Extension\PluginInterface;
class Plugin implements PluginInterface
{
/**
* 플러그인 고유 식별자 반환 (vendor-plugin 형식)
*/
public function getIdentifier(): string
{
return 'sirsoft-payment';
}
/**
* 벤더/개발자명 반환
*/
public function getVendor(): string
{
return 'sirsoft';
}
/**
* 플러그인명 반환 (표시용)
*/
public function getName(): string
{
return 'Payment';
}
/**
* 플러그인 버전 반환
*/
public function getVersion(): string
{
return '1.0.0';
}
/**
* 플러그인 설명 반환
*/
public function getDescription(): string
{
return '결제 게이트웨이 플러그인';
}
/**
* 플러그인 설치
*/
public function install(): bool
{
// 설치 로직
return true;
}
/**
* 플러그인 제거
*/
public function uninstall(): bool
{
// 제거 로직
return true;
}
/**
* 플러그인 활성화
*/
public function activate(): bool
{
return true;
}
/**
* 플러그인 비활성화
*/
public function deactivate(): bool
{
return true;
}
/**
* 플러그인이 의존하는 모듈/플러그인
*/
public function getDependencies(): array
{
return [
'sirsoft-ecommerce' => '>=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
<?php
namespace Plugins\Sirsoft\DaumPostcode;
use App\Extension\AbstractPlugin;
class Plugin extends AbstractPlugin
{
// getName(), getVersion(), getDescription()은 plugin.json에서 자동 파싱
// 별도 오버라이드 불필요
/**
* 플러그인 설정 스키마 반환
*
* 관리자 설정 페이지 UI를 동적으로 생성하는 데 사용됩니다.
*/
public function getSettingsSchema(): array
{
return [
'display_mode' => [
'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<string, number>`에 추적. 플러그인이 `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) - 확장 업데이트 시스템