• ABOUT
  • POSTS
  • GUESTBOOK

ยฉ 2025 BlueCool12 All rights reserved.

2025.09.15Next.js

๐Ÿค– Next.js MetadataRoute ์ •๋ณต: Robots, Sitemap, Manifest๊นŒ์ง€ ํ•œ ๋ฒˆ์—

Next.js App Router์—์„œ๋Š” SEO๋ฅผ ์ฒด๊ณ„์ ์œผ๋กœ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๋„๋ก MetadataRoute ๊ธฐ๋Šฅ์„ ์ง€์›ํ•œ๋‹ค. 

MetadataRoute๋Š” Next.js๊ฐ€ ์ œ๊ณตํ•˜๋Š” ํŠน์ˆ˜ ํŒŒ์ผ๋ช… ๊ธฐ๋ฐ˜์˜ ๋ผ์šฐํŒ… ๊ทœ์น™์œผ๋กœ app/ ๋””๋ ‰ํ„ฐ๋ฆฌ ํ•˜์œ„์— sitemap.ts, robots.ts, manifest.ts, opengraph-image.tsx ๋“ฑ์˜ ํŒŒ์ผ์„ ๋‘๋ฉด ํ•ด๋‹น ๊ฒฝ๋กœ๋กœ ์ž๋™ ๋ผ์šฐํŒ… ๋˜๋ฉฐ ๋ธŒ๋ผ์šฐ์ €์™€ ํฌ๋กค๋Ÿฌ๊ฐ€ ์š”์ฒญํ•  ๋•Œ ํ•„์š”ํ•œ HTTP ํ—ค๋”์™€ ์‘๋‹ต์ด ์ž๋™์œผ๋กœ ์„ค์ •๋œ๋‹ค. 

๋˜ํ•œ ์ด ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ํŒŒ์ผ๋“ค์€ ๊ธฐ๋ณธ์ ์œผ๋กœ ์ •์  ์ตœ์ ํ™”๊ฐ€ ์ ์šฉ๋˜์–ด ๋นŒ๋“œ ์‹œ ์บ์‹œ ๋˜๋ฉฐ ํ•„์š”์— ๋”ฐ๋ผ revalidate ์˜ต์…˜์ด๋‚˜ dynamic = โ€˜force-dynamicโ€™ ์„ค์ •์„ ํ†ตํ•ด ๋™์  ๊ฐฑ์‹  ์ „๋žต๋„ ์ง€์ •ํ•  ์ˆ˜ ์žˆ๋‹ค. 
 


1) Robots 

app/robots.ts ํŒŒ์ผ์„ ํ†ตํ•ด ๊ฒ€์ƒ‰ ์—”์ง„ ํฌ๋กค๋Ÿฌ์— ๋Œ€ํ•œ ์ ‘๊ทผ ์ •์ฑ…์„ ์„ ์–ธํ•  ์ˆ˜ ์žˆ๋‹ค. ์ง์ ‘ robots.txt ์ •์  ํŒŒ์ผ์„ ๋งŒ๋“ค์ง€ ์•Š์•„๋„ ์•„๋ž˜์ฒ˜๋Ÿผ ๊ฐ„๋‹จํ•œ ์ฝ”๋“œ๋งŒ์œผ๋กœ ์ž๋™์œผ๋กœ /robots.txt ๊ฒฝ๋กœ์— ์‘๋‹ต์ด ์ƒ์„ฑ๋œ๋‹ค. 
 

typescript
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 ๊ฒฝ๋กœ๋กœ ์ž๋™ ๋ผ์šฐํŒ… ๋œ๋‹ค. 
 

2) Sitemap 

sitemap ์—ญ์‹œ app/sitemap.ts ํŒŒ์ผ์„ ๋งŒ๋“ค๋ฉด ์ •์  XML ํŒŒ์ผ ์—†์ด๋„ /sitemap.xml ๊ฒฝ๋กœ๊ฐ€ ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค. ๋˜ํ•œ Next.js์—์„œ๋Š” ๋ฐ์ดํ„ฐ ๊ธฐ๋ฐ˜์˜ sitemap๋„ ์ฝ”๋“œ๋กœ ๋™์ ์œผ๋กœ ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๊ธ€/์นดํ…Œ๊ณ ๋ฆฌ ๋“ฑ ์ฝ˜ํ…์ธ ๊ฐ€ ๋งŽ์€ ์‚ฌ์ดํŠธ์—์„œ ๋งค์šฐ ์œ ์šฉํ•˜๋‹ค. 
 

typescript
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;

type Sitemap = { key: string; lastModified: string };
type SitemapResponse<T> = { sitemap: T[] };

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 ?? [];
}

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์„ ํ†ตํ•ด ํ•˜๋ฃจ์— ํ•œ ๋ฒˆ ๊ฐฑ์‹ ๋˜๋„๋ก ์บ์‹œ ์ „๋žต์„ ์ ์šฉํ•˜์˜€๋‹ค. 
 

3) PWA Manifest 

app/manifest.ts ํŒŒ์ผ์€ ๋ธŒ๋ผ์šฐ์ €์— PWA(Progressive Web App) ๊ด€๋ จ ์ •๋ณด๋ฅผ ์ œ๊ณตํ•œ๋‹ค. ์ด ํŒŒ์ผ์€ ์ž๋™์œผ๋กœ /manifest.webmanifest ๊ฒฝ๋กœ๋กœ ๋ผ์šฐํŒ… ๋˜๋ฉฐ ์•„์ด์ฝ˜, ์ด๋ฆ„, ํ…Œ๋งˆ ์ƒ‰์ƒ, ์‹œ์ž‘ URL, ๋ฐ”๋กœ ๊ฐ€๊ธฐ ๋“ฑ ์›น ์•ฑ ์„ค์น˜์™€ ๊ด€๋ จ๋œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ๋‹ด์„ ์ˆ˜ ์žˆ๋‹ค. 
 

typescript
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์ด ์—†์œผ๋ฉด ํ™ˆ ํ™”๋ฉด ์ถ”๊ฐ€ ์‹œ ๋ ˆ์ด์•„์›ƒ์ด ๊นจ์งˆ ์ˆ˜ ์žˆ๋‹ค. 
 

์ด์ „ ๊ธ€
๐Ÿ”‘ ํ•ด์‹œ ํ…Œ์ด๋ธ”(Hash Table) ๊ฐœ๋…๊ณผ ์ถฉ๋Œ ์ฒ˜๋ฆฌ ๋ฐฉ์‹ ์ •๋ฆฌ
๋‹ค์Œ ๊ธ€
๐Ÿ” chownยทchmod๋กœ ๋ฐฐ์šฐ๋Š” ๋ฆฌ๋ˆ…์Šค ํŒŒ์ผ ๊ถŒํ•œ ๊ด€๋ฆฌ
์žฅ์‹์šฉ ๋กœ๊ณ