본문으로 건너뛰기
  1. 포스트/

Cloudflare가 Googlebot에게 빈 페이지를 내줬다

· loading · loading ·
인재덕
작성자
인재덕
서울에 거주하는 리더 겸 소프트웨어 엔지니어

제가 운영하는 마켓플레이스는 Expo로 익스포트한 React Native Web 앱입니다. 모든 리스팅 페이지는 자바스크립트가 실행되기 전까지 스크립트 태그 하나와 빈 div일 뿐입니다. 사람에게는 아무 문제가 없지만 검색에는 치명적입니다. 구글은 그 JS를 실행할 때도 있지만, Bing은 하지 않고, 뉴질랜드의 한인 커뮤니티 사이트에 가장 중요한 네이버 크롤러는 확실히 하지 않습니다.

이 글은 그 페이지들을 보이게 만든 과정과, 그다음에 제가 예상하지 못한 방식으로 그걸 되돌려버린 캐싱 규칙에 대한 이야기입니다.

클로킹이 아닌 다이내믹 렌더링
#

해결책은 오래됐고 구글도 인정하는 방식입니다. 크롤러를 감지해서, SPA가 그렸을 바로 그 콘텐츠를 담은 서버 렌더링 HTML을 내주는 것. 다른 콘텐츠가 아니라, 같은 콘텐츠를 더 일찍.

그래서 /tabs/used-good-view?id=...는 분기합니다. 브라우저는 Expo 셸을 받습니다. 크롤러는 진짜 문서를 받습니다. <h1>, 사실 정보를 담은 <dl>(가격, 상태, 카테고리, 지역, 판매 가능 여부, 사진 개수, 등록일), 생성된 alt 텍스트가 붙은 사진별 <img>, 본문 발췌, 그리고 이 페이지가 고아가 되지 않도록 형제 리스팅 8개로 가는 링크까지.

마지막 항목이 생각보다 훨씬 중요했습니다. 내부 링크가 생기기 전에는 리스팅 페이지 약 1,000개를 가리키는 것이 아무것도 없었고, 서치 콘솔에서 크롤이 하루 7~11회로 가라앉는 게 보였습니다. 페이지를 찾지 못하는 크롤러는 그 페이지가 얼마나 좋은지 신경 쓰지 않습니다.

함정: 벤더 이름은 그 회사 봇의 이름이 아니다
#

크롤러 감지는 user-agent 매칭이고, 바로 여기서 물렸습니다.

한국 플랫폼들은 회사 이름과 전혀 닮지 않은 이름으로 크롤링합니다. 네이버의 크롤러는 Yeti. 다음은 Daumoa. 카카오의 링크 스크래퍼는 kakaotalk-scrap. 셋 중 어느 것도 벤더 이름이 아닙니다.

한편 같은 벤더들이 인앱 브라우저를 탑재한 앱을 내놓고, 그 브라우저는 진짜 브라우저 UA 뒤에 앱 이름을 붙여 보냅니다. 카카오톡 인앱 브라우저는 KAKAOTALK/10.5.1이라고 말합니다. 카카오의 스크래퍼는 kakaotalk-scrap/1.0이라고 말하고요.

kakao로 매칭하면 둘 다 걸립니다. 제가 정확히 그렇게 했고, 그래서 링크를 연 카카오톡·네이버앱·다음앱 사용자 전원이 크롤러 페이지를 받았습니다. 자바스크립트도, 내비게이션도, 로그인도 없는 페이지를요. 문서로서는 훌륭하고, 사람에게는 완전히 쓸모없는.

해결책은 순서 규칙이고, 규칙으로 적어둘 만합니다.

def is_crawler(request):
    """인앱 브라우저는 절대 크롤러가 아니다. UA에 그 벤더의 봇과
    같은 이름이 들어 있어도 마찬가지 — 그래서 이 검사가 먼저 온다."""
    ua = request.META.get("HTTP_USER_AGENT", "")
    if not ua or _INAPP_BROWSER_RE.search(ua):
        return False
    return bool(_CRAWLER_RE.search(ua))

인앱 브라우저를 먼저 검사하고, 그쪽이 이깁니다. 그리고 모든 크롤러 토큰은 벤더가 아니라 을 지목해야 합니다.

