Gatsby 블로그 GEO·AEO 적용기 — JSON-LD, FAQPage 자동 생성, llms.txt, 사이트맵 정리

예전에는 검색 결과 첫 페이지의 파란 링크 10개 안에 드는 것이 목표였다. 요즘은 구글의 AI 요약, Perplexity, ChatGPT 검색처럼 답변을 먼저 보여 주고 출처를 인용하는 방식이 늘고 있다. 이런 환경에서는 “사람이 클릭하게 만드는 것”만큼 “기계가 글의 구조와 글쓴이를 정확히 이해하게 만드는 것”이 중요해진다.

이 글은 Gatsby 2 기반 기술 블로그(글 186편)에 SEO, GEO, AEO 개선을 적용한 과정을 정리한다. 추상적인 체크리스트보다 실제로 바꾼 코드와 수치를 중심으로 쓴다.

SEO, GEO, AEO는 무엇이 다른가

세 용어는 겹치는 부분이 많지만, 목표로 하는 “읽는 주체”가 다르다.

구분 풀이 누가 읽나 핵심 목표
SEO Search Engine Optimization 검색 크롤러 색인되고 순위에 오르기
AEO Answer Engine Optimization 답변 엔진(스니펫, 음성 비서, AI 요약) 질문에 대한 직접 답으로 채택되기
GEO Generative Engine Optimization 생성형 AI 검색 생성된 답변에 출처로 인용되기

실무에서 할 일은 대부분 같다. 명확한 구조, 기계가 읽을 수 있는 메타데이터, 신뢰할 수 있는 글쓴이 정보, 질문과 답이 분명한 본문이다. 차이는 어디에 더 힘을 주느냐 정도다.

적용 전 진단

작업 전에 빌드 결과물의 <head>를 직접 열어 보았다. 소스 코드만 보면 놓치는 부분이 많아서, 최종 HTML을 기준으로 진단하는 것이 정확하다.

npx gatsby build
# 홈 <head>에서 메타 태그만 추려 보기
grep -oE '<meta[^>]*>' public/index.html | head -30

발견한 문제는 다음과 같았다.

항목 상태 영향
og:image 없음 카카오톡·슬랙 공유 시 미리보기 이미지 없음
og:type 모든 페이지가 website 글 페이지가 “기사”로 인식되지 않음
JSON-LD 없음 글쓴이·게시일·FAQ를 기계가 구조적으로 알 수 없음
keywords 포트폴리오 페이지에서 넘겨도 무시됨 SEO 컴포넌트가 해당 prop을 받지 않는 버그
사이트맵 URL 927개 중 739개가 태그 페이지 대부분 글 1~2개짜리 얇은 페이지
robots.txt Sitemap: 줄 없음 일부 검색엔진이 사이트맵을 자동으로 찾지 못함

1. SEO 컴포넌트를 “페이지 유형”을 아는 컴포넌트로

Gatsby 스타터의 기본 SEO 컴포넌트는 제목과 설명만 받는다. 여기에 pathname, image, type, article, jsonLd를 추가해서, 페이지가 자기 유형에 맞는 메타데이터를 내보내게 했다.

// src/components/seo.js (핵심 부분)
const SEO = ({ description, lang, meta, title, keywords, image, pathname, type, article, jsonLd }) => {
  // ...siteMetadata 조회
  const pageUrl = toAbsoluteUrl(siteUrl, pathname || `/`)
  const imageUrl = toAbsoluteUrl(siteUrl, image || DEFAULT_IMAGE)

  const articleMeta = article
    ? [
        { property: `article:published_time`, content: article.publishedTime },
        { property: `article:modified_time`, content: article.modifiedTime },
        { property: `article:section`, content: article.section },
        ...(article.tags || []).map(tag => ({ property: `article:tag`, content: tag })),
      ].filter(m => m.content)
    : []

  return (
    <Helmet
      meta={[
        { property: `og:url`, content: pageUrl },
        { property: `og:type`, content: type },
        { property: `og:image`, content: imageUrl },
        { property: `og:locale`, content: `ko_KR` },
        { name: `twitter:card`, content: image ? `summary_large_image` : `summary` },
        // ...
      ].filter(m => m.content).concat(articleMeta).concat(meta)}
    >
      {jsonLd && (
        <script type="application/ld+json">
          {JSON.stringify({ "@context": "https://schema.org", "@graph": jsonLd })}
        </script>
      )}
    </Helmet>
  )
}

