Files
Gnuboard7/docs/extension/module-assets.md
T
HeuJung 5ba7a83597 feat(core,engine): 부트스트랩 리소스 정적 게시(bake) 및 폴백 체계 도입
공개 제보 https://github.com/gnuboard/g7/issues/122 대응 — 초기 부트스트랩
리소스(다국어 병합·컴포넌트 정의·라우트·확장 번들·템플릿 dist)를 캐시 버전
디렉토리(public/build/ext/{v}/)에 실파일로 게시해 웹서버가 rewrite 전에 직접
서빙한다. 부트 임계 경로의 PHP 왕복을 제거하고(실측 TTFB 131~144ms → 1~7ms),
미게시·부분게시·GC 직후에는 fetch·태그·번들 3계층이 종전 API 로 즉시 폴백한다.

- 게시: 원자적 tmp→rename→manifest(존재=완료), 캐시 락 단일 실행, 인라인 GC
 (현재+직전 1개), incrementExtensionCacheVersion 단일 지점 terminating 트리거
 + blade 자가 치유 + 설치기 태스크(best_effort) + 일일 cleanup 스케줄,
 sudo 업데이트 대비 소유권 정상화(normalizeOwnership)·prune/백업 제외
- 프론트(engine-v1.61.0): blade 주입 cache_version 1급 시드(이중 부트 로드 제거),
 fetchStaticFirst 즉시 폴백, ComponentRegistry 버전 키드 매니페스트,
 ModuleAssetLoader 번들 정적→레거시 폴백, asset-url-recovery staticToLegacy 역변환
- 폴백 API 품질: lang/components/routes ETag+304 + 환경 분기 Cache-Control,
 열화 라우트 스냅샷 공개 캐시 금지(서버측 캐시 회피와 대칭), 게시 .htaccess
 mod_deflate + nginx gzip 스니펫(압축 전송량 회귀 방지)
- SEO 정합: 봇 HTML 은 GC 대상 정적 URL 미사용(allowStatic:false), props $switch
 봇측 해석 구현(engine-v1.56.0 패리티), 패리티 룰 expression-dialect 그룹 신설,
 상주 allow 헤더 제거로 잠금 복원, _comment* 접두 주석 키 분류
- 검증: 전 수정 red→green 4단계, Playwright 라이브 21건, Chrome MCP 24축+M1~M3,
 봇 curl 3축, 캐시 저장소(file/redis/database) 축 판정, 히스토리·공개이슈·커밋
 이력 전수 재조사 반영
- 코어 7.0.10, engine-v1.61.0. kill-switch: G7_STATIC_CACHE=false
2026-08-25 17:02:53 +09:00

21 KiB
Raw Blame History

모듈 프론트엔드 에셋 시스템

이 문서는 G7의 모듈 프론트엔드 에셋 로딩 시스템을 다룹니다.


TL;DR (5초 요약)

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 파일을 생성합니다. 메타데이터와 에셋 설정이 하나의 파일에 통합되어 있습니다.

