이번에는 새로운 마이크로서비스 선착순 시스템 쿠폰 마이크로 서비스 개발하면서 트러블 슈팅과 그걸 해결하기 위한 고민과 해결과정을 포스팅하려 합니다.
쿠폰 서비스의 요구사항을 간략히 정리해 봤는데, 선착순으로 하루에 딱 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);
}
테스트 시나리오
@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개가 넘게 조회 되버렸습니다..
멀티스레드인 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개만 발급되는 것을 확인하실 수 있습니다.
저는 db를 AWS RDS(mysql)를 사용중이였습니다.
Redis를 통해 동시성 제어에 관한 이슈는 해결했지만, AWS를 모니터링 해보니 요청시점에 RDB의 cpu 사용률이 너무 높아져 있었습니다..
이유는 아래와 같습니다.
Kafka
는 토픽에 있는 데이터를 순차적으로 가져와서 처리하게 됩니다. Consumer 가 1개가 있고 토픽에 데이터가 100개가 있다고 가정할때 Consumer 에서는 1번 데이터를 가져와서 처리가완료되면 2번 데이터를 가져와서 처리합니다.implementation 'org.springframework.kafka:spring-kafka'
@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());
}
}
@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;
}
}
@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);
}
}
@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()));
}
}
}
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();
}
선착순 쿠폰 시스템 개발을 통해, Redis의 싱글 스레드 모델로 동시성 문제를 해결하며 동시에 소수의 요청에서조차 동시성 관련 이슈가 발생할 수 있음을 경험했습니다.... 이 과정에서 동시에 많은 요청이 몰릴 경우 RDB에 부담을 주는 문제를 Kafka를 통한 비동기 처리와 부하 분산 방식으로 효과적으로 해결할 수 있었습니다.
식구하자 MSA 전환 과정에서 Kafka 활용 범위가 상당히 넓다는 것을 다시 한번 실감하게 했습니다.
오늘도 읽어주셔서 감사합니다. 😄