문서

문서

ActiCrawl을 사용하여 웹 스크래핑 워크플로를 자동화하는 방법을 알아보세요

속도 제한

속도 제한은 책임감 있는 웹 스크래핑을 위해 필수적입니다. ActiCrawl은 데이터 수집 효율성을 극대화하면서 웹사이트 리소스를 존중할 수 있도록 정교한 속도 제한 기능을 제공합니다.

속도 제한 이해하기

속도 제한이 중요한 이유

  1. 서버 리소스 존중 - 대상 웹사이트에 과부하 방지
  2. IP 차단 회피 - 데이터 소스에 대한 접근 유지
  3. robots.txt 준수 - 웹사이트 스크래핑 정책 따르기
  4. 데이터 품질 유지 - 완전한 페이지 로드 보장
  5. 법적 준수 - 서비스 약관 준수

속도 제한의 유형

ruby
# 요청 기반 제한
rate_limit :requests_per_second, 10

# 시간 기반 제한  
rate_limit :delay_between_requests, 1.second

# 동시 요청 제한
rate_limit :max_concurrent_requests, 5

# 도메인별 제한
rate_limit :per_domain, {
  "example.com" => 5.requests_per_second,
  "api.example.com" => 100.requests_per_minute
}

구성

전역 속도 제한

ActiCrawl 설정에서 전역 속도 제한 구성:

ruby
ActiCrawl.configure do |config|
  # 기본 속도 제한
  config.rate_limit = {
    requests_per_second: 10,
    burst_size: 20,
    cooldown_period: 5.seconds
  }

  # 고급 구성
  config.rate_limiter = ActiCrawl::RateLimiter.new(
    strategy: :token_bucket,
    capacity: 100,
    refill_rate: 10,
    refill_interval: 1.second
  )
end

도메인별 구성

다른 도메인에 대한 특정 제한 설정:

ruby
class DomainRateLimiter
  DOMAIN_LIMITS = {
    "api.github.com" => {
      authenticated: 5000.per_hour,
      unauthenticated: 60.per_hour
    },
    "twitter.com" => {
      tweets: 900.per_15_minutes,
      users: 900.per_15_minutes
    },
    "default" => {
      requests: 10.per_second
    }
  }

  def limit_for(domain, auth_status = :unauthenticated)
    limits = DOMAIN_LIMITS[domain] || DOMAIN_LIMITS["default"]
    limits[auth_status] || limits[:requests]
  end
end

동적 속도 제한

서버 응답에 따라 속도 제한 조정:

ruby
class AdaptiveRateLimiter
  def initialize
    @current_delay = 0.1 # 100ms 초기 지연
    @min_delay = 0.1
    @max_delay = 5.0
  end

  def execute_with_limit
    sleep(@current_delay)

    response = yield
    adjust_delay(response)

    response
  end

  private

  def adjust_delay(response)
    case response.code
    when 429 # 너무 많은 요청
      # 지수 백오프
      @current_delay = [@current_delay * 2, @max_delay].min
      wait_time = parse_retry_after(response)
      sleep(wait_time) if wait_time
    when 200
      # 성공 시 점진적으로 지연 감소
      @current_delay = [@current_delay * 0.9, @min_delay].max
    when 503 # 서비스 이용 불가
      @current_delay = [@current_delay * 1.5, @max_delay].min
    end
  end

  def parse_retry_after(response)
    retry_after = response.headers['Retry-After']
    return nil unless retry_after

    # 초 단위와 HTTP 날짜 형식 모두 처리
    if retry_after =~ /^\d+$/
      retry_after.to_i
    else
      Time.parse(retry_after) - Time.now
    end
  end
end

속도 제한 전략

1. 토큰 버킷 알고리즘

평균 속도를 유지하면서 버스트 트래픽 허용:

