공개 번들 엔드포인트가 캐시 파일이 있어도 요청마다 다시 병합했다. 캐시 키는 (type, kind, version) 인자만으로 계산되는데 "병합 결과가 비면 파일을 만들지 않는다" 는 규칙을 먼저 두느라 빌드를 앞세운 것이 원인이다. 그래서 캐시 적중 경로에도 활성 확장 열거와 산출물 전량 읽기가 붙었고, 원본이 소실되면 멀쩡한 캐시를 두고 빈 경로가 반환되어 503 이 됐다. 응답은 정상 200 이라 타이밍 말고는 드러나는 증상이 없다. 프로덕션에서 캐시 존재를 병합보다 먼저 확인하고, 캐시 미스는 같은 키의 잠금으로 1회 빌드에 수렴시킨 뒤 잠금 뒤 캐시를 재확인한다. 잠금 대기 초과·저장소 장애는 실패로 바꾸지 않고 각자 빌드로 폴백한다. 병합 결과가 비어도 선언 산출물이 전부 존재하거나 선언이 0이면 0바이트 캐시를 만들어 정적 게시까지 보낸다. 만들지 않으면 그 구성의 자산 URL 이 API 로 폴백해 방문자의 모든 페이지 로드가 PHP 를 거치고, 그 요청마다 컨트롤러가 열거를 세 번 반복한다. 캐시하지 않는 것은 산출물 소실(503 판정 보존)과 디스크 쓰기 실패뿐이라 응답 계약은 바뀌지 않는다 — 컨트롤러와 트레이트는 손대지 않았다. 같은 결의 결함이 검색봇 캐시에도 있었다. 봇 판정은 User-Agent 문자열뿐인데 캐시 키가 경로 + 전체 쿼리여서, 물음표 뒤 값만 바꾼 반복 요청이 매번 미스가 되고 그 미스마다 레이아웃 병합·표현식 평가·자기 API 루프백 호출이 일어나며 결과가 무제한 저장됐다. 키를 정규화하고(시스템 파라미터 제외·개수/길이 상한), IP 당 분당 미스 렌더 예산과 저장 규모 상한을 뒀다. 초과분은 차단이 아니라 일반 SPA 응답을 받는다 — 봇에게 오류를 주면 그 URL 이 색인에서 빠지기 때문이다. 저장 상한은 만료 항목을 걷어낸 뒤 판정한다. 인덱스는 페이지보다 오래 살아 (30일 vs 기본 2시간) 정리 없이 세면 상한이 "지금 저장된 양"이 아니라 "과거에 저장한 적이 있는 양"을 재게 되어 일방향 래치가 된다. 정리는 인덱스 전체를 훑으므로 최소 60초 간격으로만 수행한다. put 과 putWithLayout 은 같은 자원을 쓰므로 단일 저장 경로로 합쳤다 — 한쪽만 상한 밖이면 그쪽이 우회로가 되고, 인터페이스는 확장에 열려 있어 "지금 호출부가 없다" 는 방어가 되지 않는다. 캐시 적중·미적중을 기록하는 호출처가 없어 관리자 SEO 통계와 seo:stats 가 항상 0 이었던 것도 함께 고쳤다. 기록 자체에도 IP 당 상한을 둬 통계 테이블이 새 증식 축이 되지 않게 했다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2191)
29 KiB
부트스트랩 리소스 정적 게시 (Static Asset Publishing)
초기 부트스트랩 리소스(다국어 병합·컴포넌트 정의·라우트 정보·확장 병합 번들·템플릿 dist 에셋)를 캐시 버전 디렉토리 기반의 실파일로 public/ 하위에 게시(bake)하고, 웹서버가 rewrite 전에 직접 서빙하는 fast path 를 다룬다. (공개 #122)
TL;DR (5초 요약)
1. 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트·자산 URL 방식 변경·운영자 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).
두 위험은 다음으로 해소된다.
- 재게시 트리거 누락 → 조용한 stale: 모든 수명주기 이벤트(확장 설치/활성/비활성/삭제/업데이트, 언어팩 변경, 레이아웃 편집기 저장, 커스텀 번역, CLI 빌드/캐시클리어)는
ClearsTemplateCaches::incrementExtensionCacheVersion()을 경유한다. 게시 예약을 그 단일 지점 내부에 심어 누락이 구조적으로 불가능하다. 추가로 blade 렌더가 현재 버전의 미게시를 감지하면 자가 치유(terminating 게시 예약)한다. - 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 ← 확장 병합 번들 사본 (빈 번들도 0바이트로 게시)
└── 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 로 치환되어 나가므로, url('./font.woff2') 같은 참조도 그대로 해석됩니다 —
치환이 없으면 쿼리 형태(?file=)에서 기준 URL 이 /api/{타입}/assets/ 로 잡혀 엉뚱한 주소를
가리킵니다. 치환은 url() 과 @import "…" 를 대상으로 하며, 절대 URL·프로토콜 상대·data:
같은 스킴 참조는 원문 그대로 둡니다.
치환은 CSS 안의 참조만 해결합니다. 레이아웃·컴포넌트가 자산 주소를 직접 조립하는 자리는 여전히 자산 URL 헬퍼를 거쳐야 하며, 정적 확장자 URL 은 public 아래 실제 파일일 때만 200 이 되므로 그 형태를 하드코딩하지 않습니다.
갱신 축도 확장 자산과 같습니다. 운영자가 파일을 고치면 뷰 컴포저가 파일 서명 변화를 감지해 확장 캐시 버전을 올리고, 그 단일 지점이 재게시까지 예약합니다. 그 요청은 서술자를 새 버전으로 다시 해석하므로 고친 내용이 그 화면부터 반영됩니다(게시본이 아직 없는 동안에는 API 경로로 떨어지고, 그 응답이 디스크 최신 내용을 줍니다).
감지 범위는 게시 범위와 같은 열거자가 정합니다 — custom/ 아래를 재귀로 훑어 허용 확장자
전부(CSS·JS 뿐 아니라 글꼴·이미지)의 경로·수정 시각·크기를 서명 재료로 씁니다. 그래서 CSS 는
그대로 두고 url('./fonts/x.woff2') 가 가리키는 글꼴만 교체해도 다시 게시됩니다. 크기를 함께
보는 이유는 수정 시각 해상도 안의 재작성과 시각을 보존하는 복사(rsync -t)를 잡기 위해서입니다.
로드 목록(최상위 CSS·JS 만 페이지에 싣는 규약)은 그대로입니다 — 글꼴·이미지는 CSS 가 참조하는
대상이지 그 자체로 로드할 것이 아닙니다.
모듈·플러그인은 활성 확장의 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회 등록 |
게시 입력이 되는 설정값 변경도 수명주기와 같은 단일 지점을 탄다. general.asset_url_mode(자산 URL 방식)는 병합 번들 CSS 안의 url() 형태로 본문에 구워지므로, 관리자 화면 저장·단건 저장·설정 복원·g7:asset-url-mode 어느 경로로 바뀌어도 확장 캐시 버전이 올라 번들과 게시본이 다시 만들어진다. 템플릿 업데이트는 레이아웃이 바뀌지 않아도(다국어·라우트·컴포넌트·dist 만 바뀐 릴리스) 모듈·플러그인 업데이트와 같이 무조건 버전을 올린다.
버전은 만료되지 않는다
확장 캐시 버전 키는 영구(10년) TTL 로 저장된다. 재게시는 수명주기 이벤트·설정 변경·custom 파일 변경·관리자 수동 복구·php artisan cache:clear(키 소실 → 재생성) 에서만 일어난다. 종전에는 키가 기본 TTL(24시간)로 만료되어 다음 방문마다 새 버전이 만들어졌고, 그것은 정적 파일 전체(실측 715파일·40MB) 재생성과 전 방문자의 자산 URL 변경이었다 — 안전망이 아니라 우발이었다.
버전이 만료되지 않으므로 재게시 트리거가 하나라도 빠지면 게시본은 무기한 stale 이 된다. 그 안전망이 §5-1 의 관리자 수동 복구다. .env 전용 값(APP_FALLBACK_LOCALE 등)을 바꿨을 때도 같은 통로로 다시 만든다.
자가 치유 창의 실제 비용
확장 수명주기 작업(설치·활성화·비활성화·삭제·업데이트)은 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 를 웹 계정으로(sudo -u {웹계정}) 실행하거나, 관리자 > 환경설정 > 일반 의 지금 다시 만들기를 누른다. 그 명령을 웹 계정이 아닌 사용자로 돌리면 게시 산출물 소유권이 어긋난다 — §6 참조.
- 동시성: 게시는 캐시 락으로 단일 실행. manifest 존재 시 skip(멱등).
- 실패 정책: 쓰기 실패는 로그만 남기고 tmp 정리 — 사이트는 API 폴백으로 정상 ("정적 fast path 미적용" 상태이지 장애가 아니다). 다음 렌더의 자가 치유가 재시도한다.
게시는 갱신 → 생성 → 완전성 확인 이 한 묶음이다
"캐시 버전이 올랐다" 와 "그 버전의 산출물이 온전히 존재한다" 는 다른 사실이다. 둘 사이가 벌어지면 포인터는 미래를 가리키는데 파일은 없거나 깨진 상태가 되고, 그 상태는 예외도 로그도 남기지 않는다. 그래서 게시는 다음 4단계를 한 묶음으로 수행한다.
| 단계 | 수행 | 실패 시 |
|---|---|---|
| ① 프리플라이트 | 게시 루트를 만들고 쓸 수 있는지 먼저 확인 | 전 로케일 병합·전 템플릿 복사를 시작하기 전에 중단 + 실패 마커 기록 |
| ② staging 쓰기 | {v}.tmp/ 에 전부 기록 |
tmp 정리 후 폴백 |
| ③ 완전성 확인 | 파일마다 기록된 바이트 수를 대조 (복사는 원본과 크기 대조) | 예외 → manifest 미기록 → 그 버전은 영원히 "미게시" |
| ④ 원자 스왑 | 기존 버전을 .old 로 비켜낸 뒤 rename → manifest 기록 → .old 삭제 |
비켜낸 기존 버전을 제자리로 되돌린다 |
③ 이 없으면 File::put() 의 반환값이 짧은 int 인 경우(디스크 풀·quota)를 통과시킨다 — === false 검사만으로는 잡히지 않고, 절단된 JSON 을 웹서버가 정상 200 으로 서빙한다. 프론트의 fetchStaticFirst 도 같은 이유로 본문이 JSON 으로 파싱되는지까지 확인한다 (양쪽 다 있어야 3층 폴백에 구멍이 없다).
④ 에서 기존 디렉토리를 먼저 지우지 않는 이유: 삭제와 rename 사이의 창에서 이미 배달된 HTML 이 참조하는 CSS/JS/폰트가 전부 404 가 되고, 폰트에는 복구기가 없다.
.tmp 와 .old 는 GC 대상이되 10분 나이 가드를 받는다. 게시 락은 버전별이라 서로 다른 버전의 게시가 동시에 진행될 수 있고, 나이를 보지 않으면 그 순간 살아 있는 다른 게시의 작업 디렉토리를 파괴한다.
④ 의 디렉토리 rename 은 유한 재시도를 거친 뒤에야 실패로 본다. 갓 쓰여진 파일이 든 디렉토리의 rename 은 목적지가 비어 있는데도 첫 시도가 거부될 수 있고, 상태를 바꾸지 않고 그대로 다시 호출하면 성공한다(실측 4표본: 즉시 1건 · 200ms 후 3건). 재시도가 없으면 그 한 번이 그대로 게시 실패가 되어, 사이트는 API 폴백으로 멀쩡한데 실패 마커와 대시보드 알림만 올라간다.
이 재시도는 OS 로 분기하지 않는다. 거부는 파일시스템·스토리지 계층(네트워크 마운트, 스냅샷, 백신·인덱서 등)이면 어디서든 성립하는 조건이고, 특정 플랫폼에서만 재시도하면 그 밖에서 같은 실패가 조용히 남는다. 재시도가 불필요한 환경에서는 첫 시도가 성공하므로 비용이 0 이다. 실패 계약은 종전 그대로다 — 끝내 실패하면 .old 롤백과 예외가 그대로 수행된다.
실패는 억제되고 기록된다
쓰기 불가 환경에서는 게시가 매 요청 실패한다. 그 전까지 전 로케일 lang 병합과 전 템플릿 dist 복사를 이미 다 헛돈 뒤이고, 사이트는 폴백으로 살아 있어 아무도 눈치채지 못한다. 그래서 실패는 캐시 키 ext.static.publish_failure 에 {version, at, reason, count} 로 기록되고, 그 마커가 신선한 동안(TTL 300초)에는 게시를 재예약하지 않는다.
마커는 진단 표면의 입력이기도 하다 — ext-static:status 커맨드와 관리자 대시보드 알림이 이 마커를 읽는다. 사유는 셋으로 갈린다: parent_not_writable(권한) / write_failed(디스크) / lock_unavailable(캐시). 조치할 곳이 다르므로 뭉뚱그리지 않는다.
마커 자체를 쓰지 못하는 환경(캐시 저장소 불능)에서는 백오프가 걸리지 않는다 — best-effort 다. 그 경우에도 프로세스 안의 예약 가드는 그대로 유효해 한 요청 안에서 반복되지는 않는다.
- routes 병합이 열화 상태(확장 업데이트 진행 중 등)면 그 산출물은 게시하지 않는다 — 정적 파일은 스스로 회복되지 않으므로 열화가 다음 bump 까지 박제된다. 같은 규율이 폴백 API 의 HTTP 캐시 헤더에도 적용된다 — 열화 응답에는
public, max-age를 부여하지 않는다 (브라우저/CDN 박제 방지).
5-1. 관리자 화면에서의 수동 복구
관리자 > 환경설정 > 일반 의 「초기 화면 정적 파일」 카드가 게시 상태를 보여 주고, 즉시 다시 만들 수 있게 한다. 상태 판정은 서비스 한 곳(ExtensionStaticCacheService::statusReport())이 소유하며 CLI(ext-static:status)·API·화면이 같은 값을 소비한다.
| 표시 | 뜻 |
|---|---|
| 상태 배지 | 게시됨 / 미게시 / 비활성(kill-switch 꺼짐) / 개발 모드(비프로덕션) |
| 현재 버전 · 파일 수 · 마지막 게시 시각 | 현재 캐시 버전과 그 버전의 manifest 내용 |
| 실행 계정 | 웹 프로세스 계정 — 게시 산출물의 소유자가 될 계정 |
| 최근 실패 | 실패 마커(사유·연속 횟수). 사유별 조치 안내가 붙는다 |
[지금 다시 만들기] → 확인 모달 → POST /api/admin/settings/static-cache/republish. 서버는 캐시 버전을 올리고 현재 버전을 강제 재게시한다(같은 초에 다시 눌러도 내용을 다시 만든다). 게시 실패는 요청 실패가 아니라 진단 결과라 HTTP 200 + republished=false 로 돌아오고, 카드가 재조회되어 실패 사유를 보인다. 게시가 쓰이지 않는 환경(kill-switch 꺼짐·비프로덕션)에서는 버튼이 비활성이고 서버도 버전을 올리지 않는다. 권한은 조회 core.settings.read, 재게시 core.settings.update.
같은 엔드포인트가 대시보드의 「초기 화면 파일 생성 실패」 알림에도 [다시 만들기] 버튼으로 붙는다 — 운영자가 결함을 처음 만나는 곳에서 복구가 끝나도록.
715파일·40MB 복사와 번들 재병합이 웹 요청 안에서 돈다(번들은 캐시가 있으면 재병합하지 않는다). 서버는 게시 락 TTL(300초)만큼 실행 시간을 확보하고 클라이언트가 끊어도 게시를 끝내지만, FPM request_terminate_timeout·nginx fastcgi_read_timeout 이 그보다 짧은 서버에서는 응답이 먼저 끊길 수 있다. 그 경우에도 게시는 계속되므로 카드를 다시 열어 결과를 확인한다.
이 통로가 있으므로 kill-switch 를 관리자 UI 로 두지 않는 방침(§5)은 그대로다 — 복구는 화면에서, 끄기는 서버에서.
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 로 실행되면 게시 산출물이 root 소유가 될 수 있다. 산출물은 게시 트리만이 아니다 — 게시는 캐시 락(
storage/framework/cache/data/xx샤드 디렉토리 신설)과 병합 번들 빌드(storage/app/ext-bundles)까지 간접적으로 쓴다. root 소유 캐시 샤드가 남으면 이후 웹 프로세스의 캐시 쓰기가 그 샤드에 해시되는 순간 Permission denied 로 죽어 전면 500 이 된다(7.0.10 업그레이드 실사례). 따라서 root 프로세스에서는 terminating 자동 게시를 예약하지 않고 다음 웹 렌더의 자가 치유(웹 계정)에 위임한다. 명시적ext-static:publish도 웹 계정으로 실행한다(sudo -u {웹계정} php artisan ext-static:publish) — root 로 실행하면 명령이 경고와 함께 웹 계정 실행 형태를 안내하고 계속 진행한다(중단하지는 않는다). 캐시 샤드 소유권은 이 명령에만 있는 문제가 아니라 캐시를 건드리는 모든 artisan 명령에 공통이므로 INSTALL.md 「명령줄·cron 실행 계정」 규율을 따른다.ext-static:status도 root 로 실행 중이면 같은 경고를 낸다. - 코어 업데이트의 orphan 정리: 게시본은 릴리즈 소스에 없는 로컬 파생물이므로
app.update.excludes에build/ext로 등록되어 있다 —--prune업데이트가 orphan 으로 삭제하지 않고, 백업 대상에서도 제외된다.
CLI 계정과 웹 계정이 다를 때
제보된 실제 상황은 root 가 아니었다 — 비-root CLI 계정(deploy 등)이 웹 계정과 다른 구성이다. CLI 가 최초 게시를 하면 트리가 0755 deploy:deploy 로 굳고, 이후 웹(php-fpm)의 재게시가 영구히 실패한다. 사이트는 API 폴백으로 살아 있어 아무도 모른 채 정적 fast path 만 꺼진다.
그래서 게시는 소유권 정상화를 두 갈래로 수행한다.
- root 갈래: 부모 기준으로 소유권을 되돌린다 (sudo CLI 게시 대응).
- 항상 실행 갈래: 부모 그룹을 상속(
chgrp)시키고 트리에g+w를 승격한다. 비-root 에서chown은 실패해도 무해하고,chgrp(자기가 속한 그룹으로)와chmod g+w(자기 소유 파일)는 성립한다.
디렉토리는 ensureDirectoryExists(..., 0775) 직후 명시 chmod 로 굳힌다 — mode 인자는 umask 로 깎이므로(umask 022 → 0755) 그것만으로는 그룹 쓰기가 남지 않는다.
이 방식의 한계: CLI 계정과 웹 계정이 그룹을 공유할 때만 성립한다. 공유하지 않는 환경에서는 프리플라이트가 parent_not_writable 로 실패 마커를 남기고, ext-static:status 와 관리자 대시보드 알림이 그 사실을 운영자에게 전달한다. 그 경우의 조치는 운영자가 public/build 의 그룹·권한을 맞추는 것이다.
상태 점검
php artisan ext-static:status
실행 계정 / 현재 버전 / 게시 여부 / manifest 파일 수 / 트리 쓰기 가능성 / 최근 실패 마커(사유별 조치 힌트 포함) / 잔존 버전 목록을 출력한다. 이상이 있으면 비-0 으로 종료한다 — 이는 "커맨드 실행 실패" 가 아니라 조치할 대상이 있다는 신호다. root 로 실행 중이면 웹 계정 실행 형태(sudo -u {웹계정} php artisan ext-static:publish)를 함께 안내한다. 같은 판정을 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 카드에서도 볼 수 있다(§5-1).
권한 문제로 게시가 계속 실패하는 환경(공유 호스팅 등)에서는 G7_STATIC_CACHE=false 로 기능을 끄면 경고 로그도 남지 않는다.
7. 다중 웹서버 제약
게시물은 로컬 디스크 파생물이다 (확장 병합 번들 캐시와 동일한 제약). 다중 웹서버 스케일아웃 구성에서는 서버마다 게시가 필요하다 — terminating 트리거는 요청을 받은 서버에서만 실행되므로, 나머지 서버는 각자의 blade 자가 치유가 첫 렌더에서 보충한다. 공유 스토리지에 public/build/ext 를 올리는 구성은 rename 원자성이 보장되는 파일시스템에서만 사용한다.
custom 파일 변경 서명은 서버(호스트명)별로 기억한다. 캐시 저장소를 여러 서버가 공유하면 같은 파일이라도 서버마다 배포 시각(수정 시각)이 달라, 한 자리를 서버들이 번갈아 덮어쓰며 요청마다 "변경" 으로 읽혀 전체 재게시가 왕복한다. 서버별로 기억하면 각 서버는 자기 파일만 대조한다.
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를 비우지 않는다 — 루트 vite config 가build.emptyOutDir: false를 명시한다. 종전에는 기본값true로core(폴백 없는 코어 3번들)와ext(게시본)가 함께 지워졌고, 이미 배달된 HTML 의 immutable URL 은 재게시로도 복구되지 않았다(공개 #70 · #122). 잔존 구 해시 산출물은manifest.json이 선택하므로 참조되지 않는 사표이며, 산출물 정리 책임은 빌드 커맨드(PrunesBuildOutput)가 진다. 저장소의 모든 vite config 는 이 규약을 명시해야 한다 — 기본값 의존은 정적 검사가 차단한다. - 확장 병합 번들의 생성 규율은 module-assets.md "서버측 번들 병합" 이 소유한다. 본 게시는 그 산출물의 사본만 만든다.