Files
Gnuboard7/docs/extension/module-assets.md
T
HeuJung 3945b6f1b3 feat(core,extensions): 구동 에셋 자체 제공 · 자산 실패 폴백 · 운영자 추가 에셋(custom/)
공개 저장소 이슈 gnuboard/g7 (@bigmsg) 제보에서 출발한 작업이다.

브라우저가 화면을 그리려고 제3자 CDN 에 도달해야 하면, 그 도달 실패는 예외도 로그도
남기지 않고 화면 기능만 조용히 사라진다. 폐쇄망·방화벽·광고차단기에서 재현되는데
자체 서버 로그에는 흔적이 없어 운영자가 원인을 특정할 수 없다. 제보된 것은 편집기
하나였지만 같은 구조가 아이콘·글꼴·코드편집기·압축 라이브러리·설치 마법사·개발
대시보드에 똑같이 있었으므로, 번들 확장과 템플릿 전체를 자체 제공으로 옮겼다.
런타임에 외부로 나가는 것은 주소 검색 서비스 하나만 남았다.

자체 제공만으로는 부족하다 — 자기 서버에서 받는 파일도 실패할 수 있고, 종전에는 그
실패가 무음이었다. CSS 경로에 재시도 계층을 세우고(스크립트 경로와 동형), 서버가 HTML
에 직접 심는 externals 까지 실패를 붙잡아 안내 배너와 [다시 시도]로 표면화했다.
편집기·코드편집기는 확보 실패 시 평문 입력으로 내려앉되 저장 계약을 유지한다.

두 번째 축은 운영자가 CSS 를 덧붙일 자리가 없던 문제다(sir.kr 문의). 확장 디렉토리의
custom/ 을 운영자 소유로 정해, 확장 교체가 그 디렉토리만은 보존하게 했다. 출처에
의존하지 않는 서술자로 해석하므로 나중에 다른 출처가 붙어도 소비자는 바뀌지 않는다.
확장 자산과 같은 메커니즘으로 정적 게시되어 CSS 내부 상대 url 도 해석되고, 파일을
고치면 그 변경을 감지해 재게시까지 예약된다.

FTP 접근이 없는 운영자에게는 그 자리도 없는 것과 같으므로 레이아웃 편집기에서 직접
넣고 고칠 수 있게 했다. 모듈·플러그인·템플릿이 한 엔드포인트를 공유한다 — 타입별로
나누면 같은 검증이 세 벌로 갈리고 그중 약한 하나가 우회로가 된다. 여기서 올린
스크립트는 그 레이아웃 한 장이 아니라 사이트 전 화면에서 실행되므로 레이아웃 편집과
분리된 전용 권한으로 연다. 운영자 CSS 가 화면을 조작 불능으로 만들면 그것을 고칠
화면에도 같은 CSS 가 실려 스스로 갇히므로, 서버가 목록을 비우는 탈출구(?custom=off)를
함께 뒀다.

동봉 자산은 재생성 경로에 버전 대조 가드를 붙였다. 선언과 다른 버전을 버전 디렉토리에
써 넣는 조용한 거짓말은 배포된 뒤에는 드러나지 않는다.
2026-08-27 16:47:14 +09:00

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

구동에 필요한 자산은 확장이 함께 담아 자체 제공하는 것이 원칙입니다. 브라우저가 화면을 그리기 위해 제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 에 호스트별 사유를 함께 선언합니다. 사유가 없는 선언은 자체 제공 원칙의 예외로 인정되지 않습니다 — 왜 외부로 나가는지가 코드에 남아야 합니다.

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

플러그인(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"
    }
}
  • 핸들러 호출 시점에 로드
  • 초기 로딩 시간 최적화

외부 라이브러리

라이브러리는 확장이 함께 담아 자체 제공합니다(dist/vendor/{라이브러리}/{버전}/). 외부 CDN 실시간 로드는 그 CDN 에 도달하지 못하는 환경에서 기능이 조용히 사라지게 만듭니다.

아래 조건부 외부 로드는 자체 제공이 불가능한 서비스 SDK 에만 씁니다. 그 경우 manifest 에 trusted_script_hosts 와 trusted_script_hosts_reason 을 함께 선언해야 하며, 자산을 못 불러왔을 때의 동작도 함께 갖춰야 합니다.

