본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 07. 23:14

내 API가 속도 제한(Rate-Limit)에 걸린 줄 알았다 — 누군가 4시간 만에 200만 건의 요청을 스크래핑하기 전까지는

요약

인메모리 방식의 속도 제한 설정 오류로 인해 발생한 4,200달러 규모의 API 스크래핑 피해 사례와 해결책을 다룹니다. 분산 환경에서의 Redis 기반 속도 제한과 행동 지문 인식 기술의 중요성을 강조합니다.

핵심 포인트

  • 인메모리 rate-limit은 멀티 인스턴스 환경에서 무력화될 수 있음
  • 프록시 풀을 이용한 IP 교체 공격에는 IP 기반 제한이 효과적이지 않음
  • Redis를 활용한 분산 속도 제한(Distributed Rate Limiting) 도입 필요
  • 요청 패턴 엔트로피 등 행동 지문 인식을 통한 고도화된 방어 필요

나는 express-rate-limit을 설치했다. 설정을 마쳤고, 그것이 제대로 작동한다는 것을 증명하는 테스트도 완료했다.

그럼에도 불구하고, 누군가 4시간도 채 되지 않아 내 운영 서버에서 200만 건의 API 요청을 스크래핑해 갔다. 이로 인해 상위(upstream) API 호출 비용으로 4,200달러를 지불하게 되었다.

정확히 무엇이 잘못되었는지, 어떻게 알아냈는지, 그리고 현재 내가 사용하는 아키텍처(architecture)에 대해 설명하겠다.

나를 속인 설정

내 API는 단순한 Express 앱이었다. 나는 합리적인 개발자라면 누구나 하듯이 속도 제한(rate limiting)을 추가했다:

import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
...

테스트는 통과했다. curl 응답에서 X-RateLimit-Limit: 100을 확인했다. 나는 편안히 잠들었다.

문제는 무엇이었을까? 나는 로드 밸런서(load balancer) 뒤에서 **4개의 인스턴스(instances)**를 실행하고 있었다. 각 인스턴스는 자신만의 인메모리(in-memory) 카운터를 가지고 있었다. 따라서 실제 제한은 100건이 아니라 15분당 400건의 요청이었다.

게다가 공격자는 하나의 IP로 100건의 요청을 보내는 것이 아니었다. 그들은 2,000개 이상의 IP로 구성된 프록시 풀(proxy pool)을 통해 IP를 교체(rotating)하고 있었다.

발생 과정

새벽 4시가 되었을 때, 데이터베이스 연결 풀(database connection pool)이 포화 상태가 되었다. 평소 12ms가 걸리던 쿼리들이 30초 동안 타임아웃(timeout)이 발생했다.

새벽 6시 30분, 나는 상위 LLM 제공업체의 청구서를 확인했다. 자정 이후로 210만 건의 API 호출이 발생했다. 호출당 0.002달러를 계산하면, 약 4,200달러에 달했다.

공격자는 다음과 같은 방식을 사용했다:

  1. 체계적인 키워드 변형을 사용하여 우리의 검색 엔드포인트(search endpoint)를 공격함
  2. 주거용 프록시 네트워크(residential proxy network)를 통해 IP를 교체함
  3. 요청을 여러 IP로 분산시켜 인스턴스별 속도 제한(per-instance rate limits) 미만으로 유지함
  4. 우리의 응답에서 구조화된 데이터(structured data)를 추출함

내 방어 체계가 실패한 이유

방어 수단실패 원인
express-rate-limit (in-memory)인스턴스 간에 공유되지 않음
...

근본적인 실수: 나는 속도 제한(rate limiting)을 **아키텍처 문제(architecture problem)**가 아닌 **설정 문제(configuration problem)**로 취급했다.

해결책: 분산 속도 제한 (Distributed Rate Limiting)

나는 세 가지 계층으로 시스템을 재구축했다:

계층 1: Redis 슬라이딩 윈도우 (실제 속도 제한기)

import Redis from 'ioredis';
import { createClient } from 'redis-rate-limiter';

...

이것을 통해 인스턴스당 100개가 아닌, 모든 인스턴스에 걸쳐 진정한 100개 요청 제한을 적용할 수 있습니다.

레이어 2: 행동 지문 인식 (Behavioral Fingerprinting)

프록시 풀 (Proxy pools)을 사용하는 공격에 대해 IP 주소는 무용지물입니다. 대신, 저는 다음 사항들을 추적합니다:

  • 요청 패턴 엔트로피 (Request pattern entropy) — 엔드포인트(Endpoints)가 알파벳 순서대로 호출되고 있는가? 그렇다면 스크래퍼(Scraper)입니다.
  • 타이밍 규칙성 (Timing regularity) — 정확히 1.0초마다 요청이 들어오는가? 봇(Bot)입니다.
  • 헤더 일관성 (Header consistency) — 동일한 User-Agent, 동일한 Accept-Encoding, 모든 것이 동일한가? 봇(Bot)입니다.
function calculateRequestEntropy(requests) {
  const endpoints = requests.map(r => r.path);
  const uniqueEndpoints = new Set(endpoints).size;
...

레이어 3: 비용 기반 서킷 브레이커 (Cost-Based Circuit Breakers)

이것이 실제로 돈을 아껴주는 핵심입니다:

// 엔드포인트당 예상 비용 추적
const endpointCosts = {
  '/api/search': 0.002,    // LLM 호출
...

비용이 급증하면, 비용이 많이 드는 엔드포인트가 자동으로 스로틀링 (Throttling) 됩니다. 지갑이 털리는 것을 막기 위해 새벽 3시에 깨어 있을 필요가 없습니다.

30일 후의 결과

지표적용 전적용 후
성공적인 스크래핑 사고2건0건
...

진짜 교훈

속도 제한 (Rate limiting)은 단순히 숫자를 설정하는 것이 아닙니다. 다음을 이해하는 것이 핵심입니다:

  1. 위협 모델 (Threat model) — 누가, 왜 당신의 API를 스크래핑하고 싶어 할 것인가?
  2. 아키텍처 (Architecture) — 분산 시스템 (Distributed system)에서는 인메모리 (In-memory) 방식이 작동하지 않습니다. 끝입니다.
  3. 비용 노출 (Cost exposure) — 엔드포인트당 달러 비용을 파악하고, 자동 서킷 브레이커 (Circuit breakers)를 설정하십시오.

4,200달러짜리 실수는 저에게 보안 연극 (Security theater) — 겉보기에는 올바르지만 실제로는 작동하지 않는 속도 제한 — 이 아예 속도 제한을 하지 않는 것보다 더 나쁘다는 것을 가르쳐 주었습니다. 보안 연극은 실제로는 보호되지 않는 시스템을 배포하도록 잘못된 자신감을 심어줍니다.

작동하는 것처럼 보였지만 실제로는 아니었던 방어 체계에 당해본 적이 있나요? 여러분의 속도 제한 설정은 어떠한가요? 댓글로 남겨주세요. 저는 항상 제 설정을 개선할 방법을 찾고 있습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0