본문 바로가기

Django/성능 개선 + @

DRF 메가 쇼핑몰 쿠폰 발급 동시성 문제 해결

커머스 도메인을 다루는 서비스를 보면 쿠폰을 제공하는 기능이 꼭 하나씩은 존재한다. 선착순으로 진행하는 경우도 있고 그렇지 않은 경우도 있는데, 대다수의 쿠폰은 재고를 가지기 마련이다.

단순하게 생각한다면 DB에 쿠폰의 재고를 저장해두고 유저가 쿠폰을 획득할 때 마다 재고를 하나씩 차감하면 된다고 생각할 수 있는데 여기에는 큰 문제점이 하나 존재한다.

예를 들어 100개의 쿠폰을 발급할 수 있다고 가정해보자. 99개의 쿠폰이 소진되었고 재고가 1개 남은 상태이다. 이때 A라는 유저가 재고를 조회하고 데이터베이스에서는 1개의 재고가 남았다고 알려준다.

그리고 동시에 B유저가 재고 조회를 진행하고 마찬가지로 데이터베이스에서는 1개의 재고가 남았다고 알려준다. 다음으로 A가 쿠폰을 획득함과 동시에 재고를 1개 차감시킨다. 이 때 재고는 0개가 된다. 그 후 B유저 또한 조회 당시 1개의 재고가 남았다는 응답을 받았기 때문에 쿠폰을 획득하고 재고를 1개 차감시킨다. 그리고 총 재고는 -1개가 된다.

100개의 쿠폰만 발급해줘야 하는 상황에서 동시성으로 인한 이슈가 발생한 것이다. 본 포스팅에서는 이렇게 대규모의 트래픽이 들어올 때 발생할 수 있는 동시성 문제를 제어하는 방법에 대해 다뤄본다. 예제 코드는 파이썬이며 Django 프레임워크와 MySQL, Redis를 활용한다

 

1. 요구사항

  • 쿠폰은 100개가 생성된다.
  • 한명의 유저는 하나의 쿠폰만 발급 받을 수 있다.
  • 쿠폰을 발급 받으면 쿠폰의 총 수량에서 -1을 한다.

 

2. 동시성 제어

Redis에는 Set이라는 자료구조가 존재한다. 해당 자료구조는 중복을 허용하지 않는다. 따라서 Set 자료구조에 유저의 id를 저장한다면 중복을 허용하지 않기 때문에 2번의 요구사항을 만족시킬 수 있으며 Set의 총 크기를 파악하는 명령어 또한 제공되기 때문에 1번 요구사항도 만족한다. 참고로 Set에 데이터를 추가하는 SADD 명령어는 O(1)의 시간 복잡도를 가지고 있으며 Set의 크기를 파악하는 SCARD 명령어 또한 O(1)의 시간 복잡도를 가지고 있다. 한번 더 깊게 생각해보자.
위 문단에서 설명한 Redis의 Set을 활용하면 완벽하게 동시성을 제어할 수 있을까?

그렇지 않다. SCARD를 통해 발급된 쿠폰의 개수를 파악한 다음 SADD를 통해 쿠폰을 추가로 발급하는 찰나의 순간에 요청이 들어온다면 개요에 나와있는 사진과 동일한 문제가 발생할 것이다. 따라서 여기에서는 Transaction을 이용해서 여러개의 명령어를 1개의 atomic한 원자성 연산으로 실행시켜줘야 한다.

이를 위해 Redis는 MULTI와 EXEC이라는 명령어를 제공한다. MULTI를 입력한 후 다른 명령어를 입력하면 해당 명령어들은 즉시 실행되는것이 아닌 큐에 들어가게 된다. 그리고 EXEC 명령어를 통해 일괄적으로 실행된다. 이를 통해 여러 명령어를 하나의 연산으로 실행시킬 수 있는 것이다.

한가지 주의할점이 있는데, Redis Transaction의 경우 DB의 Transaction과는 다르게 롤백 기능을 제공하지 않는다. 따라서 오류 상황에 대한 대처도 필요하다. 정리하자면 다음과 같은 시나리오를 통해 요구사항을 만족시킬 수 있을 것 같다.

 

Django Code

아래의 코드는 쿠폰 모델을 선언한 것이다.
from django.db import models

from user.models import User


