본문으로 건너뛰기
2025. 9. 14
© WONKOOK LEE

이 블로그를 운영하며 배운 SEO

검색을 상징하는 돋보기 아이콘

이 블로그는 올해 초 정적 사이트 생성기(SSG, Static Site Generator)로 직접 만들어 반년 넘게 운영해왔습니다. 글을 쌓고 검색 유입을 들여다보다 보니, 글을 쓰는 일과 그 글이 검색에 닿게 하는 일은 서로 다른 작업이라는 걸 알게 됐습니다. 빌드가 뱉어낸 HTML은 출발선일 뿐, 그 뒤로 챙길 것들이 층층이 있었습니다.

정적 블로그를 검색에 제대로 노출시키려면 무엇을, 어떤 순서로 챙겨야 할까요?


이 글에서 다루는 내용

이 블로그(Docusaurus 기반, Netlify 호스팅)를 운영하며 실제로 부딪힌 순서대로 정리했습니다. 특정 프레임워크 이야기가 조금 섞이지만, 개념 자체는 어떤 스택에서든 통하는 것들입니다. SEO를 처음 챙겨보는 분도 따라올 수 있도록 용어마다 짧게 풀어 쓰겠습니다.





1. 만들어지는 것과 발견되는 것

검색에 노출되기까지는 대략 세 단계를 거칩니다.

  1. 크롤링(crawling) — 검색봇이 페이지를 방문해 HTML을 읽습니다.
  2. 색인(indexing) — 읽은 내용을 이해하고 검색 데이터베이스에 등록합니다.
  3. 랭킹(ranking) — 누군가 검색했을 때 이 페이지를 얼마나 위에 보여줄지 정합니다.

이 파이프라인에서 정적 사이트가 손을 보태는 지점은 앞쪽 두 단계, 크롤링과 색인입니다. 정적 사이트가 잘하는 건 첫 단계를 쉽게 만들어주는 것입니다. 클라이언트에서 렌더링(CSR)하는 사이트는 봇이 자바스크립트를 실행해야 본문이 보이지만, 미리 렌더링된 정적 HTML은 봇이 방문하는 즉시 본문이 다 들어 있습니다. 크롤링·색인에 유리한 출발점인 건 분명합니다.

다만 유리한 건 딱 출발점까지였습니다. 봇이 페이지를 읽을 수 있다는 것과, 그 페이지를 찾아와서 올려준다는 것은 다른 이야기입니다. 색인은 자동으로 보장되지 않고, 같은 내용을 담은 주소가 여러 개면 봇은 무엇이 정본인지 헷갈려 하고, 어느 페이지가 중요한지도 알려주지 않으면 모릅니다. 정적 사이트는 SEO의 하한선을 올려줄 뿐, 상한선은 여전히 운영하는 사람이 올려야 합니다.

Recap

검색 노출은 크롤링 → 색인 → 랭킹의 단계를 거칩니다. 정적 사이트는 미리 렌더링된 HTML로 크롤링·색인의 출발점을 유리하게 해주지만 딱 거기까지입니다. "HTML이 잘 나온다"와 "검색에 잘 뜬다" 사이의 거리를 메우는 게 이 글의 나머지입니다.




2. 공유되는 순간의 첫인상 — 메타데이터와 Open Graph

가장 먼저 챙긴 건 눈에 보이지 않는 첫인상, 메타데이터였습니다.

  • 메타 설명(meta description): 검색 결과에서 제목 아래 뜨는 두어 줄 요약입니다. 없으면 검색엔진이 본문에서 대충 발췌해 채우는데, 글 첫 줄이 이미지나 인용문으로 시작하면 발췌가 엉뚱하게 잡히곤 했습니다.
  • Open Graph(OG): 카카오톡·슬랙·X 같은 곳에 링크를 붙였을 때 뜨는 카드입니다. og:title, og:description, og:image가 그 카드의 제목·설명·썸네일이 됩니다.

초기에 놓쳤던 건 블로그 글에 요약을 안 적어둔 것이었습니다. 문서(TechLog)에는 프론트매터(frontmatter)에 요약과 대표 이미지를 꼬박꼬박 넣으면서, 정작 블로그 글에는 제목과 날짜만 적어두고 있었습니다. 그러니 공유 카드에는 사이트 기본 로고가, 검색 스니펫에는 들쭉날쭉한 발췌가 걸렸습니다.

