[식구하자_MSA] Redis+Kafka 선착순 시스템 쿠폰 서비스 구현해보자!

이민우·2024년 4월 4일
3

🍀 식구하자_MSA

목록 보기
9/21

이번에는 새로운 마이크로서비스 선착순 시스템 쿠폰 마이크로 서비스 개발하면서 트러블 슈팅과 그걸 해결하기 위한 고민과 해결과정을 포스팅하려 합니다.

🤔 고민

요구사항 정리

  • 식구 페이머니로 거래시에만 적용 가능!
  • 쿠폰은 식물 중고 거래시 3000원 할인 쿠폰
  • 매일 13시에 선착순 100명 대상으로 발급
  • 10000원 이상 중고 거래시 사용가능
  • 유효기간 1개월이내
  • 구매자는 쿠폰을 사용하면 할인금액이 차감되지만, 판매자는 할인 금액이 적용되기전 금액을 받아야 한다.

쿠폰 서비스의 요구사항을 간략히 정리해 봤는데, 선착순으로 하루에 딱 100명만 쿠폰을 발급받을 수 있습니다. 단순히 기능만 구현을 해보다가
예를 들어, 1000명의 사용자가 동시에 쿠폰 발급을 요청했을 때, 딱 정확히 100개만 쿠폰이 발급될 수 있을까 의문이 들어 테스트를 진행해 봤습니다.

문제 발생 - 동시성 제어

초기 코드


    /**
     * 쿠폰 발급
     * 요구사항 정리 : 하루에 중고 거래시 할인받을수 있는 100개 쿠폰을 발급
     * 100개 넘으면 발급 불가
     * @param : CouponRequestDto couponRequestDto
     */
    @Transactional
    public void applyCoupon(CouponRequestDto couponRequestDto) {
        LocalDate today = LocalDate.now();
        LocalDateTime startOfDay = today.atStartOfDay(); // 오늘 날짜의 자정(00:00)
        LocalDateTime endOfDay = LocalDateTime.of(today, LocalTime.MAX); // 오늘 날짜의 마지막 시간(23:59:59.999999999)
        Long countCoupon = couponRepository.countByRegDateBetween(startOfDay, endOfDay);
        //오늘 날짜 기준으로 100개보다 많으면 return
        if (countCoupon > 100) {
            return;
        }
        Coupon coupon=Coupon.builder()
                        .memberNo(couponRequestDto.getMemberNo())
                        .tradeBoardNo(couponRequestDto.getTradeBoardNo())
                        .discountPrice(couponRequestDto.getDiscountPrice())
                        .regDate(LocalDateTime.now())
                        .build();

        couponRepository.save(coupon);
    }

테스트 진행

테스트 시나리오

  • 동시에 1000명의 사용자가 쿠폰발급 요청
@Test
void applyCouponTest() throws InterruptedException {
    //given
    int threadCount = 1000;
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch countDownLatch = new CountDownLatch(threadCount);
    //when
    for (int i = 0; i<threadCount; i++) {
        CouponRequestDto couponRequestDto = new CouponRequestDto(i, 1L, 2000);
        executorService.submit(() -> {
            try{
                couponService.applyCoupon(couponRequestDto);
            }
            finally {
                countDownLatch.countDown();

            }
        });
    }
    countDownLatch.await();
    long count = couponRepository.count();
    assertThat(count).isEqualTo(100);
}

결과

결과는 발급된 쿠폰의 갯수를 100개를 예상했지만 , 110개가 발급이 되버렸습니다...

위 문제가 발생하는 이유는 바로 Race Condition 때문이였습니다.

표를 보시면 , 쓰레드 1과 쓰레드 2가 동시에 요청이 들어와서 공유 자원 쿠폰 갯수를 조회하는 시점에는 99개로 조회되어 1개만 실행되고 쿠폰 발급이 끝나야하지만, 100개가 넘게 조회 되버렸습니다..

🤔 해결 - Redis 싱글스레드

멀티스레드인 Java와는 다르게 Redis는 기본적으로 싱글 스레드 모델을 사용합니다.
그래서 저는 해결방법을 고민하다가 쿠폰 갯수를 증가시키는 작업을 Redis를 사용하여 싱글 스레드는 모든 명령어가 순차적으로 실행되도록 보장하기 때문에, 복잡한 동시성 제어 로직 없이도 데이터의 일관성을 유지하기 위해 Redis를 사용하기로 결정했습니다.