동봉 자산의 URL 생성

동봉한 자산을 런타임에 불러올 때는 URL 을 문자열로 조립하지 않고 G7Core.asset 을 씁니다. 확장 번들은 코어 모듈을 import 할 수 없으므로 이 전역이 유일한 통로입니다.

// 플러그인 — 확장 루트 기준이라 dist/ 를 포함한다
const url = G7Core.asset.plugin('sirsoft-ckeditor5', 'dist/vendor/ckeditor5/43.3.1/ckeditor5.umd.js');
await G7Core.asset.loadScript(url, { id: 'ckeditor5' });

// 모듈
G7Core.asset.module('sirsoft-board', 'dist/vendor/chart.js/4.4.0/chart.umd.js');

// 템플릿 — 서버가 dist/ 를 자동으로 붙이므로 path 에 포함하지 않는다
G7Core.asset.template('sirsoft-admin_basic', 'vendor/flag-icons/7.2.3/css/flag-icons.min.css');

자산 URL 은 서버 설정과 정적 게시 상태에 따라 확장자 형태(.../lib.js), 쿼리 형태 (...?file=...), 정적 게시본 경로(/build/ext/{버전}/...) 중 하나로 해석됩니다. 문자열로 조립하면 그 판정을 건너뛰어, 정규식 location 이 확장자를 먼저 가로채는 서버에서 그 자산만 조용히 404 가 됩니다.

AMD 로더나 워커처럼 디렉토리 접두 뒤에 파일명을 이어 붙이는 소비자는 templateDir() 을 씁니다 — 쿼리 형태는 뒤에 파일명을 이어 붙일 수 없기 때문입니다. 확장자 없는 모드에서 404 일 수 있으므로 그 소비자는 폴백을 갖춰야 합니다.

로드에 끝내 실패하면 G7Core.assets.notifyFailure({ id, label, retry }) 로 사용자에게 알립니다. console.error 한 줄로 끝내면 사용자에게는 빈 자리로만 나타나고 자체 서버 로그에도 흔적이 남지 않아 운영자가 원인을 특정할 수 없습니다.

전체 시그니처와 AssetFailure 필드는 G7Core 전역 API 레퍼런스의 「확장 자산」 절을 참조하세요.

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
        }
    ]
}

사용자 추가 에셋 (custom/)

운영자가 자기 CSS·JS·정적 파일을 덧붙일 자리를 각 확장이 제공한다.

종전에는 그런 자리가 없었다. CSS 한 줄을 더하려면 확장 소스(src/styles/)를 고치고 Node.js 로 빌드해야 했고 — 그렇게 넣은 파일은 다음 확장 업데이트에 통째로 사라졌다(확장 교체가 활성 디렉토리를 전부 갈아끼우기 때문이다). 빌드 산출물(dist/)을 직접 고치는 것도 다음 빌드에 사라진다.

자리

templates/{id}/custom/          ← 운영자 소유. 확장 교체가 보존한다
modules/{id}/custom/
plugins/{id}/custom/
├── custom.css                  규약 자동 로드 (파일명 오름차순)
├── 10-override.css
├── custom.js
├── fonts/MyFont.woff2          정적 파일 — CSS 가 상대 경로로 참조
└── assets.json                 (선택) 선언이 있으면 이것이 우선
  • 빌드하지 않는다. 파일을 놓으면 다음 요청부터 적용된다.
  • 확장 업데이트·재설치가 이 디렉토리만은 보존한다.
  • 확장을 삭제하면 확장 디렉토리와 함께 사라진다. 삭제 전 백업을 안내한다.
  • 번들 확장은 custom/ 을 담아 배포하지 않는다 — 보존 계층이 덮어쓰지 않으므로 그 파일은 기존 설치본에 영영 반영되지 않는다. 확장이 담을 자산은 dist/vendor/{lib}/{version}/ 에 둔다.

선언 파일 (custom/assets.json)

순서를 바꾸거나, 일부만 싣거나, 외부 URL 을 등록할 때만 쓴다. 필드는 template.json 의 externals 와 같은 어휘다.