해법은 단순합니다. 글마다 요약 한두 문장을 적는 것입니다. 이 블로그는 마크다운 프론트매터로 관리합니다.

---
title: "글 제목"
description: "검색 스니펫과 og:description으로 쓰이는 한두 문장 요약."
image: /content-img/blog/20250914_01/1.png # 없으면 사이트 기본 소셜 카드로 폴백
---

이 한 줄이 빌드되면 페이지 <head>에 메타 태그로 박힙니다. 요약을 적기 전과 후의 차이는 이렇습니다.

<!-- 요약이 없을 때: 검색엔진이 본문 첫머리에서 임의로 발췌 -->
<meta
name="description"
content="총 9일의 연휴 동안 개인 블로그를 만들고 글을…"
/>

<!-- description을 적었을 때: 의도한 문장이 그대로 스니펫·카드가 됨 -->
<meta
name="description"
content="9일의 설 연휴 동안 세운 OKR을 얼마나 달성했는지 돌아봅니다."
/>
<meta property="og:title" content="설 연휴 OKR 달성률 회고" />
<meta
property="og:description"
content="9일의 설 연휴 동안 세운 OKR을 얼마나 달성했는지 돌아봅니다."
/>
<meta
property="og:image"
content="https://blog.wonkooklee.com/img/social-card.png"
/>
<meta name="twitter:card" content="summary_large_image" />

대표 이미지가 없는 글은 굳이 억지로 만들지 않고, 사이트 공통 소셜 카드(1200×630 크기의 기본 이미지) 하나를 만들어 폴백으로 두었습니다. 트위터 카드 규격에 맞는 큰 이미지라, 공유했을 때 로고 한 장보다 훨씬 그럴듯한 카드가 뜹니다.

요약을 사람이 매번 기억해서 적는 건 결국 새어 나갑니다. 저는 아예 빌드 스크립트가 요약 없는 글을 막도록 만들어뒀습니다. 규칙은 의지가 아니라 도구로 강제해야 지켜집니다.

Recap

메타 설명은 검색 스니펫, Open Graph는 SNS 공유 카드의 재료입니다. 둘이 비면 검색엔진과 메신저가 알아서 채우는데 품질이 들쭉날쭉합니다. 글마다 요약을 적고, 대표 이미지가 없는 글은 공통 소셜 카드로 폴백하도록 두면 "공유되는 순간의 첫인상"이 안정됩니다.




3. 검색엔진에게 정체를 알려주기 — 구조화 데이터

검색엔진은 사람처럼 페이지를 읽고 이해하는 데 한계가 있습니다. 이 페이지가 블로그 글인지, 글쓴이가 누구인지, 언제 쓴 글인지를 기계가 읽을 수 있는 형식으로 따로 적어주면 훨씬 정확하게 이해합니다. 그게 구조화 데이터(structured data)이고, 요즘 표준은 schema.org 어휘를 JSON-LD로 적는 방식입니다.

JSON-LD는 페이지 <head>에 이런 스크립트 한 조각을 넣는 것입니다.

<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "글 제목",
"description": "글 요약",
"datePublished": "2025-09-14",
"author": { "@type": "Person", "name": "Wonkook Lee" }
}
</script>

넣은 것은 크게 두 종류였습니다. 사이트 전체가 하나의 WebSite이고 저자가 Person이라는 정보를 모든 페이지에 심어, 검색엔진이 "이 사이트는 이런 사이트이고 이 사람이 쓴다"를 하나의 엔티티로 이해하게 했습니다. 그리고 글마다 BlogPosting(블로그 글) 또는 Article(기술 문서)로 제목·요약·작성일·저자를 명시했습니다.

이 엔티티들은 따로 노는 게 아니라 서로 연결됩니다. 글은 저자(Person)가 쓰고, 사이트(WebSite)의 일부이며, 저자는 그 사이트의 발행자이기도 합니다.

여기서 얻은 실전 교훈 하나. 프레임워크가 이미 만들어주는 걸 중복으로 넣지 말 것. 확인해보니 이 블로그의 블로그 글에는 프레임워크가 BlogPosting 구조화 데이터를 자동으로 넣고 있었습니다. 그걸 모르고 하나 더 심었다가, 한 페이지에 같은 BlogPosting이 두 개 박히는 꼴이 됐습니다. 자동으로 되는 부분은 프론트매터(요약·이미지)만 잘 채우면 그대로 반영되고, 자동으로 안 되는 부분(문서 페이지의 Article 등)만 직접 넣으면 됩니다.

