Files
Gnuboard7/app/Seo/SeoCacheBounds.php
T
HeuJung f506c040c1 fix(core): 공개 확장 번들의 캐시 우선 서빙 + SEO 봇 캐시 상한
공개 번들 엔드포인트가 캐시 파일이 있어도 요청마다 다시 병합했다. 캐시 키는
(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)
2026-09-08 00:31:20 +09:00

165 lines
5.4 KiB
PHP

<?php
namespace App\Seo;
use Illuminate\Support\Facades\RateLimiter;
/**
* SEO 봇 캐시의 상한 판정 단일 출처
*
* 봇 판정은 User-Agent 문자열뿐이라 누구나 위장할 수 있다. 그런데 캐시 키가 경로 + 전체
* 쿼리였으므로 물음표 뒤 값만 바꾸면 매 요청이 미스가 되고, 미스마다 레이아웃 병합 ·
* 표현식 평가 · 자기 API 루프백 HTTP 호출이 일어나고 그 결과가 무제한으로 저장됐다.
* 요청 하나가 워커 여러 개를 묶고 캐시 저장소를 계속 키우는 통로였다.
*
* 상한은 셋으로 나뉜다:
*
* - **키 형태** — 정규화할 수 없을 만큼 큰 쿼리는 애초에 색인 대상이 아니다(캐시하지 않고 SPA).
* - **렌더 예산** — IP 당 분당 미스 렌더 수. 초과분은 SPA 로 돌려보낸다(429 가 아니다 — 봇에게
* 오류를 주면 색인에서 그 URL 이 사라진다).
* - **저장 규모** — 경로당 변종 수와 전체 항목 수.
*
* 값은 `config/core.php` 의 `seo_cache_limits` 가 SSoT 이고 env 로 덮을 수 있다.
*/
final class SeoCacheBounds
{
/**
* 캐시 키에서 제외하는 시스템 파라미터.
*
* `locale` 은 캐시 키의 별도 축이고, `_escaped_fragment_` 는 봇 렌더 요청 표식일 뿐
* 내용에 영향이 없다 — 키에 남기면 같은 페이지가 두 벌 저장된다.
*/
private const SYSTEM_QUERY_PARAMS = ['locale', '_escaped_fragment_'];
/**
* 상한값을 반환합니다.
*
* @param string $key `seo_cache_limits` 하위 키
* @param int $fallback 설정이 없을 때의 기본값
* @return int 상한값
*/
private static function limit(string $key, int $fallback): int
{
return (int) config('core.seo_cache_limits.'.$key, $fallback);
}
/**
* 캐시 키에 쓸 쿼리 파라미터를 정규화합니다.
*
* 시스템 파라미터를 제거하고 키 순서로 정렬한다. 개수·길이 상한을 넘으면 **null** 을
* 돌려주며, 그것은 "이 URL 은 캐시하지도 렌더하지도 않는다" 를 뜻한다.
*
* @param array<string, mixed> $query 요청 쿼리 파라미터
* @return array<string, mixed>|null 정규화된 파라미터 (상한 초과 시 null)
*/
public static function normalizeQuery(array $query): ?array
{
foreach (self::SYSTEM_QUERY_PARAMS as $param) {
unset($query[$param]);
}
if (count($query) > self::limit('max_query_params', 10)) {
return null;
}
ksort($query);
if (strlen(http_build_query($query)) > self::limit('max_query_length', 512)) {
return null;
}
return $query;
}
/**
* 이 IP 가 미스 렌더를 더 수행할 수 있는지 판정합니다.
*
* @param string $ip 요청 IP
* @return bool 렌더 허용 여부
*/
public static function renderAllowed(string $ip): bool
{
return ! RateLimiter::tooManyAttempts(
'seo-render:'.$ip,
self::limit('render_misses_per_minute', 60)
);
}
/**
* 미스 렌더 1건을 예산에서 차감합니다.
*
* @param string $ip 요청 IP
*/
public static function recordRender(string $ip): void
{
RateLimiter::hit('seo-render:'.$ip, 60);
}
/**
* 이 IP 의 요청을 통계로 기록할 수 있는지 판정합니다.
*
* 통계 테이블이 새로운 증식 축이 되지 않도록 기록 자체에도 상한을 둔다.
*
* @param string $ip 요청 IP
* @return bool 기록 허용 여부
*/
public static function statsAllowed(string $ip): bool
{
return ! RateLimiter::tooManyAttempts(
'seo-stats:'.$ip,
self::limit('stats_records_per_minute', 300)
);
}
/**
* 통계 기록 1건을 예산에서 차감합니다.
*
* @param string $ip 요청 IP
*/
public static function recordStat(string $ip): void
{
RateLimiter::hit('seo-stats:'.$ip, 60);
}
/**
* 새 URL 을 캐시에 저장할 수 있는지 판정합니다.
*
* 이미 인덱스에 있는 키의 **갱신**은 이 판정을 거치지 않는다(호출측 책임) — 저장 규모가
* 늘지 않기 때문이다.
*
* @param array<string, array<string, mixed>> $index 현재 캐시 인덱스
* @param string $url 저장하려는 URL (경로 + 정규화 쿼리)
* @return bool 저장 허용 여부
*/
public static function canStore(array $index, string $url): bool
{
if (count($index) >= self::limit('max_entries', 20000)) {
return false;
}
$path = self::pathOf($url);
$variants = 0;
foreach ($index as $entry) {
if (self::pathOf((string) ($entry['url'] ?? '')) === $path) {
$variants++;
}
}
return $variants < self::limit('max_variants_per_path', 50);
}
/**
* URL 에서 경로 부분만 잘라냅니다.
*
* @param string $url 캐시 URL (`/path?a=1` 형태)
* @return string 경로
*/
private static function pathOf(string $url): string
{
$path = parse_url($url, PHP_URL_PATH);
return is_string($path) ? $path : $url;
}
}