class Coupon(models.Model):
    name = models.CharField('쿠폰 이름', max_length=100)
    discount_price = models.DecimalField('쿠폰 가격', max_digits=10, decimal_places=2, null=True, blank=True)
    discount_percent = models.DecimalField('쿠폰 퍼센트', max_digits=10, decimal_places=2, null=True, blank=True)
    quantity = models.PositiveIntegerField('쿠폰 수량', default=0)

    user = models.ManyToManyField(User, through='coupon.CouponUser', related_name='coupon_user')

    reg_date = models.DateTimeField('등록 날짜', auto_now_add=True)

    class Meta:
        db_table = 'coupon'

    def __str__(self):
        return self.name


class CouponUser(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    coupon = models.ForeignKey(Coupon, on_delete=models.CASCADE)

    reg_date = models.DateTimeField('발급일', auto_now_add=True)

    class Meta:
        db_table = 'coupon_user'

    def __str__(self):
        return f'{self.user.username}, {self.coupon}'​

아래의 코드는 쿠폰의 시리얼라이저를 구현한 것이다.
class CouponUserSerializer(serializers.ModelSerializer):
    """
    유저 쿠폰 발급
    """
    user = serializers.ReadOnlyField(source='user.username')
    coupon = serializers.IntegerField(required=True)

    class Meta:
        model = CouponUser
        fields = [
            'id',
            'user',
            'coupon',
            'reg_date'
        ]

    # 동시성 문제를 해결하기 위해 트랜잭션 설정
    @transaction.atomic()
    def create(self, validated_data, *args, **kwargs):

        # 쿠폰 수량 -1
        Coupon.objects.filter(
            pk=validated_data.get('coupon'),
        ).update(quantity=F('quantity') - 1)

        # 트랜잭션 처리 확인
        # raise('도중에 실패함 근데 트랜잭션으로 해결한거 같음.')

        # 유저의 쿠폰 발급
        coupon_user = CouponUser.objects.create(
            user=self.context['request'].user,
            coupon_id=validated_data.get('coupon'),
        )
        return coupon_user​


시리얼라이저를 확인해보면 transaction.atomic()이라는 데코레이터가 보일 것이다.

만약에 유저가 쿠폰을 발급받았을 때, 쿠폰의 총 수량은 -1로 감소하고 유저는 해당 쿠폰을 발급받게 되는 로직으로 구현되었는데, 만약에 그 사이에서 에러가 발생한다면 어떻게 될까?

당연히 쿠폰의 총 수량만 감소하고 유저는 쿠폰은 발급받지 못하게 되는 이슈가 발생할 것이다.
그러면 우리는 위의 로직을 하나의 블록을 묶어서 중간에 에러가 발생하면 묶어놓은 블록 자체의 로직을 롤백할 수 있도록 구현할 필요가 있다.

이 것을 동시성이라고 하고, 이를 해결하기 위해서는 Redis나 django의 트랜잭션을 사용하면 해결된다.
본인은 둘다 해볼 예정이지만 지금은 장고의 트랜잭션을 활용할 것이다.

@transaction.atomic()

이라는  데코레이터만 추가하면 그 함수의 로직은 하나의 블록으로 묶어서 실행되는 것이다.

잠깐 쿠폰의 뷰 함수를 확인해보자.

class CouponListDetailViewSet(mixins.ListModelMixin,
                              mixins.RetrieveModelMixin,
                              mixins.CreateModelMixin,
                              viewsets.GenericViewSet):
    """
    쿠폰 리스트, 디테일 뷰셋
    """
    lookup_url_kwarg = 'coupon_id'

    def get_queryset(self):
        if self.request.method == 'GET':
            return Coupon.objects.all()
        else:
            return CouponUser.objects.alL()

    def get_serializer_class(self):
        if self.request.method == 'GET':
            return CouponSerializer
        else:
            return CouponUserSerializer

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        # serializer의 create() 함수 호출
        serializer.save()
        return Response(status=status.HTTP_201_CREATED)

def create()함수에서 serializer.save()라는 코드가 보이는데 여기서 save()의 내부함수에는 시리얼라이저의 def create()를 호출하는 함수가 내장되어있다.

그러면 반대로 시리얼라이저의 @transaction.atomic() 부분을 지우고, 뷰의 serializer.save() 부분에 트랜잭션을 설정해도 되지 않을까?

당연히 가능하다. 하지만 앞서 데코레이터로 트랜잭션을 설정할 수는 없고,

with transaction.atomic():
	serializer.save()

위의 코드처럼 수정하면 같은 효과를 낼 수 있다.