본문 바로가기
개발/Spring

[Spring] 선착순 쿠폰 발급 요구사항 개발 (Redis, Kafka)

by baau 2024. 5. 16.

이번 포스팅에서는

[Inflearn] 실습으로 배우는 선착순 이벤트 시스템 에서 실습한 내용을 기록하고자 하며, 아래 2가지 요구 사항을 만족하고 있다.

 

1. 쿠폰의 한정된 수량을 초과해서는 안된다.

2. 쿠폰은 중복 지급이 불가하여, 1인당 1장만 지급되어야 한다.

 

위의 요구사항을 만족시키고, 아래 문제를 해결하기 위해 아래 기술을 사용하였다.

 

선착순이라는 특성으로, 동시성 문제가 반드시 발생할 것이라 예상하고 이를 해결하기 위해 Redis를 활용한다.

  • INCR 커맨드 사용 : 쿠폰의 한정된 수량을 초과하지 못하도록 한다.
  • SET 자료구조 사용 : 쿠폰을 1인당 1장만 지급되어야 하기 때문에 SET 자료구조를 사용하여 중복 지급을 막는다.

Redis를 활용하여 동시성 문제를 해결할 수 있었으나, 동시에 많은 요청으로 인해 DB에 많은 부하를 줄 수 있다.

이를 해결하기 위해 Kafka를 활용하였다. 또한 API 서버가 직접 DB에 접근하지 않고, 지급 가능 여부만 판단하고 응답을 내기 때문에 처리 속도 개선할 수 있을 것이라 예상한다.

 

쿠폰 발급을 요청하는 서버(Producer)와 쿠폰 지급을 수행하는 서버(Consumer)로 구성했다.

 

아래 코드는 강의를 듣고 난 이후에 조금의 리펙토링 한 코드입니다. kafka에 대해 기본적인 개념만 숙지한 채 코드를 작성하다 보니, kafka에 대한 코드가 많이 미흡합니다. 이후 카프카에 대해서 더 학습한 뒤 포스팅할 예정입니다.

 

1. 쿠폰 발급을 요청하는 서버 (Producer 코드)

1.1 KafkaProducerConfig.java

@Configuration
public class KafkaProducerConfig {

    @Bean
    public ProducerFactory<String, String> producerFactory() {
        HashMap<String, Object> config = new HashMap<>();

        config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);

        return new DefaultKafkaProducerFactory<>(config);
    }

    @Bean
    public KafkaTemplate<String, String> kafkaTemplate() {

        return new KafkaTemplate<>(producerFactory());
    }
}

 

BOOTSTRAP_SERVERS_CONFIG (설정값)

Producer가 처음으로 연결할 Kafka 브로커의 위치를 설정 (localhost의 9092 포트 설정)

 

KEY_SERIALIZER_CLASS_CONFIG, VALUE_SERIALIZER_CLASS_CONFIG (설정값)

Producer가 Key와 Value 값을 Kafka 브로커로 전송하기 전에 데이터를 byte array로 변환할 때 사용하는 직렬화 메커니즘을 설정

 

KafkaTemplate

Spring Kafka에서 제공하는 Kafka Producer를 Wrapping 한 클래스로 Kafka에 메시지를 보내는 여러 메서드를 제공

 

1.2 CouponService.java

쿠폰 발급 요청에 대해서 이미 쿠폰을 지급받았는지와 쿠폰 수량이 남았는지에 대해서 검증하고, 유효한 요청일 경우 메시지를 발행한다.

@Service
@RequiredArgsConstructor
public class CouponService {

    private final CouponRepository couponRepository;
    private final CouponCreateProducer couponCreateProducer;
    private final CouponRedisOperation couponRedisOperation;

    public void apply(Long userId, Long couponId) {

        CouponVO couponVO = CouponVO.of(couponId, userId);
        Coupon coupon = couponRepository.findById(couponId).orElseThrow();

        checkCouponDuplicateApply(couponVO);
        checkCouponSoldOut(couponVO, coupon.getMaxCount());

        couponCreateProducer.create(CouponCreateSendRequest.of(userId, couponId));
    }

    private void checkCouponSoldOut(CouponVO couponVO, int maxCount) {
        Long count = couponRedisOperation.incr(couponVO);

        if (count > maxCount) {
            throw new CouponSoldOutException();
        }
    }

    private void checkCouponDuplicateApply(CouponVO couponVO) {
        Long apply = couponRedisOperation.addSet(couponVO);

        if (apply != 1) {
            throw new CouponDuplicateApplyException();
        }
    }

}

 

 

incr 명령어는 특정키에 대해서 값을 1 증가하고, 증가된 이후의 값을 리턴하는 함수이다.

coupon:쿠폰ID (key)에 대해서 incr 명령어를 수행하고, 리턴값이 한정된 수량보다 클 경우에는 예외를 던지도록 한다.

 

sadd 명령어는 중복되지 않는 값을 데이터로 가지는 집합에 value를 추가하는 것으로, 값이 존재하지 않을 경우 추가한 이후 1을 리턴하고 이미 동일한 값이 존재했을 경우 0을 리턴한다.

