Files
Gnuboard7/docs/backend/static-asset-publishing.md
T
HeuJung de18159986 fix(core,admin_basic): 정적 게시 재게시 누락 3건 수정 + 관리자 수동 복구 UI + sudo core:update 소유권 정합
- 템플릿 update 는 레이아웃 변경 여부와 무관하게 캐시 버전을 올리고(실패 복원 뒤에도), 자산 주소 방식 전환 3경로도 bump 한다
- config:cache 가 컨테이너 인스턴스를 덮어 이후 terminating 재게시가 사라지던 결함을 복원 헬퍼로 차단
- custom/ 변경 감지와 게시가 같은 열거자(재귀·크기 포함)를 쓰고, 서명은 호스트별로 저장
- 캐시 버전·서명 키를 만료시키지 않는다(기본 TTL 24h 로 매일 전체 재생성되던 문제)
- 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 카드: 상태 조회 + 지금 다시 만들기(확인 모달), 대시보드 알림 버튼 연결, CLI 와 같은 statusReport 소비
- sudo core:update 경로의 root 소유 잔존(설정 디렉토리·임시 폴더·로그·업그레이드 마이그레이션 산출물) 상속·정합화, ext-static 명령 root 경고와 sudo -u 힌트
- 안내·문서·트러블슈팅 4건·audit 룰 ext-cache-version-raw-read·ja 언어팩 동기
2026-09-06 15:22:52 +09:00

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).

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

  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 로 치환되어 나가므로, 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 "서버측 번들 병합" 이 소유한다. 본 게시는 그 산출물의 사본만 만든다.