[Astro] SEO 구현

2026-03-28 hit count image

Astro 블로그에서 검색 엔진 최적화(SEO)를 구현하는 방법을 공유합니다. 메타 태그, Open Graph, JSON-LD 구조화 데이터, 사이트맵, RSS, Google Analytics 등을 다룹니다.

astro

개요

이전 글 다국어(i18n) 구현에서 3개 언어를 지원하는 다국어 시스템을 구현했습니다. 이번 글에서는 검색 엔진 최적화(SEO)를 위한 다양한 기법들을 Astro에서 구현하는 방법을 다룹니다.

모든 SEO 관련 로직은 src/components/Head.astro 컴포넌트에 집중되어 있어, 한 곳에서 관리할 수 있습니다.

Head.astro 컴포넌트

Head.astro는 모든 페이지의 <head> 영역을 담당하는 컴포넌트입니다. Props로 페이지 정보를 받아 적절한 메타 태그와 구조화 데이터를 생성합니다.

// src/components/Head.astro
interface Props {
  title: string;
  description: string;
  image?: string;
  lang: Lang;
  permalink: string;
  isPost?: boolean;
  date?: string;
  categoryPosts?: CategoryPostItem[];
  noindex?: boolean;
  category?: string;
  isHome?: boolean;
  isCategory?: boolean;
}
Props설명
title페이지 제목
description페이지 설명 (160자 초과 시 자동 잘림)
image대표 이미지 경로
lang현재 언어 (ja, ko, en)
permalink페이지 URL 경로
isPost블로그 포스트 여부 (BlogPosting JSON-LD 출력용)
date작성일 (포스트인 경우)
categoryPosts카테고리 포스트 목록 (ItemList JSON-LD 출력용)
noindex검색 엔진 인덱싱 제외 여부
isHome홈페이지 여부 (WebSite JSON-LD 출력용)
isCategory카테고리 페이지 여부 (CollectionPage JSON-LD 출력용)

페이지 유형에 따라 다른 Props를 전달하여 적절한 SEO 태그를 생성합니다.

기본 메타 태그

<meta charset="utf-8" />
<meta
  name="viewport"
  content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="description" content="{description}" />
<meta name="author" content="dev-yakuza" />
<title>{title} | DeKu</title>

description은 160자를 초과하면 자동으로 잘립니다. 검색 결과에서 표시되는 길이 제한을 맞추기 위함입니다.

const description =
  rawDescription && rawDescription.length > 160
    ? rawDescription.slice(0, 160)
    : rawDescription;

Canonical URL과 hreflang

다국어 사이트에서 가장 중요한 SEO 요소입니다.

Canonical URL

<link rel="canonical" href="{canonicalUrl}" />

canonical 태그는 현재 페이지의 정규 URL을 지정합니다. 중복 콘텐츠 문제를 방지하고, 검색 엔진에게 “이 URL이 이 페이지의 대표 URL”이라고 알려줍니다.

hreflang

<link rel="alternate" hreflang="ja" href={`${siteUrl}${rawPermalink}`} />
<link rel="alternate" hreflang="ko" href={`${siteUrl}/ko${rawPermalink}`} />
<link rel="alternate" hreflang="en" href={`${siteUrl}/en${rawPermalink}`} />
<link rel="alternate" hreflang="x-default" href={`${siteUrl}${rawPermalink}`} />

hreflang 태그는 같은 콘텐츠의 다른 언어 버전을 검색 엔진에 알려줍니다. 예를 들어, 일본에서 검색하면 일본어 버전을, 한국에서 검색하면 한국어 버전을 검색 결과에 표시합니다.

x-default는 어떤 언어에도 해당하지 않는 사용자에게 보여줄 기본 페이지를 지정합니다.

rawPermalink은 언어 접두사를 제거한 경로입니다.

