diff --git a/.env.example b/.env.example index ad1164be..d0848527 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.1 +APP_VERSION=7.0.2 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.env.testing.example b/.env.testing.example index 889ac45f..4df97e96 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.1 +APP_VERSION=7.0.2 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.gitignore b/.gitignore index ee76dad5..b2a79076 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,9 @@ node_modules/ # Settings JSON files (환경별 설정) /storage/app/settings/ +# 확장 프론트엔드 병합 번들 캐시 (version-in-path, 런타임 생성) +/storage/app/ext-bundles/ + # Bootstrap cache (auto-generated) /bootstrap/cache/* !/bootstrap/cache/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index 98717a21..4ffdec6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -788,6 +788,18 @@ php artisan plugin:update sirsoft-payment --force lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱글톤·React Context·DevTools 코어)을 재사용할 때는 재번들하지 않고 `window.G7Core.__runtime` 을 빌려 쓴다 — React/컨텍스트/싱글톤 인스턴스 동일성이 강제되기 때문(사본이 둘이면 "Invalid hook call"·컨텍스트 미매칭). 메인 번들이 `G7CoreGlobals` 에서 공유 대상을 `G7Core.__runtime` 에 노출하고, lazy 번들 vite config 는 React 4종(`react`/`react-dom`/`react-dom/client`/`react/jsx-runtime`)을 external→window 로, 코어 런타임 모듈을 `resolveId` 플러그인으로 `__runtime-shims/` 로 치환한다. +### 확장 번들 병합 (서버측 concat) + +활성 모듈/플러그인의 프론트엔드 IIFE JS·CSS 는 타입별로 서버에서 하나의 번들로 병합해 서빙한다(`/api/{modules,plugins}/bundle.{js,css}?v={version}`). `ExtensionBundleService` 가 정렬·필터·concat·캐시를 전담하고, 프론트는 `window.G7Config.bundleUrls` 를 읽어 모듈 번들 → 플러그인 번들 순으로 로드한다. 병합 규율: + +- priority 순서는 선언형 — 실행 순서는 오직 manifest `loading.priority` 오름차순(`uasort`). 특정 확장 이름을 지목하는 분기를 두지 않는다. +- IIFE 사이는 `\n;\n`(JS)/`\n`(CSS) 로 잇는다. 미사용 시 ASI 경계가 깨져 번들 전체 파싱 에러가 난다. +- 소스맵은 prod strip, dev 는 개별 에셋 서빙 절대 URL 로 rewrite. 개별 에셋 서빙 라우트(`*.map` 포함)는 존치한다. +- 번들 URL 은 반드시 same-origin(`/api/...`). 외부 origin/CDN·protocol-relative 는 gdpr preblocker 에 자기 차단된다. +- 확장 에셋 절대경로는 `getBuiltAssetAbsolutePaths()`(=`getModulePath()`/`getPluginPath()`) 만 쓴다. `base_path("modules"|"plugins")` 직접 조립은 `_bundled` 경로 오해석 → 빈 번들. +- concat 루프는 확장별 try/catch — 실패 확장만 skip 하고 나머지 병합을 지속한다. +- 번들 파일명에 확장 캐시 버전을 포함(`{type}.{version}.{js,css}`). 조합 변경 시 version bump → 새 파일명 → 자동 재생성. 구파일 GC 는 `ext-bundles:cleanup` + `{module,plugin,template}:cache-clear` 가 담당한다. prod 은 version-in-path 디스크 캐시, 비프로덕션은 매 요청 concat. + ### 빌드 명령어 (Artisan) ```bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 05784da4..b4af21f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [7.0.2] - 2026-07-05 + +### Added + +- 설치된 모듈·플러그인이 늘어날수록 화면마다 개별로 내려받던 확장 스크립트·스타일 요청을 종류별로 하나로 묶어 내려받도록 개선해, 확장이 많은 사이트의 초기 로딩 요청 수를 크게 줄였습니다. 확장을 설치·활성화·비활성화하면 묶음이 자동으로 다시 만들어져 항상 현재 활성 상태와 일치합니다. + +### Fixed + +- 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다. + ## [7.0.1] - 2026-07-03 ### Changed diff --git a/INSTALL.md b/INSTALL.md index dc5e17d6..4c4d2f2b 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -246,7 +246,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.0.1 g7 +# (필요 시) mv g7-7.0.2 g7 # ZIP 파일 정리 (선택) rm g7-release.zip diff --git a/README.md b/README.md index c21b4c51..8514de61 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@
-
+
diff --git a/app/Console/Commands/CleanupExtensionBundlesCommand.php b/app/Console/Commands/CleanupExtensionBundlesCommand.php
new file mode 100644
index 00000000..cf1ac59b
--- /dev/null
+++ b/app/Console/Commands/CleanupExtensionBundlesCommand.php
@@ -0,0 +1,43 @@
+getCurrentVersion();
+ $deleted = $service->cleanupStaleBundles($currentVersion);
+
+ $this->info("오래된 확장 번들 {$deleted}건이 삭제되었습니다. (현재 version: {$currentVersion})");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/Module/ClearModuleCacheCommand.php b/app/Console/Commands/Module/ClearModuleCacheCommand.php
index 1fad57b9..6c2d5362 100644
--- a/app/Console/Commands/Module/ClearModuleCacheCommand.php
+++ b/app/Console/Commands/Module/ClearModuleCacheCommand.php
@@ -5,6 +5,7 @@ namespace App\Console\Commands\Module;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Extension\ModuleManager;
+use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -27,7 +28,8 @@ class ClearModuleCacheCommand extends Command
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository,
- private CacheInterface $cache
+ private CacheInterface $cache,
+ private ExtensionBundleService $bundleService
) {
parent::__construct();
}
@@ -86,6 +88,9 @@ class ClearModuleCacheCommand extends Command
$clearedCount += $this->clearModuleCache($module->getIdentifier());
}
+ // 모듈 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
+ $clearedCount += $this->bundleService->clearBundles('module');
+
$this->info('✅ '.__('modules.commands.cache_clear.success_all', ['count' => $clearedCount]));
}
diff --git a/app/Console/Commands/Plugin/ClearPluginCacheCommand.php b/app/Console/Commands/Plugin/ClearPluginCacheCommand.php
index 1fd77bb3..0dd0aa42 100644
--- a/app/Console/Commands/Plugin/ClearPluginCacheCommand.php
+++ b/app/Console/Commands/Plugin/ClearPluginCacheCommand.php
@@ -5,6 +5,7 @@ namespace App\Console\Commands\Plugin;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Extension\PluginManager;
+use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -27,7 +28,8 @@ class ClearPluginCacheCommand extends Command
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository,
- private CacheInterface $cache
+ private CacheInterface $cache,
+ private ExtensionBundleService $bundleService
) {
parent::__construct();
}
@@ -86,6 +88,9 @@ class ClearPluginCacheCommand extends Command
$clearedCount += $this->clearPluginCache($plugin->getIdentifier());
}
+ // 플러그인 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
+ $clearedCount += $this->bundleService->clearBundles('plugin');
+
$this->info('✅ '.__('plugins.commands.cache_clear.success_all', ['count' => $clearedCount]));
}
diff --git a/app/Console/Commands/Template/ClearTemplateCacheCommand.php b/app/Console/Commands/Template/ClearTemplateCacheCommand.php
index 8c8a8632..ca13ec43 100644
--- a/app/Console/Commands/Template/ClearTemplateCacheCommand.php
+++ b/app/Console/Commands/Template/ClearTemplateCacheCommand.php
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
use App\Extension\TemplateManager;
use App\Models\Template;
use App\Models\TemplateLayout;
+use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -27,7 +28,8 @@ class ClearTemplateCacheCommand extends Command
*/
public function __construct(
private TemplateManager $templateManager,
- private CacheInterface $cache
+ private CacheInterface $cache,
+ private ExtensionBundleService $bundleService
) {
parent::__construct();
}
@@ -153,6 +155,9 @@ class ClearTemplateCacheCommand extends Command
$this->cache->forget('templates.active.user');
$clearedCount += 2;
+ // 5. 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신)
+ $clearedCount += $this->bundleService->clearBundles();
+
$this->info('✅ '.__('templates.commands.cache_clear.success_all', [
'count' => $clearedCount,
]));
diff --git a/app/Extension/AbstractModule.php b/app/Extension/AbstractModule.php
index 8a72e698..c7a3f532 100644
--- a/app/Extension/AbstractModule.php
+++ b/app/Extension/AbstractModule.php
@@ -1190,6 +1190,29 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return $result;
}
+ /**
+ * 빌드된 에셋의 절대 파일 경로를 반환합니다.
+ *
+ * `getBuiltAssetPaths()` 는 module.json 의 상대 output 경로를 돌려주므로
+ * 파일을 실제로 읽으려면 모듈 루트(`getModulePath()`: 활성 dir 또는 `_bundled`
+ * 실제 위치)를 앞에 붙여야 한다. 서버측 번들 병합(ExtensionBundleService)이
+ * `getAssetFilePath()` 의 `base_path("modules/{id}/...")` 하드코딩을 복제하지
+ * 않고 `_bundled` 확장에서도 정확한 경로를 얻도록 이 게터를 SSoT 로 쓴다.
+ *
+ * @return array 빌드된 에셋 절대 경로 배열 ['js' => '...', 'css' => '...']
+ */
+ public function getBuiltAssetAbsolutePaths(): array
+ {
+ $relative = $this->getBuiltAssetPaths();
+ $result = [];
+
+ foreach ($relative as $kind => $output) {
+ $result[$kind] = $this->getModulePath().'/'.$output;
+ }
+
+ return $result;
+ }
+
/**
* 모듈 스토리지 드라이버 인스턴스 반환
*
diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php
index 5dd4ad10..1e76a1b8 100644
--- a/app/Extension/AbstractPlugin.php
+++ b/app/Extension/AbstractPlugin.php
@@ -1011,6 +1011,30 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
return $result;
}
+ /**
+ * 빌드된 에셋의 절대 파일 경로를 반환합니다.
+ *
+ * `getBuiltAssetPaths()` 는 plugin.json 의 상대 output 경로를 돌려주므로
+ * 파일을 실제로 읽으려면 플러그인 루트(`getPluginPath()`: 활성 dir 또는
+ * `_bundled` 실제 위치)를 앞에 붙여야 한다. 서버측 번들 병합
+ * (ExtensionBundleService)이 `getAssetFilePath()` 의
+ * `base_path("plugins/{id}/...")` 하드코딩을 복제하지 않고 `_bundled`
+ * 확장에서도 정확한 경로를 얻도록 이 게터를 SSoT 로 쓴다.
+ *
+ * @return array 빌드된 에셋 절대 경로 배열 ['js' => '...', 'css' => '...']
+ */
+ public function getBuiltAssetAbsolutePaths(): array
+ {
+ $relative = $this->getBuiltAssetPaths();
+ $result = [];
+
+ foreach ($relative as $kind => $output) {
+ $result[$kind] = $this->getPluginPath().'/'.$output;
+ }
+
+ return $result;
+ }
+
/**
* 플러그인이 설정 페이지를 가지고 있는지 확인합니다.
*
diff --git a/app/Http/Controllers/Api/Public/PublicModuleController.php b/app/Http/Controllers/Api/Public/PublicModuleController.php
index cf46e435..85de88f1 100644
--- a/app/Http/Controllers/Api/Public/PublicModuleController.php
+++ b/app/Http/Controllers/Api/Public/PublicModuleController.php
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\Module\ServeModuleAssetRequest;
+use App\Services\ExtensionBundleService;
use App\Services\ModuleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
@@ -17,11 +18,54 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PublicModuleController extends PublicBaseController
{
public function __construct(
- private readonly ModuleService $moduleService
+ private readonly ModuleService $moduleService,
+ private readonly ExtensionBundleService $bundleService
) {
parent::__construct();
}
+ /**
+ * 활성 모듈 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
+ *
+ * 활성 global 모듈 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다
+ * (프론트는 빈 스크립트 로드로 무해). 그 외에는 병합 파일을 fileResponse 로
+ * 서빙(ETag/304/환경별 Cache-Control 재사용).
+ *
+ * @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
+ */
+ public function serveBundleJs(): BinaryFileResponse|Response
+ {
+ $this->logApiUsage('modules.bundle', ['kind' => 'js']);
+
+ $version = $this->bundleService->getCurrentVersion();
+ $path = $this->bundleService->getBundleFilePath('module', 'js', $version);
+
+ if ($path === '') {
+ return response('', 200)->header('Content-Type', 'text/javascript');
+ }
+
+ return $this->fileResponse($path, 'text/javascript', 31536000);
+ }
+
+ /**
+ * 활성 모듈 프론트엔드 병합 번들(CSS)을 서빙합니다.
+ *
+ * @return BinaryFileResponse|Response 병합 CSS 파일 응답 또는 빈 응답
+ */
+ public function serveBundleCss(): BinaryFileResponse|Response
+ {
+ $this->logApiUsage('modules.bundle', ['kind' => 'css']);
+
+ $version = $this->bundleService->getCurrentVersion();
+ $path = $this->bundleService->getBundleFilePath('module', 'css', $version);
+
+ if ($path === '') {
+ return response('', 200)->header('Content-Type', 'text/css');
+ }
+
+ return $this->fileResponse($path, 'text/css', 31536000);
+ }
+
/**
* 모듈 에셋 서빙
*
diff --git a/app/Http/Controllers/Api/Public/PublicPluginController.php b/app/Http/Controllers/Api/Public/PublicPluginController.php
index bb3e0f7b..b187f984 100644
--- a/app/Http/Controllers/Api/Public/PublicPluginController.php
+++ b/app/Http/Controllers/Api/Public/PublicPluginController.php
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\Plugin\ServePluginAssetRequest;
+use App\Services\ExtensionBundleService;
use App\Services\PluginService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
@@ -17,11 +18,53 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PublicPluginController extends PublicBaseController
{
public function __construct(
- private readonly PluginService $pluginService
+ private readonly PluginService $pluginService,
+ private readonly ExtensionBundleService $bundleService
) {
parent::__construct();
}
+ /**
+ * 활성 플러그인 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
+ *
+ * 활성 global 플러그인 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다.
+ * 그 외에는 병합 파일을 fileResponse 로 서빙(ETag/304/환경별 Cache-Control 재사용).
+ *
+ * @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
+ */
+ public function serveBundleJs(): BinaryFileResponse|Response
+ {
+ $this->logApiUsage('plugins.bundle', ['kind' => 'js']);
+
+ $version = $this->bundleService->getCurrentVersion();
+ $path = $this->bundleService->getBundleFilePath('plugin', 'js', $version);
+
+ if ($path === '') {
+ return response('', 200)->header('Content-Type', 'text/javascript');
+ }
+
+ return $this->fileResponse($path, 'text/javascript', 31536000);
+ }
+
+ /**
+ * 활성 플러그인 프론트엔드 병합 번들(CSS)을 서빙합니다.
+ *
+ * @return BinaryFileResponse|Response 병합 CSS 파일 응답 또는 빈 응답
+ */
+ public function serveBundleCss(): BinaryFileResponse|Response
+ {
+ $this->logApiUsage('plugins.bundle', ['kind' => 'css']);
+
+ $version = $this->bundleService->getCurrentVersion();
+ $path = $this->bundleService->getBundleFilePath('plugin', 'css', $version);
+
+ if ($path === '') {
+ return response('', 200)->header('Content-Type', 'text/css');
+ }
+
+ return $this->fileResponse($path, 'text/css', 31536000);
+ }
+
/**
* 플러그인 에셋 서빙
*
diff --git a/app/Http/View/Composers/TemplateComposer.php b/app/Http/View/Composers/TemplateComposer.php
index ff7c9320..5d565fb3 100644
--- a/app/Http/View/Composers/TemplateComposer.php
+++ b/app/Http/View/Composers/TemplateComposer.php
@@ -99,6 +99,9 @@ class TemplateComposer
// 확장 기능 캐시 버전 (브라우저 캐시 무효화용)
$extensionCacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
+ // 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
+ $bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
+
$view->with('activeAdminTemplate', $activeTemplate);
$view->with('extensionCacheVersion', $extensionCacheVersion);
$view->with('frontendSettings', $frontendSettings);
@@ -106,12 +109,39 @@ class TemplateComposer
$view->with('moduleSettings', $moduleSettings);
$view->with('moduleAssets', $moduleAssets);
$view->with('pluginAssets', $pluginAssets);
+ $view->with('bundleUrls', $bundleUrls);
$view->with('activeModulesMeta', $activeModulesMeta);
$view->with('activePluginsMeta', $activePluginsMeta);
$view->with('appConfig', $appConfig);
$view->with('templateExternals', $templateExternals);
}
+ /**
+ * 확장 프론트엔드 병합 번들 URL을 계산합니다.
+ *
+ * 활성 global 에셋(JS/CSS)이 하나라도 있는 타입만 해당 번들 URL을 채우고,
+ * 없으면 null을 두어 프론트가 로드를 스킵하게 한다. URL은 same-origin
+ * (`/api/...`)이어야 gdpr preblocker 의 same-origin 통과 규칙에 자기 차단되지
+ * 않는다(CDN 호스팅 금지).
+ *
+ * @param array $moduleAssets 수집된 모듈 개별 에셋 맵
+ * @param array $pluginAssets 수집된 플러그인 개별 에셋 맵
+ * @param int $version 확장 캐시 버전
+ * @return array{moduleJs: ?string, moduleCss: ?string, pluginJs: ?string, pluginCss: ?string}
+ */
+ private function buildExtensionBundleUrls(array $moduleAssets, array $pluginAssets, int $version): array
+ {
+ $hasJs = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['js'])));
+ $hasCss = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['css'])));
+
+ return [
+ 'moduleJs' => $hasJs($moduleAssets) ? "/api/modules/bundle.js?v={$version}" : null,
+ 'moduleCss' => $hasCss($moduleAssets) ? "/api/modules/bundle.css?v={$version}" : null,
+ 'pluginJs' => $hasJs($pluginAssets) ? "/api/plugins/bundle.js?v={$version}" : null,
+ 'pluginCss' => $hasCss($pluginAssets) ? "/api/plugins/bundle.css?v={$version}" : null,
+ ];
+ }
+
/**
* 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다.
*
diff --git a/app/Http/View/Composers/UserTemplateComposer.php b/app/Http/View/Composers/UserTemplateComposer.php
index c6460938..d145b0b1 100644
--- a/app/Http/View/Composers/UserTemplateComposer.php
+++ b/app/Http/View/Composers/UserTemplateComposer.php
@@ -105,6 +105,9 @@ class UserTemplateComposer
// 확장 기능 캐시 버전 (브라우저 캐시 무효화용)
$extensionCacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
+ // 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
+ $bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
+
$view->with('activeUserTemplate', $activeTemplate);
$view->with('extensionCacheVersion', $extensionCacheVersion);
$view->with('frontendSettings', $frontendSettings);
@@ -112,12 +115,39 @@ class UserTemplateComposer
$view->with('moduleSettings', $moduleSettings);
$view->with('moduleAssets', $moduleAssets);
$view->with('pluginAssets', $pluginAssets);
+ $view->with('bundleUrls', $bundleUrls);
$view->with('activeModulesMeta', $activeModulesMeta);
$view->with('activePluginsMeta', $activePluginsMeta);
$view->with('appConfig', $appConfig);
$view->with('templateExternals', $templateExternals);
}
+ /**
+ * 확장 프론트엔드 병합 번들 URL을 계산합니다.
+ *
+ * 활성 global 에셋(JS/CSS)이 하나라도 있는 타입만 해당 번들 URL을 채우고,
+ * 없으면 null을 두어 프론트가 로드를 스킵하게 한다. URL은 same-origin
+ * (`/api/...`)이어야 gdpr preblocker 의 same-origin 통과 규칙에 자기 차단되지
+ * 않는다(CDN 호스팅 금지).
+ *
+ * @param array $moduleAssets 수집된 모듈 개별 에셋 맵
+ * @param array $pluginAssets 수집된 플러그인 개별 에셋 맵
+ * @param int $version 확장 캐시 버전
+ * @return array{moduleJs: ?string, moduleCss: ?string, pluginJs: ?string, pluginCss: ?string}
+ */
+ private function buildExtensionBundleUrls(array $moduleAssets, array $pluginAssets, int $version): array
+ {
+ $hasJs = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['js'])));
+ $hasCss = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['css'])));
+
+ return [
+ 'moduleJs' => $hasJs($moduleAssets) ? "/api/modules/bundle.js?v={$version}" : null,
+ 'moduleCss' => $hasCss($moduleAssets) ? "/api/modules/bundle.css?v={$version}" : null,
+ 'pluginJs' => $hasJs($pluginAssets) ? "/api/plugins/bundle.js?v={$version}" : null,
+ 'pluginCss' => $hasCss($pluginAssets) ? "/api/plugins/bundle.css?v={$version}" : null,
+ ];
+ }
+
/**
* 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다.
*
diff --git a/app/Services/ExtensionBundleService.php b/app/Services/ExtensionBundleService.php
new file mode 100644
index 00000000..9966b4c5
--- /dev/null
+++ b/app/Services/ExtensionBundleService.php
@@ -0,0 +1,475 @@
+` 로 실행해도 등록 로직은 동일하게 동작한다.
+ *
+ * 정렬/필터(`hasAssets()` && strategy==='global' + `uasort(priority)`) 는
+ * TemplateComposer 와 공유하는 SSoT 로 이 서비스에 둔다(drift 방지).
+ *
+ * 경로는 절대경로 게터(`getBuiltAssetAbsolutePaths()`)를 재사용한다 —
+ * `ModuleService::getAssetFilePath()` 의 `base_path("modules/{id}/...")`
+ * 하드코딩을 복제하지 않아야 `_bundled` 확장에서도 정확히 읽는다(제약 4).
+ *
+ * @see TemplateComposer
+ */
+class ExtensionBundleService
+{
+ // ext.cache_version 게터 재사용 — 트레이트를 use 해 self:: 로 호출(트레이트명
+ // 직접 정적 호출은 PHP 8.3+ deprecated). 인스턴스 캐시 무효화 메서드는 미사용.
+ use ClearsTemplateCaches;
+
+ /**
+ * 번들 캐시 파일이 저장되는 스토리지 디스크(= storage/app/ext-bundles).
+ */
+ private const BUNDLE_DISK = 'ext-bundles';
+
+ /**
+ * 서비스 주입
+ *
+ * @param ModuleManager $moduleManager 모듈 매니저
+ * @param PluginManager $pluginManager 플러그인 매니저
+ */
+ public function __construct(
+ private readonly ModuleManager $moduleManager,
+ private readonly PluginManager $pluginManager
+ ) {}
+
+ /**
+ * 확장 타입별 global 전략 에셋을 priority 오름차순으로 정렬해 반환합니다.
+ *
+ * TemplateComposer 의 개별 에셋 URL 생성과 번들러의 concat 이 동일한
+ * 필터/정렬을 쓰도록 하는 SSoT. 순서 제어는 오직 manifest
+ * `loading.priority` 숫자 오름차순뿐이며 특정 확장 이름 하드코딩은 없다(제약 1).
+ *
+ * @param string $type 'module' | 'plugin'
+ * @return array