Răsfoiți Sursa

sitemap 동적 생성 및 색인 제어 추가

기존 public/sitemap.xml 은 손으로 적은 9 건짜리 정적 파일로, 목록 페이지와
로그인/회원가입/비밀번호 재설정만 들어 있었다. 색인 가치가 가장 높은
영화 상세와 게시글이 한 건도 없었다.

- SitemapController: 고정 페이지 + 게시판 목록 + 공개 게시글 + 영화 상세.
  공개 판정은 Board 모델의 기존 공개 목록 쿼리와 동일
  (tb_board.is_display=1, tb_post.is_delete=0, is_secret=0, is_personal=0).
  영화 상세 주소는 뷰와 같은 규칙 base64_encode(tb_movie.movie_cd) 사용.
  1000 건 단위 chunk + 1시간 캐시 + 45,000 건 상한(초과 시 로그).
  주소는 url('/') 기준 — config('app.url') 은 환경별로 어긋날 수 있다.
- 레이아웃(desktop/mobile): canonical 추가
- robots.txt: 인증/계정/API, editor/uploader, write·edit·uploader 차단.
  /admin*/ 는 /admin/ 로 정확히 지정
- 정적 public/sitemap.xml 제거 (웹서버가 실제 파일을 라우트보다 먼저 서빙)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
admin@web.or.kr 5 zile în urmă
părinte
comite
14c004a8d8

+ 145 - 0
app/Http/Controllers/SitemapController.php

@@ -0,0 +1,145 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * sitemap.xml 생성
+ *
+ * 기존에는 public/sitemap.xml 정적 파일에 9 건만 손으로 적어두었다.
+ * 목록 페이지와 로그인/회원가입만 들어 있고, 실제 색인 가치가 있는
+ * 영화 상세와 게시글이 한 건도 없었다.
+ *
+ * 공개 판정 조건은 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;
+
+    public function index()
+    {
+        $xml = Cache::remember(self::CACHE_KEY, self::CACHE_SECONDS, function () {
+            return $this->build();
+        });
+
+        return response($xml, 200)->header('Content-Type', 'application/xml; charset=utf-8');
+    }
+
+    private function build(): string
+    {
+        // 레이아웃의 canonical(url()->current())과 같은 방식으로 요청 기준 주소를 쓴다.
+        $baseUrl = rtrim(url('/'), '/');
+        $today = date('Y-m-d');
+
+        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL;
+        $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . PHP_EOL;
+
+        // 고정 페이지. 로그인/회원가입/비밀번호 재설정은 색인 가치가 없어 제외한다.
+        $xml .= $this->url("{$baseUrl}/", $today, 'daily', '1.0');
+
+        foreach (['rank', 'search', 'trailer', 'review', 'company'] as $section) {
+            $xml .= $this->url("{$baseUrl}/movie/{$section}", $today, 'daily', '0.8');
+        }
+
+        $xml .= $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) {
+            $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) {
+                    if (!$movie->movie_cd) {
+                        continue;
+                    }
+
+                    $path = rawurlencode(base64_encode($movie->movie_cd));
+
+                    if (!$this->append($xml, "{$baseUrl}/movie/search/{$path}", $today, 'weekly', '0.7')) {
+                        return false;
+                    }
+                }
+
+                return true;
+            });
+
+        $xml .= '</urlset>' . PHP_EOL;
+
+        if ($this->truncated) {
+            // 조용히 잘리면 커버리지가 줄어든 것을 알 수 없다.
+            logger()->warning('[sitemap] URL 상한(' . self::MAX_URLS . ')에 도달해 일부가 제외되었습니다.');
+        }
+
+        return $xml;
+    }
+
+    /**
+     * 상한을 지키며 URL 을 덧붙인다. 상한에 닿으면 false 를 반환해 순회를 멈춘다.
+     */
+    private function append(string &$xml, string $location, string $lastModified, string $changeFrequency, string $priority): bool
+    {
+        if ($this->count >= self::MAX_URLS) {
+            $this->truncated = true;
+            return false;
+        }
+
+        $xml .= $this->url($location, $lastModified, $changeFrequency, $priority);
+
+        return true;
+    }
+
+    private function url(string $location, string $lastModified, string $changeFrequency, string $priority): string
+    {
+        $this->count++;
+
+        return '  <url>' . PHP_EOL
+            . '    <loc>' . htmlspecialchars($location, ENT_XML1) . '</loc>' . PHP_EOL
+            . '    <lastmod>' . $lastModified . '</lastmod>' . PHP_EOL
+            . '    <changefreq>' . $changeFrequency . '</changefreq>' . PHP_EOL
+            . '    <priority>' . $priority . '</priority>' . PHP_EOL
+            . '  </url>' . PHP_EOL;
+    }
+}

+ 15 - 2
public/robots.txt

