diff --git a/CHANGELOG.md b/CHANGELOG.md index 8025d3cb..51bddaec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ ### Changed - 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다. +- 설치가 끝난 사이트에서 매 요청마다 반복되던 불필요한 데이터베이스 조회를 줄여 페이지 응답 속도를 개선했습니다. 알림·본인인증 설정을 준비하는 과정에서 이미 존재가 보장된 테이블을 매번 다시 확인하던 동작을 설치 완료 상태에서는 건너뛰도록 했습니다. +- 화면을 그릴 때마다 서버가 매번 확장 기능의 연결 정보를 처음부터 다시 계산하던 부담을 줄여 페이지 응답 속도를 개선했습니다. 이 연결 정보는 확장을 설치·수정하거나 코어를 업데이트할 때만 바뀌므로 그 시점에 한 번 저장해 두고, 이후 요청에서는 저장된 값을 재사용하도록 했습니다. 확장이나 코어를 변경하면 저장 값이 자동으로 갱신됩니다. +- 설치된 모듈·플러그인의 코드를 찾을 때마다 서버가 파일을 하나하나 탐색하던 부담을 줄여, 특히 공유 호스팅 등 파일 접근이 느린 환경에서 페이지 응답 속도를 개선했습니다. 확장 코드의 위치를 설치·수정 시점에 미리 정리해 두고 이후 요청에서는 그 목록을 바로 참조하도록 했습니다. 확장을 설치·수정·삭제하거나 코어를 업데이트하면 목록이 자동으로 갱신됩니다. ### Fixed diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php index fb381c94..16ad9473 100644 --- a/app/Console/Commands/Core/CoreUpdateCommand.php +++ b/app/Console/Commands/Core/CoreUpdateCommand.php @@ -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(); diff --git a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php index 9f7ff3bc..04c37a98 100644 --- a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php +++ b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php @@ -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 모드에서는 부모가 처리하므로 스킵. diff --git a/app/Console/Commands/Extension/UpdateAutoloadCommand.php b/app/Console/Commands/Extension/UpdateAutoloadCommand.php index 001a3a06..ff3501db 100644 --- a/app/Console/Commands/Extension/UpdateAutoloadCommand.php +++ b/app/Console/Commands/Extension/UpdateAutoloadCommand.php @@ -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()); diff --git a/app/Console/Commands/HooksCacheCommand.php b/app/Console/Commands/HooksCacheCommand.php new file mode 100644 index 00000000..e398e9d5 --- /dev/null +++ b/app/Console/Commands/HooksCacheCommand.php @@ -0,0 +1,57 @@ +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; + } +} diff --git a/app/Console/Commands/HooksClearCommand.php b/app/Console/Commands/HooksClearCommand.php new file mode 100644 index 00000000..dcdff9ca --- /dev/null +++ b/app/Console/Commands/HooksClearCommand.php @@ -0,0 +1,45 @@ +isCached()) { + $this->info(__('hooks.cache_absent')); + + return Command::SUCCESS; + } + + $hookCacheManager->clear(); + $this->info(__('hooks.cache_cleared')); + + return Command::SUCCESS; + } +} diff --git a/app/Extension/ExtensionManager.php b/app/Extension/ExtensionManager.php index 99447f04..aa085ff1 100644 --- a/app/Extension/ExtensionManager.php +++ b/app/Extension/ExtensionManager.php @@ -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 << {$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> $psr4 네임스페이스 → base_path 상대경로(들) + * @return array 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 { diff --git a/app/Extension/HookCacheManager.php b/app/Extension/HookCacheManager.php new file mode 100644 index 00000000..1521200b --- /dev/null +++ b/app/Extension/HookCacheManager.php @@ -0,0 +1,320 @@ +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>, modules: array>>, plugins: array>>}|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> [['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 $extensions 식별자(디렉토리명) => 확장 인스턴스 + * @return array>> 확장 식별자 => 리스너 항목 목록 + */ + 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>, 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 = " $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> $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> $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; diff --git a/app/Extension/ModuleManager.php b/app/Extension/ModuleManager.php index 148727c0..c91ae44b 100644 --- a/app/Extension/ModuleManager.php +++ b/app/Extension/ModuleManager.php @@ -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; + } + /** * 모듈의 브로드캐스트 채널을 등록합니다. * diff --git a/app/Extension/PluginManager.php b/app/Extension/PluginManager.php index ab92426a..3fbd6a32 100644 --- a/app/Extension/PluginManager.php +++ b/app/Extension/PluginManager.php @@ -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; + } + /** * 플러그인의 브로드캐스트 채널을 등록합니다. * diff --git a/app/Listeners/ExtensionConfigCacheListener.php b/app/Listeners/ExtensionConfigCacheListener.php new file mode 100644 index 00000000..e785c3e3 --- /dev/null +++ b/app/Listeners/ExtensionConfigCacheListener.php @@ -0,0 +1,80 @@ + 훅 이름 → 메서드/우선순위 매핑 + */ + 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(), + ]); + } + } +} diff --git a/app/Listeners/NotificationHookListener.php b/app/Listeners/NotificationHookListener.php index 262fa694..490cc8d1 100644 --- a/app/Listeners/NotificationHookListener.php +++ b/app/Listeners/NotificationHookListener.php @@ -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; } diff --git a/app/Providers/CoreServiceProvider.php b/app/Providers/CoreServiceProvider.php index c30ca62e..529c5fdc 100644 --- a/app/Providers/CoreServiceProvider.php +++ b/app/Providers/CoreServiceProvider.php @@ -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에 등록합니다. * diff --git a/app/Repositories/IdentityPolicyRepository.php b/app/Repositories/IdentityPolicyRepository.php index 004cf036..a6b7611b 100644 --- a/app/Repositories/IdentityPolicyRepository.php +++ b/app/Repositories/IdentityPolicyRepository.php @@ -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; } diff --git a/app/Services/GeoIpDatabaseService.php b/app/Services/GeoIpDatabaseService.php index 43f89345..48a20746 100644 --- a/app/Services/GeoIpDatabaseService.php +++ b/app/Services/GeoIpDatabaseService.php @@ -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()]); } diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index dfce003f..475a8f0d 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -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, diff --git a/app/Support/ConfigCacheHelper.php b/app/Support/ConfigCacheHelper.php new file mode 100644 index 00000000..b0d27889 --- /dev/null +++ b/app/Support/ConfigCacheHelper.php @@ -0,0 +1,89 @@ +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')); + } +} diff --git a/docs/backend/service-provider.md b/docs/backend/service-provider.md index 69818513..aa2d4ffd 100644 --- a/docs/backend/service-provider.md +++ b/docs/backend/service-provider.md @@ -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 접근 오류 diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 2a2d0fb2..08d14bb6 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -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 자동 노출 면제 대상이다. diff --git a/docs/extension/extension-manager.md b/docs/extension/extension-manager.md index 18e392ab..906f07e6 100644 --- a/docs/extension/extension-manager.md +++ b/docs/extension/extension-manager.md @@ -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` 가 함께 재생성. + ### 중요 규칙 ``` diff --git a/docs/extension/hooks.md b/docs/extension/hooks.md index e17a0a76..86e2a40e 100644 --- a/docs/extension/hooks.md +++ b/docs/extension/hooks.md @@ -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()` 메서드를 구현합니다. diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index e0a52930..12836fe4 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -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 diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/hooks.php b/lang-packs/_bundled/g7-core-ja/backend/ja/hooks.php new file mode 100644 index 00000000..01312efc --- /dev/null +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/hooks.php @@ -0,0 +1,7 @@ + '静的フック マッピング キャッシュを作成しました。(コア :core / モジュール :modules / プラグイン :plugins) → :path', + 'cache_cleared' => '静的フック マッピング キャッシュを削除しました。', + 'cache_absent' => '削除する静的フック マッピング キャッシュがありません。', +]; diff --git a/lang/en/hooks.php b/lang/en/hooks.php new file mode 100644 index 00000000..4e617eec --- /dev/null +++ b/lang/en/hooks.php @@ -0,0 +1,8 @@ + '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.', +]; diff --git a/lang/ko/hooks.php b/lang/ko/hooks.php new file mode 100644 index 00000000..5723f3f2 --- /dev/null +++ b/lang/ko/hooks.php @@ -0,0 +1,8 @@ + '정적 훅 매핑 캐시를 생성했습니다. (코어 :core / 모듈 :modules / 플러그인 :plugins) → :path', + 'cache_cleared' => '정적 훅 매핑 캐시를 삭제했습니다.', + 'cache_absent' => '삭제할 정적 훅 매핑 캐시가 없습니다.', +]; diff --git a/public/index.php b/public/index.php index 6a70b8eb..5cd81797 100644 --- a/public/index.php +++ b/public/index.php @@ -1,8 +1,8 @@ alert("G7 최초 사용을 위해 설치 절차가 필요합니다. 설치 화면으로 이동합니다."); - window.location.href="' . htmlspecialchars($installUrl) . '"; + window.location.href="'.htmlspecialchars($installUrl).'"; '; 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)) { diff --git a/public/install/includes/task-runner.php b/public/install/includes/task-runner.php index 75a67366..cbe25bb5 100644 --- a/public/install/includes/task-runner.php +++ b/public/install/includes/task-runner.php @@ -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()) { diff --git a/public/install/lang/en.php b/public/install/lang/en.php index 094652e4..e6c16889 100644 --- a/public/install/lang/en.php +++ b/public/install/lang/en.php @@ -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', diff --git a/public/install/lang/ko.php b/public/install/lang/ko.php index b657478f..1823e000 100644 --- a/public/install/lang/ko.php +++ b/public/install/lang/ko.php @@ -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' => '설정 파일 생성에 실패했습니다', diff --git a/resources/views/dev-dashboard.blade.php b/resources/views/dev-dashboard.blade.php index 6a5a72d5..82cabc40 100644 --- a/resources/views/dev-dashboard.blade.php +++ b/resources/views/dev-dashboard.blade.php @@ -788,6 +788,14 @@ if (isset($_GET['ajax_action'])) { 전체 최적화 (optimize:clear) + +