toAbsoluteUrl에서 encodeURI(decodeURI(path))를 쓴 이유가 있다. 이 블로그는 한글 slug를 쓰는데, 경로가 이미 인코딩된 상태로 들어오기도 하고 아닌 채로 들어오기도 한다. 한 번 풀고 다시 인코딩하면 이중 인코딩(%25EC…)을 막을 수 있다.

canonical 태그는 이미 gatsby-plugin-canonical-urls가 넣고 있어서 SEO 컴포넌트에서는 뺐다. canonical이 두 개 있으면 검색엔진이 어느 쪽도 신뢰하지 않을 수 있다.

2. JSON-LD는 @graph로 연결한다

구조화 데이터를 페이지마다 따로따로 넣기보다, @id로 서로 연결된 그래프를 만드는 편이 좋다. “이 글(BlogPosting)의 저자는 이 사람(Person)이고, 이 사이트(WebSite)의 일부다”라는 관계가 명확해지기 때문이다.

// src/utils/structuredData.js
export const personId = siteUrl => `${siteUrl}/#person`

export const personSchema = ({ siteUrl, author, social }) => ({
  "@type": "Person",
  "@id": personId(siteUrl),
  name: author.name,
  url: `${siteUrl}/portfolio/`,
  sameAs: [`https://github.com/${social.github}`, `https://codepen.io/${social.codepen}`],
})

export const blogPostingSchema = (siteMetadata, post) => ({
  "@type": "BlogPosting",
  headline: post.frontmatter.title,
  datePublished: post.frontmatter.dateISO,
  dateModified: post.frontmatter.updatedISO || post.frontmatter.dateISO,
  author: { "@id": personId(siteMetadata.siteUrl) }, // Person 노드를 참조
  // ...
})

페이지 유형별로 넣은 스키마는 다음과 같다.

페이지 스키마
홈 WebSite, Person
글 BlogPosting, Person, BreadcrumbList, (FAQ가 있으면) FAQPage
포트폴리오 ProfilePage + Person

AEO·GEO 관점에서 특히 중요한 것은 Person의 sameAs다. GitHub 같은 외부 프로필과 연결해 두면, 답변 엔진이 “누가, 어떤 근거로 쓴 글인지” 판단하는 데 도움이 된다.

3. 마크다운 FAQ에서 FAQPage를 자동으로 만든다

이 블로그의 글 상당수는 마지막에 다음 형식의 FAQ 섹션을 두고 있었다.

## FAQ

**Q: 질문 내용?**  
A: 답변 내용.

글마다 JSON-LD를 손으로 쓰는 대신, onCreateNode에서 본문을 파싱해 노드 필드로 붙였다.

// gatsby-node.js
const extractFaq = markdown => {
  // 코드 블록 안의 "## FAQ" 예시를 실제 FAQ로 오인하지 않도록 먼저 걷어낸다
  const section = markdown.replace(/^```[\s\S]*?^```/gm, ``).match(
    /^##\s+(?:FAQ|자주\s*묻는[^\n]*)[^\n]*\n([\s\S]*?)(?=^##\s|$(?![\s\S]))/m
  )
  if (!section) return []
  const faq = []
  // "**Q: …**" 또는 "**Q1. …**" 다음 줄의 "A: …"를 한 쌍으로 본다
  const pattern = /\*\*Q\d*[.::]\s*([\s\S]+?)\*\*\s*\n+\s*A[.::]\s*([\s\S]+?)(?=\n\s*\n|$(?![\s\S]))/g
  let match
  while ((match = pattern.exec(section[1])) !== null) {
    faq.push({ question: toPlainText(match[1]), answer: toPlainText(match[2]) })
  }
  return faq
}

