Files
Gnuboard7/docs/backend/static-asset-publishing.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

17 KiB

부트스트랩 리소스 정적 게시 (Static Asset Publishing)

초기 부트스트랩 리소스(다국어 병합·컴포넌트 정의·라우트 정보·확장 병합 번들·템플릿 dist 에셋)를 캐시 버전 디렉토리 기반의 실파일로 public/ 하위에 게시(bake)하고, 웹서버가 rewrite 전에 직접 서빙하는 fast path 를 다룬다. (공개 #122)

TL;DR (5초 요약)

1. 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트와 운영자 custom 파일 변경마다 terminating 훅이 재생성 (활성 템플릿 + 활성 모듈·플러그인 custom + 병합 번들)
2. 서빙 게이트 3조건: 프로덕션 + G7_STATIC_CACHE(기본 on) + 게시 완료(manifest 존재)
3. 폴백 2층: 태그 계층은 파일 단위 file_exists + 파샬 역변환, fetch 계층은 fetchStaticFirst
4. 무효화는 버전 디렉토리 — 포인터(cache_version)가 바뀔 뿐 파일 덮어쓰기가 없다
5. .json/.js/.css 로 끝나는 신규 동적(Laravel) 라우트를 만들지 않는다 — 실파일만 정적 확장자

1. 왜 게시(bake)인가

부트스트랩 리소스는 병합 결과물이다 — lang 은 코어→템플릿→모듈→플러그인→언어팩 훅, routes 는 템플릿+활성 모듈, 번들은 활성 확장 IIFE concat. 병합 구조는 유지하되 그 결과물을 실파일로 게시하면, PHP lifecycle 없이 웹서버가 직접 서빙한다 (실측: API 경유 웜 ~131ms vs 정적 파일 17ms).

두 위험은 다음으로 해소된다.

  1. 재게시 트리거 누락 → 조용한 stale: 모든 수명주기 이벤트(확장 설치/활성/비활성/삭제/업데이트, 언어팩 변경, 레이아웃 편집기 저장, 커스텀 번역, CLI 빌드/캐시클리어)는 ClearsTemplateCaches::incrementExtensionCacheVersion() 을 경유한다. 게시 예약을 그 단일 지점 내부에 심어 누락이 구조적으로 불가능하다. 추가로 blade 렌더가 현재 버전의 미게시를 감지하면 자가 치유(terminating 게시 예약)한다.
  2. stale 파일 참조: 덮어쓰기가 아닌 버전 디렉토리 게시 + 포인터(cache_version)는 blade 가 HTML 에 주입한다. 구버전 파일은 잔존해도 참조되지 않는다. content-hash 가 필요 없다.

2. 경로 규약

public/build/ext/{cache_version}/
├── manifest.json                              ← 마지막에 기록 (게시 완료 마커)
├── .htaccess                                  ← Apache: public, max-age=31536000, immutable
├── templates/{template_id}/
│   ├── lang/{locale}.json                     ← 병합 결과 raw (lang API 와 동일 페이로드)
│   ├── components.json                        ← components.json 사본 (raw)
│   ├── routes.json                            ← 병합 결과 + {"success":true,...} 봉투
│   └── assets/{dist 이하 경로}                 ← dist/** 사본 (*.map 제외, 허용 확장자만)
└── bundles/
    ├── modules.js / modules.css               ← 확장 병합 번들 사본
    └── plugins.js / plugins.css
  • 대상: 활성 템플릿 전수, 로케일은 활성 로케일 열거(언어팩이 추가한 로케일 포함).
  • 원자성: {v}.tmp/ 에 전부 쓴 뒤 디렉토리 rename → {v}/, manifest.json 은 rename 후 마지막 기록. manifest 존재 = 게시 완료 — 부분 게시 상태가 참조되지 않는다.
  • 쓰기 안전: 식별자(vendor-name)·로케일 패턴 화이트리스트, dist 복사는 허용 확장자 화이트리스트(자산 서빙 검증 규칙과 동일 목록) + *.map 제외 + realpath 컨테인먼트.
  • config.json 과 레이아웃 JSON 은 게시 대상이 아니다 — 전자는 버전 핸드셰이크의 SSoT(항상 신선해야 함), 후자는 인증 문맥(optional.sanctum) 의존.

2-1. 동봉 자산(dist/vendor/)과 운영자 자산(custom/)

확장이 구동에 필요해 함께 담는 제3자 자산은 dist/vendor/{라이브러리}/{버전}/ 에 둡니다. 템플릿의 dist/** 는 정적 게시 대상이므로 동봉 자산도 웹서버가 직접 서빙합니다.

운영자가 덧붙이는 자산(custom/)도 모듈·플러그인·템플릿 모두 같은 방식으로 게시됩니다. 게시하지 않으면 이 자산만 API 경로에 남는데, 그 경로에서는 CSS 내부 상대 url() 이 해석되지 않습니다 — 쿼리 형태(?file=)는 기준 URL 이 /api/{타입}/assets/ 라 url('./font.woff2') 가 그 디렉토리를 가리키고, 확장자 형태는 정적 최적화 서버가 먼저 가로챕니다. 즉 정적 확장자 URL 은 public 아래 실제 파일일 때만 200 이 되므로, "폰트·이미지를 custom/ 에 두고 상대 경로로 참조" 를 성립시키는 방법은 게시뿐입니다.

갱신 축도 확장 자산과 같습니다. 운영자가 파일을 고치면 뷰 컴포저가 파일 서명 변화를 감지해 확장 캐시 버전을 올리고, 그 단일 지점이 재게시까지 예약합니다. 그 요청은 서술자를 새 버전으로 다시 해석하므로 고친 내용이 그 화면부터 반영됩니다(게시본이 아직 없는 동안에는 API 경로로 떨어지고, 그 응답이 디스크 최신 내용을 줍니다).

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

버전 디렉토리를 쓰는 이유는 업그레이드 시 구버전 삭제 대상이 명확해지기 때문입니다.

동봉 자산을 다시 만드는 절차는 명령으로 고정하고(npm run vendor:*), 그 명령은 설치된 패키지 버전과 출력 경로의 버전 디렉토리를 대조한 뒤에만 씁니다. 손으로 복사하면 어느 버전을 옮겼는지가 어디에도 남지 않아, 설치 트리가 갱신된 뒤 다시 복사하는 순간 7.2.3/ 디렉토리에 7.5.0 이 들어앉습니다 — 오류도 로그도 없이 배포본의 버전 라벨만 틀리고, 파일은 정상으로 서빙되므로 브라우저에서도 드러나지 않습니다. 두 버전이 어긋나거나 설치 패키지의 package.json 이 없어 확인할 수 없으면 생성기는 아무것도 쓰지 않고 중단합니다. 재생성 전에는 npm ci 로 lock 에 선언된 버전을 설치합니다.

3. 서빙·폴백 모델

  • 실파일이므로 Apache(RewriteCond !-f)/nginx(try_files $uri) 어느 쪽이든 서버 설정 추가 없이 rewrite 전에 직접 서빙된다. 정적 확장자 정규식 location(location ~* \.(js|css|json)$)이 있는 서버에서는 그 location 이 곧 서빙 메커니즘이 된다.
  • .json/.js/.css 로 끝나는 신규 동적(Laravel) 라우트를 만들지 않는다. 정적 확장자 location 이 있는 서버에서 PHP 폴백 없이 404 가 되는 함정을 원천 회피한다 (정적 검사가 차단). 404 는 프론트 폴백의 설계된 신호다.
  • 서버 렌더(blade) 층: AssetUrl::staticExtBase() 가 게이트 3조건(프로덕션·core.static_cache.enabled·게시 완료)을 판정한다. 태그로 방출되는 자산(템플릿 CSS·JS, 번들 4종)은 그 자산의 개별 file_exists 까지 확인 후에만 정적 URL 을 방출한다 — 태그는 404 를 받아도 스스로 재시도하지 못하기 때문.
  • 프론트 fetch 층: routes/lang/components 는 정적 URL 우선 + 응답 !ok/네트워크 실패 시 즉시 종전 API URL 폴백. 폴백 발생은 console.warn 1줄로 관측 가능하다 (조용한 폴백 금지 — 자가 치유 실패를 발견할 유일한 통로).
  • 태그 계층 런타임 복구: 브라우저에 캐시된 구 HTML 이 GC 된 구버전 정적 자산을 참조하는 등 서빙 시점 404 는, 자산 URL 자가 복구 파샬이 /build/ext/{v}/… → 종전 /api/… URL 로 1회 역변환한다. /build/core/** 는 실물 정적 파일이라 변환 대상이 아니다. 확장 병합 번들(ModuleAssetLoader)도 동일 규칙 — 정적 번들 URL 은 1회만 시도하고 미스 시 종전 API URL 에서 기존 재시도 예산을 이어간다 (같은 정적 URL 재시도는 게시본 소실을 복구하지 못한다).
  • SEO(봇) 렌더는 정적 URL 을 쓰지 않는다: 봇 HTML 은 seo.page.* 캐시(키에 cache_version 미포함, TTL 수시간)에 박제되는데 게시 디렉토리는 GC 대상이고 SEO HTML 에는 자가 복구 파샬이 없다. AssetUrl::templateAsset(..., allowStatic: false) 로 무버전 API URL 을 고정한다 — 생성한 URL 이 정적 게시 GC 보다 오래 사는 저장소에 남는 호출부는 모두 이 원칙을 따른다.
  • 비프로덕션(dev)에서는 정적 URL 을 방출하지 않는다 — dev 는 파일 수정 즉시 반영이 우선이며, 게시 자체도 트리거되지 않는다.

4. 트리거와 수명주기

트리거 지점 방식
수명주기 전체 incrementExtensionCacheVersion() 내부 terminating 게시 예약 — 프로세스당 1회, 실행 시점의 최종 버전으로 게시 (연속 bump 자연 병합)
자가 치유 blade 렌더의 staticExtBase() 게이트 현재 버전 미게시 감지 시 terminating 게시 예약. 이번 응답은 API URL — 아래 「자가 치유 창의 실제 비용」 참조
수동/워밍 php artisan ext-static:publish [--force] 설치기 완료 단계에서도 호출
GC php artisan ext-static:cleanup + 게시 성공 직후 인라인 GC 현재 + 직전 1개 보존. 스케줄 일 1회 등록

자가 치유 창의 실제 비용

확장 수명주기 작업(설치·활성화·비활성화·삭제·업데이트)은 CLI 에서 캐시 버전만 올리고 게시는 다음 웹 렌더에 위임한다. 그래서 그 직후 첫 페이지 로드 1회는 API URL 로 나간다.

이 창의 비용은 속도만이 아니다. general.asset_url_mode 가 extensionless 인 서버에서는 자산 URL 이 쿼리 형태(?file=)가 되는데, CSS 안의 상대 경로 url() 은 그 형태에서 해석되지 않는다. 실측(관리자 대시보드):

자산 게시본 사용 자가 치유 창 (API 폴백)
아이콘 폰트 (CSS 에 인라인) 정상 정상 — 하위 파일 참조가 없다
본문 글꼴 (pretendard-variable.css → woff2/…) 정상 404 — FontFace status: "error", 시스템 글꼴로 대체

기능이 사라지지는 않는다(조작 수단인 아이콘은 인라인이라 영향 없음). 화면이 한 번 다른 글꼴로 보이고, 그 뒤 게시가 끝나면 정상으로 돌아온다.

이 창을 없애려면 수명주기 작업 뒤에 php artisan ext-static:publish 를 명시적으로 실행한다. 다만 그 명령을 웹 계정이 아닌 사용자로 돌리면 게시 산출물 소유권이 어긋날 수 있으므로, 위 「소유권」 절의 주의사항을 함께 본다.

  • 동시성: 게시는 캐시 락으로 단일 실행. manifest 존재 시 skip(멱등).
  • 실패 정책: 쓰기 실패는 로그만 남기고 tmp 정리 — 사이트는 API 폴백으로 정상 ("정적 fast path 미적용" 상태이지 장애가 아니다). 다음 렌더의 자가 치유가 재시도한다.
  • routes 병합이 열화 상태(확장 업데이트 진행 중 등)면 그 산출물은 게시하지 않는다 — 정적 파일은 스스로 회복되지 않으므로 열화가 다음 bump 까지 박제된다. 같은 규율이 폴백 API 의 HTTP 캐시 헤더에도 적용된다 — 열화 응답에는 public, max-age 를 부여하지 않는다 (브라우저/CDN 박제 방지).

5. 운영자 kill-switch

.env 에 G7_STATIC_CACHE=false 를 두면 게시가 중단되고 blade 가 정적 URL 을 방출하지 않아 전면 API 폴백(종전 동작)으로 돌아간다. 기본값은 활성이며 관리자 UI 는 두지 않는다 (내부 인프라 — 파일시스템 상태를 화면 토글이 즉시 반영한다고 오해할 소지가 있고, 문제 상황의 조치는 서버 접근을 전제한다).

6. 권한과 소유권 (설치/코어 업데이트)

게시물은 런타임이 public/build/ext 에 쓰는 최초의 산출물이다 — 종전의 public/build/core 는 빌드 도구가 배포 시점에 만들고 런타임은 읽기만 했다. 따라서 다음이 성립해야 정적 fast path 가 동작한다 (미성립 시에도 사이트는 전면 API 폴백으로 정상 — 경고 로그만 남는다).

  • 웹 프로세스 계정의 public/build 쓰기 권한: 게시의 정상 주체는 terminating 훅/자가 치유 = php-fpm(웹 계정)이다. 시스템 요구사항의 그룹 공유(방식 A) 구성이라면 public/build 도 같은 원칙(g+w + 공용 그룹)을 적용한다. 게시 코드가 디렉토리를 0775 로 생성하므로 umask 동조 환경에서 그룹 쓰기가 유지된다.
  • 설치 시: 설치기 완료 단계가 ext-static:publish --force 를 best-effort 로 실행한다 — 설치기는 웹 요청 컨텍스트에서 돌므로 산출물은 웹 계정 소유가 되어 이후 재게시와 자연 정합한다. 실패해도 설치는 완료되고 첫 방문의 자가 치유가 재시도한다.
  • sudo 코어 업데이트 시: 업데이트가 root 로 실행되면 종료 시점(소유권 복원 app.update.restore_ownership 이후)에 도는 terminating 게시가 root 소유 산출물을 만들 수 있다. 산출물은 게시 트리만이 아니다 — 게시는 캐시 락(storage/framework/cache/data/xx 샤드 디렉토리 신설)과 병합 번들 빌드(storage/app/ext-bundles)까지 간접적으로 쓴다. root 소유 캐시 샤드가 남으면 이후 웹 프로세스의 캐시 쓰기가 그 샤드에 해시되는 순간 Permission denied 로 죽어 전면 500 이 된다(7.0.10 업그레이드 실사례). 따라서 root 프로세스에서는 terminating 자동 게시를 예약하지 않고 다음 웹 렌더의 자가 치유(웹 계정)에 위임한다. 명시적 ext-static:publish 는 root 로도 실행 가능하며 게시 트리(public/build/ext)는 부모 소유권 상속으로 정상화되지만, storage 측 간접 산출물의 소유권은 운영자 책임이다 — sudo 로 수동 게시했다면 storage/bootstrap/cache 의 그룹 쓰기(chgrp -R {웹그룹} + chmod -R g+w)를 확인한다.
  • 코어 업데이트의 orphan 정리: 게시본은 릴리즈 소스에 없는 로컬 파생물이므로 app.update.excludes 에 build/ext 로 등록되어 있다 — --prune 업데이트가 orphan 으로 삭제하지 않고, 백업 대상에서도 제외된다.

권한 문제로 게시가 계속 실패하는 환경(공유 호스팅 등)에서는 G7_STATIC_CACHE=false 로 기능을 끄면 경고 로그도 남지 않는다.

7. 다중 웹서버 제약

게시물은 로컬 디스크 파생물이다 (확장 병합 번들 캐시와 동일한 제약). 다중 웹서버 스케일아웃 구성에서는 서버마다 게시가 필요하다 — terminating 트리거는 요청을 받은 서버에서만 실행되므로, 나머지 서버는 각자의 blade 자가 치유가 첫 렌더에서 보충한다. 공유 스토리지에 public/build/ext 를 올리는 구성은 rename 원자성이 보장되는 파일시스템에서만 사용한다.

8. nginx 권장 설정 (선택)

버전 디렉토리라 내용이 불변이므로, 서버 기본 재검증(ETag/Last-Modified)만으로도 충분하다. 다만 압축은 서버 몫이다 — 정적 서빙은 Laravel 의 응답 압축(GzipEncodeResponse)을 우회하므로, 서버에 gzip 설정이 없으면 종전 API 대비 전송량이 회귀한다 (실측: 병합 lang JSON 약 525KB 비압축). 권장:

location ^~ /build/ext/ {
    expires max;
    add_header Cache-Control "public, immutable";
    access_log off;
    gzip on;
    gzip_types application/json application/javascript text/css image/svg+xml;
    gzip_min_length 1024;
}

Apache 는 게시 트리에 포함된 .htaccess 가 같은 캐시 헤더와 mod_deflate 압축을 함께 선언한다 (모듈 미탑재 시 자동 무시).

9. 관련 규율

  • 정적 우선 URL 규칙은 서버측 AssetUrl 과 프론트측 assetUrl.ts(+ 자가 복구 파샬 역변환)가 항상 쌍으로 수정되어야 한다 — 한쪽만 바꾸면 그 자산만 404 가 된다.
  • 게시 산출물(public/build/ext/)은 git 미추적·release 페이로드 제외다. public/build/core/ 는 계속 추적한다 (배포 산출물) — 혼동 금지.
  • 루트 npm run build(기본 vite 앱 빌드)는 public/build 를 비운다(emptyOutDir) — core 와 ext 가 함께 지워진다. core 는 core:build --production 으로 재생성해야 하고(공개 #70 의 실제 원인), ext 는 다음 프로덕션 렌더의 자가 치유가 재게시한다.
  • 확장 병합 번들의 생성 규율은 module-assets.md "서버측 번들 병합" 이 소유한다. 본 게시는 그 산출물의 사본만 만든다.