728x90

1. 지금 로그에서 실제로 벌어지는 일

Hacker News에 올라온 "A year of fighting scrapers on my 1.5 million-page website" 글의 제목은 사실 부제가 더 정확하다. 내 사이트 트래픽의 99%가 봇. 처음 이 문장을 보면 과장 같지만, 페이지 수가 많고 SEO로 롱테일 유입이 잡히는 사이트를 운영해본 사람은 대부분 고개를 끄덕인다.

내가 겪은 패턴은 이랬다. DB 사이즈 대비 페이지가 많은(카테고리 × 필터 × 페이지네이션 조합으로 URL이 기하급수로 늘어나는) 서비스에서, 어느 날부터 origin CPU가 평소의 3배로 붙어 있었다. APM에는 특정 느린 쿼리가 하나 튀는 것도 아니고, 그냥 전체적으로 골고루 요청이 늘어났다. 캐시 히트율은 오히려 떨어졌다. 이게 핵심 신호다.

사람 트래픽은 인기 페이지에 몰린다. 상위 1% URL이 트래픽의 대부분을 먹는다. 그래서 CDN 캐시가 먹힌다. 반면 스크래퍼는 사이트맵을 따라 전 페이지를 균등하게 순회한다. 150만 페이지를 다 긁으면 캐시 히트율이 바닥을 치고, 모든 요청이 origin으로 내려간다. "트래픽은 2배인데 DB 부하는 8배"가 되는 이유가 여기 있다.

가장 먼저 할 일은 남 얘기 그만 듣고 자기 로그를 세는 것이다.

# Nginx access log에서 UA별 요청 수 Top 10
awk -F'"' '{print $6}' /var/log/nginx/access.log \
  | sed 's/.*\(GPTBot\|ClaudeBot\|Bytespider\|Amazonbot\|bingbot\|Googlebot\).*/\1/' \
  | sort | uniq -c | sort -rn | head

  482119 Bytespider
  203847 GPTBot
   91204 ClaudeBot
   77650 Amazonbot
   41220 bingbot
   38904 Googlebot

여기서 이름을 대놓고 밝히는 놈들은 그래도 양심적인 편이다. 진짜 문제는 아래 명령에서 드러난다. UA가 평범한 Chrome인데 한 IP에서 수만 건이 나오는 경우.

# /24 단위로 묶어서 요청 수 집계 (분산 스크래퍼 탐지)
awk '{print $1}' /var/log/nginx/access.log \
  | cut -d. -f1-3 | sort | uniq -c | sort -rn | head -5

   58210 47.128.23
   41773 8.219.104
   19022 143.198.77
    9871 172.176.9
    3204 121.134.55

앞의 두 개는 클라우드 사업자 대역이었고, UA는 전부 최신 Chrome이었다. 사람이 클라우드 IP에서 크롬으로 5만 페이지를 볼 일은 없다.

2. robots.txt와 User-Agent 차단이 무너지는 지점

robots.txt는 규약이지 강제가 아니다. 이건 원래 그렇게 설계된 것이다. RFC 9309(Robots Exclusion Protocol)도 준수를 "권고"한다. 즉 robots.txt는 "예의를 아는 크롤러에게 부하를 줄여달라고 부탁하는 문서"이고, 예의 없는 쪽에는 어디를 긁으면 알찬지 알려주는 지도가 된다.

그래도 넣어라. 공개적으로 선언된 AI 크롤러 중 일부는 실제로 준수한다고 명시하고 있다(OpenAI GPTBot, Anthropic ClaudeBot 등이 공식 문서에서 robots.txt 준수를 밝힌다 — 정확한 UA 문자열과 IP 대역은 각 사 공식 문서 확인 필요). 법적/커뮤니케이션 측면에서도 "명시적으로 거부했다"는 기록이 남는다.

# robots.txt — 선언은 하되, 이걸로 끝났다고 믿지 말 것
User-agent: GPTBot
Disallow: /

User-agent: ClaudeBot
Disallow: /

User-agent: Bytespider
Disallow: /