만든 구조화 데이터가 제대로 인식되는지는 Rich Results Test에 URL을 넣어 바로 확인할 수 있습니다.

Recap

구조화 데이터(JSON-LD, schema.org)는 "이 페이지가 무엇인지"를 기계가 읽을 형식으로 적어주는 것입니다. 사이트·저자(WebSite/Person)와 글(BlogPosting/Article)을 명시하면 검색엔진의 이해도가 올라갑니다. 단, 프레임워크가 자동 생성하는 부분과 겹치지 않게 확인해야 합니다 — 중복은 오히려 신호를 흐립니다.




4. 무엇을 크롤링하게 할까 — 사이트맵과 크롤 예산

사이트맵(sitemap)은 "우리 사이트에 이런 페이지들이 있어요"라고 검색엔진에 건네는 목록입니다(sitemaps.org). 대부분의 정적 사이트 도구가 이걸 자동 생성해주고, robots.txt에서 그 위치를 가리키게 해둡니다.

처음엔 "많을수록 좋겠지" 하고 모든 URL을 다 넣었는데, 그게 아니었습니다. 제 사이트맵을 열어보니 실제 글은 100개 남짓인데 자동 생성된 태그·페이지네이션 페이지가 300개 넘게 목록의 대부분을 차지하고 있었습니다. 태그 하나에 글 한 편뿐인 얇은(thin) 페이지가 수백 개였습니다.

검색엔진이 한 사이트에 쓰는 크롤링 자원(흔히 크롤 예산, crawl budget)은 무한하지 않습니다. 작은 개인 사이트라 예산이 빠듯하진 않아도, "이게 다 중요한 페이지야"라고 신호를 보내놓고 정작 알맹이가 얇으면 신호 대 잡음만 나빠집니다. 그래서 사이트맵을 실제 콘텐츠 중심으로 정리했습니다.

// 얇은/기능성 페이지는 사이트맵에서 제외해 실제 글에 크롤링을 집중시킨다
sitemap: {
lastmod: "date", // 마지막 수정일을 함께 실어 재크롤을 유도
changefreq: "weekly",
ignorePatterns: [
"/**/tags/**", // 자동 생성 태그 페이지
"/blog/authors/**",
"/blog/page/**", // 페이지네이션
"/search/", // 기능 페이지
],
}

lastmod(마지막 수정일)를 함께 넣은 것도 도움이 됐습니다. 글을 고치면 그 날짜가 갱신되어, 검색엔진에 "이 페이지 최근에 바뀌었으니 다시 와줘"라는 신호가 됩니다. 정리된 사이트맵의 항목 하나는 이렇게 생겼습니다.

<url>
<loc>https://blog.wonkooklee.com/blog/20250914_01/</loc>
<lastmod>2025-09-14</lastmod>
<changefreq>weekly</changefreq>
<priority>0.5</priority>
</url>
정보

사이트맵에서 뺀다고 그 페이지가 색인에서 사라지는 건 아닙니다. 사이트맵은 "이게 중요해요"라는 추천 목록일 뿐, 봇은 내부 링크를 타고 다른 페이지도 계속 발견합니다. 목록을 정리하는 건 강제가 아니라 우선순위 신호를 깔끔하게 만드는 일입니다.

Recap

사이트맵은 검색엔진에 건네는 페이지 추천 목록이고 robots.txt가 그 위치를 가리킵니다. 자동 생성되는 얇은 태그·페이지네이션 페이지까지 다 넣으면 신호 대 잡음이 나빠지므로 실제 콘텐츠 중심으로 추려주는 게 좋습니다. lastmod로 수정일을 실어 재크롤을 유도할 수 있습니다.




5. 같은 글, 다른 주소 — URL 일관성과 리디렉트

가장 오래 헤맨 곳이고, 정적 사이트에서 의외로 흔한 함정이라 길게 적습니다.

문제의 정체는 이렇습니다. 이 블로그의 호스팅(Netlify)은 슬래시 없는 주소로 들어오면 슬래시 붙은 주소로 301 리디렉트를 겁니다.

/docs/intro   →  301  →  /docs/intro/

실제로 검색봇이 사이트맵의 슬래시 없는 주소를 가져가면 이런 왕복이 벌어집니다.

