선착순 쿠폰 발급 시스템 구현하기: Redis와 Kafka를 활용한 설계

선착순 쿠폰 발급 시스템을 구현하는 방법에 대해 궁금하여 공부하며 기록 해보았습니다.

1. 요구사항 정의

이벤트를 진행하며, 선착순 100명에게만 할인 쿠폰을 지급하고자 합니다. 시스템은 아래의 조건을 만족해야 합니다.

  • 선착순 100명에게만 지급되어야 한다.
  • 많은 유저가 동시에 쿠폰을 요청할 때 트래픽을 버틸 수 있어야 한다.
  • 쿠폰 발급 시 데이터 정합성을 유지해야 한다.

이러한 요구사항을 충족하기 위해 트랜잭션 관리, 동시성 처리, 비동기 시스템 구성 등 다양한 기술적 요소를 고려해야 합니다. 동시에 쿠폰 발급이 이루어질 때, DB에 과부하를 주지 않고 처리할 수 있는 방법도 필요합니다.

2. 문제 해결 방안

(1) Redis를 활용한 문제 해결

Redis는 싱글 스레드 기반으로 동작하며, 내부적으로는 여러 가지 고유한 특성을 통해 높은 성능을 보장합니다. 특히 INCR 명령어는 간단하면서도 매우 빠르게 쿠폰 발급 개수를 관리할 수 있습니다. 이 명령어는 Redis의 싱글 스레드 동작 방식을 활용해 여러 사용자 요청이 동시에 들어와도 데이터 정합성을 유지할 수 있게 해줍니다.

핵심 포인트:

  • INCR 명령어는 Redis 키의 값을 1씩 증가시키며, 레이스 컨디션 문제를 해결할 수 있습니다.
  • 쿠폰 발급 개수만 Redis로 관리하고, 발급 개수를 초과하지 않도록 제어합니다. Redis의 빠른 성능 덕분에 다수의 요청을 처리하면서도 성능 저하 없이 데이터를 관리할 수 있습니다.

Redis 명령어 예시:

127.0.0.1:6379> incr coupon_count
(integer) 1
127.0.0.1:6379> incr coupon_count
(integer) 2


@Repository
public class CouponCountRepository {
    private final RedisTemplate<String, String> redisTemplate;
    public CouponCountRepository(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    public Long increment() {
        return redisTemplate
                .opsForValue()
                .increment("coupon_count");
    }
}

이처럼 Redis는 빠르게 쿠폰 발급 개수를 증가시키고 그 값을 가져올 수 있습니다. 이러한 방식으로 Redis를 통해 선착순 쿠폰 발급 시스템의 개수 제한과 속도 문제를 해결할 수 있습니다.

(2) MySQL과 Redis를 함께 사용 시 문제점

Redis만으로는 발급된 쿠폰 정보를 영구적으로 저장하는 데는 한계가 있습니다. 발급된 쿠폰의 정보는 데이터베이스(MySQL) 에 저장하여 영속성(데이터 유지)을 보장해야 합니다. 하지만 MySQL과 Redis를 함께 사용할 때 발생할 수 있는 몇 가지 문제가 있습니다.

  • 트랜잭션 처리: MySQL의 insert 성능이 한계에 도달하면 다른 서비스 요청 처리에도 영향을 미칠 수 있습니다.
  • DB 자원의 과부하: 트래픽이 집중되면 DB 리소스를 많이 사용하게 되어 서비스 지연 또는 오류가 발생할 수 있습니다.

3. Kafka를 활용한 문제 해결

Kafka를 활용하면 대규모 트래픽을 효율적으로 처리할 수 있습니다. Redis로 선착순 쿠폰 개수를 관리하고, Kafka를 통해 쿠폰 발급 요청을 처리하도록 시스템을 설계할 수 있습니다.

Kafka란 이벤트 스트리밍 플랫폼으로, Producer-Consumer 구조를 통해 대규모 트래픽을 효율적으로 처리할 수 있습니다. 여기서 Producer는 쿠폰 발급 요청을 Kafka의 토픽에 적재하고, Consumer는 적재된 쿠폰 발급 요청을 처리하는 구조입니다. 이 방식으로 MySQL에 직접적으로 과부하가 걸리는 상황을 방지할 수 있습니다.

스크린샷 2024-09-26 오후 3.24.53.png

 

Kafka 사용 시 장점:

