개발 노트/Marmot

Django로 만드는 쿠폰 발급 서비스

j4ko 2025. 6. 6. 17:45
728x90
반응형

 

개요

이 포스팅은 "FastCampus"의 "대규모 쿠폰 발행 시스템 구축"에 관한 강의를 보고 Django로 재구성하면서 정리한 내용이다.

 

쿠폰 발급 서비스의 의의

쿠폰 발행은 마케팅 전략의 일환으로 높은 트래픽 동시성을 해결하지 못하면 시스템 장애로 이어질 수 있으며 이 강의에서는 이러한 문제를 해결할 수 있는 시스템 아키텍처와 해결 방안을 단계별로 알아보도록 하는 게 목표다.

 

대개의 경우 한정된 수량의 쿠폰을 짧은 시간안에 발행하는 형태로 진행되며 문제가 되는 부분은 상술했듯 높은 트래픽과 동시성 제어이다.

 

사용자가 만약 동시에 요청을 보낼 경우 쿠폰 중복 발행, 데이터 오염, 시스템 다운 타임이 발생할 수 있으며 시스템 안정성과 확장성이 보장되어야 한다.

 

 

엔티티

상황에 따른 요구사항과 해당 도메인의 특성에 따라 엔티티를 어떻게 구성할지는 달라지겠지만 이 프로젝트에서는 기본적으로 “쿠폰 정책(Coupon Policy)” 과 “쿠폰(Coupon)”이라는 두 가지 엔티티를 다룬다.

 

쿠폰 정책과 쿠폰의 관계

쿠폰 정책과 쿠폰에 대한 관계는 “쿠폰정책(1) … 쿠폰(N)”이다.

 

즉 다음과 같이 정리할 수 있다.

  • 하나의 쿠폰 정책에 여러 개의 쿠폰이 연결된다.
  • 쿠폰은 반드시 하나의 쿠폰 정책을 가진다.

 

쿠폰 정책 (Coupon Policy)

쿠폰 정책은 쿠폰의 할인 조건, 사용 가능 기간, 수량 제한 등 정책적인 요소를 다루는 엔티티이다. 시스템에서 발급되거나 적용 가능한 할인 쿠폰 정책을 관리하는 데 사용된다.

coupon_policy_id 쿠폰 정책 고유 식별자 (Auto 증가)
name 쿠폰 정책의 이름 (최대 36자)
description 쿠폰 정책 설명 (선택, 최대 72자)
discount_type 할인 유형 (정액: FIXED_AMOUNT, 정률: PERCENT_AMOUNT)
discount_value 할인 금액 or 할인율 (숫자값)
minimum_order_amount 해당 쿠폰을 적용하기 위한 최소 주문 금액
maximum_order_amount 해당 쿠폰을 적용할 수 있는 최대 주문 금액
total_quantity 발급 가능한 총 쿠폰 수량
start_time / end_time 쿠폰의 사용 가능 시작/종료 시점

 

쿠폰 (Coupon)

실제 발급된 쿠폰을 다루는 엔티티다. 개별 쿠폰의 발급, 사용 상태, 유효성 등 운영 상태를 관리하는 모델이다. 사용자에게 발급된 쿠폰이 실제로 어떻게 사용되고 어떤 상태인지를 추적한다.

coupon_id 쿠폰 고유 식별자 (Auto 증가)
coupon_code 실제 쿠폰 코드 (최대 6자)
coupon_policy 연결된 쿠폰 정책 (CouponPolicy와 ForeignKey 관계)
status 쿠폰 상태 (AVAILABLE, USED, EXPIRED, CANCELLED)
order_id 해당 쿠폰이 사용된 주문의 ID (문자열, 최대 36자)
used_at 쿠폰 사용 시간 (nullable)

 

 

시스템 구성

