# ExtensionManager (확장 관리자)

> **소스**: extension-guide.md에서 분리
> **관련 문서**: [module-basics.md](module-basics.md), [plugin-development.md](plugin-development.md)

---

## TL;DR (5초 요약)

```text
1. composer.json 수정 없음 - 런타임 오토로드 방식 사용
2. 캐시 파일: bootstrap/cache/autoload-extensions.php (gitignore 대상)
3. 설치/삭제 시 자동 갱신: updateComposerAutoload() → generateAutoloadFile()
4. 수동 갱신: php artisan extension:update-autoload
5. _bundled/_pending 디렉토리 자동 제외 (str_starts_with($name, '_') → skip)
6. src_classmap: 확장 소스 FQCN→경로 사전 계산 → findFile 파일시스템 stat 제거 (지연 로드·vendor 격리 유지)
```

---

## 목차

1. [개요](#개요)
2. [런타임 오토로드 방식](#런타임-오토로드-방식)
3. [Composer 의존성 관리](#composer-의존성-관리)
4. [식별자 검증 규칙](#식별자-검증-규칙-validextensionidentifier)
5. [관리자 UI 필터링 (hidden 플래그)](#관리자-ui-필터링-hidden-플래그)
6. [Artisan 커맨드](#artisan-커맨드)
7. [서비스 등록](#서비스-등록)

---

## 개요

모듈과 플러그인에서 공통으로 사용되는 기능을 담당하는 관리자 클래스입니다. 주로 오토로드 설정 관리를 담당합니다.

**주요 역할**:

- 런타임 오토로드 캐시 파일 생성
- 모듈/플러그인 설치 시 PSR-4 네임스페이스 등록
- Composer ClassLoader에 동적 등록

---

## 런타임 오토로드 방식

### 핵심 원칙

```
중요: composer.json을 수정하지 않음
✅ 결정: 런타임 오토로드 방식 사용 (캐시 파일 기반)
✅ 장점: 버전 관리 충돌 없음, composer dump-autoload 불필요
```

### 동작 방식

```
모듈/플러그인 설치/삭제 시
    ↓
ExtensionManager::updateComposerAutoload() 호출
    ↓
bootstrap/cache/autoload-extensions.php 파일 생성 (gitignore 대상)
    ↓
public/index.php 또는 artisan 실행 시
    ↓
$loader->addPsr4()로 런타임에 네임스페이스 등록
```

### 캐시 파일 구조

```php
// bootstrap/cache/autoload-extensions.php

return [
    'psr4' => [
        "Modules\\Sirsoft\\Ecommerce\\" => "modules/sirsoft-ecommerce/src/",
        "Plugins\\Sirsoft\\Payment\\" => "plugins/sirsoft-payment/src/",
    ],
    'classmap' => [
        "modules/sirsoft-ecommerce/module.php",
        "plugins/sirsoft-payment/plugin.php",
    ],
    'src_classmap' => [
        "Modules\\Sirsoft\\Ecommerce\\Services\\OrderService" => "modules/sirsoft-ecommerce/src/Services/OrderService.php",
        "Plugins\\Sirsoft\\Payment\\Providers\\PaymentServiceProvider" => "plugins/sirsoft-payment/src/Providers/PaymentServiceProvider.php",
    ],
    'files' => [],
    'vendor_autoloads' => [
        "modules/sirsoft-ecommerce/vendor/autoload.php",
    ],
];
```

- `classmap`: `module.php` / `plugin.php` 등 부트스트랩 파일 목록 — 진입점에서 `require_once` (즉시 로드)
- `src_classmap`: 확장 **소스** 클래스의 `FQCN → 상대경로` 맵 — 진입점에서 `$loader->addClassMap()` (지연 로드)
- `vendor_autoloads`: 모듈/플러그인의 `vendor/autoload.php` 경로 목록 (Composer 의존성이 설치된 경우만 포함)

#### src_classmap — findFile 파일시스템 스캔 제거 (성능)

확장 소스 클래스는 `addPsr4()` 로만 등록되면 Composer `findFile()` 이 클래스마다 파일시스템을 스캔(`is_dir`/`file_exists` stat)한다. OPcache 가 cold 이거나 파일시스템이 느린 환경에서는 이 stat 비용이 요청 시간의 큰 비중을 차지할 수 있다. `src_classmap` 은 `generateAutoloadFile()` 이 각 PSR-4 디렉토리를 스캔해 `FQCN → 경로` 를 사전 계산하고, 진입점(`public/index.php` / `CoreServiceProvider` / `ExtensionManager::registerExtensionAutoload`)이 `ClassLoader::addClassMap()` 으로 등록한다. `findFile()` 은 classMap 을 **최우선 조회**하므로 확장 클래스는 파일시스템 접근 0 으로 즉시 경로를 얻는다.

- **지연 로드 유지**: classmap 은 경로만 제공하며 `include` 는 실제 사용 시점에. 매 요청 전량 로드가 아니다.
- **PSR-4 폴백 보존**: classmap 에 없는 클래스(신규 추가 후 미재생성 등)는 기존 PSR-4 경로로 폴백 — 안전망.
- **vendor 격리 불변**: `src_classmap` 은 확장 **소스**(`Modules\*`/`Plugins\*`)만 담는다. 확장별 독립 서드파티 vendor 는 각 확장 `vendor/autoload.php`(별도 ClassLoader) 가 담당하므로 여기 포함되지 않는다.
- **재생성**: `psr4` 와 동일 생명주기 — install / update / uninstall 시 `updateComposerAutoload()`, 코어 업데이트 시 `extension:update-autoload` 가 함께 재생성.

### 중요 규칙

```
설치된 모듈/플러그인만 캐시 파일에 반영됩니다
_bundled/_pending 디렉토리는 활성 확장으로 인식하지 않습니다
```

- 디렉토리에 파일이 있어도 **DB에 설치 기록이 없으면 반영되지 않음**
- identifier는 디렉토리명과 동일 (예: `sirsoft-sample`, `sirsoft-payment`)
- `_`로 시작하는 디렉토리(`_bundled`, `_pending`)는 디렉토리 스캔 시 자동 제외됨
- 캐시 파일에는 활성 디렉토리 경로만 포함 (`_bundled/` 경로 미포함)

### 수행 작업

1. `ModuleRepository`와 `PluginRepository`를 통해 설치된 확장 목록 조회
2. 각 모듈/플러그인의 `composer.json`에서 PSR-4 오토로드 정보 수집
3. `bootstrap/cache/autoload-extensions.php` 파일 생성
4. `module.php`, `plugin.php` 파일을 `classmap`에 추가

### 로딩 진입점

**public/index.php** 및 **artisan**에서 `ExtensionManager::registerExtensionAutoload($loader)` 호출:

```text
registerExtensionAutoload($loader)
    ↓ 1. PSR-4 네임스페이스 등록 ($loader->addPsr4())
    ↓ 2. Classmap 파일 로드 (module.php, plugin.php → require_once)
    ↓ 3. Files 로드 (헬퍼 함수 등 → require_once)
    ↓ 4. Vendor autoloads 로드 (모듈/플러그인의 vendor/autoload.php → require_once)
```

**로딩 순서가 중요합니다**:

| 순서 | 유형 | 설명 |
| ---------- | ---------- | ---------- |
| 1 | PSR-4 | 확장의 `src/` 디렉토리 네임스페이스를 Composer ClassLoader에 등록 |
| 2 | Classmap | `module.php`, `plugin.php` 엔트리 파일을 즉시 로드 |
| 3 | Files | 헬퍼 함수 등 자동 로드 파일 |
| 4 | Vendor autoloads | 각 확장의 `vendor/autoload.php`를 `require_once`하여 외부 패키지 로드 |

- 캐시 파일 미존재 시 아무 작업도 하지 않고 return (설치 전 상태)
- 경로가 존재하지 않는 항목은 자동 스킵 (`is_dir()` / `file_exists()` 체크)

---

## Composer 의존성 관리

모듈/플러그인이 외부 Composer 패키지(예: `stripe/stripe-php`)를 필요로 할 때, 각 확장의 `composer.json`에 `require`로 정의하고 확장별 독립 `vendor/` 디렉토리에 설치합니다.

### 핵심 원칙

```text
중요: 루트 composer.json에 모듈/플러그인 패키지 추가 금지
✅ 결정: 각 확장의 composer.json → 확장별 vendor/ 디렉토리에 독립 설치
✅ 장점: 확장 간 의존성 충돌 방지, 삭제 시 깔끔한 정리
```

### Vendor 설치 모드 (VendorMode)

공유 호스팅 등 Composer 실행 불가 환경을 지원하기 위해 **VendorResolver** 경유로 설치 모드를 결정합니다.

```text
VendorMode enum:
- auto: composer 가능 시 composer, 불가 시 bundled (기본값)
- composer: composer install 강제 (불가 시 예외)
- bundled: vendor-bundle.zip 추출 강제
```

설치/업데이트 시 `installModule()` / `installPlugin()` / `updateModule()` / `updatePlugin()` 의 `VendorMode $vendorMode` 파라미터로 전달되며, `VendorResolver::install()` 가 환경 감지 + DB 기록 이전 모드 상속 + 번들 zip 무결성 검증을 거쳐 적절한 전략으로 라우팅합니다. `modules.vendor_mode` / `plugins.vendor_mode` 컬럼에 최종 사용된 모드가 기록되어 업데이트 시 자동 상속됩니다.

> 상세: [vendor-bundle.md](vendor-bundle.md) — 번들 구조, 빌드/검증/설치 흐름, 무결성 검증

### 동작 방식

```text
모듈/플러그인 설치 시 (Phase 4.5)
    ↓
hasComposerDependencies() → 외부 패키지 존재 여부 확인
    ↓ (존재 시)
runComposerInstall() → 확장 디렉토리에서 composer install 실행
    ↓
modules/{name}/vendor/ 또는 plugins/{name}/vendor/ 생성
    ↓
updateComposerAutoload() → autoload-extensions.php에 vendor_autoloads 등록
    ↓
public/index.php / artisan → vendor/autoload.php를 require_once
```

### 주요 메서드

| 메서드 | 시그니처 | 설명 |
|--------|----------|------|
| `hasComposerDependencies` | `(string $type, string $dirName): bool` | 외부 패키지 의존성 존재 여부 (`php`, `ext-*` 제외) |
| `getComposerDependencies` | `(string $type, string $dirName): array` | 외부 패키지 목록 반환 |
| `runComposerInstall` | `(string $type, string $dirName, bool $noDev, ?Command $command): bool` | 확장 디렉토리에서 `composer install` 실행 |
| `detectDuplicatePackages` | `(): array` | 여러 확장에서 중복 사용되는 패키지 감지 |
| `isComposerUnchanged` | `(string $stagingPath, string $activePath): bool` | 스테이징/활성 디렉토리의 composer 변경 여부 확인 |

### Composer 의존성 변경 감지 (isComposerUnchanged)

업데이트 시 불필요한 `composer install`을 스킵하기 위해 `composer.json`과 `composer.lock`을 이중 해시 비교합니다:

```text
isComposerUnchanged(stagingPath, activePath)
    ↓ 활성 디렉토리 또는 vendor/ 미존재 → false (설치 필요)
    ↓ composer.json 한쪽 미존재 → false (변경으로 간주)
    ↓ md5_file(staging/composer.json) !== md5_file(active/composer.json) → false
    ↓ composer.lock 존재 여부 불일치 → false
    ↓ md5_file(staging/composer.lock) !== md5_file(active/composer.lock) → false
    ↓ 모두 일치 → true (스킵 가능)
```

- **이중 비교**: `composer.json`(의존성 선언) + `composer.lock`(실제 설치 버전) 모두 확인
- **false 반환 = 설치 필요**: 하나라도 변경되었으면 `composer install` 실행
- **true 반환 = 스킵 가능**: 기존 `vendor/` 디렉토리 재사용

### runComposerInstall 환경변수

웹 서버 환경에서 Composer 실행 시 시스템 환경변수가 부족하므로, `install-worker.php` 패턴과 동일하게 환경변수를 명시적으로 구성합니다.

| 환경변수 | 출처/설정값 | 용도 |
|----------|-----------|------|
| PATH, SystemRoot | `getenv()`으로 시스템에서 복사 | Composer 바이너리 탐색 |
| TEMP, TMP | 시스템에서 복사, 불가 시 `storage/temp` | 임시 파일 (Windows 특이) |
| APPDATA, LOCALAPPDATA, USERPROFILE | `getenv()`으로 시스템에서 복사 | Windows 사용자 경로 |
| COMPOSER_HOME | `storage/composer` | Composer 캐시 디렉토리 |
| HOME | `storage/composer` | Unix-style 홈 디렉토리 |

### 삭제 시 vendor 정리

`InspectsUninstallData` trait이 제공하는 메서드:

| 메서드 | 설명 |
|--------|------|
| `getVendorDirectoryInfo(string $type, string $dirName): ?array` | vendor/ 디렉토리 크기 정보 조회 (삭제 모달 표시용) |
| `deleteVendorDirectory(string $type, string $dirName): void` | vendor/ 디렉토리 및 composer.lock 삭제 |

- **삭제 시점**: `uninstallModule`/`uninstallPlugin`에서 `$deleteData === true`일 때 실행
- **실패 처리**: 삭제 실패 시 예외를 catch하고 로그만 남김 (프로세스 중단하지 않음)

---

## Artisan 커맨드

### extension:update-autoload

모듈과 플러그인의 오토로드 캐시 파일을 생성합니다.

```bash
php artisan extension:update-autoload
```

**사용 시점**:

- 모듈/플러그인 디렉토리를 수동으로 추가/삭제한 경우
- 오토로드 캐시 파일이 손상된 경우
- 개발 중 오토로드 문제 디버깅

**출력 예시**:

```bash
$ php artisan extension:update-autoload
확장 오토로드 파일을 생성합니다...
오토로드 파일이 성공적으로 생성되었습니다.
  → bootstrap/cache/autoload-extensions.php
```

### extension:composer-install

모든 모듈과 플러그인의 Composer 의존성을 일괄 설치합니다.

```bash
php artisan extension:composer-install
```

**옵션**:

- `--no-dev`: dev 의존성 제외

**사용 시점**:

- 프로젝트 초기 설정 시 모든 확장의 의존성을 한번에 설치
- 배포 환경 구성 시

**동작**:

- `module:composer-install --all`과 `plugin:composer-install --all`을 순차 호출

---

## 식별자 검증 규칙 (ValidExtensionIdentifier)

모듈/플러그인/템플릿 설치 시 `ValidExtensionIdentifier` 규칙으로 식별자 형식을 공통 검증합니다:

| 규칙 | 설명 | 예시 |
| ---------- | ---------- | ---------- |
| **하이픈 1차 구분** | 최소 2개 부분 필수 (vendor-name) | `sirsoft-board` ✅ / `sirsoftboard` ❌ |
| **언더스코어 2차 구분** | 이름 내 단어 구분 (선택적) | `sirsoft-daum_postcode` ✅ |
| **영문 소문자 + 숫자 + `_`만** | 대문자, 특수문자 불가 | `Sirsoft-Board` ❌ / `sirsoft-my@module` ❌ |
| **각 단어 첫 글자 숫자 불가** | 하이픈/언더스코어로 구분된 각 단어 기준 | `sirsoft-2shop` ❌ / `sirsoft-board2` ✅ |
| **빈 부분 불가** | 연속 하이픈/언더스코어, 양끝 하이픈/언더스코어 금지 | `sirsoft--board` ❌ / `-sirsoft-board` ❌ |
| **최대 255자** | 길이 제한 | - |

### 유효/무효 예시

```text
✅ sirsoft-board           → Sirsoft\Board
✅ sirsoft-daum_postcode   → Sirsoft\DaumPostcode
✅ sirsoft-board2          → Sirsoft\Board2
✅ vendor-my_module_name   → Vendor\MyModuleName

❌ sirsoftboard            → 하이픈 없음 (최소 2부분 필수)
❌ sirsoft-2shop           → '2shop' 첫 글자 숫자
❌ Sirsoft-Board           → 대문자 사용
❌ sirsoft-my@module       → 특수문자 '@'
❌ sirsoft--board          → 연속 하이픈 (빈 부분)
❌ sirsoft-board_          → 끝 언더스코어 (빈 단어)
```

### Artisan 커맨드에서의 검증

`module:install`, `plugin:install`, `template:install` 등에서 `ExtensionManager::validateIdentifierFormat()`을 호출하여 잘못된 식별자 입력 시 `InvalidArgumentException`을 발생시킵니다.

### 관련 파일

- `app/Rules/ValidExtensionIdentifier.php` — 검증 규칙 구현
- `ExtensionManager::validateIdentifierFormat()` — Artisan 커맨드용 래퍼

---

## 관리자 UI 필터링 (hidden 플래그)

확장 manifest (`module.json` / `plugin.json` / `template.json`) 에 `"hidden": true` 를 설정하면, 관리자 UI 목록 응답과 artisan `*:list` 기본 출력에서 제외됩니다. 학습용 샘플 확장, 내부 운영 전용 확장을 숨길 때 사용합니다.

### 제외 범위

| 대상 | hidden 적용 |
|------|-------------|
| 관리자 UI 기본 응답 (`GET /api/admin/modules`, `/plugins`, `/templates`) | 제외 |
| artisan 기본 목록 (`module:list`, `plugin:list`, `template:list`) | 제외 |
| 설치/활성화/비활성화/제거 커맨드 (`*:install`, `*:activate`, `*:uninstall`) | 정상 동작 |
| 업데이트 감지 (`*:check-updates`, `*:update`) | 정상 동작 |
| 확장 로딩/오토로드/훅 실행 | 정상 동작 (활성화되어 있으면 기능 전부 사용 가능) |

### 숨김 포함 조회

슈퍼관리자는 다음 방법으로 숨김 확장까지 포함하여 조회할 수 있습니다:

```bash
# artisan CLI
php artisan module:list --hidden
php artisan plugin:list --hidden
php artisan template:list --hidden

# API 쿼리
GET /api/admin/modules?include_hidden=1
GET /api/admin/plugins?include_hidden=1
GET /api/admin/templates?include_hidden=1
```

### manifest 설정

```json
{
    "identifier": "gnuboard7-hello_module",
    "version": "0.1.0",
    "hidden": true
}
```

- 기본값은 필드 생략 (hidden=false)
- `true` 설정 시에만 숨김 처리됨
- 학습용 샘플 확장 예: `gnuboard7-hello_module`, `gnuboard7-hello_plugin`, `gnuboard7-hello_admin_template`, `gnuboard7-hello_user_template`

> 상세: [sample-extensions.md](sample-extensions.md) — 학습용 샘플 확장 전체 가이드

---

## 서비스 등록

`CoreServiceProvider`에서 싱글톤으로 등록됩니다.

```php
// app/Providers/CoreServiceProvider.php

public function register(): void
{
    // 확장 매니저 등록 (모듈/플러그인 공통 기능)
    $this->app->singleton(ExtensionManager::class, function ($app) {
        return new ExtensionManager;
    });

    // 모듈 매니저에 주입
    $this->app->singleton(ModuleManager::class, function ($app) {
        return new ModuleManager($app->make(ExtensionManager::class));
    });

    // 플러그인 매니저에 주입
    $this->app->singleton(PluginManager::class, function ($app) {
        return new PluginManager($app->make(ExtensionManager::class));
    });
}
```

### 의존성 관계

```
ExtensionManager (독립)
    ↓
ModuleManager ← ExtensionManager 주입
    ↓
PluginManager ← ExtensionManager 주입
```

---

## 비활성화 사유 추적 (DeactivationReason)

확장 비활성화는 **사용자 수동 비활성화**와 **시스템 자동 비활성화**를 DB 레벨에서 구분합니다.

### Enum 값

| 값 | 의미 | 시스템 트리거 |
| --- | --- | --- |
| `manual` | 관리자가 관리자 UI 또는 Artisan 커맨드로 직접 비활성화 | false |
| `incompatible_core` | 코어 버전 호환성 검사 실패로 자동 비활성화 | true |

`DeactivationReason::isSystemTriggered()` 가 `true` 인 사유는 알림 영속화 + 재호환 시 원클릭 복구 UX 대상이 됩니다.

### Manager 시그니처

```php
ModuleManager::deactivateModule(
    string $identifier,
    string $reason = DeactivationReason::Manual->value,
    ?string $incompatibleRequiredVersion = null,
): void
```

`PluginManager::deactivatePlugin` / `TemplateManager::deactivateTemplate` 도 동일 시그니처. `$incompatibleRequiredVersion` 은 `incompatible_core` 사유에서 어떤 코어 버전을 요구했는지 기록 (재호환 알림에서 사용).

### 자동 복구 흐름

코어 업그레이드 후 `incompatible_core` 사유로 비활성화된 확장이 다시 호환 범위에 들어오면 관리자 대시보드 카드에 "다시 활성화" CTA 가 표시됩니다 (수동 비활성화 `manual` 은 대상 아님 — 사용자 의도 존중).

DB 컬럼: `modules/plugins/templates` 테이블의 `deactivated_reason` (string) + `incompatible_required_version` (string|null).

---

## 관련 문서

- [모듈 개발 기초](module-basics.md) - 모듈 구조 및 설정
- [플러그인 개발](plugin-development.md) - 플러그인 구조 및 설정
- [모듈 Artisan 커맨드](module-commands.md) - 모듈 관련 커맨드
- [확장 업데이트 시스템](extension-update-system.md) - _bundled/_pending 및 업데이트
- [인덱스](index.md) - 확장 시스템 전체 문서 목록