# 한국 검색엔진. 네이버는 Yeti로, 다음은 Daumoa로, 카카오의 링크 미리보기
# 스크래퍼는 kakaotalk-scrap으로 크롤링한다. 셋 다 벤더 이름이 아니다.
r"yeti/|naverbot|daumoa|kakaotalk-scrap|kakao[a-z]*bot|"

그다음 앞단에 Cloudflare를 놓았다
#

오리진은 시드니에 있는 공유 CPU 머신 한 대이고, 초당 20~30 요청을 감당합니다. Googlebot이 걸어야 할 URL은 약 995개입니다. 그 전부가 시드니까지 도달하고 있었습니다. 모든 URL에서 cf-cache-statusDYNAMIC이었기 때문입니다. Cloudflare는 규칙이 시키지 않으면 HTML을 캐싱하지 않습니다. 존의 약 16%만 캐싱되고 있었고, 정작 중요한 부분은 하나도 포함되지 않았습니다.

그래서 크롤러 페이지에 캐시 규칙을 걸었습니다. 간단한 일이죠. 리스팅 URL의 본문이 하나가 아니라는 점만 빼면. 본문은 셋입니다.

  • HTML을 요청하는 크롤러는 서버 렌더링 페이지를 받습니다.
  • 사람은 Expo 셸을 받습니다.
  • Accept: text/markdown을 보내는 에이전트는 데이터베이스에서 만든 마크다운을 받습니다. 빈 HTML 셸을 변환해봐야 빈 문서가 나올 뿐이니까요.

오리진은 Vary: Accept를 보내고, user-agent에 대한 Vary는 어차피 가망이 없습니다. 그리고 제가 몰랐던 게 여기 있습니다.

Cloudflare는 HTML에서 Vary를 무시합니다. 세 본문이 하나의 캐시 키를 공유합니다.

먼저 도착한 본문이 그 URL을 차지하고, 해당 엣지 위치의 이후 모든 방문자에게 오리진의 s-maxage가 허용하는 한 시간 동안, 거기에 stale-while-revalidate 하루를 더해 그대로 나갑니다.

프로덕션에서 양방향으로 다 목격했습니다.

  • 네이버앱 사용자가 크롤러 페이지로 리스팅 URL을 워밍했고, 그다음 평범한 아이폰 사파리가 HIT으로 그 페이지를 받았습니다. 자바스크립트 없는 문서를 들여다보는 사람이 된 거죠.
  • 사람이 Expo 셸로 URL을 워밍했고, Googlebot이 빈 셸을 받았습니다. SEO 작업 전체가 막으려던 바로 그 실패가, 이제 더 빠르게 엣지에서 제공된 겁니다.

좁혀서 고치는 규칙
#

Cloudflare가 HTML에서 Vary를 존중하게 만들 수는 없습니다. 대신 할 수 있는 건, 세 본문 중 하나만 캐시에 들어갈 자격을 갖게 만드는 것입니다.

(starts_with(http.request.uri.path, "/tabs/")
 and not any(http.request.headers["accept"][*] contains "text/markdown")
 and (lower(http.user_agent) contains "googlebot" or ...)
 and not (lower(http.user_agent) contains "kakaotalk/" or ...))

크롤러 UA만 매칭하고, 마크다운 요청은 제외합니다. 이제 사람의 요청은 어떤 규칙에도 매칭되지 않으므로 예전처럼 DYNAMIC을 읽고 오리진으로 갑니다. 캐시를 읽고 쓰는 건 크롤러 요청뿐이고, 거기 들어갈 수 있는 본문은 크롤러용 하나뿐입니다.

엣지 TTL은 respect_origin입니다. 수명은 대시보드에 박히는 대신 페이지를 렌더링하는 코드가 계속 소유합니다.

놓치기 쉬운 운영 디테일 하나. 규칙을 좁힌다고 이미 캐시에 있는 것이 사라지지는 않습니다. 퍼지해야 합니다. 안 그러면 방금 생성을 멈춘 그 오염된 항목을 계속 내주게 됩니다.

두 목록, 그리고 그 오류가 대칭이 아닌 이유
#

