실측 기반 소개

Astro 5로 애드센스 블로그 만들기 — 콘텐츠 스키마부터 광고 슬롯 제어까지

툴콕(toolkok.com)을 Astro 5로 직접 만든 기록. 콘텐츠 컬렉션 스키마, 상수 한 곳으로 켜는 광고 슬롯, 실제 겪은 빌드 에러까지.

도구 Astro 5
가격 무료 (오픈소스)
툴콕 점수 8.8
Astro 5로 애드센스 블로그 만들기 — 콘텐츠 스키마부터 광고 슬롯 제어까지 커버 이미지
21 MIN

애드센스 심사를 염두에 둔 블로그를 만들 때 가장 성가신 건 광고 그 자체입니다. 심사를 넣기 전에는 사이트에 광고 스크립트가 없어야 정상인데, 레이아웃은 광고가 들어갈 자리를 미리 확보한 상태여야 합니다. 승인 나고 나서 부랴부랴 자리를 만들면 여백이 틀어지고, 그때 CLS 잡느라 또 하루를 씁니다.

그렇다고 <ins class="adsbygoogle"> 를 미리 여기저기 박아두면 반대 문제가 생깁니다. 승인 메일이 오는 날 홈, 글 상세, 사이드바에 흩어진 슬롯을 전부 찾아다니며 pub-ID와 슬롯 ID를 붙여넣어야 합니다. 파일 하나만 빠뜨려도 그 자리는 계속 빈 채로 남습니다.

이 글은 툴콕(toolkok.com)을 Astro 5로 만들면서 그 문제를 어떻게 풀었는지에 대한 기록입니다. 프론트매터 스키마, 광고 스위치를 상수 한 곳으로 모은 구조, 심사에 필요한 페이지, 그리고 중간에 실제로 막혔던 빌드 에러 하나를 순서대로 정리했습니다. 아직 애드센스 승인은 받기 전이고, 그 전에 끝내둘 수 있는 구조 작업만 다룹니다.

왜 Astro 5였나

애드센스 수익형 블로그의 요구사항은 단순합니다. 페이지가 빨리 뜨고, 마크다운만 추가하면 글이 늘고, 광고 스크립트 말고는 클라이언트 JS가 없는 게 좋습니다. 이 조건에서 Astro는 기본값이 이미 정답에 가깝습니다.

실제로 이 사이트의 package.json 의존성은 세 줄이 전부입니다.

{
  "dependencies": {
    "@astrojs/rss": "^4.0.11",
    "@astrojs/sitemap": "^3.2.1",
    "astro": "^5.1.1"
  }
}

React도 Vue도 없습니다. 인터랙션이라고 할 만한 게 목차 앵커 이동 정도라서 프레임워크 런타임을 얹을 이유가 없었습니다. 설치된 실제 버전은 Astro 5.18.2입니다.

astro.config.mjs도 짧습니다. 사이트 URL, 사이트맵 통합, 코드 하이라이트 테마가 전부입니다.

import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://toolkok.com',
  integrations: [sitemap()],
  markdown: {
    shikiConfig: {
      theme: 'github-light',
      wrap: true,
    },
  },
});

site 값을 넣어두면 사이트맵과 RSS가 절대 URL을 만들 때 이 값을 씁니다. 심사 단계에서 사이트맵 URL이 상대경로로 나가면 서치콘솔에서 그대로 반려되므로, 이건 처음부터 채워두는 편이 낫습니다. wrap: true는 모바일에서 코드블록 가로 스크롤을 줄이려고 켰습니다.

콘텐츠 컬렉션 스키마부터 정한다

글을 쓰기 전에 프론트매터 스키마를 먼저 확정했습니다. 리뷰 블로그는 글마다 점수, 가격, 검증 여부 같은 구조화된 값이 붙는데, 이걸 나중에 정하면 이미 쓴 글을 전부 손봐야 합니다.

Astro 5는 콘텐츠 설정 파일이 src/content.config.ts이고, 컬렉션마다 loader를 명시합니다. Astro 4 시절 예제(src/content/config.ts, loader 없음)를 그대로 붙여넣으면 동작하지 않으니 주의해야 합니다.

