diff --git a/.env.example b/.env.example index e80ed460..9139cdda 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.9 +APP_VERSION=7.0.10 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.env.testing.example b/.env.testing.example index ae970d1e..7cd187ef 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -3,7 +3,7 @@ APP_ENV=testing APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.9 +APP_VERSION=7.0.10 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.gitignore b/.gitignore index 30b9125b..9de07a62 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,9 @@ test-results/ # 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다. # 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다. *.map + +# ===== 부트스트랩 리소스 정적 게시 (bake, #122) ===== +# 병합 산출물의 버전 디렉토리 사본 — 각 서버가 수명주기 이벤트마다 재생성하는 +# 로컬 파생물이라 저장소·release 페이로드에 유입되면 안 된다. +# `public/build/core/` 는 계속 추적한다 (배포 산출물) — 혼동 금지. +/public/build/ext/ diff --git a/AGENTS.md b/AGENTS.md index 45fae97e..2b2bfebe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ -### 백엔드 [backend/](docs/backend/) (34개) +### 백엔드 [backend/](docs/backend/) (35개) | 문서 | 설명 | TL;DR 핵심 | |------|------|-----------| @@ -41,6 +41,7 @@ | [service-provider.md](docs/backend/service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 | | [service-repository.md](docs/backend/service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) | | [settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | settings JSON 의 다국어 카탈로그 라벨(_cached_name 등)은 카탈로그 빌드 시점에 보강 | +| [static-asset-publishing.md](docs/backend/static-asset-publishing.md) | 부트스트랩 리소스 정적 게시 (Static Asset Publishing) | 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트마다 termi... | | [translatable-seeders.md](docs/backend/translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 TranslatableSeede... | | [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... | | [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ad31cc4..f41a8665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [7.0.10] - 2026-08-25 + +### Added + +- 초기 화면에 필요한 다국어·컴포넌트 정의·라우트 정보·확장 번들·템플릿 에셋을 정적 파일로 미리 만들어 웹서버가 직접 전달합니다. 확장 설치/활성화나 레이아웃 편집 시 자동으로 다시 생성되며, 파일이 없으면 기존 방식으로 동작합니다. 초기 화면 표시가 빨라집니다. (#122 @glitter-gim 님께서 건의해주셨습니다.) + +### Changed + +- 템플릿 컴포넌트 정의·다국어·라우트 응답에 조건부 캐시(ETag)가 적용되어, 변경이 없으면 본문 전송 없이 캐시를 재사용합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.) + +### Fixed + +- 사이트 첫 접속 시 확장 캐시 버전이 어긋나 있으면 라우트·다국어 데이터를 두 번 내려받던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.) +- 레이아웃 props 의 `$switch` 조건 분기 값이 검색엔진(봇) 화면에서는 해석되지 않아 해당 속성이 표시되지 않던 문제를 수정했습니다. 이제 일반 화면과 봇 화면이 동일하게 분기 값을 렌더링합니다. +- sudo(root) 로 코어를 업데이트하면 업데이트 과정이 만든 캐시 파일이 root 소유로 남아, 이후 웹 화면 전체가 서버 오류(500)가 되거나 캐시가 동작하지 않을 수 있던 문제를 수정했습니다. 업데이트 종료 시 캐시·번들 디렉토리 소유권을 자동 정상화하고, 캐시 쓰기 실패는 화면을 중단시키지 않고 경고 로그와 함께 무캐시로 계속 동작합니다. + ## [7.0.9] - 2026-08-24 ### Added diff --git a/INSTALL.md b/INSTALL.md index 257047c5..46f2ef5c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -289,7 +289,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.0.9 g7 +# (필요 시) mv g7-7.0.10 g7 # ZIP 파일 정리 (선택) rm g7-release.zip diff --git a/README.ko.md b/README.ko.md index b06a1440..ae61f7be 100644 --- a/README.ko.md +++ b/README.ko.md @@ -10,7 +10,7 @@
-
+
diff --git a/README.md b/README.md
index 2cb73c03..55ed9323 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
diff --git a/app/Console/Commands/CleanupExtensionStaticCacheCommand.php b/app/Console/Commands/CleanupExtensionStaticCacheCommand.php
new file mode 100644
index 00000000..f5f10546
--- /dev/null
+++ b/app/Console/Commands/CleanupExtensionStaticCacheCommand.php
@@ -0,0 +1,42 @@
+cleanup();
+
+ $this->info("오래된 정적 게시 디렉토리 {$deleted}건이 삭제되었습니다.");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php
index bb15fdb2..06297296 100644
--- a/app/Console/Commands/Core/CoreUpdateCommand.php
+++ b/app/Console/Commands/Core/CoreUpdateCommand.php
@@ -593,6 +593,10 @@ class CoreUpdateCommand extends Command
// fallback(spawn 실패)로 부모가 upgrade step 을 직접 실행한 경우, 부모가 만든
// upgrade 로그가 root 로 남는다 — 모든 로그 쓰기가 끝난 이 시점에 정합한다.
$this->restoreUpgradeLogOwnership();
+ // root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
+ // restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
+ // 전면 500 차단 (7.0.9→7.0.10 실사례)
+ app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
return Command::SUCCESS;
@@ -677,6 +681,10 @@ class CoreUpdateCommand extends Command
}
$this->restoreUpgradeLogOwnership();
+ // root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
+ // restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
+ // 전면 500 차단 (7.0.9→7.0.10 실사례)
+ app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
return Command::SUCCESS;
@@ -772,6 +780,10 @@ class CoreUpdateCommand extends Command
}
$this->restoreUpgradeLogOwnership();
+ // root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
+ // restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
+ // 전면 500 차단 (7.0.9→7.0.10 실사례)
+ app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
return Command::FAILURE;
}
diff --git a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php
index 66c09a83..c4674adb 100644
--- a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php
+++ b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php
@@ -206,6 +206,7 @@ class ExecuteUpgradeStepsCommand extends Command
]);
$this->restoreUpgradeLogOwnership();
+ $service->normalizeRuntimeOwnershipAfterRootRun();
return UpgradeHandoffException::EXIT_CODE;
} catch (\Throwable $e) {
@@ -217,6 +218,7 @@ class ExecuteUpgradeStepsCommand extends Command
$this->error($e->getMessage());
$this->restoreUpgradeLogOwnership();
+ $service->normalizeRuntimeOwnershipAfterRootRun();
return self::FAILURE;
}
@@ -278,6 +280,10 @@ class ExecuteUpgradeStepsCommand extends Command
}
$this->restoreUpgradeLogOwnership();
+ // 단독 실행(sudo core:execute-upgrade-steps)이 만든 캐시/번들 root 산출물
+ // 소유권 정상화 — spawn 자식 모드에서도 무해(멱등)하며, 부모(CoreUpdateCommand)
+ // 종료부의 동일 호출이 부모 측 후속 쓰기를 담당한다
+ $service->normalizeRuntimeOwnershipAfterRootRun();
return self::SUCCESS;
}
diff --git a/app/Console/Commands/PublishExtensionStaticCacheCommand.php b/app/Console/Commands/PublishExtensionStaticCacheCommand.php
new file mode 100644
index 00000000..687ac28e
--- /dev/null
+++ b/app/Console/Commands/PublishExtensionStaticCacheCommand.php
@@ -0,0 +1,53 @@
+isEnabled()) {
+ $this->warn('정적 게시가 비활성화되어 있습니다 (G7_STATIC_CACHE=false). 게시를 건너뜁니다.');
+
+ return self::SUCCESS;
+ }
+
+ $published = $service->publishCurrent(force: (bool) $this->option('force'));
+
+ if (! $published) {
+ $this->error('정적 게시에 실패했습니다. 로그를 확인하세요 — 사이트는 API 폴백으로 정상 동작합니다.');
+
+ return self::FAILURE;
+ }
+
+ $this->info('부트스트랩 리소스 정적 게시가 완료되었습니다.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Exceptions/StaticCachePublishException.php b/app/Exceptions/StaticCachePublishException.php
new file mode 100644
index 00000000..6a3c3e38
--- /dev/null
+++ b/app/Exceptions/StaticCachePublishException.php
@@ -0,0 +1,12 @@
+getPrefix() . ':' . $key;
+ return $this->getPrefix().':'.$key;
}
/**
* Laravel Cache 스토어 인스턴스를 반환합니다.
*
- * @return \Illuminate\Cache\Repository 캐시 스토어 인스턴스
+ * @return Repository 캐시 스토어 인스턴스
*/
- protected function store(): \Illuminate\Cache\Repository
+ protected function store(): Repository
{
return Cache::store($this->store);
}
@@ -122,7 +123,13 @@ abstract class AbstractCacheDriver implements CacheInterface
$resolvedKey = $this->resolveKey($key);
$ttl = $ttl ?? $this->getDefaultTtl();
- return $this->store()->put($resolvedKey, $value, $ttl);
+ try {
+ return $this->store()->put($resolvedKey, $value, $ttl);
+ } catch (\Throwable $e) {
+ $this->warnWriteFailure('put', $resolvedKey, $e);
+
+ return false;
+ }
}
/**
@@ -144,7 +151,13 @@ abstract class AbstractCacheDriver implements CacheInterface
*/
public function forget(string $key): bool
{
- return $this->store()->forget($this->resolveKey($key));
+ try {
+ return $this->store()->forget($this->resolveKey($key));
+ } catch (\Throwable $e) {
+ $this->warnWriteFailure('forget', $this->resolveKey($key), $e);
+
+ return false;
+ }
}
// === Remember 패턴 ===
@@ -170,8 +183,25 @@ abstract class AbstractCacheDriver implements CacheInterface
// 항상 일반 remember + 키 인덱스에 태그 매핑 기록
// Laravel 네이티브 태그 저장은 사용하지 않음 (get/has와의 일관성 보장)
- $result = $this->store()->remember($resolvedKey, $ttl, $callback);
- $this->recordKeyTags($resolvedKey, $allTags);
+ //
+ // 저장 실패는 fail-soft — 캐시는 최적화이므로 콜백 결과를 그대로 반환한다.
+ // Repository::remember 를 쓰지 않고 get→callback→put 을 직접 수행하는 이유:
+ // put 예외를 잡아도 콜백을 재실행하지 않기 위해서다 (부수효과 이중 실행 금지).
+ // 실사례: sudo 업데이트가 키 인덱스 파일을 root 소유로 남기면 웹의 모든
+ // remember 가 put 예외로 죽어 부팅 전면 500 이 됐다 (7.0.9→7.0.10).
+ $cached = $this->store()->get($resolvedKey);
+ if ($cached !== null) {
+ return $cached;
+ }
+
+ $result = $callback();
+
+ try {
+ $this->store()->put($resolvedKey, $result, $ttl);
+ $this->recordKeyTags($resolvedKey, $allTags);
+ } catch (\Throwable $e) {
+ $this->warnWriteFailure('remember', $resolvedKey, $e);
+ }
return $result;
}
@@ -187,7 +217,7 @@ abstract class AbstractCacheDriver implements CacheInterface
*/
public function rememberQuery(string $queryHash, callable $callback, ?int $ttl = null, array $tags = []): mixed
{
- return $this->remember('query:' . $queryHash, $callback, $ttl, $tags);
+ return $this->remember('query:'.$queryHash, $callback, $ttl, $tags);
}
// === 벌크 연산 ===
@@ -237,7 +267,13 @@ abstract class AbstractCacheDriver implements CacheInterface
$resolved[$this->resolveKey($key)] = $value;
}
- return $this->store()->putMany($resolved, $ttl);
+ try {
+ return $this->store()->putMany($resolved, $ttl);
+ } catch (\Throwable $e) {
+ $this->warnWriteFailure('putMany', implode(',', array_keys($resolved)), $e);
+
+ return false;
+ }
}
// === 무효화 ===
@@ -291,6 +327,42 @@ abstract class AbstractCacheDriver implements CacheInterface
// === 키 인덱스 (태그 미지원 드라이버용) ===
+ /**
+ * 캐시 쓰기 실패를 경고 로그로 강등합니다 (fail-soft).
+ *
+ * 캐시는 최적화다 — 쓰기 실패(권한/디스크)가 페이지를 죽이면 안 된다. 실사례:
+ * sudo 코어 업데이트가 키 인덱스 파일을 root 소유로 남겨 웹 프로세스의 모든
+ * 캐시 쓰기가 Permission denied 로 죽고 부팅 경로가 전면 500 이 됐다
+ * (예외가 로거 도달 전에 발생해 laravel.log 도 비어 있었다).
+ *
+ * 로그 폭주 방지: 같은 (연산, 예외 메시지) 조합은 프로세스당 1회만 기록한다.
+ *
+ * @param string $operation 실패한 연산 (put/forget/putMany/remember/index)
+ * @param string $key 대상 키 (진단용)
+ * @param \Throwable $e 원인 예외
+ */
+ protected function warnWriteFailure(string $operation, string $key, \Throwable $e): void
+ {
+ static $warned = [];
+
+ $signature = $operation.'|'.$e->getMessage();
+ if (isset($warned[$signature])) {
+ return;
+ }
+ $warned[$signature] = true;
+
+ try {
+ Log::warning('캐시 쓰기 실패 — 무캐시로 계속 동작합니다 (스토리지 권한/디스크 확인 필요)', [
+ 'operation' => $operation,
+ 'key' => $key,
+ 'store' => $this->store,
+ 'error' => $e->getMessage(),
+ ]);
+ } catch (\Throwable) {
+ // 로그 기록조차 불가한 환경(로그 디렉토리 권한 등) — 조용히 계속
+ }
+ }
+
/**
* 키-태그 매핑을 인덱스에 기록합니다.
*
@@ -318,16 +390,22 @@ abstract class AbstractCacheDriver implements CacheInterface
*/
private function flushByIndex(): bool
{
- $indexKey = $this->getIndexKey();
- $index = $this->store()->get($indexKey, []);
+ try {
+ $indexKey = $this->getIndexKey();
+ $index = $this->store()->get($indexKey, []);
- foreach (array_keys($index) as $key) {
- $this->store()->forget($key);
+ foreach (array_keys($index) as $key) {
+ $this->store()->forget($key);
+ }
+
+ $this->store()->forget($indexKey);
+
+ return true;
+ } catch (\Throwable $e) {
+ $this->warnWriteFailure('flush', $this->getIndexKey(), $e);
+
+ return false;
}
-
- $this->store()->forget($indexKey);
-
- return true;
}
/**
@@ -338,20 +416,26 @@ abstract class AbstractCacheDriver implements CacheInterface
*/
private function flushTagsByIndex(array $tags): bool
{
- $indexKey = $this->getIndexKey();
- $index = $this->store()->get($indexKey, []);
- $tagsSet = array_flip($tags);
+ try {
+ $indexKey = $this->getIndexKey();
+ $index = $this->store()->get($indexKey, []);
+ $tagsSet = array_flip($tags);
- foreach ($index as $key => $keyTags) {
- if (array_intersect_key(array_flip($keyTags), $tagsSet)) {
- $this->store()->forget($key);
- unset($index[$key]);
+ foreach ($index as $key => $keyTags) {
+ if (array_intersect_key(array_flip($keyTags), $tagsSet)) {
+ $this->store()->forget($key);
+ unset($index[$key]);
+ }
}
+
+ $this->store()->put($indexKey, $index, 86400 * 30);
+
+ return true;
+ } catch (\Throwable $e) {
+ $this->warnWriteFailure('flushTags', implode(',', $tags), $e);
+
+ return false;
}
-
- $this->store()->put($indexKey, $index, 86400 * 30);
-
- return true;
}
/**
@@ -361,6 +445,6 @@ abstract class AbstractCacheDriver implements CacheInterface
*/
private function getIndexKey(): string
{
- return 'g7:_idx:' . $this->getPrefix();
+ return 'g7:_idx:'.$this->getPrefix();
}
}
diff --git a/app/Extension/Traits/ClearsTemplateCaches.php b/app/Extension/Traits/ClearsTemplateCaches.php
index 73bfb440..2cf403e2 100644
--- a/app/Extension/Traits/ClearsTemplateCaches.php
+++ b/app/Extension/Traits/ClearsTemplateCaches.php
@@ -4,6 +4,7 @@ namespace App\Extension\Traits;
use App\Contracts\Extension\CacheInterface;
use App\Extension\Cache\CoreCacheDriver;
+use App\Services\ExtensionStaticCacheService;
use Illuminate\Support\Facades\Log;
/**
@@ -48,6 +49,12 @@ trait ClearsTemplateCaches
'error' => $e->getMessage(),
]);
}
+
+ // 부트스트랩 리소스 정적 게시(bake) 예약 — 모든 bump 호출부(수명주기 전체)가
+ // 이 단일 지점을 경유하므로 재게시 트리거 누락이 구조적으로 불가능하다 (#122).
+ // terminating 시점에 프로세스당 1회, 실행 시점의 최종 버전으로 게시된다
+ // (연속 bump 자연 병합). 실패해도 사이트는 API 폴백으로 정상.
+ ExtensionStaticCacheService::schedulePublishOnTerminate();
}
/**
diff --git a/app/Http/Controllers/Api/Base/BaseApiController.php b/app/Http/Controllers/Api/Base/BaseApiController.php
index 3b489b6f..c98b95fd 100644
--- a/app/Http/Controllers/Api/Base/BaseApiController.php
+++ b/app/Http/Controllers/Api/Base/BaseApiController.php
@@ -210,17 +210,37 @@ abstract class BaseApiController extends Controller
}
/**
- * JSON 응답을 반환합니다 (캐싱 헤더 포함).
+ * JSON 응답을 반환합니다 (조건부 캐싱 헤더 포함).
+ *
+ * ETag 기반 조건부 캐시(If-None-Match → 304)와 환경별 Cache-Control 분기를
+ * 적용한다 — 프로덕션은 `public, max-age`, 그 외 환경은 `no-cache`(파일 수정
+ * 즉시 반영 — `fileResponse` 의 환경 분기와 동일 사상).
*
* @param mixed $data JSON으로 변환할 데이터
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간)
* @param int $status HTTP 상태 코드
- * @return JsonResponse JSON 응답
+ * @return JsonResponse|Response JSON 응답 또는 304 응답
*/
- protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse
+ protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse|Response
{
+ $etag = $this->generateETag($data);
+
+ $cacheControl = app()->environment('production')
+ ? "public, max-age={$maxAge}"
+ : 'no-cache';
+
+ if ($status === 200 && $this->isNotModified($etag)) {
+ $response = $this->notModifiedResponse($etag, $maxAge);
+ $response->headers->set('Cache-Control', $cacheControl);
+ $response->headers->set('Vary', 'Accept-Encoding');
+
+ return $response;
+ }
+
return response()->json($data, $status, [
- 'Cache-Control' => "public, max-age={$maxAge}",
+ 'Cache-Control' => $cacheControl,
+ 'ETag' => $etag,
+ 'Vary' => 'Accept-Encoding',
], ResponseHelper::JSON_ENCODE_OPTIONS);
}
@@ -283,9 +303,18 @@ abstract class BaseApiController extends Controller
): JsonResponse|Response {
$etag = $this->generateETag($data);
+ // 환경별 캐싱 정책 — 프로덕션 외 환경은 no-cache 로 파일/데이터 수정 즉시 반영
+ // (`fileResponse`/`cachedJsonResponse` 와 동일 사상, #122 작업 D)
+ $cacheControl = app()->environment('production')
+ ? "public, max-age={$maxAge}"
+ : 'no-cache';
+
// 304 Not Modified 처리
if ($this->isNotModified($etag)) {
- return $this->notModifiedResponse($etag, $maxAge);
+ $response = $this->notModifiedResponse($etag, $maxAge);
+ $response->headers->set('Cache-Control', $cacheControl);
+
+ return $response;
}
return response()->json([
@@ -294,7 +323,7 @@ abstract class BaseApiController extends Controller
'data' => $data,
], 200, [], ResponseHelper::JSON_ENCODE_OPTIONS)
->header('ETag', $etag)
- ->header('Cache-Control', "public, max-age={$maxAge}")
+ ->header('Cache-Control', $cacheControl)
->header('Vary', 'Accept-Encoding, Accept-Language');
}
}
diff --git a/app/Http/Controllers/Api/Public/PublicModuleController.php b/app/Http/Controllers/Api/Public/PublicModuleController.php
index ffecc46e..1604afec 100644
--- a/app/Http/Controllers/Api/Public/PublicModuleController.php
+++ b/app/Http/Controllers/Api/Public/PublicModuleController.php
@@ -140,9 +140,9 @@ class PublicModuleController extends PublicBaseController
* 폴백한다(무손실 보존 디그레이드).
*
* @param string $identifier 모듈 식별자
- * @return JsonResponse 컴포넌트 정의 응답
+ * @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
*/
- public function serveComponents(string $identifier): JsonResponse
+ public function serveComponents(string $identifier): JsonResponse|Response
{
$this->logApiUsage('modules.components', ['identifier' => $identifier]);
diff --git a/app/Http/Controllers/Api/Public/PublicPluginController.php b/app/Http/Controllers/Api/Public/PublicPluginController.php
index b062b2a6..2305cfe2 100644
--- a/app/Http/Controllers/Api/Public/PublicPluginController.php
+++ b/app/Http/Controllers/Api/Public/PublicPluginController.php
@@ -139,9 +139,9 @@ class PublicPluginController extends PublicBaseController
* 폴백한다(무손실 보존 디그레이드).
*
* @param string $identifier 플러그인 식별자
- * @return JsonResponse 컴포넌트 정의 응답
+ * @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
*/
- public function serveComponents(string $identifier): JsonResponse
+ public function serveComponents(string $identifier): JsonResponse|Response
{
$this->logApiUsage('plugins.components', ['identifier' => $identifier]);
diff --git a/app/Http/Controllers/Api/Public/PublicTemplateController.php b/app/Http/Controllers/Api/Public/PublicTemplateController.php
index b51cbd3b..5639762a 100644
--- a/app/Http/Controllers/Api/Public/PublicTemplateController.php
+++ b/app/Http/Controllers/Api/Public/PublicTemplateController.php
@@ -35,9 +35,9 @@ class PublicTemplateController extends PublicBaseController
* 템플릿 라우트 정보 조회 (활성화된 모듈의 routes 포함)
*
* @param string $identifier 템플릿 식별자 (vendor-name 형식)
- * @return JsonResponse 라우트 정보 응답
+ * @return JsonResponse|Response 라우트 정보 응답 (`?v` 명시 + If-None-Match 일치 시 304)
*/
- public function getRoutes(string $identifier): JsonResponse
+ public function getRoutes(string $identifier): JsonResponse|Response
{
// API 사용량 기록
$this->logApiUsage('templates.routes', ['identifier' => $identifier]);
@@ -101,6 +101,15 @@ class PublicTemplateController extends PublicBaseController
return $this->error(__('templates.errors.invalid_cache_data'), 500);
}
+ // 버전 키드 URL(`?v` 명시)은 bump 시 URL 자체가 바뀌므로 조건부 공개 캐시가 안전하다.
+ // 무버전 요청(핸드셰이크 폴백 등)은 종전대로 캐시 헤더 없이 신선 응답 (#122 작업 D).
+ // 열화 스냅샷은 공개 캐시 금지 — 서버측 캐시 회피(#493)와 동일 규율로, 같은 `?v`
+ // URL 에 public max-age 가 붙으면 브라우저/CDN 이 열화 응답을 1시간 박제한다
+ // (버전은 이미 올라간 뒤라 스스로 회복되지 않음. 정적 게시의 열화 제외와 대칭).
+ if ($rawVersion !== null && ! $this->templateService->lastRouteMergeWasDegraded()) {
+ return $this->successWithCache('templates.messages.routes_retrieved', $routesData['data'], 3600);
+ }
+
return $this->success(
__('templates.messages.routes_retrieved'),
$routesData['data']
@@ -145,9 +154,9 @@ class PublicTemplateController extends PublicBaseController
* 컴포넌트 정의 파일 서빙
*
* @param string $identifier 템플릿 식별자
- * @return JsonResponse 컴포넌트 정의 응답
+ * @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
*/
- public function serveComponents(string $identifier): JsonResponse
+ public function serveComponents(string $identifier): JsonResponse|Response
{
// API 사용량 기록
$this->logApiUsage('templates.components', ['identifier' => $identifier]);
@@ -244,9 +253,9 @@ class PublicTemplateController extends PublicBaseController
*
* @param string $identifier 템플릿 식별자
* @param string $locale 로케일 (ko, en 등)
- * @return JsonResponse 다국어 데이터 응답
+ * @return JsonResponse|Response 다국어 데이터 응답 (If-None-Match 일치 시 304)
*/
- public function serveLanguage(string $identifier, string $locale): JsonResponse
+ public function serveLanguage(string $identifier, string $locale): JsonResponse|Response
{
// API 사용량 기록
$this->logApiUsage('templates.language', [
diff --git a/app/Seo/ComponentHtmlMapper.php b/app/Seo/ComponentHtmlMapper.php
index 07a237d5..39a1054a 100644
--- a/app/Seo/ComponentHtmlMapper.php
+++ b/app/Seo/ComponentHtmlMapper.php
@@ -1457,6 +1457,9 @@ class ComponentHtmlMapper
// 표현식 해석: 문자열/숫자는 evaluate, 배열/객체는 evaluateRaw
$resolved = $evaluator->evaluateRaw($value, $context);
$data[$key] = $resolved;
+ } elseif ($this->isSwitchDefinition($value)) {
+ // $switch 선언적 분기 (React resolveObject 와 동일 위치에서 해석)
+ $data[$key] = $this->resolveSwitchValue($value, $context, $evaluator);
} elseif (is_array($value)) {
// 중첩 객체 (예: socialLinks): 재귀적으로 해석
$data[$key] = $this->resolveAllPropsRecursive($value, $context, $evaluator);
@@ -1482,6 +1485,8 @@ class ComponentHtmlMapper
foreach ($values as $k => $v) {
if (is_string($v) && str_contains($v, '{{')) {
$result[$k] = $evaluator->evaluateRaw($v, $context);
+ } elseif ($this->isSwitchDefinition($v)) {
+ $result[$k] = $this->resolveSwitchValue($v, $context, $evaluator);
} elseif (is_array($v)) {
$result[$k] = $this->resolveAllPropsRecursive($v, $context, $evaluator);
} else {
@@ -1492,6 +1497,71 @@ class ComponentHtmlMapper
return $result;
}
+ /**
+ * 값이 `$switch` 선언적 분기 객체인지 판정합니다.
+ *
+ * React `DataBindingEngine.isSwitchExpression` 과 동일 — `$switch` 와 `$cases`
+ * 키를 모두 가진 객체(연관 배열)만 해당한다.
+ *
+ * @param mixed $value 판정 대상 값
+ * @return bool $switch 정의 여부
+ */
+ private function isSwitchDefinition(mixed $value): bool
+ {
+ return is_array($value)
+ && array_key_exists('$switch', $value)
+ && array_key_exists('$cases', $value);
+ }
+
+ /**
+ * `$switch` 선언적 분기 객체를 해석합니다.
+ *
+ * React `DataBindingEngine.resolveSwitch` 와 동일 의미론 (engine-v1.56.0 패리티):
+ * ① `$switch` 키 표현식을 평가해 문자열 키로 정규화(trim, 실패 시 빈 문자열)
+ * ② `$cases` 에서 키 일치 값 선택, 없으면 `$default`, 그것도 없으면 null(React undefined)
+ * ③ 결과가 `{{}}` 포함 문자열이면 재해석, 객체면 재귀 해석(중첩 $switch 포함)
+ *
+ * 레이아웃 최상위 `computed` 의 $switch 는 `SeoRenderer::resolveComputedSwitch` 가
+ * 별도 처리한다 — 이 메서드는 노드 props 값 축 담당.
+ *
+ * @param array $definition $switch 정의 { "$switch", "$cases", "$default"? }
+ * @param array $context 데이터 컨텍스트
+ * @param ExpressionEvaluator $evaluator 표현식 평가기
+ * @return mixed 해석된 값 (매칭·기본값 모두 없으면 null)
+ */
+ private function resolveSwitchValue(array $definition, array $context, ExpressionEvaluator $evaluator): mixed
+ {
+ try {
+ $keyValue = trim((string) $evaluator->evaluate((string) ($definition['$switch'] ?? ''), $context));
+ } catch (\Throwable) {
+ $keyValue = '';
+ }
+
+ $cases = is_array($definition['$cases'] ?? null) ? $definition['$cases'] : [];
+
+ if ($keyValue !== '' && array_key_exists($keyValue, $cases)) {
+ $result = $cases[$keyValue];
+ } elseif (array_key_exists('$default', $definition)) {
+ $result = $definition['$default'];
+ } else {
+ return null;
+ }
+
+ if (is_string($result)) {
+ return str_contains($result, '{{') ? $evaluator->evaluate($result, $context) : $result;
+ }
+
+ if ($this->isSwitchDefinition($result)) {
+ return $this->resolveSwitchValue($result, $context, $evaluator);
+ }
+
+ if (is_array($result)) {
+ return $this->resolveAllPropsRecursive($result, $context, $evaluator);
+ }
+
+ return $result;
+ }
+
/**
* {field|alt_field} 패턴에서 아이템 값을 해석합니다.
*
@@ -1688,6 +1758,12 @@ class ComponentHtmlMapper
continue;
}
+ // $switch 선언적 분기 객체 (engine-v1.56.0 React 패리티) — 해석하지 않으면
+ // 배열이라는 이유로 속성이 조용히 사라진다 (예외·경고 없음)
+ if ($this->isSwitchDefinition($value)) {
+ $value = $this->resolveSwitchValue($value, $context, $evaluator);
+ }
+
if (is_string($value)) {
$evaluated = $evaluator->evaluate($value, $context);
if ($evaluated !== '') {
diff --git a/app/Seo/SeoRenderer.php b/app/Seo/SeoRenderer.php
index 22c17fd3..1cf9fc91 100644
--- a/app/Seo/SeoRenderer.php
+++ b/app/Seo/SeoRenderer.php
@@ -730,7 +730,12 @@ class SeoRenderer implements SeoRendererInterface
foreach ($cssPaths as $cssPath) {
// dist/ 접두사 제거 (서빙 경로에서는 dist가 자동 추가됨)
$servePath = preg_replace('#^dist/#', '', $cssPath);
- $urls[] = AssetUrl::templateAsset($templateIdentifier, $servePath);
+
+ // 정적 게시본(bake) 경로 금지 — 이 URL 은 SeoCacheManager(`seo.page.*`,
+ // 키에 cache_version 미포함)에 캐시된 HTML 에 박제되는데, 정적 디렉토리는
+ // GC 가 현재+직전 1개만 보존해 캐시 수명 안에 404 가 될 수 있다. SEO HTML 은
+ // asset-url-recovery 파샬도 없어 자가 복구가 불가하므로 무버전 API URL 고정.
+ $urls[] = AssetUrl::templateAsset($templateIdentifier, $servePath, allowStatic: false);
}
return $urls;
@@ -954,6 +959,11 @@ class SeoRenderer implements SeoRendererInterface
* 프론트엔드 TemplateApp이 레이아웃 레벨 initLocal/initGlobal을 상태에 적용하는 것과
* 동일하게, 각 값의 {{}} 표현식을 해석해 반환합니다.
*
+ * **데이터소스 레벨 `initLocal` 옵션은 의도적으로 처리하지 않는다** (2026-08-25 확정) —
+ * 그 옵션을 쓰는 화면(장바구니·주문서·프로필 수정·게시판 작성 폼 등)은 인증·인터랙션
+ * 화면이라 봇 렌더 가치가 없다. 봇 노출이 필요한 상태 시드는 레이아웃 최상위
+ * `initLocal`/`state` 를 사용한다 (docs/backend/seo-system.md 지원 노드 키 표 참조).
+ *
* @param mixed $block 초기 상태 블록 (키 → 값)
* @param array $context 현재 컨텍스트 (route, query 등 포함)
* @return array 평가된 초기 상태
diff --git a/app/Services/CoreUpdateService.php b/app/Services/CoreUpdateService.php
index a7bcf6fc..e17d1014 100644
--- a/app/Services/CoreUpdateService.php
+++ b/app/Services/CoreUpdateService.php
@@ -2188,6 +2188,56 @@ class CoreUpdateService
}
}
+ /**
+ * root 로 실행된 업데이트가 종료된 뒤, 런타임 쓰기 디렉토리의 소유권을 정상화합니다.
+ *
+ * `restoreOwnership()` 은 흐름 **중간**의 한 단계라, 그 이후에 일어나는 캐시
+ * 쓰기(버전 bump·상태/훅 캐시 재생성·키 인덱스 갱신·번들 빌드)가 root 소유
+ * 파일을 새로 만든다. 그 파일들이 남으면 웹 프로세스의 캐시 쓰기가 Permission
+ * denied 로 죽어 전면 500 이 된다 (실사례: 7.0.9→7.0.10 sudo 업데이트 —
+ * 치명점은 모든 remember 가 갱신하는 캐시 키 인덱스 파일).
+ *
+ * 따라서 이 메서드는 **흐름의 마지막**(restoreUpgradeLogOwnership 과 같은
+ * 지점)에서 호출되어, 대상 디렉토리 자신의 소유자(웹 쓰기 소유)를 기준으로
+ * 내용물을 재귀 정상화하고 그룹 쓰기를 동기화한다. 과거 업데이트가 남긴
+ * root 잔재도 함께 정리된다 (재귀 전체 대상).
+ *
+ * 비-root 프로세스는 chown 자체가 불가능하고 필요도 없으므로 즉시 no-op.
+ * 기준 디렉토리 자체가 root 소유(비정상 배포)면 상속 근거가 없어 스킵한다.
+ */
+ public function normalizeRuntimeOwnershipAfterRootRun(): void
+ {
+ if (! function_exists('chown') || ! function_exists('posix_geteuid') || posix_geteuid() !== 0) {
+ return;
+ }
+
+ $targets = [
+ storage_path('framework/cache'),
+ base_path('bootstrap/cache'),
+ storage_path('app/ext-bundles'),
+ ];
+
+ foreach ($targets as $dir) {
+ if (! is_dir($dir)) {
+ continue;
+ }
+
+ $owner = @fileowner($dir);
+ $group = @filegroup($dir);
+
+ if ($owner === false || $owner === 0) {
+ Log::channel('upgrade')->warning('런타임 소유권 정상화 스킵 — 기준 디렉토리가 root/판독불가 소유', [
+ 'dir' => $dir,
+ ]);
+
+ continue;
+ }
+
+ FilePermissionHelper::chownRecursive($dir, $owner, $group);
+ FilePermissionHelper::syncGroupWritability($dir);
+ }
+ }
+
/**
* 업데이트 경로의 소유권을 스냅샷 기준으로 복원합니다.
*
diff --git a/app/Services/ExtensionStaticCacheService.php b/app/Services/ExtensionStaticCacheService.php
new file mode 100644
index 00000000..5b76aa98
--- /dev/null
+++ b/app/Services/ExtensionStaticCacheService.php
@@ -0,0 +1,645 @@
+ 존재 여부) */
+ private array $publishedMemo = [];
+
+ public function __construct(
+ private TemplateService $templateService,
+ private TemplateRepositoryInterface $templateRepository,
+ private ExtensionBundleService $bundleService,
+ private LanguagePackService $languagePackService,
+ ) {}
+
+ /**
+ * 현재 확장 캐시 버전 기준으로 게시합니다.
+ *
+ * 이미 게시 완료(manifest 존재) 상태면 skip(멱등). `Cache::lock` 으로 단일
+ * 실행을 보장하며, 락 미획득 시 다른 프로세스가 게시 중인 것으로 보고 skip.
+ *
+ * @param bool $force 게시 완료 상태여도 강제 재게시
+ * @return bool 게시 완료 상태로 끝났으면 true (skip 포함), 실패/비활성이면 false
+ */
+ public function publishCurrent(bool $force = false): bool
+ {
+ if (! $this->isEnabled()) {
+ return false;
+ }
+
+ $version = self::getExtensionCacheVersion();
+
+ if (! $force && $this->isPublished($version)) {
+ return true;
+ }
+
+ $lock = Cache::lock(self::LOCK_PREFIX.$version, 300);
+
+ if (! $lock->get()) {
+ return false;
+ }
+
+ try {
+ // 락 대기 중 다른 프로세스가 완료했을 수 있다 (멱등 재확인)
+ unset($this->publishedMemo[$version]);
+ if (! $force && $this->isPublished($version)) {
+ return true;
+ }
+
+ return $this->publishVersion($version);
+ } finally {
+ $lock->release();
+ }
+ }
+
+ /**
+ * 해당 버전이 게시 완료 상태인지 확인합니다 (manifest 존재 = 완료).
+ *
+ * AssetUrl 게이트가 요청당 여러 번 호출하므로 메모이즈한다.
+ *
+ * @param int $version 확장 캐시 버전
+ * @return bool 게시 완료 여부
+ */
+ public function isPublished(int $version): bool
+ {
+ return $this->publishedMemo[$version] ??= is_file(
+ $this->versionDir($version).DIRECTORY_SEPARATOR.self::MANIFEST_FILE
+ );
+ }
+
+ /**
+ * 현재 버전 + 직전 1개를 보존하고 나머지 게시 디렉토리를 삭제합니다.
+ *
+ * 직전 버전을 남기는 이유: 브라우저에 캐시된 직전 렌더 HTML 이 아직 구버전
+ * 정적 URL 을 참조할 수 있다 (asset-url-recovery 파샬이 최후 방어).
+ *
+ * @return int 삭제된 디렉토리 수
+ */
+ public function cleanup(): int
+ {
+ $base = $this->baseDir();
+
+ if (! File::isDirectory($base)) {
+ return 0;
+ }
+
+ $current = self::getExtensionCacheVersion();
+ $versions = [];
+ $deleted = 0;
+
+ foreach (File::directories($base) as $dir) {
+ $name = basename($dir);
+
+ // 미완료 tmp 잔존물은 무조건 제거 대상 (rename 전 실패 흔적)
+ if (str_ends_with($name, '.tmp')) {
+ File::deleteDirectory($dir);
+ $deleted++;
+
+ continue;
+ }
+
+ if (ctype_digit($name)) {
+ $versions[(int) $name] = $dir;
+ }
+ }
+
+ // 현재 버전과, 현재를 제외한 최신 1개(직전) 보존
+ $keep = [$current];
+ $others = array_keys($versions);
+ rsort($others);
+ foreach ($others as $v) {
+ if ($v !== $current) {
+ $keep[] = $v;
+ break;
+ }
+ }
+
+ foreach ($versions as $v => $dir) {
+ if (! in_array($v, $keep, true)) {
+ File::deleteDirectory($dir);
+ $deleted++;
+ }
+ }
+
+ return $deleted;
+ }
+
+ /**
+ * 수명주기 이벤트에서 호출되는 terminating 게시 예약.
+ *
+ * `incrementExtensionCacheVersion()` 내부 단일 지점에서 호출된다.
+ * 프로세스당 1회만 등록하며, 게시는 예약 시점이 아니라 **실행 시점의 현재
+ * 버전**으로 수행되어 연속 bump(일괄 업데이트)를 자연 병합한다.
+ *
+ * 프로덕션 전용 — 비프로덕션은 blade 가 정적 URL 을 방출하지 않으므로
+ * 게시 자체가 무의미하고(§2-2), testing 환경의 파일 쓰기 부수효과도 차단한다.
+ */
+ public static function schedulePublishOnTerminate(): void
+ {
+ if (self::$publishScheduled || ! app()->environment('production')) {
+ return;
+ }
+
+ // root 프로세스(sudo 코어 업데이트 등)에서는 예약하지 않는다 — 게시가 만드는
+ // 캐시 락 샤드 디렉토리(storage/framework/cache/data/xx)와 병합 번들
+ // (storage/app/ext-bundles)이 root 소유로 남아, 이후 웹 프로세스의 캐시
+ // 쓰기가 그 샤드에 해시되는 순간 Permission denied 로 죽는다 (실사례:
+ // sudo 업데이트 직후 전면 500). normalizeOwnership 은 게시 트리(build/ext)만
+ // 다루므로 storage 측 부수 산출물은 회피가 정답이다. 게시는 다음 웹 렌더의
+ // 자가 치유(웹 계정)가 수행하고, 명시적 `ext-static:publish` 커맨드는 이
+ // 게이트를 거치지 않는다 (운영자 책임 — 규정 문서 §6).
+ if (self::isRootProcess()) {
+ return;
+ }
+
+ self::$publishScheduled = true;
+
+ app()->terminating(static function (): void {
+ // 실행 시점에 재무장 가능 상태로 복귀 — 요청마다 앱 인스턴스를 새로 쓰는
+ // 장수 프로세스(Octane 류)에서는 static 플래그만 살아남으므로, 리셋 없이는
+ // 2번째 이후 bump 가 새 앱에 콜백을 등록하지 못한 채 영구 미게시가 된다
+ // (자가 치유도 같은 플래그 공유). FPM(요청=프로세스)에서는 무영향.
+ self::$publishScheduled = false;
+
+ try {
+ app(self::class)->publishCurrent();
+ } catch (\Throwable $e) {
+ Log::warning('정적 게시 terminating 실행 실패 — 다음 렌더의 자가 치유가 재시도합니다', [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ });
+ }
+
+ /**
+ * 테스트 격리용 — terminating 예약 플래그를 초기화합니다.
+ */
+ public static function resetPublishScheduleForTesting(): void
+ {
+ self::$publishScheduled = false;
+ self::$rootProcessForTesting = null;
+ }
+
+ /**
+ * 테스트 전용 — root 프로세스 판정을 강제합니다 (null 로 실판정 복귀).
+ *
+ * @param bool|null $isRoot 강제할 판정값
+ */
+ public static function fakeRootProcessForTesting(?bool $isRoot): void
+ {
+ self::$rootProcessForTesting = $isRoot;
+ }
+
+ /**
+ * 현재 프로세스가 root(euid 0)로 실행 중인지 판정합니다.
+ *
+ * posix 확장이 없는 환경(Windows, 함수 비활성 호스팅)은 root 아님으로 본다.
+ *
+ * @return bool root 실행 여부
+ */
+ private static function isRootProcess(): bool
+ {
+ if (self::$rootProcessForTesting !== null) {
+ return self::$rootProcessForTesting;
+ }
+
+ return function_exists('posix_geteuid') && posix_geteuid() === 0;
+ }
+
+ /**
+ * 게시 루트 디렉토리 절대 경로를 반환합니다.
+ *
+ * @return string `public/build/ext` 절대 경로
+ */
+ public function baseDir(): string
+ {
+ return public_path('build/ext');
+ }
+
+ /**
+ * 버전 디렉토리 절대 경로를 반환합니다.
+ *
+ * @param int $version 확장 캐시 버전
+ * @return string 버전 디렉토리 절대 경로
+ */
+ public function versionDir(int $version): string
+ {
+ return $this->baseDir().DIRECTORY_SEPARATOR.$version;
+ }
+
+ /**
+ * kill-switch 판정 (`core.static_cache.enabled`, .env `G7_STATIC_CACHE`).
+ *
+ * @return bool 정적 게시 활성 여부
+ */
+ public function isEnabled(): bool
+ {
+ return (bool) config('core.static_cache.enabled', true);
+ }
+
+ /**
+ * 한 버전의 게시를 실제 수행합니다 (tmp 쓰기 → rename → manifest → GC).
+ *
+ * @param int $version 게시할 확장 캐시 버전
+ * @return bool 성공 여부
+ */
+ private function publishVersion(int $version): bool
+ {
+ $base = $this->baseDir();
+ $tmp = $base.DIRECTORY_SEPARATOR.$version.'.tmp';
+ $final = $this->versionDir($version);
+
+ try {
+ File::deleteDirectory($tmp);
+ File::ensureDirectoryExists($tmp, 0775);
+
+ $files = [];
+
+ $this->writeHtaccess($tmp, $files);
+
+ $locales = $this->publishableLocales();
+
+ foreach ($this->templateRepository->getActive() as $template) {
+ $this->publishTemplate($tmp, $template, $locales, $files);
+ }
+
+ $this->publishBundles($tmp, $version, $files);
+
+ // 원자적 스왑 — force 재게시 시 기존 디렉토리를 비켜낸 뒤 rename
+ if (File::isDirectory($final)) {
+ File::deleteDirectory($final);
+ }
+
+ if (! @rename($tmp, $final)) {
+ throw new StaticCachePublishException("Failed to rename publish directory: {$tmp} -> {$final}");
+ }
+
+ // manifest 는 rename 후 마지막 기록 — 존재 = 게시 완료
+ $this->writeManifest($final, $version, $files);
+ unset($this->publishedMemo[$version]);
+
+ // sudo/root CLI 게시 대응 — terminating 게시는 코어 업데이트의
+ // restoreOwnership **이후**(프로세스 종료 시)에 실행되므로, root 소유로
+ // 남으면 이후 php-fpm 의 재게시·GC 가 영구 실패한다. 부모(public/build)
+ // 소유권을 상속시킨다 (FilePermissionHelper::copyFile 의 sudo 대응 선례).
+ $this->normalizeOwnership();
+
+ // 인라인 GC (현재 + 직전 1개 보존)
+ $this->cleanup();
+
+ Log::info('부트스트랩 리소스 정적 게시 완료', [
+ 'version' => $version,
+ 'files' => count($files),
+ ]);
+
+ return true;
+ } catch (\Throwable $e) {
+ Log::warning('부트스트랩 리소스 정적 게시 실패 — API 폴백으로 동작합니다', [
+ 'version' => $version,
+ 'error' => $e->getMessage(),
+ ]);
+ File::deleteDirectory($tmp);
+
+ return false;
+ }
+ }
+
+ /**
+ * root 로 실행된 CLI 게시의 산출물 소유권을 부모 디렉토리 기준으로 정상화합니다.
+ *
+ * root 가 아닌 프로세스는 chown 자체가 불가능하고 필요도 없다(자기 소유로 생성됨)
+ * — 그 경우 즉시 no-op. 실패는 chownRecursive 가 경고 로그로 누적한다.
+ * 게시 루트 전체(`build/ext`)를 대상으로 하므로 방금 게시된 버전 디렉토리와
+ * 잔존 구버전이 함께 정상화된다.
+ */
+ private function normalizeOwnership(): void
+ {
+ if (! function_exists('chown') || ! function_exists('posix_geteuid') || posix_geteuid() !== 0) {
+ return;
+ }
+
+ $parent = dirname($this->baseDir());
+ $owner = @fileowner($parent);
+ $group = @filegroup($parent);
+
+ if ($owner === false || $owner === 0) {
+ return;
+ }
+
+ FilePermissionHelper::chownRecursive($this->baseDir(), $owner, $group);
+ }
+
+ /**
+ * 활성 템플릿 1개의 게시물(lang/components/routes/assets)을 기록합니다.
+ *
+ * @param string $tmp tmp 디렉토리 절대 경로
+ * @param Template $template 활성 템플릿
+ * @param array