본문으로 바로가기
반응형

개요

커머스 도메인을 다루는 서비스를 보면 쿠폰을 제공하는 기능이 꼭 하나씩은 존재한다. 선착순으로 진행하는 경우도 있고 그렇지 않은 경우도 있는데, 대다수의 쿠폰은 재고를 가지기 마련이다. 단순하게 생각한다면 DB에 쿠폰의 재고를 저장해두고 유저가 쿠폰을 획득할 때 마다 재고를 하나씩 차감하면 된다고 생각할 수 있는데 여기에는 큰 문제점이 하나 존재한다.

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

다음으로 A가 쿠폰을 획득함과 동시에 재고를 1개 차감시킨다. 이 때 재고는 0개가 된다. 그 후 B유저 또한 조회 당시 1개의 재고가 남았다는 응답을 받았기 때문에 쿠폰을 획득하고 재고를 1개 차감시킨다. 그리고 총 재고는 -1개가 된다. 100개의 쿠폰만 발급해줘야 하는 상황에서 동시성으로 인한 이슈가 발생한 것이다.

본 포스팅에서는 이렇게 대규모의 트래픽이 들어올 때 발생할 수 있는 동시성 문제를 제어하는 방법에 대해 다뤄본다. 예제 코드는 파이썬이며 FastAPI 프레임워크와 MySQL, Redis를 활용한다.

요구 사항

임의로 아래와 같은 간단한 요구사항을 정의한다.

1. 쿠폰은 정확히 100개만 발급되어야 한다.
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과는 다르게 롤백 기능을 제공하지 않는다. 따라서 오류 상황에 대한 대처도 필요하다. 정리하자면 다음과 같은 시나리오를 통해 요구사항을 만족시킬 수 있을 것 같다.

1. SCARD를 통해 현재 발급된 쿠폰의 개수를 가져옴과 동시에 SADD를 통해 발급 개수를 증가시킨다.
2. 발급된 쿠폰의 개수가 총 쿠폰 개수보다 크거나 같은 경우
    2-1. 발급 개수 증가에 성공했다면 기존에 발급되지 않은 유저이므로 해당 유저의 ID를 Set에서 제거한다.
    2-2. 발급 개수 증가에 실패했다면 기존에 발급된 유저이므로 return한다.
3. 쿠폰 획득에 실패한 경우
    3-1. 이미 쿠폰 획득에 성공한 것이므로 return한다.
4. DB에 저장되어있는 쿠폰의 총 개수를 차감시킨다.

구현

@Transactional(propagation=Propagation.REQUIRED)
async def obtain(self, coupon_id: int, user_id: int) -> Optional[NoReturn]:
    ...

쿠폰 발급을 진행하는 메소드는 위와 같은 형태이다. 쿠폰과 유저의 ID를 인자로 받는다.

user: Optional[User]
coupon: Optional[Coupon]
user, coupon = await asyncio.gather(
    self._get_user_by_id(user_id=user_id),
    self._get_coupon_by_id(coupon_id=coupon_id),
)
if not user:
    raise UserNotFoundException

if not coupon:
    raise CouponNotFoundException

if coupon.quantity <= 0:
    raise OutOfStockException

데이터베이스에서 유저와 쿠폰을 가져온 후 실제 존재하는지 검사한다. 그리고 쿠폰의 재고(quantity)가 0보다 큰 상태인 지 검사하여 현재 발급 가능한 상태인지 검사한다.

coupon_key = f"{self.COUPON_KEY}:{coupon_id}"
async with redis.pipeline(transaction=True) as pipe:
    current_count, is_obtain = (
        await pipe.scard(coupon_key).sadd(coupon_key, user_id).execute()
    )

위에서 설명한 트랜잭션을 통해 SCARD, SADD를 통해 현재 쿠폰 개수와 쿠폰 획득 여부에 대해 가져온다. 파이썬의 redis의 경우 pipeline() 메소드를 통해 트랜잭션을 사용할 수 있다.

# Check if remain quantity is available
if current_count >= self.COUPON_COUNT:
    if is_obtain == 1:
        await redis.srem(coupon_key, user_id)

    raise OutOfStockException