@@ -1,4 +1,17 @@
 User-agent: *
 User-agent: *
 Allow: /
 Allow: /
-Disallow: /admin*/
-Sitemap: https://movie.web.or.kr/sitemap.xml
+Disallow: /admin/
+Disallow: /auth/
+Disallow: /account/
+Disallow: /api/
+Disallow: /editor/uploader
+Disallow: /logout
+Disallow: /tag/posts
+Disallow: /login
+Disallow: /register
+Disallow: /password/
+Disallow: /*/write
+Disallow: /*/edit
+Disallow: /*/uploader
+
+Sitemap: https://movie.web.or.kr/sitemap.xml

+ 0 - 56
public/sitemap.xml

@@ -1,56 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<urlset
-      xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
-      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-      xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
-            http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
-<!-- created with Free Online Sitemap Generator www.xml-sitemaps.com -->
-
-
-<url>
-  <loc>https://movie.web.or.kr/</loc>
-  <lastmod>2025-08-10T23:59:23+00:00</lastmod>
-  <priority>1.00</priority>
-</url>
-<url>
-  <loc>https://movie.web.or.kr/movie/rank</loc>
-  <lastmod>2025-08-10T23:59:23+00:00</lastmod>
-  <priority>0.80</priority>
-</url>
-<url>
-  <loc>https://movie.web.or.kr/movie/search</loc>
-  <lastmod>2025-08-10T23:59:23+00:00</lastmod>
-  <priority>0.80</priority>
-</url>
-<url>
-  <loc>https://movie.web.or.kr/movie/review</loc>
-  <lastmod>2025-08-10T23:59:23+00:00</lastmod>
-  <priority>0.80</priority>
-</url>
-<url>
-  <loc>https://movie.web.or.kr/movie/trailer</loc>
-  <lastmod>2025-08-10T23:59:23+00:00</lastmod>
-  <priority>0.80</priority>
-</url>
-<url>
-  <loc>https://movie.web.or.kr/board/notice</loc>
-  <lastmod>2025-08-10T23:59:23+00:00</lastmod>
-  <priority>0.80</priority>
-</url>
-<url>
-  <loc>https://movie.web.or.kr/login</loc>
-  <lastmod>2025-08-10T23:59:23+00:00</lastmod>
-  <priority>0.80</priority>
-</url>
-<url>
-  <loc>https://movie.web.or.kr/register</loc>
-  <lastmod>2025-08-10T23:59:23+00:00</lastmod>
-  <priority>0.80</priority>
-</url>
-<url>
-  <loc>https://movie.web.or.kr/password/reset</loc>
-  <lastmod>2025-08-10T23:59:23+00:00</lastmod>
-  <priority>0.80</priority>
-</url>
-
-</urlset>

+ 3 - 0
resources/views/desktop/layouts/app.blade.php

@@ -21,6 +21,9 @@
     @if($metaDescription = config('meta_description'))
     @if($metaDescription = config('meta_description'))
         <meta name="description" content="{{ $metaDescription }}"/>
         <meta name="description" content="{{ $metaDescription }}"/>
     @endif
     @endif
+    {{-- 정규 주소(canonical). ?page= 같은 파라미터가 붙은 주소가 별도 페이지로 색인되는 것을 막는다. --}}
+    <link rel="canonical" href="{{ url()->current() }}"/>
+
     {!! config('meta_adds_info') !!}
     {!! config('meta_adds_info') !!}
 
 
     <!-- CSRF Token -->
     <!-- CSRF Token -->

+ 3 - 0
resources/views/mobile/layouts/app.blade.php

@@ -21,6 +21,9 @@
     @if($metaDescription = config('meta_description'))
     @if($metaDescription = config('meta_description'))
         <meta name="description" content="{{ $metaDescription }}"/>
         <meta name="description" content="{{ $metaDescription }}"/>
     @endif
     @endif
+    {{-- 정규 주소(canonical). ?page= 같은 파라미터가 붙은 주소가 별도 페이지로 색인되는 것을 막는다. --}}
+    <link rel="canonical" href="{{ url()->current() }}"/>
+
     {!! config('meta_adds_info') !!}
     {!! config('meta_adds_info') !!}
 
 
     <!-- CSRF Token -->
     <!-- CSRF Token -->

+ 3 - 0
routes/web.php

@@ -38,6 +38,9 @@ Auth::routes(['verify' => true]);
 
 
 Route::get('/', [MainController::class, 'index'])->name('main');
 Route::get('/', [MainController::class, 'index'])->name('main');
 
 
+// sitemap.xml (public/sitemap.xml 정적 파일을 제거했으므로 이 라우트가 응답한다)
+Route::get('/sitemap.xml', [App\Http\Controllers\SitemapController::class, 'index'])->name('sitemap');
+
 /**
 /**
 |--------------------------------------------------------------------------
 |--------------------------------------------------------------------------
 | 사용자
 | 사용자