perf(core): 서빙 API 부팅 비용 최적화 (information_schema 가드 + 훅/오토로드/config 캐시)
매 요청 균일하게 발생하던 부팅 오버헤드를 4개 축에서 제거한다. - 설치 완료 상태에서 매 요청 반복되던 information_schema 조회(알림·본인인증 테이블 hasTable)를 installer_completed 가드로 스킵. 미설치 환경은 기존 hasTable 폴백 유지. - 코어+모듈+플러그인 정적 훅 매핑을 bootstrap/cache/hooks.php 에 사전 계산해 매 요청 디렉토리 스캔·리플렉션·클래스 로딩을 제거(route:cache 동형). 등록↔발화 계약·매핑 바이트 동일. 확장/코어 변경 시 자동 재생성, 캐시 부재·손상은 스캔 폴백. - 확장 소스(Modules\*/Plugins\*)를 autoload-extensions.php 의 classmap 에 편입해 findFile 파일시스템 스캔을 제거(느린 FS·cold OPcache 환경 직격). 클래스 로딩은 여전히 lazy, PSR-4 폴백 유지. - config 캐시를 변경 지점(설정 저장/코어·확장 업데이트/APP_KEY 재생성/설치 완료)에서 clear 후 즉시 재생성하도록 ConfigCacheHelper 로 일원화. clear 만 하고 방치돼 캐시가 영구 비활성으로 남던 성능 손실 제거. HookCacheManager::read 요청당 1회 로드(memo)로 중복 파싱 제거. 부팅 매핑 fingerprint 스캔↔캐시 완전 동일(229 action + 35 filter). 신규/수정 8개 스위트 54 pass. 순수 내부 부팅 인프라 — 확장 공개 표면·훅 계약 불변.
This commit is contained in:
@@ -346,7 +346,7 @@ protected function loadModuleRoutes(): void
|
||||
- **`hasTable` 폴백 경로는 반드시 유지** — `installer_completed=false` 환경에서도 안전하게 부팅되어야 함
|
||||
- **try/catch 도 그대로 유지** — DB 연결 실패 시 안전한 스킵 계약 준수
|
||||
- 가드는 hasTable 체크 **전체 블록을 `if (! config('app.installer_completed'))` 로 래핑** 하는 형태
|
||||
- `config:cache` 를 사용하는 환경에서는 `.env` 값 변경 후 반드시 `php artisan config:clear && php artisan config:cache` 를 실행해야 함
|
||||
- `config:cache` 는 config 소스를 변경하는 라이프사이클에서 `App\Support\ConfigCacheHelper::rebuild()` 로 자동 재생성된다 (아래 "config 캐시 자동 재생성" 참조). `.env` 를 직접 편집한 경우처럼 헬퍼를 거치지 않는 변경은 여전히 `php artisan config:clear && php artisan config:cache` 를 수동 실행한다
|
||||
|
||||
### 확장 Trait 패턴
|
||||
|
||||
@@ -369,6 +369,28 @@ private static function isExtensionTableReady(string $table): bool
|
||||
}
|
||||
```
|
||||
|
||||
### 부팅 경로 Listener / Repository 적용
|
||||
|
||||
ServiceProvider 와 Trait 외에도, `CoreServiceProvider::boot()` 말미(동적 훅 등록 / IDV 정책 동기화)에서 매 요청 실행되는 다음 지점도 동일 가드를 적용합니다:
|
||||
|
||||
- `NotificationHookListener::registerDynamicHooks()` — `notification_definitions` 존재 확인 (단일 최대 비용)
|
||||
- `IdentityPolicyRepository::listHookTargets()` — `identity_policies` 존재 확인
|
||||
- `IdentityPolicyRepository::activeExtensionIdentifiers()` — `modules` / `plugins` 존재 확인 (테이블 부재 시 `null` 반환으로 필터 미적용 계약 보존)
|
||||
|
||||
```php
|
||||
// hasTable 을 단독 조건이 아니라 installer_completed 와 && 로 단락.
|
||||
// 설치 완료 시 hasTable 호출 자체를 건너뛰고 후속 로직으로 진행한다.
|
||||
if (! config('app.installer_completed') && ! Schema::hasTable('notification_definitions')) {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
`try/catch` 블록 안에 있는 Repository 조회는 catch 계약(테이블 부재/DB 오류 시 `[]` 또는 `null` 반환)을 그대로 보존한 채 조건만 확장한다.
|
||||
|
||||
### 부팅 경로 정적 훅 등록 캐시
|
||||
|
||||
`installer_completed` 가드가 부팅 시 DB 조회를 줄이는 것과 별개로, `CoreServiceProvider::boot()` 의 정적 훅 리스너 등록(`app/Listeners` 재귀 스캔 + 모듈/플러그인 `getHookListeners()` 클래스 로딩)은 `bootstrap/cache/hooks.php` 캐시로 매 요청 스캔·리플렉션을 제거한다. 캐시 부재/테스트 환경에서는 기존 스캔 경로로 안전 폴백하며, 등록 결과는 스캔 경로와 바이트 동일하다. 상세: [extension/hooks.md "정적 훅 매핑 캐시"](../extension/hooks.md).
|
||||
|
||||
### 주의사항
|
||||
|
||||
- **인스톨러 설치 완료 시점에 `INSTALLER_COMPLETED=true` 가 `.env` 에 기록되어야 함** — G7 인스톨러(`public/install/includes/task-runner.php`)는 이를 이미 수행
|
||||
@@ -408,6 +430,36 @@ php artisan migrate
|
||||
|
||||
---
|
||||
|
||||
## config 캐시 자동 재생성 (ConfigCacheHelper)
|
||||
|
||||
config 소스(`config/*.php`, `.env`, `storage/app/settings/*.json`, 활성 확장 목록)를 변경하는 라이프사이클은 `App\Support\ConfigCacheHelper::rebuild()` 를 호출해 config 캐시를 재빌드한다.
|
||||
|
||||
### 배경
|
||||
|
||||
과거 설정 변경/코어 업데이트/APP_KEY 재생성 등은 `config:clear` 만 하고 `config:cache` 를 재생성하지 않았다. 그 결과 관리자가 "시스템 최적화"(`optimizeSystem`)로 캐시를 만들어도, 이후 설정을 한 번 저장하면 캐시가 비워진 채 재생성되지 않아 이후 모든 요청이 config 파일을 재파싱했다(성능 손실). `rebuild()` 는 clear 직후 재생성까지 수행해 캐시가 비활성 상태로 잔존하지 않게 한다.
|
||||
|
||||
### 정책
|
||||
|
||||
- **환경 무관 항상 재생성** — config:cache 는 그 자체로 부팅 비용을 줄이고, G7 설정은 config 캐시에 박제되지 않고 매 요청 런타임 `Config::set()` 으로 재주입되므로(설정 stale 없음) 항상 켜두는 것이 이득이다.
|
||||
- **testing 환경**: `config:clear` 는 수행(값 반영 계약 유지)하되 `config:cache` 생성은 스킵(캐시된 config 가 다음 테스트로 누출되는 격리 파괴 방지).
|
||||
- **설치 미완료 상태**: 불완전 config 박제를 피하려 재생성을 스킵하고 clear 만 한다.
|
||||
- **local 개발 주의**: config:cache 가 켜지면 `config/*.php` 를 직접 수정해도 다음 요청에 반영되지 않는다. 이는 개발자가 `php artisan config:clear` 로 대응하는 개발자 책임 영역이다.
|
||||
|
||||
### 적용 지점
|
||||
|
||||
| 라이프사이클 | 재생성 위치 |
|
||||
| --- | --- |
|
||||
| 코어 설치 완료 | 인스톨러 `complete_flag` 직후 `config:cache` task |
|
||||
| 코어 업데이트 | `CoreUpdateCommand` Step 11 완료 지점 |
|
||||
| 코어 업그레이드 스텝(단독 실행) | `ExecuteUpgradeStepsCommand` 캐시 정리 블록 (spawn 자식은 부모가 처리) |
|
||||
| 코어 관리자 설정 저장 / 캐시 정리 / GeoIP / APP_KEY | `SettingsService` / `GeoIpDatabaseService` 의 각 지점 |
|
||||
| 확장 설치/삭제/업데이트 | `ExtensionManager::updateComposerAutoload()` (오토로드·훅 캐시와 동일 생명주기) |
|
||||
| 확장 활성화/비활성화 | `ExtensionConfigCacheListener` (`core.*.activated` / `core.*.after_deactivate` 훅 구독) |
|
||||
|
||||
**확장별 개별 환경설정 저장(board/ecommerce/plugin SettingsService)은 재생성 대상이 아니다.** 확장 설정은 `config/*.php` 가 아니라 settings JSON 에 저장되고, 매 요청 `CoreServiceProvider::boot` 의 `loadModule/PluginSettingsToConfig` 가 런타임 `Config::set('g7_settings.*', 최신값)` 으로 config 캐시에 박제된 값을 덮어쓴다. 따라서 값 stale 이 없고(즉시 반영), config 캐시를 clear 하지도 않으므로(성능 손실 없음) 재생성이 실효가 없다.
|
||||
|
||||
---
|
||||
|
||||
## 관련 이슈
|
||||
|
||||
- 인스톨러에서 `composer install` 시 DB 접근 오류
|
||||
|
||||
+7
-1
@@ -172,9 +172,15 @@ php artisan language-pack:update [identifier] [--force] [--source=auto|bundled|g
|
||||
php artisan extension:composer-install # 모든 모듈+플러그인
|
||||
|
||||
# 오토로드
|
||||
php artisan extension:update-autoload
|
||||
php artisan extension:update-autoload # 오토로드 캐시 + 정적 훅 매핑 캐시 함께 재생성
|
||||
|
||||
# 정적 훅 매핑 캐시 (부팅 비용 절감 — route:cache 동형)
|
||||
php artisan hooks:cache # bootstrap/cache/hooks.php 생성
|
||||
php artisan hooks:clear # 캐시 삭제 (삭제 후 스캔 폴백 — 항상 안전)
|
||||
```
|
||||
|
||||
> 훅 캐시는 확장 install/update 및 코어 업데이트(`clearAllCaches` → `extension:update-autoload`) 시 자동 재생성됩니다. 코어 리스너 코드 배포 시에만 `hooks:cache` 수동 실행. 상세: [extension/hooks.md "정적 훅 매핑 캐시"](extension/hooks.md).
|
||||
|
||||
### 단발성 결함 보정 (hotfix)
|
||||
|
||||
`hotfix:*` prefix 는 특정 버전의 결함 회복을 위해 신설되는 단발성 도구를 위한 표준 prefix 다. `core:*` (영구 운영 도구) 와 명확히 구분되며 dev-dashboard 자동 노출 면제 대상이다.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
3. 설치/삭제 시 자동 갱신: updateComposerAutoload() → generateAutoloadFile()
|
||||
4. 수동 갱신: php artisan extension:update-autoload
|
||||
5. _bundled/_pending 디렉토리 자동 제외 (str_starts_with($name, '_') → skip)
|
||||
6. src_classmap: 확장 소스 FQCN→경로 사전 계산 → findFile 파일시스템 stat 제거 (지연 로드·vendor 격리 유지)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -79,6 +80,10 @@ return [
|
||||
"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",
|
||||
@@ -86,8 +91,19 @@ return [
|
||||
];
|
||||
```
|
||||
|
||||
- `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` 가 함께 재생성.
|
||||
|
||||
### 중요 규칙
|
||||
|
||||
```
|
||||
|
||||
@@ -484,6 +484,44 @@ private function registerCoreHookListeners(): void
|
||||
| 위치 | `app/Listeners/**/*.php` | `modules/**/Listeners/`, `plugins/**/Listeners/` |
|
||||
| 등록 주체 | `CoreServiceProvider` | `ModuleServiceProvider`, `PluginServiceProvider` |
|
||||
|
||||
---
|
||||
|
||||
## 정적 훅 매핑 캐시 (Static Hook Cache)
|
||||
|
||||
매 요청 부팅 시 코어(`app/Listeners` 재귀 스캔) + 모듈/플러그인(`getHookListeners()`)의 정적 훅 리스너를 발견·리플렉션·`getSubscribedHooks()` 클래스 로딩하는 비용을 제거하기 위해, 사전 계산한 훅 매핑을 `bootstrap/cache/hooks.php` 에 캐시합니다 (오토로드 캐시 `autoload-extensions.php` 와 동일 위치·생명주기).
|
||||
|
||||
캐시는 "무엇을 등록할지 목록" 만 제공하며 **등록 자체는 여전히 부팅에서 수행** 되므로 등록↔발화 순서 계약은 불변입니다. 캐시 경로 등록 결과는 스캔 경로와 **훅 매핑이 바이트 동일** 합니다 (`HookListenerRegistrar::registerFromCache()` 가 `register()` 와 동일한 `applySubscribedHooks()` 에 위임).
|
||||
|
||||
### 동작
|
||||
|
||||
| 상태 | 부팅 시 동작 |
|
||||
|------|-------------|
|
||||
| 캐시 존재 (`bootstrap/cache/hooks.php`) | 스캔·리플렉션·클래스 로딩 없이 캐시 매핑으로 등록 |
|
||||
| 캐시 부재 / 손상 / 구조 불일치 | 기존 스캔 경로로 **안전 폴백** (항상 동작) |
|
||||
| 테스트 환경 (`APP_ENV=testing`) | 캐시 미사용 — 매 setUp 스캔이 정확·격리 우선 |
|
||||
|
||||
- 동적 훅(알림 등 `registerDynamicHooks()`)은 캐시 대상이 아니며 코드 변경 없이 그대로 동작합니다. 캐시에는 각 리스너의 `dynamic` 플래그만 저장하여, 동적 훅 보유 코어 리스너의 boot 후반부 지연 실행 순서를 스캔 경로와 동일하게 유지합니다.
|
||||
- 동적 훅의 DB 조회(`NotificationDefinitionService::getAllActive()`)는 이미 `['notification']` 태그로 캐시되어 있으므로 첫 요청(캐시 워밍) 이후 DB 조회가 발생하지 않습니다.
|
||||
|
||||
### 재생성 (무효화 = 재생성)
|
||||
|
||||
정적 훅 매핑은 **확장 변경 또는 코어 리스너 코드 배포 시에만** 바뀝니다. 해당 시점에 자동/수동 재생성됩니다:
|
||||
|
||||
| 사건 | 재생성 경로 |
|
||||
|------|-----------|
|
||||
| 확장 install / activate / deactivate / uninstall / update | `ExtensionManager::updateComposerAutoload()` 가 오토로드 캐시와 나란히 재생성 (자동) |
|
||||
| 코어 업데이트 (`core:update`) | `clearAllCaches()` → `extension:update-autoload` 가 오토로드 + 훅 캐시 함께 재생성 (자동). 코어 리스너 추가/변경/삭제 반영 |
|
||||
| 코어 리스너 코드 배포 | `php artisan hooks:cache` (배포 파이프라인 — `route:cache` 동형) |
|
||||
|
||||
```bash
|
||||
php artisan hooks:cache # 정적 훅 매핑 캐시 생성 (bootstrap/cache/hooks.php)
|
||||
php artisan hooks:clear # 캐시 삭제 (삭제 후 스캔 폴백 — 항상 안전)
|
||||
```
|
||||
|
||||
캐시 파일은 Git 미추적(`bootstrap/cache/*`) 이며 배포 환경마다 생성됩니다. 캐시가 없어도 스캔 폴백으로 정상 동작하므로 필수는 아니지만, 프로덕션 배포 시 부팅 비용 절감을 위해 `config:cache`/`route:cache` 와 함께 실행하는 것을 권장합니다.
|
||||
|
||||
**핵심 파일**: `app/Extension/HookCacheManager.php`, `app/Extension/HookListenerRegistrar.php` (`registerFromCache`)
|
||||
|
||||
### 동적 훅 리스너 (DB 기반)
|
||||
|
||||
DB 설정에 따라 훅 구독 대상이 동적으로 변하는 경우, 리스너에 `registerDynamicHooks()` 메서드를 구현합니다.
|
||||
|
||||
Reference in New Issue
Block a user