
sitemap.xml이 필요한 이유와 Next.js에서 동적으로 만드는 방법

사이트를 만들고 나면 아무도 안 온다
웹사이트를 열심히 만들어서 배포했습니다. 기능도 잘 돌아가고, 디자인도 나쁘지 않습니다. 그런데 구글에 검색해보면 안 나옵니다. 네이버도 마찬가지고요.
당연합니다. 검색 엔진은 우리 사이트의 존재를 모르니까요.
검색 엔진이 웹페이지를 수집하는 걸 "크롤링"이라고 합니다. 구글봇이 링크를 따라다니며 페이지를 발견하는 건데, 신규 사이트는 어디서도 링크가 안 걸려 있으니 발견될 리가 없습니다. 설령 발견된다 해도, 수백 개의 페이지를 하나하나 링크 타고 찾아다니는 건 비효율적이고요.
그래서 sitemap.xml이 필요합니다.
sitemap.xml = "우리 사이트에 이런 페이지들이 있습니다" 라는 목차 검색 엔진 입장: sitemap 없이: 메인 → 링크 클릭 → 또 클릭 → 또 클릭... (느림, 누락 많음) sitemap 있을 때: sitemap.xml 읽기 → 전체 URL 목록 확보 → 순서대로 방문 (빠름, 누락 적음)
sitemap.xml의 구조
XML 형식이고, 생각보다 단순합니다.
<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <url> <loc>https://www.example.com/</loc> <lastmod>2026-02-17T09:00:00+09:00</lastmod> <changefreq>daily</changefreq> <priority>1.0</priority> </url> <url> <loc>https://www.example.com/blog</loc> <lastmod>2026-02-15T14:30:00+09:00</lastmod> <changefreq>weekly</changefreq> <priority>0.7</priority> </url> </urlset>
각 속성의 의미를 정리하면 이렇습니다.
┌──────────────┬──────────────────────────────────────────────────┐ │ 속성 │ 설명 │ ├──────────────┼──────────────────────────────────────────────────┤ │ loc │ 페이지의 전체 URL (필수) │ │ lastmod │ 마지막 수정일 (ISO 8601 형식) │ │ changefreq │ 변경 빈도 힌트 (always/hourly/daily/weekly/ │ │ │ monthly/yearly/never) │ │ priority │ 사이트 내 상대적 중요도 (0.0 ~ 1.0, 기본 0.5) │ └──────────────┴──────────────────────────────────────────────────┘
주의할 점이 몇 가지 있습니다.
lastmod는 실제 콘텐츠 수정일이어야 합니다. 매번 현재 시간을 넣으면 검색 엔진이 "이 사이트 lastmod 못 믿겠다" 하고 무시해버립니다. 구글 공식 문서에서도 부정확한 lastmod는 참고하지 않겠다고 명시하고 있고요.
changefreq와 priority는 힌트일 뿐입니다. 구글은 사실상 이 두 값을 무시한다고 알려져 있는데, 네이버 같은 검색 엔진에서는 참고할 수 있으니 넣어두는 게 손해는 아닙니다.
정적 페이지에는 lastmod를 아예 안 넣는 것도 방법입니다. 홈페이지, 문의 페이지 같은 건 실제 수정 시점을 추적하기 어렵거든요. 억지로 현재 시간을 찍느니 차라리 생략하는 게 낫습니다.
URL이 5만 개를 넘으면? Sitemap Index
sitemap.xml 하나에 담을 수 있는 URL은 최대 50,000개, 파일 크기는 50MB까지입니다.
보통 사이트라면 충분하지만, 쇼핑몰이나 대형 커뮤니티는 콘텐츠가 수십만 개일 수 있습니다. 이때 쓰는 게 Sitemap Index입니다. 목차의 목차 같은 것이죠.
<!-- sitemap-index.xml --> <?xml version="1.0" encoding="UTF-8"?> <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <sitemap> <loc>https://www.example.com/sitemap-posts.xml</loc> <lastmod>2026-02-17T09:00:00+09:00</lastmod> </sitemap> <sitemap> <loc>https://www.example.com/sitemap-blogs.xml</loc> <lastmod>2026-02-16T15:00:00+09:00</lastmod> </sitemap> <sitemap> <loc>https://www.example.com/sitemap-books.xml</loc> <lastmod>2026-02-10T12:00:00+09:00</lastmod> </sitemap> </sitemapindex>
구조는 이렇습니다.
sitemap-index.xml (목차의 목차) ├── sitemap-posts.xml (게시글 URL 50,000개까지) ├── sitemap-blogs.xml (블로그 URL 50,000개까지) ├── sitemap-books.xml (도서 URL 50,000개까지) └── sitemap-products.xml (상품 URL 50,000개까지)
Sitemap Index 하나에 최대 50,000개의 sitemap 파일을 연결할 수 있으니, 이론상 25억 개 URL까지 커버됩니다. 이 한계에 부딪힐 일은 현실적으로 없겠죠.
유형별로 sitemap을 분리하면 관리도 편해집니다. 블로그 글이 대량으로 추가됐을 때 sitemap-blogs.xml만 갱신하면 되니까요.
뭘 넣고 뭘 빼야 하는가
sitemap에 모든 URL을 전부 포함하면 될 것 같지만, 그렇지 않습니다.
┌──────────────────┬────────────┬──────────────────────────────┐ │ 페이지 유형 │ sitemap? │ 이유 │ ├──────────────────┼────────────┼──────────────────────────────┤ │ 메인 페이지 │ O │ 사이트 대표 페이지 │ │ 공개 게시글 상세 │ O │ 검색으로 유입될 핵심 콘텐츠 │ │ 블로그 글 │ O │ 검색 유입의 핵심 │ │ 도서/교재 내용 │ O │ 교육 콘텐츠, 검색 가치 높음 │ │ 홍보 페이지 │ O │ 공개 콘텐츠 │ ├──────────────────┼────────────┼──────────────────────────────┤ │ 로그인 필요 페이지 │ X │ 검색 엔진이 접근 불가 │ │ 수업 내부 게시판 │ X │ 수강생만 볼 수 있는 콘텐츠 │ │ 관리자 페이지 │ X │ 검색에 노출되면 안 됨 │ │ 임시저장(DRAFT) │ X │ 작성자 본인만 볼 수 있음 │ │ 삭제된 글(DELETED) │ X │ 404 반환하는 URL │ │ 검색 결과 페이지 │ X │ 동적 파라미터, 중복 콘텐츠 │ │ 페이지네이션 (?p=2) │ X │ 콘텐츠가 아닌 목록 탐색용 │ └──────────────────┴────────────┴──────────────────────────────┘
기준은 하나입니다. "검색 엔진이 접근할 수 있고, 검색 결과에 노출되길 원하는 페이지만 넣는다."
말로 하면 간단한데, 통합 게시판을 운영하면 이게 꼬이기 시작합니다.
통합 게시판에서 sitemap 실수한 이야기
FullStackFamily는 하나의 UnifiedPost 테이블로 블로그, Q&A, 자유게시판, 수업 게시판을 전부 운영합니다. 게시판마다 Board가 있고, 글은 Board에 속하는 구조인데요.
UnifiedBoard (게시판) ├── slug: "qna" preset: QNA → /qna/{postId} ├── slug: "free" preset: FREE → /boards/free/posts/{postId} ├── slug: "logs" preset: GENERAL → /boards/logs/posts/{postId} ├── slug: "blog-toto" preset: BLOG → /@toto/posts/{postId} └── slug: "qna" (수업용) preset: QNA → /lessons/5/boards/qna/posts/{postId}
문제는 sitemap 생성 쿼리에 있었습니다.
-- 원래 쿼리 SELECT b.slug, p.id, p.updated_at FROM unified_post p JOIN unified_board b ON p.board_id = b.id WHERE b.lesson_id IS NULL AND p.status = 'PUBLISHED'
lesson_id IS NULL이면 "수업에 속하지 않는 게시판"인데, 블로그 게시판도 수업에 속하지 않습니다. 그래서 블로그 글이 /boards/blog-toto/posts/11990 같은 존재하지 않는 URL로 sitemap에 등록되고 있었습니다.
블로그 글은 이미 별도 API에서 /@toto/posts/11990 형태로 정상 등록하고 있었으니, 같은 글이 잘못된 URL과 정상 URL 두 벌로 중복 등록된 셈입니다.
sitemap.xml에 등록된 동일한 글: ✗ https://www.fullstackfamily.com/boards/blog-toto/posts/11990 (잘못된 URL) ✓ https://www.fullstackfamily.com/@toto/posts/11990 (정상 URL)
DB를 뒤져보니, 게시판에 is_global과 is_active 플래그가 있었습니다.
┌──────────────────────┬───────────┬───────────┬────────────────────┐ │ 게시판 │ is_global │ is_active │ sitemap 포함? │ ├──────────────────────┼───────────┼───────────┼────────────────────┤ │ qna (전역 Q&A) │ true │ true │ O (공개 게시판) │ │ free (자유게시판) │ true │ true │ O (공개 게시판) │ │ logs (오늘의삽질) │ true │ true │ O (공개 게시판) │ │ tototube (토토의 즐프) │ true │ true │ O (공개 게시판) │ │ ai (AI) │ true │ true │ O (공개 게시판) │ ├──────────────────────┼───────────┼───────────┼────────────────────┤ │ blog-toto (블로그) │ false │ true │ X (별도 처리) │ │ notice (수업용) │ false │ false │ X (비공개) │ │ assignment (수업용) │ false │ false │ X (비공개) │ └──────────────────────┴───────────┴───────────┴────────────────────┘
is_global = true AND is_active = true인 게시판이 딱 sitemap에 포함돼야 하는 공개 게시판 5개와 일치합니다. 운이 좋았다고 해야 할까요.
-- 수정된 쿼리 SELECT b.slug, p.id, p.updated_at FROM unified_post p JOIN unified_board b ON p.board_id = b.id WHERE b.is_global = true AND b.is_active = true AND p.status = 'PUBLISHED'
WHERE 조건 두 줄 고치는 간단한 수정이지만, 발견하기까지가 문제였습니다. "sitemap에 이상한 URL이 들어있다"는 걸 누군가 눈으로 확인해야 했거든요. sitemap은 만들어놓고 잊어버리기 쉬운데, 콘텐츠 구조가 바뀔 때마다 한 번은 열어봐야 합니다.
Next.js에서 동적 sitemap 만들기
Next.js에서는 app/sitemap.ts 파일을 만들면 /sitemap.xml 경로로 자동 서빙됩니다.
frontend/src/app/sitemap.ts → https://www.fullstackfamily.com/sitemap.xml
전체 흐름은 이렇습니다.
요청: GET /sitemap.xml │ ▼ ┌──────────────────────────────────────┐ │ Next.js sitemap.ts │ │ │ │ 1. 정적 페이지 직접 정의 │ │ 2. 백엔드 API 5개 병렬 호출 │ │ 3. 응답을 URL 목록으로 변환 │ │ 4. XML 자동 생성 │ └──────────────────────────────────────┘ │ ├── /api/sitemap/posts (게시글) ├── /api/sitemap/promo (홍보) ├── /api/sitemap/lessons (수업 소개) ├── /api/sitemap/books (도서) └── /api/sitemap/blogs (블로그)
구현하면서 신경 쓴 부분이 몇 가지 있습니다.
정적 페이지는 lastmod를 생략
const staticPages = [ { url: BASE_URL, changeFrequency: 'daily', priority: 1 }, { url: `${BASE_URL}/qna`, changeFrequency: 'daily', priority: 0.8 }, { url: `${BASE_URL}/books`, changeFrequency: 'weekly', priority: 0.8 }, // lastmod 없음 - 실제 수정일을 모르면 안 넣는 게 낫다 ]
백엔드 API는 병렬 호출
const [posts, promo, lessons, books, blogs] = await Promise.all([ fetchPostEntries(), fetchPromoEntries(), fetchLessonEntries(), fetchBookEntries(), fetchBlogEntries(), ])
5개의 API를 순차 호출하면 각각 100ms씩만 걸려도 500ms입니다. Promise.all이면 가장 느린 하나의 시간만 걸리고요.
에러가 나도 정적 페이지는 살아남게
async function fetchPostEntries() { try { const res = await fetch(`${API_URL}/api/sitemap/posts`) if (!res.ok) return [] return res.json() } catch { console.error('[Sitemap] Failed to fetch post entries') return [] // 빈 배열 반환 → 정적 페이지는 유지 } }
DB가 일시적으로 중단되었다고 sitemap 전체가 500 에러를 반환하면 곤란합니다. 동적 콘텐츠 API가 실패하면 빈 배열을 반환하여, 최소한 정적 페이지는 항상 남아있도록 했습니다.
콘텐츠 유형별로 URL 경로가 다르다
게시글: /boards/{boardSlug}/posts/{postId} (단, QnA는 /qna/{postId}) 홍보: /promo/{slug} 수업: /lessons/{id} 도서: /books/{bookSlug}/{chapterSlug} 블로그: /@{blogSlug}/posts/{postId}/{titleSlug}
같은 DB의 글이라도 게시판 종류에 따라 URL이 완전히 다릅니다. 백엔드에서 유형별로 API를 분리하고, 프론트엔드에서 URL을 조립하는 구조로 가닥을 잡았습니다.
1시간 캐시
export const revalidate = 3600 // 1시간 캐시
sitemap은 실시간성이 중요하지 않습니다. 글을 쓰고 1시간 뒤에 검색에 반영돼도 충분하니까요.
백엔드: 가벼운 전용 API
sitemap용 API는 필요한 최소 데이터만 내려줍니다.
일반 게시글 API 응답: sitemap용 API 응답: { { id, title, content, boardSlug: "free", author, tags, comments, postId: 1234, thumbnailUrl, excerpt, updatedAt: "2026-02-17T09:00:00+09:00" viewCount, voteCount... } } → 필드 20개+ → 필드 3개
일반 게시글 API를 그대로 쓰면 본문, 태그, 댓글까지 불필요하게 함께 조회됩니다. sitemap에는 URL과 수정일만 있으면 되니, 전용 경량 API를 따로 뒀습니다.
@GetMapping("/posts") public List<SitemapPostEntry> getPostEntries() { return unifiedPostRepository .findSitemapPostEntriesRaw(PostStatus.PUBLISHED) .stream() .map(raw -> new SitemapPostEntry( raw.boardSlug(), raw.postId(), raw.updatedAt().atZone(KOREA_ZONE).toOffsetDateTime())) .toList(); }
날짜는 ISO 8601 형식에 타임존 오프셋(+09:00)을 포함합니다. 검색 엔진이 시간을 정확히 해석하려면 이게 있어야 합니다.
구글과 네이버에 등록하기
sitemap.xml을 만들었으면 검색 엔진에 알려줘야 합니다.
Google Search Console
1. https://search.google.com/search-console 접속 2. 사이트 소유권 인증 (DNS TXT 레코드 또는 HTML 파일) 3. 왼쪽 메뉴 → Sitemaps 4. sitemap URL 입력: https://www.example.com/sitemap.xml 5. 제출
제출하면 구글이 sitemap을 읽고 각 URL을 수집하기 시작합니다. "발견된 URL", "색인된 URL", "제외된 URL" 같은 상태를 대시보드에서 볼 수 있고요.
네이버 Search Advisor
1. https://searchadvisor.naver.com 접속 2. 사이트 등록 + 소유권 인증 (HTML 파일 또는 메타 태그) 3. 요청 → 사이트맵 제출 4. sitemap URL 입력 5. 제출
네이버는 구글보다 크롤링이 느린 편이라 sitemap 등록이 더 중요합니다. sitemap 없이는 네이버 검색 반영까지 꽤 오래 걸릴 수 있거든요.
robots.txt에도 명시
검색 엔진이 사이트에 처음 올 때 가장 먼저 읽는 파일이 robots.txt입니다. 여기에 sitemap 위치를 적어두면 Search Console에 별도 등록하지 않아도 자동으로 발견합니다.
# robots.txt User-agent: * Allow: / Sitemap: https://www.fullstackfamily.com/sitemap.xml
등록하면 뭐가 달라지나
sitemap 자체가 검색 순위를 올려주진 않습니다. 다만 검색 엔진이 우리 콘텐츠를 빠짐없이 발견하게 해줍니다. 순위를 올리려면 먼저 색인에 포함돼야 하니, 그 첫 단계인 셈입니다.
sitemap 없이: 구글에 100개 페이지 중 40개만 색인됨 나머지 60개는 구글이 아직 발견 못함 sitemap 있을 때: 구글에 100개 페이지 중 95개 색인됨 5개는 noindex 또는 중복으로 제외 (의도한 대로)
새 블로그 글을 쓰면 sitemap에 자동 추가되고, 검색 엔진이 다음 크롤링 때 발견합니다. sitemap 없으면 다른 페이지에서 링크를 타고 들어올 때까지 기다려야 합니다.
검색 엔진의 크롤링 예산(crawl budget)도 무한하지 않습니다. 큰 사이트에서는 priority 힌트로 중요한 페이지를 먼저 방문하도록 유도할 수 있고요.
그리고 의외로 유용한 것이 Google Search Console의 sitemap 리포트입니다. "제출된 URL 수 vs 색인된 URL 수"를 비교하면, 깨진 URL이나 리다이렉트 루프 같은 문제가 바로 보입니다. 이런 걸 sitemap 리포트에서 처음 발견하는 경우가 적지 않습니다.
마무리
sitemap.xml 자체는 기술적으로 어려운 게 아닙니다. URL 목록을 XML로 만들면 끝이니까요. 어려운 건 "무엇을 넣고 무엇을 빼느냐"를 계속 신경 쓰는 것입니다.
이번에 잘못된 URL이 포함된 것도, 통합 게시판에 블로그 기능을 추가하면서 쿼리 조건이 안 맞게 된 게 원인이었습니다. 기능은 계속 추가되는데 sitemap 쿼리는 처음 만든 그대로였던 거죠.
콘텐츠 구조 변경 → sitemap 확인 → 필요시 수정 이 루프를 빼먹지 않는 게 전부입니다.






댓글
댓글을 작성하려면 이 필요합니다.