import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const posts = defineCollection({
  loader: glob({ base: './src/content/posts', pattern: '**/*.{md,mdx}' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),

    /** 카테고리 슬러그 — consts.ts의 CATEGORIES 키와 일치 */
    category: z.enum(['ai-tool', 'dev', 'design', 'automation']),
    tags: z.array(z.string()).default([]),

    /** 리뷰 대상 도구 이름 */
    tool: z.string().optional(),
    /** 툴콕 점수 0~10 */
    score: z.number().min(0).max(10).optional(),
    /** 가격 표기 */
    pricing: z.string().optional(),
    /** 직접 실측·검증했으면 true → '검증완료' 배지 */
    verified: z.boolean().default(false),

    featured: z.boolean().default(false),
    draft: z.boolean().default(false),
    heroImage: z.string().optional(),
  }),
});

export const collections = { posts };

여기서 실제로 값을 하는 부분이 몇 군데 있습니다.

필드정의이유
categoryz.enum([...])오타난 카테고리는 빌드가 막습니다. 문자열로 뒀다면 desgin 같은 값이 조용히 빈 페이지를 만들었을 겁니다
scorez.number().min(0).max(10)10점 만점 표기를 강제. 11점이나 95점이 들어올 여지를 없앰
pubDatez.coerce.date()프론트매터의 2026-07-19 문자열을 Date로 변환. 정렬·RSS·구조화데이터가 전부 Date를 요구함
verifiedz.boolean().default(false)기본값이 false. 실측한 글에만 명시적으로 true를 달아야 배지가 붙음
draftz.boolean().default(false)getCollection에서 필터해 빌드 자체에서 제외

verified의 기본값을 false로 둔 건 정책적인 선택입니다. 검증 배지는 붙이는 게 기본이 아니라 빼는 게 기본이어야, 실측 안 한 글에 실수로 배지가 달리지 않습니다.

draft 처리는 조회 지점마다 반복됩니다. 홈, 카테고리, 태그, RSS 전부 같은 필터를 씁니다.

const posts = await getCollection('posts', ({ data }) => !data.draft);

Astro 5에서는 엔트리 식별자가 entry.slug가 아니라 entry.id이고, 렌더링도 entry.render() 대신 astro:content에서 가져온 render() 함수를 씁니다. 글 상세 라우트는 이렇게 됩니다.

---
import { getCollection, render } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('posts', ({ data }) => !data.draft);
  return posts.map((post) => ({ params: { slug: post.id }, props: { post, posts } }));
}

const { post, posts } = Astro.props;
const { Content, headings } = await render(post);
---

애드센스 스위치를 상수 한 곳으로

서두에서 말한 문제의 해법이 여기입니다. 광고 관련 값은 전부 src/consts.ts 한 파일에 모았습니다.

/**
 * 애드센스 설정.
 * 승인 전에는 빈 문자열로 두세요 — 광고 자리에 점선 플레이스홀더만 표시되고
 * 실제 광고 스크립트는 로드되지 않습니다.
 * 승인 후 client 값(ca-pub-...)만 넣으면 전체 슬롯이 한 번에 켜집니다.
 */
export const ADSENSE = {
  client: '', // 예: 'ca-pub-1234567890123456'
  slots: {
    top: '',       // 히어로 하단 배너
    inFeed: '',    // 목록 중간 인피드
    inArticle: '', // 본문 중간
    sidebar: '',   // 사이드바 고정
  },
} as const;

그리고 광고 자리는 전부 AdSlot.astro 컴포넌트 하나로 통일했습니다. 핵심은 live 한 줄입니다.

---
import { ADSENSE } from '../consts';

interface Props {
  placement: keyof typeof ADSENSE.slots;
  format?: 'auto' | 'fluid' | 'rectangle';
  label?: string;
  class?: string;
}

const { placement, format = 'auto', label = '광고 영역', class: className = '' } = Astro.props;

const client = ADSENSE.client;
const slot = ADSENSE.slots[placement];
const live = Boolean(client && slot);
---

<aside class={`ad ${className}`} aria-label="광고">
  {
    live ? (
      <ins
        class="adsbygoogle"
        style="display:block"
        data-ad-client={client}
        data-ad-slot={slot}
        data-ad-format={format}
        data-full-width-responsive="true"
      />
      <script is:inline>
        (adsbygoogle = window.adsbygoogle || []).push({});
      </script>
    ) : (
      <div class="ad-ph">
        <span class="mono">{label}</span>
        <span class="ad-ph__sub mono">{placement}</span>
      </div>
    )
  }
