Throttling (최대 호출 횟수 제한하기)

guava·2022년 1월 16일
0
post-custom-banner

파이썬/장고 웹서비스 개발 완벽 가이드 with 리액트 강의를 듣고 정리한 글입니다.

DRF에서 지원하는 Throttling 설정을 통해 시간당 최대 횟수를 제한할 수 있다. 또한 APIView에 scope를 지정함으로써 scope별로 다르게 설정도 가능하다.

본 포스팅에서는 Throttling의 Rate, Scope 등의 개념을 살펴보고 어떻게 활용하면 되는지 살펴본다.

1. Throttling


1.1. 용어 정리


  • Rate : 지정 기간 내에 허용할 최대 호출 횟수
  • Scope : 각 Rate에 대한 별칭 (alias)
  • Throttle : 특정 조건 하에 최대 호출 횟수를 결정하는 로직이 구현된 클래스

1.2. 기본 제공 Throttle


AnonRateThrottle

  • 인증 요청에는 제한을 두지 않고, 비인증 요청에는 IP 단위로 횟수 제한
  • 디폴트 scope: anon

UserRateThrottle

  • 인증 요청에는 유저 단위로 횟수를 제한하고, 비인증 요청에는 IP 단위로 횟수제한
  • 디폴트 scope: user

ScopedRateThrottle

  • 인증 요청에는 유저 단위로 횟수를 제한하고, 비인증 요청에는 IP 단위로 횟수 제한
  • 각 APIView내 Throttle_scope 설정을 읽어, APIView별로 서로 다른 Scope를 적용

디폴트 설정

REST_FRAMEWORK = {
    'DEFAULT_THROTTLE_CLASSES': [],  # Throttle 호출 제한을 하지 않음.
    'DEFAULT_THROTTLE_RATES': {
        'anon': None,
        'user': None,
    },
}

설정 예

REST_FRAMEWORK = {
    'DEFAULT_THROTTLE_CLASSES': [
        'rest_framework.throttling.UserRateThrottle',  # 모든 API에 대해서 유저 단위로 횟수를 제한하고, 비인증 요청에 대해서는 IP단위로 횟수 제한
    ],
    'DEFAULT_THROTTLE_RATES': {
        # 'anon': None, # default
        'user': '10/day',  # 하루에 10번 허용
    },
}

ViewSet 예

from rest_framework.throttling import UserRateThrottle

class PostViewSet(ViewSet):
    throttle_classes = UserRateThrottle

1.3 최대 호출 횟수 제한을 넘긴다면?


429 Too Many Requests 응답

예외 메시지에 API 활용이 가능한 시점을 알려줍니다.

→ 이는 Throttle의 wait 멤버 함수를 통해 계산

2. Cache


2.1. Cache


  • 매 요청시마다 cache에서 timestamp list를 get/set → 캐시 성능이 중요
  • SimpleRateThrottle에서는 다음과 같이 디폴트 캐시 설정
# rest_framework/throttling.py
    
from django.core.cache import cache as default_cache
    
class SimpleRateThrottle(BaseThrottle):
    cache = default_cache
    ```
    

## 2.2. 장고의 Cache 지원

---

- 기본 settings의 디폴트 캐시 : 로컬 메모리 캐시
- 다양한 캐시 지원
    1. **Memcached** 서버 지원 : `django.core.cache.backends.MemcachedCache` 혹은 `PyLibMCCache`
        - Memcache도 메모리 기반의 서버이기 때문에, Memcached 재시작되면 데이터가 초기화가 된다.
    2. 데이터베이스 캐시 : `django.core.cache.backends.DatabaseCache`
    3. 파일 시스템 캐시 : `django.core.cache.backends..FileBasedCache`
    4. **로컬 메모리 캐시**(default) : `django.core.cache.backends.LocMemCache`
    5. 더미 캐시 : `django.core.cache.backends.dummy.DummyCache` ⇒ 실제로 캐시를 수행하진 않습니다.
- redis를 활용한 캐시
    - [**django-redis-cache**](https://github.com/sebleier/django-redis-cache)
    - Redis는 Memcached와 유사하나 메모리 캐시 뿐만 아니라 디스크에 동기화 싱크를 맞추기 때문에 재시작 시에도 데이터가 초기화 되지 않는다. (장점)

## 2.3. Throttle별 캐시 설정

---

>[https://docs.djangoproject.com/en/2.2/ref/settings/#caches](https://docs.djangoproject.com/en/2.2/ref/settings/#caches)
[https://www.django-rest-framework.org/api-guide/throttling/#setting-up-the-cache](https://www.django-rest-framework.org/api-guide/throttling/#setting-up-the-cache)

settings.CACHES의 "default" 사용
Throttle 클래스 별로 다른 캐시 설정 지원

```python
from django.core.cache import caches