의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

먼저 redis를 사용하기 위해 , 의존성을 추가해줍니다.

개선된 코드

@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");
    }

}

RedisTemplate을 사용하여 쿠폰 갯수를 1씩 증가시켜줬습니다.

public void applyCoupon(CouponRequestDto couponRequestDto) {
        Long count = couponCountRepository.increment();
        //오늘 날짜 기준으로 100개보다 많으면 return
        if (count > 100) {
            return;
        }
        Coupon coupon=Coupon.builder()
                        .memberNo(couponRequestDto.getMemberNo())
                        .tradeBoardNo(couponRequestDto.getTradeBoardNo())
                        .discountPrice(couponRequestDto.getDiscountPrice())
                        .regDate(LocalDateTime.now())
                        .build();

        couponRepository.save(coupon);
    }
    
    

Redis를 통해 코드를 개선했고 , 한번 테스트를 진행보도록 하겠습니다!!

테스트 결과

테스트 코드는 위에 첨부한 코드와 같습니다

Redis를 통해 코드를 변경하니,동시에 사용자 1000명이 요청해도 쿠폰이 딱 100개만 발급되는 것을 확인하실 수 있습니다.

문제 발생 - RDB 성능 저하

저는 db를 AWS RDS(mysql)를 사용중이였습니다.
Redis를 통해 동시성 제어에 관한 이슈는 해결했지만, AWS를 모니터링 해보니 요청시점에 RDB의 cpu 사용률이 너무 높아져 있었습니다..

🤔 해결 - Kafka

  • 선착순 쿠폰 발급 요청에 동시에 여러 요청이 오면 rdb에 부담이 가므로 다른 서비스 지연 및 성능에 대한 저하가 올수도있음 이를 해결하기위해 kafka를 통해 해결하기 했습니다

카프카(Kafka)를 사용하면 왜 RDB의 부하를 줄일수 있을까요??

이유는 아래와 같습니다.

1. 처리량 조절

  • 처리량 조절: Kafka 는 토픽에 있는 데이터를 순차적으로 가져와서 처리하게 됩니다. Consumer 가 1개가 있고 토픽에 데이터가 100개가 있다고 가정할때 Consumer 에서는 1번 데이터를 가져와서 처리가완료되면 2번 데이터를 가져와서 처리합니다.

    10:00시에 100명의 유저가 1번씩 요청을 보내게 됐을때 API 에서 직접 처리를 한다면 데이터베이스에 100번의 요청이 한번에 몰리게 될 것입니다.

    하지만 이 요청을 카프카 프로듀서를 이용하여 토픽에 전송하고 Consumer 를 이용하여 데이터를 처리한다면 데이터베이스에 요청하는양을 조절할 수 있게되므로 DB의 부하를 줄일 수 있습니다.

2. 부하 분산

  • 요청의 버퍼링: 카프카는 높은 처리량을 지원하는 분산 스트리밍 플랫폼으로, 대량의 요청을 효율적으로 버퍼링할 수 있습니다. 이를 통해 순간적인 트래픽 급증 시에도 요청을 안정적으로 관리할 수 있습니다.
  • 스케일 아웃: 카프카 클러스터는 수평적으로 확장 가능합니다. 따라서 요청의 양이 증가함에 따라 더 많은 컨슈머를 동적으로 추가하여 처리량을 증가시킬 수 있습니다.

Kafka 적용

1. 의존성 추가

implementation 'org.springframework.kafka:spring-kafka'

2. Producer 설정 파일

@Configuration
public class KafkaProducerConfig {
    @Bean
    public ProducerFactory<String, CouponRequestDto> producerFactory() {
        HashMap<String, Object> config = new HashMap<>();
        config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "카프카 서버 url");
        config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);

        return new DefaultKafkaProducerFactory<>(config);

    }

    @Bean
    public KafkaTemplate<String, CouponRequestDto> kafkaTemplate() {
        return new KafkaTemplate<>(producerFactory());
    }
}