const rawPermalink = permalink
  .replace(/^\/(ko|en)\//, '/')
  .replace(/^\/(ko|en)$/, '/');

Open Graph 태그

소셜 미디어에서 링크를 공유할 때 표시되는 정보를 제어합니다.

<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:type" content={isPost ? 'article' : 'website'} />
<meta property="og:site_name" content="DeKu" />
<meta property="og:locale" content={localeMap[lang]} />

주요 포인트

Open Graph의 주요 태그들은 다음과 같습니다.

  • og:type: 블로그 포스트는 article, 그 외는 website로 설정
  • og:locale: 언어별 로케일 매핑 (ja_JP, ko_KR, en_US)
  • og:locale:alternate: 다른 언어 버전의 로케일도 함께 제공
  • og:image: 이미지가 없으면 사이트 아이콘을 기본값으로 사용

포스트인 경우 추가 정보도 포함하도록 했습니다.

{isPost && date && <meta property="article:published_time" content="{date}" />}
{isPost && <meta property="article:author" content="dev-yakuza" />} {isPost &&
category && <meta property="article:section" content="{category}" />}

Twitter Cards

Twitter(X)에서 링크를 공유할 때의 카드 형식을 지정합니다.

<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@DevYakuza" />
<meta name="twitter:creator" content="@DevYakuza" />
<meta name="twitter:title" content="{title}" />
<meta name="twitter:description" content="{description}" />
<meta name="twitter:image" content="{ogImage}" />
<meta name="twitter:image:alt" content="{title}" />

summary_large_image 카드 타입을 사용하면 큰 이미지가 포함된 카드로 표시됩니다.

JSON-LD 구조화 데이터

검색 엔진이 페이지의 내용을 더 정확하게 이해할 수 있도록 구조화 데이터를 제공합니다. JSON-LD 형식을 사용하며, 페이지 유형에 따라 다른 스키마를 출력합니다.

Person (모든 페이지)

저자의 소셜 미디어 프로필을 연결하여 검색 엔진에 저자 정보를 제공합니다.

{
  "@context": "https://schema.org",
  "@type": "Person",
  "name": "dev-yakuza",
  "url": "https://deku.posstree.com",
  "sameAs": [
    "https://github.com/dev-yakuza",
    "https://www.facebook.com/yakuza.dev.54",
    "https://www.instagram.com/dev_yakuza/",
    "https://twitter.com/DevYakuza"
  ]
}

Organization (모든 페이지)

사이트(또는 조직)의 기본 정보를 검색 엔진에 알려줍니다. 사이트 이름, URL, 로고 이미지를 포함하여 검색 결과에서 브랜드 정보가 정확하게 표시되도록 합니다.

{
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "DeKu",
  "url": "https://deku.posstree.com",
  "logo": "https://deku.posstree.com/assets/images/icon.jpg"
}

BlogPosting (포스트 페이지)

다음과 같이 수정하여 Google 검색 결과에서 리치 스니펫으로 표시될 수 있습니다.

{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "포스트 제목",
  "description": "포스트 설명",
  "datePublished": "2026-04-29T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "dev-yakuza",
    "url": "https://deku.posstree.com"
  },
  "publisher": {
    "@type": "Organization",
    "name": "DeKu",
    "logo": {
      "@type": "ImageObject",
      "url": "https://deku.posstree.com/assets/images/icon.jpg"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://deku.posstree.com/ko/astro/seo/"
  }
}

WebSite (홈페이지)

SearchAction을 포함하면 Google 검색 결과에서 사이트 내 검색 박스가 표시될 수 있습니다.

{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "DeKu",
  "url": "https://deku.posstree.com",
  "inLanguage": ["ja", "ko", "en"],
  "potentialAction": {
    "@type": "SearchAction",
    "target": {
      "@type": "EntryPoint",
      "urlTemplate": "https://deku.posstree.com/search/?q={search_term_string}"
    },
    "query-input": "required name=search_term_string"
  }
}

ItemList (카테고리 페이지)

카테고리 페이지에 속한 포스트 목록을 순서가 있는 리스트로 검색 엔진에 전달합니다. 각 항목의 순서(position), URL, 제목을 포함하여 검색 엔진이 카테고리 내 콘텐츠 구조를 파악할 수 있도록 합니다.

{
  "@context": "https://schema.org",
  "@type": "ItemList",
  "itemListElement": [
    { "@type": "ListItem", "position": 1, "url": "...", "name": "..." },
    { "@type": "ListItem", "position": 2, "url": "...", "name": "..." }
  ]
}

CollectionPage (카테고리 페이지)

해당 페이지가 여러 콘텐츠를 모아놓은 컬렉션 페이지임을 검색 엔진에 알려줍니다. 카테고리명, 설명, URL을 포함하여 검색 엔진이 이 페이지를 개별 포스트가 아닌 콘텐츠 모음 페이지로 올바르게 인식하도록 합니다.

{
  "@context": "https://schema.org",
  "@type": "CollectionPage",
  "name": "카테고리명",
  "description": "카테고리 설명",
  "url": "https://deku.posstree.com/ko/astro/"
}

사이트맵

@astrojs/sitemap 플러그인으로 다국어 사이트맵을 자동 생성합니다. 이 블로그에서는 각 포스트의 frontmatter에 있는 date 필드를 lastmod로 사용하기 위해, 빌드 시 콘텐츠 파일을 읽어 URL별 날짜 맵을 생성하는 방식을 사용합니다.

// astro.config.mjs
import sitemap from '@astrojs/sitemap';
import fs from 'node:fs';

/** 콘텐츠 frontmatter에서 URL 경로 → lastmod 날짜 맵을 생성 */
function buildLastmodMap() {
  const map = new Map();
  const files = fs.globSync('src/content/**/*.md');
  for (const file of files) {
    const content = fs.readFileSync(file, 'utf-8');
    const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
    if (!match) continue;
    const fm = match[1];
    const dateMatch = fm.match(/^date:\s*['"]?(\d{4}-\d{2}-\d{2})['"]?/m);
    const permalinkMatch = fm.match(/^permalink:\s*['"]?([^\s'"]+)['"]?/m);
    const langMatch = fm.match(/^lang:\s*['"]?(\w+)['"]?/m);
    if (!dateMatch || !permalinkMatch || !langMatch) continue;
    const date = new Date(dateMatch[1]);
    const permalink = permalinkMatch[1];
    const lang = langMatch[1];
    const prefix = lang === 'ja' ? '' : `/${lang}`;
    const path = `${prefix}${permalink}`;
    map.set(path, date);
  }
  return map;
}

const lastmodMap = buildLastmodMap();

export default defineConfig({
  integrations: [
    sitemap({
      serialize(item) {
        const url = new URL(item.url);
        const lastmod = lastmodMap.get(url.pathname);
        if (lastmod) {
          item.lastmod = lastmod;
        }
        return item;
      },
      i18n: {
        defaultLocale: 'ja',
        locales: {
          ja: 'ja',
          ko: 'ko',
          en: 'en',
        },
      },
    }),
  ],
});
  • buildLastmodMap(): 모든 마크다운 파일의 frontmatter에서 date, permalink, lang을 추출하여 URL 경로별 날짜 맵을 생성합니다. 이렇게 하면 모든 URL의 lastmod가 현재 날짜가 아닌, 해당 포스트의 실제 작성일로 설정됩니다.
  • serialize: 사이트맵의 각 URL에 대해 lastmodMap에서 해당 날짜를 찾아 lastmod로 설정합니다.
  • i18n: 다국어 설정으로 hreflang 태그가 포함된 사이트맵을 생성합니다. 검색 엔진이 각 페이지의 언어별 버전을 인식할 수 있습니다.

빌드 시 dist/sitemap-index.xmldist/sitemap-0.xml 등의 파일이 자동으로 생성됩니다.

RSS 피드

@astrojs/rss 패키지로 언어별 RSS 피드를 생성합니다.

npm install @astrojs/rss

각 언어별로 별도의 RSS 피드 파일을 생성합니다.

  • 일본어: /feed.xml
  • 한국어: /ko/feed.xml
  • 영어: /en/feed.xml

Head.astro에서 현재 언어에 맞는 RSS 링크를 제공합니다.

<link
  rel="alternate"
  type="application/rss+xml"
  title="DeKu RSS Feed"
  href="{rssPath}"
/>
const rssPath = getLocalizedPath('/feed.xml', lang);

Google Analytics

Google Analytics 4(GA4)를 연동하여 방문자 통계를 수집합니다.

<script
  async
  src="https://www.googletagmanager.com/gtag/js?id=G-FR62E3SBVX"
></script>
<script is:inline>
  window.dataLayer = window.dataLayer || [];
  function gtag() {
    dataLayer.push(arguments);
  }
  gtag('js', new Date());
  gtag('config', 'G-FR62E3SBVX');
</script>

is:inline 지시어를 사용하면 Astro가 이 스크립트를 번들링하지 않고 그대로 HTML에 포함합니다. 외부 분석 스크립트는 번들링할 필요가 없으므로 is:inline을 사용합니다.

robots.txt와 검색 엔진 등록

robots.txt

public/robots.txt 파일로 검색 엔진 크롤러의 접근을 제어합니다.

User-agent: *
Allow: /
Sitemap: https://deku.posstree.com/sitemap-index.xml

모든 크롤러에게 전체 사이트 접근을 허용하고, 사이트맵 위치를 알려줍니다.

검색 엔진 등록

Google Search Console과 Naver 웹마스터 도구에 사이트를 등록하기 위한 인증 파일을 public/ 디렉토리에 배치합니다.

public/
├── googleXXXXXXXXXXXXXXXX.html    # Google 인증 파일
└── naverXXXXXXXXXXXXXXXX.html     # Naver 인증 파일

또한 Head.astro에 메타 태그로도 인증합니다.

<meta
  name="google-site-verification"
  content="rHTd8BRbOeSqAskkyVusn529XiwZ2eHbv1tnB1IDnjA"
/>

완료

이번 글에서는 Astro 블로그의 SEO 구현을 살펴보았습니다.

  • Head.astro 컴포넌트에서 모든 SEO 메타 태그를 중앙 관리
  • canonical URL과 hreflang으로 다국어 중복 콘텐츠 방지
  • Open Graph와 Twitter Cards로 소셜 미디어 공유 최적화
  • JSON-LD 구조화 데이터로 리치 스니펫 지원 (Person, BlogPosting, WebSite, CollectionPage, ItemList)
  • @astrojs/sitemap으로 다국어 사이트맵 자동 생성
  • @astrojs/rss로 언어별 RSS 피드 제공
  • Google Analytics 4(GA4) 연동

Jekyll에서의 SEO 구현과 비교하면, Astro에서는 컴포넌트 방식으로 타입 안전하게 SEO 태그를 관리할 수 있어 유지보수가 훨씬 편합니다. 이전 Jekyll SEO 구현에 대해서는 jekyll SEO 포스트를 참고하세요.

다음 글 이미지 최적화 — 커스텀 rehype 플러그인에서는 Sharp를 이용한 AVIF/WebP 자동 변환 플러그인 구현을 다룹니다.

시리즈 안내

이 포스트는 Jekyll에서 Astro로 마이그레이션 시리즈의 일부입니다.

  1. Jekyll에서 Astro로 마이그레이션한 이유
  2. Astro 설치 및 프로젝트 구성
  3. Content Collections와 마크다운 마이그레이션
  4. 다국어(i18n) 구현
  5. SEO 구현
  6. 이미지 최적화 — 커스텀 rehype 플러그인
  7. 댓글 시스템 (Utterances)
  8. 광고 연동 (Google AdSense)
  9. Pagefind를 이용한 검색 구현
  10. 레이아웃과 컴포넌트 아키텍처
  11. GitHub Pages 배포
  12. 소셜 공유 자동화 스크립트
  13. 트러블슈팅과 팁

제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!

앱 홍보

책 홍보

블로그를 운영하면서 좋은 기회가 생겨 책을 출판하게 되었습니다.

아래 링크를 통해 제가 쓴 책을 구매하실 수 있습니다.
많은 분들에게 도움이 되면 좋겠네요.



SHARE
Twitter Facebook RSS