모듈 기본 레이아웃 */ class LayoutResolverService { /** * 캐시 히트/미스 카운터 */ private array $cacheStats = [ 'hits' => 0, 'misses' => 0, ]; /** * LayoutRepository, ModuleManager 주입 */ public function __construct( private LayoutRepositoryInterface $layoutRepository, private ModuleManager $moduleManager, private CacheInterface $cache ) {} /** * 레이아웃 해석 결과 캐시 TTL을 반환합니다. */ private function getCacheTtl(): int { return (int) g7_core_settings('cache.layout_ttl', config('template.layout.cache_ttl', 3600)); } /** * 레이아웃 이름으로 실제 로드할 레이아웃 결정 * * 우선순위: * 1. 템플릿 오버라이드 (source_type = 'template', 오버라이드 경로) * 2. 모듈 기본 레이아웃 (source_type = 'module') * * @param string $layoutName 예: sirsoft-ecommerce_admin_products_index * @param int $templateId 현재 활성 템플릿 ID */ public function resolve(string $layoutName, int $templateId): ?TemplateLayout { // Before 훅 - 레이아웃 해석 전 HookManager::doAction('core.layout_resolver.before_resolve', $layoutName, $templateId); // 캐시 확인 $cacheKey = $this->getResolutionCacheKey($layoutName, $templateId); $cachedId = $this->cache->get($cacheKey); if ($cachedId !== null) { $this->cacheStats['hits']++; Log::debug('레이아웃 해석 캐시 히트', [ 'layout_name' => $layoutName, 'template_id' => $templateId, 'cache_key' => $cacheKey, ]); // 캐시된 ID로 레이아웃 조회 if ($cachedId === 0) { // 이전에 해석 결과가 없었던 경우 HookManager::doAction('core.layout_resolver.after_resolve', null, $layoutName, $templateId, true); return null; } $layout = $this->layoutRepository->findById($cachedId); // After 훅 - 캐시에서 해석 완료 HookManager::doAction('core.layout_resolver.after_resolve', $layout, $layoutName, $templateId, true); return $layout; } // 캐시 미스 - 실제 해석 수행 $this->cacheStats['misses']++; Log::debug('레이아웃 해석 캐시 미스', [ 'layout_name' => $layoutName, 'template_id' => $templateId, 'cache_key' => $cacheKey, ]); $layout = $this->resolveInternal($layoutName, $templateId); // 캐시에 결과 저장 (없는 경우 0으로 저장하여 null 구분) $cacheValue = $layout ? $layout->id : 0; $cacheTtl = $this->getCacheTtl(); $this->cache->put($cacheKey, $cacheValue, $cacheTtl); Log::info('레이아웃 해석 완료 및 캐싱', [ 'layout_name' => $layoutName, 'template_id' => $templateId, 'resolved_id' => $layout?->id, 'source_type' => $layout?->source_type?->value, 'cache_key' => $cacheKey, 'ttl' => $cacheTtl, ]); // After 훅 - DB에서 해석 완료 HookManager::doAction('core.layout_resolver.after_resolve', $layout, $layoutName, $templateId, false); return $layout; } /** * 레이아웃 해석 내부 로직 (캐싱 없음) */ private function resolveInternal(string $layoutName, int $templateId): ?TemplateLayout { // 1. 템플릿 오버라이드 확인 (최우선) $override = $this->layoutRepository->findTemplateOverride($templateId, $layoutName); if ($override) { // 버전 호환성 검사 if ($this->checkOverrideVersionCompatibility($override)) { Log::debug('템플릿 오버라이드 발견', [ 'layout_name' => $layoutName, 'template_id' => $templateId, 'override_id' => $override->id, ]); return $override; } Log::warning('템플릿 오버라이드 버전 비호환, 모듈 기본 레이아웃으로 폴백', [ 'layout_name' => $layoutName, 'template_id' => $templateId, 'override_id' => $override->id, ]); } // 2. 모듈 기본 레이아웃 반환 (폴백) $moduleLayout = $this->layoutRepository->findModuleLayout($templateId, $layoutName); if ($moduleLayout) { Log::debug('모듈 기본 레이아웃 사용', [ 'layout_name' => $layoutName, 'template_id' => $templateId, 'layout_id' => $moduleLayout->id, 'source_identifier' => $moduleLayout->source_identifier, ]); } return $moduleLayout; } /** * 레이아웃 오버라이드의 버전 호환성을 검사합니다. * * @param TemplateLayout $override 오버라이드 레이아웃 * @return bool 호환 여부 */ private function checkOverrideVersionCompatibility(TemplateLayout $override): bool { $content = is_string($override->content) ? json_decode($override->content, true) : $override->content; // version_constraint가 없으면 항상 호환 if (! isset($content['version_constraint'])) { return true; } $constraint = $content['version_constraint']; $layoutName = $override->name; // 레이아웃 이름에서 모듈 식별자 추출 (DOT 앞 부분) // 예: sirsoft-ecommerce.admin_products_index → sirsoft-ecommerce $dotPos = strpos($layoutName, '.'); if ($dotPos === false) { // DOT가 없으면 UNDERSCORE 포맷 시도 // 예: sirsoft-ecommerce_admin_products_index → sirsoft-ecommerce if (preg_match('/^([a-z0-9]+-[a-z0-9]+)[_.]/', $layoutName, $matches)) { $moduleIdentifier = $matches[1]; } else { return true; } } else { $moduleIdentifier = substr($layoutName, 0, $dotPos); } // 모듈 버전 조회 $version = $this->moduleManager->getModuleVersion($moduleIdentifier); if (! $version) { Log::warning('오버라이드 버전 검사: 모듈 버전 정보 없음', [ 'override_id' => $override->id, 'module' => $moduleIdentifier, 'constraint' => $constraint, ]); // 버전 정보 없으면 호환으로 처리 (하위 호환성) return true; } // Composer Semver로 검증 try { $compatible = Semver::satisfies($version, $constraint); } catch (\Exception $e) { Log::warning('오버라이드 버전 제약 조건 파싱 실패', [ 'override_id' => $override->id, 'constraint' => $constraint, 'error' => $e->getMessage(), ]); // 파싱 실패 시 호환으로 처리 (안전 폴백) return true; } if (! $compatible) { Log::warning('오버라이드 버전 비호환', [ 'override_id' => $override->id, 'module' => $moduleIdentifier, 'constraint' => $constraint, 'current_version' => $version, ]); } return $compatible; } /** * 레이아웃 해석 캐시 키 생성 * * 소스 해시를 포함하여 오버라이드 정보를 캐시 키에 반영합니다. */ private function getResolutionCacheKey(string $layoutName, int $templateId): string { return "layout_resolver.{$templateId}.{$layoutName}"; } /** * 특정 레이아웃의 해석 캐시를 무효화합니다. */ public function clearResolutionCache(string $layoutName, int $templateId): void { // Before 훅 - 캐시 무효화 전 HookManager::doAction('core.layout_resolver.before_cache_clear', $layoutName, $templateId); $cacheKey = $this->getResolutionCacheKey($layoutName, $templateId); $this->cache->forget($cacheKey); Log::info('레이아웃 해석 캐시 무효화', [ 'layout_name' => $layoutName, 'template_id' => $templateId, 'cache_key' => $cacheKey, ]); // After 훅 - 캐시 무효화 후 HookManager::doAction('core.layout_resolver.after_cache_clear', $layoutName, $templateId, $cacheKey); } /** * 특정 템플릿의 모든 레이아웃 해석 캐시를 무효화합니다. */ public function clearAllResolutionCacheByTemplate(int $templateId): void { // Before 훅 - 전체 캐시 무효화 전 HookManager::doAction('core.layout_resolver.before_all_cache_clear', $templateId); // 해당 템플릿의 모든 레이아웃 조회 $layouts = $this->layoutRepository->getLayoutNamesByTemplateId($templateId); foreach ($layouts as $layoutName) { $cacheKey = $this->getResolutionCacheKey($layoutName, $templateId); $this->cache->forget($cacheKey); } Log::info('템플릿의 모든 레이아웃 해석 캐시 무효화', [ 'template_id' => $templateId, 'count' => $layouts->count(), ]); // After 훅 - 전체 캐시 무효화 후 HookManager::doAction('core.layout_resolver.after_all_cache_clear', $templateId, $layouts->count()); } /** * 특정 모듈의 모든 레이아웃 해석 캐시를 무효화합니다. */ public function clearResolutionCacheByModule(string $moduleIdentifier): void { // Before 훅 - 모듈 캐시 무효화 전 HookManager::doAction('core.layout_resolver.before_module_cache_clear', $moduleIdentifier); // 해당 모듈의 모든 레이아웃 조회 $layouts = $this->layoutRepository->getLayoutsByModule($moduleIdentifier); foreach ($layouts as $layout) { $cacheKey = $this->getResolutionCacheKey($layout->name, $layout->template_id); $this->cache->forget($cacheKey); } Log::info('모듈의 모든 레이아웃 해석 캐시 무효화', [ 'module_identifier' => $moduleIdentifier, 'count' => $layouts->count(), ]); // After 훅 - 모듈 캐시 무효화 후 HookManager::doAction('core.layout_resolver.after_module_cache_clear', $moduleIdentifier, $layouts->count()); } /** * 캐시 히트율 통계 조회 * * @return array{hits: int, misses: int, total: int, hit_rate: float} */ public function getCacheStats(): array { $total = $this->cacheStats['hits'] + $this->cacheStats['misses']; $hitRate = $total > 0 ? round(($this->cacheStats['hits'] / $total) * 100, 2) : 0.0; return [ 'hits' => $this->cacheStats['hits'], 'misses' => $this->cacheStats['misses'], 'total' => $total, 'hit_rate' => $hitRate, ]; } /** * 캐시 통계 초기화 */ public function resetCacheStats(): void { $this->cacheStats = [ 'hits' => 0, 'misses' => 0, ]; } /** * 특정 레이아웃이 오버라이드되었는지 확인 */ public function isOverridden(string $layoutName, int $templateId): bool { return $this->layoutRepository->findTemplateOverride($templateId, $layoutName) !== null; } /** * 특정 템플릿에서 오버라이드된 모든 레이아웃 목록 조회 */ public function getOverriddenLayouts(int $templateId): Collection { return $this->layoutRepository->getOverriddenLayouts($templateId); } /** * 특정 모듈의 레이아웃 중 오버라이드된 것들 조회 */ public function getModuleLayoutOverrides(string $moduleIdentifier, int $templateId): Collection { return $this->layoutRepository->getModuleLayoutOverrides($moduleIdentifier, $templateId); } }