3. Consumer 설정 파일

@Configuration
public class KafkaConsumerConfig {
    @Bean
    public ConsumerFactory<String, CouponRequestDto> consumerFactory() {
        Map<String, Object> config = new HashMap<>();
        JsonDeserializer<CouponRequestDto> deserializer = new JsonDeserializer<>();
        // 패키지 신뢰 오류로 인해 모든 패키지를 신뢰하도록 작성
        deserializer.addTrustedPackages("*");

        config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "카프카 서버 url");
        config.put(ConsumerConfig.GROUP_ID_CONFIG, "coupon");
        config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, deserializer);

        return new DefaultKafkaConsumerFactory<>(config, new StringDeserializer(), deserializer);

    }

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

        return factory;

    }
}

4. Producer 코드

@Component
public class CouponCreateProducer {
    private final KafkaTemplate<String, CouponRequestDto> kafkaTemplate;

    public CouponCreateProducer(KafkaTemplate<String, CouponRequestDto> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void create(CouponRequestDto couponRequestDto) {
        kafkaTemplate.send("coupon_created", couponRequestDto);
    }

}
프로듀서에서 요청이 들어오면 coupon_created라는 토픽을 생성을 합니다

5. Consumer 코드

@Component
@RequiredArgsConstructor
public class CouponCreatedConsumer {
    private final CouponRepository couponRepository;
    private final FailedEventRepository failedEventRepository;
    private final Logger logger = LoggerFactory.getLogger(CouponCreatedConsumer.class);


    @KafkaListener(topics = "coupon_created", groupId = "coupon")
    public void listener(CouponRequestDto couponRequestDto) {

        try{
            Coupon coupon=Coupon.builder()
                    .memberNo(couponRequestDto.getMemberNo())
                    .discountPrice(couponRequestDto.getDiscountPrice())
                    .regDate(LocalDateTime.now())
                    .build();
            couponRepository.save(coupon);
        }
        catch (Exception e){
            logger.error("Failed to create coupon: "+ couponRequestDto.getMemberNo());
            failedEventRepository.save(new FailedEvent(couponRequestDto.getMemberNo()));
        }
    }
}
  • @KafkaListener 어노테이션을 사용하여 coupon 그룹 ID를 가진 컨슈머로 등록되어 있으며, 메시지가 도착할 때마다 자동으로 listener 메서드가 호출

6. CouponService 쿠폰 적용 메서드 로직 변경

 public StatusResponseDto applyCoupon(CouponRequestDto couponRequestDto) {
        // coupon 발급 전에 redis 싱글스레드 1증가
        Long add = appliedUserRepository.add(couponRequestDto.getMemberNo());
        if (add != 1) {
            return StatusResponseDto.addStatus(409);

        }
        Long count = couponCountRepository.increment();
        //오늘 날짜 기준으로 100개보다 많으면 return
        if (count > 100) {
            return StatusResponseDto.addStatus(429);
        }

        couponCreateProducer.create(couponRequestDto);
        return StatusResponseDto.success();
    }
  • 쿠폰의 갯수가 100개보다 적을 시, couponCreateProducer create메서드에 요청을 보내줍니다.
  • Long add = appliedUserRepository.add(couponRequestDto.getMemberNo()); 이부분은 중간에 요구사항을 1인당 1개만 발급 가능하게 변경해서 추가되었습니다.(이부분에 대해 궁금하신분들은 댓글 남겨주세요 :))

😊 문제를 해결하며 느낀점

선착순 쿠폰 시스템 개발을 통해, Redis의 싱글 스레드 모델로 동시성 문제를 해결하며 동시에 소수의 요청에서조차 동시성 관련 이슈가 발생할 수 있음을 경험했습니다.... 이 과정에서 동시에 많은 요청이 몰릴 경우 RDB에 부담을 주는 문제를 Kafka를 통한 비동기 처리부하 분산 방식으로 효과적으로 해결할 수 있었습니다.

식구하자 MSA 전환 과정에서 Kafka 활용 범위가 상당히 넓다는 것을 다시 한번 실감하게 했습니다.

오늘도 읽어주셔서 감사합니다. 😄

profile
백엔드 공부중입니다!

0개의 댓글

관련 채용 정보