coupon:쿠폰ID:users (key) 에 대해서 sadd 명령어를 수행하여 userId를 저장한다. 이미 발급한 경우 0을 리턴하기 때문에 예외를 던지도록 한다.

 

* 하지만 현재 중복 지급을 방지하는 과정에서, 모든 쿠폰 지급 요청에 대해 반드시 sadd 명령어를 수행하기 때문에 set에 요청한 모든 userId가 저장되는 문제점이 존재한다. 해당 문제에 대해서는 개선이 필요하다. 

 

1.3 CouponCreateProducer.java

@Component
@RequiredArgsConstructor
public class CouponCreateProducer {

    private final KafkaTemplate<String, String> kafkaTemplate;
    private final ObjectMapper objectMapper;

    public void create(CouponCreateSendRequest request) {

        try {
            final String payload = objectMapper.writeValueAsString(request);
            kafkaTemplate.send("coupon_create", payload);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
}

 

두 검증을 통과할 경우는 kafka에 userId와 couponId를 담은 메시지를 "coupon_create" 토픽으로 발행한다. 이를 Consumer가 받아 지급처리를 해줄 것이다.

 

1.4 CouponRedisOperation.java

@Slf4j
@Component
@RequiredArgsConstructor
public class CouponRedisOperation {

    private final RedisTemplate<String, String> redisTemplate;

    private static final String COUPON_KEY_PATTERN = "coupon:%s";
    private static final String COUPON_APPLY_USERS_KEY_PATTERN = "coupon:%s:users";

    public Long addSet(CouponVO couponVO) {

        String key = generateKey(COUPON_APPLY_USERS_KEY_PATTERN, couponVO);

        return redisTemplate
                .opsForSet()
                .add(key, couponVO.getUserId());
    }

    public Long incr(CouponVO couponVO) {

        String key = generateKey(COUPON_KEY_PATTERN, couponVO);

        return redisTemplate
                .opsForValue()
                .increment(key);
    }

    private static String generateKey(String pattern, CouponVO couponVO) {
        return String.format(pattern, couponVO.getCouponId());
    }
}

 

2. 쿠폰 지급하는 서버 (Producer 코드)

2.1 KafkaConsumerConfig.java

@Configuration
public class KafkaConsumerConfig {

    @Bean
    public ConsumerFactory<String, String> consumerFactory() {
        Map<String, Object> config = new HashMap<>();

        config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        config.put(ConsumerConfig.GROUP_ID_CONFIG, "group_1");
        config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);

        return new DefaultKafkaConsumerFactory<>(config);
    }

    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory() {
        ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>();
        factory.setConsumerFactory(consumerFactory());

        return factory;
    }

}

 

GROUP_ID_CONFIG (설정값)

Consumer가 속한 Consumer 그룹의 ID를 설정

Consumer 그룹은 같은 토픽을 소비하는 Consumer들의 그룹으로, 그룹 내의 모든 Consumer는 토픽의 서로 다른 파티션에서 메시지를 읽는다. 이를 통해 메시지 처리를 병렬화 하여 처리 속도를 향상시킨다.

 

ConcurrentKafkaListenerContainerFactory

@KafkaListener 어노테이션이 붙은 메서드에 주입되어 사용되며, 메시지를 동시에 처리할 수 있는 메시지 리스너 컨테이너를 생성

 

2.2 CouponConsumer.java

@Component
@Slf4j
@RequiredArgsConstructor
public class CouponConsumer {

    private final CouponBoxRepository couponBoxRepository;
    private final ObjectMapper objectMapper;

    @KafkaListener(topics = "coupon_create", groupId = "group_1")
    public void receive(String message) {
        log.info("Received Coupon Create message : {}", message);

        try {
            CouponCreateMessage couponCreateMessage = objectMapper.readValue(message, CouponCreateMessage.class);
            couponBoxRepository.save(
                    CouponBox.builder()
                            .userId(couponCreateMessage.getUserId())
                            .couponId(couponCreateMessage.getCouponId())
                            .build());
        } catch (Exception e) {
            log.error("failed to create coupon::" + message);
        }
    }
}

 

@KafkaListener 어노테이션을 통해 KafkaConsumerConfig에서 설정해 둔 ConcurrentKafkaListenerContainerFactory로 컨테이너를 사용할 수 있다.

 

"coupon_create" 토픽을 구독하여 메시지를 받으면, CouponBox 엔티티를 저장함으로써 쿠폰 지급처리를 한다.

 

3. 테스트 코드

@Test
public void 여러명_중복불가_응모() throws InterruptedException {

    // given
    Coupon coupon = couponRepository.save(Coupon.builder()
            .title("Coupon_A")
            .maxCount(100)
            .build());

    // when
    int threadCount = 1000;
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
        long userId = i % 200;
        executorService.submit(() -> {
            try {
                applyService.apply(userId, coupon.getId());
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    Thread.sleep(10000);

    // then
    long count = couponBoxRepository.count();

    assertThat(count).isEqualTo(coupon.getMaxCount());
}

 

성공적으로 동시성 문제가 발생하지 않고, 한정된 수량 내에서 1인당 1개만 지급되었다.

 

consumer에서 메시지를 받아 처리하는 로그를 확인할 수 있다.

 

 

참고