Skip to content

Latest commit

 

History

History
1382 lines (1091 loc) · 48 KB

File metadata and controls

1382 lines (1091 loc) · 48 KB

플러그인 개발 가이드

관련 문서: index.md | extension-manager.md | hooks.md


TL;DR (5초 요약)

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 구현
  3. composer.json 작성법
  4. 플러그인 디렉토리 구조
  5. 플러그인 설정 시스템
  6. 설정 레이아웃 파일 시스템
  7. defaults.json과 frontend_schema
  8. 플러그인 원칙

플러그인 네이밍 규칙

디렉토리명

vendor-plugin 형식 (GitHub 스타일)

  • 소문자 사용
  • 하이픈(-): vendor와 plugin 구분
  • 언더스코어(_): 플러그인명 내 단어 구분 (선택적)
  • 예: sirsoft-payment, sirsoft-daum_postcode, johndoe-analytics

네임스페이스

Plugins\Vendor\Plugin\ 형식

  • PascalCase 사용
  • 백슬래시() 구분
  • 예: Plugins\Sirsoft\Payment\, Plugins\Sirsoft\DaumPostcode\

디렉토리명 → 네임스페이스 변환 규칙

필수: 변환 규칙 이해

하이픈(-) → 네임스페이스 구분자(\)
언더스코어(_) → 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를 참조하세요.


PluginInterface 구현

기본 구조

중요: plugin.php는 루트 레벨에 위치 (src/ 내부 아님)

<?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.jsonvendor 필드를 우선 사용. 값이 없으면 디렉토리명의 첫 단어로 폴백 (예: sirsoft)

기본값 제공 메서드 (필요시 오버라이드)

메서드 기본값 설명
install() true 설치 로직
uninstall() true 제거 로직
activate() true 활성화 로직
deactivate() true 비활성화 로직
getDynamicTables() [] 런타임 동적 생성 테이블 목록 (언인스톨 시 Manager가 삭제)
getDynamicPermissionIdentifiers() [] 런타임 생성 권한 식별자 — stale cleanup 보존 대상
getDynamicRoleIdentifiers() [] 런타임 생성 역할 식별자 — stale cleanup 보존 대상
getDependencies() [] 의존하는 모듈/플러그인 목록
getHookListeners() [] 훅 리스너 클래스 목록
upgrades() [] 업그레이드 스텝 (upgrades/ 디렉토리 자동 발견). g7_version >= 7.0.0-beta.5 인 플러그인은 신규 step 이 AbstractUpgradeStep 상속 의무 (upgrade-step-guide §13) — 미상속 시 PluginManager::runUpgradeStepsRuntimeException throw

동적 식별자 보존 규칙: Permission::updateOrCreate() / Role::firstOrCreate() 등으로 런타임에 생성한 엔티티는 업데이트 시 cleanupStalePluginEntries 에 의해 "정적 정의에 없는 고아 레코드" 로 판정되어 삭제될 위험이 있습니다. 이를 방지하려면 동적 식별자 목록을 위 3개 훅에서 반환하세요 — 정적 정의 + 동적 식별자가 병합된 expected 목록을 기준으로 판정되어 보존됩니다. 상세는 extension-update-system.md 참조.


composer.json 작성법

중요: autoload에 ["src/", "./"] 포함 (루트의 plugin.php 로딩을 위해)

{
    "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 설정 상세

"autoload": {
    "psr-4": {
        "Plugins\\Sirsoft\\Payment\\": ["src/", "./"]
    }
}
  • "src/": src 디렉토리 내 클래스 로딩
  • "./": 루트의 plugin.php 로딩

플러그인 디렉토리 구조

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 참조
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 자동 바인딩과 다국어 로드를 직접 구현해야 하며, 글로벌 컨테이너 바인딩 누수 결함의 위험이 발생한다.

표준 구조

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, ...) 는 코어/타 확장 도메인을 누수시킨다. audit 룰 extension-no-global-cache-rebind 가 자동 차단한다.
  • 인라인 contextual binding 복제 금지$this->app->when([X::class])->needs(StorageInterface::class)->give(...) 패턴을 직접 작성하지 말고 $storageServices 배열에 등록한다.
  • Listener 의 동적 인스턴스 생성에서 app(CacheInterface::class) 직접 호출 금지app()->makeWith(MyService::class, [...]) 로 컨테이너 해석에 위임해야 contextual binding 이 적용된다.

