← 프로젝트 회고 · Network
RETROSPECTIVE · 회고

봇 유입을 버리지 말고 표시만 하기: 클라우드 IP 대역 판별

Network

사이트에 유입 분석을 붙였다. 페이지를 볼 때마다 리퍼러, 검색어, 기기, 브라우저, 대략적인 지역을 기록하고 관리자 화면에서 날짜별로 보는 기능이다. 붙이고 하루가 지나자 이상한 방문이 눈에 띄었다. 해상도가 1920x1080인 'iOS 사파리', 리퍼러가 옛 주소인데 위치가 아일랜드 더블린인 리눅스 크롬. 사람이라고 보기 어려운 방문이었다.

User-Agent만으로는 안 된다

봇 판별은 보통 User-Agent에서 시작한다. Googlebot, HeadlessChrome 같은 문자열을 걸러 주는 라이브러리(isbot)를 이미 쓰고 있었다. 하지만 위의 방문들은 평범한 브라우저 UA를 달고 있었다. UA는 보내는 쪽이 마음대로 쓰는 문자열이라, 숨기려고 마음먹은 봇은 UA로 못 잡는다.

숨기기 어려운 건 어디서 접속했는가다. 사람은 집이나 회사, 통신사망에서 들어온다. AWS, GCP, Azure 같은 데이터센터 IP에서 브라우저로 들어오는 건 대부분 크롤러, 모니터링, 미리보기 생성기, 스캐너다.

클라우드 업체는 IP 대역을 공개한다

다행히 큰 클라우드 업체들은 자기 IP 대역을 기계가 읽을 수 있는 형태로 공개한다.

업체공개 방식
AWSip-ranges.json (리전·서비스별)
GCPcloud.json
AzureService Tags 주간 JSON (파일 주소가 매주 바뀌어서 안내 페이지에서 링크를 찾아야 함)
Oracle, DigitalOceanJSON / CSV

하루 한 번 이 목록을 받아 DB에 저장하고, 방문 기록마다 IP가 어느 업체 대역에 속하는지 표시하기로 했다. 한 업체 목록 받기에 실패하면 그 업체는 예전 목록을 그대로 쓴다. 하나가 실패했다고 판별 전체가 비면 안 되니까.

수만 개 대역에서 빠르게 찾기

대역은 CIDR(52.95.0.0/16) 수만 개다. 방문마다 전부 훑으면 O(n)이다. 대신 이렇게 했다.

  1. CIDR을 시작·끝 정수 구간으로 바꾼다. IPv4는 32비트 정수, IPv6는 128비트라 BigInt로.
  2. 시작 주소로 정렬하고, 겹치거나 맞닿은 구간은 합친다. 업체별로 하나의 정렬된 구간 배열이 된다.
  3. 조회할 IP도 정수로 바꿔서, '시작 ≤ IP'인 마지막 구간을 이진 탐색으로 찾고 그 끝보다 작거나 같은지 본다.
// 정렬·병합된 구간 [start, end] 배열에서 ip가 속하는지 O(log n)
function inRanges(ranges: [bigint, bigint][], ip: bigint) {
let lo = 0, hi = ranges.length - 1, hit = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (ranges[mid][0] <= ip) { hit = mid; lo = mid + 1; }
else hi = mid - 1;
}
return hit >= 0 && ip <= ranges[hit][1];
}

병합해 두면 구간끼리 겹치지 않으니 '시작이 IP 이하인 마지막 구간' 하나만 보면 된다. 병합하지 않으면 그 앞 구간들도 확인해야 한다.

버리지 말고 표시만

처음엔 클라우드 IP 방문을 아예 저장하지 않을까 생각했다. 하지만 그러지 않았다.

  1. 판별이 틀릴 수 있다. 회사 VPN이나 클라우드 PC로 들어오는 진짜 사람도 있다.
  2. 목록을 늦게 받으면 그 사이 방문은 판별 전 상태다. 버렸다면 되돌릴 수 없다.
  3. 봇 유입 자체도 정보다. 어떤 크롤러가 얼마나 오는지, 옛 주소로 아직 누가 들어오는지.

그래서 기록에는 cloud 필드(업체 이름, 아니면 빈 문자열)만 붙이고, 날짜별 집계는 전체와 '클라우드 뺀 것' 두 벌을 만든다. 관리자 화면의 '클라우드 IP 빼기'는 기본으로 켜 두되, 끄면 전부 볼 수 있다. 목록이 아직 없을 때 들어온 기록은 필드를 아예 비워 두고, 매시간 작업이 목록이 생긴 뒤에 채우고 그 날짜 집계를 다시 계산한다. '아니다(빈 문자열)'와 '아직 모른다(필드 없음)'를 구분해 둔 덕분이다.

개인정보와 같이 설계하기

IP로 판별하려면 IP를 저장해야 한다. 원본 IP는 30일만 두고 지우고, 방문 기록은 180일 뒤 TTL 인덱스로 자동 삭제, 그 뒤에는 날짜별 집계만 남게 했다. 순방문자 수는 IP를 그대로 쓰지 않고 날짜·IP·UA를 비밀값으로 HMAC한 값으로 센다. 날짜가 들어가서 하루가 지나면 같은 사람도 다른 값이 된다. 이 내용은 개인정보 처리방침에 그대로 적었다.

배운 점

  1. 위조할 수 있는 신호(UA)보다 위조하기 어려운 신호(접속 네트워크)를 먼저 본다.
  2. 범위 조회는 '정렬 + 병합 + 이진 탐색'이면 대부분 충분하다. 특수한 자료구조(인터벌 트리, 트라이)는 그다음이다.
  3. 분류 결과는 원본을 버리는 필터가 아니라 붙여 두는 라벨로. 판별 규칙이 바뀌어도 다시 계산할 수 있다.
  4. '거짓'과 '모름'을 다른 값으로 저장하면 나중에 채워 넣을 수 있다.
  5. 외부 목록에 기대는 기능은 받기 실패를 기본값으로 생각한다. 이번에도 개발 환경에서는 그 주소들이 막혀 있어서, 실제 다운로드는 운영에서 처음 확인했고 관리자 화면에 업체별 대역 수와 실패 여부를 띄워 두었다.
Gunmo Lee