그 자체는 정상입니다. 문제는 사이트맵과 canonical 태그는 슬래시 없는 주소로 나가고 있었다는 것입니다. 검색엔진에게는 "정본 주소는 /docs/intro야"라고 알려주면서, 정작 그 주소로 가보면 /docs/intro/로 튕겨내고 있었습니다. 한 페이지에 주소가 두 개 있고, 스스로 어느 쪽이 정본인지 엇갈린 신호를 보낸 셈입니다. 이걸 방치하면 Search Console에 "리디렉션이 포함된 페이지"가 쌓입니다. 색인이 아예 막히는 건 아니지만, 크롤이 리디렉트에 낭비되고 신호가 지저분해집니다.

두 개념을 정리하고 나서야 풀렸습니다.

  • 후행 슬래시(trailing slash): 주소 끝의 /. /a//a는 검색엔진에게 다른 URL입니다. 사이트 전체가 한쪽으로 통일돼야 합니다.
  • 표준 URL(canonical): "이 내용의 정본 주소는 이거야"라고 <link rel="canonical">로 못 박는 것입니다(Google 문서). 같은 내용이 여러 주소로 접근되더라도 canonical 하나로 모아줍니다.

해결은 호스트가 실제로 서빙하는 형태에 사이트맵·canonical·내부 링크를 전부 맞추는 것이었습니다. 호스트가 슬래시 붙은 주소를 200으로 주니, 사이트 설정도 슬래시로 통일했습니다.

// 호스트가 /…/ 로 서빙하므로 사이트가 내보내는 모든 URL도 /…/ 로 통일
const config = {
url: "https://blog.wonkooklee.com",
trailingSlash: true,
};

이 한 줄로 사이트맵·canonical·og:url·구조화 데이터의 주소가 모두 슬래시 붙은 형태로 정렬되어, 리디렉트 불일치가 사라졌습니다. 확인은 curl로 응답 코드를 찍어보면 됩니다.

# 슬래시 없는 주소는 301로 최종 주소를 알려주고
$ curl -sI https://blog.wonkooklee.com/docs/intro | head -2
HTTP/2 301
location: https://blog.wonkooklee.com/docs/intro/

# 사이트맵·canonical이 가리키는 주소는 곧바로 200
$ curl -sI https://blog.wonkooklee.com/docs/intro/ | head -1
HTTP/2 200

canonical 태그도 리디렉트되는 주소가 아니라 최종 주소를 가리키게 정렬됩니다.

<!-- 정렬 전: canonical이 301되는 주소를 가리킴 -->
<link rel="canonical" href="https://blog.wonkooklee.com/docs/intro" />
<!-- 정렬 후 -->
<link rel="canonical" href="https://blog.wonkooklee.com/docs/intro/" />

그런데 여기서 하나 더 있었습니다. 내부 링크도 같이 통일해야 한다는 것입니다. 설정을 바꿔도, 홈에서 글로 거는 링크를 날것의 <a href="/docs/intro">(슬래시 없이)로 박아두면 그 링크는 여전히 리디렉트를 유발합니다. 프레임워크가 제공하는 링크 컴포넌트를 쓰면 후행 슬래시 규칙이 자동으로 적용되지만, 손으로 박은 앵커 태그는 그 혜택을 못 받습니다.

// ❌ 날것의 앵커 — 슬래시 정규화가 안 되어 리디렉트를 유발
<a href="/docs/intro">문서</a>

// ✅ 프레임워크 링크 — trailingSlash가 자동 적용됨
<Link to="/docs/intro">문서</Link>

문서끼리 거는 상대 링크에도 비슷한 함정이 있었습니다. 확장자 없이 [문서](./other-page)로 걸면 후행 슬래시 환경에서 경로 해석이 어긋나 빌드가 깨졌습니다. [문서](./other-page.md)처럼 파일 확장자를 붙이면 프레임워크가 실제 주소로 정확히 변환해줘서 슬래시 규칙과 무관하게 안전했습니다.

URL은 사람에겐 사소해 보여도 검색엔진에겐 신원입니다. 같은 글이 두 신원을 가지면 둘 다 손해입니다.

Recap

/a/a/는 검색엔진에게 다른 URL입니다. 호스트의 리디렉트 방향과 사이트맵·canonical·내부 링크가 어긋나면 "리디렉션 페이지"가 쌓입니다. 호스트가 서빙하는 형태(여기선 후행 슬래시)로 사이트 전체를 통일하고, 내부 링크는 날것의 <a> 대신 프레임워크 링크 컴포넌트를, 문서 간 링크는 확장자를 붙여 거는 게 안전합니다.