Redis에서 가져온 현재 발급 개수와 총 쿠폰 개수를 비교한다. 발급된 쿠폰의 개수가 총 쿠폰 개수보다 크거나 같은 경우, 발급 개수(is_obtain) 증가에 성공했다면 기존에 발급받지 않은 유저이기 때문에 해당 유저의 ID를 Set에서 제거한다. 발급 개수 증가에 실패했다면 기존에 발급받은 유저이기 때문에 넘어가고 특정 예외를 raise하도록 한다.

Bold처리한 부분에 대해서 한번 생각해볼 필요가 있을 것 같다. 예를 들어 100개의 쿠폰이 모두 발급된 상황이라고 가정해보자. 이 때 SCARD, SADD를 진행하는 경우 SCARD=100, SADD=1이 나오는 경우 이미 100개의 쿠폰이 발급되었지만 SADD=1 즉, 획득에 성공하여 Set에 현재 유저의 ID를 넣게 되고 Set의 개수는 101개가 된다. (데이터베이스에는 반영이 되지 않기 때문에 실제 지급은 되지 않겠지만) 따라서 100개가 되었을 시점에 획득에 성공한 유저는 Set에서 제거해줘야 정확하게 쿠폰을 발급받은 유저의 목록만 얻을 수 있다.

# Case of already obtained
if is_obtain == 0:
    raise AlreadyObtainException

이미 위쪽 코드에서 발급 개수가 초과했는지 검사한 이후의 코드 블럭이므로 획득에 실패한 경우 이미 쿠폰을 발급받았음을 의미한다.

coupon.quantity = Coupon.quantity - 1
session.add(coupon)
user_coupon = UserCoupon(
    user=user,
    coupon=coupon,
)
session.add(user_coupon)

마지막으로 DB에 저장되어있는 쿠폰의 개수를 하나 감소시키고 유저가 가지고 있는 쿠폰을 저장하고있는 user_coupon 테이블에 데이터를 저장시킨다.

검증

부하 테스트는 locust를 통해 진행했다. 대략 500명의 유저가 동시에 쿠폰을 획득하려하는 시나리오로 구성했으며 결과는 다음과 같다.

mysql> select user_id from user_coupon group by user_id;
...

|     426 |
|     431 |
|     437 |
|     441 |
|     445 |
|     447 |
|     467 |
|     476 |
|     477 |
|     480 |
|     485 |
|     497 |
+---------+
100 rows in set (0.02 sec)

데이터베이스의 경우 user_id로 그룹핑하여 확인해본 결과 한명의 유저가 하나의 쿠폰을 얻었음을 확인할 수 있다.

127.0.0.1:6379> keys *
1) "coupon:1"
127.0.0.1:6379> smembers coupon:1
 ...
 86) "421"
 87) "424"
 88) "425"
 89) "426"
 90) "431"
 91) "437"
 92) "441"
 93) "445"
 94) "447"
 95) "467"
 96) "476"
 97) "477"
 98) "480"
 99) "485"
100) "497"

Redis에 저장되어있는 Set또한 데이터베이스와 마찬가지로 한명의 유저가 하나씩 총 100개의 쿠폰이 발급되었음을 확인할 수 있다.

생각해볼만한 점

분산락을 사용하면 어떨까? 동작은 제대로 할 것으로 생각된다. 하지만 락을 잡고 있는 동안 다른 Request는 대기해야하기 때문에 대규모 환경에 부적합하다고 생각한다.

Redis를 활용하는것은 관리 포인트가 증가하는것은 아닐까? 그냥 Version컬럼등을 활용한 낙관적 락을 사용하면 어떨까?

추가적으로 대규모 트래픽을 수용하기 위해 쿠폰 획득 요청이 들어오면 큐로 던지고 컨슈머에서 처리함으로써 디비 병목현상을 줄일 수 있다던지..여러가지 고려해볼 사항이 많다는 생각이 든다.

소스 코드

본 포스팅의 모든 소스코드는 https://github.com/teamhide/coupon-system 에서 확인할 수 있다.

반응형