개요에서 언급한 바와 같이 이 강의는 Java 환경에 맞춰 구성되었다. 하지만 내가 주로 쓰는 언어는 Java가 아니므로 현재 사용 중인 기술 스택에 맞춰 Django로 쿠폰 발행 시스템을 재구성해봤다.

 

주요 컴포넌트

Client

  • Coupon Service로 쿠폰 발급 요청을 보내는 주체이다. 개발 단계에서는 스스로 쿠폰 발급 요청을 보내지만 테스트 단계에서는 성능 테스팅 도구를 통해 대량의 요청을 보내서 Coupon Service가 높은 트래픽을 견뎌내는지 테스트해보자.

Coupon Service

  • Coupon 발급을 처리하는 Django Server이다. 쿠폰 정책이 유효한지 검증하며 이를 통해 쿠폰을 발급한다.
  • 쿠폰 정책을 검증하고 쿠폰 발급을 처리하는 주체이기 때문에 동시성 제어를 담당한다.

Data Base

  • 쿠폰 정책과 발급된 쿠폰을 영구 저장한다. RDB에서의 트래픽을 확인해 보고 Redis를 도입하여 캐싱처리 후 더 높은 트래픽을 받아낼 수 있는지 처리해 보자.

 

DataBase 기반

초기에는 Client가 CouponService Server로 쿠폰 발급 요청을 보내고 CouponService Server가 DB에 쿠폰을 생성 및 저장하며 저장 결과를 반환할 것이다.(DB는 MySQL을 사용했다. )

sequenceDiagram
    participant Client
    participant CouponService
    participant DB as Database

    Client->>CouponService: 쿠폰 발급 요청 (정책 ID 포함)
    CouponService->>DB: 쿠폰 정책 조회 (정책 ID)
    DB-->>CouponService: 쿠폰 정책 정보 반환

    alt 정책이 유효함
        CouponService->>DB: 쿠폰 발급 및 저장
        DB-->>CouponService: 발급 결과 반환
        CouponService-->>Client: 쿠폰 코드 응답
    else 정책이 없음 또는 만료됨
        CouponService-->>Client: 발급 불가 메시지 반환
    end

 

기본적인 CRUD를 통한 데이터 처리, Transaction을 통해 쿠폰 발행을 처리한다.

 

Redis 기반

높은 트래픽을 받아낼 수 있게 Redis를 적용하자. CouponService Server와 DataBase 중간에 Redis가 추가되었다.

sequenceDiagram
    participant Client
    participant CouponService
    participant Redis
    participant DB as Database

    Client->>CouponService: 쿠폰 발급 요청 (정책 ID 포함)
    CouponService->>DB: 쿠폰 정책 조회 (정책 ID)
    DB-->>CouponService: 쿠폰 정책 정보 반환

    alt 정책이 유효함
        CouponService->>Redis: 정책 수량 차감 시도 (Decr or Lock)
        alt Redis 처리 성공
            CouponService->>DB: 쿠폰 발급 및 저장
            DB-->>CouponService: 발급 결과 반환
            CouponService-->>Client: 쿠폰 코드 응답
        else Redis 처리 실패 (수량 없음 등)
            CouponService-->>Client: 쿠폰 수량 소진 메시지
        end
    else 정책이 없음 또는 만료됨
        CouponService-->>Client: 발급 불가 메시지 반환
    end

Redis를 활용한 동시성 제어와, 분산 락을 구현하여 Redis 구성에서 데이터 정합성을 어떻게 지킬 수 있는지 알아보자.

 

 

중간 정리

상술한 모든 내용을 정리하면 다음과 같은 요구사항을 처리할 수 있어야 한다.

 

쿠폰 정책 관리 측면

  • 쿠폰 발행 수량 제한
  • 사용 기간 관리
  • 할인 정책 설정

 

쿠폰 발행 측면

  • 동시성 제어
  • 수량 제한 확인
  • 중복 발행 방지

 

쿠폰 발행 서비스

