구글이 내 사이트를 안 긁는다 - Search Console과 서버 로그로 Googlebot을 직접 추적한 기록
도메인(k0.co.kr)을 붙이고 사이트맵까지 제출한 지 한참 됐는데, Search Console을 열어보니 색인된 페이지가 4개뿐이었다. 나머지는 전부 "발견됨 - 현재 색인이 생성되지 않음". 애드센스 쪽에서는 "ads.txt 파일을 찾을 수 없음" 경고까지 붙어 있었다. 블로그 글은 20편이 넘고 도구 페이지도 20개나 되는데 검색에는 거의 안 걸리는 상태였다.
처음엔 당연히 "내가 뭘 잘못 설정했겠지"라고 생각했다. 그래서 기술적인 SEO 설정부터 하나하나 뒤졌는데, 결론부터 말하면 설정은 멀쩡했고 문제는 구글이 아예 오지 않는다는 것이었다. 그걸 확인할 수 있었던 건 Search Console이 아니라 우리 서버의 요청 로그였다. 이 글은 그 과정과, 로그로 크롤러를 추적하는 방법, 그리고 그 사이에 겪은 몇 가지 함정을 정리한 기록이다.
먼저 의심한 것 - 기술적 SEO 체크리스트
색인이 안 될 때 흔히 원인으로 꼽히는 것들을 전부 점검했다. 각 항목은 curl로 실제 응답을 받아서 확인했다.
점검 항목과 결과
- canonical 태그 — 모든 페이지가
https://k0.co.kr/...자기 자신을 정확히 가리킴. 이상 없음. - noindex — meta 태그,
X-Robots-Tag헤더 둘 다 없음. 이상 없음. - robots.txt — 관리자·메일·검색 페이지만 막고 나머지는 허용. 사이트맵 경로도 적혀 있음. 이상 없음.
- title / description — 페이지마다 고유함. 중복 없음.
- HTTPS 인증서 — 인증서의 도메인 목록(SAN)에
k0.co.kr이 들어 있고, 체인도 정상. 이상 없음. - ads.txt — 200으로 잘 내려가고, 애드센스 게시자 ID도 일치. 이상 없음.
- 외부 접근 — 휴대폰 LTE로 접속해도 정상. 공유기 포트포워딩 문제도 아님.
다 멀쩡했다. 설정이 멀쩡한데 색인이 안 된다면 남은 가능성은 두 가지다. 구글이 와서 보고 "가치 없다"고 판단했거나, 애초에 구글이 오지 않았거나. 둘은 해결 방법이 완전히 다르기 때문에 어느 쪽인지 먼저 알아야 했다.
답은 서버 로그에 있었다
마침 며칠 전에 스캔 공격을 조사하려고 모든 요청을 DB에 남기는 기능을 만들어둔 게 있었다. Flask의 after_request 훅에서 요청마다 IP, 경로, 상태 코드, User-Agent를 PostgreSQL request_log 테이블에 한 줄씩 쌓는 단순한 구조다.
@app.after_request
def log_request_activity(response):
if not _is_noise_request(): # /static, 헬스체크 등은 제외
activity_log.record_request(
ip_address=request.remote_addr,
method=request.method,
path=request.path,
status_code=response.status_code,
user_agent=request.headers.get("User-Agent", ""),
)
return response
여기서 User-Agent에 Googlebot이 들어간 요청만 뽑아서 날짜별로 세어봤다. DB 서버가 UTC 기준이라 한국 시간으로 바꿔서 집계해야 날짜가 어긋나지 않는다(이거 한 번 틀려서 "왜 새벽에 몰려 있지?" 하고 한참 헤맨 적이 있다).
SELECT (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Seoul')::date AS day,
count(*) AS total,
count(*) FILTER (WHERE path IN ('/robots.txt', '/sitemap.xml')) AS meta,
count(*) FILTER (WHERE path LIKE '/blog/%') AS blog,
count(*) FILTER (WHERE path LIKE '/tools%') AS tools
FROM request_log
WHERE user_agent ILIKE '%googlebot%'
GROUP BY 1
ORDER BY 1;
결과는 명확했다. 로그를 쌓기 시작한 뒤 3.5일 동안 Googlebot 요청은 고작 24건이었고, 그 내역은 이랬다.
처음 3.5일간 Googlebot 요청 24건의 내역
/robots.txt— 11건/sitemap.xml— 6건- 실제 콘텐츠 페이지 — 3건
/ads.txt— 0건 (애드센스 경고의 원인이 이거였다. 파일이 없는 게 아니라 구글이 안 가져간 것)
즉 구글은 사이트맵을 꼬박꼬박 읽어가면서도, 거기 적힌 URL은 거의 방문하지 않고 있었다. Search Console의 "발견됨 - 현재 색인이 생성되지 않음"이 정확히 이 상태를 말하는 것이다. "주소는 알고 있는데 아직 가볼 차례가 안 됐다"는 뜻이다.
새 도메인은 구글 입장에서 신뢰도가 없어서 크롤링에 쓰는 자원(흔히 크롤 예산이라고 부른다)을 아주 조금만 배정받는다. 이 상태에서는 페이지를 아무리 다듬어도 구글이 와서 보지 않으니 소용이 없다. 원인을 "설정 문제"가 아니라 "방문 빈도 문제"로 좁힌 것만으로도 삽질을 크게 줄일 수 있었다.
함정 - 그 Googlebot, 진짜 구글 맞아?
로그를 보다가 이상한 걸 발견했다. User-Agent는 분명 Googlebot/2.1인데 요청한 경로가 이런 것들이었다.
/.env.bak/.docker/config.json/wp-config.php/.git/config
구글이 남의 서버에서 환경변수 백업 파일이나 git 설정을 찾을 리가 없다. 이건 Googlebot을 사칭한 취약점 스캐너다. User-Agent는 요청하는 쪽이 아무렇게나 적을 수 있는 문자열일 뿐이라, 방화벽이나 레이트리밋이 "구글은 봐주자"는 규칙을 두는 경우를 노리고 이름을 빌려 쓰는 것이다.
실제로 IP를 보니 진짜 Googlebot 요청은 전부 66.249.x.x 대역에서 왔고, 사칭 요청은 전혀 다른 대역(104.28.x.x 등)에서 왔다. 크롤러 통계를 낼 때는 이런 가짜를 반드시 걸러내야 숫자가 정확해진다.
진짜 Googlebot인지 확인하는 방법
구글이 공식적으로 안내하는 방법은 역방향 DNS 조회 후 다시 정방향으로 확인하는 것이다. 역방향 결과가 googlebot.com이나 google.com으로 끝나고, 그 이름을 다시 조회했을 때 원래 IP가 나와야 진짜다.
$ host 66.249.66.1
1.66.249.66.in-addr.arpa domain name pointer crawl-66-249-66-1.googlebot.com.
$ host crawl-66-249-66-1.googlebot.com
crawl-66-249-66-1.googlebot.com has address 66.249.66.1 # 원래 IP와 일치 → 진짜
요청이 많아서 하나하나 조회하기 번거롭다면, 구글이 공개하는 Googlebot IP 대역 목록(googlebot.json)과 대조하는 방법도 있다. 이 사이트 규모에서는 간단히 66.249.% 조건만 추가해도 충분했다.
-- 사칭 봇 제외
WHERE user_agent ILIKE '%googlebot%'
AND ip_address LIKE '66.249.%'
하나 더. User-Agent를 Googlebot으로 바꿔서 curl로 테스트해본 내 요청도 같은 테이블에 그대로 남는다. 집 공유기 IP로 찍힌 Googlebot 요청이 있어서 잠깐 놀랐는데, 알고 보니 내가 보낸 거였다. 통계를 보기 전에 자기 테스트 요청부터 걸러내는 것도 잊지 말자.
그래서 한 일 - 구글이 올 때 최대한 많이 보게 만들기
구글이 오는 빈도 자체는 내가 직접 늘릴 수 없다. 대신 올 때마다 더 많은 페이지를 쉽게 발견하도록 구조를 손봤다.
1. 사이트맵 누락 페이지 추가
사이트맵을 다시 보니 소개 페이지, 개인정보처리방침 같은 정상 페이지 몇 개가 빠져 있었다. 사이트맵을 손으로 관리하면 새 페이지를 추가할 때 이런 누락이 생기기 쉽다. 그래서 도구 목록은 메뉴를 그리는 데 쓰는 정의를 그대로 가져다 사이트맵을 만들도록 바꿨다. 도구를 하나 추가하면 메뉴와 사이트맵에 동시에 반영된다.
for endpoint in ALL_TOOL_ENDPOINTS: # 메뉴와 같은 목록을 재사용
urls.append(CANONICAL_DOMAIN + url_for(endpoint))
for p in blog.get_all_published_posts_for_sitemap():
urls.append(CANONICAL_DOMAIN + url_for("blog.detail", ...))
2. 크롤 깊이 줄이기 - 홈에서 한 번에 닿게
도구 페이지 20개는 원래 "홈 → 도구 모음 → 각 도구"로 두 번을 타고 들어가야 닿았다. 구글은 홈에서 가까운 페이지일수록 중요하게 보고 먼저 방문하는 경향이 있어서, 홈에 도구 바로가기 링크를 전부 깔아 깊이를 2에서 1로 줄였다. 블로그 최신 글도 홈에 노출돼 있어서 새 글은 홈 한 번만 긁으면 바로 발견된다.
3. 얇은 페이지는 과감히 noindex
스도쿠 페이지는 본문이 외부 게임을 불러오는 iframe 하나뿐이라, 구글 입장에서는 내용이 거의 없는 페이지다. 이런 페이지가 섞여 있으면 사이트 전체 품질 평가에 도움이 안 되기 때문에 noindex를 걸고 사이트맵에서도 뺐다.
재미있는 건 그 뒤다. 며칠 뒤 Search Console에서 "noindex 태그에 의해 제외됨"이라는 알림 메일이 와서 깜짝 놀랐는데, 확인해보니 원인은 딱 그 스도쿠 페이지 하나였다. 한때 사이트맵에 넣었다가 뺀 걸 구글이 기억해뒀다가 다시 와서 noindex를 확인한 것이다. 의도한 대로 동작한 거라 오히려 반가운 알림이었다. 이런 알림이 오면 당황하지 말고, 사이트맵의 URL 전체가 200 응답이고 noindex가 없는지부터 확인하면 된다. 의도하지 않은 페이지가 나올 때가 진짜 문제다.
결과 - 로그로 본 변화
2주쯤 지나고 다시 같은 쿼리를 돌려봤다(사칭 봇은 제외).
날짜별 Googlebot 요청 (한국 시간 기준)
- 9월 14~17일 — 하루 5~10건. 대부분 robots.txt와 sitemap.xml
- 9월 18~19일 — 하루 70건 이상. 블로그 글 30여 개, 도구 페이지 20여 개를 한 번에 수집
- 9월 20~28일 — 다시 하루 5~27건으로 잠잠
- 9월 29일 — 77건. 새벽 3시 47분부터 5분 동안 블로그 페이지 37건을 약 8초 간격으로 연달아 수집
합계로 보면 처음 3.5일 동안 콘텐츠 요청이 3건이던 것이, 2주 남짓한 기간에 252건이 됐다. 블로그에 공개된 글 28편은 전부 최소 한 번씩 수집됐고, 처음에 한 번도 안 가져가던 ads.txt도 이제는 주기적으로 가져간다.
패턴도 눈에 띈다. 구글은 매일 조금씩 오는 게 아니라, 며칠에 한 번 몰아서 대량으로 긁어간다. 그 사이에는 robots.txt와 사이트맵만 확인하면서 바뀐 게 있는지 본다. 그러니 하루 이틀 로그에 아무것도 안 찍힌다고 조급해할 필요는 없다.
솔직히 말하면, 이 변화가 내가 손본 것 덕분인지 단순히 도메인이 시간이 지나며 신뢰를 쌓은 덕분인지는 구분할 수 없다. 다만 로그를 보고 있었기 때문에 적어도 "구글이 왔는데 거절당한 것"이 아니라 "아직 안 온 것"이라는 건 확신할 수 있었고, 그 덕에 멀쩡한 설정을 괜히 뜯어고치는 삽질은 하지 않았다.
URL 검사의 "리소스 90/101개를 로드하지 못함", 무시해도 될까
Search Console의 URL 검사 도구로 홈페이지를 테스트해보면 무시무시한 문구가 뜬다. "페이지 리소스: 리소스 90/101개를 로드하지 못함". 목록을 펼쳐보면 전부 이런 것들이다.
cdn.jsdelivr.net/npm/pretendard@.../Pretendard-Bold.subset.83.woff2같은 폰트 파일 수십 개 — "기타 오류"- 구글 광고 관련 XHR 요청 — "기타 오류"
googleads.g.doubleclick.net/...— "robots.txt에서 Googlebot이 차단됨"- 날씨 아이콘 이미지, 심지어 우리 서버의 로고 이미지까지 — "기타 오류"
결론부터 말하면 대부분 신경 쓰지 않아도 된다.
- "기타 오류"는 대개 진짜 오류가 아니다. URL 검사의 실시간 테스트는 페이지 하나를 렌더링할 때 가져올 리소스 개수와 시간에 제한을 둔다. 한도를 넘은 리소스는 받아오다 말고 "기타 오류"로 표시된다. 실제로 목록에 있던 우리 로고 이미지를 직접 요청해보니 200으로 잘 내려왔다.
- 폰트가 많은 이유 — Pretendard 같은 한글 웹폰트는 용량이 커서, 글자 범위별로 수십~수백 개 조각(subset)으로 나눠 필요한 조각만 받는 방식을 쓴다. 굵기 5종 × 조각 수십 개라 요청 수가 폭발하는 것뿐이다. 폰트가 안 받아져도 구글은 텍스트를 기본 글꼴로 읽으니 색인에는 영향이 없다.
- doubleclick의 "robots.txt에서 차단됨" — 이건 우리 robots.txt가 아니라 구글 광고 서버의 robots.txt가 자기 크롤러를 막은 것이다. 우리가 할 수 있는 것도, 할 필요도 없다.
신경 써야 하는 경우는 딱 하나, 본문 내용을 그리는 데 필요한 자기 사이트의 JS나 CSS가 실패할 때다. 예를 들어 글 본문을 자바스크립트로 불러오는 구조인데 그 스크립트가 막혀 있다면 구글은 빈 페이지를 보게 된다. 판단 기준은 URL 검사의 "크롤링된 페이지 보기 → 스크린샷"이다. 스크린샷에 본문 글자가 제대로 보이면 리소스 실패 목록은 무시해도 된다.
진짜로 찾은 문제 - 파비콘이 없었다
리소스 목록은 괜찮았지만, 같은 날 로그를 보다가 진짜 문제를 하나 찾았다.
14:44:19 | / | 200 | Googlebot 스마트폰
14:45:49 | /favicon.ico | 404 | Googlebot-Image/1.0
페이지를 긁은 직후에 Googlebot-Image가 /favicon.ico를 찾으러 왔다가 404를 받고 돌아갔다. 파비콘을 아예 안 만들어뒀던 것이다. 구글 검색 결과에는 사이트 이름 옆에 작은 아이콘이 붙는데, 파비콘이 없으면 밋밋한 기본 지구본 아이콘이 붙는다. 순위에 직접 영향을 주지는 않지만, 결과 목록에서 눈에 띄는지와 믿을 만해 보이는지가 클릭률에 영향을 준다.
구글의 파비콘 요구사항은 간단하다. 정사각형이고, 크기는 48px의 배수(48, 96, 144...)를 권장한다. 기존 로고로 16·32·48px이 한 파일에 들어간 .ico와 192px PNG를 만들고, 두 곳에 연결했다.
<!-- base.html <head> -->
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" type="image/png" href="/static/img/favicon-192.png" sizes="192x192">
<link rel="apple-touch-icon" href="/static/img/apple-touch-icon.png">
# 브라우저와 Googlebot-Image는 <link rel=icon>과 별개로
# 루트의 /favicon.ico를 직접 찾으므로 라우트도 따로 둔다.
@app.route("/favicon.ico")
def favicon():
return send_from_directory(app.static_folder, "img/favicon.ico",
mimetype="image/vnd.microsoft.icon", max_age=86400)
<link rel="icon">만 넣으면 끝일 것 같지만, 많은 크롤러와 브라우저가 HTML을 보기 전에 관례적으로 /favicon.ico 경로를 먼저 찔러본다. 그래서 루트 경로도 같이 살려두는 편이 안전하다. 검색 결과에 반영되는 데는 보통 며칠에서 몇 주가 걸린다.
덤 - 같은 사이트가 주소 5개로 열리고 있었다
점검하다 보니 하나가 더 걸렸다. 이 사이트는 원래 무료 DDNS 주소로 운영하다가 k0.co.kr을 산 거라, 예전 주소들(duckdns, kro.kr 등)로 들어와도 똑같은 내용이 200으로 그대로 보였다. 모든 페이지에 canonical 태그가 k0.co.kr을 가리키고 있긴 했지만, canonical은 "이쪽이 원본이에요"라는 힌트일 뿐 강제가 아니다. 검색엔진이나 애드센스 심사 입장에서는 같은 사이트의 복사본 5개로 보일 여지가 있다.
그래서 예전 주소로 들어오는 요청은 전부 정식 도메인으로 301(영구 이동)시키도록 바꿨다. 301은 "주소가 영구적으로 바뀌었다"는 뜻이라 검색엔진이 예전 주소가 받던 평가도 새 주소로 넘겨준다.
MIRROR_HOSTS = {"lsh429.duckdns.org", "lsh429.kro.kr", ...}
@app.before_request
def redirect_mirror_hosts():
if request.host.split(":")[0].lower() in MIRROR_HOSTS:
return redirect(CANONICAL_DOMAIN + request.full_path.rstrip("?"), code=301)
호스트 이름을 허용 목록이 아니라 차단 목록(예전 주소 목록)으로 비교한 데는 이유가 있다. 쿠버네티스 헬스체크는 파드 IP로 직접 들어오기 때문에 Host 헤더가 IP다. "k0.co.kr이 아니면 전부 리다이렉트"로 짜면 헬스체크까지 301을 받아 파드가 재시작 루프에 빠질 수 있다.
정리 - 색인이 안 될 때 확인할 순서
- 기술적 설정 점검 — canonical, noindex, robots.txt, HTTPS, 사이트맵. 여기서 걸리면 그게 원인이다.
- 서버 로그에서 Googlebot이 실제로 왔는지 확인 — Search Console만 보지 말고 직접 로그를 보자. 이게 가장 확실한 판단 근거다.
- 사칭 봇 걸러내기 — User-Agent만 믿지 말고 IP(역방향 DNS)로 검증한다. 자기 테스트 요청도 제외한다.
- 안 왔다면 → 발견하기 쉽게 — 사이트맵 누락 확인, 홈에서 주요 페이지까지 링크 깊이 줄이기, 외부에서 들어오는 링크 늘리기. 그리고 기다리기.
- 왔는데 색인이 안 된다면 → 콘텐츠 문제 — 본문이 얇은 페이지, 중복 페이지를 정리하거나 noindex로 뺀다.
- URL 검사의 리소스 실패 목록은 스크린샷에 본문이 잘 보이면 무시한다.
- 파비콘, 미러 도메인처럼 로그를 봐야만 보이는 작은 구멍들을 막는다.
마무리
새 사이트를 만들면 색인이 안 되는 기간은 누구나 겪는다. 그 기간에 제일 답답한 건 "뭐가 문제인지 모른다"는 점인데, Search Console은 결과만 보여줄 뿐 이유는 잘 알려주지 않는다. 요청 로그를 직접 쌓아두면 구글이 언제 와서 무엇을 가져갔는지 눈으로 확인할 수 있고, 그것만으로도 할 일과 기다릴 일이 명확하게 갈린다.
테이블 하나와 after_request 훅 몇 줄이면 되는 작업이라, 직접 운영하는 사이트가 있다면 한 번쯤 만들어두길 추천한다. 덤으로 이번처럼 구글을 사칭하는 스캐너가 뭘 노리는지도 보인다. 참고로 이 글의 수치는 이 사이트 한 곳의 기록일 뿐이라, 구글의 크롤링 주기나 기준은 사이트마다, 그리고 시기마다 다를 수 있다는 점은 감안하시길.
댓글 0
로그인 후 댓글을 작성할 수 있습니다.