속도 제한
속도 제한은 책임감 있는 웹 스크래핑을 위해 필수적입니다. ActiCrawl은 데이터 수집 효율성을 극대화하면서 웹사이트 리소스를 존중할 수 있도록 정교한 속도 제한 기능을 제공합니다.
속도 제한 이해하기
속도 제한이 중요한 이유
- 서버 리소스 존중 - 대상 웹사이트에 과부하 방지
- IP 차단 회피 - 데이터 소스에 대한 접근 유지
- robots.txt 준수 - 웹사이트 스크래핑 정책 따르기
- 데이터 품질 유지 - 완전한 페이지 로드 보장
- 법적 준수 - 서비스 약관 준수
속도 제한의 유형
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
요약
효과적인 속도 제한은 지속 가능한 웹 스크래핑에 필수적입니다:
- 사용 사례에 따라 적절한 전략 선택
- 서버 리소스와 robots.txt 규칙 존중
- 서버 피드백에 응답하는 적응형 속도 제한 구현
- 성능 최적화를 위한 속도 제한 메트릭 모니터링
- 확장된 배포를 위한 분산 속도 제한 사용
- 속도 제한 응답을 우아하게 처리
- 속도 제한을 철저히 테스트
- 웹의 좋은 시민이 되기
기억하세요: 목표는 스크래핑하는 웹사이트를 존중하면서 효율적으로 데이터를 수집하는 것입니다.