참고한 강의에서는 개선 방향을 DB → REDIS → Kafka 형식으로 가져가지만 단순히 DB → REDIS 까지만 정리해 봤다.

 

DB 기반 쿠폰 발행

DataBase 기반으로 쿠폰 발행 시에는 동시성 제어를 위해 “SELECT … FOR UPDATE”를 통한 Exclusive Lock을 걸어 처리한다. 이는 두 개의 트랜잭션이 동시에 실행되더라도 같은 쿠폰이 중복으로 발행되지 않도록 방지하기 위함이다.

graph LR
    Client  --> CouponService
    CouponService --> DB[(Database)]

쿠폰 발행을 Django에서 구현할 때는 다음과 같은 로직을 사용했다.

@transaction.atomic
def issue_coupon(self,
                 request: CouponCreateSchema.CouponCreateRequest):
    """ 쿠폰 생성하기 """
		coupon_policy: Optional[CouponPolicy] = CouponPolicy.objects.select_for_update()\\
		.filter(
		            coupon_policy_id=request.validated_data["coupon_policy_id"]
		        ).first()

    if coupon_policy is None:
        raise Exception("Coupon Policy Not Found")

    if not coupon_policy.is_issueable_time_coupon():
        raise InvalidIssuedTimesCoupon()

    # 쿠폰 정책에 설정된 수량보다 많은 쿠폰을 생성할 수 없음
    issued_coupon_count = Coupon.objects.filter(coupon_policy=coupon_policy).count()
    if coupon_policy.total_quantity <= issued_coupon_count:
        raise NotEnoughCoupon()

    new_coupon = Coupon.init_entity(coupon_policy=coupon_policy)
    new_coupon.save()

    return new_coupon

쿠폰 정책에 따라 쿠폰을 발행할 수 없는 경우의 Exception을 처리하고 검증이 끝나면 쿠폰 발행을 처리한다. 위 코드를 사용해 쿠폰 정책부터 쿠폰 발급까지의 쿼리는 다음과 같다.

(0.002) | SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED

(0.001) | BEGIN

(0.001) | SELECT `coupon_policy`.`updated_at`,
	|        `coupon_policy`.`created_at`,
	|        `coupon_policy`.`coupon_policy_id`,
	|        `coupon_policy`.`name`,
	|        `coupon_policy`.`description`,
	|        `coupon_policy`.`discount_type`,
	|        `coupon_policy`.`discount_value`,
	|        `coupon_policy`.`minimum_order_amount`,
	|        `coupon_policy`.`maximum_order_amount`,
	|        `coupon_policy`.`total_quantity`,
	|        `coupon_policy`.`start_time`,
	|        `coupon_policy`.`end_time`
	| FROM `coupon_policy`
	| WHERE `coupon_policy`.`coupon_policy_id` = 160
	| ORDER BY `coupon_policy`.`coupon_policy_id` DESC
	| LIMIT 1
	| FOR
	| UPDATE

(0.003) | SELECT count(*) AS `__count`
	| FROM `coupon`
	| WHERE `coupon`.`coupon_policy_id` = 160

(0.001) | INSERT INTO `coupon` (`updated_at`, `created_at`, `coupon_code`, `coupon_policy_id`, `status`, `order_id`, `used_at`)
	| VALUES ('2025-06-05 16:53:24.184268', '2025-06-05 16:53:24.184289', '86ab96', 160, 'AVAILABLE', '47bb3a6de6ef4980b037cd7c42bdfee2', NULL)

(0.002) | COMMIT

이를 gunicorn으로 띄워서 RPS를 측정해 본다면 어느 정도의 수치가 나올까?

gunicorn config.wsgi:application --workers 8 --threads 8 --timeout 60 --keep-alive 5

User 수는 1000명 증가율은 100으로 설정했다.

단순 요청을 걸어서 처리했고 RPS는 150 ~ 160 범위로 측정됐다.

 

