isCssAsset($mimeType, $requestedPath)) { return $this->fileResponse($filePath, $mimeType, $maxAge); } $css = @file_get_contents($filePath); // 읽기에 실패하면 치환을 포기하고 원본을 그대로 서빙한다 — 치환은 편의 장치이므로 // 그 실패가 자산 자체를 못 내보내는 사유가 되어서는 안 된다. if ($css === false) { return $this->fileResponse($filePath, $mimeType, $maxAge); } // 서브리소스에는 CSS 자신이 받은 캐시 버전을 그대로 승계한다. 버전이 오르면 CSS URL // 이 바뀌어 재요청되고, 그 안의 서브리소스 URL 도 같은 버전을 달고 나가므로 두 계층의 // 무효화 시점이 어긋나지 않는다. $version = request()->query('v'); $version = is_string($version) && $version !== '' ? $version : null; $rewritten = AssetCssUrlRewriter::rewrite( $css, $requestedPath, static fn (string $path): string => AssetUrl::extensionApiAsset($extensionType, $identifier, $path, $version) ); // ETag 는 **내보내는 본문** 기준이어야 한다. 파일 stat 기준으로 잡으면 URL 모드가 // 바뀌어 본문이 달라져도 같은 ETag 가 나와 브라우저가 옛 본문을 계속 쓴다. $etag = md5($rewritten); if (request()->header('If-None-Match') === $etag) { return response('', 304)->header('ETag', $etag); } $cacheControl = app()->environment('production') ? "public, max-age={$maxAge}, immutable" : 'no-cache'; return response($rewritten, 200, [ 'Content-Type' => $mimeType, 'Expires' => gmdate('D, d M Y H:i:s', time() + $maxAge).' GMT', 'ETag' => $etag, 'Cache-Control' => $cacheControl, ]); } /** * 이 자산이 CSS 인지 판정합니다. * * MIME 과 확장자를 함께 본다 — 서빙 계층이 돌려주는 MIME 은 환경에 따라 * `text/plain` 으로 떨어질 수 있고, 그때 확장자가 유일한 단서다. * * @param string $mimeType MIME 타입 * @param string $path 확장 기준 요청 경로 * @return bool CSS 여부 */ private function isCssAsset(string $mimeType, string $path): bool { if (str_contains(strtolower($mimeType), 'css')) { return true; } return strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'css'; } }