exports.onCreateNode = ({ node, actions }) => {
  if (node.internal.type === `MarkdownRemark`) {
    actions.createNodeField({ node, name: `faq`, value: extractFaq(node.rawMarkdownBody || ``) })
  }
}

정규식은 처음에 **Q: 형식만 잡도록 짰다. 그런데 전체 글에 돌려 보니 FAQ 섹션이 있는데도 추출이 안 되는 글이 2편 나왔다. 확인해 보니 **Q1.처럼 번호를 붙인 형식이었다. Q\d*[.::]로 바꾼 뒤 54편, Q&A 316쌍이 추출되었다. 파서를 만들면 전체 데이터에 한 번 돌려 보고 “섹션은 있는데 결과가 0인 파일”을 찾는 것이 가장 빠른 검증법이다.

또 하나의 함정은 이 글 자체에서 나왔다. 위처럼 FAQ 형식을 설명하는 코드 블록 안에 ## FAQ가 들어 있어서, 추출기가 글 끝의 진짜 FAQ 대신 코드 블록 속 예시를 먼저 잡았다. 그래서 파싱 전에 펜스 코드 블록을 걷어내는 한 줄을 추가했다. 마크다운을 정규식으로 다룰 때는 코드 블록부터 제외한다는 원칙을 기억해 두면 좋다.

필드는 스키마에 명시해 둔다. 그래야 FAQ가 없는 글만 있는 환경에서도 GraphQL 쿼리가 깨지지 않는다.

type Fields {
  slug: String
  faq: [FaqItem]
  image: String
}
type FaqItem {
  question: String
  answer: String
}

FAQPage에 대한 현실적인 기대치

구글은 2023년 8월부터 FAQ 리치 결과(검색 결과에 펼쳐지는 Q&A)를 잘 알려진 정부·보건 관련 권위 사이트 위주로만 보여 주고 있다. 일반 기술 블로그가 FAQPage를 넣는다고 검색 결과에 Q&A가 펼쳐질 가능성은 낮다.

그럼에도 넣은 이유는 답변 엔진 관점이다. 질문과 답이 명시적으로 짝지어진 데이터는 기계가 “이 글이 어떤 질문에 답하는지”를 파악하는 데 여전히 유용하다. 단, 화면에 실제로 보이는 FAQ만 마크업해야 한다. 숨겨진 Q&A를 구조화 데이터에만 넣는 것은 가이드라인 위반이다.

4. 빌드 시 llms.txt 생성

llms.txt는 사이트의 개요와 주요 문서 목록을 LLM이 읽기 좋은 마크다운으로 제공하자는 제안 형식이다. 아직 표준이 아니고, 주요 AI 크롤러가 이 파일을 반드시 읽는다는 보장도 없다. 하지만 생성 비용이 거의 없으므로, 빌드할 때 자동으로 만들게 했다.

// gatsby-node.js
exports.onPostBuild = async ({ graphql }) => {
  const { data } = await graphql(`{ /* site 정보 + 전체 글 */ }`)
  const { title, description, siteUrl } = data.site.siteMetadata
  const byCategory = _.groupBy(data.allMarkdownRemark.nodes, n => n.frontmatter.category)

  const lines = [`# ${title}`, ``, `> ${description}`, ``]
  Object.keys(byCategory).sort().forEach(category => {
    lines.push(`## ${category}`, ``)
    byCategory[category].forEach(node => {
      lines.push(`- [${node.frontmatter.title}](${siteUrl}${encodeURI(node.fields.slug)}): ${node.frontmatter.description}`)
    })
    lines.push(``)
  })
  fs.writeFileSync(path.join(`public`, `llms.txt`), lines.join(`\n`))
}