이 규칙에는 크롤러 UA 목록이 필요하고, Django에는 이미 하나 있습니다. 같은 지식이 두 시스템에 두 벌 있는 건 대개 냄새나는 일이지만, 여기서 흥미로운 건 둘이 어긋났을 때 벌어지는 일입니다. 대칭이 아니거든요.

  • Cloudflare의 봇 목록은 Django 쪽의 부분집합으로 유지되어야 합니다. Cloudflare는 봇이라 여기지만 Django는 아니라고 보는 토큰이 있으면, 크롤러 전용 키 아래에 SPA 셸이 캐싱되고 그게 진짜 크롤러에게 나갑니다.
  • 인앱 브라우저 제외 목록은 Django 쪽의 상위집합이어야 합니다. 너무 많이 제외해봐야 캐시 히트를 잃을 뿐입니다.

어느 쪽이든 안전한 방향으로 틀리면 캐싱을 조금 잃습니다. 첫 번째를 반대 방향으로 틀리면 다시 Googlebot에게 빈 페이지를 내주게 됩니다. 참고로 이 비대칭 때문에 목록이 정규식 대신 contains를 씁니다. matches는 Business 플랜이 필요하고, 알려진 안전한 방향으로 대충이어도 되는 목록에는 contains로 충분합니다.

페이지 대신 빌드를 실패시키는 불변식
#

크롤러 페이지에는 7일간 유효한 presigned 이미지 URL이 들어갑니다. 페이지 자체는 엣지에서 한 시간, 거기에 stale-while-revalidate 하루가 붙습니다.

이 두 숫자는 서로 관련이 있고, 캐시 수명이 이미지 수명을 넘어서는 순간 엣지는 모든 사진이 403인 페이지를 내주기 시작합니다. 이걸 알려주는 알림은 없습니다. HTML 자체는 멀쩡하니까요.

그래서 그 상수들을 소유한 모듈은 관계가 깨지면 임포트 자체를 거부합니다.

CRAWLER_IMAGE_TTL = 7
# s-maxage + stale-while-revalidate >= CRAWLER_IMAGE_TTL 이면 임포트 시점에 raise

서로 다른 파일에 있는 두 상수 사이의 제약은 코드 리뷰에서는 보이지 않고, 프로세스 시작 시점에는 명백합니다. 프로세스가 시작하는 곳에 두세요.

Cloudflare가 한 일 중 되돌려야 했던 두 가지
#

Vary: Origin이 번들을 캐시 밖으로 밀어냈습니다. django-cors-headers는 자기가 건드리는 모든 응답에 Vary: Origin을 찍고, Cloudflare는 Accept-Encoding 이상으로 varying하는 응답을 캐싱하지 않습니다. 그래서 크리티컬 패스에서 가장 큰 1.6MB 자바스크립트 번들이 통째로 엣지에서 조용히 제외되고 있었습니다. 해결책은 미들웨어를 목록 맨 앞에 두어 나가는 길에 마지막으로 실행되게 하고, /_expo/ 자산 경로의 Vary를 다시 쓰는 것입니다.

엣지 압축이 오리진 압축보다 나빴습니다. Cloudflare가 즉석에서 Brotli를 하게 두면 2,041,742바이트가 나왔고, 오리진의 gzip은 1,658,115바이트였습니다. 생각하지 않아도 되는 대가로 23% 더 큰 셈입니다. 온더플라이 압축은 압축률이 아니라 CPU를 최적화합니다. 오리진에서 미리 압축한 자산이 이깁니다.

크롤링을 기다리지 않고 검색엔진에 알리기
#

프로토콜 두 개, 그리고 둘을 합쳐도 커버리지는 실망스럽습니다.

IndexNow는 핑 하나를 받고 Bing, Yandex, Seznam을 커버합니다. 구글과 네이버는 참여하지 않습니다. post_save에서 데몬 스레드로 돌고, 타임아웃 5초, 판매완료·마감된 리스팅은 건너뛰며, 키가 없으면 아무 일도 하지 않습니다. 네트워크 실패는 경고를 남길 뿐 그걸 촉발한 저장을 절대 실패시키지 않습니다. 베스트에포트 사이드 채널에 유일하게 허용되는 동작이죠.