</aside>

placementkeyof typeof ADSENSE.slots로 받는 게 작지만 중요한 부분입니다. 호출하는 쪽에서 placement="sidebr" 같은 오타를 내면 타입 단계에서 걸립니다. 슬롯 키를 나중에 추가해도 자동으로 허용값이 늘어납니다.

호출부는 이런 모양입니다. 위치와 포맷만 넘기고 ID는 관여하지 않습니다.

<AdSlot placement="top" label="광고 영역 · 본문 상단" class="post__ad" />
<AdSlot placement="inArticle" label="광고 영역 · 본문 하단" class="post__ad" />
<AdSlot placement="sidebar" format="rectangle" label="광고 영역 · 사이드바" />
<AdSlot placement="inFeed" format="fluid" label="광고 영역 · 인피드" class="feed-ad" />

승인 전에는 점선 박스가 렌더링됩니다. 이게 그냥 편의 기능이 아니라 개발 중에 계속 값을 합니다. 광고가 들어갈 자리의 최소 높이(현재 96px)가 레이아웃에 반영된 상태로 작업하니까, 나중에 실제 광고가 들어와도 주변 여백을 다시 잡을 일이 없습니다.

adsbygoogle.js 로더 자체도 같은 상수로 막았습니다. BaseLayout.astro<head> 안입니다.

{
  ADSENSE.client && (
    <script
      async
      src={`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${ADSENSE.client}`}
      crossorigin="anonymous"
    />
  )
}

client가 빈 문자열이면 이 태그는 아예 출력되지 않습니다. 심사 전 사이트에 광고 네트워크 스크립트가 붙어 있는 상태를 원천적으로 피할 수 있고, 개발 중 페이지 로드도 그만큼 가벼워집니다.

마지막으로 public/ads.txt는 주석 처리해뒀습니다. 잘못된 pub-ID가 들어간 ads.txt는 없느니만 못하기 때문입니다.

# 애드센스 승인 후 아래 줄의 pub-ID를 본인 값으로 교체하세요.
# google.com, pub-0000000000000000, DIRECT, f08c47fec0942fa0

정리하면 승인 후 해야 할 일은 두 파일입니다. consts.tsADSENSE 객체를 채우고, ads.txt의 주석을 풀면 홈 상단, 인피드, 본문 상단, 본문 하단, 사이드바 다섯 자리가 동시에 켜집니다.

심사 전에 만들어둔 페이지

애드센스 심사는 콘텐츠 품질만 보는 게 아니라 사이트가 “운영되는 매체”처럼 생겼는지도 봅니다. 그래서 글보다 먼저 만든 페이지들이 있습니다.

  • /about — 리뷰 기준, 운영자 소개, 수익·협찬 정책을 명시
  • /privacy — 개인정보처리방침. 애드센스 쿠키와 제3자 광고 사용을 별도 항목으로 서술
  • /contact — 이메일 문의 창구와 답변 가능 범위
  • /category/[category], /tag/[tag] — 콘텐츠 구조를 드러내는 목록 페이지
  • /rss.xml, /sitemap-index.xml@astrojs/rss@astrojs/sitemap으로 생성
  • /404 — 잘못된 URL에서 빈 화면이 나오지 않도록

글 상세에는 작성자 바이라인과 하단 저자 카드를 넣었습니다. E-E-A-T 관점에서 “누가 썼는지 모르는 리뷰”를 피하려는 목적입니다. 구조화 데이터도 레이아웃에서 자동으로 붙습니다.

const jsonLd = article
  ? {
      '@context': 'https://schema.org',
      '@type': 'Article',
      headline: title,
      description,
      datePublished: article.published,
      dateModified: article.modified ?? article.published,
      author: { '@type': 'Person', name: SITE.author },
      publisher: { '@type': 'Organization', name: SITE.name },
      mainEntityOfPage: canonical,
    }
  : {
      '@context': 'https://schema.org',
      '@type': 'WebSite',
      name: SITE.name,
      url: SITE.url,
      description: SITE.description,
    };

글 상세에서만 article prop을 넘기고, 나머지 페이지는 자동으로 WebSite 타입이 됩니다. canonicalAstro.url.pathnamesite 값을 조합해 페이지마다 만들어집니다. 카테고리와 태그 페이지가 늘어날수록 중복 콘텐츠로 잡히기 쉬운데, canonical을 레이아웃에서 일괄 처리하면 페이지를 추가할 때마다 신경 쓸 필요가 없습니다.