ruby
class TokenBucket
  attr_reader :capacity, :tokens, :refill_rate

  def initialize(capacity:, refill_rate:, refill_interval: 1.second)
    @capacity = capacity
    @tokens = capacity
    @refill_rate = refill_rate
    @refill_interval = refill_interval
    @last_refill = Time.now
    @mutex = Mutex.new
  end

  def consume(tokens = 1)
    @mutex.synchronize do
      refill!

      if @tokens >= tokens
        @tokens -= tokens
        true
      else
        false
      end
    end
  end

  def wait_for_tokens(tokens = 1)
    loop do
      return if consume(tokens)
      sleep(0.1)
    end
  end

  private

  def refill!
    now = Time.now
    elapsed = now - @last_refill

    tokens_to_add = (elapsed / @refill_interval) * @refill_rate
    @tokens = [@tokens + tokens_to_add, @capacity].min
    @last_refill = now
  end
end

# 사용법
bucket = TokenBucket.new(capacity: 100, refill_rate: 10)

crawler.before_request do
  bucket.wait_for_tokens(1)
end

2. 슬라이딩 윈도우

롤링 시간 창에서 요청 추적:

ruby
class SlidingWindowLimiter
  def initialize(window_size:, max_requests:)
    @window_size = window_size
    @max_requests = max_requests
    @requests = []
    @mutex = Mutex.new
  end

  def allow_request?
    @mutex.synchronize do
      now = Time.now
      # 윈도우 밖의 오래된 요청 제거
      @requests.reject! { |time| now - time > @window_size }

      if @requests.size < @max_requests
        @requests << now
        true
      else
        false
      end
    end
  end

  def time_until_next_request
    return 0 if @requests.empty?

    oldest_request = @requests.min
    wait_time = @window_size - (Time.now - oldest_request)
    [wait_time, 0].max
  end
end

# 사용법
limiter = SlidingWindowLimiter.new(
  window_size: 60.seconds,
  max_requests: 100
)

if limiter.allow_request?
  crawler.fetch(url)
else
  sleep(limiter.time_until_next_request)
  retry
end

3. 리키 버킷

일정한 속도로 요청 처리:

ruby
class LeakyBucket
  def initialize(capacity:, leak_rate:)
    @capacity = capacity
    @leak_rate = leak_rate
    @queue = Queue.new
    @mutex = Mutex.new
    start_leak_thread
  end

  def add_request(request)
    @mutex.synchronize do
      if @queue.size < @capacity
        @queue.push(request)
        true
      else
        false # 버킷 오버플로
      end
    end
  end

  private

  def start_leak_thread
    Thread.new do
      loop do
        sleep(1.0 / @leak_rate)

        request = @queue.pop(true) rescue nil
        request&.call if request
      end
    end
  end
end

4. 분산 속도 제한

다중 인스턴스 배포를 위한:

ruby
class RedisRateLimiter
  def initialize(redis:, key_prefix: "rate_limit")
    @redis = redis
    @key_prefix = key_prefix
  end

  def allow_request?(identifier, limit:, window:)
    key = "#{@key_prefix}:#{identifier}:#{window_key(window)}"

    @redis.multi do |multi|
      multi.incr(key)
      multi.expire(key, window)
    end.first <= limit
  end

  def rate_limit_status(identifier, limit:, window:)
    key = "#{@key_prefix}:#{identifier}:#{window_key(window)}"
    current = @redis.get(key).to_i

    {
      limit: limit,
      remaining: [limit - current, 0].max,
      reset_at: Time.now + @redis.ttl(key)
    }
  end

  private

  def window_key(window)
    (Time.now.to_i / window) * window
  end
end

속도 제한 응답 처리

속도 제한 감지

ruby
class RateLimitDetector
  RATE_LIMIT_INDICATORS = {
    status_codes: [429, 503],
    headers: ['X-RateLimit-Remaining', 'Retry-After'],
    body_patterns: [
      /rate limit exceeded/i,
      /too many requests/i,
      /throttled/i
    ]
  }

  def rate_limited?(response)
    # 상태 코드 확인
    return true if RATE_LIMIT_INDICATORS[:status_codes].include?(response.code)

    # 헤더 확인
    return true if response.headers.keys.any? { |h| 
      h.match?(/rate.?limit/i) && response.headers[h].to_i == 0
    }

    # 응답 본문 확인
    return true if RATE_LIMIT_INDICATORS[:body_patterns].any? { |pattern|
      response.body.match?(pattern)
    }

    false
  end

  def extract_retry_info(response)
    {
      retry_after: response.headers['Retry-After'],
      limit: response.headers['X-RateLimit-Limit'],
      remaining: response.headers['X-RateLimit-Remaining'],
      reset: response.headers['X-RateLimit-Reset']
    }
  end
