항공권 조회 봇을 브라우저 증명(PoW) 토큰으로 걸러낸 이야기
조회 대비 예약 비율(look-to-book)이 기준을 초과하면 과금되는 구조라, 예약 없이 조회 수만 증가시키는 봇이 문제였습니다. PoW 토큰 게이트로 봇 요청을 차단하고, 벤더 호출량(앱 로그)과 캐시 hit율(DB)로 효과를 검증했습니다.

항공권 조회 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 결과는 예측할 수 없으므로 정답을 빠르게 도출하는 방법이 없고, counter 를 하나씩 대입해 보는 수밖에 없습니다. 앞자리에 0 이 4개 나올 확률은 약 1/65,536 이라 평균 수만 번을 계산해야 하는데, 실제 브라우저에서는 수십 ms 면 완료됩니다. difficulty(앞에 와야 하는 0 의 개수)를 1 증가시키면 계산량이 16배가 됩니다.
| 정상 브라우저 | 단순 HTTP 봇 | |
|---|---|---|
| JS 실행 | 함 | 안 함 |
| 계산 문제 풀이 | 자동으로 수십 ms | SHA-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 라 영향이 없고, 단일사용 소비 단계에서만 예외를 catch 해서 요청을 통과시킵니다.
실제로는 게이트를 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 를 교차하고, 봇 표적이 아닌 채널을 대조군으로 설정했기 때문입니다.