같은 onPostBuild에서 robots.txt도 siteUrl 기반으로 생성하게 바꿨다. 도메인이 바뀌어도 Sitemap: 줄이 자동으로 따라온다.

User-agent: *
Allow: /

Sitemap: https://www.bottlehs.dev/sitemap.xml

5. 사이트맵 정리: 927개에서 264개로

태그가 739개였고, 대부분 글이 한두 개뿐이었다. 이런 페이지는 검색 사용자에게 가치가 거의 없고, 신규 사이트의 제한된 크롤링 예산을 잡아먹는다. 기준을 “글 3개 이상인 태그만 색인”으로 정하고, 판단을 한 곳에서만 하도록 태그 페이지 생성 시점의 context에 넣었다.

// gatsby-node.js — 태그 페이지를 만들 때 색인 여부를 결정
const MIN_POSTS_FOR_INDEXED_TAG = 3

tags.forEach(tag => {
  createPage({
    path: `/tags/${_.kebabCase(tag.fieldValue)}/`,
    component: blogTag,
    context: {
      tag: tag.fieldValue,
      noindex: tag.totalCount < MIN_POSTS_FOR_INDEXED_TAG,
    },
  })
})

태그 템플릿은 이 값을 보고 noindex, follow를 넣는다. follow를 유지하므로 태그 페이지를 통한 글 링크 탐색은 막지 않는다.

<SEO
  title={`#${tag} 관련 글`}
  meta={pageContext.noindex ? [{ name: `robots`, content: `noindex, follow` }] : []}
/>

사이트맵 플러그인은 같은 context.noindex 값을 읽어 제외한다. 태그 페이지와 사이트맵이 서로 다른 기준을 쓰면 “사이트맵에는 있는데 noindex인 페이지”라는 모순이 생기므로, 판단 로직은 반드시 하나여야 한다.

// gatsby-config.js — gatsby-plugin-sitemap 옵션
serialize: ({ site, allSitePage, allMarkdownRemark }) => {
  const lastmodBySlug = {}
  allMarkdownRemark.nodes.forEach(({ fields, frontmatter }) => {
    lastmodBySlug[fields.slug] = frontmatter.updated || frontmatter.date
  })

  return allSitePage.nodes
    .filter(page => !page.context?.noindex)
    .map(page => ({
      url: `${site.siteMetadata.siteUrl}${encodeURI(page.path)}`, // 한글 경로 인코딩
      ...(lastmodBySlug[page.path] && {
        lastmodISO: new Date(lastmodBySlug[page.path]).toISOString(),
      }),
    }))
},
지표 이전 이후
사이트맵 URL 927 264
태그 페이지(사이트맵 내) 739 76
lastmod 0 187
인코딩 안 된 한글 URL 578 0
noindex 처리된 태그 페이지 0 663

6. 배포 후에야 보인 버그: 존재하지 않는 대표 이미지

og:image는 본문의 첫 이미지를 쓰도록 했다. 로컬 빌드는 문제없이 통과했는데, 배포 후 실제 URL을 요청해 보니 일부 글의 og:image가 404였다. 30편의 글이 /assets/etc.png 같은 이미지를 참조하고 있었는데, 그 파일이 원래부터 저장소에 없었던 것이다. 본문 이미지도 깨져 있었지만 아무도 눈치채지 못했고, 대표 이미지로 끌어올리면서 공유 미리보기까지 깨질 뻔했다.

static/ 폴더에 실제로 파일이 있을 때만 대표 이미지로 쓰고, 없으면 기본 이미지로 대체하게 고쳤다.

const extractImage = markdown => {
  const match = markdown.match(/!\[[^\]]*\]\(\s*(\/[^)\s]+|https?:\/\/[^)\s]+)/)
  if (!match) return null
  const image = match[1]
  if (image.startsWith(`/`) && !fs.existsSync(path.join(__dirname, `static`, decodeURI(image)))) {
    return null // 기본 이미지로 대체
  }
  return image
}

