공개 번들 엔드포인트가 캐시 파일이 있어도 요청마다 다시 병합했다. 캐시 키는 (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)
243 lines
8.6 KiB
PHP
243 lines
8.6 KiB
PHP
<?php
|
|
|
|
namespace App\Seo;
|
|
|
|
use App\Seo\Contracts\SeoCacheManagerInterface;
|
|
use App\Seo\Contracts\SeoRendererInterface;
|
|
use Closure;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class SeoMiddleware
|
|
{
|
|
public function __construct(
|
|
private readonly BotDetector $botDetector,
|
|
private readonly SeoCacheManagerInterface $cacheManager,
|
|
private readonly SeoRendererInterface $renderer,
|
|
private readonly SeoCacheStatsService $statsService,
|
|
) {}
|
|
|
|
/**
|
|
* 검색 봇 요청 시 SEO HTML을 반환합니다.
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param Closure $next 다음 미들웨어
|
|
* @return Response SEO HTML(HIT/MISS) 또는 SPA 폴백(BYPASS 포함)
|
|
*/
|
|
public function handle(Request $request, Closure $next): Response
|
|
{
|
|
// SEO 캐시가 글로벌 비활성화 상태면 스킵
|
|
if (! g7_core_settings('seo.bot_detection_enabled', true)) {
|
|
return $next($request);
|
|
}
|
|
|
|
// 봇이 아니면 SPA 응답
|
|
if (! $this->botDetector->isBot($request)) {
|
|
return $next($request);
|
|
}
|
|
|
|
// 기본 로케일을 setLocale() 전에 저장 (setLocale이 config('app.locale')을 변경하므로)
|
|
$defaultLocale = config('app.locale');
|
|
|
|
// ?locale= 파라미터 해석 (봇 전용)
|
|
$locale = $this->resolveSeoLocale($request, $defaultLocale);
|
|
|
|
// 기본 로케일을 ?locale=xx로 명시한 경우 → clean URL로 301 리다이렉트 (중복 URL 방지)
|
|
if ($request->query('locale') === $defaultLocale) {
|
|
$cleanUrl = $request->url();
|
|
|
|
// 기존 쿼리에서 locale 제거
|
|
$query = $request->query();
|
|
unset($query['locale']);
|
|
if (! empty($query)) {
|
|
$cleanUrl .= '?'.http_build_query($query);
|
|
}
|
|
|
|
return response('', 301, ['Location' => $cleanUrl]);
|
|
}
|
|
|
|
// SEO 로케일 설정 (setLocale은 config('app.locale')도 변경하므로 기본 로케일을 별도 전달)
|
|
$request->attributes->set('seo_default_locale', $defaultLocale);
|
|
app()->setLocale($locale);
|
|
|
|
// 캐시 키용 쿼리 정규화 — 정규화할 수 없을 만큼 큰 쿼리는 색인 대상이 아니다.
|
|
// 그런 URL 까지 렌더·저장하면 물음표 뒤 값만 바꾼 반복 요청이 무한한 미스가 된다.
|
|
$normalizedQuery = SeoCacheBounds::normalizeQuery($request->query());
|
|
|
|
if ($normalizedQuery === null) {
|
|
return $this->bypass($request, $next);
|
|
}
|
|
|
|
$ip = (string) $request->ip();
|
|
|
|
// 캐시 키용 URL 생성 (경로 + 정규화된 쿼리)
|
|
$cacheUrl = $this->buildCacheUrl($request, $normalizedQuery);
|
|
|
|
// 캐시 확인 — 적중은 비용이 없으므로 렌더 예산과 무관하게 서빙한다
|
|
$cachedHtml = $this->cacheManager->get($cacheUrl, $locale);
|
|
if ($cachedHtml !== null) {
|
|
$this->recordStat($ip, fn () => $this->statsService->recordHit(
|
|
$cacheUrl,
|
|
$locale,
|
|
$request->attributes->get('seo_layout_name') ?: null
|
|
));
|
|
|
|
return response($cachedHtml, 200, [
|
|
'Content-Type' => 'text/html; charset=utf-8',
|
|
'X-SEO-Cache' => 'HIT',
|
|
]);
|
|
}
|
|
|
|
// 미스 렌더는 IP 당 분당 예산 안에서만 — 초과분은 오류가 아니라 SPA 를 받는다.
|
|
// 봇에게 429 를 주면 그 URL 이 색인에서 빠지므로 차단이 곧 손해가 된다.
|
|
if (! SeoCacheBounds::renderAllowed($ip)) {
|
|
return $this->bypass($request, $next);
|
|
}
|
|
|
|
SeoCacheBounds::recordRender($ip);
|
|
|
|
$startedAt = microtime(true);
|
|
|
|
// 렌더링
|
|
try {
|
|
$html = $this->renderer->render($request);
|
|
} catch (\Throwable $e) {
|
|
// 정확한 throw 지점 진단을 위한 상세 정보 — file/line/exception class/trace 첫 10프레임
|
|
$traceFrames = array_slice(
|
|
array_map(static function ($frame) {
|
|
$file = $frame['file'] ?? '?';
|
|
$line = $frame['line'] ?? '?';
|
|
$class = $frame['class'] ?? '';
|
|
$type = $frame['type'] ?? '';
|
|
$function = $frame['function'] ?? '?';
|
|
|
|
return $file.':'.$line.' '.$class.$type.$function;
|
|
}, $e->getTrace()),
|
|
0,
|
|
10
|
|
);
|
|
|
|
Log::error('[SEO] Rendering failed, falling back to SPA', [
|
|
'url' => $cacheUrl,
|
|
'error' => $e->getMessage(),
|
|
'exception_class' => get_class($e),
|
|
'file' => $e->getFile(),
|
|
'line' => $e->getLine(),
|
|
'user_agent' => $request->userAgent(),
|
|
'locale' => $locale,
|
|
'trace' => $traceFrames,
|
|
]);
|
|
|
|
return $next($request);
|
|
}
|
|
|
|
// 렌더링 실패 시 SPA fallback
|
|
if ($html === null) {
|
|
return $next($request);
|
|
}
|
|
|
|
// 캐시 저장 (레이아웃명 포함 — invalidateByLayout 동작을 위해 필수)
|
|
$layoutName = $request->attributes->get('seo_layout_name', '');
|
|
$this->cacheManager->putWithLayout($cacheUrl, $locale, $html, $layoutName);
|
|
|
|
$this->recordStat($ip, fn () => $this->statsService->recordMiss(
|
|
$cacheUrl,
|
|
$locale,
|
|
$layoutName ?: null,
|
|
null,
|
|
(int) round((microtime(true) - $startedAt) * 1000)
|
|
));
|
|
|
|
return response($html, 200, [
|
|
'Content-Type' => 'text/html; charset=utf-8',
|
|
'X-SEO-Cache' => 'MISS',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 캐시·렌더를 건너뛰고 SPA 응답을 돌려줍니다.
|
|
*
|
|
* 상한 초과는 오류가 아니라 "이 요청은 봇 렌더 대상이 아니다" 라는 판정이다. 헤더는
|
|
* 운영 진단의 유일한 통로다 — 응답 본문만으로는 일반 SPA 폴백과 구분되지 않는다.
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param Closure $next 다음 미들웨어
|
|
* @return Response SPA 응답
|
|
*/
|
|
private function bypass(Request $request, Closure $next): Response
|
|
{
|
|
$response = $next($request);
|
|
$response->headers->set('X-SEO-Cache', 'BYPASS');
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* 통계 기록을 IP 당 상한 안에서만 수행합니다.
|
|
*
|
|
* 기록 자체에 상한이 없으면 통계 테이블이 새로운 증식 축이 된다.
|
|
*
|
|
* @param string $ip 요청 IP
|
|
* @param callable $record 기록 동작
|
|
*/
|
|
private function recordStat(string $ip, callable $record): void
|
|
{
|
|
if (! SeoCacheBounds::statsAllowed($ip)) {
|
|
return;
|
|
}
|
|
|
|
SeoCacheBounds::recordStat($ip);
|
|
$record();
|
|
}
|
|
|
|
/**
|
|
* 캐시 키용 URL을 생성합니다.
|
|
*
|
|
* 경로 + 정규화된 쿼리 파라미터로 구성한다. 정규화는 시스템 파라미터(`locale`,
|
|
* `_escaped_fragment_`)를 제거하고 키 순서로 정렬하므로, 같은 조합은 순서와 무관하게
|
|
* 같은 키가 되고 봇 렌더 표식만 다른 두 URL 이 두 벌로 저장되지 않는다.
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param array<string, mixed> $normalizedQuery 정규화된 쿼리 파라미터
|
|
* @return string 캐시 키용 URL
|
|
*/
|
|
private function buildCacheUrl(Request $request, array $normalizedQuery): string
|
|
{
|
|
$path = $request->getPathInfo();
|
|
|
|
if ($normalizedQuery === []) {
|
|
return $path;
|
|
}
|
|
|
|
return $path.'?'.http_build_query($normalizedQuery);
|
|
}
|
|
|
|
/**
|
|
* SEO 요청의 로케일을 해석합니다.
|
|
*
|
|
* ?locale= 쿼리 파라미터가 supported_locales에 포함되면 사용하고,
|
|
* 그렇지 않으면 기본 로케일을 반환합니다.
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param string $defaultLocale 기본 로케일
|
|
* @return string 해석된 로케일
|
|
*/
|
|
private function resolveSeoLocale(Request $request, string $defaultLocale): string
|
|
{
|
|
$requestedLocale = $request->query('locale');
|
|
|
|
if (! $requestedLocale) {
|
|
return $defaultLocale;
|
|
}
|
|
|
|
$supportedLocales = config('app.supported_locales', [$defaultLocale]);
|
|
|
|
if (in_array($requestedLocale, $supportedLocales, true)) {
|
|
return $requestedLocale;
|
|
}
|
|
|
|
return $defaultLocale;
|
|
}
|
|
}
|