donghakim.dev — zsh
← ls ../blog

항공권 조회 봇을 브라우저 증명(PoW) 토큰으로 걸러낸 이야기

조회 대비 예약 비율(look-to-book)이 기준을 넘으면 과금되는 구조라, 조회만 부풀리는 봇이 문제였습니다. 토큰 게이트로 걸러내고 효과를 로그와 DB 로 검증했습니다.

항공권 조회 봇을 브라우저 증명(PoW) 토큰으로 걸러낸 이야기 · cover

항공권 조회 API 는 뒤에서 외부 예약 벤더로 요청을 보냅니다. 이 벤더는 호출 1건마다 돈을 매기는 게 아니라 조회 대비 실제 예약 비율(look-to-book) 로 과금합니다. 조회를 여러 번 하는 동안 예약이 한 건 나오는 식인데, 이 비율이 기준선을 넘어가면(= 조회는 많은데 예약은 적으면) 그때부터 과금이 붙는 구조입니다. 그래서 예약으로 이어지지 않는 헛조회가 늘수록 불리해집니다. 그런데 어느 날부터 조회 엔드포인트에 자동화 봇이 붙어, 예약 없는 조회만 잔뜩 쌓기 시작했습니다.

  • 하루 8,800~12,100건. 정상 트래픽의 100~200배.
  • User-Agent 가 수백 건이 한 글자도 안 다름 → 사람이 아님.
  • Referer 를 우리 사이트 주소로 위장 → "우리 사이트에서 온 요청인 척".

여기서 첫 번째 벽에 부딪혔습니다. 헤더로는 못 막습니다. User-Agent 든 Referer 든 봇이 얼마든지 우리 사이트인 것처럼 위조할 수 있으니까요.

값이 아니라 계산을 시킨다 — PoW

핵심 아이디어는 이거였습니다. 브라우저가 보내는 값(헤더·쿠키)은 봇이 흉내 낼 수 있지만, 브라우저가 실행하는 일(JS 계산)은 흉내 내기 어렵습니다. 그래서 "너 우리 사이트에서 왔니?" 를 헤더로 묻는 대신, 작은 계산 숙제를 풀게 해서 진짜 브라우저인지 증명하게 만들었습니다. 이게 PoW(작업증명)입니다.

숙제는 이런 모양입니다.

서버가 준 것:  nonce, difficulty = 4
브라우저가 할 일:
    counter 를 0, 1, 2, 3... 늘려가며
    sha256(nonce + ":" + counter) 의 16진수가 "0000" 으로 시작하는 counter 찾기

sha256 결과는 예측이 안 되니 지름길이 없고, 무식하게 다 넣어보는 수밖에 없습니다. 앞자리에 0 이 4개 나올 확률은 약 1/65,536 이라 평균 수만 번을 돌려야 하는데, 실제 브라우저에서는 수십 ms 면 끝납니다. difficulty(앞에 와야 하는 0 의 개수)를 1 올리면 계산량이 16배가 됩니다.

정상 브라우저단순 HTTP 봇
JS 실행안 함
숙제 풀이자동으로 수십 msSHA-256 솔버를 직접 포팅해야
결과입장권 획득 → 조회 성공입장권 없음 → 차단

전체 흐름 — 숙제, 입장권, 조회

① 입장권 발급 (검색 시작 시 1번)
   GET /challenge  →  서버가 서명된 nonce + difficulty 반환

② 숙제 풀고 입장권 받기
   브라우저가 정답 counter 를 찾아서
   POST /redeem { nonce, solution }
   →  서버가 nonce 서명·신선도 검증 → 정답 검증 → 토큰(5분) 발급

③ 조회 (토큰 5분간 재사용)
   POST /get-avail-list  (헤더 X-Search-Token)
   →  서버는 토큰 서명·만료만 확인 (stateless)
      유효하면 조회, 없거나 위조면 200 + 빈 응답 (벤더 호출 0)

토큰이 5분간 재사용되므로 PoW 는 검색 세션당 한 번만 풀면 됩니다. 정상 사용자는 게이트가 있는 줄도 모릅니다.

몇 가지 설계 포인트

  • nonce 는 1회용. redeem 하는 순간 소비 표시를 지웁니다. 안 그러면 봇이 숙제를 한 번만 풀고 그 정답으로 입장권을 무한정 찍어낼 수 있습니다. "입장권 1장 = 숙제 1번" 을 강제하는 장치입니다.
  • 토큰은 HMAC 서명, 저장은 안 함. 만료 시각에 서버 비밀키로 서명을 붙였습니다. 비밀키를 모르면 서명을 못 맞추니 위조가 안 되고, 토큰을 DB·캐시에 저장하지 않으니 조회마다 캐시를 안 읽어도 됩니다(stateless 검증).
  • 캐시가 죽어도 조회는 산다(fail-open). 이 봇 차단 기능이 본 서비스를 죽이면 안 되므로, 캐시 장애 시 "nonce 단일사용" 만 포기하고 조회는 살립니다. 핵심은 null(키 없음) 과 예외(캐시 죽음)를 구분하는 것이었습니다. 서명 검증과 PoW 검증은 원래 stateless 라 영향이 없고, 단일사용 소비만 예외를 잡아 통과시킵니다.