  • 이벤트 기반 아키텍처: 쿠폰 발급 요청을 Producer에서 Consumer로 전달하여 비동기적으로 처리할 수 있습니다. 쿠폰 발급 요청을 Topic이라는 Queue에 쌓고, 각 요청을 개별적으로 처리할 수 있습니다. 즉, 한 번에 많은 요청이 몰려도 MySQL에는 순차적으로 저장됩니다.
  • 확장성: Kafka는 분산 시스템으로 수평 확장이 용이합니다. 만약 트래픽이 예상보다 많아지면 Consumer의 개수를 늘려 성능을 보완할 수 있습니다.

Kafka 토픽 생성 및 프로듀서/컨슈머 실행하기:

# Kafka 토픽 생성
docker exec -i kafka kafka-topics.sh --bootstrap-server localhost:9092 --create --topic coupon_create

# Kafka 프로듀서 실행
docker exec -it kafka kafka-console-producer.sh --topic coupon_create --broker-list 0.0.0.0:9092

# Kafka 컨슈머 실행
docker exec -it kafka kafka-console-consumer.sh --topic coupon_create --bootstrap-server localhost:9092 --key-deserializer "org.apache.kafka.common.serialization.StringDeserializer" --value-deserializer "org.apache.kafka.common.serialization.LongDeserializer"

이러한 방식으로 Kafka Producer를 통해 쿠폰 발급 요청을 전달하고, Consumer가 쿠폰을 발급하는 구조를 구현할 수 있습니다.

4. 시스템 개선: 쿠폰 발급 시 발생하는 문제 해결

(1) 1인당 1개의 쿠폰 발급 제한

쿠폰 발급을 1인당 1개로 제한하려면 Redis의 Set 자료구조를 활용할 수 있습니다. Redis에는 SADD 명령어를 통해 Set을 사용할 수 있고 특정 유저가 두 번 이상 쿠폰을 발급받지 않도록 제어할 수 있습니다.

 

Redis Set 사용 예시:

// Redis cli: coupon_users라는 Redis Set에 유저 ID 1을 추가하는 명령어
127.0.0.1:6379> sadd coupon_users 1

@Repository
public class AppliedUserRepository {
    private final RedisTemplate<String, String> redisTemplate;
    public AppliedUserRepository(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    public Long add(Long userId) {
        return redisTemplate
                .opsForSet()
                .add("applied_user", userId.toString());
    }
}

@Service
public class ApplyService {

    private final CouponCountRepository couponCountRepository;
    private final CouponCreateProducer couponCreateProducer;
    private final AppliedUserRepository appliedUserRepository;

    public void apply(Long userId) {
        Long apply = appliedUserRepository.add(userId);

        // 1이 아니라면 이미 요청을 발급 했던 유저
        if (apply != 1) {
            return;
        }

        Long count = couponCountRepository.increment();

        if (count > 100) {
            return;
        }

        couponCreateProducer.create(userId);
    }
}

이미 발급된 유저 ID가 있을 경우 중복되지 않기 때문에 발급을 방지할 수 있습니다.

(2) 쿠폰 발급 실패 시 처리

스크린샷 2024-09-26 오후 3.27.53.png

쿠폰 발급 과정에서 오류가 발생할 경우, 쿠폰 발급 개수는 증가했지만 실제로 쿠폰이 발급되지 않는 문제가 발생할 수 있습니다. 이를 해결하기 위해 백업 데이터와 로그를 남겨서 문제를 해결할 수 있습니다. 오류가 발생한 데이터는 별도의 테이블이나 Redis에 저장하고, 이를 주기적으로 확인하여 쿠폰을 다시 발급합니다.

 

FailedEvent 엔티티 사용 예시:

  • 쿠폰 발급 실패 시 FailedEvent 테이블에 로그를 남기고, 배치 작업을 통해 이를 재처리합니다.
  • 이 방식은 시스템의 안정성을 높이고, 쿠폰 발급 오류로 인한 손실을 최소화할 수 있습니다.

 


참고

인프런 '최상용'님의 '실습으로 배우는 선착순 이벤트 시스템'을 수강하고 정리한 글입니다.