교훈은 단순하다. 빌드 성공이 곧 정상은 아니다. 메타데이터가 가리키는 리소스까지 실제로 열리는지 확인해야 한다.

검증 방법

배포 후에는 다음 순서로 확인했다.

  1. curl로 실제 응답 확인: 사이트맵에서 URL 몇 개를 뽑아 상태 코드를 확인한다.
curl -s https://www.bottlehs.dev/sitemap.xml \
  | grep -o '<loc>[^<]*' | sed 's#<loc>##' | awk 'NR%60==1' \
  | while read u; do echo "$(curl -s -o /dev/null -w '%{http_code}' "$u") $u"; done
  1. JSON-LD 파싱 확인: 글 페이지의 ld+json을 JSON으로 파싱해 @type 목록을 본다.
  2. 사이트맵과 noindex의 일관성 확인: 사이트맵 안의 URL 중 noindex가 붙은 페이지가 0개인지 확인한다.
  3. 외부 검사 도구: 구글 리치 결과 테스트와 Schema Markup Validator로 문법 오류를 확인한다.

적용할 때 주의할 점

  • 구조화 데이터는 화면 내용과 일치해야 한다. 보이지 않는 정보를 마크업하면 무시되거나 불이익을 받을 수 있다.
  • 메타 태그는 한 곳에서만 만든다. canonical처럼 플러그인이 이미 넣는 태그를 컴포넌트에서 또 넣으면 중복된다.
  • 색인 기준은 하나의 소스에서 파생시킨다. 태그 페이지의 noindex와 사이트맵 제외가 같은 값을 보도록 설계했다.
  • GEO·AEO는 콘텐츠 품질을 대신하지 않는다. 구조화 데이터는 좋은 글을 기계가 더 잘 이해하게 도울 뿐, 얇은 글을 좋은 글로 바꿔 주지 않는다.

FAQ

Q: Gatsby 2처럼 오래된 버전에서도 이 방식이 동작하나요?
A: 동작한다. 이 글의 코드는 Gatsby 2.32와 react-helmet 기반에서 적용하고 빌드까지 확인한 것이다. Gatsby 4.19부터는 react-helmet 대신 내장 Head API를 쓰는 것이 권장되지만, 메타데이터를 구성하는 로직은 그대로 옮길 수 있다.

Q: FAQ 리치 결과가 거의 안 나온다면 FAQPage는 넣을 필요가 없나요?
A: 검색 결과 화면만 보면 효과가 작다. 하지만 질문과 답의 짝을 명시적으로 제공한다는 점에서 답변 엔진이 글을 이해하는 데는 여전히 도움이 되고, 본문에 이미 있는 FAQ를 자동 변환하는 것이라 유지 비용도 거의 없다.

Q: llms.txt를 만들면 AI 검색에 인용되나요?
A: 보장되지 않는다. llms.txt는 아직 제안 단계의 형식이고, 어떤 AI 서비스가 이를 읽는지는 서비스마다 다르다. 비용이 거의 들지 않는 선택지로 보고 추가하는 것이 적절하다.

Q: 태그 페이지를 아예 삭제하지 않고 noindex만 한 이유는 무엇인가요?
A: 태그 페이지는 방문자가 관련 글을 찾는 탐색 수단으로 여전히 유용하다. noindex, follow로 두면 검색 결과에서는 빠지지만, 크롤러는 그 페이지의 링크를 따라 글을 발견할 수 있다.

Q: 글 3개라는 기준은 어떻게 정했나요?
A: 정답이 있는 값은 아니다. 태그 페이지가 “여러 글을 묶어 보여 주는 가치”를 가지려면 최소 몇 편은 있어야 한다고 보고 3으로 정했다. 상수 하나로 관리하므로 사이트 상황에 맞게 조정하면 된다.


Written by Jeon Byung Hun 개발을 즐기는 bottlehs - Engineer, MS, AI, FE, BE, OS, IOT, Blockchain, 설계, 테스트