User-agent: *
Crawl-delay: 10
Disallow: /search
Disallow: /*?sort=
Disallow: /*?page=

UA 차단이 무너지는 과정은 거의 정해진 각본이다.

  1. Bytespider UA를 차단한다 → 트래픽 뚝 떨어진다 → 뿌듯하다.
  2. 2주 뒤 같은 ASN에서 UA가 Mozilla/5.0 ... Chrome/121.0.0.0 Safari/537.36으로 바뀐 트래픽이 같은 패턴으로 들어온다.
  3. UA로는 이제 구분이 안 된다. 정상 유저와 문자열이 완전히 동일하다.
  4. IP를 차단한다 → 다음 주엔 주거용 프록시(residential proxy) 대역에서 IP 하나당 20~30건씩 쪼개서 들어온다.

이 지점에서 발상을 바꿔야 한다. "누구냐"로 막는 게임에서 "어떻게 행동하냐"로 막는 게임으로. UA는 클라이언트가 자기 입으로 하는 자기소개다. 거짓말하는 데 비용이 0원이다. 반면 행동 패턴은 스크래퍼의 목적(전 페이지 수집) 자체에서 나오기 때문에 위조하려면 수집 효율을 포기해야 한다. 방어자는 이 비대칭을 이용해야 한다.

3. 탐지·완화 계층 설계와 각 방식의 성격

탐지 신호를 겹쳐 쌓기

단일 신호로 판정하면 무조건 오탐이 난다. 점수를 합산하는 구조로 가야 한다. 내가 쓰는 신호는 대략 이렇다.

  • ASN / IP 대역: 클라우드 사업자(AWS, GCP, Azure, Alibaba, Hetzner, DigitalOcean 등) 대역에서 오는 브라우저 UA 트래픽은 강한 의심 신호. 단, 국내 서비스라면 기업 프록시·모바일 캐리어 NAT·앱 내 웹뷰 크롤링 때문에 무조건 차단은 위험하다.
  • 요청 분포의 균등성: 세션 내 URL이 서로 링크로 연결되지 않은 곳을 무작위로 훑고 있으면 봇. 사람은 목록 → 상세 → 뒤로 → 상세 패턴을 그린다.
  • 부수 리소스 미요청: HTML만 받고 CSS/JS/이미지/폰트를 전혀 안 받는 클라이언트. 브라우저를 자칭하면서 이러면 거짓말이다.
  • 헤더 지문: Accept-Language 부재, Accept*/*, Sec-Fetch-* 헤더 없음, HTTP/1.1 고정(최신 크롬은 h2/h3). TLS JA3/JA4 지문이 UA와 불일치하는 경우도 강한 신호다.
  • 요청 간격의 규칙성: 표준편차가 지나치게 작은 요청 간격. 사람은 이렇게 못 한다.

완화 수단 비교

수단막는 대상비용한계
Rate Limit (IP/세션)단일 IP 고속 크롤러매우 낮음분산 프록시에 무력
캐시 계층 강화부하만 (트래픽은 그대로)낮음, 효과 확실롱테일 페이지 캐시 히트 어려움
PoW 챌린지 (Anubis류)JS 미실행 대량 스크래퍼중간 (UX·SEO 영향)헤드리스 브라우저는 통과
타르핏 / 지연 응답수집 효율 자체커넥션 점유 주의내 리소스도 붙잡힘
CDN Bot Management광범위블랙박스, 오탐 시 디버깅 난이도

여기서 PoW 챌린지가 최근 오픈소스 진영에서 많이 언급된다. Anubis가 대표적이고, GNOME·kernel.org 같은 곳에서 도입했다는 이야기가 커뮤니티에 널리 돌았다(도입 현황과 현재 버전은 프로젝트 공식 문서 확인 필요). 원리는 단순하다.

요청이 오면 HTML 대신 작은 JS를 내려준다. 클라이언트는 조건을 만족하는 해시를 찾을 때까지 브루트포스를 돌린다. 답을 맞히면 쿠키를 발급하고 통과시킨다.

