fix(core): 라우트 캐시 상태에서 DevTools 덤프·브라우저 로그 수집 복구

route:cache 가 걸리면 RouteServiceProvider 가 캐시 로드로 분기해 라우트 파일이
실행되지 않는다. routes/devtools.php 는 전역 함수 5개를 정의하고 핸들러가 호출했고,
boost 의 browser-logs 는 프로바이더 boot 등록분이라 setCompiledRoutes 교체로
폐기됐다. 7.0.6 의 RouteCacheHelper 가 12개 라이프사이클 지점에서 캐시를 자동
생성하면서, 확장을 한 번이라도 건드린 사이트에서 항상 재현됐다.

로직을 오토로드되는 App\Support\DevTools\* 로 옮기고 browser-logs URI 를 G7 가
직접 소유해 캐시에 함께 구워지게 했다. 게이트가 참조하던 App\Models\Setting 은
저장소에 없는 유물이라 ConfigRepositoryInterface 로 교체했다.

같은 부류의 재발은 정적 검사(route-file-local-function)로 차단하고, 재현이
in-process 로 불가능하므로 서브프로세스 하네스로 회귀를 고정했다.
This commit is contained in:
HeuJung
2026-08-07 10:13:52 +09:00
parent e80be1d924
commit 4744e7bf85
16 changed files with 1352 additions and 458 deletions
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace App\Support\DevTools;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Psr\Log\LoggerInterface;
/**
* 브라우저 콘솔 로그 수집기 (`POST _boost/browser-logs`).
*
* 왜 G7 가 이 엔드포인트를 소유하는가:
* 원래는 `Laravel\Boost\BoostServiceProvider::boot()` 이 등록했다. 그런데 라우트 캐시가
* 걸리면 `Router::setCompiledRoutes()` 가 라우트 컬렉션을 **통째로 교체**하고, 이 교체는
* `booted` 콜백에서 일어난다. 즉 프로바이더 `boot()` 에서 등록한 라우트는 조건 충족 여부와
* 무관하게 전부 폐기된다 (프레임워크 자신의 `BroadcastManager::routes()` 는 `routesAreCached()`
* 가드를 갖고 있으나 boost 에는 없다).
*
* 그 결과 `routes/web.php` 의 SPA catch-all(GET 전용)만 남아 POST 가 405 로 튕기고,
* `BrowserLogger` 는 `Route::has('boost.browser-logs')` 가 false 라 폴백 리터럴 URL 을
* 스크립트에 굽는다. fetch 실패는 `.catch` 로 삼켜져 **오류 한 줄 없이 로그 수집만 사라진다.**
* G7 라우트 파일에서 직접 등록하면 캐시에 함께 구워져 이 경로가 살아남는다.
*
* 로그 채널 결정:
* `logging.channels.browser` 는 `config/logging.php` 에 없고 boost 가 런타임에 주입한다.
* 그 주입은 boost 의 `shouldRun()` 뒤에 있어 testing·비-local·`boost.enabled=false` 에서는
* 아예 일어나지 않는다. 따라서 채널 존재를 전제하지 않고 없으면 직접 만든다.
* 반대로 `config/logging.php` 에 채널을 정적 정의하지는 **않는다** — 정의하면 boost 의
* `registerBrowserLogger()` 가 `!== null` 조건에 걸려 조용히 스킵되므로 결합이 오히려 는다.
*/
class BrowserLogWriter
{
/**
* 브라우저가 전송한 콘솔 로그를 기록합니다.
*
* @param Request $request 로그 전송 요청
* @return JsonResponse 처리 결과
*/
public static function handle(Request $request): JsonResponse
{
$logs = $request->input('logs', []);
// 빈 요청이 browser.log 파일을 만들지 않도록 채널 해석 자체를 미룬다.
if (! is_array($logs) || $logs === []) {
return response()->json(['status' => 'logged']);
}
$logger = self::channel();
foreach ($logs as $log) {
if (! is_array($log)) {
continue;
}
$logger->log(
self::level($log['type'] ?? 'log'),
self::buildMessage($log['data'] ?? []),
[
'url' => $log['url'] ?? null,
'user_agent' => ($log['userAgent'] ?? null) ?: null,
'timestamp' => ($log['timestamp'] ?? null) ?: now()->toIso8601String(),
]
);
}
return response()->json(['status' => 'logged']);
}
/**
* 브라우저 로그 타입을 PSR-3 로그 레벨로 변환합니다.
*
* boost 원본은 `$log['type']` 이 없으면 `match` 에서 UnhandledMatchError 로 터진다.
* 여기서는 호출부에서 기본값을 채워 넘긴다.
*
* @param mixed $type 브라우저 로그 타입
* @return string PSR-3 로그 레벨
*/
private static function level(mixed $type): string
{
return match ($type) {
'warn' => 'warning',
'log', 'table' => 'debug',
'window_error', 'uncaught_error', 'unhandled_rejection' => 'error',
'error', 'info', 'notice', 'critical', 'alert', 'emergency' => $type,
default => 'debug',
};
}
/**
* 브라우저 로그 data 배열을 사람이 읽을 수 있는 한 줄 메시지로 만듭니다.
*
* 중첩 배열(예: `{"message":"...","reason":{"name":"TypeError",...}}`)도 재귀 평탄화한다.
*
* @param mixed $data 로그 data 페이로드
* @return string 조립된 메시지
*/
private static function buildMessage(mixed $data): string
{
if (! is_array($data)) {
return self::stringify($data);
}
return implode(' ', array_map(
static fn (mixed $value): string => is_array($value)
? self::buildMessage($value)
: self::stringify($value),
$data
));
}
/**
* 스칼라/객체 값을 문자열로 변환합니다.
*
* @param mixed $value 변환할 값
* @return string 문자열 표현
*/
private static function stringify(mixed $value): string
{
return match (true) {
is_string($value) => $value,
is_numeric($value) => (string) $value,
is_bool($value) => $value ? 'true' : 'false',
is_null($value) => 'null',
default => (string) json_encode($value),
};
}
/**
* 브라우저 로그 채널을 반환합니다 (미정의 시 즉석 생성).
*
* @return LoggerInterface 로그 채널
*/
private static function channel(): LoggerInterface
{
if (config('logging.channels.browser') !== null) {
return Log::channel('browser');
}
return Log::build([
'driver' => 'single',
'path' => storage_path('logs/browser.log'),
'level' => config('logging.level', 'debug'),
]);
}
}
+284
View File
@@ -0,0 +1,284 @@
<?php
namespace App\Support\DevTools;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
/**
* G7 DevTools 상태 덤프 기록기.
*
* 브라우저가 전송한 디버깅 데이터를 `storage/debug-dump/` 아래 섹션별 파일로 저장한다.
* 전송 모드는 세 가지이며 저장 결과(파일명·이력 규칙)는 모두 동일하다.
*
* - bulk 소용량: 모든 섹션이 단일 요청의 `sections` 배열에 담겨 온다
* - sectional 대용량: 섹션 → 청크 단위로 쪼개 여러 요청에 나눠 온다
* - legacy 하위 호환: 섹션명이 요청의 최상위 키로 온다
*
* 왜 라우트 파일이 아니라 클래스인가:
* 원래 이 로직은 `routes/devtools.php` 안의 전역 함수 5개였다. `route:cache` 가 걸리면
* `RouteServiceProvider::boot()` 이 `loadCachedRoutes()` 로 분기해 **라우트 파일 자체가
* 로드되지 않는다**. 클로저는 `SerializableClosure` 로 복원되지만 전역 함수는 오토로드
* 대상이 아니라 정의되지 않아, 핸들러가 호출하는 즉시 `Call to undefined function` 500 이
* 된다. 클래스로 옮겨야 오토로더가 캐시 여부와 무관하게 심볼을 찾아준다.
*
* 왜 `app/Http/Controllers/` 가 아닌가:
* 코어 컨트롤러는 API 레퍼런스 문서(`docs/backend/api/**`) 동반이 강제된다. 이 엔드포인트는
* 디버그 모드 전용 개발 도구라 공개 API 표면이 아니므로, 문서 의무가 붙는 위치를 피해
* `app/Support/` 하위 도메인 디렉토리에 둔다 (`ApiDoc/`, `Query/`, `Routing/` 과 동일 관례).
*
* 응답 스키마는 MCP 도구와 브라우저 클라이언트가 의존하므로 변경하지 않는다.
*/
class DebugDumpWriter
{
/**
* 섹션명 → 저장 파일명 매핑 (SSoT).
*
* 세 전송 모드가 모두 이 표 하나만 본다. 과거에는 같은 29행이 세 곳에 복제되어 있어
* 섹션 하나를 추가할 때 세 곳을 모두 고쳐야 했다.
*
* @var array<string, string>
*/
public const SECTION_FILES = [
'state' => 'state-latest.json',
'actions' => 'actions-latest.json',
'cache' => 'cache-latest.json',
'lifecycle' => 'lifecycle-latest.json',
'network' => 'network-latest.json',
'expressions' => 'expressions-latest.json',
'forms' => 'form-latest.json',
'performance' => 'performance-latest.json',
'conditionals' => 'conditionals-latest.json',
'dataSources' => 'datasources-latest.json',
'handlers' => 'handlers-latest.json',
'componentEvents' => 'component-events-latest.json',
'stateRendering' => 'state-rendering-latest.json',
'stateHierarchy' => 'state-hierarchy-latest.json',
'contextFlow' => 'context-flow-latest.json',
'styleValidation' => 'style-validation-latest.json',
'authDebug' => 'auth-debug-latest.json',
'logs' => 'logs-latest.json',
'layout' => 'layout-latest.json',
'changeDetection' => 'change-detection-latest.json',
'sequenceTracking' => 'sequence-latest.json',
'staleClosureTracking' => 'stale-closure-latest.json',
'cacheDecisionTracking' => 'cache-decisions-latest.json',
'dataPathTransformTracking' => 'data-path-transform-latest.json',
'nestedContextTracking' => 'nested-context-latest.json',
'formBindingValidationTracking' => 'form-binding-validation-latest.json',
'computedDependencyTracking' => 'computed-dependency-latest.json',
'modalStateScopeTracking' => 'modal-state-scope-latest.json',
'namedActionTracking' => 'named-action-tracking-latest.json',
];
/** 이력 파일(`state-{timestamp}.json`)을 남기는 섹션 */
private const HISTORY_SECTION = 'state';
/** 오래된 세션 디렉토리 정리 기준 (초) */
private const SESSION_TTL_SECONDS = 600;
/** JSON 저장 공통 플래그 */
private const JSON_FLAGS = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE;
/**
* 섹션별 청크 분할 전송을 처리합니다 (대용량 데이터).
*
* @param Request $request 덤프 요청
* @param string $debugDir 최종 저장 디렉토리
* @param string $sessionsDir 청크 임시 디렉토리
* @return JsonResponse 처리 결과
*/
public static function handleSectional(Request $request, string $debugDir, string $sessionsDir): JsonResponse
{
$sessionId = $request->input('sessionId');
$sectionName = $request->input('sectionName');
$chunkData = $request->input('chunkData');
$chunkIndex = (int) $request->input('chunkIndex', 0);
$totalChunks = (int) $request->input('totalChunks', 1);
$isLastChunk = $request->boolean('isLastChunk');
$isLastSection = $request->boolean('isLastSection');
$timestamp = $request->input('timestamp');
$saveHistory = $request->boolean('saveHistory');
// 세션 디렉토리 생성
$sessionDir = $sessionsDir.'/'.$sessionId;
if (! File::isDirectory($sessionDir)) {
File::makeDirectory($sessionDir, 0755, true);
}
// 청크를 임시 파일에 저장
File::put($sessionDir.'/'.$sectionName.'.chunk.'.$chunkIndex, $chunkData);
// 해당 섹션의 마지막 청크이면 모든 청크를 병합
if ($isLastChunk) {
$mergedData = '';
for ($i = 0; $i < $totalChunks; $i++) {
$chunkPath = $sessionDir.'/'.$sectionName.'.chunk.'.$i;
if (File::exists($chunkPath)) {
$mergedData .= File::get($chunkPath);
File::delete($chunkPath);
}
}
File::put($sessionDir.'/'.$sectionName.'.json', $mergedData);
}
// 전체 덤프의 마지막 섹션이면 최종 파일 생성 후 세션 정리
if ($isLastSection) {
self::mergeSectionsToFinalFiles($sessionDir, $debugDir, $saveHistory, $timestamp);
File::deleteDirectory($sessionDir);
self::cleanupOldSessions($sessionsDir);
}
return response()->json([
'status' => 'success',
'sessionId' => $sessionId,
'sectionName' => $sectionName,
'chunkIndex' => $chunkIndex,
'totalChunks' => $totalChunks,
'isLastSection' => $isLastSection,
]);
}
/**
* 일괄 전송을 처리합니다 (소용량 데이터).
*
* @param Request $request 덤프 요청
* @param string $debugDir 최종 저장 디렉토리
* @return JsonResponse 처리 결과
*/
public static function handleBulk(Request $request, string $debugDir): JsonResponse
{
$sections = $request->input('sections', []);
$saveHistory = $request->boolean('saveHistory');
$timestampStr = self::formatTimestamp($request->input('timestamp'));
$savedSections = [];
foreach ((array) $sections as $sectionName => $sectionData) {
if (! isset(self::SECTION_FILES[$sectionName])) {
continue;
}
$json = json_encode($sectionData, self::JSON_FLAGS);
File::put($debugDir.'/'.self::SECTION_FILES[$sectionName], $json);
$savedSections[] = $sectionName;
if ($saveHistory && $sectionName === self::HISTORY_SECTION) {
File::put($debugDir."/state-{$timestampStr}.json", $json);
}
}
return response()->json([
'status' => 'success',
'mode' => 'bulk',
'savedSections' => $savedSections,
'timestamp' => $timestampStr,
]);
}
/**
* 레거시 전체 전송을 처리합니다 (하위 호환성 — 섹션명이 요청 최상위 키).
*
* @param Request $request 덤프 요청
* @param string $debugDir 최종 저장 디렉토리
* @return JsonResponse 처리 결과
*/
public static function handleLegacy(Request $request, string $debugDir): JsonResponse
{
$timestamp = now()->format('Ymd_His');
$saveHistory = $request->boolean('saveHistory');
foreach (self::SECTION_FILES as $sectionName => $fileName) {
if (! $request->has($sectionName)) {
continue;
}
$json = json_encode($request->input($sectionName), self::JSON_FLAGS);
File::put($debugDir.'/'.$fileName, $json);
if ($saveHistory && $sectionName === self::HISTORY_SECTION) {
File::put($debugDir."/state-{$timestamp}.json", $json);
}
}
return response()->json([
'status' => 'success',
'path' => $debugDir.'/state-latest.json',
'timestamp' => $timestamp,
]);
}
/**
* 세션 디렉토리의 섹션 파일들을 최종 저장 위치로 옮깁니다.
*
* @param string $sessionDir 세션 임시 디렉토리
* @param string $debugDir 최종 저장 디렉토리
* @param bool $saveHistory 이력 파일 저장 여부
* @param mixed $timestamp 클라이언트가 보낸 밀리초 타임스탬프
* @return void
*/
private static function mergeSectionsToFinalFiles(
string $sessionDir,
string $debugDir,
bool $saveHistory,
mixed $timestamp
): void {
$timestampStr = self::formatTimestamp($timestamp);
foreach (File::files($sessionDir) as $file) {
$sectionName = pathinfo($file, PATHINFO_FILENAME);
if (! isset(self::SECTION_FILES[$sectionName])) {
continue;
}
File::copy($file, $debugDir.'/'.self::SECTION_FILES[$sectionName]);
if ($saveHistory && $sectionName === self::HISTORY_SECTION) {
File::copy($file, $debugDir."/state-{$timestampStr}.json");
}
}
}
/**
* 오래된 세션 디렉토리를 정리합니다.
*
* @param string $sessionsDir 세션 루트 디렉토리
* @return void
*/
private static function cleanupOldSessions(string $sessionsDir): void
{
if (! File::isDirectory($sessionsDir)) {
return;
}
$cutoffTime = time() - self::SESSION_TTL_SECONDS;
foreach (File::directories($sessionsDir) as $sessionDir) {
if (File::lastModified($sessionDir) < $cutoffTime) {
File::deleteDirectory($sessionDir);
}
}
}
/**
* 클라이언트 타임스탬프(밀리초)를 이력 파일명용 문자열로 변환합니다.
*
* `date()` 의 두 번째 파라미터는 int 이므로 `$timestamp / 1000` 의 float 결과를 그대로
* 넘기면 PHP 8.1+ 에서 암묵 형변환 deprecation 이 발생한다 — 명시적으로 캐스팅한다.
*
* @param mixed $timestamp 밀리초 타임스탬프 (없으면 현재 시각 사용)
* @return string `Ymd_His` 형식 문자열
*/
private static function formatTimestamp(mixed $timestamp): string
{
if (! is_numeric($timestamp)) {
return now()->format('Ymd_His');
}
return date('Ymd_His', (int) ($timestamp / 1000));
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Support\DevTools;
use App\Contracts\Repositories\ConfigRepositoryInterface;
/**
* DevTools 엔드포인트 접근 게이트 (디버그 모드 판정).
*
* 왜 별도 클래스인가:
* `routes/devtools.php` 의 게이트는 원래
* `config('app.debug') || \App\Models\Setting::getValue('debug.mode', false)` 였는데,
* **`App\Models\Setting` 은 저장소에 존재하지 않는다** (설정이 JSON 으로 이관되며 제거된 유물).
* `||` 단축평가에 가려져 드러나지 않았을 뿐이다 — `SettingsServiceProvider` 가 `debug.mode` 를
* `config('app.debug')` 로 동기화하므로 정상 경로에서는 두 번째 항이 평가되지 않는다.
*
* 그러나 settings JSON 의 `debug` 카테고리가 비면 그 동기화가 조기 반환하고, `.env` 의
* `APP_DEBUG=false` 와 만나 **403 이어야 할 응답이 `Class "App\Models\Setting" not found`
* 500 이 된다.** 이 게이트의 두 번째 조건은 한 번도 동작한 적이 없다.
*
* 판정 SSoT 는 형제 컴포넌트인 `App\Http\Middleware\SyncBoostWithDebugMode` 가 쓰는
* `ConfigRepositoryInterface::get('debug.mode', false)` 와 일치시킨다.
*/
class DebugGate
{
/**
* 디버그 모드 활성화 여부를 반환합니다.
*
* `.env` 의 `APP_DEBUG` 또는 관리자 환경설정의 `debug.mode` 중 하나라도 켜져 있으면 true.
*
* @return bool 디버그 모드 활성화 여부
*/
public static function isEnabled(): bool
{
if ((bool) config('app.debug')) {
return true;
}
return (bool) app(ConfigRepositoryInterface::class)->get('debug.mode', false);
}
}