공개 번들 엔드포인트가 캐시 파일이 있어도 요청마다 다시 병합했다. 캐시 키는 (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)
296 lines
9.0 KiB
PHP
296 lines
9.0 KiB
PHP
<?php
|
|
|
|
namespace App\Seo;
|
|
|
|
use App\Contracts\Extension\CacheInterface;
|
|
use App\Seo\Contracts\SeoCacheManagerInterface;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class SeoCacheManager implements SeoCacheManagerInterface
|
|
{
|
|
/**
|
|
* 캐시 키 프리픽스 (드라이버 접두사 `g7:core:` 다음에 붙음)
|
|
*/
|
|
private const CACHE_PREFIX = 'seo.page.';
|
|
|
|
/**
|
|
* 캐시된 URL 인덱스 키
|
|
*/
|
|
private const INDEX_KEY = 'seo.cached_urls';
|
|
|
|
/**
|
|
* 저장 상한에서 인덱스를 정리한 시각을 남기는 표식 키
|
|
*/
|
|
private const PRUNE_MARK_KEY = 'seo.index_pruned_at';
|
|
|
|
/**
|
|
* 저장 상한에서 인덱스 정리를 다시 시도하기까지의 최소 간격 (초)
|
|
*/
|
|
private const PRUNE_INTERVAL_SECONDS = 60;
|
|
|
|
public function __construct(private readonly CacheInterface $cache) {}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function get(string $url, string $locale): ?string
|
|
{
|
|
if (! $this->isEnabled()) {
|
|
return null;
|
|
}
|
|
|
|
$key = $this->buildKey($url, $locale);
|
|
|
|
return $this->cache->get($key);
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function put(string $url, string $locale, string $html): void
|
|
{
|
|
$this->storePage($url, $locale, $html, null);
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function invalidateByUrl(string $urlPattern): int
|
|
{
|
|
$count = 0;
|
|
$index = $this->getIndex();
|
|
|
|
foreach ($index as $entry) {
|
|
if ($this->matchesPattern($entry['url'], $urlPattern)) {
|
|
$this->cache->forget($entry['key']);
|
|
$count++;
|
|
}
|
|
}
|
|
|
|
if ($count > 0) {
|
|
$this->rebuildIndex();
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function invalidateByLayout(string $layoutName): int
|
|
{
|
|
$count = 0;
|
|
$index = $this->getIndex();
|
|
|
|
foreach ($index as $entry) {
|
|
if (($entry['layout'] ?? '') === $layoutName) {
|
|
$this->cache->forget($entry['key']);
|
|
$count++;
|
|
}
|
|
}
|
|
|
|
if ($count > 0) {
|
|
$this->rebuildIndex();
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function clearAll(): void
|
|
{
|
|
$index = $this->getIndex();
|
|
|
|
foreach ($index as $entry) {
|
|
$this->cache->forget($entry['key']);
|
|
}
|
|
|
|
$this->cache->forget(self::INDEX_KEY);
|
|
|
|
Log::info('[SEO] All cache cleared', ['count' => count($index)]);
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function getCachedUrls(): array
|
|
{
|
|
return array_map(fn ($entry) => $entry['url'], $this->getIndex());
|
|
}
|
|
|
|
/**
|
|
* 캐시 키 빌드 (URL + 로케일)
|
|
*
|
|
* @param string $url URL
|
|
* @param string $locale 로케일
|
|
* @return string 캐시 키
|
|
*/
|
|
private function buildKey(string $url, string $locale): string
|
|
{
|
|
return self::CACHE_PREFIX.md5($url.'|'.$locale);
|
|
}
|
|
|
|
/**
|
|
* 캐시 활성화 여부 확인 — 고급 탭 값을 쓰되 SEO 탭에 별도 지정이 있으면 그것이 우선 (D19).
|
|
*/
|
|
private function isEnabled(): bool
|
|
{
|
|
return SeoCacheSettings::pageCacheEnabled();
|
|
}
|
|
|
|
/**
|
|
* 캐시 TTL (초) — 고급 탭 값을 쓰되 SEO 탭에 별도 지정이 있으면 그것이 우선 (D19).
|
|
*/
|
|
private function getCacheTtl(): int
|
|
{
|
|
return SeoCacheSettings::pageCacheTtl();
|
|
}
|
|
|
|
/**
|
|
* 캐시 인덱스를 조회합니다.
|
|
*/
|
|
private function getIndex(): array
|
|
{
|
|
return $this->cache->get(self::INDEX_KEY, []);
|
|
}
|
|
|
|
/**
|
|
* 저장 상한에서 인덱스 정리를 시도해도 되는지 판정하고, 시도한다면 표식을 남깁니다.
|
|
*
|
|
* 정리는 인덱스 전체를 훑는다(항목마다 캐시 조회). 살아 있는 항목만으로 상한에 닿은
|
|
* 경로는 저장 시도마다 그 스캔을 되풀이하게 되고, 그 빈도는 봇 미스 렌더 예산만큼이다
|
|
* — 정리해도 자리가 나지 않는 상태에서 비용만 곱해진다. 그래서 간격으로 묶는다.
|
|
*
|
|
* @return bool 정리를 수행해도 되면 true
|
|
*/
|
|
private function shouldAttemptPrune(): bool
|
|
{
|
|
if ($this->cache->has(self::PRUNE_MARK_KEY)) {
|
|
return false;
|
|
}
|
|
|
|
$this->cache->put(self::PRUNE_MARK_KEY, true, self::PRUNE_INTERVAL_SECONDS);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 유효한 캐시만 남겨 인덱스를 재구성하고 그 결과를 반환합니다.
|
|
*
|
|
* 페이지는 TTL 로 사라지지만 인덱스 항목은 남는다 — 이 메서드가 그 차이를 메우는
|
|
* 유일한 지점이므로, 인덱스를 근거로 판정하는 쪽(저장 상한)은 판정 전에 여기를 거친다.
|
|
*
|
|
* @return array<string, array<string, mixed>> 정리된 인덱스
|
|
*/
|
|
private function rebuildIndex(): array
|
|
{
|
|
$index = $this->getIndex();
|
|
$validIndex = [];
|
|
|
|
foreach ($index as $key => $entry) {
|
|
if ($this->cache->has($entry['key'])) {
|
|
$validIndex[$key] = $entry;
|
|
}
|
|
}
|
|
|
|
$this->cache->put(self::INDEX_KEY, $validIndex, 86400 * 30);
|
|
|
|
return $validIndex;
|
|
}
|
|
|
|
/**
|
|
* URL이 패턴에 매칭되는지 확인합니다.
|
|
*
|
|
* @param string $url URL
|
|
* @param string $pattern 패턴 (와일드카드 * 지원)
|
|
*/
|
|
private function matchesPattern(string $url, string $pattern): bool
|
|
{
|
|
// 와일드카드를 정규식으로 변환
|
|
$regex = str_replace(['*', '/'], ['.*', '\/'], $pattern);
|
|
|
|
return (bool) preg_match('/^'.$regex.'$/', $url);
|
|
}
|
|
|
|
/**
|
|
* 캐시 저장 시 레이아웃 정보를 함께 저장합니다.
|
|
*
|
|
* 인덱스는 단일 캐시 항목에 전체 변종 배열을 담고 저장마다 통째로 다시 쓴다. 항목 수에
|
|
* 상한이 없으면 쿼리만 바꾼 반복 요청이 그 배열을 무한히 키운다 — 그래서 **새 URL** 은
|
|
* 경로당 변종 수와 전체 항목 수 상한 안에서만 저장한다. 이미 인덱스에 있는 키의 갱신은
|
|
* 저장 규모를 늘리지 않으므로 상한과 무관하게 쓴다.
|
|
*
|
|
* @param string $url URL
|
|
* @param string $locale 로케일
|
|
* @param string $html HTML
|
|
* @param string $layoutName 레이아웃명
|
|
*/
|
|
public function putWithLayout(string $url, string $locale, string $html, string $layoutName): void
|
|
{
|
|
$this->storePage($url, $locale, $html, $layoutName);
|
|
}
|
|
|
|
/**
|
|
* 페이지와 인덱스 항목을 저장합니다 (`put`/`putWithLayout` 공통 경로).
|
|
*
|
|
* 두 공개 메서드는 같은 자원(페이지 캐시 + 인덱스)을 쓰므로 저장 규모 상한도 같아야
|
|
* 한다 — 한쪽에만 두면 다른 쪽이 우회로가 되고, 인터페이스는 확장에 열려 있어
|
|
* "지금 호출부가 없다" 는 방어가 되지 않는다.
|
|
*
|
|
* @param string $url URL (경로 + 정규화 쿼리)
|
|
* @param string $locale 로케일
|
|
* @param string $html 저장할 HTML
|
|
* @param string|null $layoutName 레이아웃명 (없으면 인덱스에 기록하지 않음)
|
|
*/
|
|
private function storePage(string $url, string $locale, string $html, ?string $layoutName): void
|
|
{
|
|
if (! $this->isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
$key = $this->buildKey($url, $locale);
|
|
$index = $this->getIndex();
|
|
|
|
if (! isset($index[$key])) {
|
|
// 상한이 세는 인덱스에는 **페이지가 이미 만료된** 항목이 섞인다 — 인덱스는
|
|
// 페이지보다 훨씬 오래 살고(30일 vs 기본 2시간) 저장마다 수명이 갱신되며
|
|
// 스스로 줄지 않는다. 여기서 한 번 정리하지 않으면 상한이 "지금 저장된 양"이
|
|
// 아니라 "과거에 저장한 적이 있는 양"을 재게 되어, 한 번 닿은 경로는 실제
|
|
// 캐시가 비어도 영영 저장이 막힌다(상한이 아니라 일방향 래치가 된다).
|
|
if (! SeoCacheBounds::canStore($index, $url) && $this->shouldAttemptPrune()) {
|
|
$index = $this->rebuildIndex();
|
|
}
|
|
|
|
if (! SeoCacheBounds::canStore($index, $url)) {
|
|
Log::debug('[SEO] 캐시 저장 상한에 도달해 저장하지 않습니다', [
|
|
'url' => $url,
|
|
'locale' => $locale,
|
|
'entries' => count($index),
|
|
]);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
$this->cache->put($key, $html, $this->getCacheTtl());
|
|
|
|
$entry = [
|
|
'url' => $url,
|
|
'locale' => $locale,
|
|
'key' => $key,
|
|
];
|
|
|
|
if ($layoutName !== null) {
|
|
$entry['layout'] = $layoutName;
|
|
}
|
|
|
|
$entry['cached_at'] = now()->toIso8601String();
|
|
|
|
$index[$key] = $entry;
|
|
|
|
$this->cache->put(self::INDEX_KEY, $index, 86400 * 30);
|
|
}
|
|
}
|