# 모듈 프론트엔드 에셋 시스템 > 이 문서는 G7의 모듈 프론트엔드 에셋 로딩 시스템을 다룹니다. --- ## TL;DR (5초 요약) ```text 1. module.json에 에셋 매니페스트 정의 (js, css, loading strategy) 2. Vite IIFE 빌드로 dist/에 번들 생성 3. TemplateApp 초기화 시 자동 로드 (global 전략) 4. 핸들러 네이밍: {module-identifier}.{handler-name} 5. 빌드 명령: php artisan module:build [identifier] (기본: _bundled, --active로 활성) ``` --- ## 목차 - [개요](#개요) - [에셋 매니페스트 스키마](#에셋-매니페스트-스키마) - [모듈 프론트엔드 구조](#모듈-프론트엔드-구조) - [핸들러 등록](#핸들러-등록) - [빌드 및 배포](#빌드-및-배포) - [에셋 로딩 전략](#에셋-로딩-전략) - [외부 라이브러리](#외부-라이브러리) - [관련 문서](#관련-문서) --- ## 개요 모듈/플러그인은 자체 프론트엔드 에셋(JS, CSS, 이미지, 폰트 등)을 포함할 수 있습니다. 이 시스템은 활성화된 모듈의 에셋을 동적으로 로드하여 ActionDispatcher에 핸들러를 등록합니다. ### 작동 원리 ``` 1. admin.blade.php 렌더링 └─ window.G7Config.moduleAssets 주입 2. TemplateApp.init() └─ ModuleAssetLoader.loadActiveExtensionAssets() ├─ CSS 로드 (병렬) └─ JS 로드 (병렬 fetch + 순차 실행) ├─ priority 오름차순 정렬 후 DOM append ├─ script.async = false → 삽입 순서대로 실행 보장 (HTML 사양) └─ 각 모듈의 initModule() 실행 └─ ActionDispatcher.registerHandler() 3. 레이아웃 렌더링 └─ 모듈 핸들러 사용 가능 ``` > **성능 참고**: JS 번들은 `Promise.all` 로 병렬 fetch 되며, `script.async = false` 와 > priority 정렬된 DOM append 순서로 **실행 순서는 유지**됩니다. N 개의 확장 IIFE 로딩이 > N × (fetch 시간) 에서 max(fetch 시간) 으로 단축됩니다. 단, 확장 간 런타임 의존성 > (다른 확장의 window 전역/핸들러 참조) 이 발생하면 priority 필드로 실행 순서를 명시해야 합니다. --- ## 에셋 매니페스트 스키마 ### module.json (통합 스키마) 모듈 루트에 `module.json` 파일을 생성합니다. 메타데이터와 에셋 설정이 하나의 파일에 통합되어 있습니다. ```json { "identifier": "sirsoft-ecommerce", "vendor": "sirsoft", "name": { "ko": "이커머스", "en": "Ecommerce" }, "version": "1.0.0", "description": { "ko": "상품 및 주문 관리를 위한 이커머스 모듈", "en": "E-commerce module for product and order management" }, "g7_version": ">=1.0.0", "dependencies": { "modules": {}, "plugins": {} }, "github_url": null, "github_changelog_url": null, "assets": { "js": { "entry": "resources/js/index.ts", "output": "dist/js/module.iife.js" }, "css": { "entry": "resources/css/main.css", "output": "dist/css/module.css" }, "handlers": true, "static": "resources/assets/" }, "loading": { "strategy": "global", "priority": 100 } } ``` > **참고**: `identifier`, `vendor`는 디렉토리명에서 자동 추론되므로 생략 가능합니다. > `name`, `version`, `description`은 AbstractModule에서 자동 파싱됩니다. ### 스키마 설명 #### 메타데이터 필드 | 필드 | 타입 | 필수 | 설명 | |------|------|------|------| | `identifier` | `string` | 선택 | 모듈 식별자 (디렉토리명에서 자동 추론) | | `vendor` | `string` | 선택 | 벤더명 (identifier에서 자동 추론) | | `name` | `string\|object` | 필수 | 모듈명 (다국어: `{"ko": "...", "en": "..."}`) | | `version` | `string` | 필수 | 시맨틱 버전 (예: `1.0.0`) | | `description` | `string\|object` | 필수 | 모듈 설명 (다국어 지원) | | `g7_version` | `string` | 선택 | 그누보드7 코어 버전 제약 (예: `>=1.0.0`) | | `dependencies` | `object` | 선택 | 모듈/플러그인 의존성 | | `github_url` | `string\|null` | 선택 | GitHub 저장소 URL (업데이트 감지용) | | `github_changelog_url` | `string\|null` | 선택 | GitHub 변경 이력 URL | | `trusted_script_hosts` | `string[]` | 선택 | 레이아웃이 로드할 수 있는 외부 스크립트 신뢰 호스트 목록 (아래 참조) | #### `trusted_script_hosts` — 외부 스크립트 신뢰 호스트 **구동에 필요한 자산은 확장이 함께 담아 자체 제공하는 것이 원칙입니다.** 브라우저가 화면을 그리기 위해 제3자 CDN 에 도달해야 하면, 그 도달 실패는 예외도 로그도 남기지 않고 화면 기능만 조용히 사라집니다 — 폐쇄망·방화벽·광고차단기에서 재현되며 자체 서버 로그에 흔적이 없어 운영자가 원인을 특정할 수 없습니다. 동봉 위치는 `dist/vendor/{라이브러리}/{버전}/` 입니다. 이 필드는 **자체 제공이 불가능한 경우**에만 씁니다 — 라이브러리가 아니라 그 회사 서버와 통신하는 서비스 SDK(주소 검색 등)가 그렇습니다. 자체 호스팅해도 동작하지 않으므로 외부 의존이 남습니다. 레이아웃 보안 정책은 `scripts[].src`·`data_sources[].endpoint` 를 기본적으로 same-origin 경로(`/` 로 시작)만 허용하고, 외부 origin·protocol-relative(`//host`)·scheme 포함 URL 은 저장 시점과 렌더 시점 양쪽에서 차단합니다. 확장이 정당하게 외부 CDN 스크립트를 써야 하면 그 호스트를 이 배열에 선언합니다. 활성 확장이 선언한 호스트만 집계되며(편집자는 추가 불가 — manifest 는 배포물), 코어가 활성 확장 전체의 선언을 모아 allowlist 를 구성합니다. - 값은 호스트명만(스킴/경로 없이). 예: `"t1.daumcdn.net"`. - **`trusted_script_hosts_reason` 에 호스트별 사유를 함께 선언합니다.** 사유가 없는 선언은 자체 제공 원칙의 예외로 인정되지 않습니다 — 왜 외부로 나가는지가 코드에 남아야 합니다. ```jsonc { "trusted_script_hosts": ["t1.daumcdn.net"], "trusted_script_hosts_reason": { "t1.daumcdn.net": "라이브러리가 아니라 Daum 이 운영하는 서비스 SDK 다. 스크립트가 Daum 서버와 통신하므로 자체 호스팅해도 동작하지 않는다." } } ``` - 외부 의존이 남는 기능은 **그 자산을 못 불러왔을 때의 동작**을 함께 갖춰야 합니다. 예: 주소 검색 SDK 가 없으면 우편번호·주소를 직접 입력할 수 있게 두고 안내를 띄웁니다. - 이 기능은 코어 7.0.7 에서 도입되었습니다. 선언하는 확장은 `g7_version` 을 `>=7.0.7` 로 두는 것이 계약상 정확합니다(하위 코어에서는 필드가 무시되어 무해). - 관련 보안 정책 상세: [frontend/security.md](../frontend/security.md). 플러그인(`plugin.json`)·템플릿(`template.json`)도 동일 필드를 지원합니다. #### 에셋 필드 | 필드 | 설명 | |------|------| | `assets.js.entry` | JS 소스 엔트리 포인트 | | `assets.js.output` | 빌드된 JS 출력 경로 | | `assets.css.entry` | CSS 소스 엔트리 포인트 | | `assets.css.output` | 빌드된 CSS 출력 경로 | | `assets.handlers` | 핸들러 포함 여부 | | `assets.static` | 정적 에셋 소스 디렉토리 | | `loading.strategy` | 로딩 전략 (global, layout, lazy) | | `loading.priority` | 로드 우선순위 (낮을수록 먼저) | --- ## 모듈 프론트엔드 구조 ### 디렉토리 구조 ``` modules/_bundled/sirsoft-ecommerce/ ├── module.json ← 에셋 매니페스트 ├── package.json ← npm 패키지 정의 ├── vite.config.ts ← Vite 빌드 설정 ├── tsconfig.json ← TypeScript 설정 ├── dist/ ← 빌드 출력 (_bundled 은 Git 추적 — 배포 산출물, `*.map` 만 ignore) │ ├── js/module.iife.js │ ├── css/module.css │ └── assets/ │ ├── fonts/ │ └── images/ ├── resources/ │ ├── js/ ← JS 소스 │ │ ├── index.ts │ │ ├── types.ts │ │ └── handlers/ │ │ ├── index.ts │ │ └── updateProductField.ts │ ├── css/main.css ← CSS 소스 │ └── assets/ ← 정적 에셋 소스 │ ├── fonts/ │ └── images/ └── ... ``` ### package.json ```json { "name": "sirsoft-ecommerce", "version": "1.0.0", "private": true, "type": "module", "scripts": { "dev": "vite build --watch", "build": "vite build" }, "devDependencies": { "typescript": "^5.0.0", "vite": "^6.0.0" } } ``` ### vite.config.ts ```typescript import { defineConfig } from 'vite'; import path from 'path'; export default defineConfig({ build: { lib: { entry: path.resolve(__dirname, 'resources/js/index.ts'), name: 'SirsoftEcommerce', fileName: 'module', formats: ['iife'], }, outDir: 'dist', rollupOptions: { output: { entryFileNames: 'js/[name].iife.js', assetFileNames: (assetInfo) => { if (assetInfo.name?.endsWith('.css')) { return 'css/[name][extname]'; } return 'assets/[name][extname]'; }, }, }, emptyOutDir: true, minify: true, sourcemap: true, }, resolve: { alias: { '@': path.resolve(__dirname, 'resources/js'), }, }, }); ``` --- ## 핸들러 등록 ### 모듈 엔트리 파일 (index.ts) ```typescript import '../css/main.css'; import { handlerMap } from './handlers'; const MODULE_IDENTIFIER = 'sirsoft-ecommerce'; export function initModule(): void { const registerHandlers = () => { const actionDispatcher = (window as any).G7Core?.getActionDispatcher?.(); if (actionDispatcher) { Object.entries(handlerMap).forEach(([name, handler]) => { const fullName = `${MODULE_IDENTIFIER}.${name}`; actionDispatcher.registerHandler(fullName, handler); }); console.log(`[Module:${MODULE_IDENTIFIER}] Handlers registered`); } else { // ActionDispatcher 초기화 대기 setTimeout(registerHandlers, 100); } }; if (document.readyState === 'complete') { registerHandlers(); } else { window.addEventListener('load', registerHandlers); } } // IIFE 빌드 시 즉시 실행 initModule(); // 코어 재초기화 시 재등록 진입점 노출 (아래 "코어 재초기화 시 핸들러 재등록" 참조) (window as any).__SirsoftEcommerce = { identifier: MODULE_IDENTIFIER, initModule, }; ``` ### 코어 재초기화 시 핸들러 재등록 로케일 전환처럼 `TemplateApp` 이 다시 초기화되는 시점에 ActionDispatcher 는 **새 인스턴스로 교체**된다. 이때 앞서 등록해 둔 확장 핸들러는 전부 사라지므로, 코어가 각 확장에 재등록을 요청한다. 요청 방식은 **window 전역 객체에서 약속된 이름의 함수를 찾아 호출**하는 것 하나뿐이다. | 확장 타입 | 전역 객체 | 재등록 진입점 | | --- | --- | --- | | 모듈 | `window.__[ModuleName]` | `initModule()` | | 플러그인 | `window.__[PluginName]` | `initPlugin()` | | 템플릿 | `window.G7TemplateHandlers` | 코어가 직접 재등록 (확장 작업 불필요) | ```typescript // 플러그인 엔트리 파일 (index.ts) function initPlugin(): void { registerHandlersWithRetry(); // 핸들러 재등록만 수행 } initPlugin(); (window as any).__SirsoftDaumPostcode = { identifier: PLUGIN_IDENTIFIER, initPlugin, }; ``` 이름은 고정이다. 전역 객체를 노출하지 않거나 진입점 이름이 다르면(`init`, `bootstrap`, `setup` 등) 코어는 그 확장을 재등록 대상에서 조용히 건너뛴다. 그 결과는 다음과 같다: - 사용자가 언어를 한 번 바꾼 뒤부터 해당 확장의 모든 액션이 **무반응**이 된다 - 핸들러가 없으므로 dispatch 는 그대로 무시된다 — 콘솔 에러도, 토스트도, 네트워크 요청도 없다 - 새로고침하면 정상으로 돌아오므로 재현 조건을 모르면 원인 추적이 어렵다 진입점은 **핸들러 재등록만** 수행한다. 최초 진입 1회로 충분한 작업(리다이렉트 복귀 처리, MutationObserver·인터셉터 설치, DOM 주입 등)은 넣지 않는다 — 재초기화마다 중복 실행된다. ### 핸들러 정의 ```typescript // handlers/updateProductField.ts import type { ActionContext } from '@/types'; interface UpdateProductFieldParams { productId: string | number; field: string; value: string | number | boolean; stateKey?: string; } export function updateProductFieldHandler( params: UpdateProductFieldParams, context: ActionContext ): void { const { productId, field, value, stateKey = 'products' } = params; // setState를 통해 상태 업데이트 if (context.setState) { context.setState({ [stateKey]: { _modified: { [productId]: { [field]: value } } } }); } } // handlers/index.ts import { updateProductFieldHandler } from './updateProductField'; import { updateOptionFieldHandler } from './updateOptionField'; import type { ActionHandler } from '@/types'; export const handlerMap: Record = { updateProductField: updateProductFieldHandler, updateOptionField: updateOptionFieldHandler, }; ``` ### 레이아웃에서 핸들러 사용 ```json { "component": "Input", "props": { "type": "number", "value": "{{row.stock_quantity}}" }, "events": { "onBlur": { "handler": "sirsoft-ecommerce.updateProductField", "params": { "productId": "{{row.id}}", "field": "stock_quantity", "value": "{{$event.target.value}}" } } } } ``` --- ## 빌드 및 배포 ### Artisan 커맨드 ```bash # _bundled에서 빌드 (기본값) php artisan module:build sirsoft-ecommerce # 모든 _bundled 모듈 빌드 php artisan module:build --all # 프로덕션 빌드 (_bundled) php artisan module:build sirsoft-ecommerce --production # 파일 감시 모드 (활성 디렉토리에서 자동 실행) php artisan module:build sirsoft-ecommerce --watch # 활성 디렉토리에서 빌드 php artisan module:build sirsoft-ecommerce --active # 빌드 후 활성 디렉토리 반영 php artisan module:update sirsoft-ecommerce ``` ### npm 스크립트 ```bash # 모듈 디렉토리에서 직접 실행 cd modules/_bundled/sirsoft-ecommerce npm install npm run build # 개발 모드 (파일 감시) npm run dev ``` ### 에셋 서빙 API 빌드된 에셋은 다음 API를 통해 서빙됩니다: ``` GET /api/modules/assets/{identifier}/{path} 예시: /api/modules/assets/sirsoft-ecommerce/dist/js/module.iife.js /api/modules/assets/sirsoft-ecommerce/dist/css/module.css ``` --- ## 서버측 번들 병합 (Server-side Bundle) 활성 모듈/플러그인이 늘어날수록 개별 IIFE JS/CSS 요청이 선형 증가한다. 이를 줄이기 위해 코어는 타입별(모듈/플러그인)로 활성 `global` 에셋을 서버에서 하나의 번들로 병합해 서빙한다. 각 확장 IIFE 는 자체 클로저에서 자가등록(레지스트리 + 핸들러/리스너)을 수행하므로, priority 순으로 이어붙여 단일 `