{
    "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 — 외부 스크립트 신뢰 호스트

레이아웃 보안 정책은 scripts[].src·data_sources[].endpoint 를 기본적으로 same-origin 경로(/ 로 시작)만 허용하고, 외부 origin·protocol-relative(//host)·scheme 포함 URL 은 저장 시점과 렌더 시점 양쪽에서 차단합니다. 확장이 정당하게 외부 CDN 스크립트를 써야 하면 그 호스트를 이 배열에 선언합니다. 활성 확장이 선언한 호스트만 집계되며(편집자는 추가 불가 — manifest 는 배포물), 코어가 활성 확장 전체의 선언을 모아 allowlist 를 구성합니다.

{
  "trusted_script_hosts": ["cdn.ckeditor.com"]
}
  • 값은 호스트명만(스킴/경로 없이). 예: "cdn.ckeditor.com", "t1.daumcdn.net".
  • 이 기능은 코어 7.0.7 에서 도입되었습니다. 선언하는 확장은 g7_version 을 >=7.0.7 로 두는 것이 계약상 정확합니다(하위 코어에서는 필드가 무시되어 무해).
  • 관련 보안 정책 상세: 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

{
    "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

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)

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 코어가 직접 재등록 (확장 작업 불필요)
// 플러그인 엔트리 파일 (index.ts)
function initPlugin(): void {
    registerHandlersWithRetry();   // 핸들러 재등록만 수행
}

initPlugin();

(window as any).__SirsoftDaumPostcode = {
    identifier: PLUGIN_IDENTIFIER,
    initPlugin,
};

이름은 고정이다. 전역 객체를 노출하지 않거나 진입점 이름이 다르면(init, bootstrap, setup 등) 코어는 그 확장을 재등록 대상에서 조용히 건너뛴다. 그 결과는 다음과 같다:

  • 사용자가 언어를 한 번 바꾼 뒤부터 해당 확장의 모든 액션이 무반응이 된다
  • 핸들러가 없으므로 dispatch 는 그대로 무시된다 — 콘솔 에러도, 토스트도, 네트워크 요청도 없다
  • 새로고침하면 정상으로 돌아오므로 재현 조건을 모르면 원인 추적이 어렵다

진입점은 핸들러 재등록만 수행한다. 최초 진입 1회로 충분한 작업(리다이렉트 복귀 처리, MutationObserver·인터셉터 설치, DOM 주입 등)은 넣지 않는다 — 재초기화마다 중복 실행된다.

핸들러 정의

// 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<string, ActionHandler> = {
    updateProductField: updateProductFieldHandler,
    updateOptionField: updateOptionFieldHandler,
};

레이아웃에서 핸들러 사용

{
    "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 커맨드

# _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 스크립트

# 모듈 디렉토리에서 직접 실행
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 순으로 이어붙여 단일 <script> 로 실행해도 등록 동작은 동일하다.

프로덕션에서는 이 병합 산출물의 사본이 정적 게시본(public/build/ext/{v}/bundles/)으로 함께 게시되어 웹서버가 직접 서빙할 수 있다 — static-asset-publishing.md 참조. 번들의 생성·정렬·구분자 규율은 계속 본 문서가 소유한다.

서빙 엔드포인트

GET /api/modules/bundle.js?v={version}
GET /api/modules/bundle.css?v={version}
GET /api/plugins/bundle.js?v={version}
GET /api/plugins/bundle.css?v={version}

{version} = 확장 캐시 버전(ClearsTemplateCaches::getExtensionCacheVersion()). 활성 조합이 바뀌면 install/activate/deactivate/update 라이프사이클에서 version 이 bump 되어 새 URL → 새 캐시 파일명으로 자동 무효화된다.

동작 흐름

1. blade → window.G7Config.bundleUrls 주입 (활성 에셋 없는 타입은 null)
2. TemplateApp.loadExtensionAssets()
   └─ ModuleAssetLoader.loadBundle('module', ...) → loadBundle('plugin', ...)
       ├─ 단일 <script async=false> + 단일 <link> append
       └─ 번들 내부 물리 순서(=priority 정렬)로 IIFE 자가등록 실행

bundleUrls 가 없으면(구버전 blade) ModuleAssetLoader.loadActiveExtensionAssets 개별 로딩으로 폴백한다.

병합 규율

규율 내용 정적 검사
priority 순서 manifest loading.priority 오름차순만. 확장 이름 하드코딩 금지 (선언형)
\n;\n 구분자 IIFE 사이는 \n;\n(JS)/\n(CSS). 미사용 시 ASI 붕괴 → 전체 파싱 에러 extension-bundle-concat-separator
소스맵 prod strip, dev 는 개별 에셋 서빙 절대 URL 로 rewrite -
same-origin 번들 URL 은 /api/... 만 (CDN 금지 — gdpr preblocker 자기차단 방지) extension-bundle-url-same-origin
절대경로 게터 getBuiltAssetAbsolutePaths() 사용. base_path("modules"|"plugins") 직접 조립 금지 extension-bundle-asset-path-getter
확장별 try/catch 파일 읽기 실패 시 해당 확장만 skip, 나머지 병합 지속 (메모리+회귀테스트)
CSS url() 상대경로 url() 을 가진 CSS 는 번들 제외(개별 폴백) -

캐시 stale 관리

번들 파일(storage/app/ext-bundles/{type}.{version}.{js,css})은 Laravel 캐시 스토어 밖 파일시스템이라 version bump/cache:clear 가 구파일을 지우지 않는다. 정리 경로:

php artisan ext-bundles:cleanup           # 현재 version 외 구파일 삭제
php artisan module:cache-clear            # 모듈 번들 파일 정리 포함
php artisan plugin:cache-clear            # 플러그인 번들 파일 정리 포함
php artisan template:cache-clear          # 전체 번들 파일 정리 포함

프로덕션은 version-in-path 디스크 캐시, 비프로덕션(dev/watch)은 캐시 없이 매 요청 concat(rebuild 즉시 반영). _bundled 수정 후에는 {type}:update {id} --force 로 활성 반영 후 version bump 로 번들이 재생성된다.

개별 에셋 서빙 라우트(/api/{type}/assets/..., *.map 포함)는 소스맵·static 참조를 위해 존치한다. 다만 *.map 의 실제 서빙은 local 환경에서만 허용된다 — 소스맵에는 원본 코드 전문이 담기므로 운영에서는 확장자 화이트리스트가 차단한다. 상세: template-security.md "소스맵 (map) — 로컬 개발 환경 전용".

전송 압축 (gzip)

번들 JS/CSS 는 fileResponse()(= response()->file() → BinaryFileResponse)로 서빙되며, GzipEncodeResponse 미들웨어가 gzip 압축을 적용한다. BinaryFileResponse 는 getContent() 가 false 를 반환하므로, 미들웨어는 파일 경로(getFile()->getPathname())에서 본문을 읽어 압축한 뒤 헤더(Content-Type/ETag/Cache-Control)를 승계한 일반 Response 로 치환한다.

  • 1KB 미만 번들(예: 빈 CSS)은 MIN_COMPRESS_SIZE 가드로 압축 생략.
  • Accept-Encoding: gzip 미포함 요청, 이미 Content-Encoding 이 있는 응답, 304 응답은 압축 대상에서 제외.
  • 회귀 테스트: tests/Feature/Middleware/GzipEncodeResponseTest.php (BinaryFileResponse 압축/헤더 승계/소용량 skip).

BinaryFileResponse 를 압축 대상에 포함하지 않으면 번들이 비압축 전송되는 사각지대가 생긴다(모듈/플러그인 번들은 크기가 커 압축 이득이 특히 크다).


에셋 로딩 전략

global (기본값)

앱 초기화 시 자동으로 로드됩니다.

{
    "loading": {
        "strategy": "global",
        "priority": 100
    }
}
  • TemplateApp.init()에서 자동 로드
  • 모든 페이지에서 핸들러 사용 가능
  • 우선순위(priority)가 낮을수록 먼저 로드

layout (향후 지원)

특정 레이아웃에서만 로드됩니다.

{
    "loading": {
        "strategy": "layout"
    }
}
  • 레이아웃의 scripts 섹션에서 명시적 로드
  • 해당 레이아웃 진입 시 로드

lazy (향후 지원)

필요할 때 동적으로 로드됩니다.

{
    "loading": {
        "strategy": "lazy"
    }
}
  • 핸들러 호출 시점에 로드
  • 초기 로딩 시간 최적화

외부 라이브러리

외부 CDN 스크립트를 조건부로 로드할 수 있습니다.

module.json 설정

{
    "assets": {
        "external": [
            {
                "src": "https://cdn.example.com/chart.js",
                "id": "chartjs-cdn",
                "if": "{{_global.settings.useCharts}}"
            }
        ]
    }
}

레이아웃 scripts 섹션

{
    "scripts": [
        {
            "src": "https://cdn.example.com/lib.js",
            "id": "external-lib",
            "if": "{{_global.modules['sirsoft-ecommerce'].useExternalLib}}",
            "async": true
        }
    ]
}

AbstractModule 에셋 메서드

AbstractModule은 에셋 관련 헬퍼 메서드를 제공합니다:

메서드 설명
getAssets() module.json의 assets 섹션 반환
getAssetLoadingConfig() loading 설정 반환 (strategy, priority)
hasAssets() 에셋 정의 존재 여부
getBuiltAssetPaths() 빌드된 에셋 경로 반환 (js, css)

관련 문서