라우트가 얼마나 불어나는지는 빌드 산출물로 확인할 수 있습니다. 글이 3편이던 시점의 dist/에는 HTML 23개가 있었습니다. 글 3개, 카테고리 4개, 태그 11개, 그리고 홈·about·privacy·contact·404입니다. 글보다 목록 페이지가 훨씬 빨리 늘어난다는 뜻이고, 그래서 태그를 무분별하게 다는 걸 자제하기로 했습니다.

막혔던 빌드 에러: 템플릿 안의 비교 연산자

사이드바 목차를 만들면서 실제로 빌드가 깨졌습니다. 처음엔 필터를 템플릿 안에 인라인으로 넣었습니다.

<!-- 이렇게 쓰면 빌드가 깨집니다 -->
<ul>
  {headings.filter((h) => h.depth <= 3).map((h) => (
    <li><a href={`#${h.slug}`}>{h.text}</a></li>
  ))}
</ul>

원인은 h.depth <= 3<입니다. .astro 파일의 템플릿 영역은 JSX 비슷하게 생겼지만 별도 컴파일러가 파싱하고, 이 컴파일러는 표현식 안의 <를 태그의 시작으로 오인합니다. <= 3).map(...부터가 태그 이름으로 읽히면서 그 뒤 구조 전체가 어긋납니다. 문법은 멀쩡해 보이는데 엉뚱한 위치에서 파싱 오류가 나기 때문에 원인을 찾는 데 시간이 걸렸습니다.

해결은 두 단계로 했습니다. 첫째, 계산을 프론트매터(--- 사이 스크립트 영역)로 옮깁니다. 이 영역은 일반 TypeScript라서 <를 써도 아무 문제가 없습니다. 둘째, 그래도 헷갈릴 여지를 없애려고 비교 자체를 ===로 바꿨습니다.

---
/** 목차: h2~h3만 (템플릿 안에서 비교 연산자를 쓰면 컴파일러가 태그로 오인함) */
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
---

{
  toc.length > 0 && (
    <nav class="toc card" aria-label="목차">
      <h2 class="mono toc__title">목차</h2>
      <ul>
        {toc.map((h) => (
          <li class={`toc__d${h.depth}`}>
            <a href={`#${h.slug}`}>{h.text}</a>
          </li>
        ))}
      </ul>
    </nav>
  )
}

여기서 얻은 규칙은 간단합니다.

.astro 템플릿 표현식 안에는 <가 들어가는 연산자(<, <=)를 두지 않는다. 필터·정렬·조건 계산은 프론트매터에서 끝내고 템플릿에는 결과 변수만 넘긴다.

>>=는 상대적으로 안전하고, 실제로 위 코드에서도 toc.length > 0은 템플릿에 그대로 남아 있습니다. 문제가 되는 건 여는 꺾쇠 쪽입니다. 부수 효과로 코드도 읽기 좋아집니다. 템플릿에는 마크업만 남고, 데이터 가공은 전부 위쪽 한 곳에 모입니다.

배포 설정

Cloudflare Pages에 올릴 예정이라 정적 파일 두 개를 public/에 미리 넣어뒀습니다. 빌드 결과와 함께 그대로 복사됩니다.

public/_headers — 보안 헤더와 캐시 정책입니다. 해시가 붙은 _astro/* 에셋만 1년 immutable로 잡습니다.

/*
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  X-Frame-Options: SAMEORIGIN
  Permissions-Policy: geolocation=(), microphone=(), camera=()

/_astro/*
  Cache-Control: public, max-age=31536000, immutable

/favicon.svg
  Cache-Control: public, max-age=604800

public/_redirects — www를 apex로 301 처리합니다. 색인 단계에서 두 주소가 각각 잡히면 곤란하기 때문에 도메인 연결 전에 넣어뒀습니다.

# www → apex (Cloudflare Pages)
https://www.toolkok.com/* https://toolkok.com/:splat 301!

Cloudflare Pages 쪽 빌드 설정은 프레임워크 프리셋 Astro, 빌드 명령 npm run build, 출력 디렉터리 dist, Node 버전 22입니다.

주의사항: Node 버전

Node 버전을 22로 고정해둔 데는 이유가 있습니다. 로컬(Windows, Node v24.13.0)에서 npm run build를 돌리면 콘텐츠 동기화와 타입 생성까지는 정상적으로 끝나고 Collecting build info... 직후 프로세스가 종료 코드 -1073740791(0xC0000409, STATUS_STACK_BUFFER_OVERRUN)로 죽습니다. 세 번 연속 같은 지점에서 재현됐습니다.

[content] Syncing content
[content] Synced content
[types] Generated 947ms
[build] output: "static"
[build] Collecting build info...
[build] ✓ Completed in 975ms.
EXITCODE=-1073740791

원인을 끝까지 파고들지는 않았습니다. Node 24 자체 문제인지, 경로에 한글이 섞인 환경 때문인지, 특정 의존성 문제인지 아직 확인하지 못했습니다. 다만 이 글에 인용한 dist/ 산출물은 이 증상이 나타나기 전 빌드 결과이고, 배포 환경은 Node 22로 지정해두었습니다. Astro 프로젝트를 새로 잡는다면 최신 Node를 무조건 쓰기보다 LTS로 맞춰두는 편이 안전합니다.

점수: 8.8

정적 블로그, 그중에서도 광고 수익형 사이트라는 용도에 한정한 평가입니다.

좋은 점

  • 의존성 3개로 SEO·RSS·사이트맵·코드 하이라이트가 전부 해결됩니다. 클라이언트 JS를 얹지 않는 게 기본값이라 광고 스크립트가 유일한 외부 부하가 됩니다.
  • zod 기반 콘텐츠 스키마가 빌드 타임에 프론트매터를 검증합니다. 카테고리 오타, 범위를 벗어난 점수, 날짜 형식 문제를 배포 전에 잡아줍니다.
  • 컴포넌트 props를 keyof typeof로 묶으면 광고 슬롯 같은 설정값이 타입으로 보호됩니다. 상수 파일 하나만 고쳐 사이트 전체 광고를 켜고 끄는 구조를 무리 없이 만들 수 있었습니다.
  • 프론트매터(TS)와 템플릿이 한 파일에 있어서 페이지별 데이터 가공을 별도 레이어 없이 처리할 수 있습니다.

아쉬운 점

  • 템플릿 파서가 표현식 안의 <=를 태그로 오인합니다. 오류 위치가 실제 원인과 떨어져 있어서 처음 겪으면 시간을 씁니다.
  • Astro 4에서 5로 오면서 콘텐츠 컬렉션 API가 바뀌었습니다(설정 파일 위치, loader 필수, entry.slugentry.id, entry.render()render(entry)). 검색으로 나오는 예제 상당수가 아직 구버전 기준이라 그대로 붙여넣으면 동작하지 않습니다.
  • 로컬 Node 24 환경에서 빌드가 재현 가능하게 죽었습니다. 원인은 미확인이지만, 런타임 버전에 민감할 수 있다는 점은 감안해야 합니다.
  • 인터랙션이 필요한 순간이 오면 결국 아일랜드에 프레임워크를 얹어야 하고, 그 시점부터는 “의존성 3개”의 장점이 사라집니다.

이럴 때 쓰세요: 마크다운 글이 계속 쌓이고, 광고와 SEO 메타는 전역에서 일괄 제어하고 싶고, 클라이언트 JS는 최소로 가져가려는 콘텐츠 사이트.

아직 남은 일

구조는 끝났지만 승인 조건은 아직 채우는 중입니다. 남은 항목은 이렇습니다.

  • 깊이 있는 글 20~30편 확보 (얕은 글을 대량으로 찍는 건 구글의 scaled content abuse 정책 대상입니다)
  • 커스텀 도메인 연결
  • 승인 후 consts.tsADSENSE 값과 ads.txt 반영
  • 서치콘솔 등록 및 색인 확인

승인 결과와 실제 광고를 켠 뒤의 레이아웃 변화는 나중에 별도로 정리하겠습니다. 이 글 시점에서 확실히 말할 수 있는 건, 승인 전에 광고 자리를 플레이스홀더로 잡아두고 스위치를 한 파일에 모아두는 작업은 나중에 반드시 이득이 된다는 정도입니다.

관련 문서는 Astro 공식 문서, Astro 콘텐츠 컬렉션 가이드, Cloudflare Pages 문서를 참고했습니다.