end

재시도 전략

ruby
class RateLimitRetryHandler
  def handle_rate_limit(response)
    retry_info = extract_retry_info(response)

    if retry_info[:retry_after]
      wait_time = parse_retry_after(retry_info[:retry_after])
      logger.info "속도 제한됨. #{wait_time}초 대기"
      sleep(wait_time)
    elsif retry_info[:reset]
      wait_until_reset(retry_info[:reset])
    else
      exponential_backoff
    end
  end

  private

  def wait_until_reset(reset_time)
    reset_at = Time.at(reset_time.to_i)
    wait_time = [reset_at - Time.now, 0].max

    logger.info "속도 제한이 #{reset_at}에 재설정됩니다. #{wait_time}초 대기"
    sleep(wait_time + 1) # 1초 버퍼 추가
  end

  def exponential_backoff
    @retry_count ||= 0
    @retry_count += 1

    wait_time = [2 ** @retry_count, 300].min # 최대 5분
    logger.info "지수 백오프: #{wait_time}초 대기"
    sleep(wait_time)
  end
end

모니터링 및 분석

속도 제한 메트릭

ruby
class RateLimitMetrics
  def initialize
    @metrics = Hash.new { |h, k| h[k] = { requests: 0, limited: 0 } }
  end

  def record_request(domain, limited: false)
    @metrics[domain][:requests] += 1
    @metrics[domain][:limited] += 1 if limited
  end

  def rate_limit_ratio(domain)
    stats = @metrics[domain]
    return 0 if stats[:requests].zero?

    (stats[:limited].to_f / stats[:requests] * 100).round(2)
  end

  def report
    @metrics.map do |domain, stats|
      {
        domain: domain,
        total_requests: stats[:requests],
        rate_limited: stats[:limited],
        limit_ratio: rate_limit_ratio(domain)
      }
    end
  end
end

성능 영향 분석

ruby
class RateLimitPerformanceAnalyzer
  def analyze_impact(with_limits:, without_limits:)
    {
      throughput: {
        with_limits: calculate_throughput(with_limits),
        without_limits: calculate_throughput(without_limits),
        impact: throughput_impact(with_limits, without_limits)
      },
      response_time: {
        with_limits: average_response_time(with_limits),
        without_limits: average_response_time(without_limits),
        overhead: response_time_overhead(with_limits, without_limits)
      },
      success_rate: {
        with_limits: success_rate(with_limits),
        without_limits: success_rate(without_limits)
      }
    }
  end

  private

  def calculate_throughput(data)
    total_time = data.last[:timestamp] - data.first[:timestamp]
    data.size / total_time.to_f
  end

  def throughput_impact(with_limits, without_limits)
    with_throughput = calculate_throughput(with_limits)
    without_throughput = calculate_throughput(without_limits)

    ((without_throughput - with_throughput) / without_throughput * 100).round(2)
  end
end

모범 사례

1. robots.txt 존중

ruby
class RobotsTxtCompliance
  def initialize
    @robots_cache = {}
  end

  def can_fetch?(url)
    uri = URI.parse(url)
    robots = fetch_robots_txt(uri)

    robots.allowed?(url, "ActiCrawl")
  end

  def crawl_delay(url)
    uri = URI.parse(url)
    robots = fetch_robots_txt(uri)

    robots.crawl_delay("ActiCrawl") || 0
  end

  private

  def fetch_robots_txt(uri)
    robots_url = "#{uri.scheme}://#{uri.host}/robots.txt"

    @robots_cache[robots_url] ||= begin
      response = Net::HTTP.get_response(URI.parse(robots_url))
      Robotstxt.parse(response.body, robots_url)
    rescue
      Robotstxt.parse("", robots_url) # 오류 시 빈 robots.txt
    end
  end