실제로는 게이트를 2단계로 뒀습니다. ① 운항하지 않는 공항코드가 섞인 요청을 화이트리스트로 먼저 걷어내고(정상 유저는 드롭다운을 거치니 잘못된 코드를 보낼 일이 없습니다), ② 그다음 PoW 토큰 게이트입니다. 둘 다 차단 시 200 + 빈 응답으로 돌려보내 벤더 호출을 아예 발생시키지 않습니다.

효과 측정 — 한 지표만 보지 않는다

여기서부터가 이 글을 쓴 이유입니다. "막았다" 는 느낌이 아니라 숫자로 확인하고 싶었고, 그러려면 서로 다른 레이어의 두 소스를 교차해야 했습니다. 하나는 앱 로그(유입 요청 / 게이트 차단량 / 실제 벤더 호출량), 다른 하나는 캐시 통계 DB(서비스별 hit·miss)였습니다.

벤더 호출량 (하루):

구간벤더 호출/일
봇 피크 (무방비)~9,040
화이트리스트 가드만~4,160
PoW 게이트 enforce (현재)~2,000~2,500

봇 피크 대비 약 73~75% 감소했고, enforce 구간의 절대량은 봇이 잠잠하던 날의 실사용자 baseline 과 같은 수준이었습니다.

캐시 hit율:

여기서는 두 채널을 나란히 놓고 봤습니다. 하나는 봇이 실제로 긁던 채널(우리 웹사이트의 항공권 조회)이고, 다른 하나는 봇의 표적이 아니었던 다른 유입 경로입니다. 뒤엣것은 같은 캐시를 쓰지만 봇 영향이 없으니, 앞 채널의 변화가 정말 봇 제거에서 온 것인지 가려낼 대조군 역할을 합니다.

구간봇이 노린 채널대조군 (봇 표적 아님)
가드만16.0%18.6%
PoW enforce (측정 시점)24.2%20.7%

봇이 노린 채널의 hit율이 16% → 24% 로 올랐습니다. 봇이 매번 새로운 날짜를 조회하며 만들어내던 100% miss 트래픽이 빠지면서, 실사용자의 재조회 비중이 올라간 결과입니다. 여기서 중요한 건 대조군입니다. 같은 기간 대조군 채널은 18.6% → 20.7% 로 거의 움직이지 않았습니다. 표적이 된 채널만 크게 변했다는 건, 이 변화가 전역 캐시 설정을 바꿔서가 아니라 봇 트래픽을 걷어낸 데서 왔다는 걸 뒷받침합니다. 측정 직후엔 24% 였고, 이후 실사용자 트래픽이 안정되며 지금은 전반적으로 35% 안팎까지 올라왔습니다.

봇이 다시 유입된 구간(하루 유입이 ~14,000 건까지 복귀)에도 벤더 호출은 ~2,000~2,900/일 로 평탄했습니다. 게이트가 캐시·벤더 호출보다 앞단에서 하루 ~10,000 건을 잘라내기 때문입니다.

hit율 절대값이 낮은 건 정상이다

한 가지 덧붙이면, 이 서비스의 캐시 hit율은 (지금 35% 안팎으로 안정됐지만) 절대값 자체가 그리 높게 나오기 어려운 구조입니다.

  • 실수요자 위주 트래픽이라 같은 조건을 반복 조회하는 비율이 낮습니다.
  • 조회에서 예매로 넘어가는 전환율이 높습니다(약 50%).
  • 예매가 일어나면 해당 노선의 캐시를 전량 무효화합니다.

그래서 hit율은 절대값(예: "70% 달성")보다 추세(측정 시점 16% → 24%, 현재 35% 안팎)로 읽었습니다. 봇 차단의 실제 효과는 hit율이 아니라, 예약으로 이어지지 않는 조회를 얼마나 걷어냈는지 — 즉 벤더 조회 호출량이 줄어 look-to-book 비율이 나아졌는지로 판단했습니다. 그 판단이 성립했던 건 로그와 DB 를 교차하고, 봇 표적이 아닌 채널을 대조군으로 뒀기 때문입니다.

#bot#cache#cost#monitoring#pow#proof-of-work#security#troubleshooting