목록으로

애드센스 ads.txt 경고를 받고 파헤치다가 찾은 진짜 색인 버그

시작: 애드센스의 수익 손실 경고

블로그를 운영하다 보면 가끔 애드센스에서 이런 알림을 받는다.

수익 손실 위험 - 수익에 심각한 영향을 미치지 않도록 사이트에서 발견된 ads.txt 파일 문제를 해결해야 합니다.

무섭게 생긴 문구지만, 실제로 뭐가 문제인지 하나씩 확인해보기로 했다.

ads.txt 자체는 멀쩡했다

먼저 배포된 사이트에서 ads.txt가 정상적으로 서빙되는지 확인했다.

curl -sS -D - "https://iambilly-blog.vercel.app/ads.txt"

결과는 200 OK, 내용도 애드센스가 요구하는 값과 완전히 일치했다.

google.com, pub-89xxxxxxxxx575, DIRECT, f08c47fec0942fa0

ads.txt는 쉼표로 구분된 4개 컬럼으로 구성되는데, 각각의 의미는 다음과 같다.

컬럼값의미
1google.com이 광고 인벤토리를 판매할 권한을 가진 광고 시스템(SSP)의 도메인
2pub-89xxxxxxxxx575그 광고 시스템 안에서 나(퍼블리셔)를 식별하는 계정 ID
3DIRECT나와 광고 시스템의 관계. 내가 직접 계약한 관계면 DIRECT, 중개상을 거치면 RESELLER
4f08c47fec0942fa0광고 시스템(여기선 구글)의 인증 기관 ID. 계정마다 다른 값이 아니라 구글을 쓰는 모든 사이트가 공통으로 갖는 고정값

즉 이 한 줄은 "구글이 내 사이트의 광고 인벤토리를 직접 판매할 정당한 권한을 갖고 있다"는 걸 광고주/거래소 쪽에 선언하는 용도다. ads.txt가 만들어진 이유 자체가 "이 사이트 광고 인벤토리는 나만 팔 수 있다"를 공개적으로 못박아서, 제3자가 내 사이트인 척 가짜 인벤토리를 파는 도메인 스푸핑 사기를 막기 위함이다.

혹시 구글 크롤러만 차단되는 건 아닌지 Mediapartners-Google, AdsBot-Google User-Agent로도 다시 요청해봤지만 결과는 동일했다. 리다이렉트도 http → https 한 번뿐이라 문제가 될 범위가 아니었고, 파일을 hex 덤프로 열어 BOM이나 숨은 문자가 섞였는지도 확인했지만 깨끗했다.

즉, 코드/설정 쪽에는 고칠 게 없었다. 이런 경우 남는 건 구글 쪽 크롤러의 재검증 주기 지연인데, 실제로 애드센스는 ads.txt를 실시간이 아니라 주기적으로만 재크롤링하기 때문에 파일이 정상이어도 예전 경고가 한동안 남아있는 경우가 흔하다고 한다.

그런데 색인된 페이지 수가 이상하게 적다

ads.txt는 결론이 났지만, 서치콘솔을 열어보니 다른 게 눈에 띄었다. 블로그 글은 30개가 넘는데 색인된 페이지는 겨우 7개뿐이었다. 다만 이 숫자를 100% 자연 크롤링 결과로 보기는 애매한 게, 예전에 URL 검사 도구로 몇몇 페이지를 직접 "색인 생성 요청"했던 기억이 어렴풋이 있다. 정확히 몇 개를 수동으로 요청했는지는 기억이 가물가물해서, 7개 중 일부는 수동 요청 덕분에 색인된 걸 수도 있다는 점은 감안해야 한다. 그래도 30개 넘는 글 중 7개는 누가 봐도 낮은 비율이라, 이건 진짜 문제일 가능성이 높아서 사이트맵부터 열어봤다.

curl -sS "https://iambilly-blog.vercel.app/sitemap.xml"

그런데 이상한 URL들이 섞여 있었다.

/blog/css-layouts-copy
/blog/css-layouts%20copy       <- 공백이 그대로 인코딩된 URL
/blog/nuxt-useasyncdata-cache-error
/blog/nuxt-useAsyncData-cache-error   <- 대소문자만 다른 중복 URL

