fix(core): 공개 템플릿 config 캐시 무효화 누락과 확장 캐시 삭제 경로 동종 결함 정비

공개 config.json 의 고정 캐시 키(template.config.{identifier})가 어떤 갱신
경로에서도 forget 되지 않아 template:update/cache-clear/비활성화/삭제 후에도
최대 1시간(TTL) 이전 manifest 로 응답되던 결함(공개 )을 수정한다.

- TemplateManager::clearTemplateCache 공개화 — config/components_manifest(두
 변종)/현재 버전 routes·language 키 능동 forget, updateTemplate cleanup 호출
- forgetLayoutCacheKeys 가 편집기용 .with_source_meta 변종까지 삭제
- ext.cache_version 쓰기를 incrementExtensionCacheVersion 단일 지점으로 통일
- module/plugin:cache-clear 의 유령 키 forget 을 실재 캐시(레이아웃 서빙/해석
 캐시, 상태 키, 미들웨어 인덱스, 버전 bump) 무효화로 교체
- 공개 routes/language/layout API 의 ?v 생략 폴백을 0 → 현재 버전으로 교정
This commit is contained in:
HeuJung
2026-08-17 13:22:48 +09:00
parent 09a700e4c7
commit e819db9d42
27 changed files with 891 additions and 247 deletions
+3
View File
@@ -70,6 +70,9 @@
- 확인 질문이 있는 Artisan 명령(확장 설치·삭제·업데이트, 코어 업데이트, 검색 인덱스 재생성 등)을 터미널이 아닌 곳에서 실행하면 아무 반응 없이 멈춰 있던 문제를 수정했습니다. 예약 작업·배포 스크립트·CI 처럼 사람이 답할 수 없는 환경에서는 질문이 화면에 나오지도 않은 채 응답을 기다렸기 때문에, 명령이 실패한 것도 아니고 진행 중인 것도 아닌 상태로 남아 "명령이 몹시 느리다" 로만 보였습니다. 이제 그런 환경에서는 질문을 건너뛰고 기본 동작을 따릅니다 — 삭제·업데이트처럼 되돌리기 어려운 명령의 기본 동작은 "중단" 이므로, 확인 없이 수행되는 일은 없습니다. 그대로 진행하려면 종전처럼 `--no-interaction` 이나 `--force` 를 붙이면 됩니다.
- Windows 에서 확장(모듈·플러그인·템플릿) 업데이트가 "디렉토리 이동 실패" 로 중단되던 문제를 해소했습니다. Windows 는 편집기·개발 도구·파일 감시 프로그램이 폴더를 보고 있기만 해도 폴더 이름 바꾸기를 차단하므로, 폴더를 통째로 이동하는 방식으로는 실패를 피할 수 없었습니다. 이제 이동이 차단되면 파일 단위 교체로 자동 전환해 어떤 프로그램도 종료하지 않고 업데이트를 완료합니다. 잠근 프로그램을 찾아 강제 종료하던 이전 동작은 제거했습니다 — 사용 중인 편집기나 개발 도구가 예고 없이 종료되는 부작용이 있었고, 폴더를 감시만 하는 프로그램은 찾아내지도 못했습니다. 백업 복원에도 같은 방식이 적용되며, 실패한 업데이트가 남긴 임시 폴더는 다음 업데이트 때 자동으로 정리됩니다.
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
- 템플릿을 업데이트하거나 템플릿 캐시를 삭제해도 공개 템플릿 설정(config.json)이 최대 1시간 동안 이전 내용으로 응답되던 문제를 수정했습니다. 비활성화·삭제한 템플릿의 설정이 같은 시간 동안 계속 조회되던 문제와, `template:cache-clear` 명령이 실제로는 대부분의 캐시를 지우지 못하면서 삭제 완료로 보고하던 문제도 함께 고쳤습니다. 모듈·플러그인의 캐시 삭제 명령도 실재하는 캐시를 지우도록 정비했습니다. (#119 @glitter-gim 님께서 제보해주셨습니다.)
- 템플릿 업데이트로 컴포넌트 정의(components.json)가 바뀌어도 레이아웃 저장 검증이 최대 1시간 동안 이전 정의를 기준으로 동작하던 문제와, 템플릿 상태 변경 후에도 레이아웃 편집기의 병합 캐시 일부가 남아 편집기가 이전 내용을 받을 수 있던 문제를 수정했습니다.
- 캐시 버전 조회 주소(`?v=`)를 생략하고 공개 라우트·다국어·레이아웃 API 를 직접 호출하는 경우(외부 연동·봇 등), 그 응답이 어떤 갱신 경로로도 무효화되지 않는 별도 캐시에 남던 문제를 수정했습니다. 브라우저를 통한 일반 사용에는 영향이 없습니다.
## [7.0.6] - 2026-08-10
@@ -2,15 +2,22 @@
namespace App\Console\Commands\Module;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\ExtensionMiddlewareRegistry;
use App\Extension\ModuleManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\InvalidatesLayoutCache;
use App\Services\ExtensionBundleService;
use App\Services\LayoutResolverService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ClearModuleCacheCommand extends Command
{
use ClearsTemplateCaches;
use InvalidatesLayoutCache;
/**
* The name and signature of the console command.
*/
@@ -27,15 +34,19 @@ class ClearModuleCacheCommand extends Command
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository,
private CacheInterface $cache,
private ExtensionBundleService $bundleService
protected LayoutRepositoryInterface $layoutRepository,
private ExtensionBundleService $bundleService,
private LayoutResolverService $layoutResolver
) {
parent::__construct();
}
/**
* Execute the console command.
*
* 실재하는 서버 캐시(모듈 레이아웃 서빙/해석 캐시 + 상태 키 + 미들웨어 인덱스
* + 버전 포함 키)를 무효화한다.
* 종전 forget 대상(module.config.{id} 등)은 writer 가 없는 유령 키였다 (#588).
*/
public function handle(): int
{
@@ -61,6 +72,11 @@ class ClearModuleCacheCommand extends Command
$clearedCount = $this->clearModuleCache($identifier);
// 상태 키(ext.modules.*) + 미들웨어 인덱스 + 버전 포함 키 무효화 (버전 bump)
ModuleManager::invalidateModuleStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
$this->info('✅ '.__('modules.commands.cache_clear.success_single', [
'module' => $identifier,
'count' => $clearedCount,
@@ -69,25 +85,16 @@ class ClearModuleCacheCommand extends Command
// 모든 모듈 캐시 삭제
$this->info(__('modules.commands.cache_clear.clearing_all'));
// 전체 모듈 캐시 키 삭제
$cacheKeys = [
'modules.all',
'modules.active',
'modules.installed',
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
// 각 모듈별 캐시 삭제
$allModules = $this->moduleManager->getAllModules();
foreach ($allModules as $moduleName => $module) {
foreach ($this->moduleManager->getAllModules() as $module) {
$clearedCount += $this->clearModuleCache($module->getIdentifier());
}
// 상태 키 + 미들웨어 인덱스 + 버전 포함 키 무효화는 전체에서 1회면 충분
ModuleManager::invalidateModuleStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
// 모듈 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
$clearedCount += $this->bundleService->clearBundles('module');
@@ -113,26 +120,23 @@ class ClearModuleCacheCommand extends Command
/**
* 특정 모듈의 캐시를 삭제합니다.
*
* 모듈이 소유한 실재 서버 캐시는 레이아웃 캐시(서빙/병합 + 해석)다 — 각각
* 트레이트(InvalidatesLayoutCache)와 LayoutResolverService 단일 지점으로
* 위임해 키 규약 드리프트를 방지한다. 해석 캐시(layout_resolver.*)는 버전
* 접미사 없는 고정 키에 레이아웃 row ID 를 저장하므로 능동 삭제하지 않으면
* TTL 동안 이전 해석 결과가 유지된다 (#588 동종 보강).
*
* @param string $identifier 모듈 식별자
* @return int 캐시를 무효화한 레이아웃 수
*/
private function clearModuleCache(string $identifier): int
{
$clearedCount = 0;
$this->invalidateExtensionLayoutCache($identifier, 'module');
$this->layoutResolver->clearResolutionCacheByModule($identifier);
// 모듈별 캐시 키
$cacheKeys = [
"module.config.{$identifier}",
"module.info.{$identifier}",
"module.routes.{$identifier}",
"module.permissions.{$identifier}",
"module.menus.{$identifier}",
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
return $clearedCount;
return $this->layoutRepository
->getBySourceIdentifier($identifier, LayoutSourceType::Module)
->count();
}
}
@@ -2,15 +2,21 @@
namespace App\Console\Commands\Plugin;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\ExtensionMiddlewareRegistry;
use App\Extension\PluginManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\InvalidatesLayoutCache;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ClearPluginCacheCommand extends Command
{
use ClearsTemplateCaches;
use InvalidatesLayoutCache;
/**
* The name and signature of the console command.
*/
@@ -27,8 +33,7 @@ class ClearPluginCacheCommand extends Command
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository,
private CacheInterface $cache,
protected LayoutRepositoryInterface $layoutRepository,
private ExtensionBundleService $bundleService
) {
parent::__construct();
@@ -36,6 +41,12 @@ class ClearPluginCacheCommand extends Command
/**
* Execute the console command.
*
* 실재하는 서버 캐시(플러그인 레이아웃 캐시 + 상태 키 + 미들웨어 인덱스
* + 버전 포함 키)를 무효화한다.
* 종전 forget 대상(plugin.config.{id} 등)은 writer 가 없는 유령 키였다 (#588).
* 해석 캐시(layout_resolver.*)는 모듈 레이아웃 전용이라 플러그인은 비대상
* (LayoutResolverService::resolve 의 fromModules 스코프는 plugin row 를 반환하지 않음).
*/
public function handle(): int
{
@@ -61,6 +72,11 @@ class ClearPluginCacheCommand extends Command
$clearedCount = $this->clearPluginCache($identifier);
// 상태 키(ext.plugins.*) + 미들웨어 인덱스 + 버전 포함 키 무효화 (버전 bump)
PluginManager::invalidatePluginStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
$this->info('✅ '.__('plugins.commands.cache_clear.success_single', [
'plugin' => $identifier,
'count' => $clearedCount,
@@ -69,25 +85,16 @@ class ClearPluginCacheCommand extends Command
// 모든 플러그인 캐시 삭제
$this->info(__('plugins.commands.cache_clear.clearing_all'));
// 전체 플러그인 캐시 키 삭제
$cacheKeys = [
'plugins.all',
'plugins.active',
'plugins.installed',
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
// 각 플러그인별 캐시 삭제
$allPlugins = $this->pluginManager->getAllPlugins();
foreach ($allPlugins as $pluginName => $plugin) {
foreach ($this->pluginManager->getAllPlugins() as $plugin) {
$clearedCount += $this->clearPluginCache($plugin->getIdentifier());
}
// 상태 키 + 미들웨어 인덱스 + 버전 포함 키 무효화는 전체에서 1회면 충분
PluginManager::invalidatePluginStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
// 플러그인 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
$clearedCount += $this->bundleService->clearBundles('plugin');
@@ -113,25 +120,18 @@ class ClearPluginCacheCommand extends Command
/**
* 특정 플러그인의 캐시를 삭제합니다.
*
* 플러그인이 소유한 실재 서버 캐시는 레이아웃 캐시뿐이다 — 트레이트 단일 지점
* (InvalidatesLayoutCache)으로 위임해 키 규약 드리프트를 방지한다.
*
* @return int 캐시를 무효화한 레이아웃 수
*/
private function clearPluginCache(string $identifier): int
{
$clearedCount = 0;
$this->invalidateExtensionLayoutCache($identifier, 'plugin');
// 플러그인별 캐시 키 (메뉴 캐시 없음)
$cacheKeys = [
"plugin.config.{$identifier}",
"plugin.info.{$identifier}",
"plugin.routes.{$identifier}",
"plugin.permissions.{$identifier}",
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
return $clearedCount;
return $this->layoutRepository
->getBySourceIdentifier($identifier, LayoutSourceType::Plugin)
->count();
}
}
@@ -2,16 +2,17 @@
namespace App\Console\Commands\Template;
use App\Contracts\Extension\CacheInterface;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Models\Template;
use App\Models\TemplateLayout;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ClearTemplateCacheCommand extends Command
{
use ClearsTemplateCaches;
/**
* The name and signature of the console command.
*/
@@ -28,7 +29,6 @@ class ClearTemplateCacheCommand extends Command
*/
public function __construct(
private TemplateManager $templateManager,
private CacheInterface $cache,
private ExtensionBundleService $bundleService
) {
parent::__construct();
@@ -67,6 +67,10 @@ class ClearTemplateCacheCommand extends Command
/**
* 특정 템플릿의 캐시 삭제
*
* 라이프사이클(update/deactivate/uninstall)과 동일한 무효화 단일 지점
* (`TemplateManager::clearTemplateCache()`)을 경유한다 — 커맨드가 키 목록을
* 별도로 유지하면 키 규약 변경 시 유령 forget 으로 사문화된다 (#588, 공개 #119).
*/
private function clearSingleTemplateCache(string $identifier): void
{
@@ -78,34 +82,12 @@ class ClearTemplateCacheCommand extends Command
$this->info(__('templates.commands.cache_clear.clearing_single', ['template' => $identifier]));
$clearedCount = 0;
// 고정 키(config/components_manifest) + 현재 버전 routes/language + 레이아웃 캐시
$clearedCount = $this->templateManager->clearTemplateCache($identifier);
// 1. 레이아웃 캐시 삭제
$templateRecord = Template::where('identifier', $identifier)->first();
if ($templateRecord) {
$layouts = TemplateLayout::where('template_id', $templateRecord->id)->get();
foreach ($layouts as $layout) {
$this->cache->forget("layout.{$identifier}.{$layout->name}");
$clearedCount++;
}
}
// 2. Routes 캐시 삭제
$this->cache->forget("template.routes.{$identifier}");
$clearedCount++;
// 3. 다국어 파일 캐시 삭제
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
foreach ($supportedLocales as $locale) {
$this->cache->forget("template.language.{$identifier}.{$locale}");
$clearedCount++;
}
// 4. 활성 템플릿 타입 캐시 삭제
if ($templateRecord) {
$this->cache->forget("templates.active.{$templateRecord->type}");
$clearedCount++;
}
// 상태 키(ext.templates.*) + 버전 포함 키 무효화 (버전 bump)
TemplateManager::invalidateTemplateStatusCache();
$this->incrementExtensionCacheVersion();
$this->info('✅ '.__('templates.commands.cache_clear.success_single', [
'template' => $identifier,
@@ -126,36 +108,17 @@ class ClearTemplateCacheCommand extends Command
$this->info(__('templates.commands.cache_clear.clearing_all'));
$clearedCount = 0;
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
// 모든 설치된 템플릿의 캐시 삭제
$templates = Template::all();
foreach ($templates as $templateRecord) {
// 1. 레이아웃 캐시 삭제
$layouts = TemplateLayout::where('template_id', $templateRecord->id)->get();
foreach ($layouts as $layout) {
$this->cache->forget("layout.{$templateRecord->identifier}.{$layout->name}");
$clearedCount++;
}
// 2. Routes 캐시 삭제
$this->cache->forget("template.routes.{$templateRecord->identifier}");
$clearedCount++;
// 3. 다국어 파일 캐시 삭제
foreach ($supportedLocales as $locale) {
$this->cache->forget("template.language.{$templateRecord->identifier}.{$locale}");
$clearedCount++;
}
// 모든 설치된 템플릿의 캐시 삭제 (설치 레코드 기준 — 라이프사이클과 동일 지점)
foreach (Template::all() as $templateRecord) {
$clearedCount += $this->templateManager->clearTemplateCache($templateRecord->identifier);
}
// 4. 활성 템플릿 타입 캐시 삭제
$this->cache->forget('templates.active.admin');
$this->cache->forget('templates.active.user');
$clearedCount += 2;
// 상태 키 + 버전 포함 키 무효화는 전체에서 1회면 충분
TemplateManager::invalidateTemplateStatusCache();
$this->incrementExtensionCacheVersion();
// 5. 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신)
// 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신)
$clearedCount += $this->bundleService->clearBundles();
$this->info('✅ '.__('templates.commands.cache_clear.success_all', [
+58 -22
View File
@@ -1687,13 +1687,9 @@ class TemplateManager implements TemplateManagerInterface
}
if (! in_array($override->name, $registeredLayoutNames)) {
// 캐시 삭제 (레코드 삭제 전에 수행)
$cacheKey = "template.{$templateId}.layout.{$override->name}";
$this->cache()->forget($cacheKey);
$sourceHash = md5($override->source_type?->value.$override->source_identifier);
$cacheKeyWithHash = "template.{$templateId}.layout.{$override->name}.{$sourceHash}";
$this->cache()->forget($cacheKeyWithHash);
// 캐시 삭제 (레코드 삭제 전에 수행) — 키 조립은 트레이트 단일 지점에 위임
// (.with_source_meta 변종 포함, 수작업 키 목록의 규약 드리프트 방지)
$this->forgetLayoutCacheKeys($override, $templateName);
$override->forceDelete();
$deletedCount++;
@@ -1740,14 +1736,10 @@ class TemplateManager implements TemplateManagerInterface
}
foreach ($overrideLayouts as $layout) {
// 기본 캐시 키 패턴으로 삭제
$cacheKey = "template.{$templateId}.layout.{$layout->name}";
$this->cache()->forget($cacheKey);
// sourceHash를 포함한 캐시 키도 삭제 (LayoutService의 캐시 키 패턴)
$sourceHash = md5($layout->source_type?->value.$layout->source_identifier);
$cacheKeyWithHash = "template.{$templateId}.layout.{$layout->name}.{$sourceHash}";
$this->cache()->forget($cacheKeyWithHash);
// 키 조립은 트레이트 단일 지점에 위임 (.with_source_meta 변종 포함).
// 이 경로는 identifier 를 보유하지 않으므로 '' 전달 — 버전 포함 공개
// 서빙 키는 종전과 동일하게 버전 bump 로 무효화된다.
$this->forgetLayoutCacheKeys($layout, '');
}
Log::info(__('templates.info.override_layouts_cache_invalidated'), [
@@ -1794,41 +1786,79 @@ class TemplateManager implements TemplateManagerInterface
/**
* 템플릿 관련 모든 캐시를 삭제합니다.
*
* 템플릿 라이프사이클(update/deactivate/uninstall/cache-clear)이 공유하는
* 무효화 단일 지점이다. 버전 접미사 없는 고정 키(`template.config.{identifier}`,
* `template.{id|identifier}.components_manifest`)는 캐시 버전 bump 로 무효화되지
* 않으므로 여기서 능동 forget 한다 — 누락 시 공개 config.json 이 TTL(1시간) 동안
* 이전 manifest 로 응답된다 (#588, 공개 #119).
*
* template:cache-clear 커맨드가 호출할 수 있도록 public 이다.
*
* @param string $templateIdentifier 템플릿 식별자
* @return int 능동 삭제를 시도한 캐시 키 수
*/
protected function clearTemplateCache(string $templateIdentifier): void
public function clearTemplateCache(string $templateIdentifier): int
{
$clearedCount = 0;
// identifier 만으로 가능한 고정 키 forget — uninstall 등 DB 레코드가 이미
// 없는 상황에서도 반드시 수행되어야 하므로 레코드 조회보다 먼저 둔다.
$this->cache()->forget("template.config.{$templateIdentifier}");
$clearedCount++;
// components_manifest identifier 변종 (ComponentExists 는 int|string 수용)
$this->cache()->forget("template.{$templateIdentifier}.components_manifest");
$clearedCount++;
// 활성 템플릿 + 레이아웃 무변경 업데이트 경로는 캐시 버전 bump 가 없어
// 현재 버전 routes/language 키가 stale 로 남는다 — warmTemplateCache() 와
// 대칭으로 현재 버전 키를 능동 삭제한다 (이전 버전 키는 TTL 자연 만료).
$cacheVersion = self::getExtensionCacheVersion();
$this->cache()->forget("template.routes.{$templateIdentifier}.v{$cacheVersion}");
$clearedCount++;
foreach (config('app.supported_locales', ['ko', 'en']) as $locale) {
$this->cache()->forget("template.language.{$templateIdentifier}.{$locale}.v{$cacheVersion}");
$clearedCount++;
}
// DB 기반 조회 — 파일 시스템 상태와 무관하게 캐시 삭제 보장
// (파일 교체 중이거나 reloadTemplate() 전에도 캐시를 확실히 삭제)
$templateRecord = $this->templateRepository->findByIdentifier($templateIdentifier);
if (! $templateRecord) {
return;
return $clearedCount;
}
// 레이아웃 캐시 삭제 (버전 없는 내부 캐시)
$this->clearLayoutCaches($templateIdentifier);
// components_manifest 숫자 id 변종 (StoreLayoutRequest 가 integer 검증 — 실운영 키)
$this->cache()->forget("template.{$templateRecord->id}.components_manifest");
$clearedCount++;
// Routes/다국어 캐시는 버전 포함 키이므로 incrementExtensionCacheVersion() + TTL로 무효화됨
// 레이아웃 캐시 삭제 (버전 없는 내부 캐시)
$clearedCount += $this->clearLayoutCaches($templateIdentifier);
Log::info(__('templates.info.cache_cleared'), [
'template' => $templateIdentifier,
]);
return $clearedCount;
}
/**
* 템플릿의 모든 레이아웃 캐시를 삭제합니다.
*
* @param string $templateIdentifier 템플릿 식별자
* @return int 캐시를 무효화한 레이아웃 수
*/
protected function clearLayoutCaches(string $templateIdentifier): void
protected function clearLayoutCaches(string $templateIdentifier): int
{
// 템플릿의 모든 레이아웃 조회
$template = $this->templateRepository->findByIdentifier($templateIdentifier);
if (! $template) {
return;
return 0;
}
$this->invalidateTemplateLayoutCache($template->id, $templateIdentifier);
return $this->layoutRepository->getByTemplateId($template->id)->count();
}
/**
@@ -3110,6 +3140,12 @@ class TemplateManager implements TemplateManagerInterface
$onProgress?->__invoke('cleanup', '정리 중...');
ExtensionBackupHelper::deleteBackup($backupPath);
// 고정 키(config/components_manifest) + 현재 버전 routes/language 키 능동 삭제.
// 비활성 업데이트·레이아웃 무변경 업데이트 경로는 버전 bump 가 없어 이 호출이
// 없으면 공개 config.json 이 TTL 동안 이전 manifest 로 응답된다 (#588, 공개 #119).
// 활성 경로의 refreshTemplateLayouts() 경유 중복 삭제는 forget 멱등이라 무해.
$this->clearTemplateCache($identifier);
$this->clearAllTemplateLanguageCaches();
$this->clearAllTemplateRoutesCaches();
// refreshTemplateLayouts() 내부에서 변경 시 incrementExtensionCacheVersion() 호출됨
@@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Log;
* 버전 없는 내부 캐시 키를 능동 삭제합니다:
* 1. template.{templateId}.layout.{layoutName} - LayoutService에서 사용
* 2. template.{templateId}.layout.{layoutName}.{sourceHash} - 모듈/플러그인 레이아웃
* 3. 위 두 형태의 `.with_source_meta` 접미사 변종 - 편집기용 병합 응답 캐시
*
* 버전 포함 캐시 (layout.{identifier}.{name}.v{version})는
* incrementExtensionCacheVersion() + TTL로 무효화됩니다.
@@ -98,20 +99,26 @@ trait InvalidatesLayoutCache
$cache = $this->resolveLayoutCache();
// 1. LayoutService 내부 캐시 (버전 없음 → 능동 삭제)
$cache->forget("template.{$layout->template_id}.layout.{$layout->name}");
// 편집기용 병합 응답은 `.with_source_meta` 접미사 키로 별도 캐싱되므로
// (LayoutService::getMergedLayoutCacheKey) 두 변종을 함께 지운다 —
// 접미사 키를 남기면 activate/deactivate/uninstall/refresh 후에도
// 편집기가 stale 병합 캐시를 받는다 (#588).
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name));
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, withSourceMeta: true));
// 2. 소스 해시 포함 키 (버전 없음 → 능동 삭제)
// 2. 소스 해시 포함 키 (버전 없음 → 능동 삭제) — 접미사는 소스 해시 뒤
if ($layout->source_type && $layout->source_identifier) {
$sourceHash = md5($layout->source_type->value.$layout->source_identifier);
$cache->forget("template.{$layout->template_id}.layout.{$layout->name}.{$sourceHash}");
$sourceType = $layout->source_type->value;
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, $sourceType, $layout->source_identifier));
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, $sourceType, $layout->source_identifier, true));
}
// 3. PublicLayoutController 캐시 (버전 포함) — 일반 응답 + 편집기(`.meta`) 응답 두 키 모두.
// PublicLayoutController::serve() 가 `with_source_meta=1`(레이아웃 편집기) 응답을 `.meta`
// 접미사 별도 키로 캐싱하므로, 그 키를 함께 삭제하지 않으면 템플릿/레이아웃 상태 변화
// (refresh-layout / activate / deactivate / uninstall 등) 후에도 편집기가 stale 캐시를
// 받는다. 레이아웃
// 저장 경로(LayoutService::clearPublicServingCache)는 이미 두 키를 지우므로 정합을 맞춘다.
// 받는다. 레이아웃 저장 경로(LayoutService::clearPublicServingCache)는 이미 두 키를
// 지우므로 정합을 맞춘다.
if ($templateIdentifier) {
$cacheVersion = (int) $cache->get('ext.cache_version', 0);
$cache->forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}");
@@ -137,26 +144,32 @@ trait InvalidatesLayoutCache
/**
* 레이아웃 캐시 키를 생성합니다.
*
* `$withSourceMeta` 가 true 면 `.with_source_meta` 접미사를 붙인다 — 편집기용
* 병합 응답 키(LayoutService::getMergedLayoutCacheKey 와 동형, 접미사는 소스 해시 뒤).
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param string|null $sourceType 소스 타입 (선택)
* @param string|null $sourceIdentifier 소스 식별자 (선택)
* @param bool $withSourceMeta 편집기용 병합 응답 키 여부 (선택)
* @return string 캐시 키
*/
protected function buildLayoutCacheKey(
int $templateId,
string $layoutName,
?string $sourceType = null,
?string $sourceIdentifier = null
?string $sourceIdentifier = null,
bool $withSourceMeta = false
): string {
$baseKey = "template.{$templateId}.layout.{$layoutName}";
$metaSuffix = $withSourceMeta ? '.with_source_meta' : '';
if ($sourceType && $sourceIdentifier) {
$sourceHash = md5($sourceType.$sourceIdentifier);
return "{$baseKey}.{$sourceHash}";
return "{$baseKey}.{$sourceHash}{$metaSuffix}";
}
return $baseKey;
return "{$baseKey}{$metaSuffix}";
}
}
@@ -3,10 +3,12 @@
namespace App\Http\Controllers\Api\Public;
use App\Enums\ExtensionStatus;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Services\LayoutService;
use App\Services\TemplateService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
@@ -17,6 +19,8 @@ use Illuminate\Http\Response;
*/
class PublicLayoutController extends PublicBaseController
{
use ClearsTemplateCaches;
/**
* 레이아웃 캐시 TTL (초)
*/
@@ -69,7 +73,10 @@ class PublicLayoutController extends PublicBaseController
// 키만 forget 하므로 키 형식이 어긋나 무효화가 빗나간다(저장/복원 후 편집기 캔버스만
// stale). nonce 는 브라우저 HTTP 캐시 우회용(URL·ETag 차이로 이미 달성)이고, 서버 캐시 키
// 정합은 정수 버전이 SSoT 다. `(int)` 캐스팅은 PHP 가 소수점에서 절단해 정수부만 남긴다.
$cacheVersion = (int) request()->query('v', 0);
// `?v` 생략 시 현재 버전으로 폴백 — 리터럴 0 폴백은 워밍/무효화 어느 경로에도
// 걸리지 않는 `.v0` 영구 사각 키를 만든다 (#588).
$rawVersion = request()->query('v');
$cacheVersion = $rawVersion !== null ? (int) $rawVersion : self::getExtensionCacheVersion();
// 편집기 출처 메타 옵션
// - 옵션이 truthy 면 각 노드에 `__source` 메타를 부여한 응답을 반환
@@ -119,7 +126,7 @@ class PublicLayoutController extends PublicBaseController
$mergedLayout,
self::CACHE_TTL
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
} catch (ModelNotFoundException $e) {
// 레이아웃 또는 부모 레이아웃을 찾을 수 없음 - 예외 메시지 전달
return $this->notFound($e->getMessage());
}
@@ -22,6 +22,8 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
*/
class PublicTemplateController extends PublicBaseController
{
use ClearsTemplateCaches;
public function __construct(
private TemplateService $templateService,
private TemplateLayoutAttachmentService $layoutAttachmentService,
@@ -40,8 +42,11 @@ class PublicTemplateController extends PublicBaseController
// API 사용량 기록
$this->logApiUsage('templates.routes', ['identifier' => $identifier]);
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화
$cacheVersion = request()->query('v', 0);
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화.
// `?v` 생략 시 현재 버전으로 폴백 — 리터럴 0 폴백은 워밍/무효화 어느 경로에도
// 걸리지 않는 `.v0` 영구 사각 키를 만든다 (#588). `(int)` 캐스트로 키 위생 겸용.
$rawVersion = request()->query('v');
$cacheVersion = $rawVersion !== null ? (int) $rawVersion : self::getExtensionCacheVersion();
// 확장 업데이트 중에는 활성 디렉토리가 잠시 비어 그 모듈의 라우트가 통째로 빠진다.
// 그 순간의 응답을 버전 키에 캐시하면 업데이트가 끝난 뒤에도 캐시가 만료될 때까지
@@ -226,7 +231,7 @@ class PublicTemplateController extends PublicBaseController
// 캐시 버전을 응답에 포함하여 프론트엔드가 API 호출 시 사용하도록 함
$responseData = $configData['data'];
$responseData['cache_version'] = ClearsTemplateCaches::getExtensionCacheVersion();
$responseData['cache_version'] = self::getExtensionCacheVersion();
return $this->success(
__('templates.messages.config_retrieved'),
@@ -249,8 +254,10 @@ class PublicTemplateController extends PublicBaseController
'locale' => $locale,
]);
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화
$cacheVersion = request()->query('v', 0);
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화.
// `?v` 생략 시 현재 버전으로 폴백 (getRoutes 와 동일 — `.v0` 사각 키 방지)
$rawVersion = request()->query('v');
$cacheVersion = $rawVersion !== null ? (int) $rawVersion : self::getExtensionCacheVersion();
// 캐싱된 응답 반환 (1시간 유효)
$languageData = $this->cached(
@@ -4,7 +4,7 @@ namespace App\Listeners\LayoutEditor;
use App\Contracts\Extension\HookListenerInterface;
use App\Contracts\Repositories\TemplateCustomTranslationRepositoryInterface;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Services\LanguagePack\CustomTranslationUsageScanner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
@@ -30,6 +30,8 @@ use Throwable;
*/
class MarkOrphanedCustomTranslations implements HookListenerInterface
{
use ClearsTemplateCaches;
/**
* @param TemplateCustomTranslationRepositoryInterface $repository 커스텀 키 리포지토리
* @param CustomTranslationUsageScanner $scanner 키 사용 스캐너 (순수)
@@ -124,12 +126,11 @@ class MarkOrphanedCustomTranslations implements HookListenerInterface
/**
* 다국어 캐시 버전을 무효화합니다.
*
* `ext.cache_version` 은 코어 소유 키이므로 컨테이너 바인딩에 의존하지 않고
* 항상 CoreCacheDriver(`g7:core:` 네임스페이스)를 직접 생성합니다
* (ClearsTemplateCaches 트레이트와 동일 SSoT).
* `ext.cache_version` 쓰기는 ClearsTemplateCaches 트레이트 단일 지점 경유 —
* 트레이트가 고정 CoreCacheDriver + 메모이즈 스토어로 write/read 일관성을 보장한다.
*/
private function invalidateLanguageCache(): void
{
(new CoreCacheDriver(config('cache.default', 'array')))->put('ext.cache_version', time());
$this->incrementExtensionCacheVersion();
}
}
+6 -4
View File
@@ -2,7 +2,6 @@
namespace App\Services;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\LayoutExtensionRepositoryInterface;
use App\Contracts\Repositories\LayoutExtensionVersionRepositoryInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
@@ -15,6 +14,7 @@ use App\Extension\HookManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\ComputesLayoutContentHash;
use App\Models\LayoutExtension;
use App\Models\TemplateLayoutExtensionVersion;
@@ -32,6 +32,7 @@ use Illuminate\Support\Facades\Log;
*/
class LayoutExtensionService
{
use ClearsTemplateCaches;
use ComputesLayoutContentHash;
/**
@@ -123,7 +124,6 @@ class LayoutExtensionService
private LayoutExtensionRepositoryInterface $repository,
private LayoutExtensionVersionRepositoryInterface $versionRepository,
private LayoutRepositoryInterface $layoutRepository,
private CacheInterface $cache,
private PluginRepositoryInterface $pluginRepository,
private ModuleRepositoryInterface $moduleRepository,
) {}
@@ -1830,8 +1830,10 @@ class LayoutExtensionService
}
// extension_point 는 여러 레이아웃에서 사용될 수 있으므로 전역 캐시 버전 갱신
// overlay 도 프론트엔드 브라우저 캐시 무효화를 위해 동일하게 갱신
$this->cache->put('ext.cache_version', time());
// overlay 도 프론트엔드 브라우저 캐시 무효화를 위해 동일하게 갱신.
// 쓰기는 트레이트 단일 지점(고정 CoreCacheDriver + 메모이즈 스토어) 경유 —
// 주입 CacheInterface 직접 put 은 재바인딩/스토어 상이 시 read 와 어긋난다.
$this->incrementExtensionCacheVersion();
}
/**
+8 -3
View File
@@ -12,6 +12,7 @@ use App\Exceptions\CircularReferenceException;
use App\Exceptions\ConcurrentModificationException;
use App\Exceptions\LayoutIncludeException;
use App\Extension\HookManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Helpers\PermissionHelper;
use App\Models\TemplateLayout;
use App\Models\User;
@@ -21,6 +22,8 @@ use Illuminate\Support\Facades\Log;
class LayoutService
{
use ClearsTemplateCaches;
/**
* LayoutRepository, LayoutVersionRepository, TemplateRepository, LayoutResolverService 및 LayoutExtensionService 주입
*/
@@ -1470,7 +1473,9 @@ class LayoutService
// 프론트엔드 브라우저 캐시 무효화를 위해 ext.cache_version 증가
// PublicLayoutController가 ?v={version} 기반 HTTP 캐시를 사용하므로
// 버전 변경 시 브라우저가 새 URL로 인식하여 캐시를 우회합니다.
$this->cache->put('ext.cache_version', time());
// 쓰기는 트레이트 단일 지점(고정 CoreCacheDriver + 메모이즈 스토어) 경유 —
// 주입 CacheInterface 직접 put 은 재바인딩/스토어 상이 시 read 와 어긋난다.
$this->incrementExtensionCacheVersion();
// After 훅 - 레이아웃 업데이트 후
HookManager::doAction('core.layout.after_update', $layout, $templateId, $name, $data);
@@ -1579,8 +1584,8 @@ class LayoutService
// 캐시 무효화
$this->clearDependentLayoutsCache($templateId, $name);
// 프론트엔드 브라우저 캐시 무효화
$this->cache->put('ext.cache_version', time());
// 프론트엔드 브라우저 캐시 무효화 (트레이트 단일 지점 경유)
$this->incrementExtensionCacheVersion();
// After 훅 - 버전 복원 후
HookManager::doAction('core.layout.after_version_restore', $newVersion, $templateId, $name, $versionId);
@@ -2,9 +2,9 @@
namespace App\Services;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\TemplateCustomTranslationRepositoryInterface;
use App\Exceptions\ConcurrentModificationException;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Models\TemplateCustomTranslation;
use App\Services\LanguagePack\CustomTranslationUsageScanner;
use Illuminate\Database\Eloquent\Collection;
@@ -22,10 +22,12 @@ use Illuminate\Database\QueryException;
* - seq 충돌 시 재시도 (unique 제약 위반 → seq 증가)
* - 신규 키 생성 시 모든 활성 로케일에 폴백 시드
* - 낙관적 잠금 (`lock_version` — update 경로)
* - 커스텀 키 CRUD 시 다국어 캐시 무효화
* - 커스텀 키 CRUD 시 다국어 캐시 무효화
*/
class TemplateCustomTranslationService
{
use ClearsTemplateCaches;
/**
* seq 충돌 재시도 최대 횟수.
*/
@@ -33,11 +35,9 @@ class TemplateCustomTranslationService
/**
* @param TemplateCustomTranslationRepositoryInterface $repository 커스텀 다국어 키 리포지토리
* @param CacheInterface $cache 캐시 드라이버
*/
public function __construct(
private readonly TemplateCustomTranslationRepositoryInterface $repository,
private readonly CacheInterface $cache,
) {}
/**
@@ -287,11 +287,12 @@ class TemplateCustomTranslationService
*
* `serveLanguage` 캐시 키가 `template.language.{id}.{locale}.v{cacheVersion}`
* 형태로 `ext.cache_version` 을 포함하므로, 이 값을 갱신하면 다음
* `loadTranslations` 가 재fetch 합니다.
* `loadTranslations` 가 재fetch 합니다. 쓰기는 트레이트 단일 지점
* (고정 CoreCacheDriver + 메모이즈 스토어) 경유.
*/
private function invalidateLanguageCache(): void
{
$this->cache->put('ext.cache_version', time());
$this->incrementExtensionCacheVersion();
}
/**
+1 -1
View File
@@ -97614,7 +97614,7 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** 활성 템플릿의 설정 파일(template.json, error_config 등 메타데이터)을 서빙하는 공개 엔드포인트입니다. 응답에 확장 캐시 버전(`cache_version`)을 포함해 프론트엔드가 후속 API 호출에 사용하게 하며, 비활성/미존재 템플릿은 404입니다. 1시간 캐시됩니다. 인증이 필요 없습니다.
**설명** 활성 템플릿의 설정 파일(template.json, error_config 등 메타데이터)을 서빙하는 공개 엔드포인트입니다. 응답에 확장 캐시 버전(`cache_version`)을 포함해 프론트엔드가 후속 API 호출에 사용하게 하며, 비활성/미존재 템플릿은 404입니다. 1시간 캐시됩니다. 서버 캐시는 템플릿 라이프사이클(`template:update` / `template:cache-clear` / 비활성화 / 삭제) 시점에 즉시 무효화되어 최신 매니페스트가 반영됩니다. 인증이 필요 없습니다.
### GET /api/templates/{identifier}/editor-spec
+1 -1
View File
@@ -181,7 +181,7 @@ php artisan template:install [identifier] [--force]
php artisan template:activate [identifier]
php artisan template:deactivate [identifier]
php artisan template:uninstall [identifier]
php artisan template:cache-clear
php artisan template:cache-clear [identifier?]
php artisan template:check-updates [identifier?]
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force] [--source=auto|bundled|github]
+57 -34
View File
@@ -34,23 +34,24 @@
## 캐싱 계층
### 1. 활성 템플릿 캐시
### 1. 활성 템플릿 상태 캐시
```php
// 키: templates.active.{type}
// TTL: 3600초 (1시간)
// 값: Template 모델 객체
// 키: ext.templates.active_identifiers / ext.templates.active_identifiers_{type} / ext.templates.installed_identifiers
// TTL: extension_status_ttl 설정값 (기본 86400초)
// 값: 템플릿 식별자 목록 (CachesTemplateStatus 트레이트가 관리)
Cache::remember("templates.active.admin", 3600, function () {
$activeIdentifiers = $cache->remember('ext.templates.active_identifiers_admin', function () {
return Template::where('type', 'admin')
->where('is_active', true)
->first();
});
->pluck('identifier')
->all();
}, $ttl);
```
**무효화 시점**:
- 템플릿 활성화/비활성화 시
- 템플릿 삭제 시
- 템플릿 설치/활성화/비활성화/삭제/업데이트 시 (`TemplateManager::invalidateTemplateStatusCache()` — 상태 키 4종 일괄 삭제)
- `template:cache-clear` 실행 시
---
@@ -93,22 +94,22 @@ Cache::remember("template.layout.merged.dashboard.ko", 3600, function () use ($l
---
### 4. 컴포넌트 레지스트리 캐시
### 4. 컴포넌트 매니페스트 캐시
```php
// 키: template.components.{identifier}
// TTL: 무제한 (수동 무효화)
// 값: components.json 파싱 결과
// 키: template.{templateId}.components_manifest (숫자 id / identifier 두 변종)
// TTL: 3600초 (1시간)
// 값: components.json 파싱 결과 (레이아웃 컴포넌트 검증용)
Cache::rememberForever("template.components.sirsoft-admin_basic", function () use ($identifier) {
$manifest = $cache->remember("template.{$templateId}.components_manifest", function () use ($identifier) {
$componentsPath = base_path("templates/{$identifier}/components.json");
return json_decode(file_get_contents($componentsPath), true);
});
```
**무효화 시점**:
- 템플릿 재설치 시
- components.json 수정 시 (개발 중)
- 템플릿 라이프사이클 전 경로 (`TemplateManager::clearTemplateCache()` — update/deactivate/uninstall/`template:cache-clear`)
- components.json 이 바뀌는 템플릿 업데이트 시 (위 경로에 포함)
---
@@ -326,11 +327,13 @@ use Illuminate\Support\Facades\Cache;
class TemplateCacheService
{
/**
* 템플릿 활성화 캐시 무효화
* 템플릿 상태 캐시 무효화
*/
public function invalidateActiveTemplate(string $type): void
{
Cache::forget("templates.active.{$type}");
// 상태 키(ext.templates.active_identifiers* / installed_identifiers)는
// CachesTemplateStatus 가 관리 — 단일 지점에 위임한다
TemplateManager::invalidateTemplateStatusCache();
}
/**
@@ -363,11 +366,12 @@ class TemplateCacheService
}
/**
* 컴포넌트 레지스트리 캐시 무효화
* 컴포넌트 매니페스트 캐시 무효화 (숫자 id / identifier 두 변종)
*/
public function invalidateComponentRegistry(string $identifier): void
public function invalidateComponentRegistry(int $templateId, string $identifier): void
{
Cache::forget("template.components.{$identifier}");
Cache::forget("template.{$templateId}.components_manifest");
Cache::forget("template.{$identifier}.components_manifest");
}
/**
@@ -387,8 +391,8 @@ class TemplateCacheService
$this->invalidateLayoutChain($layout->layout_name);
}
// 컴포넌트 레지스트리 캐시 무효화
$this->invalidateComponentRegistry($identifier);
// 컴포넌트 매니페스트 캐시 무효화
$this->invalidateComponentRegistry($template->id, $identifier);
}
}
```
@@ -460,7 +464,7 @@ class LayoutService
`LayoutService`의 저장(`updateLayoutContent`) 및 버전 복원(`restoreVersion`) 메서드에서:
1. **`clearDependentLayoutsCache()`** 호출 — 해당 레이아웃 + 이를 extends하는 자식 레이아웃의 병합 캐시 + `PublicLayoutController` 서빙 캐시(`layout.{identifier}.{name}.v{version}`)를 재귀적으로 무효화
2. **`extension_cache_version` 증가** (`Cache::put('extension_cache_version', time())`) — CDN/브라우저 HTTP 캐시 busting. `PublicLayoutController`가 `?v={version}` 기반 캐싱을 사용하므로, 버전 변경 시 브라우저가 새 URL로 인식하여 캐시를 우회합니다.
2. **`ext.cache_version` 증가** (`$this->incrementExtensionCacheVersion()` — `ClearsTemplateCaches` 트레이트) — CDN/브라우저 HTTP 캐시 busting. `PublicLayoutController`가 `?v={version}` 기반 캐싱을 사용하므로, 버전 변경 시 브라우저가 새 URL로 인식하여 캐시를 우회합니다. 버전 키 쓰기는 반드시 트레이트를 경유합니다 — 캐시 드라이버에 직접 `put` 하면 스토어/네임스페이스가 어긋날 때 bump 가 읽기 경로에서 보이지 않습니다.
```php
// LayoutService::updateLayoutContent() 내부
@@ -469,8 +473,8 @@ $layout = $this->layoutRepository->update($layout->id, $updateData);
// 캐시 무효화 (현재 레이아웃 + 자식 레이아웃 + PublicLayoutController 서빙 캐시)
$this->clearDependentLayoutsCache($templateId, $name);
// 프론트엔드 브라우저 캐시 무효화
Cache::put('extension_cache_version', time());
// 프론트엔드 브라우저 캐시 무효화 (ClearsTemplateCaches 트레이트)
$this->incrementExtensionCacheVersion();
```
```php
@@ -479,12 +483,12 @@ $newVersion = $this->versionRepository->restoreVersion($layout->id, $versionId);
// 동일하게 캐시 무효화
$this->clearDependentLayoutsCache($templateId, $name);
Cache::put('extension_cache_version', time());
$this->incrementExtensionCacheVersion();
```
이 과정이 누락되면 편집/복원 내용이 실제 사이트에 반영되지 않습니다.
> 참고: `clearDependentLayoutsCache()`는 내부적으로 `clearLayoutCache()`를 호출하며, 이 메서드는 병합 캐시 삭제 + `clearPublicServingCache()`(기본 v0 + 현재 extension_cache_version 캐시 삭제) + 모듈 레이아웃인 경우 `LayoutResolverService` 캐시도 무효화합니다.
> 참고: `clearDependentLayoutsCache()`는 내부적으로 `clearLayoutCache()`를 호출하며, 이 메서드는 병합 캐시 삭제(일반 + `.with_source_meta` 편집기 변종) + `clearPublicServingCache()`(현재 `ext.cache_version` 버전 키 삭제) + 모듈 레이아웃인 경우 `LayoutResolverService` 캐시도 무효화합니다.
---
@@ -519,12 +523,31 @@ Cache::put('extension_cache_version', time());
## 캐시 키 요약
| 캐시 타입 | 키 패턴 | TTL |
|----------|---------|-----|
| 활성 템플릿 | `templates.active.{type}` | 3600초 (1시간) |
| 레이아웃 JSON | `template.layout.{name}.{locale}` | 1800초 (30분) |
| 병합된 레이아웃 | `template.layout.merged.{name}.{locale}` | 3600초 (1시간) |
| 컴포넌트 레지스트리 | `template.components.{identifier}` | 무제한 |
서버 사이드 캐시는 모두 `CacheInterface`(`g7:core:` 네임스페이스) 에 저장됩니다. 무효화 방식은 두 갈래입니다 — **버전 키**(`.v{v}` 접미사)는 `ext.cache_version` bump 로 다음 요청부터 새 키를 쓰고 이전 키는 TTL 로 자연 만료되며, **고정 키**(버전 접미사 없음)는 라이프사이클에서 능동 `forget` 해야 합니다.
| 캐시 타입 | 키 패턴 | TTL | 무효화 |
|----------|---------|-----|--------|
| 공개 템플릿 설정 (config.json) | `template.config.{identifier}` | 3600초 | 고정 키 — 라이프사이클 능동 삭제 |
| 공개 라우트 (routes.json) | `template.routes.{identifier}.v{v}` | 3600초 | 버전 키 + 현재 버전 키 능동 삭제 |
| 공개 다국어 (lang/{locale}.json) | `template.language.{identifier}.{locale}.v{v}` | 3600초 | 버전 키 + 현재 버전 키 능동 삭제 |
| 공개 레이아웃 서빙 | `layout.{identifier}.{name}.v{v}` (+ 편집기 `.meta` 변종) | layout_ttl (기본 3600초) | 버전 키 + 현재 버전 키 능동 삭제 |
| 병합 레이아웃 (내부) | `template.{templateId}.layout.{name}` (+ `.{sourceHash}` / `.with_source_meta` 변종) | layout_ttl | 고정 키 — 능동 삭제 |
| 컴포넌트 매니페스트 (검증) | `template.{templateId}.components_manifest` (identifier 변종 포함) | 3600초 | 고정 키 — 능동 삭제 |
| 템플릿 상태 | `ext.templates.active_identifiers*` / `ext.templates.installed_identifiers` | 86400초 | 라이프사이클 능동 삭제 |
| 확장 캐시 버전 좌표 | `ext.cache_version` | 없음 | bump 전용 (`ClearsTemplateCaches`) |
### `template:cache-clear` 가 무효화하는 대상
`template:cache-clear {identifier?}` 는 템플릿 라이프사이클(update/deactivate/uninstall)과 동일한 무효화 단일 지점을 경유합니다.
| 대상 | 단일 모드 | 전체 모드 |
|------|-----------|-----------|
| `template.config.{identifier}` | ✅ | ✅ (설치된 전 템플릿) |
| `template.{id}.components_manifest` (identifier 변종 포함) | ✅ | ✅ |
| 현재 버전 routes/language 키 | ✅ | ✅ |
| 병합 레이아웃 캐시 (변종 포함) | ✅ | ✅ |
| 템플릿 상태 키 + `ext.cache_version` bump | ✅ | ✅ (1회) |
| 확장 프론트엔드 병합 번들 파일 | — | ✅ |
---
+1 -1
View File
@@ -79,7 +79,7 @@ php artisan template:activate [vendor-template]
**기능**:
- `templates.is_active` 플래그 설정
- 동일 타입의 기존 활성 템플릿 비활성화
- 캐시 무효화 (`templates.active.{type}`)
- 상태 캐시 무효화 (`ext.templates.active_identifiers*` / `ext.templates.installed_identifiers`)
---
@@ -2,6 +2,7 @@
namespace Tests\Feature\Api\Public;
use App\Contracts\Extension\CacheInterface;
use App\Enums\ExtensionStatus;
use App\Models\Template;
use App\Models\TemplateLayout;
@@ -210,9 +211,13 @@ class LayoutServingTest extends TestCase
],
]);
// PublicLayoutController 는 CacheInterface + 버전 키 ("layout.{id}.{name}.v{v}") 사용
$cache = app(\App\Contracts\Extension\CacheInterface::class);
$cacheKey = "layout.{$template->identifier}.{$layout->name}.v0";
// PublicLayoutController 는 CacheInterface + 버전 키 ("layout.{id}.{name}.v{v}") 사용.
// `?v` 생략 시 현재 확장 캐시 버전으로 폴백하므로 (#588 — `.v0` 사각 키 방지)
// 버전을 시드해 결정적 키로 검증한다
Cache::put('g7:core:ext.cache_version', 1234);
$cache = app(CacheInterface::class);
$cacheKey = "layout.{$template->identifier}.{$layout->name}.v1234";
$cache->forget($cacheKey);
// Act: 첫 번째 요청 (캐시 미스, DB 조회)
@@ -4,10 +4,17 @@ namespace Tests\Feature\Console\Commands\Module;
use App\Enums\ExtensionOwnerType;
use App\Enums\ExtensionStatus;
use App\Enums\LayoutSourceType;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\ExtensionMiddlewareRegistry;
use App\Extension\ModuleManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Models\Module;
use App\Models\Permission;
use App\Models\Template;
use App\Models\TemplateLayout;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\Helpers\ProtectsExtensionDirectories;
use Tests\TestCase;
@@ -163,24 +170,97 @@ class ModuleArtisanCommandsTest extends TestCase
/**
* module:cache-clear 커맨드가 모든 캐시를 삭제하는지 테스트
*
* 실재하는 상태 키(ext.modules.*_identifiers)가 소멸하고 캐시 버전이 bump 되는지
* 검증한다 (#588 — 기존 forget 대상은 writer 없는 유령 키였다).
*/
public function test_cache_clear_command_clears_all_caches(): void
{
$coreCache = new CoreCacheDriver(config('cache.default', 'array'));
$coreCache->put('ext.modules.active_identifiers', ['stale-module'], 3600);
$coreCache->put('ext.modules.installed_identifiers', ['stale-module'], 3600);
// 실제 writer(ExtensionMiddlewareRegistry::buildIndex)와 동형으로 태그 포함
// remember — flush() 는 taggable 스토어에서 태그 인덱스 기반으로 삭제한다
$coreCache->remember('extension.middleware_index', fn () => [['stale' => true]], 3600, [ExtensionMiddlewareRegistry::CACHE_TAG]);
Cache::put('g7:core:ext.cache_version', 1000);
$this->artisan('module:cache-clear')
->expectsOutput(__('modules.commands.cache_clear.clearing_all'))
->assertExitCode(0);
$this->assertNull(
$coreCache->get('ext.modules.active_identifiers'),
'module:cache-clear 후 활성 모듈 상태 캐시가 삭제되어야 합니다.'
);
$this->assertNull(
$coreCache->get('ext.modules.installed_identifiers'),
'module:cache-clear 후 설치 모듈 상태 캐시가 삭제되어야 합니다.'
);
$this->assertNull(
$coreCache->get('extension.middleware_index'),
'module:cache-clear 후 확장 미들웨어 인덱스 캐시(extension.middleware_index)가 삭제되어야 합니다.'
);
$this->assertGreaterThan(
1000,
ClearsTemplateCaches::getExtensionCacheVersion(),
'module:cache-clear 후 확장 캐시 버전이 bump 되어야 합니다.'
);
}
/**
* module:cache-clear 커맨드가 특정 모듈의 캐시만 삭제하는지 테스트
*
* 해석 캐시(layout_resolver.{templateId}.{name})는 버전 접미사 없는 고정 키로
* 레이아웃 row ID 를 저장하므로, 커맨드가 능동 삭제하지 않으면 TTL 동안
* 이전 해석 결과가 유지된다 (#588 동종 보강).
*/
public function test_cache_clear_command_clears_single_module_cache(): void
{
$identifier = 'sirsoft-board';
// 모듈 소유 레이아웃 row — 해석 캐시 키 도출(getLayoutsByModule)의 원천
$template = Template::factory()->create([
'identifier' => 'cachetest-admin_basic',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
]);
TemplateLayout::create([
'template_id' => $template->id,
'name' => 'sirsoft-board.cache_probe',
'source_type' => LayoutSourceType::Module->value,
'source_identifier' => $identifier,
'content' => ['meta' => ['title' => 'probe'], 'components' => []],
]);
$coreCache = new CoreCacheDriver(config('cache.default', 'array'));
$coreCache->put('ext.modules.active_identifiers', ['stale-module'], 3600);
$coreCache->put("layout_resolver.{$template->id}.sirsoft-board.cache_probe", 12345, 3600);
// 실제 writer(ExtensionMiddlewareRegistry::buildIndex)와 동형으로 태그 포함
// remember — flush() 는 taggable 스토어에서 태그 인덱스 기반으로 삭제한다
$coreCache->remember('extension.middleware_index', fn () => [['stale' => true]], 3600, [ExtensionMiddlewareRegistry::CACHE_TAG]);
Cache::put('g7:core:ext.cache_version', 1000);
$this->artisan('module:cache-clear', ['identifier' => $identifier])
->expectsOutput(__('modules.commands.cache_clear.clearing_single', ['module' => $identifier]))
->assertExitCode(0);
$this->assertNull(
$coreCache->get('ext.modules.active_identifiers'),
'module:cache-clear(단일) 후 모듈 상태 캐시가 삭제되어야 합니다.'
);
$this->assertNull(
$coreCache->get("layout_resolver.{$template->id}.sirsoft-board.cache_probe"),
'module:cache-clear(단일) 후 해당 모듈 레이아웃의 해석 캐시(layout_resolver.*)가 삭제되어야 합니다.'
);
$this->assertNull(
$coreCache->get('extension.middleware_index'),
'module:cache-clear(단일) 후 확장 미들웨어 인덱스 캐시(extension.middleware_index)가 삭제되어야 합니다.'
);
$this->assertGreaterThan(
1000,
ClearsTemplateCaches::getExtensionCacheVersion(),
'module:cache-clear(단일) 후 확장 캐시 버전이 bump 되어야 합니다.'
);
}
/**
@@ -4,9 +4,13 @@ namespace Tests\Feature\Console\Commands\Plugin;
use App\Enums\ExtensionOwnerType;
use App\Enums\ExtensionStatus;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\ExtensionMiddlewareRegistry;
use App\Extension\PluginManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Tests\Helpers\ProtectsExtensionDirectories;
use Tests\TestCase;
@@ -183,12 +187,41 @@ class PluginArtisanCommandsTest extends TestCase
/**
* plugin:cache-clear 커맨드가 모든 캐시를 삭제하는지 테스트
*
* 실재하는 상태 키(ext.plugins.*_identifiers)가 소멸하고 캐시 버전이 bump 되는지
* 검증한다 (#588 — 기존 forget 대상은 writer 없는 유령 키였다).
*/
public function test_cache_clear_command_clears_all_caches(): void
{
$coreCache = new CoreCacheDriver(config('cache.default', 'array'));
$coreCache->put('ext.plugins.active_identifiers', ['stale-plugin'], 3600);
$coreCache->put('ext.plugins.installed_identifiers', ['stale-plugin'], 3600);
// 실제 writer(ExtensionMiddlewareRegistry::buildIndex)와 동형으로 태그 포함
// remember — flush() 는 taggable 스토어에서 태그 인덱스 기반으로 삭제한다
$coreCache->remember('extension.middleware_index', fn () => [['stale' => true]], 3600, [ExtensionMiddlewareRegistry::CACHE_TAG]);
Cache::put('g7:core:ext.cache_version', 1000);
$this->artisan('plugin:cache-clear')
->expectsOutput(__('plugins.commands.cache_clear.clearing_all'))
->assertExitCode(0);
$this->assertNull(
$coreCache->get('ext.plugins.active_identifiers'),
'plugin:cache-clear 후 활성 플러그인 상태 캐시가 삭제되어야 합니다.'
);
$this->assertNull(
$coreCache->get('ext.plugins.installed_identifiers'),
'plugin:cache-clear 후 설치 플러그인 상태 캐시가 삭제되어야 합니다.'
);
$this->assertNull(
$coreCache->get('extension.middleware_index'),
'plugin:cache-clear 후 확장 미들웨어 인덱스 캐시(extension.middleware_index)가 삭제되어야 합니다.'
);
$this->assertGreaterThan(
1000,
ClearsTemplateCaches::getExtensionCacheVersion(),
'plugin:cache-clear 후 확장 캐시 버전이 bump 되어야 합니다.'
);
}
/**
@@ -198,9 +231,30 @@ class PluginArtisanCommandsTest extends TestCase
{
$identifier = 'sirsoft-daum_postcode';
$coreCache = new CoreCacheDriver(config('cache.default', 'array'));
$coreCache->put('ext.plugins.active_identifiers', ['stale-plugin'], 3600);
// 실제 writer(ExtensionMiddlewareRegistry::buildIndex)와 동형으로 태그 포함
// remember — flush() 는 taggable 스토어에서 태그 인덱스 기반으로 삭제한다
$coreCache->remember('extension.middleware_index', fn () => [['stale' => true]], 3600, [ExtensionMiddlewareRegistry::CACHE_TAG]);
Cache::put('g7:core:ext.cache_version', 1000);
$this->artisan('plugin:cache-clear', ['identifier' => $identifier])
->expectsOutput(__('plugins.commands.cache_clear.clearing_single', ['plugin' => $identifier]))
->assertExitCode(0);
$this->assertNull(
$coreCache->get('ext.plugins.active_identifiers'),
'plugin:cache-clear(단일) 후 플러그인 상태 캐시가 삭제되어야 합니다.'
);
$this->assertNull(
$coreCache->get('extension.middleware_index'),
'plugin:cache-clear(단일) 후 확장 미들웨어 인덱스 캐시(extension.middleware_index)가 삭제되어야 합니다.'
);
$this->assertGreaterThan(
1000,
ClearsTemplateCaches::getExtensionCacheVersion(),
'plugin:cache-clear(단일) 후 확장 캐시 버전이 bump 되어야 합니다.'
);
}
/**
@@ -3,6 +3,7 @@
namespace Tests\Feature\Console\Commands\Template;
use App\Enums\ExtensionStatus;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\TemplateManager;
use App\Models\Template;
use App\Models\TemplateLayout;
@@ -366,20 +367,41 @@ class TemplateArtisanCommandsTest extends TestCase
$this->artisan('template:list', ['--status' => 'inactive'])
->assertExitCode(0);
// cache-clear 커맨드 (특정 템플릿)
// cache-clear 커맨드 (특정 템플릿) — 공개 config 캐시 실소멸까지 검증 (#588, 공개 #119)
$coreCache = new CoreCacheDriver(config('cache.default', 'array'));
$coreCache->put("template.config.{$identifier}", [
'success' => true,
'data' => ['version' => '0.0.1-stale'],
], 3600);
$this->artisan('template:cache-clear', ['identifier' => $identifier])
->expectsOutput(__('templates.commands.cache_clear.clearing_single', ['template' => $identifier]))
->assertExitCode(0);
$this->assertNull(
$coreCache->get("template.config.{$identifier}"),
'template:cache-clear(단일) 후 공개 config 캐시가 삭제되어야 합니다.'
);
// 활성화하여 cache-clear 전체 테스트
$this->artisan('template:activate', ['identifier' => $identifier])
->assertExitCode(0);
// cache-clear 커맨드 (전체)
// cache-clear 커맨드 (전체) — 전체 모드도 config 캐시 실소멸 검증
$coreCache->put("template.config.{$identifier}", [
'success' => true,
'data' => ['version' => '0.0.1-stale'],
], 3600);
$this->artisan('template:cache-clear')
->expectsOutput(__('templates.commands.cache_clear.clearing_all'))
->assertExitCode(0);
$this->assertNull(
$coreCache->get("template.config.{$identifier}"),
'template:cache-clear(전체) 후 공개 config 캐시가 삭제되어야 합니다.'
);
// 비활성화
$this->artisan('template:deactivate', ['identifier' => $identifier])
->assertExitCode(0);
@@ -2,6 +2,7 @@
namespace Tests\Feature\Template;
use App\Contracts\Extension\CacheInterface;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Template;
@@ -203,9 +204,13 @@ class PublicLayoutControllerTest extends TestCase
// 캐시 초기화 — CacheInterface 를 통해 코어 캐시 prefix 일관 사용
// (Laravel Cache facade 의 global `CACHE_PREFIX` 와 CoreCacheDriver 의
// `g7:core` prefix 가 합쳐진 실제 키와 동등하게 조회/삭제하기 위함)
$coreCache = app(\App\Contracts\Extension\CacheInterface::class);
$coreCache->forget("layout.{$template->identifier}.{$layout->name}.v0");
// `g7:core` prefix 가 합쳐진 실제 키와 동등하게 조회/삭제하기 위함).
// `?v` 생략 시 현재 확장 캐시 버전으로 폴백하므로 (#588 — `.v0` 사각 키 방지)
// 버전을 시드해 결정적 키로 검증한다
Cache::put('g7:core:ext.cache_version', 1234);
$coreCache = app(CacheInterface::class);
$coreCache->forget("layout.{$template->identifier}.{$layout->name}.v1234");
$coreCache->forget("template.{$template->id}.layout.{$layout->name}");
// Act: 첫 번째 요청 (캐시 생성)
@@ -214,7 +219,7 @@ class PublicLayoutControllerTest extends TestCase
// 캐시가 생성되었는지 CacheInterface 로 확인 (컨트롤러가 사용하는 동일 경로)
$this->assertTrue(
$coreCache->has("layout.{$template->identifier}.{$layout->name}.v0"),
$coreCache->has("layout.{$template->identifier}.{$layout->name}.v1234"),
'레이아웃 응답이 CoreCacheDriver(g7:core 접두사) 에 캐시되어야 함',
);
@@ -252,7 +257,7 @@ class PublicLayoutControllerTest extends TestCase
'content' => ['meta' => ['title' => 'Dashboard'], 'data_sources' => [], 'components' => []],
]);
$coreCache = app(\App\Contracts\Extension\CacheInterface::class);
$coreCache = app(CacheInterface::class);
$version = 1781151505;
$nonce = 7;
// 편집 모드(.meta) — `core.templates.layouts.edit` 권한 보유 사용자로 인증.
@@ -2,6 +2,7 @@
namespace Tests\Feature\Template;
use App\Contracts\Extension\CacheInterface;
use App\Enums\ExtensionStatus;
use App\Models\Template;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -141,9 +142,12 @@ class PublicTemplateControllerTest extends TestCase
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
]);
// 캐시 초기화 (CacheInterface 사용 — v{version} 접미사 포함, 기본 v=0)
$cache = app(\App\Contracts\Extension\CacheInterface::class);
$cacheKey = "template.routes.{$template->identifier}.v0";
// `?v` 생략 시 현재 확장 캐시 버전으로 폴백하므로 (#588 — `.v0` 사각 키 방지)
// 버전을 시드해 결정적 키로 검증한다 (getExtensionCacheVersion 은 0 을 반환하지 않음)
Cache::put('g7:core:ext.cache_version', 1234);
$cache = app(CacheInterface::class);
$cacheKey = "template.routes.{$template->identifier}.v1234";
$cache->forget($cacheKey);
// Act: 첫 번째 요청 (캐시 생성)
@@ -160,4 +164,98 @@ class PublicTemplateControllerTest extends TestCase
// 두 응답의 데이터가 동일한지 확인
$this->assertEquals($response1->json('data'), $response2->json('data'));
}
/**
* config.json 서빙이 캐시를 생성하고 installed manifest 버전을 반환하는지 테스트 (#588, 공개 #119)
*
* @scenario cache_state=cold,template_type=admin
*
* @effects serve_config_caches_manifest_and_embeds_cache_version
*/
public function test_serve_config_caches_manifest_and_returns_version(): void
{
// Arrange: 활성화된 템플릿 생성 (실제 번들 파일을 읽는 경량 픽스처)
$template = Template::create([
'identifier' => 'sirsoft-admin_basic',
'vendor' => 'sirsoft',
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
]);
$cache = app(CacheInterface::class);
$cacheKey = "template.config.{$template->identifier}";
$cache->forget($cacheKey);
// Act
$response = $this->getJson("/api/templates/{$template->identifier}/config.json");
// Assert: 성공 + installed template.json 의 실제 version 반환
$response->assertStatus(200)->assertJson(['success' => true]);
$manifest = json_decode(
file_get_contents(base_path("templates/{$template->identifier}/template.json")),
true
);
$this->assertSame($manifest['version'], $response->json('data.version'));
// cache_version 은 캐시 read 후 현재값이 주입되는 유효 정수
$this->assertIsInt($response->json('data.cache_version'));
$this->assertGreaterThan(0, $response->json('data.cache_version'));
// 서버 캐시 키가 생성됨
$this->assertNotNull($cache->get($cacheKey), 'config 조회 후 서버 캐시가 생성되어야 합니다.');
}
/**
* config.json 캐시 삭제 후 최신 manifest 가 반환되는지 테스트 (#588, 공개 #119)
*
* 캐시 히트 증명(변조본 서빙) → forget → 실제 파일 버전 복귀 체인.
* 라이브 재현(sentinel 주입 → cache-clear 무효 → 직접 forget 으로만 해소)의 자동화판.
*
* @scenario cache_state=warm_stale,template_type=admin
*
* @effects serve_config_returns_installed_manifest_after_invalidation
*/
public function test_serve_config_returns_fresh_manifest_after_cache_cleared(): void
{
// Arrange
$template = Template::create([
'identifier' => 'sirsoft-admin_basic',
'vendor' => 'sirsoft',
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
]);
$cache = app(CacheInterface::class);
$cacheKey = "template.config.{$template->identifier}";
// 캐시에 변조된 manifest 심기 (파일 무수정 — stale 상태 재현)
$cache->put($cacheKey, [
'success' => true,
'data' => ['version' => '9.9.9-stale-sentinel'],
], 3600);
// Act & Assert 1: 캐시 히트 경로 증명 — 변조본이 그대로 서빙됨
$stale = $this->getJson("/api/templates/{$template->identifier}/config.json");
$stale->assertStatus(200);
$this->assertSame('9.9.9-stale-sentinel', $stale->json('data.version'));
// Act & Assert 2: forget 후 실제 파일 버전 복귀
$cache->forget($cacheKey);
$fresh = $this->getJson("/api/templates/{$template->identifier}/config.json");
$fresh->assertStatus(200);
$manifest = json_decode(
file_get_contents(base_path("templates/{$template->identifier}/template.json")),
true
);
$this->assertSame($manifest['version'], $fresh->json('data.version'));
}
}
@@ -284,6 +284,196 @@ class TemplateCacheManagementTest extends TestCase
}
}
/**
* template:cache-clear 커맨드가 공개 config 캐시를 삭제하는지 테스트 (#588, 공개 #119)
*
* `template.config.{identifier}` 는 버전 접미사 없는 고정 키라 캐시 버전 bump 로
* 무효화되지 않는다 — 커맨드가 능동 forget 하지 않으면 TTL(1시간) 동안
* 이전 manifest 가 계속 서빙된다.
*
* @scenario cache_state=warm_stale,lifecycle_trigger=cache_clear_command_single,template_type=admin
*
* @effects cache_clear_command_forgets_template_config_key
*/
public function test_template_cache_clear_command_forgets_public_config_cache(): void
{
// Arrange
$this->templateManager->installTemplate('sirsoft-admin_basic');
$this->coreCache()->put('template.config.sirsoft-admin_basic', [
'success' => true,
'data' => ['version' => '0.0.1-stale'],
], 3600);
// Act
$this->artisan('template:cache-clear', ['identifier' => 'sirsoft-admin_basic'])
->assertExitCode(0);
// Assert
$this->assertNull(
$this->coreCache()->get('template.config.sirsoft-admin_basic'),
'template:cache-clear 후 공개 config 캐시(template.config.{identifier})가 삭제되어야 합니다.'
);
}
/**
* template:cache-clear 전체 모드가 공개 config 캐시를 삭제하는지 테스트 (#588, 공개 #119)
*
* @scenario cache_state=warm_stale,lifecycle_trigger=cache_clear_command_all,template_type=admin
*
* @effects cache_clear_command_forgets_template_config_key
*/
public function test_template_cache_clear_all_forgets_public_config_cache(): void
{
// Arrange
$this->templateManager->installTemplate('sirsoft-admin_basic');
$this->coreCache()->put('template.config.sirsoft-admin_basic', [
'success' => true,
'data' => ['version' => '0.0.1-stale'],
], 3600);
// Act (무인자 = 전체 모드)
$this->artisan('template:cache-clear')
->assertExitCode(0);
// Assert
$this->assertNull(
$this->coreCache()->get('template.config.sirsoft-admin_basic'),
'template:cache-clear 전체 모드 후 공개 config 캐시가 삭제되어야 합니다.'
);
}
/**
* clearTemplateCache() 가 config + components_manifest 고정 키를 삭제하는지 테스트 (#588)
*
* components_manifest 는 숫자 id / identifier 두 변종 키가 실재한다
* (ComponentExists 는 int|string 을 수용). 템플릿 업데이트로 components.json 이
* 변해도 이 키가 남으면 최대 1시간 구 매니페스트로 레이아웃을 검증한다.
*
* @scenario cache_state=warm_stale,lifecycle_trigger=template_update,template_type=admin
*
* @effects template_update_invalidates_public_config_cache,clear_template_cache_forgets_components_manifest_variants
*/
public function test_clear_template_cache_forgets_config_and_components_manifest(): void
{
// Arrange
$this->templateManager->installTemplate('sirsoft-admin_basic');
$template = Template::where('identifier', 'sirsoft-admin_basic')->first();
$this->coreCache()->put('template.config.sirsoft-admin_basic', [
'success' => true,
'data' => ['version' => '0.0.1-stale'],
], 3600);
$this->coreCache()->put("template.{$template->id}.components_manifest", ['components' => []], 3600);
$this->coreCache()->put('template.sirsoft-admin_basic.components_manifest', ['components' => []], 3600);
// Act — 라이프사이클(update/deactivate/uninstall/cache-clear)이 공유하는 무효화 단일 지점
$this->templateManager->clearTemplateCache('sirsoft-admin_basic');
// Assert
$this->assertNull(
$this->coreCache()->get('template.config.sirsoft-admin_basic'),
'clearTemplateCache() 후 공개 config 캐시가 삭제되어야 합니다.'
);
$this->assertNull(
$this->coreCache()->get("template.{$template->id}.components_manifest"),
'clearTemplateCache() 후 숫자 id 변종 components_manifest 캐시가 삭제되어야 합니다.'
);
$this->assertNull(
$this->coreCache()->get('template.sirsoft-admin_basic.components_manifest'),
'clearTemplateCache() 후 identifier 변종 components_manifest 캐시가 삭제되어야 합니다.'
);
}
/**
* 비활성화/삭제 라이프사이클이 공개 config 캐시를 삭제하는지 테스트 (#588, 공개 #119)
*
* 활성 시절 warm 된 config 캐시가 남으면 비활성/삭제된 템플릿의 manifest 를
* 최대 1시간 200 으로 계속 서빙한다 (캐시 히트가 상태 검사보다 먼저).
*
* @scenario cache_state=warm_stale,lifecycle_trigger=deactivate,template_type=admin
*
* @effects deactivate_uninstall_invalidate_public_config_cache
*/
public function test_template_deactivate_and_uninstall_invalidate_public_config_cache(): void
{
// Arrange
$this->templateManager->installTemplate('sirsoft-admin_basic');
$this->templateManager->activateTemplate('sirsoft-admin_basic');
$warmConfig = function (): void {
$this->coreCache()->put('template.config.sirsoft-admin_basic', [
'success' => true,
'data' => ['version' => '0.0.1-stale'],
], 3600);
};
// Act & Assert — 비활성화 경로
$warmConfig();
$this->templateManager->deactivateTemplate('sirsoft-admin_basic');
$this->assertNull(
$this->coreCache()->get('template.config.sirsoft-admin_basic'),
'비활성화 후 공개 config 캐시가 삭제되어야 합니다.'
);
// Act & Assert — 삭제 경로
$warmConfig();
$this->templateManager->uninstallTemplate('sirsoft-admin_basic');
$this->assertNull(
$this->coreCache()->get('template.config.sirsoft-admin_basic'),
'삭제 후 공개 config 캐시가 삭제되어야 합니다.'
);
}
/**
* 비활성화 시 편집기용 `.with_source_meta` 변종 레이아웃 캐시도 삭제되는지 테스트 (#588)
*
* LayoutService 는 편집기 병합 응답을 `.with_source_meta` 접미사 키로 별도 캐싱한다.
* 라이프사이클 무효화(forgetLayoutCacheKeys)가 접미사 없는 키만 지우면
* activate/deactivate/uninstall/refresh 후에도 편집기용 병합 캐시가 잔존한다.
*
* @scenario cache_state=warm_stale,lifecycle_trigger=uninstall,template_type=admin
*
* @effects with_source_meta_layout_cache_variants_forgotten
*/
public function test_deactivate_template_clears_with_source_meta_layout_caches(): void
{
// Arrange
$this->templateManager->installTemplate('sirsoft-admin_basic');
$this->templateManager->activateTemplate('sirsoft-admin_basic');
$template = Template::where('identifier', 'sirsoft-admin_basic')->first();
$layouts = TemplateLayout::where('template_id', $template->id)->get();
$this->assertGreaterThan(0, $layouts->count());
$warmedKeys = [];
foreach ($layouts as $layout) {
$key = "template.{$template->id}.layout.{$layout->name}.with_source_meta";
$this->coreCache()->put($key, ['test' => 'meta'], 3600);
$warmedKeys[] = $key;
// 소스 해시 변종 (LayoutService::getMergedLayoutCacheKey 와 동형 — 접미사는 해시 뒤)
if ($layout->source_type && $layout->source_identifier) {
$sourceHash = md5($layout->source_type->value.$layout->source_identifier);
$hashKey = "template.{$template->id}.layout.{$layout->name}.{$sourceHash}.with_source_meta";
$this->coreCache()->put($hashKey, ['test' => 'meta'], 3600);
$warmedKeys[] = $hashKey;
}
}
// Act
$this->templateManager->deactivateTemplate('sirsoft-admin_basic');
// Assert
foreach ($warmedKeys as $key) {
$this->assertNull(
$this->coreCache()->get($key),
"비활성화 후 편집기용 병합 캐시({$key})가 삭제되어야 합니다."
);
}
}
/**
* 테스트용 레이아웃 JSON 파일 생성
*/
@@ -2,6 +2,7 @@
namespace Tests\Feature\Template;
use App\Contracts\Extension\CacheInterface;
use App\Enums\ExtensionStatus;
use App\Models\Template;
use App\Services\TemplateService;
@@ -129,9 +130,12 @@ class TemplateLanguageServingTest extends TestCase
*/
public function test_language_file_is_cached(): void
{
// PublicBaseController::cached() 는 CacheInterface + v{version} 접미사 (기본 v=0)
$cache = app(\App\Contracts\Extension\CacheInterface::class);
$cacheKey = 'template.language.sirsoft-admin_basic.ko.v0';
// `?v` 생략 시 현재 확장 캐시 버전으로 폴백하므로 (#588 — `.v0` 사각 키 방지)
// 버전을 시드해 결정적 키로 검증한다
Cache::put('g7:core:ext.cache_version', 1234);
$cache = app(CacheInterface::class);
$cacheKey = 'template.language.sirsoft-admin_basic.ko.v1234';
$cache->forget($cacheKey);
// 첫 번째 요청
@@ -179,4 +183,4 @@ class TemplateLanguageServingTest extends TestCase
$response = $this->getJson('/api/templates/sirsoft-admin_basic/lang/KO.json');
$response->assertStatus(404);
}
}
}
@@ -16,7 +16,11 @@ use Tests\TestCase;
* 새 CacheInterface 를 통해 캐시를 삭제하는지 검증.
*
* 실제 명령어 실행은 모듈 manager / plugin manager 의존성이 크므로,
* 명령어가 사용하는 키 형식과 forget 동작을 키 차원에서 검증합니다.
* CacheInterface 의 다건 put/forget 왕복 동작을 키 차원에서 검증합니다.
*
* 주의: 아래 키 문자열은 이관 당시의 픽스처다. 현행 커맨드는 유령 키 forget 을
* 제거하고 상태 키 무효화 + 레이아웃 캐시 + 버전 bump 로 동작한다 (#588) —
* 커맨드의 실동작 검증은 {Module,Plugin,Template}ArtisanCommandsTest 가 담당한다.
*/
class ArtisanCacheClearTest extends TestCase
{
@@ -38,9 +42,7 @@ class ArtisanCacheClearTest extends TestCase
}
/**
* G-1-1: module:cache-clear 명령어 — 모듈 관련 캐시 삭제 검증.
*
* ClearModuleCacheCommand::clearModuleCache() 가 사용하는 키 패턴을 검증.
* G-1-1: 모듈 키 픽스처 다건 put→forget 왕복 (드라이버 동작 검증 — 커맨드 미호출).
*/
#[Test]
public function g_1_1_module_cache_clear_removes_per_module_keys(): void
@@ -58,7 +60,7 @@ class ArtisanCacheClearTest extends TestCase
$this->cache->put($k, 'data');
}
// 명령어 핸들러 시뮬레이션 — CacheInterface.forget 호출
// CacheInterface.forget 다건 호출 (드라이버 동작 검증)
foreach ($keys as $k) {
$this->cache->forget($k);
}
@@ -69,7 +71,7 @@ class ArtisanCacheClearTest extends TestCase
}
/**
* G-1-2: plugin:cache-clear 명령어 — 플러그인 캐시 삭제.
* G-1-2: 플러그인 키 픽스처 다건 put→forget 왕복 (드라이버 동작 검증 — 커맨드 미호출).
*/
#[Test]
public function g_1_2_plugin_cache_clear_removes_per_plugin_keys(): void
@@ -96,7 +98,7 @@ class ArtisanCacheClearTest extends TestCase
}
/**
* G-1-3: template:cache-clear 명령어 — 템플릿 + 레이아웃 + routes + 다국어 캐시 삭제.
* G-1-3: 템플릿/레이아웃 키 픽스처 다건 put→forget 왕복 (드라이버 동작 검증 — 커맨드 미호출).
*/
#[Test]
public function g_1_3_template_cache_clear_removes_template_layout_routes(): void
@@ -127,7 +129,7 @@ class ArtisanCacheClearTest extends TestCase
}
/**
* G-1-4: 인수 없이 호출 시 모든 모듈 캐시 + 전역 키 삭제.
* G-1-4: 전역 + 개별 키 혼합 다건 put→forget 왕복 (드라이버 동작 검증 — 커맨드 미호출).
*/
#[Test]
public function g_1_4_clear_without_args_clears_all(): void
@@ -143,7 +145,7 @@ class ArtisanCacheClearTest extends TestCase
$this->cache->put("module.info.{$id}", 'data');
}
// 명령어 시뮬레이션 — 전역 + 개별 모두 삭제
// 전역 + 개별 모두 forget (드라이버 동작 검증)
$globalKeys = ['modules.all', 'modules.active', 'modules.installed'];
foreach ($globalKeys as $k) {
$this->cache->forget($k);
@@ -0,0 +1,86 @@
<?php
namespace Tests\Unit\Extension;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 템플릿 업데이트 성공 cleanup 의 config 캐시 무효화 구조 테스트 (#588, 공개 #119)
*
* `template.config.{identifier}` 는 버전 접미사 없는 고정 키라 캐시 버전 bump 로
* 무효화되지 않는다. updateTemplate() 성공 cleanup 이 clearTemplateCache() 를
* 직접 호출하지 않으면, 비활성 업데이트·레이아웃 무변경 업데이트 경로에서 공개
* config.json 이 TTL(1시간) 동안 이전 manifest 를 계속 서빙한다.
*
* 실제 updateTemplate() 통합 실행은 업데이트 소스(_pending/신버전) 픽스처가 필요해
* 비용이 크므로, clearTemplateCache() 의 키 삭제는 TemplateCacheManagementTest 가
* 동작으로 검증하고 본 테스트는 호출 존재를 소스 구조로 고정한다
* (선례: ExtensionUpdateStatusCacheSymmetryTest).
*/
class TemplateUpdateConfigCacheInvalidationTest extends TestCase
{
/**
* @scenario cache_state=warm_current,lifecycle_trigger=template_update,template_type=user
*
* @effects template_update_invalidates_public_config_cache
*/
#[Test]
public function update_template_cleanup_calls_clear_template_cache(): void
{
$body = $this->methodSource(
base_path('app/Extension/TemplateManager.php'),
'updateTemplate'
);
$this->assertStringContainsString(
'$this->clearTemplateCache(',
$body,
'updateTemplate() 성공 cleanup 이 clearTemplateCache() 를 호출해야 합니다 — '
.'누락 시 공개 config.json 이 캐시 TTL 동안 이전 manifest 로 응답됩니다.'
);
}
/**
* 대상 메서드 본문을 주석 제거 후 반환합니다.
*
* 주석을 남겨 두면 설명 문장에 들어 있는 호출 표기가 검사를 통과시킨다.
*
* @param string $path 대상 파일 절대 경로
* @param string $method 대상 메서드명
* @return string 주석이 제거된 메서드 본문
*/
private function methodSource(string $path, string $method): string
{
$this->assertFileExists($path);
$stripped = '';
foreach (token_get_all(file_get_contents($path)) as $token) {
if (is_array($token) && in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) {
continue;
}
$stripped .= is_array($token) ? $token[1] : $token;
}
$start = strpos($stripped, "public function {$method}(");
$this->assertNotFalse($start, "{$path} 에서 {$method}() 메서드를 찾지 못했습니다.");
// 중괄호 균형으로 메서드 본문 끝을 찾는다
$open = strpos($stripped, '{', $start);
$depth = 0;
$length = strlen($stripped);
for ($i = $open; $i < $length; $i++) {
if ($stripped[$i] === '{') {
$depth++;
} elseif ($stripped[$i] === '}') {
$depth--;
if ($depth === 0) {
return substr($stripped, $open, $i - $open + 1);
}
}
}
$this->fail("{$path} 의 {$method}() 본문 끝을 찾지 못했습니다.");
}
}
@@ -0,0 +1,33 @@
# audit:allow test-scenario-coverage reason: axes 30 cross product(pairwise) 중 동작 분기가 실재하는 조합만 docblock 매핑. cache_state=cold 는 라이프사이클 트리거와 직교(캐시가 없으면 forget 은 무조건 no-op 멱등)라 트리거 조합 확장이 형식적이고, template_type 은 무효화 경로가 타입 무관 단일 코드(clearTemplateCache)라 admin 대표 + 구조 테스트의 user 로 충분. effects 7건은 전수 매핑됨.
feature: template_public_config_cache_invalidation
description: |
공개 템플릿 설정(config.json) 서빙 캐시 `template.config.{identifier}` 는 버전
접미사 없는 고정 키라 캐시 버전 bump 로 무효화되지 않는다. 템플릿 라이프사이클
(cache-clear 커맨드 단일/전체, update, deactivate, uninstall) 전 경로가
`TemplateManager::clearTemplateCache()` 단일 지점을 경유해 이 키(+ components_manifest
두 변종, 현재 버전 routes/language 키, `.with_source_meta` 편집기 변종)를 능동
삭제함을 고정한다 (#588, 공개 #119).
coverage_strategy: pairwise
axes:
cache_state: [cold, warm_current, warm_stale]
lifecycle_trigger: [cache_clear_command_single, cache_clear_command_all, template_update, deactivate, uninstall]
template_type: [admin, user]
effects:
- cache_clear_command_forgets_template_config_key
- template_update_invalidates_public_config_cache
- deactivate_uninstall_invalidate_public_config_cache
- serve_config_caches_manifest_and_embeds_cache_version
- serve_config_returns_installed_manifest_after_invalidation
- clear_template_cache_forgets_components_manifest_variants
- with_source_meta_layout_cache_variants_forgotten
test_files:
- tests/Feature/Template/TemplateCacheManagementTest.php
- tests/Feature/Template/PublicTemplateControllerTest.php
- tests/Feature/Console/Commands/Template/TemplateArtisanCommandsTest.php
- tests/Unit/Extension/TemplateUpdateConfigCacheInvalidationTest.php