django-redis 동시성 문제 해결하는 법

프로필 사진mingke

목차

동시성 문제

동시성 문제는 어떤 작업들이 동시에 실행 되면서 발생하는 예기치 않은 에러나 비정상적 동작을 의미합니다. 동시성 문제의 유형은 상황에 따라 다를 수 있습니다. 최근에 오름캠프 훈련생들에게 관련된 코드를 리뷰했었는데 그 내용을 이번 포스팅에서 간단하게 다루려고 합니다.

Django로 API를 개발할 때 동시에 여러 번 요청이 오더라도 1번 만 처리되게 해야하는 상황이 있을 수 있습니다. 예를 들면 상품 재고가 1개 일 때, 동시에 구매가 2번일어나서 -1이 되면 안되겠죠. Redis를 이용해서 해결한 방법을 공유해보겠습니다.

Django Redis

django 4.0부터는 Redis를 캐시 백엔드로 기본 지원하는데요. 그래도 django-redis 라이브러리가 더 많은 기능과 옵션들을 제공하여 django-redis를 사용하고 있습니다.

  • 설치
pip install django-redis

그리고 Redis를 사용하려면 Redis 서버를 실행시켜야겠죠. Local에서 Docker를 이용해서 Redis Container를 실행하고 진행했습니다.

Redis도 실행되어있고 django-redis 도 설치되었다면 settings.py를 수정합니다.

...
CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
    }
}

기본적인 설정은 위와 같습니다. 추가 정보 확인

이렇게 추가하면 from django.core.cache import cache 으로 redis에 연결된 클라이언트를 사용할 수 있습니다.

View에서 적용하기

다음과 같은 상품 재고를 업데이트하는 API 테스트가 있습니다.

@pytest.mark.django_db(transaction=True)
def test_product_update_with_concurrency_problem(db, client, sample_products):
    product = sample_products[0]
    product_id = product.id
    stock = product.stock -1
 
    def send_request(product_id, stock):
        return client.patch(f"/api/products/{product_id}/", {"stock": stock})
 
    with ThreadPoolExecutor() as executor:
        responses = executor.map(send_request, [product_id] * 5, [stock] * 5)
 
    status_codes = [response.status_code for response in responses]
 
    assert list(status_codes).count(200) == 1

이 테스트 코드는 상품의 재고를 업데이트 하는 것인데 5개의 요청을 동시에 보냅니다. 하지만 업데이트 요청은 5개 중에 1개만 성공해야 통과가 되는 테스트입니다.

이 테스트를 성공시키기 위해서 Redis의 락을 사용하였고 view코드를 다음과 같이 작성해봤습니다.

from django.core.cache import cache
 
class ProductViewSet(viewsets.ModelViewSet):
    ...
 
    def update(self, request, *args, **kwargs):
        lock_id = f"product-update-lock-{self.kwargs['pk']}"
        lock = cache.lock(lock_id, timeout=3, blocking_timeout=1)
 
        try:
            acquired = lock.acquire(blocking=True)
            if acquired:
                response = super().update(request, *args, **kwargs)
                return response
            else:
                return Response({"error": "3초에 한 번만 수정할 수 있습니다."}, status=status.HTTP_429_TOO_MANY_REQUESTS)
        except Exception as e:
            return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

cache.lock 의 파라미터를 살펴보겠습니다.

  • timeout 은 lock의 지속시간을 의미합니다.
  • blocking_timeout lock을 얻기 위해 대기하는 시간을 의미합니다.

위 코드는 락을 얻기 위해 1초 동안 대기하다가 얻지 못하면 acquiredFalse가 됩니다. 3초가 되면 자동으로 락은 해제됩니다.

마무리

동시성 문제는 다양한 상황에서 발생할 수 있는데 여러 해결 방법 중 Redis 분산락을 이용하는 방법을 알아봤습니다. django-redis 를 이용해서 쉽게 redis 서버에 접근하고 코드에서 이용할 수 있습니다.

Loading...
Loading...