상세: cache-driver.md ## 확장에서 cache 바인딩


플러그인 설정 시스템

플러그인이 관리자 설정 UI를 제공하려면 AbstractPlugin을 상속하고 설정 관련 메서드를 구현합니다.

AbstractPlugin 상속 (권장)

참고: getName(), getVersion(), getDescription()plugin.json에서 자동 파싱되므로 오버라이드가 불필요합니다.

<?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_schemawindow.G7Config.plugins에 노출할 설정을 제어할 수 있습니다.

파일 경로: plugins/{identifier}/config/settings/defaults.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.jsonfrontend_schema 있음 스키마 기반 필터링 (expose: true만 G7Config에 노출)
defaults.json 없거나 frontend_schema 비어있음 기존 동작 유지 (sensitive: true만 제외)

카테고리 구조 설정에서의 필터링

카테고리 구조 설정을 사용하는 플러그인은 카테고리/필드 레벨로 제어할 수 있습니다:

{
  "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.jsondefaults 섹션 (1순위)
  2. getConfigValues() 반환값 (하위 호환성)
  3. 빈 값이면 초기화 스킵
하위 호환성: defaults.json이 없는 기존 플러그인은 기존 동작을 유지합니다.
✅ 권장: 새 플러그인은 defaults.json + frontend_schema를 사용하세요.

플러그인이 제공하는 훅 정의

외부 연동 플러그인은 다른 모듈/플러그인이 구독할 수 있는 훅을 정의할 수 있습니다:

/**
 * 플러그인이 제공하는 훅 정보 반환
 */
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 레이아웃 파일을 통해 정의합니다. AbstractPlugingetSettingsLayout() 메서드가 레이아웃 파일 경로를 반환하며, 파일이 존재하면 자동으로 설정 UI를 제공합니다.

핵심 원칙

필수: 설정 UI는 resources/layouts/settings.json으로 정의 (PHP 코드에 하드코딩 금지)
필수: resources/layouts/admin/plugin_settings.json 파일로 레이아웃 정의
✅ 필수: 템플릿 오버라이드 우선순위 준수

레이아웃 파일 경로

기본 경로: plugins/{identifier}/resources/layouts/admin/plugin_settings.json

// 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가 자동 생성됩니다:

{
  "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: 접두사를 사용하여 플러그인 다국어 파일을 참조합니다:

{
  "label": "$t:settings.display_mode.label"
}

다국어 파일 (resources/lang/ko.json):

{
  "settings": {
    "page_title": "Daum 우편번호 설정",
    "display_mode": {
      "label": "표시 방식",
      "hint": "주소 검색 창을 표시하는 방식을 선택합니다."
    }
  }
}

커스텀 레이아웃 (slots 사용)

기본 UI 대신 직접 UI를 구성하려면 slots.content를 정의합니다:

주의: slots 사용 시 Form 자동 바인딩 규칙
필수: dataKey와 trackChanges를 컨테이너에 설정
필수: Input/Select에 name prop 추가 (자동 바인딩 활성화)
주의: 수동 value 바인딩과 onChange 액션은 자동 바인딩 시 불필요

자동 바인딩 패턴 (권장):

{
  "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}}" → 변경 시 활성화

수동 바인딩 패턴 (사용 금지):

{
  "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 우편번호 플러그인

파일 구조:

plugins/sirsoft-daum_postcode/
├── plugin.php
├── composer.json
└── resources/
    ├── lang/
    │   ├── ko.json
    │   └── en.json
    └── layouts/
        └── admin/
            └── plugin_settings.json

plugin_settings.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:

{
  "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레벨 계층적 권한 구조(플러그인 → 카테고리 → 개별 권한)를 사용합니다.

항목 모듈 플러그인
정의 구조 categoriespermissions categoriespermissions (동일)
DB 저장 구조 모듈 노드 → 카테고리 → 개별 권한 플러그인 노드 → 카테고리 → 개별 권한
identifier 패턴 vendor-module.category.action vendor-plugin.category.action
등록 메서드 getPermissions() getPermissions()

플러그인 권한 정의 방법

AbstractPlugin::getPermissions()를 오버라이드합니다. 모듈과 동일한 categories 구조를 사용합니다:

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.jsonname 사용
  2. 2레벨 (카테고리 노드): categories 각 항목을 플러그인 노드의 자식으로 생성
  3. 3레벨 (개별 권한): 각 카테고리의 permissions 항목을 카테고리 노드의 자식으로 생성
  4. 리프 노드만 역할에 할당 가능 (그룹/카테고리 노드는 UI 그룹화 전용)
  5. categories가 없는 플러그인은 권한 노드를 생성하지 않음

권한 설정 UI에서 모듈과 동일하게 "플러그인명 → 카테고리명 → 개별 권한" 구조로 표시됩니다.

참고: 모듈 권한 구조 상세는 permissions.md를 참조하세요.


플러그인 원칙

플러그인 격리 원칙

필수: 플러그인은 모듈에만 의존 (플러그인 간 직접 의존 금지)
✅ 필수: 다른 플러그인과는 API로만 상호작용

5대 원칙

1. 격리성 (Isolation)

플러그인 간 직접 의존 금지

// ❌ DON'T: 다른 플러그인 직접 참조
use Plugins\Other\Analytics\Service\AnalyticsService;

// ✅ DO: API를 통한 통신
$response = Http::get('/api/analytics/data');

2. 의존성 (Dependency)

모듈에만 의존 가능

public function getDependencies(): array
{
    return [
        // ✅ 모듈 의존성만 허용
        'sirsoft-ecommerce' => '>=1.0.0',

        // ❌ 다른 플러그인 의존성 금지
        // 'other-payment' => '>=1.0.0',
    ];
}

3. API 통신 (API Communication)

다른 플러그인과는 API로만 상호작용

// 플러그인 간 통신은 HTTP API 사용
$response = Http::withToken($token)
    ->get('/api/plugins/analytics/report');

4. Lazy Loading

필요 시에만 로딩

// PluginManager가 플러그인을 필요할 때만 로딩
// 활성화된 플러그인만 메모리에 로드

5. 캐싱 (Caching)

플러그인 목록 캐싱으로 성능 최적화

// PluginManager가 플러그인 목록을 캐싱
// 변경 시에만 캐시 갱신

6. 레이아웃 제한 (Layout Restriction)

플러그인은 모듈과 달리 완전한 레이아웃을 등록할 수 없습니다.

필수: 페이지 레이아웃은 모듈만 등록 가능 (플러그인은 layout_extensions만 사용)
허용: admin/plugin_settings.json (환경설정 UI) — 유일한 완전 레이아웃
✅ 필수: 설정 외 UI 확장은 layout_extensions(확장 지점/Overlay)로만 가능
방식 모듈 플러그인
임의 페이지 레이아웃 (admin/user) 가능 금지
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] 플러그인 시더 실행

플러그인 에셋 빌드

php artisan plugin:build [identifier]

옵션:

  • --all: 모든 플러그인의 에셋 빌드
  • --watch: 파일 변경 감시 모드 (개발용)
  • --production: 프로덕션 최적화 빌드

수행 작업:

  • npm 의존성 설치 (node_modules 없는 경우)
  • Vite를 통한 IIFE 번들 빌드
  • dist/js/plugin.iife.jsdist/css/plugin.css 생성
  • 빌드 결과 파일 크기 출력
  • extension_cache_version 증가 (프론트엔드 캐시 무효화)
    • 주의: --watch 모드에서는 캐시 버전이 증가하지 않음

플러그인 시더 실행

php artisan plugin:seed [identifier]

옵션:

  • --class=SeederClass: 특정 시더 클래스만 실행
  • --sample: 샘플 데이터 시더도 함께 실행 (기본: 설치 시더만)
  • --count=key=value: 시더에 카운트 옵션 전달 (반복 가능)
  • --force: 프로덕션 환경에서 강제 실행

예시:

# 단일 플러그인 시더 실행 (설치 시더만)
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/ 하위 디렉토리에 위치

캐시 무효화

플러그인 활성화/비활성화/삭제 시 다음 캐시가 자동으로 무효화됩니다:

✅ 템플릿 언어 캐시 (template.language.*)
✅ 템플릿 routes 캐시 (template.routes.*)
✅ extension_cache_version 증가 (프론트엔드 캐시 무효화)
✅ 플러그인 상태 캐시 무효화

이를 통해 플러그인 상태 변경 시 프론트엔드가 즉시 새로운 데이터를 받아올 수 있습니다.


번들 디렉토리 작업 규칙

필수: 플러그인 수정/개발은 _bundled 디렉토리에서만 작업 (활성 디렉토리 직접 수정 금지)
필수: _bundled 작업 완료 후 반영/검증은 업데이트 프로세스 사용

개발 워크플로우

1. plugins/_bundled/{identifier}/ 에서 코드 수정
2. plugin.json 버전 올리기
3. php artisan plugin:update {identifier} 로 활성 디렉토리에 반영
4. 테스트 실행으로 검증

왜 활성 디렉토리 직접 수정이 금지되는가?

  • 활성 디렉토리는 .gitignore 대상 → Git에 변경 기록 불가
  • 다음 업데이트 시 _bundled 소스로 덮어쓰기 → 직접 수정 사항 유실
  • 업데이트 프로세스 미수행 시 마이그레이션/레이아웃 갱신 누락

예외: 초기 개발 (아직 _bundled에 미등록)

✅ 허용: 신규 플러그인 초기 개발 시 활성 디렉토리에서 직접 작업
전환점: _bundled에 최초 반영한 이후부터는 반드시 _bundled에서만 작업

상세: extension-update-system.md "번들 디렉토리 개발 워크플로우" 참조


코드 변경 시 버전/업그레이드 필수

필수: 플러그인 코드를 변경한 경우 버전을 올리고 필요 시 업그레이드 스텝을 작성해야 합니다.
버전 변경 없이 _bundled에 반영하면, 이미 설치된 환경에서 업데이트가 감지되지 않습니다.

필수 작업

  1. plugin.json 버전 올리기: version 필드를 Semantic Versioning에 따라 증가
  2. _bundled 동기화: plugins/_bundled/{identifier}/ 디렉토리에 변경 사항 반영
  3. 업그레이드 스텝 작성 (조건부): DB 스키마/환경설정 구조/데이터 마이그레이션이 필요한 경우

업그레이드 스텝 작성 기준

변경 유형 업그레이드 스텝 필요 비고
DB 스키마 변경 ✅ (+ 마이그레이션) 컬럼/테이블 구조 변경
환경설정 구조 변경 ✅ (SettingsMigrator) 설정 키 이름/구조 변경
기존 데이터 변환 데이터 형식 변환, 기본값
레이아웃 JSON 변경 ❌ (자동 갱신) refresh-layout에서 처리
PHP 코드만 변경 버전만 올리면 됨

상세: extension-update-system.md "개발자 버전 업데이트 가이드" 참조


SEO 변수 선언 (seoVariables)

플러그인이 SEO 메타 데이터(제목/설명)에 동적 변수를 제공하려면 seoVariables() 메서드를 오버라이드합니다. 모듈과 동일한 API입니다.

오버라이드 시점

  • 플러그인이 SEO 대상 페이지(예: 결제 완료 페이지)를 layout_extensions로 확장하는 경우
  • 플러그인 설정의 SEO 템플릿에서 {key} 변수 치환이 필요한 경우

구조

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에 플러그인을 선언합니다.

{
    "meta": {
        "seo": {
            "extensions": [{ "type": "plugin", "id": "sirsoft-payment" }],
            "page_type": "checkout"
        }
    }
}
  • _common 키: 모든 page_type에 공통 적용 (page_type별 선언이 우선)
  • 설치 시 ValidatesSeoVariables 트레이트가 변수명 고유성 검증
  • data 소스 변수만 vars에서 표현식 매핑 필요, 나머지 소스는 자동 해석

상세: 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 옵션 레퍼런스

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 이상 내포 코어 버전으로 상향

예시

일반 플러그인 (대다수):

G7Core.state.setLocal({ "form.category": "news" });
// render 옵션 생략 → 엔진이 A↔B 동기화 자동 처리

WYSIWYG 에디터 등 자체 DOM 관리 플러그인:

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 "이중 저장소 구조" 섹션 참조


관련 문서