robots.txt๋ ๊ฒ์ ์์ง ํฌ๋กค๋ฌ๊ฐ ์ฌ์ดํธ๋ฅผ ์ด๋ป๊ฒ ํ์ํ ์ ์๋์ง ์ ์ํ๋ ํ์ผ์ด๋ค.
Next.js์์๋ app/robots.ts ํ์ผ์ ์์ฑํ๋ฉด ์๋์ผ๋ก /robots.txt ๊ฒฝ๋ก๊ฐ ์์ฑ๋๋ค.
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: '*', allow: '/' }],
sitemap: 'https://pyomin.com/sitemap.xml'
}
}
ํจ์ ์ด๋ฆ์ ์์ ๋กญ๊ฒ ์ง์ ๊ฐ๋ฅํ๋ฉฐ ํ์ผ๋ช ์ ๋ฐ๋์ robots.ts ์ฌ์ผ /robots.txt ๊ฒฝ๋ก๋ก ์๋ ๋ผ์ฐํ ๋๋ค.
sitemap.xml์ ์ฌ์ดํธ์ URL ๊ตฌ์กฐ๋ฅผ ๊ฒ์ ์์ง์ ์ ๋ฌํ๋ ํ์ผ์ด๋ค.
sitemap ์ญ์ app/sitemap.ts ํ์ผ์ ๋ง๋ค๋ฉด ์๋์ผ๋ก /sitemap.xml์ด ์์ฑ๋๋ค.
๋ํ API ๋ฐ์ดํฐ๋ฅผ ํ์ฉํด ๋์ sitemap ์์ฑ๋ ๊ฐ๋ฅํ๋ค. ๋ธ๋ก๊ทธ์ฒ๋ผ ๊ธ์ด๋ ์นดํ
๊ณ ๋ฆฌ๊ฐ ๋ง์ ์ฌ์ดํธ์์ ๋งค์ฐ ์ ์ฉํ๋ค.
import type { MetadataRoute } from "next";
import { getApiBase } from "@/lib/api/apiBases";
export const revalidate = 86400;
const SITE_URL = 'https://pyomin.com';
// ์ ์ ํ์ด์ง ์ ๋ณด
const staticPagesInfo = [
{ path: '/', changeFrequency: 'weekly', priority: 1.0, lastModified: undefined },
{ path: '/posts', changeFrequency: 'weekly', priority: 0.7, lastModified: undefined },
{ path: '/about', changeFrequency: 'yearly', priority: 0.5, lastModified: '2025-09-05T00:00:00.000Z' },
{ path: '/guestbooks', changeFrequency: 'yearly', priority: 0.5, lastModified: '2025-07-01T00:00:00.000Z' },
] as const;
// API ๋ฐ์ดํฐ ํ์ ์ ์
type Sitemap = { key: string; lastModified: string };
type SitemapResponse<T> = { sitemap: T[] };
// sitemap ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
async function fetchPostSitemap(): Promise<Sitemap[]> {
const res = await fetch(`${getApiBase()}/posts/sitemap`);
if (!res.ok) throw new Error('posts/sitemap ์คํจ');
const json = (await res.json()) as SitemapResponse<Sitemap>;
return json.sitemap ?? [];
}
async function fetchCategorySitemap(): Promise<Sitemap[]> {
const res = await fetch(`${getApiBase()}/categories/sitemap`);
if (!res.ok) return [];
const json = (await res.json()) as SitemapResponse<Sitemap>;
return json.sitemap ?? [];
}
// sitemap ์์ฑ
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [posts, categories] = await Promise.all([fetchPostSitemap(), fetchCategorySitemap()]);
const latestPostDateISO = posts.at(0)?.lastModified ?? new Date().toISOString();
const staticPages: MetadataRoute.Sitemap = staticPagesInfo.map(({ path, lastModified, changeFrequency, priority }) => ({
url: `${SITE_URL}${path}`,
lastModified: lastModified ?? latestPostDateISO,
changeFrequency,
priority
}));
const categoryPages: MetadataRoute.Sitemap = categories.map((category) => ({
url: `${SITE_URL}/posts/category/${encodeURIComponent(category.key)}`,
lastModified: category.lastModified,
changeFrequency: 'monthly',
priority: 0.6,
}));
const postPages: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${SITE_URL}/posts/${encodeURIComponent(post.key)}`,
lastModified: post.lastModified,
changeFrequency: 'monthly',
priority: 0.8,
}));
return [...staticPages, ...categoryPages, ...postPages];
}
์ ์ URL์ ๊ณ ์ ๋ ๋ ์ง ํน์ ์ต์ ๊ธ ๊ธฐ์ค์ผ๋ก lastModified๋ฅผ ์ค์ ํ์์ผ๋ฉฐ ์นดํ
๊ณ ๋ฆฌ/๊ธ ๋ชฉ๋ก์ API๋ฅผ ํตํด ์ค์๊ฐ fetch ํ์ฌ URL ๋ฐ ์์ ์ผ์ ์ง์ ํ์๋ค.
๋ํ export const revalidate = 86400์ ํตํด ํ๋ฃจ์ ํ ๋ฒ ๊ฐฑ์ ๋๋๋ก ์บ์ ์ ๋ต์ ์ ์ฉํ์๋ค.
์ฌ์ดํธ ๊ท๋ชจ๊ฐ ์ปค์ง ๊ฒฝ์ฐ ๊ฒ์ ์์ง ์ ํ์ด ์์ผ๋ฏ๋ก sitemap index ํ์ผ์ ์์ฑํ์ฌ ๋ถํ ๊ด๋ฆฌํ๋ ๊ฒ์ด ํ์ํจ
manifest ํ์ผ์ ๋ธ๋ผ์ฐ์ ์ ์น ์ฑ ์ค์น ๋ฐ ์ค์น ํ์ ๋์ ์ ๋ณด๋ฅผ ์ ๊ณตํ๋ PWA ํ์ ์ค์ ํ์ผ์ด๋ค.
Next.js์์๋ app/manifest.ts ํ์ผ์ ์์ฑํ๋ฉด ์๋์ผ๋ก /manifest.json ๊ฒฝ๋ก๊ฐ ์์ฑ๋๋ค.
์ด ํ์ผ์๋ ์ฑ ์ด๋ฆ, ์์ด์ฝ, ํ
๋ง ์์, ์์ URL, ๋ฐ๋ก๊ฐ๊ธฐ ๋ฑ์ ์ ๋ณด๊ฐ ํฌํจ๋๋ค.
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
// ์ฑ์ ์ ์ฒด ์ด๋ฆ
name: 'BlueCool Blog',
// ์ฑ์ ์งง์ ์ด๋ฆ
short_name: 'BlueCool',
// ์ฑ ์ค๋ช
description: 'BlueCool ๋ธ๋ก๊ทธ์ ๊ธ๋ค์ ๋น ๋ฅด๊ฒ ํ์ํด๋ณด์ธ์!',
// ์ฑ ๊ณ ์ ์๋ณ์
id: '/',
// ์ฑ์ด ์์๋๋ ๊ธฐ๋ณธ URL
start_url: '/',
// ์ฑ์ด ์ ๊ทผ ๊ฐ๋ฅํ URL ๋ฒ์
scope: '/',
// ์ฑ ์คํ ๋ฐฉ์
display: 'standalone',
// display๊ฐ ์ง์๋์ง ์์ ๊ฒฝ์ฐ ์ฐ์ ์์ ๋์
display_override: ['standalone', 'browser'],
// ์ฑ ๋ก๋ฉ ์ ๋ฐฐ๊ฒฝ ์
background_color: '#E6F7FB',
// ๋ธ๋ผ์ฐ์ ํด๋ฐ ๋ฐ UI์ ํ ๋ง ์์
theme_color: '#145B6F',
// ํ๋ฉด ๋ฐฉํฅ
orientation: 'any',
// ํ ์คํธ ๋ฐฉํฅ
dir: 'ltr',
// ๊ธฐ๋ณธ ์ธ์ด ์ค์
lang: 'ko-KR',
// ์ฑ์ ์นดํ ๊ณ ๋ฆฌ
categories: ['programming', 'blog', 'technology', 'education'],
// ํํ๋ฉด ์์ด์ฝ๋ค
icons: [
{ src: '/images/manifest/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/images/manifest/icon-192-maskable.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: '/images/manifest/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/images/manifest/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
{ src: '/images/manifest/icon-256.png', sizes: '256x256', type: 'image/png' },
{ src: '/images/manifest/icon-384.png', sizes: '384x384', type: 'image/png' },
{ src: '/images/manifest/icon-32.png', sizes: '32x32', type: 'image/png' },
{ src: '/images/manifest/icon-16.png', sizes: '16x16', type: 'image/png' },
],
// ์ฑ ์์ด์ฝ์ ๊ธธ๊ฒ ๋๋ ์ ๋ ๋์ค๋ ๋จ์ถ ๋ฉ๋ด
shortcuts: [
{ name: 'Posts', url: '/posts', description: '์ ์ฒด ๊ธ ๋ชฉ๋ก' },
{ name: 'About', url: '/about', description: '์๊ฐ' },
{ name: 'Guestbook', url: '/guestbooks', description: '๋ฐฉ๋ช ๋ก' },
],
};
}
manifest ์ค์ ์์ ์ฃผ์ํ ์ ์ ์์ด์ฝ์ ๋ฐ๋์ ๋ค์ํ ํด์๋(192, 512, 256 ๋ฑ)๋ก ์ ๊ณตํด์ผ ์น ์ฑ ์ค์น ๋ฒํผ์ด ์ ์์ ์ผ๋ก ๋ ธ์ถ๋๋ค. ํนํ Android๋ ์ผ๋ถ ํ๋ซํผ์์๋ maskable icon์ด ์์ผ๋ฉด ํ ํ๋ฉด ์ถ๊ฐ ์ ๋ ์ด์์์ด ๊นจ์ง ์ ์๋ค.