{
  "assets": [
    { "type": "style",  "file": "10-override.css" },
    { "type": "script", "file": "custom.js" },
    { "type": "style",  "url": "https://fonts.example.com/x.css", "reason": "본문 웹폰트" }
  ]
}
  • file 은 custom/ 기준 상대 경로다. 상위 디렉토리 이탈은 차단된다.
  • url 은 운영자가 자기 사이트에 직접 등록하는 외부 자산이다. reason(사유)이 없으면 싣지 않는다 — 왜 외부로 나가는지가 파일에 남아야 한다. 외부 서비스에는 방문자의 IP·UA 가 전달된다.
  • 선언 파일이 있으면 규약 스캔은 하지 않는다. 둘을 합치면 "선언에서 뺐는데 왜 아직 로드되나" 가 된다.
  • 선언 파일이 깨졌으면(JSON 파싱 실패) 규약 스캔으로 되돌아가지 않고 그 확장의 custom 을 비운다. 되돌아가면 운영자가 의도적으로 뺀 파일이 되살아난다.

로드 순서

① 템플릿 외부 리소스 → 코어 엔진 → 템플릿 CSS
② 확장 병합 번들 (모듈 → 플러그인)
③ 사용자 추가 에셋 (모듈 → 플러그인 → 템플릿)   ← 언제나 마지막

CSS 는 나중에 온 규칙이 이긴다. 운영자가 덧붙인 스타일이 확장 스타일보다 뒤에 와야 재정의가 성립한다. 템플릿이 ③ 의 마지막인 이유는 화면 외관의 최종 책임이 템플릿에 있어서다.

같은 확장 안에서는 선언 순서(없으면 파일명 오름차순), CSS 를 JS 보다 먼저 싣는다.

사용자 추가 에셋은 JS 부팅 이후에 붙으므로 아주 짧은 스타일 적용 지연이 있다. 이는 확장 번들 CSS 가 이미 갖고 있는 성질이며, 순서 정합을 깨는 것보다 낫다.

캐시 무효화

파일을 고치면 URL 이 바뀐다 — 캐시를 지우라고 안내할 필요가 없다. 다만 그 방법이 확장 타입에 따라 다르다.

세 타입 모두 확장 자산과 같은 메커니즘으로 정적 게시된다. 그래서 URL 도 같은 축(확장 캐시 버전)을 쓴다 — 정적 경로는 언제나 현재 게시 버전이므로, 파일 서명을 URL 에 실으면 버전 일치 게이트에 걸려 정적 분기가 영영 선택되지 않는다. 대신 운영자가 파일을 고치면 뷰 컴포저가 그 변화를 감지해 확장 캐시 버전을 올리고, 그 단일 지점이 재게시까지 예약한다.

이 게시가 필요한 이유는 성능이 아니라 상대 경로다. API 경로에서는 CSS 내부 url('./font.woff2') 가 해석되지 않는다(기준 URL 이 자산 디렉토리가 아니다). 정적 확장자 URL 은 public 아래 실제 파일일 때만 200 이 되므로, 게시본만이 상대 참조를 성립시킨다.

모듈·플러그인도 같다. 다만 게시 대상은 활성 확장의 custom/ 뿐이다 — 자산 서빙이 활성 확장에만 응답하므로 비활성 확장의 파일을 게시해 봐야 아무도 참조하지 않는 사본이 쌓인다. 확장의 빌드 산출물은 개별 파일이 아니라 병합 번들로 게시되므로, custom/ 이 그 확장에서 유일한 개별 게시 대상이다.

확장하기

해석기는 출처에 의존하지 않는 서술자를 돌려준다.

['id' => 'custom:templates:sirsoft-basic:10-override.css',
 'type' => 'style',          // style | script
 'url'  => '/api/templates/assets/sirsoft-basic?file=custom%2F10-override.css&v=…',
 'version' => 1787660208,
 'source' => 'file']         // file | url | (확장이 더한 출처)

소비자(뷰 컴포저·프론트 로더·서빙·순서 규칙)는 source 를 보지 않는다. 다른 출처를 더하려면 해석기 끝의 필터 훅을 쓴다.

