ํ๋ก ํธ์๋(Next.js) ์๋ฒ์์ sitemap์ ์์ฑํ ๋ ๋ฐฑ์๋(Spring Boot) API๋ฅผ ํตํด ๊ธ ๋ชฉ๋ก๊ณผ ์นดํ
๊ณ ๋ฆฌ ๋ชฉ๋ก์ ๋ฐ์์ค๋ ๊ธฐ์กด ๊ตฌ์กฐ์์๋ ์ ์ฒด ๋ฐ์ดํฐ๋ฅผ ํ ๋ฒ์ ๋ถ๋ฌ์ค์ง ์๊ณ ํ์ด์ง ๋จ์๋ก ์์ฐจ ์์ฒญ์ ๋ณด๋ด๋ค ๋ณด๋ ๊ธ ์๊ฐ ๋ง์์ง์๋ก ์๋ต ์๋๊ฐ ์ง์ฐ๋๋ ๋ฌธ์ ๊ฐ ๋ฐ์ํ์๋ค.
Googlebot์ ๊ฒฝ์ฐ ์์ฒญ ์๊ฐ์ด ๋ค์ ๊ธธ๋๋ผ๋ ๊ธฐ๋ค๋ฆฌ๋ ํธ์ด์ง๋ง ๋ค์ด๋ฒ ๊ฒ์์์ง ๋ด(Yeti)์ ์ผ์ ์๊ฐ ์ด์ ์๋ต์ด ์์ ๊ฒฝ์ฐ ์์ฒญ์ ๋์ด๋ฒ๋ฆฌ๋ ๊ฒ์ผ๋ก ํ์ธ๋์๋ค.
๊ธฐ์กด SSR ๋ฐฉ์์ผ๋ก ์์ฑํ๋ sitemap์ SSG๋ก ์ ํํ์ฌ ๋น๋ ์ sitemap ์์ฑ
import { MetadataRoute } from "next";
// export const dynamic = 'force-dynamic'; ๊ธฐ์กด SSR ์ค์
export const dynamic = 'force-static'; // SSG ๋ฐฉ์์ผ๋ก ์ ํ
์ด ๋ฐฉ๋ฒ์ผ๋ก ์๋ต์๋๋ ํฌ๊ฒ ์ค์ผ ์ ์์์ง๋ง ์๋ก์ด ๊ธ์ด ์ถ๊ฐ๋์ด sitemap์ ์ถ๊ฐํ๊ธฐ ์ํด์๋ ์ ์ฒด ํ๋ก์ ํธ๋ฅผ ์ฌ๋น๋ํด์ผ ํ์ฌ ์ค์๊ฐ ๋ฐ์ ์ธก๋ฉด์์ ์ ์ฐ์ฑ์ด ๋จ์ด์ก๋ค.
์ฒ์์๋ sitemap ์์ฑ์ ์ํด ๋ณ๋์ ์ ์ฉ ์ปจํธ๋กค๋ฌ๋ฅผ ๋ง๋๋ ๊ฒ์ด ๊ณผ๋ํ๊ฒ ๋๊ปด์ ธ ๊ธฐ์กด ๊ฒ์๊ธ ๋ชฉ๋ก API๋ฅผ ๊ทธ๋๋ก ํ์ฉํ์๋ค.
๊ทธ๋ฌ๋ ํ์ด์ง ๊ธฐ๋ฐ API์ด๋ค ๋ณด๋ ์ ์ฒด ๋ฐ์ดํฐ๋ฅผ ์์ฐจ์ ์ผ๋ก ์ฌ๋ฌ ๋ฒ ํธ์ถํด์ผ ํ๊ณ ๋ณธ๋ฌธ ๊ฐ์ sitemap์ ๋ถํ์ํ ํ๋๊น์ง ํฌํจ๋์ด ์ค๋ฒํค๋๊ฐ ํฌ๊ณ ๊ธ ์๊ฐ ๋์ด๋ ์๋ก ์๋ต ์๋๊ฐ ๊ธ์ฆํ์๋ค.
๋ฐ๋ผ์ ๊ทผ๋ณธ์ ์ธ ํด๊ฒฐ์ ์ํด ๊ธฐ์กด ๋๋ฉ์ธ์ /sitemap ์๋ ํฌ์ธํธ๋ฅผ ์ถ๊ฐํ์๋ค.
@Query("""
SELECT new com.pyomin.cool.dto.SitemapDto(
p.slug,
COALESCE(p.updatedAt, p.createdAt)
)
FROM Post p
WHERE p.status = :status
ORDER BY COALESCE(p.updatedAt, p.createdAt) DESC, p.id DESC
""")
List<SitemapDto<String>> findAllForSitemap(PostStatus status);
sitemap ์์ฑ์ ํ์ํ ๋ฐ์ดํฐ๋ง ์ต์ํ์ผ๋ก ์กฐํํ๋๋ก DTO ํ๋ก์ ์
์ ์ฌ์ฉํ์๊ณ lastmod ์ค์ ์ ์ํด COALESCE()๋ฅผ ์ฌ์ฉํ์ฌ ์์ ์ผ์ด ์์ผ๋ฉด ์์ฑ์ผ์ ์กฐํํ๋๋ก ์ฒ๋ฆฌํ์๋ค.
SitemapDto๋ ์๋์ ๊ฐ์ด ์ค๊ณํ์๋ค.
package com.pyomin.cool.dto;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
public record SitemapDto<K>(K key, Instant lastModified) {
public SitemapDto(K key, LocalDateTime lastModifiedLdt) {
this(
key,
lastModifiedLdt == null
? null
: lastModifiedLdt.atZone(ZoneId.of("Asia/Seoul")).toInstant()
);
}
}
๋ฌธ์์ด slug๋ฟ๋ง ์๋๋ผ ์ถํ์ ๋ค๋ฅธ ํ์
์ ์๋ณ์๋ ํฌํจํ ์ ์๋๋ก ์ ๋ค๋ฆญ์ผ๋ก ์ค๊ณํ์์ผ๋ฉฐ sitemap ํ์ค ํ๋์ธ <lastmod>๋ UTC ๊ธฐ๋ฐ ISO 8601 ํฌ๋งท์ ์๊ตฌํ๊ธฐ ๋๋ฌธ์ DB์์ ์กฐํํ LocalDateTime์ ๊ทธ๋๋ก ์ฌ์ฉํ์ง ์๊ณ ์์ฑ์๋ฅผ ํตํด Asia/Seoul ํ์์กด์ ์ง์ ํ ๋ค Instant๋ก ๋ณํํ์ฌ ํ์์กด์ ๋
๋ฆฝ์ ์ธ ๊ฐ์ ๋ณด๊ดํ๋๋ก ์ค๊ณํ์๋ค.
์ดํ ์นดํ
๊ณ ๋ฆฌ๋ ๊ฐ์ ๋ฐฉ์์ผ๋ก ์๋ ํฌ์ธํธ๋ฅผ ์ค๊ณํ ํ ํ๋ก ํธ์์ Promise.all()์ ์ฌ์ฉํ์ฌ ๋์ ํธ์ถ์ ํตํด ์๋ต ์ง์ฐ์ ์ต์ํํ๊ณ ISR ์ฃผ๊ธฐ๋ฅผ 24์๊ฐ์ผ๋ก ์ค์ ํ์๋ค.
import { MetadataRoute } from "next";
import { getApiBase } from "@/lib/api/apiBase";
export const revalidate = 86400; // ISR (ํ๋ฃจ๋ง๋ค ์ฌ์์ฑ)
const SITE_URL = 'https://pyomin.com';
type SlugSitemap = { slug: string; lastModified: string };
type SitemapResponse<T> = { sitemap: T[] };
async function fetchPostSitemap(): Promise<SlugSitemap[]> {
const res = await fetch(`${getApiBase()}/posts/sitemap`);
if (!res.ok) throw new Error('posts/sitemap ์คํจ');
const json = (await res.json()) as SitemapResponse<SlugSitemap>;
return json.sitemap ?? [];
}
// ์นดํ
๊ณ ๋ฆฌ fetch ์๋ต
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [posts, categories] = await Promise.all([fetchPostSitemap(), fetchCategorySitemap()]); // ๋์ ํธ์ถ
const postPages: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${SITE_URL}/posts/${encodeURIComponent(post.slug)}`,
lastModified: post.lastModified,
changeFrequency: 'monthly',
priority: 0.8,
}));
// ์ด ์ธ ์ฌ์ดํธ๋งต ์์ฑ ๋ฐ return
}
๊ฒฐ๊ณผ์ ์ผ๋ก sitemap ์๋ต ์๋๋ฅผ 1์ด ์ด๋ด๋ก ์ค์ด๋ ๋์์ ๊ฒ์์์ง์ด ์๊ตฌํ๋ ํ์ค ํฌ๋งท์ ์งํฌ ์ ์๊ฒ ๋์๋ค.

