DRF 커스텀 스로틀링 구현하기 - 페널티 시스템까지
DRF 커스텀 스로틀링 구현하기 - 페널티 시스템까지
커스텀 스로틀링을 구현하게된 배경:
프로젝트 중 이벤트성으로 발급된 난수쿠폰을 등록하여 특정 조건을 만족하면 추가 혜택을 받을 수 있는 시스템이었다.
사용자가 잘못된 쿠폰 번호를 지속적으로 입력하며 등록을 시도할 경우 시스템에 부담을 줄 수 있다고 예상했다. 특히 쿠폰 유효성 검증 과정에서 DB 조회가 빈번하게 발생하고, 악의적인 사용자가 무작위 번호를 대량으로 시도할 가능성 또한 배제할 수 없었다.
그 결과, 단순히 시간당 횟수 제한이 아니라, 악용 시도에 대해서는 더 강력한 제재가 필요하다는 결론에 이르렀다.
기획 협의 사항
- 5분에 10번까지 요청 허용
- 그 이상 시도 시, 10분 간 완전 차단 (악의적 사용자로 판단)
기술적 도전 과제
- DRF 내장 스로틀링만으로는 페널티 시스템 구현 불가능
- 두 가지 다른 시간 기준을 동시에 관리해야 함 (5분 제한 + 10분 차단)
- 성공/실패에 따른 다른 처리 로직 필요
기술적 분석: 왜 DRF 기본 스로틀링으로는 부족한가
DRF 기본 스로틀링의 한계
1
2
3
4
5
6
7
# DRF 기본 방식으로는 이것만 가능
'DEFAULT_THROTTLE_RATES': {
'bonus_plan': '10/5min', # 5분에 10번
}
# 우리가 원하는 것
'10/5min + 10분 페널티' # 이런 설정은 존재하지 않음
1. 단일 시간 기준의 한계
1 2 3 4 5 6 7 8 9 10 # DRF SimpleRateThrottle.allow_request()의 기본 로직 def allow_request(self, request, view): if len(self.history) >= self.num_requests: return False self.history.insert(0, self.now) return True # 문제점: 5분 후에는 바로 다시 요청 가능 # 우리가 원하는 "10분 페널티"는 구현 불가능2. 페널티 개념의 부재
1 2 3 4 5 6 7 8 # 악의적 사용자의 패턴 (DRF 기본으로는 막을 수 없음) # 09:00 - 09:05: 10번 요청 (모두 허용) # 09:05 - 09:10: 10번 요청 (모두 허용) # 09:10 - 09:15: 10번 요청 (모두 허용) # ...계속 반복 # 결과: 5분마다 10번씩 무한 반복 가능!
- DRF 기본 스로틀링:
- 시간 경과 → 자동 해제
- 기획 협의 사항:
- 제한 위반 → 페널티 부여
해결책 설계: 이중 캐시 시스템 & 페널티 메커니즘
두 개의 독립적인 캐시 관리
- 일반 스로틀링: 정상적인 사용 패턴 관리
- Freeze 시스템: 악의적 사용 패턴 차단
1 2 3 4 5 6 7 8 9 class CustomThrottle(SimpleRateThrottle): rate = True ... freeze_duration = 10 * 60 # 10분 페널티 def parse_rate(self, rate): num_requests = 10 # 5분에 10번 duration = 5 * 60 # 5분 return num_requests, duration1. 이중 캐시 키 전략
1 2 3 4 5 6 7 8 9 10 11 12 13 def get_cache_key(self, request, view): """일반 스로틀링용 - 5분간 요청 기록 관리""" self.scope = "normal" ident = request.user.pk return self.cache_format % {"scope": self.scope, "ident": ident} # 결과: 'throttle_normal_123' def get_freeze_cache_key(self, request): """페널티 관리용 - 10분간 차단 상태 관리""" self.scope = "freeze" ident = request.user.pk return self.cache_format % {"scope": self.scope, "ident": ident} # 결과: 'throttle_freeze_123'2.
3단계
검증 로직
- 1단계: 정상적인 시간 경과 처리
- 2단계: 페널티 중인 사용자 차단
- 3단계: 새로운 위반 발생 시 페널티 부여
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 def allow_request(self, request, view): """3단계로 구성된 정교한 검증""" self.key = self.get_cache_key(request, view) self.freeze_key = self.get_freeze_cache_key(request) self.history = self.cache.get(self.key, []) self.now = self.timer() # 1단계: 만료된 기록 정리 (기본 슬라이딩 윈도우) while self.history and self.history[-1] <= self.now - self.duration: self.history.pop() # 2단계: Freeze 상태 우선 확인 if self.cache.get(self.freeze_key): return self.throttle_failure() # 페널티 중이면 무조건 차단 # 3단계: 일반 제한 확인 + 위반 시 페널티 적용 if len(self.history) >= self.num_requests: self.set_freeze_throttle() # 페널티 즉시 적용! return self.throttle_failure() return self.throttle_success()
핵심 구현: Freeze 시스템의 동작 원리
페널티 적용 메커니즘
1 2 3 4 def set_freeze_throttle(self): """제한 위반 시 즉시 페널티 적용""" # freeze_duration = 10 * 60 (10분) self.cache.set(self.freeze_key, self.history, self.freeze_duration)Freeze 시스템의 실제 동작:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 # 사용자 행동 시나리오 # 1차: 정상 사용 (5분간 8번 요청) → 모두 허용 history = [timestamp8, timestamp7, ..., timestamp1] # 8개 freeze_cache = None # 페널티 없음 # 2차: 한계 테스트 (추가로 3번 더 요청) # 9번째 요청: 허용 (9 < 10) # 10번째 요청: 허용 (10 = 10, 아직 경계선) # 11번째 요청: 거부 + 페널티 발동! if len(self.history) >= 10: # 10개 >= 10개 self.set_freeze_throttle() # freeze__123에 10분 TTL 설정 return False # 3차: 페널티 기간 (10분간) if self.cache.get(self.freeze_key): # freeze 캐시 존재 확인 return False # 어떤 요청이든 무조건 차단 # 4차: 페널티 해제 (10분 후 자동) # freeze 캐시 TTL 만료 → 다시 정상적인 5분/10번 제한 적용
성공/실패 분리 처리의 정교함
- 틀린 시도만 제한하는 것이 쿠폰 시스템에서는 더 합리적
- 올바른 쿠폰을 입력하면 다시 깨끗한 상태로 시작
- 계속 틀린 쿠폰만 입력하는 사용자만 제재
1 2 3 4 5 6 7 8 9 def throttle_success(self): self.history.insert(0, self.now) return True def set_throttle(self): self.cache.set(self.key, self.history, self.duration) def delete_throttle_cache(self): self.cache.delete(self.key)ViewSet에서의 실제 사용:
1 2 3 4 5 6 7 throttle.allow_request(request, self) # 검사만 try: serializer.is_valid(raise_exception=True) throttle.delete_throttle_cache() # 성공 → 캐시 삭제 except ValidationError: throttle.set_throttle() # 실패 → 캐시 저장
향후 개선 방향과 고려사항
1. 사용자 경험 개선 필요성
- 상황별 맞춤 메시지: 의도적 악용 vs 실수 구분
- 남은 시간 정확히 표시: 사용자 혼란 최소화
- 대안 제시: 다른 기능 이용 안내
2. 정상 사용자 보호 강화
우려사항:
- 네트워크 불안정 환경에서 자동 재시도로 인한 정상 사용자에게 페널티 부여
개선 방향:
- 패턴 인식: 네트워크 오류 vs 악의적 시도 구분
- 적응형 제한: 사용자별 평소 패턴 고려
- 화이트리스트: 신뢰도 높은 사용자 예외 처리
3. 성능 최적화 여지
우려사항:
- (2개의 캐시 키 사용으로 인한) 사용자 증가 시 메모리 사용량 급증 예상
- 최적화 방향에 대해서는 더 고민이 필요…
This post is licensed under CC BY 4.0 by the author.