HookManager::addFilter('core.assets.custom_assets', function (array $assets, string $type, string $id): array {
    $assets[] = [
        'id' => 'custom:my-source:'.$id,
        'type' => 'style',
        'url' => '/api/my-endpoint.css',
        'version' => $updatedAt,
        'source' => 'my-source',
    ];

    return $assets;
}, 10, 3);

훅으로 더한 항목도 자기 version 을 실어야 한다 — 캐시 서명이 항목별 버전의 합성이기 때문이다.

화면에서 관리하기 (레이아웃 편집기)

FTP 나 서버 셸이 유일한 경로였다면, 그 접근이 없는 운영자에게는 이 기능이 없는 것과 같다. 레이아웃 편집기 상단의 [커스텀 자산] 버튼이 같은 디렉토리를 화면에서 다룬다 — 텍스트(css·js·mjs·json)는 본문을 열어 고치고, 폰트·이미지는 올리고 지운다. 모달 상단의 대상 선택기로 편집 중인 템플릿과 활성 모듈·플러그인을 오갈 수 있다.

  • 저장·업로드·삭제는 확장 캐시 버전을 올려 정적 게시본까지 갱신한다. 파일만 바꾸고 게시본이 그대로면 운영자에게는 "고쳤는데 화면이 그대로" 로만 나타난다.
  • 바이너리는 본문 편집기를 열지 않는다. 텍스트로 열어 저장하면 내용이 손상된다.
  • 업로드 허용 확장자는 자산 서빙 화이트리스트와 같은 목록이다. 관리 쪽만 넓히면 올릴 수는 있는데 서빙되지 않는 파일이 생기고, 좁히면 서빙 규칙이 사문화된다.

권한은 레이아웃 편집(core.templates.layouts.edit)과 분리된 core.extensions.custom_assets.manage 하나다. 확장 타입별로 쪼개지 않는다 — 쪼개면 운영자가 셋을 다 부여해야 하고, "모듈 CSS 는 되는데 템플릿 CSS 는 안 되는" 상태가 실질적 의미 없이 생긴다. 여기서 올린 스크립트는 그 레이아웃 한 장이 아니라 사이트 전 화면에서 실행되므로, 레이아웃을 고칠 수 있다는 것이 곧 그 권한이 될 수 없다. 기존 사이트에는 코어 업데이트의 표준 권한 동기화로 도달한다(관리자 역할은 자동 부여).

API 레퍼런스: docs/backend/api/extensions.md 의 custom-assets 엔드포인트 5종. 세 타입이 한 엔드포인트 (extensions/{type}/{identifier}/custom-assets)를 공유한다 — 타입별로 나누면 같은 검증·문서· 테스트가 세 벌로 갈리고, 그중 하나만 약해지면 그 경로가 조용한 우회로가 된다.

자기 CSS 에 갇히지 않기 (?custom=off)

운영자가 넣은 CSS 한 줄이 화면을 조작 불능으로 만들 수 있다. 그런데 그것을 고칠 관리자 화면에도 같은 CSS 가 실려 있어, 고치러 들어갈 수가 없다.

주소에 ?custom=off 를 붙여 다시 열면 서버가 목록을 비운다. 자산이 페이지에 도달하지 않으므로, 이미 깨진 화면에서 자바스크립트가 돌기를 기대하지 않아도 된다. 레이아웃 편집기 툴바에도 같은 동작의 토글이 있고, 꺼진 동안에는 지금 화면이 평소와 다른 상태임을 버튼이 드러낸다.

이 파라미터는 그 요청 한 번에만 작용하며 저장되지 않는다. 화면을 고친 뒤 파라미터 없이 열면 곧바로 원래대로 돌아온다.

지원하지 않는 것

방법 판정
dist/css/components.css 직접 수정 다음 빌드·업데이트에 소실된다
src/styles/custom.css + 빌드 확장 저작자의 방법으로는 유효하다. 운영자에게는 부적합(빌드 필요 + 업데이트 소실)
custom/custom.css 에 파일만 놓기 권장

AbstractModule 에셋 메서드

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

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

관련 문서