end

2. 요청 큐잉 구현

ruby
class RequestQueue
  def initialize(rate_limiter:)
    @queue = Queue.new
    @rate_limiter = rate_limiter
    @workers = []
    start_workers
  end

  def enqueue(request)
    @queue.push(request)
  end

  def shutdown
    @workers.each { |w| w[:stop] = true }
    @workers.each { |w| w[:thread].join }
  end

  private

  def start_workers
    5.times do
      worker = { stop: false }
      worker[:thread] = Thread.new do
        until worker[:stop]
          request = @queue.pop(true) rescue nil

          if request
            @rate_limiter.wait_for_token
            process_request(request)
          else
            sleep(0.1)
          end
        end
      end

      @workers << worker
    end
  end

  def process_request(request)
    request.call
  rescue => e
    logger.error "요청 실패: #{e.message}"
  end
end

3. 연결 풀링 사용

ruby
class ConnectionPool
  def initialize(size: 10, timeout: 5)
    @pool = Queue.new
    @size = size
    @timeout = timeout

    size.times { @pool.push(create_connection) }
  end

  def with_connection
    connection = checkout
    yield connection
  ensure
    checkin(connection)
  end

  private

  def checkout
    @pool.pop(true)
  rescue ThreadError
    if @pool.empty? && @created < @size
      create_connection
    else
      raise "연결 풀 소진됨"
    end
  end

  def checkin(connection)
    @pool.push(connection)
  end

  def create_connection
    # HTTP 클라이언트 연결 생성
    HTTP.persistent("https://example.com")
  end
end

4. 모니터링 및 알림

ruby
class RateLimitMonitor
  ALERT_THRESHOLDS = {
    rate_limit_ratio: 20, # 요청의 20%가 속도 제한됨
    queue_size: 1000,
    avg_wait_time: 10 # 초
  }

  def check_health
    alerts = []

    if rate_limit_ratio > ALERT_THRESHOLDS[:rate_limit_ratio]
      alerts << "높은 속도 제한 비율: #{rate_limit_ratio}%"
    end

    if queue_size > ALERT_THRESHOLDS[:queue_size]
      alerts << "큰 요청 대기열: #{queue_size}개 요청"
    end

    if avg_wait_time > ALERT_THRESHOLDS[:avg_wait_time]
      alerts << "높은 평균 대기 시간: #{avg_wait_time}초"
    end

    send_alerts(alerts) if alerts.any?
  end
end

속도 제한 테스트

ruby
require 'test_helper'

class RateLimiterTest < ActiveSupport::TestCase
  def setup
    @limiter = TokenBucket.new(capacity: 10, refill_rate: 5)
  end

  test "토큰 용량 존중" do
    10.times { assert @limiter.consume }
    assert_not @limiter.consume
  end

  test "시간 경과에 따른 토큰 리필" do
    10.times { @limiter.consume }
    sleep(1.1)

    assert_equal 5, @limiter.tokens
  end

  test "버스트 트래픽 처리" do
    requests = []

    20.times do |i|
      if @limiter.consume
        requests << { time: Time.now, index: i }
      end
    end

    assert_equal 10, requests.size
  end
end

요약

효과적인 속도 제한은 지속 가능한 웹 스크래핑에 필수적입니다:

  1. 사용 사례에 따라 적절한 전략 선택
  2. 서버 리소스와 robots.txt 규칙 존중
  3. 서버 피드백에 응답하는 적응형 속도 제한 구현
  4. 성능 최적화를 위한 속도 제한 메트릭 모니터링
  5. 확장된 배포를 위한 분산 속도 제한 사용
  6. 속도 제한 응답을 우아하게 처리
  7. 속도 제한을 철저히 테스트
  8. 웹의 좋은 시민이 되기

기억하세요: 목표는 스크래핑하는 웹사이트를 존중하면서 효율적으로 데이터를 수집하는 것입니다.