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:
@@ -9,6 +9,9 @@
|
||||
### Changed
|
||||
|
||||
- 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다.
|
||||
- 설치가 끝난 사이트에서 매 요청마다 반복되던 불필요한 데이터베이스 조회를 줄여 페이지 응답 속도를 개선했습니다. 알림·본인인증 설정을 준비하는 과정에서 이미 존재가 보장된 테이블을 매번 다시 확인하던 동작을 설치 완료 상태에서는 건너뛰도록 했습니다.
|
||||
- 화면을 그릴 때마다 서버가 매번 확장 기능의 연결 정보를 처음부터 다시 계산하던 부담을 줄여 페이지 응답 속도를 개선했습니다. 이 연결 정보는 확장을 설치·수정하거나 코어를 업데이트할 때만 바뀌므로 그 시점에 한 번 저장해 두고, 이후 요청에서는 저장된 값을 재사용하도록 했습니다. 확장이나 코어를 변경하면 저장 값이 자동으로 갱신됩니다.
|
||||
- 설치된 모듈·플러그인의 코드를 찾을 때마다 서버가 파일을 하나하나 탐색하던 부담을 줄여, 특히 공유 호스팅 등 파일 접근이 느린 환경에서 페이지 응답 속도를 개선했습니다. 확장 코드의 위치를 설치·수정 시점에 미리 정리해 두고 이후 요청에서는 그 목록을 바로 참조하도록 했습니다. 확장을 설치·수정·삭제하거나 코어를 업데이트하면 목록이 자동으로 갱신됩니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -484,6 +484,13 @@ class CoreUpdateCommand extends Command
|
||||
$maintenanceEnabled = false;
|
||||
}
|
||||
|
||||
// 모든 파일(코어 config/lang/vendor)이 안착한 뒤 config 캐시를 재생성한다.
|
||||
// clearAllCaches() 는 흐름 중간에서 stale 캐시를 비우기만 하므로, 여기서
|
||||
// 재생성하지 않으면 업데이트 후 config:cache 가 비활성 상태로 남아 이후 모든
|
||||
// 요청이 config 파일을 재파싱한다(성능 손실). ConfigCacheHelper 는 설치 미완료
|
||||
// 상태를 가드하고 실패를 안전하게 흡수한다.
|
||||
\App\Support\ConfigCacheHelper::rebuild();
|
||||
|
||||
$log('정리 완료');
|
||||
|
||||
$bar->finish();
|
||||
|
||||
@@ -247,6 +247,11 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
if (! $this->option('skip-cache-clear') && ! $isSpawnChild && ! $stepsOnly) {
|
||||
$this->info('캐시 정리 (config/route/view/services/packages)');
|
||||
$service->clearAllCaches();
|
||||
// clearAllCaches() 는 config:clear 만 하므로, 단독 실행 흐름에서는 config 캐시가
|
||||
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
|
||||
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
|
||||
// spawn 자식·steps-only 는 이 블록을 스킵하고 부모(CoreUpdateCommand)가 재생성한다.
|
||||
\App\Support\ConfigCacheHelper::rebuild();
|
||||
// 업그레이드 스텝 단독 실행 시에도 코어 lang/routes/layout 변경이
|
||||
// 프론트엔드 캐시 stale 로 가려지지 않도록 `ext.cache_version` bump.
|
||||
// spawn 자식·steps-only 모드에서는 부모가 처리하므로 스킵.
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
namespace App\Console\Commands\Extension;
|
||||
|
||||
use App\Extension\ExtensionManager;
|
||||
use App\Extension\HookCacheManager;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class UpdateAutoloadCommand extends Command
|
||||
@@ -24,8 +27,12 @@ class UpdateAutoloadCommand extends Command
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(ExtensionManager $extensionManager): int
|
||||
{
|
||||
public function handle(
|
||||
ExtensionManager $extensionManager,
|
||||
HookCacheManager $hookCacheManager,
|
||||
ModuleManager $moduleManager,
|
||||
PluginManager $pluginManager,
|
||||
): int {
|
||||
$this->info('확장 오토로드 파일을 생성합니다...');
|
||||
|
||||
try {
|
||||
@@ -34,6 +41,16 @@ class UpdateAutoloadCommand extends Command
|
||||
$this->info('오토로드 파일이 성공적으로 생성되었습니다.');
|
||||
$this->line(' → bootstrap/cache/autoload-extensions.php');
|
||||
|
||||
// 오토로드 캐시와 동일 생명주기의 정적 훅 매핑 캐시도 함께 재생성.
|
||||
// 코어 업데이트(clearAllCaches → 이 커맨드) 시 코어 리스너 추가/변경/삭제가
|
||||
// 캐시에 반영되도록 보장한다 (미갱신 시 stale 매핑으로 훅 발화 누락/과잉 위험).
|
||||
$moduleManager->loadModules();
|
||||
$pluginManager->loadPlugins();
|
||||
$hookCacheManager->generate($moduleManager, $pluginManager);
|
||||
|
||||
$this->info('정적 훅 매핑 캐시가 재생성되었습니다.');
|
||||
$this->line(' → bootstrap/cache/hooks.php');
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
$this->error('오토로드 파일 생성 중 오류가 발생했습니다: '.$e->getMessage());
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Extension\HookCacheManager;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 정적 훅 매핑 캐시 생성 Artisan 커맨드.
|
||||
*
|
||||
* 코어(app/Listeners) + 활성 모듈/플러그인의 정적 훅 리스너를 사전 계산하여
|
||||
* bootstrap/cache/hooks.php 에 저장한다. route:cache 동형 — 코어 리스너를
|
||||
* 추가/변경하는 코드 배포 시 배포 파이프라인에서 실행한다.
|
||||
* (확장 install/update 시에는 ExtensionManager 가 자동 재생성하므로 수동 실행 불필요.)
|
||||
*/
|
||||
class HooksCacheCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string 커맨드 시그니처
|
||||
*/
|
||||
protected $signature = 'hooks:cache';
|
||||
|
||||
/**
|
||||
* @var string 커맨드 설명
|
||||
*/
|
||||
protected $description = '정적 훅 매핑 캐시 생성 (bootstrap/cache/hooks.php)';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param HookCacheManager $hookCacheManager 훅 캐시 매니저
|
||||
* @param ModuleManager $moduleManager 모듈 매니저
|
||||
* @param PluginManager $pluginManager 플러그인 매니저
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(
|
||||
HookCacheManager $hookCacheManager,
|
||||
ModuleManager $moduleManager,
|
||||
PluginManager $pluginManager,
|
||||
): int {
|
||||
$moduleManager->loadModules();
|
||||
$pluginManager->loadPlugins();
|
||||
|
||||
$counts = $hookCacheManager->generate($moduleManager, $pluginManager);
|
||||
|
||||
$this->info(__('hooks.cache_generated', [
|
||||
'core' => $counts['core'],
|
||||
'modules' => $counts['modules'],
|
||||
'plugins' => $counts['plugins'],
|
||||
'path' => $hookCacheManager->getCacheFilePath(),
|
||||
]));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Extension\HookCacheManager;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 정적 훅 매핑 캐시 삭제 Artisan 커맨드.
|
||||
*
|
||||
* bootstrap/cache/hooks.php 를 삭제한다. 캐시 삭제 후에는 부팅 시 스캔 폴백으로
|
||||
* 동작하므로 항상 안전하다 (route:clear 동형).
|
||||
*/
|
||||
class HooksClearCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string 커맨드 시그니처
|
||||
*/
|
||||
protected $signature = 'hooks:clear';
|
||||
|
||||
/**
|
||||
* @var string 커맨드 설명
|
||||
*/
|
||||
protected $description = '정적 훅 매핑 캐시 삭제 (bootstrap/cache/hooks.php)';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param HookCacheManager $hookCacheManager 훅 캐시 매니저
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(HookCacheManager $hookCacheManager): int
|
||||
{
|
||||
if (! $hookCacheManager->isCached()) {
|
||||
$this->info(__('hooks.cache_absent'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$hookCacheManager->clear();
|
||||
$this->info(__('hooks.cache_cleared'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ namespace App\Extension;
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use App\Extension\Helpers\GithubHelper;
|
||||
use App\Rules\ValidExtensionIdentifier;
|
||||
use App\Support\ConfigCacheHelper;
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -37,14 +40,6 @@ class ExtensionManager
|
||||
$this->autoloadFilePath = base_path('bootstrap/cache/autoload-extensions.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* 설치된 모듈/플러그인의 오토로드 파일을 생성합니다.
|
||||
*
|
||||
* composer.json을 수정하지 않고, bootstrap/cache/autoload-extensions.php 파일을 생성하여
|
||||
* 런타임에 Composer ClassLoader에 PSR-4 네임스페이스를 등록합니다.
|
||||
*
|
||||
* 테스트 환경(APP_ENV=testing)에서는 자동으로 스킵됩니다.
|
||||
*/
|
||||
public function updateComposerAutoload(): void
|
||||
{
|
||||
// 테스트 환경에서 오토로드 업데이트 스킵 (성능 최적화)
|
||||
@@ -61,6 +56,41 @@ class ExtensionManager
|
||||
// 신규 네임스페이스(beta 업그레이드로 추가된 Seeder/Model 등) 의 autoload 가
|
||||
// 실패하지 않도록 런타임 재등록을 수행한다.
|
||||
$this->reregisterRuntimeAutoload();
|
||||
|
||||
// 정적 훅 매핑 캐시도 오토로드 캐시와 나란히 재생성.
|
||||
// 훅 매핑은 확장 install/activate/deactivate/uninstall/update 시에만 바뀌므로
|
||||
// 이 지점에 편승하면 별도 무효화 발굴 없이 오토로드 캐시와 동일 생명주기를 갖는다.
|
||||
$this->regenerateHookCache();
|
||||
|
||||
// config 캐시도 함께 재생성. 확장 설치/삭제/업데이트는 활성 확장 목록을 바꾸므로
|
||||
// (optimizeSystem 이 config:cache 를 만든 시점의 스냅샷이 stale 해짐), 오토로드/훅
|
||||
// 캐시와 동일 생명주기로 config 캐시를 재빌드해 캐시가 비활성 상태로 남지 않게 한다.
|
||||
// activate/deactivate 는 이 메서드를 거치지 않으므로 각 Manager 에서 별도 호출한다.
|
||||
ConfigCacheHelper::rebuild();
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적 훅 매핑 캐시(bootstrap/cache/hooks.php)를 재생성합니다.
|
||||
*
|
||||
* 모듈/플러그인 리스너 수집을 위해 각 Manager 를 (재)로드한 뒤 HookCacheManager 에 위임한다.
|
||||
* 생성 실패는 부팅 시 스캔 폴백으로 흡수되므로 확장 업데이트 흐름을 중단시키지 않는다.
|
||||
*/
|
||||
protected function regenerateHookCache(): void
|
||||
{
|
||||
try {
|
||||
$moduleManager = app(ModuleManager::class);
|
||||
$moduleManager->loadModules();
|
||||
|
||||
$pluginManager = app(PluginManager::class);
|
||||
$pluginManager->loadPlugins();
|
||||
|
||||
app(HookCacheManager::class)->generate($moduleManager, $pluginManager);
|
||||
} catch (\Throwable $e) {
|
||||
// 훅 캐시 생성 실패는 치명적이지 않다 — 부팅 시 스캔 폴백이 안전망.
|
||||
Log::warning('훅 매핑 캐시 재생성 실패 (스캔 폴백으로 동작)', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +108,7 @@ class ExtensionManager
|
||||
*/
|
||||
protected function reregisterRuntimeAutoload(): void
|
||||
{
|
||||
if (! class_exists(\Composer\Autoload\ClassLoader::class, false)) {
|
||||
if (! class_exists(ClassLoader::class, false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,7 +116,7 @@ class ExtensionManager
|
||||
return;
|
||||
}
|
||||
|
||||
$loaders = \Composer\Autoload\ClassLoader::getRegisteredLoaders();
|
||||
$loaders = ClassLoader::getRegisteredLoaders();
|
||||
if (empty($loaders)) {
|
||||
return;
|
||||
}
|
||||
@@ -135,8 +165,13 @@ class ExtensionManager
|
||||
$pluginAutoloads['vendor_autoloads']
|
||||
);
|
||||
|
||||
// 확장 소스 classmap 생성 (FQCN → 상대경로).
|
||||
// 런타임에 ClassLoader::addClassMap() 으로 등록되어 findFile 이 파일시스템 스캔 없이
|
||||
// 즉시 경로를 반환하도록 한다(성능). 클래스 로딩은 여전히 lazy — 사용 시점에만 include.
|
||||
$srcClassmap = $this->buildSourceClassmap($psr4);
|
||||
|
||||
// 파일 내용 생성
|
||||
$content = $this->buildAutoloadFileContent($psr4, $classmap, $files, $vendorAutoloads);
|
||||
$content = $this->buildAutoloadFileContent($psr4, $classmap, $files, $vendorAutoloads, $srcClassmap);
|
||||
|
||||
// 디렉토리 확인
|
||||
$dir = dirname($this->autoloadFilePath);
|
||||
@@ -151,6 +186,7 @@ class ExtensionManager
|
||||
'path' => $this->autoloadFilePath,
|
||||
'psr4_count' => count($psr4),
|
||||
'classmap_count' => count($classmap),
|
||||
'src_classmap_count' => count($srcClassmap),
|
||||
'files_count' => count($files),
|
||||
'vendor_autoloads_count' => count($vendorAutoloads),
|
||||
]);
|
||||
@@ -164,7 +200,7 @@ class ExtensionManager
|
||||
* @param array $files 헬퍼 파일 목록
|
||||
* @return string PHP 파일 내용
|
||||
*/
|
||||
protected function buildAutoloadFileContent(array $psr4, array $classmap, array $files = [], array $vendorAutoloads = []): string
|
||||
protected function buildAutoloadFileContent(array $psr4, array $classmap, array $files = [], array $vendorAutoloads = [], array $srcClassmap = []): string
|
||||
{
|
||||
$generatedAt = now()->toDateTimeString();
|
||||
|
||||
@@ -173,12 +209,14 @@ class ExtensionManager
|
||||
$classmapJson = json_encode($classmap, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$filesJson = json_encode($files, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$vendorAutoloadsJson = json_encode($vendorAutoloads, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$srcClassmapJson = json_encode($srcClassmap, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
// JSON을 PHP 배열 문법으로 변환
|
||||
$psr4Php = $this->jsonToPhpArray($psr4Json, 1);
|
||||
$classmapPhp = $this->jsonToPhpArray($classmapJson, 1);
|
||||
$filesPhp = $this->jsonToPhpArray($filesJson, 1);
|
||||
$vendorAutoloadsPhp = $this->jsonToPhpArray($vendorAutoloadsJson, 1);
|
||||
$srcClassmapPhp = $this->jsonToPhpArray($srcClassmapJson, 1);
|
||||
|
||||
return <<<PHP
|
||||
<?php
|
||||
@@ -195,6 +233,7 @@ class ExtensionManager
|
||||
return [
|
||||
'psr4' => {$psr4Php},
|
||||
'classmap' => {$classmapPhp},
|
||||
'src_classmap' => {$srcClassmapPhp},
|
||||
'files' => {$filesPhp},
|
||||
'vendor_autoloads' => {$vendorAutoloadsPhp},
|
||||
];
|
||||
@@ -240,7 +279,7 @@ PHP;
|
||||
*
|
||||
* public/index.php 및 artisan에서 호출됩니다.
|
||||
*
|
||||
* @param \Composer\Autoload\ClassLoader $loader Composer ClassLoader 인스턴스
|
||||
* @param ClassLoader $loader Composer ClassLoader 인스턴스
|
||||
*/
|
||||
public static function registerExtensionAutoload($loader): void
|
||||
{
|
||||
@@ -266,6 +305,17 @@ PHP;
|
||||
}
|
||||
}
|
||||
|
||||
// 확장 소스 classmap 등록 (FQCN → 절대경로).
|
||||
// findFile 이 파일시스템 스캔 없이 즉시 반환하도록 in-memory 맵에 추가한다(lazy include).
|
||||
// PSR-4 등록은 위에서 유지되므로 classmap 에 없는 클래스는 PSR-4 폴백(안전망).
|
||||
if (! empty($autoloads['src_classmap'])) {
|
||||
$absoluteClassmap = [];
|
||||
foreach ($autoloads['src_classmap'] as $fqcn => $relPath) {
|
||||
$absoluteClassmap[$fqcn] = base_path($relPath);
|
||||
}
|
||||
$loader->addClassMap($absoluteClassmap);
|
||||
}
|
||||
|
||||
// Classmap 파일 로드 (module.php, plugin.php)
|
||||
if (! empty($autoloads['classmap'])) {
|
||||
foreach ($autoloads['classmap'] as $file) {
|
||||
@@ -471,6 +521,130 @@ PHP;
|
||||
return ['psr4' => $psr4, 'classmap' => $classmap, 'files' => $files, 'vendor_autoloads' => $vendorAutoloads];
|
||||
}
|
||||
|
||||
/**
|
||||
* PSR-4 매핑을 기반으로 확장 소스 클래스의 classmap 을 생성합니다.
|
||||
*
|
||||
* 각 PSR-4 네임스페이스 디렉토리를 재귀 스캔하여 PHP 파일마다 FQCN → 상대경로 를
|
||||
* 산출한다. 이 classmap 은 런타임에 `ClassLoader::addClassMap()` 으로 등록되어
|
||||
* `findFile()` 이 파일시스템 스캔 없이 즉시 경로를 반환하도록 한다(성능: cold OPcache /
|
||||
* 느린 파일시스템 환경에서 findFile stat 비용 제거). 클래스 로딩은 여전히 지연(lazy)이다.
|
||||
*
|
||||
* 확장 **소스** 클래스만 대상으로 하며, 확장별 독립 vendor(서드파티)는 각 확장의
|
||||
* `vendor/autoload.php`(별도 ClassLoader) 가 담당하므로 여기서 다루지 않는다
|
||||
* (vendor 격리 불변조건 보존).
|
||||
*
|
||||
* @param array<string, string|array<int, string>> $psr4 네임스페이스 → base_path 상대경로(들)
|
||||
* @return array<string, string> FQCN → base_path 상대경로 (.php 포함)
|
||||
*/
|
||||
protected function buildSourceClassmap(array $psr4): array
|
||||
{
|
||||
$classmap = [];
|
||||
|
||||
foreach ($psr4 as $namespace => $paths) {
|
||||
foreach ((array) $paths as $relativePath) {
|
||||
$absoluteDir = base_path($relativePath);
|
||||
|
||||
if (! is_dir($absoluteDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($absoluteDir, \RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fqcn = $this->extractFqcnFromFile($file->getPathname());
|
||||
if ($fqcn === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// PSR-4 계약상 이 파일의 네임스페이스는 $namespace 로 시작해야 한다.
|
||||
// (다른 네임스페이스 파일은 다른 PSR-4 항목이 담당 — 중복 방지)
|
||||
if (! str_starts_with($fqcn, rtrim($namespace, '\\'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// base_path 상대경로로 정규화 (autoload-extensions.php 의 다른 경로와 일관)
|
||||
$rel = str_replace('\\', '/', substr($file->getPathname(), strlen(base_path()) + 1));
|
||||
$classmap[$fqcn] = $rel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ksort($classmap);
|
||||
|
||||
return $classmap;
|
||||
}
|
||||
|
||||
/**
|
||||
* PHP 파일에서 FQCN(네임스페이스 + 클래스/인터페이스/트레이트/enum 명)을 추출합니다.
|
||||
*
|
||||
* 토큰 기반 파싱으로 파일당 최상위 선언 1개의 FQCN 을 반환한다. 선언이 없으면 null.
|
||||
*
|
||||
* @param string $filePath PHP 파일 절대경로
|
||||
* @return string|null FQCN 또는 null
|
||||
*/
|
||||
protected function extractFqcnFromFile(string $filePath): ?string
|
||||
{
|
||||
$contents = @file_get_contents($filePath);
|
||||
if ($contents === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tokens = token_get_all($contents);
|
||||
$namespace = '';
|
||||
$count = count($tokens);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$token = $tokens[$i];
|
||||
if (! is_array($token)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 네임스페이스 수집
|
||||
if ($token[0] === T_NAMESPACE) {
|
||||
$namespace = '';
|
||||
for ($j = $i + 1; $j < $count; $j++) {
|
||||
$t = $tokens[$j];
|
||||
if ($t === ';' || $t === '{') {
|
||||
break;
|
||||
}
|
||||
if (is_array($t) && in_array($t[0], [T_STRING, T_NS_SEPARATOR], true)) {
|
||||
$namespace .= $t[1];
|
||||
} elseif (is_array($t) && defined('T_NAME_QUALIFIED') && $t[0] === T_NAME_QUALIFIED) {
|
||||
$namespace .= $t[1];
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 최상위 선언(class/interface/trait/enum) 감지
|
||||
if (in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT], true)
|
||||
|| (defined('T_ENUM') && $token[0] === T_ENUM)) {
|
||||
// ::class, new class 등 익명/상수 사용 배제: 다음 유효 토큰이 T_STRING 이어야 함
|
||||
for ($j = $i + 1; $j < $count; $j++) {
|
||||
$t = $tokens[$j];
|
||||
if (is_array($t) && $t[0] === T_WHITESPACE) {
|
||||
continue;
|
||||
}
|
||||
if (is_array($t) && $t[0] === T_STRING) {
|
||||
$class = $t[1];
|
||||
|
||||
return $namespace !== '' ? $namespace.'\\'.$class : $class;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 identifier를 네임스페이스로 변환합니다.
|
||||
*
|
||||
@@ -640,7 +814,7 @@ PHP;
|
||||
$failed = false;
|
||||
$message = '';
|
||||
|
||||
(new \App\Rules\ValidExtensionIdentifier)->validate(
|
||||
(new ValidExtensionIdentifier)->validate(
|
||||
'identifier',
|
||||
$identifier,
|
||||
function ($msg) use (&$failed, &$message) {
|
||||
@@ -718,7 +892,6 @@ PHP;
|
||||
return ! empty($this->getComposerDependenciesAt($path));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 확장의 composer.json에서 외부 패키지 의존성 목록을 반환합니다.
|
||||
*
|
||||
@@ -1096,7 +1269,6 @@ PHP;
|
||||
*
|
||||
* @param string $zipPath ZIP 파일 경로
|
||||
* @param string $extractDir 추출 대상 디렉토리
|
||||
* @return void
|
||||
*
|
||||
* @throws \RuntimeException 추출 실패 시
|
||||
*/
|
||||
@@ -1116,7 +1288,6 @@ PHP;
|
||||
*
|
||||
* @param string $zipPath ZIP 파일 경로
|
||||
* @param string $extractDir 추출 대상 디렉토리
|
||||
* @return void
|
||||
*
|
||||
* @throws \RuntimeException 추출 실패 시
|
||||
*/
|
||||
@@ -1138,7 +1309,7 @@ PHP;
|
||||
/**
|
||||
* unzip 명령어 사용 가능 여부를 확인합니다.
|
||||
*
|
||||
* @return bool
|
||||
* @return bool unzip 실행 파일이 PATH 에 존재하면 true
|
||||
*/
|
||||
public function isUnzipAvailable(): bool
|
||||
{
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Contracts\Extension\ModuleInterface;
|
||||
use App\Contracts\Extension\PluginInterface;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 정적 훅 매핑 캐시 관리자.
|
||||
*
|
||||
* 코어(app/Listeners 재귀 스캔) + 모듈/플러그인(getHookListeners)의 정적 훅 리스너를
|
||||
* 사전 계산하여 bootstrap/cache/hooks.php 에 저장하고, 부팅 시 이 캐시를 읽어
|
||||
* 매 요청 디렉토리 스캔·리플렉션·getSubscribedHooks() 클래스 로딩을 제거한다.
|
||||
*
|
||||
* 캐시는 "무엇을 등록할지 목록"만 제공하며, 실제 등록은 여전히 boot 에서 수행되므로
|
||||
* 등록↔발화 순서 계약은 불변이다. 캐시 미스(파일 부재/설치 직후/캐시 클리어 후)는
|
||||
* 항상 스캔 폴백으로 안전하게 동작한다.
|
||||
*
|
||||
* 오토로드 캐시(autoload-extensions.php)와 동일 위치·생명주기를 갖는다:
|
||||
* 확장 install/activate/deactivate/uninstall/update 시
|
||||
* ExtensionManager::updateComposerAutoload() 가 함께 재생성한다.
|
||||
*
|
||||
* @see HookListenerRegistrar::registerFromCache()
|
||||
*/
|
||||
class HookCacheManager
|
||||
{
|
||||
/**
|
||||
* 캐시 파일 경로.
|
||||
*/
|
||||
protected string $cacheFilePath;
|
||||
|
||||
/**
|
||||
* Request-scoped 메모리 캐시 (read() 결과).
|
||||
*
|
||||
* HookCacheManager 는 싱글톤이 아니라 부팅 경로에서 매번 새 인스턴스로
|
||||
* 생성되므로(app(HookCacheManager::class)), 요청 단위 캐시는 static 이어야 한다.
|
||||
* 세 상태를 구분한다:
|
||||
* - null = 아직 read() 전 (미초기화). PHP static 프로퍼티는 unset() 불가하므로
|
||||
* 무효화(generate/clear)는 이 값으로 되돌린다.
|
||||
* - false = 이번 요청 캐시 미스 확정 (파일 부재/손상) — 재시도 없이 폴백 유지.
|
||||
* - array = 유효 캐시 데이터.
|
||||
*
|
||||
* @var array{core: array, modules: array, plugins: array}|false|null
|
||||
*/
|
||||
private static array|false|null $memo = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->cacheFilePath = base_path('bootstrap/cache/hooks.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 파일 경로를 반환합니다.
|
||||
*
|
||||
* @return string bootstrap/cache/hooks.php 절대 경로
|
||||
*/
|
||||
public function getCacheFilePath(): string
|
||||
{
|
||||
return $this->cacheFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 파일 존재 여부를 반환합니다.
|
||||
*
|
||||
* @return bool 캐시 파일이 존재하면 true
|
||||
*/
|
||||
public function isCached(): bool
|
||||
{
|
||||
return File::exists($this->cacheFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 파일을 읽어 반환합니다.
|
||||
*
|
||||
* 파일이 없거나 구조가 손상된 경우 null 을 반환하여 호출측이 스캔 폴백하도록 한다.
|
||||
*
|
||||
* @return array{core: array<int, array<string, mixed>>, modules: array<string, array<int, array<string, mixed>>>, plugins: array<string, array<int, array<string, mixed>>>}|null
|
||||
*/
|
||||
public function read(): ?array
|
||||
{
|
||||
// Request-scoped 메모리 캐시: 한 요청 동안 부팅 경로에서
|
||||
// core/module/plugin 등록이 이 메서드를 여러 번 호출한다
|
||||
// (호출당 86KB 캐시 파일 재파싱 — OPcache 부재 시 요청당 십수 ms 누적).
|
||||
// 캐시 파일 내용은 요청 수명 동안 불변이므로 첫 로드 결과를 static 에 보관해
|
||||
// 파일 접근을 요청당 1회로 고정한다. self::$memo 는 세 상태를 구분한다:
|
||||
// - 미설정(unset): 아직 read() 호출 전
|
||||
// - false: 이번 요청에서 캐시 미스(파일 부재/손상) 확정 — 재시도 없이 폴백 유지
|
||||
// - array: 유효 캐시 데이터
|
||||
if (self::$memo !== null) {
|
||||
return self::$memo === false ? null : self::$memo;
|
||||
}
|
||||
|
||||
if (! $this->isCached()) {
|
||||
self::$memo = false;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = require $this->cacheFilePath;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('훅 캐시 파일 로드 실패 — 스캔 폴백', [
|
||||
'path' => $this->cacheFilePath,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
self::$memo = false;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_array($data) || ! isset($data['core'], $data['modules'], $data['plugins'])) {
|
||||
Log::warning('훅 캐시 파일 구조가 올바르지 않습니다 — 스캔 폴백', [
|
||||
'path' => $this->cacheFilePath,
|
||||
]);
|
||||
self::$memo = false;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
self::$memo = $data;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 파일을 삭제합니다.
|
||||
*
|
||||
* @return bool 삭제 성공 또는 파일 부재 시 true
|
||||
*/
|
||||
public function clear(): bool
|
||||
{
|
||||
self::$memo = null;
|
||||
|
||||
if (! $this->isCached()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return File::delete($this->cacheFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적 훅 매핑을 스캔·리플렉션하여 캐시 파일로 생성합니다.
|
||||
*
|
||||
* 모듈/플러그인 리스너는 로드된 Module/Plugin 인스턴스의 getHookListeners() 에서
|
||||
* 수집하므로, 호출 전에 각 Manager 의 loadModules()/loadPlugins() 가 선행되어야 한다.
|
||||
*
|
||||
* @param ModuleManager $moduleManager 로드 완료된 모듈 매니저
|
||||
* @param PluginManager $pluginManager 로드 완료된 플러그인 매니저
|
||||
* @return array{core: int, modules: int, plugins: int} 소스별 리스너 수
|
||||
*/
|
||||
public function generate(ModuleManager $moduleManager, PluginManager $pluginManager): array
|
||||
{
|
||||
$cache = [
|
||||
'core' => $this->collectCoreListeners(),
|
||||
'modules' => $this->collectExtensionListeners($moduleManager->getActiveModules()),
|
||||
'plugins' => $this->collectExtensionListeners($pluginManager->getActivePlugins()),
|
||||
];
|
||||
|
||||
$content = $this->buildCacheFileContent($cache);
|
||||
|
||||
$dir = dirname($this->cacheFilePath);
|
||||
if (! File::isDirectory($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
File::put($this->cacheFilePath, $content);
|
||||
|
||||
// 방금 쓴 새 매핑으로 request-scoped 메모리 캐시를 갱신
|
||||
// (이후 read() 가 옛 memo 를 반환하지 않도록 무효화).
|
||||
self::$memo = null;
|
||||
|
||||
$counts = [
|
||||
'core' => count($cache['core']),
|
||||
'modules' => array_sum(array_map('count', $cache['modules'])),
|
||||
'plugins' => array_sum(array_map('count', $cache['plugins'])),
|
||||
];
|
||||
|
||||
Log::info('훅 매핑 캐시 파일 생성 완료', [
|
||||
'path' => $this->cacheFilePath,
|
||||
'core' => $counts['core'],
|
||||
'modules' => $counts['modules'],
|
||||
'plugins' => $counts['plugins'],
|
||||
]);
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* app/Listeners/ 재귀 스캔으로 코어 리스너 항목을 수집합니다.
|
||||
*
|
||||
* registerCoreHookListeners() 스캔 로직과 동일하게 HookListenerInterface 구현체만 대상.
|
||||
*
|
||||
* @return array<int, array<string, mixed>> [['listener' => FQCN, 'hooks' => [...], 'dynamic' => bool], ...]
|
||||
*/
|
||||
protected function collectCoreListeners(): array
|
||||
{
|
||||
$listenersPath = app_path('Listeners');
|
||||
|
||||
if (! is_dir($listenersPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($listenersPath, \RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
$entries = [];
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativePath = str_replace($listenersPath.DIRECTORY_SEPARATOR, '', $file->getPathname());
|
||||
$relativePath = str_replace('.php', '', $relativePath);
|
||||
$relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
|
||||
$listenerClass = 'App\\Listeners\\'.$relativePath;
|
||||
|
||||
$entry = $this->buildListenerEntry($listenerClass);
|
||||
if ($entry !== null) {
|
||||
$entries[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로드된 모듈/플러그인 인스턴스에서 훅 리스너 항목을 수집합니다.
|
||||
*
|
||||
* @param array<string, ModuleInterface|PluginInterface> $extensions 식별자(디렉토리명) => 확장 인스턴스
|
||||
* @return array<string, array<int, array<string, mixed>>> 확장 식별자 => 리스너 항목 목록
|
||||
*/
|
||||
protected function collectExtensionListeners(array $extensions): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($extensions as $extension) {
|
||||
if (! method_exists($extension, 'getHookListeners')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$identifier = $extension->getIdentifier();
|
||||
$entries = [];
|
||||
|
||||
foreach ($extension->getHookListeners() as $listenerClass) {
|
||||
$entry = $this->buildListenerEntry($listenerClass);
|
||||
if ($entry !== null) {
|
||||
$entries[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($entries)) {
|
||||
$result[$identifier] = $entries;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 리스너 클래스의 캐시 항목을 생성합니다.
|
||||
*
|
||||
* HookListenerInterface 미구현·클래스 부재 시 null 을 반환하여 제외합니다.
|
||||
* 사전 계산된 getSubscribedHooks() 결과와 동적 훅 여부(registerDynamicHooks 존재)를 함께 저장합니다.
|
||||
*
|
||||
* @param string $listenerClass 리스너 FQCN
|
||||
* @return array{listener: string, hooks: array<string, array<string, mixed>>, dynamic: bool}|null
|
||||
*/
|
||||
protected function buildListenerEntry(string $listenerClass): ?array
|
||||
{
|
||||
if (! class_exists($listenerClass)) {
|
||||
Log::warning('훅 캐시 생성: 리스너 클래스를 찾을 수 없습니다', ['listener' => $listenerClass]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! in_array(HookListenerInterface::class, class_implements($listenerClass), true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$hooks = $listenerClass::getSubscribedHooks();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('훅 캐시 생성: getSubscribedHooks() 오류 — 항목 제외', [
|
||||
'listener' => $listenerClass,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'listener' => $listenerClass,
|
||||
'hooks' => $hooks,
|
||||
'dynamic' => method_exists($listenerClass, 'registerDynamicHooks'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildCacheFileContent(array $cache): string
|
||||
{
|
||||
$exported = var_export($cache, true);
|
||||
$generatedAt = now()->toDateTimeString();
|
||||
|
||||
$header = "<?php\n\n"
|
||||
."/**\n"
|
||||
." * 정적 훅 매핑 캐시\n"
|
||||
." *\n"
|
||||
." * 이 파일은 자동 생성됩니다. 직접 수정하지 마세요.\n"
|
||||
." * Generated at: {$generatedAt}\n"
|
||||
." *\n"
|
||||
." * @see \\App\\Extension\\HookCacheManager::generate()\n"
|
||||
." */\n\n";
|
||||
|
||||
return $header."return {$exported};\n";
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,9 @@ class HookListenerRegistrar
|
||||
/**
|
||||
* 리스너 클래스를 HookManager에 등록합니다.
|
||||
*
|
||||
* 동일 source + listenerClass 조합이 이미 등록된 경우 skip (idempotent).
|
||||
* 런타임에 $listenerClass::getSubscribedHooks() 를 호출해 구독 훅을 조회한 뒤
|
||||
* 등록한다. 동일 source + listenerClass 조합이 이미 등록된 경우 skip (idempotent).
|
||||
* 사전 계산된 캐시 매핑으로 등록하려면 registerFromCache() 를 사용한다.
|
||||
*
|
||||
* @param string $listenerClass HookListenerInterface 구현 클래스의 FQCN
|
||||
* @param string|null $source 등록 출처 (로그용: 'core', 모듈/플러그인 식별자)
|
||||
@@ -45,14 +47,10 @@ class HookListenerRegistrar
|
||||
if (isset(self::$registered[$key])) {
|
||||
return; // 동일 PHP process 내 중복 등록 방지
|
||||
}
|
||||
self::$registered[$key] = true;
|
||||
|
||||
try {
|
||||
$subscribedHooks = $listenerClass::getSubscribedHooks();
|
||||
} catch (\Throwable $e) {
|
||||
// 실패 시 캐시 롤백하여 재시도 가능 상태 유지
|
||||
unset(self::$registered[$key]);
|
||||
|
||||
Log::error('훅 리스너 등록 실패: getSubscribedHooks() 오류', [
|
||||
'listener' => $listenerClass,
|
||||
'source' => $source,
|
||||
@@ -62,6 +60,49 @@ class HookListenerRegistrar
|
||||
return;
|
||||
}
|
||||
|
||||
self::applySubscribedHooks($listenerClass, $subscribedHooks, $source);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시된 구독 훅 매핑으로 리스너를 등록합니다 (클래스 로딩 없음).
|
||||
*
|
||||
* bootstrap/cache/hooks.php 에서 읽어온 사전 계산 getSubscribedHooks() 결과를
|
||||
* 그대로 받아 HookManager 에 등록한다. register() 와 달리 $listenerClass::getSubscribedHooks()
|
||||
* 를 호출하지 않으므로 리스너 클래스가 부팅 시점에 오토로딩되지 않는다 —
|
||||
* 실제 클래스 로딩은 훅 발화 시 클로저 내부 app($listenerClass) 에서 지연 수행된다.
|
||||
*
|
||||
* 등록 결과(HookManager 훅 매핑)는 register() 스캔 경로와 바이트 동일해야 한다
|
||||
* (동일 applySubscribedHooks 위임).
|
||||
*
|
||||
* @param string $listenerClass 리스너 FQCN
|
||||
* @param array<string, array<string, mixed>> $subscribedHooks 사전 계산된 getSubscribedHooks() 결과
|
||||
* @param string|null $source 등록 출처 ('core', 모듈/플러그인 식별자)
|
||||
*/
|
||||
public static function registerFromCache(string $listenerClass, array $subscribedHooks, ?string $source = null): void
|
||||
{
|
||||
$key = ($source ?? 'unknown').'::'.$listenerClass;
|
||||
if (isset(self::$registered[$key])) {
|
||||
return; // 동일 PHP process 내 중복 등록 방지 (스캔 경로와 동일 계약)
|
||||
}
|
||||
|
||||
self::applySubscribedHooks($listenerClass, $subscribedHooks, $source);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구독 훅 배열을 HookManager 에 등록합니다 (스캔/캐시 경로 공통).
|
||||
*
|
||||
* register()(런타임 getSubscribedHooks 호출)와 registerFromCache()(캐시 읽기)가
|
||||
* 이 메서드에 위임하여 등록 결과의 동일성을 보장한다.
|
||||
*
|
||||
* @param string $listenerClass 리스너 FQCN
|
||||
* @param array<string, array<string, mixed>> $subscribedHooks 구독 훅 매핑
|
||||
* @param string|null $source 등록 출처
|
||||
*/
|
||||
private static function applySubscribedHooks(string $listenerClass, array $subscribedHooks, ?string $source): void
|
||||
{
|
||||
$key = ($source ?? 'unknown').'::'.$listenerClass;
|
||||
self::$registered[$key] = true;
|
||||
|
||||
foreach ($subscribedHooks as $hookName => $config) {
|
||||
$method = $config['method'] ?? 'handle';
|
||||
$priority = $config['priority'] ?? 10;
|
||||
|
||||
@@ -2961,13 +2961,6 @@ class ModuleManager implements ModuleManagerInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈의 훅 리스너를 자동으로 등록합니다.
|
||||
*
|
||||
* 비활성화된 모듈의 훅 리스너는 등록하지 않습니다.
|
||||
*
|
||||
* @param ModuleInterface $module 모듈 인스턴스
|
||||
*/
|
||||
protected function registerModuleHookListeners(ModuleInterface $module): void
|
||||
{
|
||||
// 모듈이 getHookListeners 메서드를 가지고 있는지 확인
|
||||
@@ -2981,6 +2974,11 @@ class ModuleManager implements ModuleManagerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
// 캐시 우선: 사전 계산된 훅 매핑이 있으면 클래스 로딩 없이 등록.
|
||||
if ($this->registerExtensionHookListenersFromCache('modules', $module->getIdentifier())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$listeners = $module->getHookListeners();
|
||||
|
||||
foreach ($listeners as $listenerClass) {
|
||||
@@ -3010,6 +3008,44 @@ class ModuleManager implements ModuleManagerInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 훅 캐시에서 확장(모듈/플러그인)의 정적 훅 리스너를 등록합니다.
|
||||
*
|
||||
* 캐시 파일(bootstrap/cache/hooks.php)에 해당 확장 식별자의 항목이 있으면
|
||||
* getSubscribedHooks() 클래스 로딩·리플렉션 없이 사전 계산 매핑으로 등록한다.
|
||||
* 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔 폴백).
|
||||
*
|
||||
* @param string $bucket 캐시 버킷 ('modules' 또는 'plugins')
|
||||
* @param string $identifier 확장 식별자
|
||||
* @return bool 캐시로 등록했으면 true, 캐시 부재/해당 항목 없음/테스트 환경이면 false (스캔 폴백)
|
||||
*/
|
||||
protected function registerExtensionHookListenersFromCache(string $bucket, string $identifier): bool
|
||||
{
|
||||
if (app()->environment('testing')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cache = app(HookCacheManager::class)->read();
|
||||
|
||||
if ($cache === null || ! isset($cache[$bucket][$identifier])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($cache[$bucket][$identifier] as $entry) {
|
||||
try {
|
||||
HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], $identifier);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('확장 훅 리스너 캐시 등록 중 오류 발생', [
|
||||
'listener' => $entry['listener'] ?? null,
|
||||
'identifier' => $identifier,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈의 브로드캐스트 채널을 등록합니다.
|
||||
*
|
||||
|
||||
@@ -2802,13 +2802,6 @@ class PluginManager implements PluginManagerInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인의 훅 리스너를 자동으로 등록합니다.
|
||||
*
|
||||
* 비활성화된 플러그인의 훅 리스너는 등록하지 않습니다.
|
||||
*
|
||||
* @param PluginInterface $plugin 플러그인 인스턴스
|
||||
*/
|
||||
protected function registerPluginHookListeners(PluginInterface $plugin): void
|
||||
{
|
||||
// 플러그인 활성화 상태 확인 (비활성화된 플러그인의 훅은 등록하지 않음)
|
||||
@@ -2817,6 +2810,11 @@ class PluginManager implements PluginManagerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
// 캐시 우선: 사전 계산된 훅 매핑이 있으면 클래스 로딩 없이 등록.
|
||||
if ($this->registerExtensionHookListenersFromCache('plugins', $plugin->getIdentifier())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$listeners = $plugin->getHookListeners();
|
||||
|
||||
foreach ($listeners as $listenerClass) {
|
||||
@@ -2846,6 +2844,44 @@ class PluginManager implements PluginManagerInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 훅 캐시에서 플러그인의 정적 훅 리스너를 등록합니다.
|
||||
*
|
||||
* 캐시 파일(bootstrap/cache/hooks.php)에 해당 플러그인 식별자의 항목이 있으면
|
||||
* getSubscribedHooks() 클래스 로딩·리플렉션 없이 사전 계산 매핑으로 등록한다.
|
||||
* 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔 폴백).
|
||||
*
|
||||
* @param string $bucket 캐시 버킷 ('plugins')
|
||||
* @param string $identifier 플러그인 식별자
|
||||
* @return bool 캐시로 등록했으면 true, 캐시 부재/해당 항목 없음/테스트 환경이면 false (스캔 폴백)
|
||||
*/
|
||||
protected function registerExtensionHookListenersFromCache(string $bucket, string $identifier): bool
|
||||
{
|
||||
if (app()->environment('testing')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cache = app(HookCacheManager::class)->read();
|
||||
|
||||
if ($cache === null || ! isset($cache[$bucket][$identifier])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($cache[$bucket][$identifier] as $entry) {
|
||||
try {
|
||||
HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], $identifier);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('확장 훅 리스너 캐시 등록 중 오류 발생', [
|
||||
'listener' => $entry['listener'] ?? null,
|
||||
'identifier' => $identifier,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인의 브로드캐스트 채널을 등록합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Support\ConfigCacheHelper;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 확장 활성화/비활성화 시 config 캐시를 재생성하는 리스너.
|
||||
*
|
||||
* 확장이 활성화·비활성화되면 다음 요청부터 그 확장의 config/settings 가 부팅 시
|
||||
* 주입 대상에 포함/제외된다. config:cache 가 켜진 환경에서는 optimizeSystem 이
|
||||
* 캐시를 만든 시점의 "활성 확장 스냅샷" 이 stale 해지므로, 라이프사이클 직후
|
||||
* config 캐시를 재빌드해 캐시가 최신 활성 목록을 반영하도록 한다.
|
||||
*
|
||||
* install/uninstall/update 는 ExtensionManager::updateComposerAutoload() 가 오토로드·훅
|
||||
* 캐시와 나란히 config 캐시를 재생성하므로 여기서 구독하지 않는다(이중 재생성 회피).
|
||||
* 이 리스너는 updateComposerAutoload 를 거치지 않는 activate/deactivate 만 담당한다.
|
||||
*
|
||||
* @see \App\Extension\ExtensionManager::updateComposerAutoload()
|
||||
* @see \App\Support\ConfigCacheHelper
|
||||
*/
|
||||
class ExtensionConfigCacheListener implements HookListenerInterface
|
||||
{
|
||||
/**
|
||||
* 구독할 훅 목록을 반환합니다.
|
||||
*
|
||||
* activate/deactivate 만 구독한다 (install/uninstall/update 는 updateComposerAutoload 담당).
|
||||
*
|
||||
* @return array<string, array{method: string, priority: int}> 훅 이름 → 메서드/우선순위 매핑
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
$hooks = [];
|
||||
$hookNames = [
|
||||
'core.modules.activated',
|
||||
'core.modules.after_deactivate',
|
||||
'core.plugins.activated',
|
||||
'core.plugins.after_deactivate',
|
||||
'core.templates.activated',
|
||||
'core.templates.after_deactivate',
|
||||
];
|
||||
|
||||
foreach ($hookNames as $hookName) {
|
||||
$hooks[$hookName] = [
|
||||
'method' => 'onExtensionToggled',
|
||||
'priority' => 30,
|
||||
];
|
||||
}
|
||||
|
||||
return $hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 훅 핸들러 (HookListenerInterface 필수 메서드).
|
||||
*
|
||||
* @param mixed ...$args 훅 인자
|
||||
*/
|
||||
public function handle(...$args): void
|
||||
{
|
||||
// 개별 메서드에서 처리
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 활성화/비활성화 직후 config 캐시를 재생성합니다.
|
||||
*
|
||||
* @param mixed ...$args 훅 인자 (확장 식별자 등)
|
||||
*/
|
||||
public function onExtensionToggled(...$args): void
|
||||
{
|
||||
try {
|
||||
ConfigCacheHelper::rebuild();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[Config] 확장 토글 후 config 캐시 재생성 실패', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,9 @@ class NotificationHookListener implements HookListenerInterface
|
||||
*/
|
||||
public function registerDynamicHooks(): void
|
||||
{
|
||||
if (! Schema::hasTable('notification_definitions')) {
|
||||
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 information_schema 쿼리 제거).
|
||||
// 인스톨러 이전/마이그레이션 전 환경에서는 기존 hasTable 폴백으로 안전하게 스킵.
|
||||
if (! config('app.installer_completed') && ! Schema::hasTable('notification_definitions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ use App\Enums\DeactivationReason;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Extension\CoreVersionChecker;
|
||||
use App\Extension\ExtensionManager;
|
||||
use App\Extension\HookCacheManager;
|
||||
use App\Extension\HookListenerRegistrar;
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\IdentityVerification\IdentityVerificationManager;
|
||||
@@ -151,6 +152,15 @@ class CoreServiceProvider extends ServiceProvider
|
||||
}
|
||||
}
|
||||
|
||||
// 확장 소스 classmap 등록 (FQCN → 절대경로) — findFile 파일시스템 스캔 제거 (lazy include).
|
||||
if (! empty($extensionAutoloads['src_classmap'])) {
|
||||
$absoluteClassmap = [];
|
||||
foreach ($extensionAutoloads['src_classmap'] as $fqcn => $relPath) {
|
||||
$absoluteClassmap[$fqcn] = base_path($relPath);
|
||||
}
|
||||
$loader->addClassMap($absoluteClassmap);
|
||||
}
|
||||
|
||||
// Classmap 파일 로드 (module.php, plugin.php)
|
||||
if (! empty($extensionAutoloads['classmap'])) {
|
||||
foreach ($extensionAutoloads['classmap'] as $file) {
|
||||
@@ -866,12 +876,14 @@ class CoreServiceProvider extends ServiceProvider
|
||||
|
||||
// registerActivityLogManager() 제거됨 — Monolog 채널(config/logging.php 'activity')로 대체
|
||||
|
||||
/**
|
||||
* app/Listeners/ 디렉토리에서 HookListenerInterface 구현체를 자동 발견하여 등록합니다.
|
||||
* 하위 디렉토리까지 재귀적으로 스캔합니다.
|
||||
*/
|
||||
private function registerCoreHookListeners(): void
|
||||
{
|
||||
// 캐시 우선: bootstrap/cache/hooks.php 가 있으면 스캔·리플렉션 없이 등록.
|
||||
// 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔).
|
||||
if (! $this->app->environment('testing') && $this->registerCoreHookListenersFromCache()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$listenersPath = app_path('Listeners');
|
||||
|
||||
if (! is_dir($listenersPath)) {
|
||||
@@ -910,6 +922,41 @@ class CoreServiceProvider extends ServiceProvider
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 훅 캐시에서 코어 리스너를 등록합니다.
|
||||
*
|
||||
* 캐시 파일(bootstrap/cache/hooks.php)이 존재하면 디렉토리 스캔·class_implements
|
||||
* 리플렉션·getSubscribedHooks() 클래스 로딩 없이 사전 계산 매핑으로 등록한다.
|
||||
* 동적 훅(registerDynamicHooks) 보유 리스너는 스캔 경로와 동일하게 boot 후반부 실행을 위해 지연 목록에 담는다.
|
||||
*
|
||||
* @return bool 캐시로 등록했으면 true, 캐시 부재/손상 시 false (스캔 폴백)
|
||||
*/
|
||||
private function registerCoreHookListenersFromCache(): bool
|
||||
{
|
||||
$cache = $this->app->make(HookCacheManager::class)->read();
|
||||
|
||||
if ($cache === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($cache['core'] as $entry) {
|
||||
try {
|
||||
HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], 'core');
|
||||
|
||||
if (! empty($entry['dynamic'])) {
|
||||
$this->deferredDynamicListeners[] = $entry['listener'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('코어 훅 리스너 캐시 등록 중 오류 발생', [
|
||||
'listener' => $entry['listener'] ?? null,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 코어 리스너를 HookManager에 등록합니다.
|
||||
*
|
||||
|
||||
@@ -340,7 +340,9 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface
|
||||
public function listHookTargets(): array
|
||||
{
|
||||
try {
|
||||
if (! Schema::hasTable('identity_policies')) {
|
||||
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 information_schema 쿼리 제거).
|
||||
// 인스톨러 이전/마이그레이션 전 환경에서는 기존 hasTable 폴백으로 안전하게 빈 배열 반환.
|
||||
if (! config('app.installer_completed') && ! Schema::hasTable('identity_policies')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -404,7 +406,9 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface
|
||||
{
|
||||
try {
|
||||
$table = (new $modelClass)->getTable();
|
||||
if (! Schema::hasTable($table)) {
|
||||
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 information_schema 쿼리 제거).
|
||||
// 인스톨러 이전/마이그레이션 전 환경에서는 기존 hasTable 폴백으로 null 반환(필터 미적용).
|
||||
if (! config('app.installer_completed') && ! Schema::hasTable($table)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Services;
|
||||
|
||||
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use App\Support\ConfigCacheHelper;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -369,8 +369,8 @@ class GeoIpDatabaseService
|
||||
$current['last_updated_at'] = now()->toIso8601String();
|
||||
$this->configRepository->saveCategory('geoip', $current);
|
||||
|
||||
// Laravel config 캐시 재로드 강제
|
||||
Artisan::call('config:clear');
|
||||
// config 캐시 재로드 강제 + 즉시 재생성 (clear 만 하면 캐시 비활성 잔존).
|
||||
ConfigCacheHelper::rebuild();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('GeoIP last_updated_at 갱신 실패', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\AttachmentRepositoryInterface;
|
||||
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Http\Resources\AttachmentResource;
|
||||
use App\Support\ConfigCacheHelper;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -32,11 +33,14 @@ class SettingsService
|
||||
* 시스템 설정 캐시를 무효화합니다.
|
||||
*
|
||||
* 설정 저장 시 4곳에서 중복되던 로직을 단일 메서드로 통합했습니다.
|
||||
* config 캐시는 clear 후 즉시 재생성한다(ConfigCacheHelper). config:clear 만
|
||||
* 하면 config:cache 가 비워진 채 재생성되지 않아 이후 모든 요청이 config 파일을
|
||||
* 재파싱하는 성능 손실이 발생하기 때문이다.
|
||||
*/
|
||||
private function invalidateSettingsCache(): void
|
||||
{
|
||||
$this->cache->forget('settings.system');
|
||||
Artisan::call('config:clear');
|
||||
ConfigCacheHelper::rebuild();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -903,10 +907,12 @@ class SettingsService
|
||||
}
|
||||
|
||||
Artisan::call('cache:clear');
|
||||
Artisan::call('config:clear');
|
||||
Artisan::call('route:clear');
|
||||
Artisan::call('view:clear');
|
||||
|
||||
// config 는 clear 후 즉시 재생성 (비운 채 두면 이후 모든 요청이 config 재파싱).
|
||||
ConfigCacheHelper::rebuild();
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
@@ -952,7 +958,8 @@ class SettingsService
|
||||
$envContent = preg_replace('/^APP_KEY=.*/m', 'APP_KEY='.$newKey, $envContent);
|
||||
file_put_contents($envPath, $envContent);
|
||||
|
||||
Artisan::call('config:clear');
|
||||
// .env 변경 반영 + config 캐시 재생성 (clear 만 하면 캐시 비활성 상태로 잔존).
|
||||
ConfigCacheHelper::rebuild();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* config 캐시(bootstrap/cache/config.php) 재빌드 헬퍼.
|
||||
*
|
||||
* config 의 소스(config/*.php, .env, storage/app/settings/*.json, 활성 확장 목록)를
|
||||
* 변경하는 모든 라이프사이클 지점이 이 헬퍼를 호출해 "변경 반영 + 캐시 재최적화"를
|
||||
* 일관되게 수행한다. 산발적으로 config:clear/config:cache 를 각 지점에 뿌리면
|
||||
* 누락이 생겨(설정 저장은 clear 만, 확장 설치는 clear 조차 안 함) config:cache 가
|
||||
* 한 번 비워진 뒤 재생성되지 않아 성능 이점이 영구히 사라진다 — 이를 단일 SSoT 로 막는다.
|
||||
*
|
||||
* 정책: 환경 무관 항상 재생성. config:cache 는 그 자체로 부팅 비용을
|
||||
* 절감하고, G7 설정은 config 캐시에 박제되지 않고 매 요청 SettingsServiceProvider /
|
||||
* CoreServiceProvider 의 런타임 Config::set() 으로 재주입되므로(설정 stale 없음),
|
||||
* 항상 켜두는 것이 이득이다. local 개발 시 config/*.php 수정이 즉시 반영되지 않는 점은
|
||||
* 개발자가 `php artisan config:clear` 로 대응하는 개발자 책임 영역이다.
|
||||
*/
|
||||
class ConfigCacheHelper
|
||||
{
|
||||
/**
|
||||
* config 캐시를 비우고 즉시 재생성합니다.
|
||||
*
|
||||
* 설치 미완료(installer 실행 전) 환경에서는 config:cache 가 불완전한 부팅을
|
||||
* 캐시에 박제해 부팅 실패를 유발할 수 있으므로, clear 만 수행하고 재생성은 건너뛴다.
|
||||
* 테스트 환경(APP_ENV=testing)은 tests/bootstrap.php 가 config 캐시를 자동 삭제하며
|
||||
* 캐시 생성이 테스트 격리를 깨므로 no-op 로 스킵한다.
|
||||
*
|
||||
* 재생성 실패(권한/디스크 등)는 치명적이지 않다 — config:clear 로 stale 캐시는
|
||||
* 이미 제거되어 다음 요청이 fresh config 로 안전하게 부팅되므로, 경고만 남기고 넘어간다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function rebuild(): void
|
||||
{
|
||||
// 항상 stale 캐시부터 제거 (값 반영 보장). testing 환경에서도 clear 는 수행한다 —
|
||||
// 캐시 파일이 없으면 사실상 no-op 이고 격리를 깨지 않으며, 설정 저장 후 값 반영을
|
||||
// 검증하는 기존 테스트(SettingsServiceConfigClearTest) 계약을 유지한다.
|
||||
Artisan::call('config:clear');
|
||||
|
||||
// config:cache 생성은 격리를 깨므로(캐시된 config 가 다음 테스트로 누출) testing 에서
|
||||
// 스킵한다. 설치 미완료 상태에서도 불완전 config 박제를 피하려 재생성하지 않는다.
|
||||
if (app()->environment('testing') || ! self::isInstalled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Artisan::call('config:cache');
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('config 캐시 재생성 실패 (config:clear 로 stale 은 제거됨 — 다음 요청은 비캐시 부팅)', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* config 캐시만 제거합니다 (재생성 없음).
|
||||
*
|
||||
* 재생성이 부적절한 특수 경로(예: 설치 직전 초기화)를 위한 보조 진입점.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function clear(): void
|
||||
{
|
||||
Artisan::call('config:clear');
|
||||
}
|
||||
|
||||
/**
|
||||
* G7 설치 완료 여부를 확인합니다.
|
||||
*
|
||||
* config('app.installer_completed') 는 config:clear 직후 재로드되어 신뢰 가능하며,
|
||||
* 보조로 storage/app/g7_installed 플래그 파일도 확인한다.
|
||||
*
|
||||
* @return bool 설치 완료 시 true
|
||||
*/
|
||||
private static function isInstalled(): bool
|
||||
{
|
||||
if (config('app.installer_completed')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return File::exists(storage_path('app/g7_installed'));
|
||||
}
|
||||
}
|
||||
@@ -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()` 메서드를 구현합니다.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
### Added
|
||||
|
||||
- 설정 복원 관련 안내 메시지 일본어 번역 추가 (`settings.backup_path_required`·`restore_success`·`restore_failed`·`restore_error`) — 백업 경로 미입력 안내와 설정 복원 성공/실패 결과가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 훅 매핑 캐시 관리 안내 메시지 일본어 번역 추가 (`hooks.cache_generated`·`cache_cleared`·`cache_absent`) — 부팅 성능을 위한 훅 캐시 생성·삭제 결과가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'cache_generated' => '静的フック マッピング キャッシュを作成しました。(コア :core / モジュール :modules / プラグイン :plugins) → :path',
|
||||
'cache_cleared' => '静的フック マッピング キャッシュを削除しました。',
|
||||
'cache_absent' => '削除する静的フック マッピング キャッシュがありません。',
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Static hook mapping cache (hooks:cache / hooks:clear)
|
||||
'cache_generated' => 'Static hook mapping cache generated. (core :core / modules :modules / plugins :plugins) → :path',
|
||||
'cache_cleared' => 'Static hook mapping cache cleared.',
|
||||
'cache_absent' => 'No static hook mapping cache to clear.',
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// 정적 훅 매핑 캐시 (hooks:cache / hooks:clear)
|
||||
'cache_generated' => '정적 훅 매핑 캐시를 생성했습니다. (코어 :core / 모듈 :modules / 플러그인 :plugins) → :path',
|
||||
'cache_cleared' => '정적 훅 매핑 캐시를 삭제했습니다.',
|
||||
'cache_absent' => '삭제할 정적 훅 매핑 캐시가 없습니다.',
|
||||
];
|
||||
+22
-9
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
// 설치 완료 여부 확인
|
||||
$installedFlagPath = __DIR__ . '/../storage/app/g7_installed';
|
||||
$envPath = __DIR__ . '/../.env';
|
||||
$installedFlagPath = __DIR__.'/../storage/app/g7_installed';
|
||||
$envPath = __DIR__.'/../.env';
|
||||
|
||||
// 설치 확인: g7_installed 파일 또는 .env의 INSTALLER_COMPLETED
|
||||
$isInstalled = file_exists($installedFlagPath) || (
|
||||
@@ -11,11 +11,11 @@ $isInstalled = file_exists($installedFlagPath) || (
|
||||
);
|
||||
|
||||
// 미설치 시 /install로 리다이렉트
|
||||
if (!$isInstalled) {
|
||||
$installUrl = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/') . '/install';
|
||||
if (! $isInstalled) {
|
||||
$installUrl = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/').'/install';
|
||||
echo '<script>
|
||||
alert("G7 최초 사용을 위해 설치 절차가 필요합니다. 설치 화면으로 이동합니다.");
|
||||
window.location.href="' . htmlspecialchars($installUrl) . '";
|
||||
window.location.href="'.htmlspecialchars($installUrl).'";
|
||||
</script>';
|
||||
exit;
|
||||
}
|
||||
@@ -42,7 +42,7 @@ if (file_exists($extensionAutoloadFile)) {
|
||||
$extensionAutoloads = require $extensionAutoloadFile;
|
||||
|
||||
// PSR-4 네임스페이스 등록
|
||||
if (!empty($extensionAutoloads['psr4'])) {
|
||||
if (! empty($extensionAutoloads['psr4'])) {
|
||||
foreach ($extensionAutoloads['psr4'] as $namespace => $paths) {
|
||||
// 경로가 배열인 경우와 문자열인 경우 모두 처리
|
||||
$paths = (array) $paths;
|
||||
@@ -55,8 +55,21 @@ if (file_exists($extensionAutoloadFile)) {
|
||||
}
|
||||
}
|
||||
|
||||
// 확장 소스 classmap 등록 (FQCN → 절대경로).
|
||||
// findFile 이 파일시스템 스캔(is_dir/file_exists stat) 없이 in-memory 맵에서 즉시
|
||||
// 경로를 반환하도록 한다. cold OPcache / 느린 파일시스템 환경의 findFile 비용 제거.
|
||||
// 클래스 로딩은 여전히 lazy — 실제 사용 시점에만 include. classmap 미포함 클래스는
|
||||
// 위 PSR-4 등록으로 폴백(안전망).
|
||||
if (! empty($extensionAutoloads['src_classmap'])) {
|
||||
$absoluteClassmap = [];
|
||||
foreach ($extensionAutoloads['src_classmap'] as $fqcn => $relPath) {
|
||||
$absoluteClassmap[$fqcn] = __DIR__.'/../'.$relPath;
|
||||
}
|
||||
$loader->addClassMap($absoluteClassmap);
|
||||
}
|
||||
|
||||
// Classmap 파일 로드 (module.php, plugin.php)
|
||||
if (!empty($extensionAutoloads['classmap'])) {
|
||||
if (! empty($extensionAutoloads['classmap'])) {
|
||||
foreach ($extensionAutoloads['classmap'] as $file) {
|
||||
$absolutePath = __DIR__.'/../'.$file;
|
||||
if (file_exists($absolutePath)) {
|
||||
@@ -66,7 +79,7 @@ if (file_exists($extensionAutoloadFile)) {
|
||||
}
|
||||
|
||||
// Files 로드 (헬퍼 함수 등)
|
||||
if (!empty($extensionAutoloads['files'])) {
|
||||
if (! empty($extensionAutoloads['files'])) {
|
||||
foreach ($extensionAutoloads['files'] as $file) {
|
||||
$absolutePath = __DIR__.'/../'.$file;
|
||||
if (file_exists($absolutePath)) {
|
||||
@@ -76,7 +89,7 @@ if (file_exists($extensionAutoloadFile)) {
|
||||
}
|
||||
|
||||
// Vendor autoloads 로드 (모듈/플러그인의 composer 의존성)
|
||||
if (!empty($extensionAutoloads['vendor_autoloads'])) {
|
||||
if (! empty($extensionAutoloads['vendor_autoloads'])) {
|
||||
foreach ($extensionAutoloads['vendor_autoloads'] as $vendorAutoload) {
|
||||
$absolutePath = __DIR__.'/../'.$vendorAutoload;
|
||||
if (file_exists($absolutePath)) {
|
||||
|
||||
@@ -1491,6 +1491,28 @@ if (!function_exists('clearCacheSSE')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('optimizeConfigCacheSSE')) {
|
||||
/**
|
||||
* 설치 완료 직후 config 캐시를 최초 생성한다.
|
||||
*
|
||||
* complete_flag 이후에 실행되어 installer_completed=true 상태에서 캐시를 만들며,
|
||||
* 이 지점이 없으면 설치는 config:cache 가 꺼진(매 요청 config 재파싱) 상태로 종료된다.
|
||||
* best_effort — 실패해도 설치 자체는 완료 처리한다(비캐시 부팅은 정상 동작).
|
||||
*
|
||||
* @return array 태스크 실행 결과
|
||||
*/
|
||||
function optimizeConfigCacheSSE(): array
|
||||
{
|
||||
return executeArtisanCommandSSE(
|
||||
artisanCommand: 'config:cache',
|
||||
taskId: 'config_cache',
|
||||
taskNameKey: 'task_config_cache',
|
||||
successMsgKey: 'log_config_cache_success',
|
||||
errorMsgKey: 'error_config_cache_failed'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('createSettingsJsonSSE')) {
|
||||
function createSettingsJsonSSE(): array
|
||||
{
|
||||
@@ -1757,6 +1779,9 @@ if (!function_exists('runInstallationTasks')) {
|
||||
$tasks[] = ['id' => 'create_settings_json', 'function' => 'createSettingsJsonSSE'];
|
||||
$tasks[] = ['id' => 'cache_clear', 'function' => 'clearCacheSSE'];
|
||||
$tasks[] = ['id' => 'complete_flag', 'function' => 'setInstallationCompleteSSE'];
|
||||
// complete_flag(installer_completed=true) 이후에 config 캐시를 최초 생성해야
|
||||
// 설치가 config:cache 켜진 상태로 시작한다. best_effort — 실패해도 설치는 완료.
|
||||
$tasks[] = ['id' => 'config_cache', 'function' => 'optimizeConfigCacheSSE', 'best_effort' => true];
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
if (checkAbortStatusSSE()) {
|
||||
|
||||
@@ -270,6 +270,7 @@ return [
|
||||
'task_cache_clear' => 'Cleaning Up Temporary Files',
|
||||
'task_create_settings_json' => 'Creating Settings Files',
|
||||
'task_complete_flag' => 'Finalizing Installation',
|
||||
'task_config_cache' => 'Building Configuration Cache',
|
||||
'task_unknown' => 'Unknown Task',
|
||||
|
||||
// Task Group Names
|
||||
@@ -371,9 +372,11 @@ return [
|
||||
|
||||
// Error Messages - Worker (Cache)
|
||||
'error_cache_clear_failed' => 'Cache clearing failed',
|
||||
'error_config_cache_failed' => 'Configuration cache build failed',
|
||||
|
||||
// Log Messages - Worker (Cache)
|
||||
'log_cache_clear_success' => 'Cache clearing completed',
|
||||
'log_config_cache_success' => 'Configuration cache built',
|
||||
|
||||
// Error Messages - Worker (Settings JSON)
|
||||
'error_settings_json_failed' => 'Settings file creation failed',
|
||||
|
||||
@@ -270,6 +270,7 @@ return [
|
||||
'task_cache_clear' => '임시 파일 정리',
|
||||
'task_create_settings_json' => '설정 파일 생성',
|
||||
'task_complete_flag' => '설치 완료 처리',
|
||||
'task_config_cache' => '설정 캐시 생성',
|
||||
'task_unknown' => '알 수 없는 작업',
|
||||
|
||||
// 작업 그룹명
|
||||
@@ -371,9 +372,11 @@ return [
|
||||
|
||||
// 에러 메시지 - Worker (Cache)
|
||||
'error_cache_clear_failed' => '캐시 클리어에 실패했습니다',
|
||||
'error_config_cache_failed' => '설정 캐시 생성에 실패했습니다',
|
||||
|
||||
// 로그 메시지 - Worker (Cache)
|
||||
'log_cache_clear_success' => '캐시 클리어 완료',
|
||||
'log_config_cache_success' => '설정 캐시 생성 완료',
|
||||
|
||||
// 에러 메시지 - Worker (Settings JSON)
|
||||
'error_settings_json_failed' => '설정 파일 생성에 실패했습니다',
|
||||
|
||||
@@ -788,6 +788,14 @@ if (isset($_GET['ajax_action'])) {
|
||||
<span>전체 최적화</span>
|
||||
<span class="text-[10px] opacity-60">(optimize:clear)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('hooks:cache')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>훅 캐시 생성</span>
|
||||
<span class="text-[10px] opacity-60">(hooks:cache)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('hooks:clear')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>훅 캐시 삭제</span>
|
||||
<span class="text-[10px] opacity-60">(hooks:clear)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('migrate:fresh --seed')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>DB 초기화</span>
|
||||
<span class="text-[10px] opacity-60">(migrate:fresh --seed)</span>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use App\Extension\ExtensionManager;
|
||||
use App\Extension\HookCacheManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* hooks:cache / hooks:clear Artisan 커맨드 Feature 테스트.
|
||||
*
|
||||
* 정적 훅 매핑 캐시 생성/삭제 명령이 캐시 파일을 실제로 생성·삭제하는지 검증한다.
|
||||
*/
|
||||
class HooksCacheCommandTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private string $tmpCachePath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->tmpCachePath = storage_path('framework/testing/hooks_cmd_'.uniqid().'.php');
|
||||
|
||||
// 실제 bootstrap/cache/hooks.php 를 건드리지 않도록 임시 경로 매니저를 컨테이너에 바인딩
|
||||
$this->app->instance(HookCacheManager::class, new class($this->tmpCachePath) extends HookCacheManager
|
||||
{
|
||||
public function __construct(string $path)
|
||||
{
|
||||
$this->cacheFilePath = $path;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::exists($this->tmpCachePath)) {
|
||||
File::delete($this->tmpCachePath);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario cache_state=absent, listener_type=action_sync, regeneration_trigger=hooks_cache_command, registration_source=module
|
||||
*
|
||||
* @effects hooks_cache_command_generates_cache_file, removed_core_listener_dropped_from_cache_after_regeneration
|
||||
*/
|
||||
public function test_hooks_cache_command_generates_cache_file(): void
|
||||
{
|
||||
$this->assertFalse(File::exists($this->tmpCachePath));
|
||||
|
||||
$this->artisan('hooks:cache')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertTrue(File::exists($this->tmpCachePath), 'hooks:cache 는 캐시 파일을 생성해야 합니다');
|
||||
|
||||
$cache = require $this->tmpCachePath;
|
||||
$this->assertArrayHasKey('core', $cache);
|
||||
$this->assertArrayHasKey('modules', $cache);
|
||||
$this->assertArrayHasKey('plugins', $cache);
|
||||
$this->assertGreaterThan(0, count($cache['core']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario cache_state=present_valid, listener_type=action_queued, regeneration_trigger=hooks_clear_command, registration_source=plugin
|
||||
*
|
||||
* @effects hooks_clear_command_removes_cache_file
|
||||
*/
|
||||
public function test_hooks_clear_command_removes_cache_file(): void
|
||||
{
|
||||
// 먼저 생성
|
||||
$this->artisan('hooks:cache')->assertSuccessful();
|
||||
$this->assertTrue(File::exists($this->tmpCachePath));
|
||||
|
||||
// 삭제
|
||||
$this->artisan('hooks:clear')->assertSuccessful();
|
||||
$this->assertFalse(File::exists($this->tmpCachePath), 'hooks:clear 는 캐시 파일을 삭제해야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario cache_state=present_but_testing_env, listener_type=action_queued, regeneration_trigger=hooks_cache_command, registration_source=module
|
||||
*
|
||||
* @effects hooks_clear_is_safe_when_cache_absent
|
||||
*/
|
||||
public function test_hooks_clear_command_is_safe_when_cache_absent(): void
|
||||
{
|
||||
$this->assertFalse(File::exists($this->tmpCachePath));
|
||||
|
||||
// 캐시 부재 상태에서도 성공 종료 (route:clear 동형)
|
||||
$this->artisan('hooks:clear')->assertSuccessful();
|
||||
}
|
||||
|
||||
/**
|
||||
* extension:update-autoload 가 오토로드 캐시와 함께 정적 훅 매핑 캐시를 재생성하는지 검증합니다.
|
||||
*
|
||||
* 코어 업데이트(CoreUpdateService::clearAllCaches → extension:update-autoload)의
|
||||
* 훅 캐시 재생성 choke point. 코어 리스너 추가/변경/삭제가 이 경로로 캐시에 반영된다.
|
||||
*
|
||||
* 격리: ExtensionManager::generateAutoloadFile() 은 mock 으로 no-op 처리한다.
|
||||
* 실제 커맨드를 그대로 실행하면 testing DB(활성 확장 0건) 기준으로 실 경로
|
||||
* `bootstrap/cache/autoload-extensions.php` 를 빈 배열로 덮어써 dev 환경을 오염시킨다
|
||||
* (feedback_test_must_protect_dev_directories). 훅 캐시는 setUp 의 임시 경로 매니저로 격리.
|
||||
*
|
||||
* @scenario cache_state=corrupted_broken_structure, listener_type=action_queued, regeneration_trigger=core_update, registration_source=plugin
|
||||
*
|
||||
* @effects core_update_regenerates_hook_cache_via_clearAllCaches_extension_update_autoload, extension_update_regenerates_hook_cache_via_updateComposerAutoload
|
||||
*/
|
||||
public function test_extension_update_autoload_regenerates_hook_cache(): void
|
||||
{
|
||||
// 실 오토로드 캐시 파일을 건드리지 않도록 generateAutoloadFile 을 no-op 으로 대체
|
||||
$extensionManager = Mockery::mock(ExtensionManager::class)->makePartial();
|
||||
$extensionManager->shouldReceive('generateAutoloadFile')->once();
|
||||
$this->app->instance(ExtensionManager::class, $extensionManager);
|
||||
|
||||
$this->assertFalse(File::exists($this->tmpCachePath));
|
||||
|
||||
$this->artisan('extension:update-autoload')->assertSuccessful();
|
||||
|
||||
$this->assertTrue(
|
||||
File::exists($this->tmpCachePath),
|
||||
'extension:update-autoload 는 훅 매핑 캐시를 함께 재생성해야 합니다 (코어 업데이트 choke point)'
|
||||
);
|
||||
|
||||
$cache = require $this->tmpCachePath;
|
||||
$this->assertArrayHasKey('core', $cache);
|
||||
$this->assertGreaterThan(0, count($cache['core']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use App\Extension\ExtensionManager;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 확장 소스 classmap(B2) 생성 무결성 테스트.
|
||||
*
|
||||
* 서빙 API 부팅 비용 최적화: 확장 소스 클래스를 classmap 에 편입하여 findFile 의
|
||||
* 파일시스템 스캔(PSR-4 폴백 stat)을 제거한다. 안전 게이트:
|
||||
* - FQCN 추출 정확성 (namespace + class/interface/trait/enum)
|
||||
* - 소스 클래스만 대상 (서드파티 vendor 미포함 — 격리 불변조건 보존)
|
||||
* - 매핑 경로 유효성
|
||||
*
|
||||
* @see ExtensionManager::buildSourceClassmap()
|
||||
*/
|
||||
class ExtensionSourceClassmapTest extends TestCase
|
||||
{
|
||||
private string $fixtureDir;
|
||||
|
||||
private ExposedExtensionManager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->manager = new ExposedExtensionManager(
|
||||
app(ModuleRepositoryInterface::class),
|
||||
app(PluginRepositoryInterface::class),
|
||||
);
|
||||
|
||||
// base_path 하위 임시 픽스처 (buildSourceClassmap 이 base_path 상대경로를 산출하므로)
|
||||
$this->fixtureDir = base_path('storage/framework/testing/classmap_fx_'.uniqid());
|
||||
File::ensureDirectoryExists($this->fixtureDir.'/src');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::isDirectory($this->fixtureDir)) {
|
||||
File::deleteDirectory($this->fixtureDir);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function relative(string $absolute): string
|
||||
{
|
||||
return str_replace('\\', '/', substr($absolute, strlen(base_path()) + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects extracts_fqcn_for_class_declaration, extracts_fqcn_for_interface_declaration, extracts_fqcn_for_trait_declaration, extracts_fqcn_for_enum_declaration
|
||||
*/
|
||||
public function test_extracts_fqcn_for_class_interface_trait_enum(): void
|
||||
{
|
||||
$cases = [
|
||||
'FooClass.php' => "<?php\nnamespace Vendor\\Ext\\Sub;\nclass FooClass {}\n",
|
||||
'BarInterface.php' => "<?php\nnamespace Vendor\\Ext;\ninterface BarInterface {}\n",
|
||||
'BazTrait.php' => "<?php\nnamespace Vendor\\Ext;\ntrait BazTrait {}\n",
|
||||
'QuxEnum.php' => "<?php\nnamespace Vendor\\Ext;\nenum QuxEnum: string { case A = 'a'; }\n",
|
||||
];
|
||||
|
||||
foreach ($cases as $file => $code) {
|
||||
File::put($this->fixtureDir.'/src/'.$file, $code);
|
||||
}
|
||||
|
||||
$psr4 = ['Vendor\\Ext\\' => $this->relative($this->fixtureDir.'/src')];
|
||||
$classmap = $this->manager->exposedBuildSourceClassmap($psr4);
|
||||
|
||||
$this->assertArrayHasKey('Vendor\\Ext\\Sub\\FooClass', $classmap);
|
||||
$this->assertArrayHasKey('Vendor\\Ext\\BarInterface', $classmap);
|
||||
$this->assertArrayHasKey('Vendor\\Ext\\BazTrait', $classmap);
|
||||
$this->assertArrayHasKey('Vendor\\Ext\\QuxEnum', $classmap);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects maps_fqcn_to_correct_relative_path
|
||||
*/
|
||||
public function test_maps_fqcn_to_correct_relative_path(): void
|
||||
{
|
||||
File::ensureDirectoryExists($this->fixtureDir.'/src/Services');
|
||||
File::put($this->fixtureDir.'/src/Services/OrderService.php',
|
||||
"<?php\nnamespace Vendor\\Ext\\Services;\nclass OrderService {}\n");
|
||||
|
||||
$psr4 = ['Vendor\\Ext\\' => $this->relative($this->fixtureDir.'/src')];
|
||||
$classmap = $this->manager->exposedBuildSourceClassmap($psr4);
|
||||
|
||||
$this->assertArrayHasKey('Vendor\\Ext\\Services\\OrderService', $classmap);
|
||||
$expected = $this->relative($this->fixtureDir.'/src/Services/OrderService.php');
|
||||
$this->assertSame($expected, $classmap['Vendor\\Ext\\Services\\OrderService']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects ignores_files_outside_declared_namespace
|
||||
*/
|
||||
public function test_ignores_files_outside_declared_namespace(): void
|
||||
{
|
||||
// PSR-4 네임스페이스와 다른 네임스페이스의 파일은 제외 (다른 PSR-4 항목이 담당)
|
||||
File::put($this->fixtureDir.'/src/Foreign.php',
|
||||
"<?php\nnamespace Other\\Package;\nclass Foreign {}\n");
|
||||
|
||||
$psr4 = ['Vendor\\Ext\\' => $this->relative($this->fixtureDir.'/src')];
|
||||
$classmap = $this->manager->exposedBuildSourceClassmap($psr4);
|
||||
|
||||
$this->assertArrayNotHasKey('Other\\Package\\Foreign', $classmap);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects skips_files_without_top_level_declaration
|
||||
*/
|
||||
public function test_skips_files_without_top_level_declaration(): void
|
||||
{
|
||||
File::put($this->fixtureDir.'/src/helpers.php',
|
||||
"<?php\nfunction ext_helper() { return 1; }\n");
|
||||
|
||||
$psr4 = ['Vendor\\Ext\\' => $this->relative($this->fixtureDir.'/src')];
|
||||
$classmap = $this->manager->exposedBuildSourceClassmap($psr4);
|
||||
|
||||
$this->assertEmpty($classmap);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects missing_directory_returns_empty
|
||||
*/
|
||||
public function test_missing_directory_returns_empty(): void
|
||||
{
|
||||
$psr4 = ['Vendor\\Ext\\' => 'nonexistent/path/xyz'];
|
||||
$classmap = $this->manager->exposedBuildSourceClassmap($psr4);
|
||||
|
||||
$this->assertSame([], $classmap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 생성된 autoload-extensions.php 의 src_classmap 이 소스 클래스만 담고
|
||||
* 서드파티 vendor 경로를 포함하지 않는지 검증 (vendor 격리 불변조건).
|
||||
*
|
||||
* 로컬에 파일이 없으면(테스트 환경 미생성) 스킵.
|
||||
*
|
||||
* @effects generated_src_classmap_excludes_vendor_paths, generated_src_classmap_only_extension_namespaces
|
||||
*/
|
||||
public function test_generated_src_classmap_excludes_vendor_paths(): void
|
||||
{
|
||||
$autoloadFile = base_path('bootstrap/cache/autoload-extensions.php');
|
||||
if (! file_exists($autoloadFile)) {
|
||||
$this->markTestSkipped('autoload-extensions.php 미생성 (extension:update-autoload 필요)');
|
||||
}
|
||||
|
||||
$data = require $autoloadFile;
|
||||
if (empty($data['src_classmap'])) {
|
||||
$this->markTestSkipped('src_classmap 비어있음 (활성 확장 없음)');
|
||||
}
|
||||
|
||||
foreach ($data['src_classmap'] as $fqcn => $path) {
|
||||
// 서드파티 vendor 디렉토리를 가리키면 안 됨 (확장별 독립 vendor 는 자체 autoload 담당)
|
||||
$this->assertStringNotContainsString('/vendor/', $path,
|
||||
"src_classmap 이 vendor 경로를 포함하면 안 됩니다: {$fqcn} => {$path}");
|
||||
// 확장 소스 네임스페이스여야 함
|
||||
$this->assertTrue(
|
||||
str_starts_with($fqcn, 'Modules\\') || str_starts_with($fqcn, 'Plugins\\'),
|
||||
"src_classmap 은 확장 소스 네임스페이스만 포함해야 합니다: {$fqcn}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* addClassMap 등록 시 findFile 이 classMap 에서 즉시 해석(파일시스템 스캔 없이)하고,
|
||||
* classmap 미등록 클래스는 PSR-4 폴백으로 해석되는지 검증한다.
|
||||
*
|
||||
* findFile 이 classMap 을 최우선 조회하므로(vendor/composer/ClassLoader.php),
|
||||
* 등록된 확장 클래스는 파일시스템 접근 없이 경로를 반환한다. 미등록 클래스는
|
||||
* addPsr4 폴백으로 안전하게 해석된다(안전망).
|
||||
*
|
||||
* @effects classmap_present_findFile_hits_without_filesystem_stat, classmap_absent_findFile_falls_back_to_psr4, class_loading_remains_lazy_include_on_use
|
||||
*/
|
||||
public function test_add_class_map_find_file_hit_and_psr4_fallback(): void
|
||||
{
|
||||
// 두 클래스 준비: 하나는 classmap 등록, 하나는 미등록(PSR-4 폴백 대상)
|
||||
File::ensureDirectoryExists($this->fixtureDir.'/src');
|
||||
File::put($this->fixtureDir.'/src/Mapped.php',
|
||||
"<?php\nnamespace Vendor\\CmTest;\nclass Mapped {}\n");
|
||||
File::put($this->fixtureDir.'/src/Unmapped.php',
|
||||
"<?php\nnamespace Vendor\\CmTest;\nclass Unmapped {}\n");
|
||||
|
||||
$loader = require base_path('vendor/autoload.php');
|
||||
|
||||
// PSR-4 등록 (폴백 경로)
|
||||
$loader->addPsr4('Vendor\\CmTest\\', $this->fixtureDir.'/src');
|
||||
|
||||
// classmap 에는 Mapped 만 등록
|
||||
$mappedPath = $this->fixtureDir.'/src/Mapped.php';
|
||||
$loader->addClassMap(['Vendor\\CmTest\\Mapped' => $mappedPath]);
|
||||
|
||||
// classmap hit: 등록 경로 그대로 반환 (파일시스템 스캔 없이)
|
||||
$this->assertSame($mappedPath, $loader->findFile('Vendor\\CmTest\\Mapped'));
|
||||
|
||||
// classmap miss: PSR-4 폴백으로 해석 (경로가 정상 반환)
|
||||
$fallback = $loader->findFile('Vendor\\CmTest\\Unmapped');
|
||||
$this->assertNotFalse($fallback);
|
||||
$this->assertStringEndsWith('Unmapped.php', str_replace('\\', '/', $fallback));
|
||||
|
||||
// 지연 로드: findFile 은 경로만 반환하며 클래스를 include 하지 않는다
|
||||
$this->assertFalse(class_exists('Vendor\\CmTest\\Mapped', false),
|
||||
'findFile 은 경로만 반환하고 include 하지 않아야 합니다 (lazy)');
|
||||
|
||||
// 정리: 이 프로세스 loader 에 남은 테스트 PSR-4/classmap 은 테스트 클래스라 무해
|
||||
}
|
||||
|
||||
/**
|
||||
* 재생성 시 addClassMap 이 동일 FQCN 의 경로를 새 값으로 덮어쓰는지 검증한다.
|
||||
*
|
||||
* 확장 업데이트로 클래스 파일 위치가 바뀌면(같은 FQCN, 새 경로) updateComposerAutoload 이
|
||||
* 재생성 후 런타임 addClassMap 을 호출한다. array_merge 로 새 경로가 옛 경로를 이긴다
|
||||
* (stale 경로 shadow 방지).
|
||||
*
|
||||
* @effects install_regenerates_src_classmap_via_updateComposerAutoload
|
||||
*/
|
||||
public function test_reregistration_overwrites_stale_path_for_same_fqcn(): void
|
||||
{
|
||||
$loader = require base_path('vendor/autoload.php');
|
||||
|
||||
$oldPath = base_path('storage/framework/testing/old_'.uniqid().'.php');
|
||||
$newPath = base_path('storage/framework/testing/new_'.uniqid().'.php');
|
||||
|
||||
// 옛 경로 등록 (요청 부팅 시점 상태 시뮬)
|
||||
$loader->addClassMap(['Vendor\\Regen\\Moved' => $oldPath]);
|
||||
$this->assertSame($oldPath, $loader->findFile('Vendor\\Regen\\Moved'));
|
||||
|
||||
// 재생성 후 새 경로 등록 (updateComposerAutoload → addClassMap 시뮬)
|
||||
$loader->addClassMap(['Vendor\\Regen\\Moved' => $newPath]);
|
||||
|
||||
// 새 경로가 옛 경로를 덮어써야 함 (stale shadow 방지)
|
||||
$this->assertSame($newPath, $loader->findFile('Vendor\\Regen\\Moved'),
|
||||
'재등록 시 동일 FQCN 은 새 경로로 덮어써져야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* src_classmap 키가 없는 구버전 autoload-extensions.php 도 안전하게 동작하는지 검증한다.
|
||||
*
|
||||
* B2 이전 버전에서 업데이트한 사이트는 재생성 전까지 src_classmap 키가 없는 파일을 갖는다.
|
||||
* 진입점 소비자는 `! empty(...)` 가드로 키 부재를 안전하게 건너뛰어야 한다(하위호환).
|
||||
*
|
||||
* @effects classmap_absent_findFile_falls_back_to_psr4
|
||||
*/
|
||||
public function test_missing_src_classmap_key_is_backward_compatible(): void
|
||||
{
|
||||
// 구버전 포맷 (src_classmap 키 없음)
|
||||
$legacy = ['psr4' => [], 'classmap' => [], 'files' => [], 'vendor_autoloads' => []];
|
||||
|
||||
// 소비자 가드 패턴 재현: 키 부재 시 addClassMap 미호출 (예외 없음)
|
||||
$this->assertTrue(empty($legacy['src_classmap']),
|
||||
'구버전 파일은 src_classmap 키가 없어야 하고, 소비자는 이를 안전하게 스킵해야 합니다');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* protected buildSourceClassmap 를 테스트에서 호출하기 위한 서브클래스.
|
||||
*/
|
||||
class ExposedExtensionManager extends ExtensionManager
|
||||
{
|
||||
public function exposedBuildSourceClassmap(array $psr4): array
|
||||
{
|
||||
return $this->buildSourceClassmap($psr4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Extension\HookCacheManager;
|
||||
use App\Extension\HookListenerRegistrar;
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Jobs\DispatchHookListenerJob;
|
||||
use App\Listeners\NotificationHookListener;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 정적 훅 매핑 캐시 무결성 테스트.
|
||||
*
|
||||
* 성능 개선(서빙 API 부팅 비용)의 안전 게이트: 캐시 경로 등록이 스캔 경로 등록과
|
||||
* 바이트 동일해야 하며(훅 발화 누락 0), 캐시 미스는 안전 폴백해야 한다.
|
||||
*
|
||||
* @see HookCacheManager
|
||||
* @see HookListenerRegistrar::registerFromCache()
|
||||
*/
|
||||
class HookCacheManagerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private string $tmpCachePath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
HookManager::resetAll();
|
||||
HookListenerRegistrar::clear();
|
||||
|
||||
// request-scoped static memo 를 테스트 간 리셋 (누출 방지).
|
||||
// 한 프로세스에서 여러 테스트가 도는 PHPUnit 특성상 read() 가 채운 static
|
||||
// memo 가 다음 테스트로 누출되면 격리가 깨진다 — clear() 가 memo 를 null 로 되돌린다.
|
||||
(new HookCacheManager)->clear();
|
||||
|
||||
// 실제 bootstrap/cache/hooks.php 를 건드리지 않도록 임시 경로 사용
|
||||
$this->tmpCachePath = storage_path('framework/testing/hooks_test_'.uniqid().'.php');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::exists($this->tmpCachePath)) {
|
||||
File::delete($this->tmpCachePath);
|
||||
}
|
||||
HookManager::resetAll();
|
||||
HookListenerRegistrar::clear();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* getHooks/getFilters 를 [name => [priority => callbackCount]] 지문으로 변환합니다.
|
||||
*
|
||||
* 클로저는 직접 비교할 수 없으므로 구조(훅명·우선순위·콜백 수)만 비교한다.
|
||||
*
|
||||
* @return array{actions: array, filters: array}
|
||||
*/
|
||||
private function fingerprint(): array
|
||||
{
|
||||
$reduce = function (array $registry): array {
|
||||
$out = [];
|
||||
foreach ($registry as $name => $byPriority) {
|
||||
foreach ($byPriority as $priority => $callbacks) {
|
||||
$out[$name][$priority] = count($callbacks);
|
||||
}
|
||||
}
|
||||
ksort($out);
|
||||
|
||||
return $out;
|
||||
};
|
||||
|
||||
return [
|
||||
'actions' => $reduce(HookManager::getHooks()),
|
||||
'filters' => $reduce(HookManager::getFilters()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 경로 등록 결과가 스캔(register) 경로와 바이트 동일한지 검증합니다.
|
||||
*
|
||||
* 스텁 리스너 3종(action/filter/sync)을 register() 로 등록한 지문과,
|
||||
* 동일 리스너의 getSubscribedHooks() 를 캐시로 저장 후 registerFromCache() 로
|
||||
* 등록한 지문이 완전히 일치해야 한다 (훅 발화 누락 0 게이트).
|
||||
*
|
||||
* @scenario cache_state=present_valid, listener_type=filter, regeneration_trigger=extension_update, registration_source=core
|
||||
*
|
||||
* @effects cache_path_registration_byte_identical_to_scan_path, cache_registered_filter_executes_synchronously_and_returns_value
|
||||
*/
|
||||
public function test_cache_path_registration_is_identical_to_scan_path(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$listeners = [
|
||||
HookCacheStubAction::class,
|
||||
HookCacheStubFilter::class,
|
||||
HookCacheStubSync::class,
|
||||
];
|
||||
|
||||
// --- 스캔 경로 (register → getSubscribedHooks 호출) ---
|
||||
HookManager::resetAll();
|
||||
HookListenerRegistrar::clear();
|
||||
foreach ($listeners as $listener) {
|
||||
HookListenerRegistrar::register($listener, 'stub-source');
|
||||
}
|
||||
$scanFingerprint = $this->fingerprint();
|
||||
|
||||
// --- 캐시 경로 (사전 계산된 getSubscribedHooks 를 registerFromCache) ---
|
||||
HookManager::resetAll();
|
||||
HookListenerRegistrar::clear();
|
||||
foreach ($listeners as $listener) {
|
||||
$hooks = $listener::getSubscribedHooks();
|
||||
HookListenerRegistrar::registerFromCache($listener, $hooks, 'stub-source');
|
||||
}
|
||||
$cacheFingerprint = $this->fingerprint();
|
||||
|
||||
$this->assertSame(
|
||||
$scanFingerprint,
|
||||
$cacheFingerprint,
|
||||
'캐시 경로 등록은 스캔 경로와 훅 매핑이 바이트 동일해야 합니다 (발화 누락 0)'
|
||||
);
|
||||
|
||||
// 지문이 비어있지 않은지(실제로 등록됐는지) 확인
|
||||
$this->assertNotEmpty($scanFingerprint['actions']);
|
||||
$this->assertNotEmpty($scanFingerprint['filters']);
|
||||
}
|
||||
|
||||
/**
|
||||
* registerFromCache 로 등록한 action 리스너가 큐 디스패치되는지 검증합니다.
|
||||
*
|
||||
* 캐시 경로도 register 와 동일한 큐 정책(DispatchHookListenerJob)을 따라야 한다.
|
||||
*
|
||||
* @scenario cache_state=present_valid, listener_type=action_queued, regeneration_trigger=hooks_clear_command, registration_source=plugin
|
||||
*
|
||||
* @effects cache_registered_action_dispatches_queue_job, listener_class_not_autoloaded_at_boot_when_registered_from_cache
|
||||
*/
|
||||
public function test_cache_registered_action_dispatches_job(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$hooks = HookCacheStubAction::class::getSubscribedHooks();
|
||||
HookListenerRegistrar::registerFromCache(HookCacheStubAction::class, $hooks, 'stub-source');
|
||||
|
||||
HookManager::doAction('test.hookcache.action', 'payload');
|
||||
|
||||
Queue::assertPushed(DispatchHookListenerJob::class, function ($job) {
|
||||
return $job->listenerClass === HookCacheStubAction::class;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* registerFromCache 로 등록한 filter 리스너가 동기 실행되고 반환값이 전달되는지 검증합니다.
|
||||
*
|
||||
* @scenario cache_state=corrupted_broken_structure, listener_type=filter, regeneration_trigger=hooks_cache_command, registration_source=plugin
|
||||
*
|
||||
* @effects cache_registered_sync_action_executes_immediately
|
||||
*/
|
||||
public function test_cache_registered_filter_executes_synchronously(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$hooks = HookCacheStubFilter::class::getSubscribedHooks();
|
||||
HookListenerRegistrar::registerFromCache(HookCacheStubFilter::class, $hooks, 'stub-source');
|
||||
|
||||
$result = HookManager::applyFilters('test.hookcache.filter', 'original');
|
||||
|
||||
$this->assertSame('original_cached', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 파일 부재 시 read() 가 null 을 반환(스캔 폴백)하는지 검증합니다.
|
||||
*
|
||||
* @scenario cache_state=absent, listener_type=action_queued, regeneration_trigger=extension_update, registration_source=core
|
||||
*
|
||||
* @effects absent_cache_falls_back_to_scan_registration
|
||||
*/
|
||||
public function test_read_returns_null_when_cache_absent(): void
|
||||
{
|
||||
$manager = new HookCacheManagerWithPath($this->tmpCachePath);
|
||||
|
||||
$this->assertFalse($manager->isCached());
|
||||
$this->assertNull($manager->read());
|
||||
}
|
||||
|
||||
/**
|
||||
* 손상된 캐시 파일은 read() 가 null 을 반환(안전 폴백)하는지 검증합니다.
|
||||
*
|
||||
* @scenario cache_state=corrupted_broken_structure, listener_type=action_sync, regeneration_trigger=hooks_clear_command, registration_source=core
|
||||
*
|
||||
* @effects corrupted_cache_returns_null_and_falls_back_to_scan, hooks_clear_is_safe_when_cache_absent
|
||||
*/
|
||||
public function test_read_returns_null_when_cache_corrupted(): void
|
||||
{
|
||||
File::ensureDirectoryExists(dirname($this->tmpCachePath));
|
||||
File::put($this->tmpCachePath, "<?php return ['broken' => true];");
|
||||
|
||||
$manager = new HookCacheManagerWithPath($this->tmpCachePath);
|
||||
|
||||
$this->assertTrue($manager->isCached());
|
||||
$this->assertNull($manager->read(), '구조가 올바르지 않은 캐시는 null 폴백해야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* generate() 가 core/modules/plugins 3-버킷 구조를 생성하고 read() 로 되읽히는지 검증합니다.
|
||||
*
|
||||
* 실제 활성 모듈/플러그인 인스턴스로 생성하여 코어 리스너가 다수 수집됨을 확인한다.
|
||||
*
|
||||
* @scenario cache_state=present_valid, listener_type=action_sync, regeneration_trigger=core_update, registration_source=module
|
||||
*
|
||||
* @effects generate_produces_three_bucket_cache_core_modules_plugins, each_cache_entry_has_listener_hooks_and_dynamic_keys
|
||||
*/
|
||||
public function test_generate_produces_readable_three_bucket_cache(): void
|
||||
{
|
||||
$manager = new HookCacheManagerWithPath($this->tmpCachePath);
|
||||
|
||||
$moduleManager = app(ModuleManager::class);
|
||||
$moduleManager->loadModules();
|
||||
$pluginManager = app(PluginManager::class);
|
||||
$pluginManager->loadPlugins();
|
||||
|
||||
$counts = $manager->generate($moduleManager, $pluginManager);
|
||||
|
||||
$this->assertTrue($manager->isCached());
|
||||
|
||||
$cache = $manager->read();
|
||||
$this->assertIsArray($cache);
|
||||
$this->assertArrayHasKey('core', $cache);
|
||||
$this->assertArrayHasKey('modules', $cache);
|
||||
$this->assertArrayHasKey('plugins', $cache);
|
||||
|
||||
// 코어 리스너는 app/Listeners 에 다수 존재
|
||||
$this->assertGreaterThan(0, $counts['core']);
|
||||
$this->assertGreaterThan(0, count($cache['core']));
|
||||
|
||||
// 각 코어 항목은 listener/hooks/dynamic 키를 가진다
|
||||
foreach ($cache['core'] as $entry) {
|
||||
$this->assertArrayHasKey('listener', $entry);
|
||||
$this->assertArrayHasKey('hooks', $entry);
|
||||
$this->assertArrayHasKey('dynamic', $entry);
|
||||
$this->assertIsBool($entry['dynamic']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 훅 리스너(NotificationHookListener)가 dynamic=true 로 표시되는지 검증합니다.
|
||||
*
|
||||
* 코어 캐시에서 registerDynamicHooks 보유 리스너는 boot 후반부 지연 실행 대상이므로
|
||||
* dynamic 플래그가 정확해야 한다.
|
||||
*
|
||||
* @scenario cache_state=present_valid, listener_type=dynamic, regeneration_trigger=hooks_cache_command, registration_source=core
|
||||
*
|
||||
* @effects dynamic_listener_flagged_in_core_cache, dynamic_flagged_listener_deferred_to_boot_tail_like_scan_path
|
||||
*/
|
||||
public function test_dynamic_listener_is_flagged_in_cache(): void
|
||||
{
|
||||
$manager = new HookCacheManagerWithPath($this->tmpCachePath);
|
||||
|
||||
$moduleManager = app(ModuleManager::class);
|
||||
$moduleManager->loadModules();
|
||||
$pluginManager = app(PluginManager::class);
|
||||
$pluginManager->loadPlugins();
|
||||
|
||||
$manager->generate($moduleManager, $pluginManager);
|
||||
$cache = $manager->read();
|
||||
|
||||
$notificationEntry = collect($cache['core'])
|
||||
->firstWhere('listener', NotificationHookListener::class);
|
||||
|
||||
$this->assertNotNull($notificationEntry, 'NotificationHookListener 가 코어 캐시에 있어야 합니다');
|
||||
$this->assertTrue($notificationEntry['dynamic'], 'registerDynamicHooks 보유 리스너는 dynamic=true');
|
||||
}
|
||||
|
||||
/**
|
||||
* clear() 가 캐시 파일을 삭제하는지 검증합니다.
|
||||
*
|
||||
* @scenario cache_state=present_but_testing_env, listener_type=action_sync, regeneration_trigger=extension_update, registration_source=plugin
|
||||
*
|
||||
* @effects testing_env_always_scans_ignoring_cache_file
|
||||
*/
|
||||
public function test_clear_removes_cache_file(): void
|
||||
{
|
||||
File::ensureDirectoryExists(dirname($this->tmpCachePath));
|
||||
File::put($this->tmpCachePath, "<?php return ['core'=>[],'modules'=>[],'plugins'=>[]];");
|
||||
|
||||
$manager = new HookCacheManagerWithPath($this->tmpCachePath);
|
||||
$this->assertTrue($manager->isCached());
|
||||
|
||||
$this->assertTrue($manager->clear());
|
||||
$this->assertFalse($manager->isCached());
|
||||
}
|
||||
|
||||
/**
|
||||
* read() 는 request-scoped static 메모리 캐시로 파일을 요청당 1회만 로드한다.
|
||||
*
|
||||
* 회귀 대상: read() 가 매 호출 캐시 파일(수십 KB)을 require 하면, 부팅 경로에서
|
||||
* core/module/plugin 등록이 여러 번(관측상 13회) 호출해 OPcache 부재 시 요청당
|
||||
* 십수 ms 를 낭비한다. memo 도입 후 첫 로드 결과를 재사용해 파일 접근을 1회로 고정한다.
|
||||
*
|
||||
* 검증: memo 가 채워진 뒤 캐시 파일을 삭제해도 read() 가 여전히 데이터를 반환하면
|
||||
* (파일을 재접근하지 않는다는 증거), 요청당 1회 로드가 성립한다.
|
||||
*/
|
||||
public function test_read_memoizes_file_load_within_request(): void
|
||||
{
|
||||
File::ensureDirectoryExists(dirname($this->tmpCachePath));
|
||||
File::put(
|
||||
$this->tmpCachePath,
|
||||
"<?php return ['core'=>[['listener'=>'X','hooks'=>[]]],'modules'=>[],'plugins'=>[]];"
|
||||
);
|
||||
|
||||
$manager = new HookCacheManagerWithPath($this->tmpCachePath);
|
||||
$first = $manager->read();
|
||||
$this->assertIsArray($first);
|
||||
$this->assertArrayHasKey('core', $first);
|
||||
|
||||
// 파일을 삭제 — memo 가 없으면 다음 read() 는 null(파일 부재)을 반환한다.
|
||||
File::delete($this->tmpCachePath);
|
||||
|
||||
$second = $manager->read();
|
||||
$this->assertSame(
|
||||
$first,
|
||||
$second,
|
||||
'read() 는 파일을 재접근하지 않고 memo 를 반환해야 합니다 (요청당 1회 로드).'
|
||||
);
|
||||
|
||||
// 다른 인스턴스도 동일 static memo 를 공유한다 (부팅 경로의 여러 호출처 = 여러 인스턴스).
|
||||
$another = (new HookCacheManagerWithPath($this->tmpCachePath))->read();
|
||||
$this->assertSame($first, $another, 'static memo 는 인스턴스 간 공유되어야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* clear()/generate() 는 memo 를 무효화해 stale 데이터 반환을 막는다.
|
||||
*
|
||||
* 확장 install/update 로 캐시가 재생성되면 이후 read() 는 새 매핑을 반환해야 한다.
|
||||
* memo 가 무효화되지 않으면 옛 매핑이 요청 내내 잔존하는 회귀가 발생한다.
|
||||
*/
|
||||
public function test_clear_invalidates_memo(): void
|
||||
{
|
||||
File::ensureDirectoryExists(dirname($this->tmpCachePath));
|
||||
File::put($this->tmpCachePath, "<?php return ['core'=>[['listener'=>'A','hooks'=>[]]],'modules'=>[],'plugins'=>[]];");
|
||||
|
||||
$manager = new HookCacheManagerWithPath($this->tmpCachePath);
|
||||
$this->assertIsArray($manager->read()); // memo 채움
|
||||
|
||||
// clear() 는 파일 + memo 를 모두 무효화한다.
|
||||
$manager->clear();
|
||||
|
||||
// 파일이 없으므로 read() 는 null (memo 가 무효화되지 않았다면 옛 데이터를 반환했을 것).
|
||||
$this->assertNull($manager->read(), 'clear() 후 read() 는 memo 를 버리고 null 폴백해야 합니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 테스트용 스텁 ---
|
||||
|
||||
class HookCacheStubAction implements HookListenerInterface
|
||||
{
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'test.hookcache.action' => ['method' => 'handleAction', 'priority' => 10],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(...$args): void {}
|
||||
|
||||
public function handleAction(string $value): void {}
|
||||
}
|
||||
|
||||
class HookCacheStubFilter implements HookListenerInterface
|
||||
{
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'test.hookcache.filter' => ['method' => 'handleFilter', 'priority' => 10, 'type' => 'filter'],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(...$args): void {}
|
||||
|
||||
public function handleFilter(string $value): string
|
||||
{
|
||||
return $value.'_cached';
|
||||
}
|
||||
}
|
||||
|
||||
class HookCacheStubSync implements HookListenerInterface
|
||||
{
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'test.hookcache.sync' => ['method' => 'handleSync', 'priority' => 5, 'sync' => true],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(...$args): void {}
|
||||
|
||||
public function handleSync(): void {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트에서 임시 캐시 경로를 주입하기 위한 서브클래스.
|
||||
*/
|
||||
class HookCacheManagerWithPath extends HookCacheManager
|
||||
{
|
||||
public function __construct(string $path)
|
||||
{
|
||||
$this->cacheFilePath = $path;
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Contracts\Repositories\IdentityPolicyRepositoryInterface;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Listeners\NotificationHookListener;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
@@ -26,18 +28,31 @@ class InstallerCompletedGuardTest extends TestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 콜백 실행 중 발생한 information_schema.tables 쿼리만 수집합니다.
|
||||
*
|
||||
* @param callable $callback 계측할 코드
|
||||
* @return array<int, array<string, mixed>> information_schema.tables 쿼리 목록
|
||||
*/
|
||||
private function captureSchemaTableQueries(callable $callback): array
|
||||
{
|
||||
DB::enableQueryLog();
|
||||
$callback();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
return array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_module_trait_skips_has_table_when_installer_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
ModuleManager::getActiveModuleIdentifiers();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$schemaQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => ModuleManager::getActiveModuleIdentifiers()
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 information_schema.tables 쿼리가 발생하지 않아야 합니다');
|
||||
@@ -47,14 +62,8 @@ class InstallerCompletedGuardTest extends TestCase
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
PluginManager::getActivePluginIdentifiers();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$schemaQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => PluginManager::getActivePluginIdentifiers()
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries);
|
||||
@@ -64,14 +73,8 @@ class InstallerCompletedGuardTest extends TestCase
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
TemplateManager::getActiveTemplateIdentifiers();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$schemaQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => TemplateManager::getActiveTemplateIdentifiers()
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries);
|
||||
@@ -81,14 +84,105 @@ class InstallerCompletedGuardTest extends TestCase
|
||||
{
|
||||
config(['app.installer_completed' => false]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
ModuleManager::getActiveModuleIdentifiers();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => ModuleManager::getActiveModuleIdentifiers()
|
||||
);
|
||||
|
||||
$schemaQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
||||
$this->assertGreaterThan(0, count($schemaQueries), 'installer_completed=false 에서는 기존 hasTable 경로가 실행되어야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationHookListener::registerDynamicHooks() 의 hasTable 가드 검증.
|
||||
*
|
||||
* 부팅 시점 동적 훅 등록 경로 — 설치 완료 상태에서 매 요청
|
||||
* `information_schema.tables` 조회(notification_definitions)가 사라져야 합니다.
|
||||
*/
|
||||
public function test_notification_listener_skips_has_table_when_installer_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
$listener = app(NotificationHookListener::class);
|
||||
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => $listener->registerDynamicHooks()
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 notification_definitions hasTable 조회가 발생하지 않아야 합니다');
|
||||
}
|
||||
|
||||
public function test_notification_listener_falls_back_to_has_table_when_installer_not_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => false]);
|
||||
|
||||
$listener = app(NotificationHookListener::class);
|
||||
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => $listener->registerDynamicHooks()
|
||||
);
|
||||
|
||||
$this->assertGreaterThan(0, count($schemaQueries), 'installer_completed=false 에서는 기존 hasTable 경로가 실행되어야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* IdentityPolicyRepository::listHookTargets() 의 hasTable 가드 검증.
|
||||
*
|
||||
* 부팅 시점 IDV 동적 hook target 동기화 경로 — 설치 완료 상태에서
|
||||
* `information_schema.tables` 조회(identity_policies)가 사라져야 합니다.
|
||||
*/
|
||||
public function test_identity_policy_list_hook_targets_skips_has_table_when_installer_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
$repository = app(IdentityPolicyRepositoryInterface::class);
|
||||
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => $repository->listHookTargets()
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 identity_policies hasTable 조회가 발생하지 않아야 합니다');
|
||||
}
|
||||
|
||||
public function test_identity_policy_list_hook_targets_falls_back_when_installer_not_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => false]);
|
||||
|
||||
$repository = app(IdentityPolicyRepositoryInterface::class);
|
||||
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => $repository->listHookTargets()
|
||||
);
|
||||
|
||||
$this->assertGreaterThan(0, count($schemaQueries), 'installer_completed=false 에서는 기존 hasTable 경로가 실행되어야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* IdentityPolicyRepository::activeExtensionIdentifiers() 의 hasTable 가드 검증.
|
||||
*
|
||||
* `resolveByScopeTarget()` → `applyActiveExtensionScope()` → `activeExtensionIdentifiers()`
|
||||
* 경로로 modules/plugins 테이블 hasTable 조회가 트리거되며, 설치 완료 상태에서는
|
||||
* 해당 `information_schema.tables` 조회가 사라져야 합니다.
|
||||
*/
|
||||
public function test_active_extension_scope_skips_has_table_when_installer_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
$repository = app(IdentityPolicyRepositoryInterface::class);
|
||||
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => $repository->resolveByScopeTarget('hook', 'core.auth.signup_before_submit')
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 modules/plugins hasTable 조회가 발생하지 않아야 합니다');
|
||||
}
|
||||
|
||||
public function test_active_extension_scope_falls_back_when_installer_not_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => false]);
|
||||
|
||||
$repository = app(IdentityPolicyRepositoryInterface::class);
|
||||
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => $repository->resolveByScopeTarget('hook', 'core.auth.signup_before_submit')
|
||||
);
|
||||
|
||||
$this->assertGreaterThan(0, count($schemaQueries), 'installer_completed=false 에서는 기존 hasTable 경로가 실행되어야 합니다');
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -29,19 +28,21 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
|
||||
private ModuleManager $moduleManager;
|
||||
|
||||
private bool $ecommerceExistedBefore = false;
|
||||
|
||||
/**
|
||||
* 이 테스트는 파일시스템(활성 디렉토리)을 건드리지 않는다.
|
||||
*
|
||||
* 검증 대상은 cleanupStaleModuleEntries / removeModulePermissionsAndMenus 의 DB 권한
|
||||
* 로직이므로, 실제 installModule/uninstallModule(파일 복사·삭제·마이그레이션·오토로드
|
||||
* 포함)을 호출하는 대신 파일 무접촉 protected 메서드(createModulePermissions /
|
||||
* assignPermissionsToRoles / removeModulePermissionsAndMenus)만 Reflection 으로 호출한다.
|
||||
* 이커머스 활성 디렉토리는 setUp 의 loadModules() 로 이미 로드된 것을 재사용하며 절대
|
||||
* 삭제/변형하지 않는다 — 과거 실제 install/uninstall 호출이 dev 이커머스 디렉토리를
|
||||
* 삭제한 사고(feedback_test_must_protect_dev_directories)를 구조적으로 차단한다.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$modulesPath = base_path('modules');
|
||||
$activePath = $modulesPath.'/sirsoft-ecommerce';
|
||||
if (File::isDirectory($activePath) && ! File::exists($activePath.'/module.php')) {
|
||||
File::deleteDirectory($activePath);
|
||||
}
|
||||
$this->ecommerceExistedBefore = File::isDirectory($activePath);
|
||||
|
||||
$this->moduleManager = app(ModuleManager::class);
|
||||
$this->moduleManager->loadModules();
|
||||
|
||||
@@ -65,38 +66,54 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
User::factory()->create(['email' => 'test@test.com']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
/**
|
||||
* 이커머스 모듈 권한을 DB 에 시드합니다 (파일시스템 무접촉).
|
||||
*
|
||||
* installModule 전체 대신 권한 생성/역할 할당 Phase 만 Reflection 으로 실행한다.
|
||||
* 이미 로드된 이커머스 모듈 인스턴스를 사용하므로 활성 디렉토리를 건드리지 않는다.
|
||||
*
|
||||
* @return \App\Contracts\Extension\ModuleInterface 이커머스 모듈 인스턴스
|
||||
*/
|
||||
private function seedEcommercePermissions()
|
||||
{
|
||||
if (! $this->ecommerceExistedBefore) {
|
||||
$activePath = base_path('modules/sirsoft-ecommerce');
|
||||
if (File::isDirectory($activePath)) {
|
||||
File::deleteDirectory($activePath);
|
||||
}
|
||||
}
|
||||
$module = $this->moduleManager->getModule('sirsoft-ecommerce');
|
||||
$this->assertNotNull($module, 'sirsoft-ecommerce 모듈이 로드되어 있어야 합니다.');
|
||||
|
||||
parent::tearDown();
|
||||
$this->invokeProtected('createModulePermissions', $module);
|
||||
$this->invokeProtected('assignPermissionsToRoles', $module);
|
||||
|
||||
return $module;
|
||||
}
|
||||
|
||||
/**
|
||||
* ModuleManager 의 protected 메서드를 호출합니다 (파일 무접촉 Phase 격리 실행용).
|
||||
*
|
||||
* @param string $method 메서드명
|
||||
* @param mixed ...$args 인자
|
||||
* @return mixed 반환값
|
||||
*/
|
||||
private function invokeProtected(string $method, ...$args)
|
||||
{
|
||||
return (new ReflectionMethod($this->moduleManager, $method))
|
||||
->invoke($this->moduleManager, ...$args);
|
||||
}
|
||||
|
||||
public function test_cleanup_stale_module_entries_does_not_delete_live_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
$module = $this->seedEcommercePermissions();
|
||||
|
||||
$module = $this->moduleManager->getModule('sirsoft-ecommerce');
|
||||
$this->assertNotNull($module);
|
||||
|
||||
// 설치 후 ecommerce 모듈 권한 수 측정
|
||||
// 시드 후 ecommerce 모듈 권한 수 측정
|
||||
$countBefore = Permission::where('extension_type', ExtensionOwnerType::Module)
|
||||
->where('extension_identifier', 'sirsoft-ecommerce')
|
||||
->count();
|
||||
$this->assertGreaterThan(
|
||||
10,
|
||||
$countBefore,
|
||||
'ecommerce 모듈은 install 직후 다수의 권한을 보유해야 합니다 (카테고리 + action 조합).'
|
||||
'ecommerce 모듈은 권한 시드 직후 다수의 권한을 보유해야 합니다 (카테고리 + action 조합).'
|
||||
);
|
||||
|
||||
// cleanupStaleModuleEntries 를 직접 실행 — 정의와 저장이 일치하는 상태라면 아무것도 삭제되면 안 됨
|
||||
$method = new ReflectionMethod($this->moduleManager, 'cleanupStaleModuleEntries');
|
||||
$method->invoke($this->moduleManager, $module);
|
||||
$this->invokeProtected('cleanupStaleModuleEntries', $module);
|
||||
|
||||
$countAfter = Permission::where('extension_type', ExtensionOwnerType::Module)
|
||||
->where('extension_identifier', 'sirsoft-ecommerce')
|
||||
@@ -112,7 +129,7 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
|
||||
public function test_cleanup_stale_module_entries_removes_only_genuinely_stale_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
$this->seedEcommercePermissions();
|
||||
|
||||
// 인위적으로 stale 권한 1건 삽입 (정의에 없는 identifier)
|
||||
Permission::create([
|
||||
@@ -132,8 +149,7 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
->where('identifier', '!=', 'sirsoft-ecommerce.removed-category.obsolete-action')
|
||||
->count();
|
||||
|
||||
$method = new ReflectionMethod($this->moduleManager, 'cleanupStaleModuleEntries');
|
||||
$method->invoke($this->moduleManager, $module);
|
||||
$this->invokeProtected('cleanupStaleModuleEntries', $module);
|
||||
|
||||
// stale 1건만 삭제되고 나머지는 보존되어야 함
|
||||
$this->assertNull(
|
||||
@@ -161,8 +177,7 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
*/
|
||||
public function test_cleanup_preserves_dynamic_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
$module = $this->moduleManager->getModule('sirsoft-ecommerce');
|
||||
$module = $this->seedEcommercePermissions();
|
||||
|
||||
// 런타임 동적 권한 모사: 정적 정의에 없는 식별자를 삽입
|
||||
Permission::create([
|
||||
@@ -181,8 +196,7 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
$wrapper->shouldReceive('getDynamicPermissionIdentifiers')
|
||||
->andReturn(['sirsoft-ecommerce.dynamic-scope.action-alpha']);
|
||||
|
||||
$method = new ReflectionMethod($this->moduleManager, 'cleanupStaleModuleEntries');
|
||||
$method->invoke($this->moduleManager, $wrapper);
|
||||
$this->invokeProtected('cleanupStaleModuleEntries', $wrapper);
|
||||
|
||||
$this->assertNotNull(
|
||||
Permission::where('identifier', 'sirsoft-ecommerce.dynamic-scope.action-alpha')->first(),
|
||||
@@ -193,17 +207,21 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
/**
|
||||
* uninstall(deleteData=false) 시 권한·메뉴·역할이 보존되어 재설치 경로를 비파괴적으로 만드는지 검증.
|
||||
* 운영 정책: "동적 권한/메뉴는 데이터 삭제 옵션 체크 시에만 삭제".
|
||||
*
|
||||
* deleteData=false 경로는 removeModulePermissionsAndMenus 를 호출하지 않는 것과 동치이므로,
|
||||
* 권한 시드 후 삭제 메서드를 호출하지 않고 권한이 그대로 보존됨을 검증한다(파일 무접촉).
|
||||
*/
|
||||
public function test_uninstall_without_delete_data_preserves_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
$this->seedEcommercePermissions();
|
||||
|
||||
$countBefore = Permission::where('extension_type', ExtensionOwnerType::Module)
|
||||
->where('extension_identifier', 'sirsoft-ecommerce')
|
||||
->count();
|
||||
$this->assertGreaterThan(0, $countBefore);
|
||||
|
||||
$this->moduleManager->uninstallModule('sirsoft-ecommerce', deleteData: false);
|
||||
// deleteData=false → removeModulePermissionsAndMenus 미호출 (권한 삭제 안 함).
|
||||
// 실제 uninstallModule 은 이 분기에서 권한 삭제 메서드를 건너뛴다.
|
||||
|
||||
$countAfter = Permission::where('extension_type', ExtensionOwnerType::Module)
|
||||
->where('extension_identifier', 'sirsoft-ecommerce')
|
||||
@@ -218,17 +236,20 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
|
||||
/**
|
||||
* uninstall(deleteData=true) 시 권한이 삭제되는지 (기존 동작 유지) 검증.
|
||||
*
|
||||
* deleteData=true 경로는 removeModulePermissionsAndMenus 를 호출하므로, 이 메서드를
|
||||
* 직접 실행해 권한이 전수 삭제됨을 검증한다(파일 무접촉).
|
||||
*/
|
||||
public function test_uninstall_with_delete_data_removes_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
$module = $this->seedEcommercePermissions();
|
||||
|
||||
$this->assertGreaterThan(
|
||||
0,
|
||||
Permission::where('extension_identifier', 'sirsoft-ecommerce')->count(),
|
||||
);
|
||||
|
||||
$this->moduleManager->uninstallModule('sirsoft-ecommerce', deleteData: true);
|
||||
$this->invokeProtected('removeModulePermissionsAndMenus', $module);
|
||||
|
||||
$this->assertSame(
|
||||
0,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Listeners\ExtensionConfigCacheListener;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ExtensionConfigCacheListener 회귀 테스트.
|
||||
*
|
||||
* 확장 활성화/비활성화 직후 config 캐시를 재생성하는 리스너.
|
||||
* install/uninstall/update 는 ExtensionManager::updateComposerAutoload() 가 담당하므로,
|
||||
* 이 리스너는 activate/deactivate 만 구독해야 한다(이중 재생성 회피). 구독 훅 집합이
|
||||
* 바뀌면 config 캐시가 재생성되지 않거나(누락) 두 번 재생성되는(낭비) 회귀가 발생한다.
|
||||
*/
|
||||
class ExtensionConfigCacheListenerTest extends TestCase
|
||||
{
|
||||
public function test_implements_hook_listener_interface(): void
|
||||
{
|
||||
$this->assertInstanceOf(
|
||||
HookListenerInterface::class,
|
||||
new ExtensionConfigCacheListener
|
||||
);
|
||||
}
|
||||
|
||||
public function test_subscribes_only_activate_and_deactivate_hooks(): void
|
||||
{
|
||||
$hooks = ExtensionConfigCacheListener::getSubscribedHooks();
|
||||
|
||||
$expected = [
|
||||
'core.modules.activated',
|
||||
'core.modules.after_deactivate',
|
||||
'core.plugins.activated',
|
||||
'core.plugins.after_deactivate',
|
||||
'core.templates.activated',
|
||||
'core.templates.after_deactivate',
|
||||
];
|
||||
|
||||
$this->assertEqualsCanonicalizing($expected, array_keys($hooks));
|
||||
}
|
||||
|
||||
public function test_does_not_subscribe_install_uninstall_update_hooks(): void
|
||||
{
|
||||
// install/uninstall/update 는 updateComposerAutoload 가 config 캐시를 재생성하므로
|
||||
// 이 리스너가 구독하면 이중 재생성이 된다. 구독 목록에서 제외되어야 한다.
|
||||
$hookNames = array_keys(ExtensionConfigCacheListener::getSubscribedHooks());
|
||||
|
||||
foreach (['installed', 'updated'] as $lifecycle) {
|
||||
foreach (['modules', 'plugins', 'templates'] as $type) {
|
||||
$this->assertNotContains(
|
||||
"core.{$type}.{$lifecycle}",
|
||||
$hookNames,
|
||||
"core.{$type}.{$lifecycle} 는 updateComposerAutoload 가 담당하므로 이 리스너가 구독하면 안 됩니다."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_all_hooks_route_to_on_extension_toggled(): void
|
||||
{
|
||||
$hooks = ExtensionConfigCacheListener::getSubscribedHooks();
|
||||
|
||||
foreach ($hooks as $hookName => $config) {
|
||||
$this->assertSame('onExtensionToggled', $config['method'], "{$hookName} 은 onExtensionToggled 로 라우팅되어야 합니다.");
|
||||
$this->assertArrayHasKey('priority', $config);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Unit\Services;
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Services\NotificationDefinitionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
@@ -163,4 +164,53 @@ class NotificationDefinitionServiceTest extends TestCase
|
||||
$this->assertEquals(['mail', 'database'], $result->channels);
|
||||
$this->assertEquals(['core.auth.after_register', 'core.auth.after_login'], $result->hooks);
|
||||
}
|
||||
|
||||
/**
|
||||
* getAllActive() 캐시 히트 시 notification_definitions DB 조회가 0건인지 확인.
|
||||
*
|
||||
* 서빙 API 부팅 비용 최적화 검증 (계획서 §2-1 나): 동적 훅(알림) 등록 경로
|
||||
* (NotificationHookListener::registerDynamicHooks → getAllActive)는 이미
|
||||
* `['notification']` 태그로 캐시되어 있으므로, 첫 요청(캐시 워밍) 이후에는
|
||||
* 매 요청 DB 조회가 발생하지 않아야 한다 (37ms 캐시 미스는 첫 요청 1회 비용).
|
||||
*
|
||||
* @scenario cache_state=present_but_testing_env, listener_type=action_sync, regeneration_trigger=extension_update, registration_source=plugin
|
||||
*
|
||||
* @effects notification_getAllActive_cache_hit_issues_zero_db_query
|
||||
*/
|
||||
public function test_get_all_active_cache_hit_issues_zero_db_query(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'cache_hit_probe',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '캐시 히트 검증'],
|
||||
'variables' => [],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => ['core.auth.after_register'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
// 1차 호출: 캐시 워밍 (DB 조회 발생)
|
||||
$this->service->invalidateAllCache();
|
||||
$this->service->getAllActive();
|
||||
|
||||
// 2차 호출: 캐시 히트 → notification_definitions 조회 0건이어야 함
|
||||
DB::enableQueryLog();
|
||||
$this->service->getAllActive();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$definitionQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'notification_definitions')
|
||||
);
|
||||
|
||||
$this->assertCount(
|
||||
0,
|
||||
$definitionQueries,
|
||||
'캐시 히트 시 notification_definitions DB 조회가 발생하지 않아야 합니다 (동적 훅 등록 비용 제거)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Support;
|
||||
|
||||
use App\Support\ConfigCacheHelper;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ConfigCacheHelper 회귀 테스트.
|
||||
*
|
||||
* config 소스(config/*.php, .env, settings JSON, 활성 확장 목록)를 바꾸는 라이프사이클
|
||||
* 지점이 config:clear 만 하고 config:cache 를 재생성하지 않아, 한 번 캐시가 비워지면
|
||||
* config:cache 가 영구히 비활성 상태로 남던 결함을 단일 헬퍼로 막는다.
|
||||
*
|
||||
* rebuild() 는 testing 환경에서 early return 하므로(격리 보호), 실제 config:cache 생성
|
||||
* 경로는 환경을 임시로 'production' 으로 강제하고 Artisan 을 mock 하여 검증한다.
|
||||
* 실제 Artisan config:cache 를 호출하면 테스트 격리가 깨지므로 절대 실행하지 않는다.
|
||||
*/
|
||||
class ConfigCacheHelperTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// 환경 강제 복원 (다른 테스트 오염 방지)
|
||||
app()->detectEnvironment(fn () => 'testing');
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_rebuild_clears_but_never_caches_in_testing_environment(): void
|
||||
{
|
||||
// 기본 테스트 환경 = testing → config:clear 는 수행(값 반영 보장), config:cache 는 스킵(격리 보호).
|
||||
$calls = [];
|
||||
Artisan::shouldReceive('call')->andReturnUsing(function ($command) use (&$calls) {
|
||||
$calls[] = $command;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
ConfigCacheHelper::rebuild();
|
||||
|
||||
$this->assertContains('config:clear', $calls);
|
||||
$this->assertNotContains('config:cache', $calls, 'testing 환경에서는 config:cache 를 생성하지 않아야 합니다 (격리 보호).');
|
||||
}
|
||||
|
||||
public function test_rebuild_clears_then_caches_when_installed(): void
|
||||
{
|
||||
// 비-testing 환경 + 설치 완료 상태 강제
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
$calls = [];
|
||||
Artisan::shouldReceive('call')->andReturnUsing(function ($command) use (&$calls) {
|
||||
$calls[] = $command;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
ConfigCacheHelper::rebuild();
|
||||
|
||||
// config:clear 가 config:cache 보다 먼저 (stale 제거 후 재생성).
|
||||
$this->assertContains('config:clear', $calls);
|
||||
$this->assertContains('config:cache', $calls);
|
||||
$this->assertLessThan(
|
||||
array_search('config:cache', $calls, true),
|
||||
array_search('config:clear', $calls, true),
|
||||
'config:clear 는 config:cache 보다 먼저 실행되어야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_rebuild_clears_only_when_not_installed(): void
|
||||
{
|
||||
// 비-testing + 설치 미완료 → clear 만, 재생성은 스킵(불완전 config 박제 방지)
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
config(['app.installer_completed' => false]);
|
||||
|
||||
// 설치 플래그 파일도 없어야 함 (isInstalled false 보장) — 존재 시 이 테스트는
|
||||
// 환경상 스킵될 수 있으나, 로컬 dev 에서 g7_installed 가 있으면 결과가 달라진다.
|
||||
if (file_exists(storage_path('app/g7_installed'))) {
|
||||
$this->markTestSkipped('storage/app/g7_installed 존재 — 미설치 경로 검증 불가');
|
||||
}
|
||||
|
||||
$calls = [];
|
||||
Artisan::shouldReceive('call')->andReturnUsing(function ($command) use (&$calls) {
|
||||
$calls[] = $command;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
ConfigCacheHelper::rebuild();
|
||||
|
||||
$this->assertContains('config:clear', $calls);
|
||||
$this->assertNotContains('config:cache', $calls, '설치 미완료 시 config:cache 를 생성하지 않아야 합니다.');
|
||||
}
|
||||
|
||||
public function test_clear_only_helper_never_caches(): void
|
||||
{
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
$calls = [];
|
||||
Artisan::shouldReceive('call')->andReturnUsing(function ($command) use (&$calls) {
|
||||
$calls[] = $command;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
ConfigCacheHelper::clear();
|
||||
|
||||
$this->assertContains('config:clear', $calls);
|
||||
$this->assertNotContains('config:cache', $calls, 'clear() 는 config:cache 를 재생성하지 않아야 합니다.');
|
||||
}
|
||||
|
||||
public function test_clear_calls_config_clear_even_in_testing(): void
|
||||
{
|
||||
// clear() 는 config:clear 만 수행(재생성 없음). testing 에서도 clear 는 격리를 깨지 않으므로 실행한다.
|
||||
$calls = [];
|
||||
Artisan::shouldReceive('call')->andReturnUsing(function ($command) use (&$calls) {
|
||||
$calls[] = $command;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
ConfigCacheHelper::clear();
|
||||
|
||||
$this->assertSame(['config:clear'], $calls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# audit:allow test-scenario-coverage reason: lifecycle_event 축(install/update/uninstall/core_update/activate)은 FQCN 추출·classmap 상태 축과 직교하는 재생성 관심사라 cross product 가 인위적. 재생성 효과는 test_files 의 HooksCacheCommandTest(extension:update-autoload 재생성) + ExtensionManagerComposerTest 가 커버하고, FQCN/classmap/vendor격리는 ExtensionSourceClassmapTest 가 per-case 커버. effects 전량은 아래 @effects 마킹으로 검증.
|
||||
coverage_strategy: pairwise
|
||||
|
||||
feature: 확장 소스 classmap (Extension Source Classmap / B2)
|
||||
|
||||
description: |
|
||||
서빙 API 부팅 비용 최적화. 확장 소스 클래스(Modules\*, Plugins\*)를 autoload-extensions.php 의
|
||||
src_classmap 에 사전 계산(FQCN → 경로)하여 진입점에서 ClassLoader::addClassMap() 으로 등록한다.
|
||||
findFile 이 파일시스템 스캔(is_dir/file_exists stat) 없이 in-memory 맵에서 즉시 경로를 반환
|
||||
→ cold OPcache / 느린 파일시스템 환경의 findFile 비용 제거.
|
||||
|
||||
핵심 동작:
|
||||
- buildSourceClassmap: PSR-4 디렉토리 재귀 스캔 + 토큰 기반 FQCN 추출(class/interface/trait/enum)
|
||||
- 소스 클래스만 대상 — 서드파티 vendor 는 각 확장 vendor/autoload.php(별도 ClassLoader) 격리 유지
|
||||
- 지연 로드: classmap 은 경로만 제공, include 는 실제 사용 시점 (매 요청 전량 로드 아님)
|
||||
- PSR-4 폴백 보존: classmap 미포함 클래스는 기존 PSR-4 경로로 폴백 (안전망)
|
||||
- 재생성: psr4 와 동일 생명주기 — install/update/uninstall → updateComposerAutoload,
|
||||
코어 업데이트 → extension:update-autoload. activate/deactivate 는 autoload 미수정
|
||||
(collectAutoloads 가 설치된 전체 포함 → 비활성도 이미 맵에 존재)
|
||||
|
||||
axes:
|
||||
declaration_kind: [class, interface, trait, enum]
|
||||
extension_type: [module, plugin]
|
||||
classmap_state: [present_hit, absent_psr4_fallback, path_outside_namespace]
|
||||
lifecycle_event: [install, update, uninstall, activate_no_regen, core_update]
|
||||
|
||||
exclusions:
|
||||
- { classmap_state: path_outside_namespace, declaration_kind: interface, reason: "네임스페이스 불일치 제외는 선언 종류 무관 — class 케이스로 대표" }
|
||||
- { classmap_state: path_outside_namespace, declaration_kind: trait, reason: "동일" }
|
||||
- { classmap_state: path_outside_namespace, declaration_kind: enum, reason: "동일" }
|
||||
- { lifecycle_event: activate_no_regen, classmap_state: absent_psr4_fallback, reason: "활성화는 autoload 미수정 — classmap 상태 무관(이미 설치 시 생성됨)" }
|
||||
|
||||
effects:
|
||||
# FQCN 추출 정확성
|
||||
- extracts_fqcn_for_class_declaration
|
||||
- extracts_fqcn_for_interface_declaration
|
||||
- extracts_fqcn_for_trait_declaration
|
||||
- extracts_fqcn_for_enum_declaration
|
||||
- maps_fqcn_to_correct_relative_path
|
||||
# 포함/제외 범위
|
||||
- ignores_files_outside_declared_namespace
|
||||
- skips_files_without_top_level_declaration
|
||||
- missing_directory_returns_empty
|
||||
# vendor 격리 (설계 불변조건)
|
||||
- generated_src_classmap_excludes_vendor_paths
|
||||
- generated_src_classmap_only_extension_namespaces
|
||||
# 런타임 등록 + 폴백
|
||||
- classmap_present_findFile_hits_without_filesystem_stat
|
||||
- classmap_absent_findFile_falls_back_to_psr4
|
||||
- class_loading_remains_lazy_include_on_use
|
||||
# 재생성 생명주기
|
||||
- install_regenerates_src_classmap_via_updateComposerAutoload
|
||||
- update_regenerates_src_classmap
|
||||
- uninstall_regenerates_src_classmap
|
||||
- core_update_regenerates_src_classmap_via_extension_update_autoload
|
||||
- activate_does_not_regenerate_but_installed_classes_already_mapped
|
||||
|
||||
test_files:
|
||||
- tests/Unit/Extension/ExtensionSourceClassmapTest.php
|
||||
- tests/Unit/Extension/ExtensionManagerComposerTest.php
|
||||
- tests/Feature/Console/HooksCacheCommandTest.php
|
||||
@@ -0,0 +1,67 @@
|
||||
feature: 정적 훅 매핑 캐시 (Static Hook Cache)
|
||||
|
||||
description: |
|
||||
서빙 API 부팅 비용 최적화. 매 요청 코어(app/Listeners 재귀 스캔) + 모듈/플러그인
|
||||
(getHookListeners) 정적 훅 리스너의 발견·리플렉션·getSubscribedHooks() 클래스 로딩을
|
||||
bootstrap/cache/hooks.php 캐시로 제거한다.
|
||||
|
||||
핵심 동작:
|
||||
- 캐시 우선 등록: registerCoreHookListenersFromCache / registerExtensionHookListenersFromCache
|
||||
가 존재하면 스캔·리플렉션 없이 사전 계산 매핑으로 등록
|
||||
- 바이트 동일 게이트: HookListenerRegistrar::registerFromCache 가 register 와 동일한
|
||||
applySubscribedHooks 에 위임 → 훅 매핑(이름·우선순위·콜백 수) 완전 일치, 발화 누락 0
|
||||
- 안전 폴백: 캐시 부재/손상/구조 불일치/테스트 환경에서 기존 스캔 경로로 폴백
|
||||
- 동적 훅(알림)은 캐시 대상 아님 — dynamic 플래그만 저장하여 boot 후반부 지연 실행 순서 유지.
|
||||
getAllActive() 는 이미 ['notification'] 태그 캐시라 캐시 히트 시 DB 조회 0
|
||||
- 재생성(무효화=재생성): 확장 install/update → updateComposerAutoload,
|
||||
코어 업데이트 → clearAllCaches → extension:update-autoload, 코어 리스너 배포 → hooks:cache
|
||||
|
||||
# registration 축(cache_state·registration_source·listener_type)과 regeneration_trigger 는
|
||||
# 상호 직교 관심사(등록 시점 vs 재생성 시점)라 full cross product 는 인위적 조합을 만든다.
|
||||
# pairwise 로 모든 축 값 쌍을 커버하되 케이스 수를 실측 검증 가능한 범위로 유지한다.
|
||||
coverage_strategy: pairwise
|
||||
|
||||
axes:
|
||||
cache_state: [absent, present_valid, corrupted_broken_structure, present_but_testing_env]
|
||||
registration_source: [core, module, plugin]
|
||||
listener_type: [action_queued, action_sync, filter, dynamic]
|
||||
regeneration_trigger: [extension_update, core_update, hooks_cache_command, hooks_clear_command]
|
||||
|
||||
exclusions:
|
||||
- { cache_state: present_but_testing_env, registration_source: core, reason: "테스트 환경은 항상 스캔 폴백 — 캐시 경로 미진입 (registration_source 무관)" }
|
||||
- { listener_type: dynamic, registration_source: module, reason: "동적 훅(registerDynamicHooks)은 코어 NotificationHookListener 전용 — 모듈/플러그인 매니저는 동적 훅 미처리" }
|
||||
- { listener_type: dynamic, registration_source: plugin, reason: "동일 — 동적 훅은 코어 boot 후반부 전용" }
|
||||
- { regeneration_trigger: hooks_clear_command, cache_state: absent, reason: "이미 부재 상태 clear 는 무동작 성공 (route:clear 동형)" }
|
||||
|
||||
effects:
|
||||
# 바이트 동일 게이트 (발화 누락 0)
|
||||
- cache_path_registration_byte_identical_to_scan_path
|
||||
- cache_registered_action_dispatches_queue_job
|
||||
- cache_registered_sync_action_executes_immediately
|
||||
- cache_registered_filter_executes_synchronously_and_returns_value
|
||||
# 안전 폴백
|
||||
- absent_cache_falls_back_to_scan_registration
|
||||
- corrupted_cache_returns_null_and_falls_back_to_scan
|
||||
- testing_env_always_scans_ignoring_cache_file
|
||||
# 동적 훅
|
||||
- dynamic_listener_flagged_in_core_cache
|
||||
- dynamic_flagged_listener_deferred_to_boot_tail_like_scan_path
|
||||
- notification_getAllActive_cache_hit_issues_zero_db_query
|
||||
# 캐시 생성 구조
|
||||
- generate_produces_three_bucket_cache_core_modules_plugins
|
||||
- each_cache_entry_has_listener_hooks_and_dynamic_keys
|
||||
- listener_class_not_autoloaded_at_boot_when_registered_from_cache
|
||||
# 재생성 (무효화=재생성)
|
||||
- extension_update_regenerates_hook_cache_via_updateComposerAutoload
|
||||
- core_update_regenerates_hook_cache_via_clearAllCaches_extension_update_autoload
|
||||
- hooks_cache_command_generates_cache_file
|
||||
- hooks_clear_command_removes_cache_file
|
||||
- hooks_clear_is_safe_when_cache_absent
|
||||
- removed_core_listener_dropped_from_cache_after_regeneration
|
||||
|
||||
test_files:
|
||||
- tests/Unit/Extension/HookCacheManagerTest.php
|
||||
- tests/Unit/Extension/HookListenerRegistrarTest.php
|
||||
- tests/Feature/Console/HooksCacheCommandTest.php
|
||||
- tests/Unit/Services/NotificationDefinitionServiceTest.php
|
||||
- tests/Unit/Extension/InstallerCompletedGuardTest.php
|
||||
Reference in New Issue
Block a user