구글의 Indexing API는 정확히 두 가지 스키마 타입만 받고, 그중 하나가 제게 있는 JobPosting입니다. 살아 있는 공고는 URL_UPDATED, 마감된 공고는 URL_DELETED를 보냅니다. 그 삭제 신호가 정직한 이유는 마감된 리스팅이 noindex도 함께 내보내기 때문이고, 코드에 그렇게 적어두었습니다. 나중에 noindex를 걷어내는 사람이 무엇을 같이 망가뜨리는지 알 수 있도록.

거기서 시간을 두 번 썼습니다. 할당량은 하루 200 URL인데, 스크래퍼 임포트 한 번이 시그널을 수백 번 발생시킵니다. 그래서 임포트된 리스팅은 건너뜁니다. 안 그러면 임포트 한 번이 하루치 예산을 전부 씁니다. 그리고 엔드포인트는 콜론이 들어간 urlNotifications:publish입니다. 슬래시를 쓰면 구글은 평범한 HTML 404를 반환하는데, 그건 API가 비활성화된 것과 똑같아 보입니다.

구조화 데이터가 가르쳐준 것
#

규칙 셋, 각각 서치 콘솔에게 혼나면서 배웠습니다.

  1. 파싱할 수 없는 건 빼세요. 급여는 자유 텍스트로 들어옵니다. 예전에는 원문 문자열을 baseSalary.value로 내보냈는데, 그건 유효하지 않고 모든 공고에 단위 누락 플래그가 붙었습니다. 지금은 파싱 안 되는 급여는 그냥 없습니다.
  2. 스키마를 만족시키려고 필드를 지어내지 마세요. 구인 공고에 상세 주소가 있는 경우는 드뭅니다. PostalAddress에 그럴듯한 걸 넣고 싶어지죠. 구인 공고에 지어낸 주소는 불완전한 주소보다 나쁩니다. 그래서 지역과 국가만 들어갑니다.
  3. 만료일은 선택이 아닙니다. 구글은 validThrough가 없는 JobPosting을 버립니다. 그래서 하나를 유도하되, 오래된 공고가 어제 만료됐다고 주장하지 않도록 하한을 둡니다.

그리고 사이트맵에 대한 네 번째. 전부에 changefreq: daily를 주장하지 마세요. 리스팅 천 개를 매일 다시 크롤링해달라는 요청은 과부하 상태의 호스트로 읽히고, 구글은 사이트 전체의 크롤 속도를 낮추는 것으로 응답합니다. 실제로 얼마나 최근에 바뀌었는지에서 유도하세요.

사이트맵에는 품질 하한선도 있습니다. 제목과 본문을 합쳐 40자. 서치 콘솔이 약 1,000개 리스팅을 “크롤링됨 – 현재 색인이 생성되지 않음"으로 보고했었고, 어떤 중고 페이지의 고유 콘텐츠 전부가 한 단어짜리 제목과 전화번호 하나였습니다. 40자는 일부러 낮게 잡았습니다. 484개 리스팅 중 471개가 한국어이고 한국어는 글자당 담는 정보가 많으니까요. 영어에 맞춘 기준이었다면 마켓플레이스의 절반이 날아갔을 겁니다. 얇은 리스팅도 인덱스 페이지를 통해 크롤 가능한 상태로 남고, 누군가 수정하면 자동으로 사이트맵에 다시 들어옵니다.

여기서 가져갈 것
#

  • 한 URL에서 서로 다른 본문을 내주는 일은, 무엇보다 먼저 캐시 키 문제입니다. CDN이 무엇을 키로 삼는지 알아내고, 바로 그 콘텐츠 타입에서 증명하기 전까지는 Vary를 무시한다고 가정하세요.
  • 두 시스템이 하나의 목록에 합의해야 한다면, 어느 방향의 오류가 안전한지 알아내서 양쪽 사본 옆에 적어두세요. “이건 어긋날 수 있고, 허용되는 어긋남은 이 방향뿐이다"가 어긋나지 않는 척하는 것보다 낫습니다.
  • 봇은 벤더 이름이 아니라 봇 이름으로 매칭하세요. 특히 영어권 밖에서는, 그 벤더가 사용자 손에 들린 브라우저도 만들고 있습니다.
  • 멀리 떨어진 상수 사이의 불변식은 시끄럽게 실패하는 곳에 두세요. 임포트 시점은 공짜이고 아무도 건너뛸 수 없습니다.