비유하면 입장 전에 팔굽혀펴기 20회다. 사람 한 명에겐 몇백 밀리초 짜증이지만, 150만 페이지를 긁으려는 쪽에는 150만 번의 팔굽혀펴기다. 스크래퍼의 경제성을 깨는 게 목적이지, 완벽히 막는 게 목적이 아니다. 실제로 헤드리스 크롬을 붙인 상대는 PoW를 그냥 푼다. 다만 헤드리스 브라우저는 curl보다 수십 배 비싸므로, 그 자체가 방어다.

Nginx에서 당장 적용 가능한 형태

http {
  # 봇성 신호에 따라 존을 분리
  map $http_user_agent $bot_ua {
    default            0;
    ~*(GPTBot|ClaudeBot|Bytespider|Amazonbot|CCBot)  1;
  }
  map $bot_ua $limit_key {
    1  $binary_remote_addr;   # 자칭 봇: 강하게
    0  "";                    # 나머지는 아래 존에서 처리
  }

  limit_req_zone $limit_key            zone=botzone:10m rate=6r/m;
  limit_req_zone $binary_remote_addr   zone=perip:10m   rate=20r/s;

  server {
    location / {
      limit_req zone=botzone burst=3 nodelay;
      limit_req zone=perip  burst=40;
      limit_req_status 429;
      proxy_pass http://app;
    }
  }
}

중요한 건 limit_req_status 429다. 기본값은 503인데, 503은 "서버 장애"로 해석돼서 정상 검색엔진 크롤러가 색인을 빼버릴 수 있다. 429는 "속도만 줄여라"라는 뜻이라 훨씬 안전하다. 그리고 CDN 뒤에 있다면 $binary_remote_addr는 CDN IP이므로 real_ip_headerset_real_ip_from 설정이 먼저다. 이거 안 하면 전 세계 트래픽을 CDN 엣지 IP 몇 개로 묶어서 레이트리밋하게 되고, 서비스가 통째로 429를 뱉는다.

4. 실무 관점: 트레이드오프와 흔한 함정

함정 1: real_ip 설정 누락으로 전체 429

가장 자주 보는 사고다. 로그에 이런 게 도배된다.

2024/03/11 14:22:07 [error] 1274#1274: *88213 limiting requests,
excess: 40.812 by zone "perip", client: 172.68.34.9,
server: example.com, request: "GET /product/8812 HTTP/2.0",
host: "example.com"

client:가 실제 사용자 IP가 아니라 CDN 엣지 IP(위 예시는 Cloudflare 대역 형태)로 찍히면 100% 이 문제다. 해결은 아래처럼.

set_real_ip_from 173.245.48.0/20;   # CDN 대역 전체를 넣어야 함
# ... (사업자 공식 IP 목록을 스크립트로 갱신)
real_ip_header CF-Connecting-IP;    # 또는 X-Forwarded-For
real_ip_recursive on;

CDN IP 목록은 바뀌므로 하드코딩하고 방치하면 안 된다. 사업자별 공식 IP 목록 엔드포인트를 크론으로 받아 include 파일로 떨구는 게 정석이다.

함정 2: PoW 챌린지가 SEO와 접근성을 때린다

Anubis류를 전 경로에 걸면 Googlebot·네이버 Yeti도 챌린지를 만난다. JS를 실행하는 크롤러도 있지만 렌더 예산이 있어서 색인 속도가 떨어질 수 있고, 실행하지 못하는 쪽은 그냥 페이지를 못 본다. 실무 처방은:

  • 검증된 검색엔진은 역방향 DNS + 정방향 확인으로 화이트리스트. UA만 믿으면 안 된다.
  • 챌린지는 비싼 경로에만. 검색·필터·정렬 파라미터가 붙은 URL, API 엔드포인트, 페이지네이션 깊은 곳.
  • 정적 상세 페이지는 캐시로 버티고 챌린지 없이 둔다.
  • JS 미지원 클라이언트(스크린리더 환경, 구형 브라우저)를 위한 우회 경로를 반드시 남긴다.
# 자칭 Googlebot 검증 (rDNS 왕복 확인)
$ dig +short -x 66.249.66.1
crawl-66-249-66-1.googlebot.com.
$ dig +short crawl-66-249-66-1.googlebot.com
66.249.66.1        # 일치하면 진짜

$ dig +short -x 47.128.23.77
ec2-47-128-23-77.ap-southeast-1.compute.amazonaws.com.
# UA는 Googlebot인데 EC2 → 위조 확정

