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:
HeuJung committed 2026-07-03 17:47:23 +09:00
1 parent deacbc7b66
commit c08dae7cb0
41 files changed
+2590 -131

No files matched your search

@@ -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;
}
}
+189 -18
View File
@@ -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
{
+320
View File
@@ -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";
}
}
+46 -5
View File
@@ -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;
+43 -7
View File
@@ -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;
}
/**
* 모듈의 브로드캐스트 채널을 등록합니다.
*
+43 -7
View File
@@ -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(),
]);
}
}
}
+3 -1
View File
@@ -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;
}
+51 -4
View File
@@ -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;
}
+3 -3
View File
@@ -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()]);
}
+10 -3
View File
@@ -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,
+89
View File
@@ -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'));
}
}