이렇게 “SELECT … FOR UPDATE”를 사용하는 해서 동시성 제어를 처리하는 경우 무엇을 알아야 할까?

  • 잠금 경합
    • 먼저 들어온 요청이 트랜잭션을 걸고 처리하는 동안 다른 요청의 트랜잭션이 이를 대기해야 된다.
  • 트랜잭션이 길어질 수 있음에 주의
    • 하나의 트랜잭션이 오래 걸릴수록 잠금 유지 시간도 길어지기 때문에 트랜잭션의 범위를 짧게 가져가야 한다.
  • DB 자원 소모
    • 락 대기 트랜잭션이 늘어나면 CPU, I/O 자원에 병목이 생긴다. 이는 DB에 트래픽이 집중되어 DB 자체에 병복이 걸릴 수 있음을 의미한다.
  • Dead Lock 문제
    • SELECT … FOR UPDATE 사용에 주의를 가하지 않으면 Dead Lock이 유발될 수 있다.

 

정리해 보자면 SELECT … FOR UPDATE를 사용하는 경우에는 잠금으로 인해 대기시간이 늘어나 DB에 병목으로 이어질 수 있다는 점이다. 참고한 강의에서는 Redis를 활용해 이러한 이슈에 대응하지만 만약 Redis를 쓰지 않고 해결한다고 하면 어떤 방법들이 있을까?

 

대체적으로는 다음과 같은 방법들이 존재한다.

  • 트랜잭션 시간 줄이기 → 트랜잭션 안에서 최소한의 작업만 수행
  • 락 범위 줄이기 → WHERE 조건을 세밀하게 조정해 락이 걸리는 레코드 수를 최소화
  • 낙관적 락 적용 → 단순 SELECT … FOR UPDATE를 거는 것은 PESSIMISTIC LOCK 방식이므로 version 필드를 추가해 실패한 트랜잭션은 재시도를 하게끔 유도하자.

 

 

캐싱을 통한 쿠폰 발행

앞선 DB 기반 쿠폰 발행에서는 “SELECT … FOR UPDATE”를 사용해 동시성 제어를 시도했다. 또한 트랜잭션 처리와 대기가 길어지면 병목이 발생한다는 점을 정리할 수 있었다. 이러한 트랜잭션의 경합을 Redis 수준에서 제어하도록 처리해 보기 위해 Redis를 한번 경유해서 쿠폰이 발행될 수 있게 조정해 보자.

graph LR
    Client --> CouponService
    CouponService --> DB[(Database)]
    CouponService --> Redis

django에서 redis를 사용하기 위해서 django-redis를 설치했으며 Redis를 이용한 쿠폰 발행 로직은 다음과 같이 가져갔다.

class CouponRedisService:
    COUPON_QUANTITY_KEY = "coupon:quantity"
    COUPON_LOCK_KEY = "coupon:lock"
    LOCK_WAIT_TIME = 5
    LOCK_LEASE_TIME = 10

    @transaction.atomic
    def issue_coupon(self, request: CouponCreateSchema.CouponCreateRequest) -> Coupon:

        quantity_key = f"{self.COUPON_QUANTITY_KEY}:{request.validated_data['coupon_policy_id']}"
        lock_key = f"{self.COUPON_LOCK_KEY}:{request.validated_data['coupon_policy_id']}"

        try:
            with cache.lock(lock_key, timeout=self.LOCK_WAIT_TIME, blocking_timeout=self.LOCK_LEASE_TIME):
                coupon_policy: Optional[CouponPolicy] = CouponPolicy.objects.select_for_update().filter(
                    coupon_policy_id=request.validated_data["coupon_policy_id"]
                ).first()

                if coupon_policy is None:
                    raise Exception("Coupon Policy Not Found")

                if not coupon_policy.is_issueable_time_coupon():
                    raise InvalidIssuedTimesCoupon()

                current_quantity = cache.get(quantity_key)
                if int(current_quantity) <= 0:
                    raise NotEnoughCoupon()
                else:
                    cache.decr(quantity_key)

                new_coupon = Coupon.init_entity(coupon_policy=coupon_policy)
                new_coupon.save()

                return new_coupon
        except redis.exceptions.LockError as e:
            raise Exception("Too Many Coupon Issue Request")