함정 3: ASN 통째 차단이 정상 사용자를 자른다

"AWS 대역 전부 차단"은 시원하지만 위험하다. 국내 환경에서 실제로 걸리는 것들: 카카오톡·라인 링크 미리보기 봇, 슬랙 unfurl, 결제 PG사 서버 콜백, 파트너사 API 클라이언트, 기업 VPN, 그리고 자사 모니터링/합성 모니터링. Synthetic 체크가 죽으면서 "장애 알림이 안 왔다"가 아니라 "알림이 계속 온다"로 나타나기도 한다.

그래서 순서를 지켜야 한다. 탐지만 켜고 로그 기록(dry-run) → 며칠 관찰 → 챌린지/지연 → 마지막에 차단. 처음부터 403을 쏘는 건 자기 발등 찍기다.

함정 4: 타르핏은 상대만 붙잡는 게 아니다

일부러 응답을 30초 지연시키거나 무한 스트림을 내려주는 타르핏은 심리적 만족도가 높다. 하지만 그 커넥션은 내 워커/파일디스크립터도 점유한다. Nginx처럼 이벤트 기반이면 그나마 싸지만, 그 뒤에 스레드 기반 앱 서버가 있다면 자기 자신을 DoS 하게 된다. 타르핏은 반드시 엣지에서, 커넥션 상한을 걸고 쓴다.

실제 효과가 가장 컸던 것: 캐시와 URL 설계

솔직히 말하면, 화려한 챌린지보다 이게 효과가 컸다. 150만 페이지의 정체는 대개 파라미터 조합 폭발이다.

  • 정렬·필터 파라미터 조합 URL에 rel="canonical"을 걸고 robots에서 차단
  • 파라미터를 화이트리스트 방식으로 정규화 → 캐시 키 수를 한 자릿수로 줄임
  • 깊은 페이지네이션(예: page=500 이상)은 아예 404 또는 챌린지
  • 익명 사용자에겐 무조건 CDN 캐시 응답. 로그인 쿠키가 있을 때만 origin

스크래퍼가 여전히 오더라도 전부 엣지 캐시에서 끝나면 origin은 조용하다. 트래픽 비용은 남지만 DB는 산다. 트래픽 비용까지 잡으려면 그때 차단 단계로 간다.

5. 정리

한 줄 요약: UA와 robots.txt로 "누구냐"를 묻는 방어는 이미 끝났다. 행동 패턴으로 점수를 매기고, 캐시로 부하를 흡수하고, PoW·레이트리밋으로 상대의 수집 단가를 올리는 다층 방어로 가야 한다.

단계별로 이렇게 권한다.

  • 페이지 수천 개 이하, 부하 여유 있음 → robots.txt 선언 + 자칭 봇 UA 레이트리밋(429)까지. 그 이상은 과투자다.
  • 페이지 수만~수십만, origin CPU가 튀기 시작 → 캐시 키 정규화와 파라미터 통제가 1순위. 차단보다 먼저.
  • 페이지 수십만 이상 + 트래픽 비용이 아프다 → ASN 스코어링, JA3/JA4 지문, CDN Bot Management 검토. 여기서 오탐 관리 프로세스(dry-run → 챌린지 → 차단)를 반드시 문서화.
  • 커뮤니티/위키/문서 사이트, 익명 읽기 트래픽이 핵심 → Anubis류 PoW 챌린지가 가장 가성비 좋은 구간으로 보인다. 단 SEO 경로 예외 처리가 전제.

마지막으로 하나. 이건 끝나는 싸움이 아니다. 원문 제목이 "1년간의 싸움"인 이유가 그거다. 차단 규칙은 만드는 순간부터 낡기 시작하니, 규칙 자체보다 탐지 지표를 대시보드에 상시 띄워두는 것이 더 중요하다. 나는 "캐시 히트율", "origin 요청 중 부수 리소스 미요청 비율", "클라우드 ASN 비중" 세 개를 그래프로 보고 있다. 이 셋 중 하나가 꺾이면 새 스크래퍼가 들어온 것이다.

참고 자료

728x90

+ Recent posts