Ver código fonte

sitemap index 로 분할 (영화 45,000 건 상한 초과 해소)

직전 배포에서 45,000 건 상한에 정확히 도달해 영화 일부가 제외되었다
(영화 44,987 + 게시글 5 + 고정/게시판). 규격 상한은 파일당 50,000 URL / 50MB 라
sitemap index 로 나눈다.

  /sitemap.xml            index
  /sitemap-pages.xml      고정 + 게시판 목록 + 공개 게시글
  /sitemap-movies-{n}.xml 영화 상세 40,000 건 단위

- 영화 파일 개수는 tb_movie COUNT 로 계산(캐시 1시간). 범위 밖 페이지는 404
- 게시글/영화 각각 개별 캐시. 상한에 의한 누락 없음

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
admin@web.or.kr 5 dias atrás
pai
commit
58dd46233e
2 arquivos alterados com 142 adições e 87 exclusões
  1. 138 86
      app/Http/Controllers/SitemapController.php
  2. 4 1
      routes/web.php

+ 138 - 86
app/Http/Controllers/SitemapController.php

@@ -6,135 +6,187 @@ use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 
 /**
- * sitemap.xml 생성
+ * sitemap 생성
  *
- * 기존에는 public/sitemap.xml 정적 파일에 9 건만 손으로 적어두었다.
- * 목록 페이지와 로그인/회원가입만 들어 있고, 실제 색인 가치가 있는
- * 영화 상세와 게시글이 한 건도 없었다.
+ * 영화가 45,000 건을 넘어 단일 파일로는 규격 상한(파일당 50,000 URL / 50MB)에 걸린다.
+ * 그래서 sitemap index 로 나눈다.
+ *
+ *   /sitemap.xml            index (자식 목록)
+ *   /sitemap-pages.xml      고정 페이지 + 게시판 목록 + 공개 게시글
+ *   /sitemap-movies-1.xml   영화 상세 (MOVIES_PER_FILE 건 단위)
  *
  * 공개 판정 조건은 Board 모델의 기존 공개 목록 쿼리와 동일하게 맞춘다.
  * (BRD.is_display = 1, PST.is_delete = 0, PST.is_secret = 0, PST.is_personal = 0)
  */
 class SitemapController extends Controller
 {
-    private const CACHE_KEY = 'sitemap.xml';
     private const CACHE_SECONDS = 3600;
 
-    // sitemap 규격 상한은 파일당 50,000 건이다. 여유를 두고 자른다.
-    private const MAX_URLS = 45000;
-
-    private int $count = 0;
-    private bool $truncated = false;
+    // 규격 상한(50,000)에 여유를 둔다.
+    private const MOVIES_PER_FILE = 40000;
 
+    /**
+     * sitemap index
+     */
     public function index()
     {
-        $xml = Cache::remember(self::CACHE_KEY, self::CACHE_SECONDS, function () {
-            return $this->build();
+        $xml = Cache::remember('sitemap.index', self::CACHE_SECONDS, function () {
+            $baseUrl = $this->baseUrl();
+            $today = date('Y-m-d');
+
+            $body = '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL;
+            $body .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . PHP_EOL;
+            $body .= $this->sitemapEntry("{$baseUrl}/sitemap-pages.xml", $today);
+
+            for ($page = 1; $page <= $this->movieFileCount(); $page++) {
+                $body .= $this->sitemapEntry("{$baseUrl}/sitemap-movies-{$page}.xml", $today);
+            }
+
+            $body .= '</sitemapindex>' . PHP_EOL;
+
+            return $body;
         });
 
-        return response($xml, 200)->header('Content-Type', 'application/xml; charset=utf-8');
+        return $this->response($xml);
     }
 
-    private function build(): string
+    /**
+     * 고정 페이지 + 게시판 목록 + 공개 게시글
+     */
+    public function pages()
     {
-        // 레이아웃의 canonical(url()->current())과 같은 방식으로 요청 기준 주소를 쓴다.
-        $baseUrl = rtrim(url('/'), '/');
-        $today = date('Y-m-d');
+        $xml = Cache::remember('sitemap.pages', self::CACHE_SECONDS, function () {
+            $baseUrl = $this->baseUrl();
+            $today = date('Y-m-d');
+
+            $body = $this->openUrlset();
+
+            // 로그인/회원가입/비밀번호 재설정은 색인 가치가 없어 제외한다.
+            $body .= $this->url("{$baseUrl}/", $today, 'daily', '1.0');
+
+            foreach (['rank', 'search', 'trailer', 'review', 'company'] as $section) {
+                $body .= $this->url("{$baseUrl}/movie/{$section}", $today, 'daily', '0.8');
+            }
+
+            $body .= $this->url("{$baseUrl}/tag", $today, 'daily', '0.6');
+
+            $boards = DB::table('tb_board')
+                ->where('is_display', '=', 1)
+                ->orderBy('sort')
+                ->pluck('code');
+
+            foreach ($boards as $code) {
+                $body .= $this->url("{$baseUrl}/board/{$code}", $today, 'daily', '0.8');
+            }
+
+            // 공지/전체공지도 실제로 열리는 페이지이므로 함께 넣는다.
+            DB::table('tb_post')
+                ->join('tb_board', 'tb_board.id', '=', 'tb_post.board_id')
+                ->where('tb_board.is_display', '=', 1)
+                ->where('tb_post.is_delete', '=', 0)
+                ->where('tb_post.is_secret', '=', 0)
+                ->where('tb_post.is_personal', '=', 0)
+                ->select('tb_board.code', 'tb_post.id', 'tb_post.updated_at', 'tb_post.created_at')
+                ->orderBy('tb_post.id')
+                ->chunk(1000, function ($posts) use (&$body, $baseUrl, $today) {
+                    foreach ($posts as $post) {
+                        $modified = $post->updated_at ?: $post->created_at;
+                        $lastModified = $modified ? date('Y-m-d', strtotime($modified)) : $today;
+
+                        $body .= $this->url("{$baseUrl}/board/{$post->code}/{$post->id}", $lastModified, 'weekly', '0.6');
+                    }
+                });
 
-        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL;
-        $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . PHP_EOL;
+            return $body . $this->closeUrlset();
+        });
 
-        // 고정 페이지. 로그인/회원가입/비밀번호 재설정은 색인 가치가 없어 제외한다.
-        $xml .= $this->url("{$baseUrl}/", $today, 'daily', '1.0');
+        return $this->response($xml);
+    }
 
-        foreach (['rank', 'search', 'trailer', 'review', 'company'] as $section) {
-            $xml .= $this->url("{$baseUrl}/movie/{$section}", $today, 'daily', '0.8');
+    /**
+     * 영화 상세. 페이지 번호는 1 부터 시작한다.
+     */
+    public function movies(int $page)
+    {
+        if ($page < 1 || $page > $this->movieFileCount()) {
+            abort(404);
         }
 
-        $xml .= $this->url("{$baseUrl}/tag", $today, 'daily', '0.6');
+        $xml = Cache::remember("sitemap.movies.{$page}", self::CACHE_SECONDS, function () use ($page) {
+            $baseUrl = $this->baseUrl();
+            $today = date('Y-m-d');
 
-        // 게시판 목록
-        $boards = DB::table('tb_board')
-            ->where('is_display', '=', 1)
-            ->orderBy('sort')
-            ->pluck('code');
+            $body = $this->openUrlset();
 
-        foreach ($boards as $code) {
-            $xml .= $this->url("{$baseUrl}/board/{$code}", $today, 'daily', '0.8');
-        }
-
-        // 게시글. 공지/전체공지도 실제로 열리는 페이지이므로 함께 넣는다.
-        DB::table('tb_post')
-            ->join('tb_board', 'tb_board.id', '=', 'tb_post.board_id')
-            ->where('tb_board.is_display', '=', 1)
-            ->where('tb_post.is_delete', '=', 0)
-            ->where('tb_post.is_secret', '=', 0)
-            ->where('tb_post.is_personal', '=', 0)
-            ->select('tb_board.code', 'tb_post.id', 'tb_post.updated_at', 'tb_post.created_at')
-            ->orderBy('tb_post.id')
-            ->chunk(1000, function ($posts) use (&$xml, $baseUrl, $today) {
-                foreach ($posts as $post) {
-                    $modified = $post->updated_at ?: $post->created_at;
-                    $lastModified = $modified ? date('Y-m-d', strtotime($modified)) : $today;
-
-                    if (!$this->append($xml, "{$baseUrl}/board/{$post->code}/{$post->id}", $lastModified, 'weekly', '0.6')) {
-                        return false;
-                    }
-                }
-
-                return true;
-            });
-
-        // 영화 상세.
-        // 뷰가 route('movie.search.show', base64_encode($row->movie_cd)) 로 링크를 만들므로 같은 규칙을 쓴다.
-        DB::table('tb_movie')
-            ->select('movie_cd')
-            ->orderBy('movie_cd')
-            ->chunk(1000, function ($movies) use (&$xml, $baseUrl, $today) {
-                foreach ($movies as $movie) {
+            // 뷰가 route('movie.search.show', base64_encode($row->movie_cd)) 로 링크를 만들므로 같은 규칙을 쓴다.
+            DB::table('tb_movie')
+                ->select('movie_cd')
+                ->orderBy('movie_cd')
+                ->offset(($page - 1) * self::MOVIES_PER_FILE)
+                ->limit(self::MOVIES_PER_FILE)
+                ->get()
+                ->each(function ($movie) use (&$body, $baseUrl, $today) {
                     if (!$movie->movie_cd) {
-                        continue;
+                        return;
                     }
 
                     $path = rawurlencode(base64_encode($movie->movie_cd));
+                    $body .= $this->url("{$baseUrl}/movie/search/{$path}", $today, 'weekly', '0.7');
+                });
 
-                    if (!$this->append($xml, "{$baseUrl}/movie/search/{$path}", $today, 'weekly', '0.7')) {
-                        return false;
-                    }
-                }
-
-                return true;
-            });
+            return $body . $this->closeUrlset();
+        });
 
-        $xml .= '</urlset>' . PHP_EOL;
+        return $this->response($xml);
+    }
 
-        if ($this->truncated) {
-            // 조용히 잘리면 커버리지가 줄어든 것을 알 수 없다.
-            logger()->warning('[sitemap] URL 상한(' . self::MAX_URLS . ')에 도달해 일부가 제외되었습니다.');
-        }
+    /**
+     * 영화 sitemap 파일 개수. 최소 1 개는 내보낸다.
+     */
+    private function movieFileCount(): int
+    {
+        $total = Cache::remember('sitemap.movies.total', self::CACHE_SECONDS, function () {
+            return DB::table('tb_movie')->count();
+        });
 
-        return $xml;
+        return max(1, (int)ceil($total / self::MOVIES_PER_FILE));
     }
 
     /**
-     * 상한을 지키며 URL 을 덧붙인다. 상한에 닿으면 false 를 반환해 순회를 멈춘다.
+     * 레이아웃의 canonical(url()->current())과 같은 방식으로 요청 기준 주소를 쓴다.
+     * config('app.url') 은 환경파일 값에 좌우되어 어긋날 수 있다.
      */
-    private function append(string &$xml, string $location, string $lastModified, string $changeFrequency, string $priority): bool
+    private function baseUrl(): string
     {
-        if ($this->count >= self::MAX_URLS) {
-            $this->truncated = true;
-            return false;
-        }
+        return rtrim(url('/'), '/');
+    }
+
+    private function response(string $xml)
+    {
+        return response($xml, 200)->header('Content-Type', 'application/xml; charset=utf-8');
+    }
 
-        $xml .= $this->url($location, $lastModified, $changeFrequency, $priority);
+    private function openUrlset(): string
+    {
+        return '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL
+            . '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . PHP_EOL;
+    }
 
-        return true;
+    private function closeUrlset(): string
+    {
+        return '</urlset>' . PHP_EOL;
     }
 
-    private function url(string $location, string $lastModified, string $changeFrequency, string $priority): string
+    private function sitemapEntry(string $location, string $lastModified): string
     {
-        $this->count++;
+        return '  <sitemap>' . PHP_EOL
+            . '    <loc>' . htmlspecialchars($location, ENT_XML1) . '</loc>' . PHP_EOL
+            . '    <lastmod>' . $lastModified . '</lastmod>' . PHP_EOL
+            . '  </sitemap>' . PHP_EOL;
+    }
 
+    private function url(string $location, string $lastModified, string $changeFrequency, string $priority): string
+    {
         return '  <url>' . PHP_EOL
             . '    <loc>' . htmlspecialchars($location, ENT_XML1) . '</loc>' . PHP_EOL
             . '    <lastmod>' . $lastModified . '</lastmod>' . PHP_EOL

+ 4 - 1
routes/web.php

@@ -38,8 +38,11 @@ Auth::routes(['verify' => true]);
 
 Route::get('/', [MainController::class, 'index'])->name('main');
 
-// sitemap.xml (public/sitemap.xml 정적 파일을 제거했으므로 이 라우트가 응답한다)
+// sitemap (public/sitemap.xml 정적 파일을 제거했으므로 이 라우트가 응답한다)
+// 영화가 규격 상한(파일당 50,000)을 넘어 index 로 나눈다.
 Route::get('/sitemap.xml', [App\Http\Controllers\SitemapController::class, 'index'])->name('sitemap');
+Route::get('/sitemap-pages.xml', [App\Http\Controllers\SitemapController::class, 'pages'])->name('sitemap.pages');
+Route::get('/sitemap-movies-{page}.xml', [App\Http\Controllers\SitemapController::class, 'movies'])->whereNumber('page')->name('sitemap.movies');
 
 /**
 |--------------------------------------------------------------------------