class CustomAnonRateThrottle(AnonRateThrottle):
    cache = caches['alternate']

3. 설정


3.1. Rates 포맷


  • 포맷: "숫자/간격"
  • 숫자: 지정 간격 내의 최대 요청 제한 횟수
  • 간격: 지정 문자열의 첫 글자만 사용. "d", "day", "ddd"모두 Day로서 사용 "s" : 초, "m": 분, "h": 시, "d": 일

3.2. Rates 제한 메커니즘


SingleRateThrottle에서는 요청한 시간의 timestamp를 list로 유지. 매 요청시마다 다음을 수행.

  1. cache에서 timestamp list를 가져온다.
  2. 체크 범위 밖의 timestamp값들은 모두 버린다.
  3. timestamp list의 크기가 허용범위보다 클 경우, 요청을 거부한다.
  4. timestamp list의 크기가 허용범위보다 작을 경우, 현재 timestamp를 list에 추가하고, cache에 저장한다.

3.3. 클라이언트 IP


X-Forwarded-For 헤더와 REMOTE_ADDR 헤더를 참조해서, 확정

우선순위 : X-Forwarded-For > REMOTE_ADDR

  • 실제 클라이언트 IP에서 요청 시 X-Forwarded-For라는 헤더 이름으로 요청이 온다.
    • 요청을 중계하는 AWS의 로드밸런서, Nginx 등이 X-Forwarded-For라는 헤더를 지원해야 함. (일반적으로는 지원 하나, 확인이 필요할 수 있다.)
    • 중계하는 서버에서 X-Forwarded-For를 지원한다면, 장고에서 실제 클라이언트 IP요청의 횟수를 체크할 수 있다.
  • X-Forwarded-For헤더가 없다면, REMOTE_ADDR헤더를 활용하게 된다.

3.4. API별로 서로 다른 Rate 적용하기


throwttle_classes를 적용할 때, 스콥을 다르게 주기.

# 프로젝트/settings.py
REST_FRAMEWORK = {
    'DEFAULT_THROTTLE_CLASSES': [],
    'DEFAULT_THROTTLE_RATES': {
        'contact': '1000/day',
        'upload': '20/day',
    },
}

# myapp/throttles.py
class CotactRateThrottle(UserRateThrottle):
    scope = 'contact'

class UploadRateThrottle(UserRateThrottle):
    scope = 'upload'

# myapp/views.py
class ContactListView(APIView):
    throttle_classes = [CotactRateThrottle]

class ContactDetailView(APIView):
    throttle_classes = [ContactRateThrottle]

class UploadView(APIView):
    throttle_classes = [UploadRateThrottle]

위의 코드를 ScopedRateThrottle를 통해 간결하게 변경

# 프로젝트/settings.py
REST_FRAMEWORK = {
    'DEFAULT_THROTTLE_CLASSES': [
        'rest_framework.throttling.ScopedRateThrottle',
    ],
    'DEFAULT_THROTTLE_RATES': {
        'contact': '1000/day',
        'upload': '20/day',
    },
}

# myapp/views.py
class ContactListView(APIView):
    throttle_scope = 'contact'

class ContactDetailView(APIView):
    throttle_scope = 'contact'

class UploadView(APIView):
    throttle_scope = 'upload'

3.5. 유저 별로 Rate 다르게 적용하기


REST_FRAMEWORK = {
    'DEFAULT_THROTTLE_RATES': {
        'premium_user': '1000/day',
        'light_user': '10/day',
    },
}

from rest_framework import viewsets
from .serializers import PostSerializer
from .throttling import PremiumThrottle
from .models import Post

class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    throttle_classes = [PremiumThrottle]
    premium_scope = 'premium_user'
    light_scope = 'light_user'

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)
from rest_framework.throttling import UserRateThrottle
class PremiumThrottle(UserRateThrottle):
    def __init__(self):
        """User에 따라 scope이 달라지기에, 생성자에서는 get_rate()를 수행하지 않도록 했습니다."""
        pass  # 생성자에서 기존의 scope을 구현하는 부분이 동작하지 않도록 비워둔다(pass).

    def allow_request(self, request, view):  # allow_request라는 멤버함수 구현을 통해서 현재 이 요청을 허용/거부를 결정한다.
        premium_scope = getattr(view, 'premium_scope', None)
        light_scope = getattr(view, 'light_scope', None)

        if request.user.profile.is_premium_user:
            if not premium_scope:
                return True
            self.scope = premium_scope # premium_scope설정이 없다면, 제한을 두지 않습니다.
        else:
            if not light_scope:
                return True
            self.scope = light_scope # light_scope 설정이 없다면, 제한을 두지 않습니다.

        self.rate = self.get_rate()
        self.num_requests, self.duration = self.parse_rate(self.rate)

        return super().allow_request(request, view)
post-custom-banner

0개의 댓글