같은 글인데 URL이 두 개씩 존재하고 있었다.

원인: 파일명 그대로 라우트를 만드는 코드

nuxt.config.ts에 이런 코드가 있었다.

nitro: {
  prerender: {
    crawlLinks: true,
    routes: await (async () => {
      const blogFiles = await fs.readdir(resolve('content/blog'));
      const blogRoutes = blogFiles.map(file => `/blog/${file.replace('.md', '')}`);
      return [...blogRoutes];
    })(),
  },
},

content/blog 폴더의 파일명을 그대로 URL로 바꿔서 프리렌더 라우트 목록을 만드는 코드였다. 문제는 실제 글 조회는 @nuxt/content가 슬러그화(소문자 변환, 공백 → 하이픈)한 경로 기준으로 동작한다는 점이다.

그래서 파일명에 대문자나 공백이 섞인 글에서 URL이 갈라졌다.

실제 파일정상 동작 URL이 코드가 만든 깨진 URL
nuxt-useAsyncData-cache-error.md/blog/nuxt-useasyncdata-cache-error/blog/nuxt-useAsyncData-cache-error
css-layouts copy.md (파일명 자체도 잘못 지어짐)/blog/css-layouts-copy/blog/css-layouts%20copy

실제로 오른쪽 URL에 접속해보면 200 응답은 오는데 본문이 텅 비어 있었다. 빌드 시점에 저 파일명 그대로 정적 페이지가 만들어졌는데, 콘텐츠 조회 시 슬러그가 안 맞아서 데이터를 못 찾은 것이다. 그리고 이 빈 페이지들이 사이트맵에도 그대로 올라가서 구글에 노출되고 있었다.

구글 입장에서는 "응답은 오는데 내용이 없는" URL을 계속 크롤링하게 되는 셈이라, 크롤 예산이 낭비되고 정상 글의 색인도 늦어질 수 있는 구조였다.

고친 방법

1. 파일명 기반 라우트 생성 코드 제거

nitro: {
  prerender: {
    crawlLinks: true,
  },
},

애초에 이 코드는 불필요했다. crawlLinks: true와 @nuxtjs/sitemap 모듈이 콘텐츠 컬렉션의 정상 경로를 기준으로 알아서 라우트를 찾아주기 때문이다.

2. 이름이 잘못된 파일 정리

css-layouts copy.md는 예전에 다른 글을 복사해서 새 글을 쓴 흔적이 파일명에 그대로 남아있던 케이스였다. 실제 내용은 "선언적 프로그래밍 vs 절차형 프로그래밍"이라 파일명을 내용에 맞게 바꿨다.

git mv "content/blog/css-layouts copy.md" \
       "content/blog/declarative-vs-procedural-programming.md"

검증

로컬에서 빌드 후 사이트맵을 다시 확인했다.

curl -sS "http://localhost:3000/sitemap.xml" | grep -c "<loc>"

수정 전에는 중복/깨진 URL이 섞여 있었지만, 수정 후에는 실제 글 31개 + /blog + /log, 정확히 33개만 깔끔하게 남았다.

정리

문제원인이번 수정으로 해결됐나
애드센스 ads.txt 경고구글 쪽 재크롤링 지연 (파일 자체는 정상)아니오 — 코드 문제가 아니라서 별도 조치 불가, 시간이 해결
색인 페이지 수 부족파일명 기반 라우트 생성 코드의 슬러그 불일치로 사이트맵에 빈 URL 노출부분적으로 해결 — 버그는 제거했지만 구글이 기존에 알던 URL을 색인에서 완전히 빼는 데는 시간이 걸릴 수 있음

무서운 경고 문구를 받으면 일단 화면에 보이는 문구 그대로 믿기보다, 실제로 무엇이 어떻게 동작하는지 하나씩 curl로 찍어보는 게 제일 빠르다는 걸 다시 느꼈다. 그리고 예상과 다른 곳(ads.txt가 아니라 사이트맵)에서 진짜 원인을 찾은 것도 재밌는 경험이었다.