Files
Gnuboard7/app/Support/RouteCacheHelper.php
HeuJung 67cae00dd8 fix(core): 코어 업데이트 spawn 자식의 config 캐시 오판 3건 수정 + 컨테이너 보존 래퍼 전수
7.0.9 클린 설치본에서 7.0.10 으로 올리자 업그레이드 스텝 단계가 "부모 메모리 stale" 로 중단됐다.
스텝 자식은 부모가 비우지 않은 이전 버전 config 캐시로 부팅해 env APP_VERSION 오버라이드와
신버전 config/app.php 의 update 목록을 모두 보지 못한다. 이전 릴리즈에서는 자식 진입부의
config:cache 가 전역 Container 를 일회용 앱으로 바꿔 놓는 부수효과로 가드가 우연히 침묵했고,
그 부수효과를 걷어낸 withPreservedContainer 가 잠복 결함을 드러냈다.

- stale 가드는 CoreVersionChecker::getCoreVersion(env 우선) 으로 판독
- 부모는 spawn 직전 config 캐시를 비우고, 자식은 캐시 파일이 있으면 디스크 config 를 직접 읽는다
 (7.0.10 이 추가한 public/build/ext 쓰기 권한 정상화 누락 차단)
- route:cache 도 같은 부수효과를 남기므로 RouteCacheHelper·optimizeSystem 을 보존 래퍼로 감쌌다
 (단독 스텝 재실행·시스템 최적화 뒤 정적 재게시 예약 소실)
- 과거 업그레이드 사고 14부류를 7.0.10 변경과 대조 — 재발 조건은 위 3건뿐
- audit 룰 fresh-app-artisan-call-outside-cache-helper + 픽스처, 회귀 테스트 6건(수정 전 red)
2026-09-06 18:41:53 +09:00

95 lines
4.5 KiB
PHP

<?php
namespace App\Support;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
/**
* 라우트 캐시(bootstrap/cache/routes-v*.php) 재빌드 헬퍼.
*
* 라우트의 소스(코어 routes/*.php, 활성 모듈·플러그인의 routes/api.php)를 변경하는
* 모든 라이프사이클 지점이 이 헬퍼를 호출해 "변경 반영 + 캐시 재최적화"를 일관되게
* 수행한다. 산발적으로 route:clear 를 각 지점에 뿌리면 누락이 생기고(확장 설치·활성화는
* clear 조차 하지 않았다), 반대로 clear 만 하면 한 번 비워진 캐시가 재생성되지 않아
* 성능 이점이 영구히 사라진다 — 이를 단일 SSoT 로 막는다.
*
* 라우트 캐시는 훅 캐시와 성질이 다르다. 훅 캐시는 항목이 없으면 스캔 폴백으로 동작해
* 낡아도 안전하지만, **라우트 캐시에는 폴백이 없다** — 캐시에 없는 라우트는 그대로 404 다.
* 오류도 경고도 남지 않고 그 엔드포인트만 조용히 사라지므로, 확장을 설치한 쪽에서는
* "내 코드는 분명히 있는데 라우트가 없다" 는 형태로만 관측된다.
*
* 정책: 환경 무관 항상 재생성 (`ConfigCacheHelper` 와 동일). route:cache 는 그 자체로
* 부팅 비용을 절감하고, 라우트는 배포 단위로 고정되므로 항상 켜두는 것이 이득이다.
* local 개발 시 라우트 파일 수정이 즉시 반영되지 않는 점은 개발자가 `php artisan route:clear`
* 로 대응하는 개발자 책임 영역이며, 확장 라우트는 `{module|plugin}:update` 가 이 헬퍼를
* 경유하므로 그 경로에서는 자동 반영된다.
*/
class RouteCacheHelper
{
/**
* 라우트 캐시를 비우고 즉시 재생성합니다.
*
* `route:cache` 는 내부적으로 기존 캐시를 지운 뒤 새 애플리케이션 인스턴스를 부팅해
* 라우트를 수집하므로, 방금 설치·활성화한 확장의 라우트도 함께 잡힌다.
*
* 설치 미완료(installer 실행 전) 환경에서는 불완전한 부팅을 캐시에 박제할 수 있으므로
* clear 만 수행한다. 테스트 환경은 캐시 생성이 격리를 깨므로 동일하게 스킵한다.
*
* 재생성 실패(직렬화 불가한 클로저 라우트, 권한/디스크 등)는 치명적이지 않다 —
* clear 로 stale 캐시는 이미 제거되어 다음 요청이 fresh 라우트로 안전하게 부팅된다.
* 비어 있으면 느릴 뿐 정확하지만, 낡으면 방금 설치한 확장이 통째로 동작하지 않는다.
*
* @return void
*/
public static function rebuild(): void
{
// 항상 stale 캐시부터 제거 (변경 반영 보장).
Artisan::call('route:clear');
if (app()->environment('testing') || ! self::isInstalled()) {
return;
}
try {
// `route:cache` 도 새 Application 을 부팅하며 전역 Container 인스턴스를 그 일회용 앱으로
// 바꿔 놓는다(`config:cache` 와 동일). 되돌리지 않으면 이 뒤에 등록되는
// `app()->terminating()` 이 종료되지 않는 앱에 걸려 실행되지 않는다 — 단독 실행
// `core:execute-upgrade-steps` 가 라우트 재생성 뒤에 확장 캐시 버전을 올리므로 그
// 재게시 예약이 그렇게 사라졌다 (2026-09-06 전수조사).
ConfigCacheHelper::withPreservedContainer(static fn () => Artisan::call('route:cache'));
} catch (\Throwable $e) {
Log::warning('라우트 캐시 재생성 실패 (route:clear 로 stale 은 제거됨 — 다음 요청은 비캐시 부팅)', [
'error' => $e->getMessage(),
]);
}
}
/**
* 라우트 캐시만 제거합니다 (재생성 없음).
*
* 재생성이 부적절한 특수 경로(예: 코어 업데이트 흐름 중간 — vendor 교체 중 상태를
* 캐시에 구울 수 없다)를 위한 보조 진입점.
*
* @return void
*/
public static function clear(): void
{
Artisan::call('route:clear');
}
/**
* G7 설치 완료 여부를 확인합니다.
*
* @return bool 설치 완료 시 true
*/
private static function isInstalled(): bool
{
if (config('app.installer_completed')) {
return true;
}
return file_exists(storage_path('app/g7_installed'));
}
}