블로그를 운영하다 보면 가끔 애드센스에서 이런 알림을 받는다.
수익 손실 위험 - 수익에 심각한 영향을 미치지 않도록 사이트에서 발견된 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개 컬럼으로 구성되는데, 각각의 의미는 다음과 같다.
| 컬럼 | 값 | 의미 |
|---|---|---|
| 1 | google.com | 이 광고 인벤토리를 판매할 권한을 가진 광고 시스템(SSP)의 도메인 |
| 2 | pub-89xxxxxxxxx575 | 그 광고 시스템 안에서 나(퍼블리셔)를 식별하는 계정 ID |
| 3 | DIRECT | 나와 광고 시스템의 관계. 내가 직접 계약한 관계면 DIRECT, 중개상을 거치면 RESELLER |
| 4 | f08c47fec0942fa0 | 광고 시스템(여기선 구글)의 인증 기관 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을 계속 크롤링하게 되는 셈이라, 크롤 예산이 낭비되고 정상 글의 색인도 늦어질 수 있는 구조였다.
nitro: {
prerender: {
crawlLinks: true,
},
},
애초에 이 코드는 불필요했다. crawlLinks: true와 @nuxtjs/sitemap 모듈이 콘텐츠 컬렉션의 정상 경로를 기준으로 알아서 라우트를 찾아주기 때문이다.
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가 아니라 사이트맵)에서 진짜 원인을 찾은 것도 재밌는 경험이었다.