django-redis 동시성 문제 해결하는 법
목차
동시성 문제
동시성 문제는 어떤 작업들이 동시에 실행 되면서 발생하는 예기치 않은 에러나 비정상적 동작을 의미합니다. 동시성 문제의 유형은 상황에 따라 다를 수 있습니다. 최근에 오름캠프 훈련생들에게 관련된 코드를 리뷰했었는데 그 내용을 이번 포스팅에서 간단하게 다루려고 합니다.
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초 동안 대기하다가 얻지 못하면 acquired
가 False
가 됩니다. 3초가 되면 자동으로 락은 해제됩니다.
마무리
동시성 문제는 다양한 상황에서 발생할 수 있는데 여러 해결 방법 중 Redis 분산락을 이용하는 방법을 알아봤습니다. django-redis
를 이용해서 쉽게 redis 서버에 접근하고 코드에서 이용할 수 있습니다.