6. 측정하지 않으면 개선할 수 없다 — Search Console

여기까지가 "심는" 일이었다면, 마지막은 "확인하는" 일입니다. Google Search Console은 내 사이트가 검색엔진 눈에 어떻게 보이는지 알려주는 무료 도구입니다. 방문 분석을 하는 애널리틱스와는 역할이 다릅니다 — 이쪽은 색인·검색 유입 담당입니다.

처음 등록하고 할 일은 단순합니다.

  1. 소유권 인증 (도메인 DNS에 레코드 하나 추가하는 방식이 깔끔합니다).
  2. 사이트맵 제출.
  3. 대표 페이지 몇 개는 URL 검사 도구로 색인 요청.

그다음부터는 페이지 색인 생성 리포트를 읽는 눈이 필요합니다. 처음엔 빨간 딱지가 무섭게 느껴지는데, 대부분은 "오류"가 아니라 "상태"였습니다.

리포트에 뜨는 상태실제 의미대응
리디렉션이 포함된 페이지그 주소가 리디렉트됨정상일 수 있음. 사이트맵·canonical이 최종 주소를 가리키는지 확인
크롤링됨 - 현재 색인 생성 안 됨크롤은 했지만 아직 색인 보류흔함. 시간이 해결하거나 콘텐츠가 얇으면 보강
대체 페이지(적절한 canonical 포함)중복인데 canonical을 잘 처리함문제 아님

가장 도움이 된 건 URL 검사 도구였습니다. 특정 주소를 넣으면 "구글이 고른 표준 URL"과 "참조 페이지(어디서 이 주소로 링크하는지)"를 보여줍니다.

URL 검사 — https://blog.wonkooklee.com/docs/intro
색인 생성됨: 아니오 · 리디렉션이 포함된 페이지
사용자 선언 표준 URL: …/docs/intro/
구글 선택 표준 URL: …/docs/intro/ (선언과 일치)
참조 페이지: / , /terms/ ← 여기서 아직 옛 주소로 링크 중

5번에서 이야기한 리디렉트 문제의 실마리가 바로 이 "참조 페이지"였습니다. 슬래시 없는 주소가 참조 페이지 목록에 뜨는 걸 보고서야 "홈과 약관 페이지의 내부 링크가 아직 옛 주소로 걸려 있구나"를 깨달았습니다. 측정 도구가 없었다면 몰랐을 일입니다.

Recap

Search Console은 색인·검색 유입을 보는 무료 도구입니다. 소유권 인증 → 사이트맵 제출 → 대표 페이지 색인 요청이 기본 순서입니다. 색인 리포트의 빨간 항목 상당수는 오류가 아니라 정상 상태이니 의미를 구분해 읽어야 하고, URL 검사 도구는 "구글이 고른 표준 URL"과 "참조 페이지"를 보여줘 문제의 실마리를 줍니다.




한 장 요약

챙길 것무엇을
메타데이터글마다 요약(description), 기본 소셜 카드검색 스니펫·공유 카드의 첫인상
Open Graphog:title·og:description·og:imageSNS 공유 시 카드 품질
구조화 데이터WebSite/Person/Article JSON-LD검색엔진이 정체를 이해 (자동 생성분과 중복 주의)
사이트맵실제 콘텐츠 중심 + lastmod크롤 우선순위 신호, 재크롤 유도
URL 일관성후행 슬래시 통일, 내부 링크는 <Link>리디렉트·중복 신호 제거
측정Search Console 색인 리포트심은 것이 실제로 먹히는지 확인

돌아보면 처음 가졌던 생각은 "SEO는 한 번 켜두면 되는 스위치"였습니다. 막상 해보니 SEO는 한 번의 설정이 아니라 계속되는 운영에 가까웠습니다. 글을 쓸 때마다 요약을 챙기고, 구조가 바뀌면 URL 신호를 다시 맞추고, Search Console을 이따금 열어 무엇이 걸렸는지 확인하는 일. 화려하진 않지만 애써 쓴 글이 검색을 타고 누군가에게 닿으려면 결국 이 꾸준함이 필요했습니다.




References

검색엔진 공식 문서

표준·명세

도구 문서

좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자