Redis 수준에서 락을 획득하기 때문에 로그는 따로 설정하지 않았다. DB 기반의 쿠폰 발행과 동일하게 Gunicorn을 사용했다.

gunicorn config.wsgi:application --workers 8 --threads 8 --timeout 60 --keep-alive 5

다시 Locust를 통해 RPS를 측정한 경우 190대 정도로 꽤 많이 향상된 것을 알 수 있다. 또한 백분위 수는 다음과 같다.

 

이렇게 REDIS를 통해 락을 제어하고 처리하는 경우 무엇을 알아야 할까?

  • 분산 락 처리
    • DB에서의 락 대신 Redis에서 키 기반의 락을 설정하여 트랜잭션 경합을 Redis 수준으로 제어할 수 있다.
  • 읽기 병목 해소
    • 캐시 히트율이 높아질수록 DB 요청을 줄여 락 경합을 줄이는 효과

 

REDIS를 도입했음에도 불구하고 개선할 여지는 아직도 존재하는데 이는 다음과 같다.

  • 락 획득 대기시간 최적화 → 트래픽 패턴에 따라 조 락 획득 대기 시간을 조정해야 한다.
  • 재시도 메커니즘 추가 → 락 획득에 실패한 요청은 exponential backoff를 적용해 다시 시도하게끔 구성해야 한다.
  • 분산 락 실패 시 대기열 전환 검토

 

마치며

이 내용은 FastCampus의 강의 중 “쿠폰 발행 서비스” 구현을 참고하여 Django로 구성하여 만들어본 내용 중 일부를 기록한 것이다. FastCampus에서는 Java로 예제를 다루기 때문에 Django로 재구성하는 과정에서 다음과 같은 고민을 했었다.

  • Java의 다양한 객체를 Django에서는 어떻게 표현할 수 있을까?
  • Java의 ORM과 Service, Repository 패턴을 Django의 ORM으로 만들어도 괜찮을까?
  • Java의 Connection Pool 설정이 Django의 CONN_MAX_AGE로 대체할 수 있는 건가?
  • Django RestFramWork의 Serializer를 사용했는데 이것 때문에 더 느려지는 요소는 없을까?
  • 생각보다 RPS가 낮은데 어떻게 확장할 수 있을까?

위는 Java를 Django로 구성하는 과정에서의 고민이었는데 실제로 쿠폰 발행 서비스를 만든다면 다음과 같은 측면에서 개선점을 잡아봐야 하지 않을까 싶다.

  • 쿠폰 발행 시, 대기열을 통해 접속 순번을 보여주기
    • 대기열이 많아지는 경우 얼마나 기다리는지 보여줄 수 있기 때문에 효과적이라고 생각됨
  • 쿠폰 발행 시, 알림 처리
    • 본 포스팅에서는 단순히 쿠폰 발급 요청을 통해 쿠폰이 생성되지만 현실에서는 특정 사용자에게 쿠폰을 발행하는 케이스도 있음
  • 쿠폰 사용 시, 쿠폰 사용 이력 관리
    • 쿠폰을 어디에, 어떻게 사용했다는 이력을 기반으로 유의미한 지표를 가정하고 분석할 수 있지 않을까

이 강의를 통해 동시성 제어와 트래픽이 높아지는 경우를 어떻게 처리하는지를 실험해 볼 수 있는 내용이었기도 하지만 현실에서 사용할법한 상황을 가정한다는 점에서 알고 있는 지식을 점검하고 어떻게 개선하면 좋을지를 알 수 있었던 좋은 데모인 듯싶다.

728x90
반응형