목차
개요
이전 글 다국어(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.xml과 dist/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 메타 태그를 중앙 관리canonicalURL과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로 마이그레이션 시리즈의 일부입니다.
- Jekyll에서 Astro로 마이그레이션한 이유
- Astro 설치 및 프로젝트 구성
- Content Collections와 마크다운 마이그레이션
- 다국어(i18n) 구현
- SEO 구현
- 이미지 최적화 — 커스텀 rehype 플러그인
- 댓글 시스템 (Utterances)
- 광고 연동 (Google AdSense)
- Pagefind를 이용한 검색 구현
- 레이아웃과 컴포넌트 아키텍처
- GitHub Pages 배포
- 소셜 공유 자동화 스크립트
- 트러블슈팅과 팁
제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!
앱 홍보
Deku가 개발한 앱을 한번 사용해보세요.Deku가 개발한 앱은 Flutter로 개발되었습니다.관심있으신 분들은 앱을 다운로드하여 사용해 주시면 정말 감사하겠습니다.