본문 바로가기
Lecture

선착순 이벤트 시스템에서 발생가능한 동시성 문제와 해결 방안 탐구(redis, kafka)

by Renechoi 2023. 7. 11.

선착순으로 쿠폰을 발급해 주는 시스템에서 발생할 수 있는 문제와 해결 방안을 살펴본다. 

 

 

요구사항 

선착순 100명에게 할인쿠폰을 제공하는 이벤트를 진행하고자 한다.

 

- 선착순 100명에게만 지급되어야 한다.
- 101개 이상이 지급되면 안 된다.
- 순간적으로 몰리는 트래픽을 버틸 수 있어야 한다.

 

구현 코드는 다음과 같다. 

 

먼저 쿠폰 도메인이다. 

 

@Entity
@Getter
public class Coupon {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long id;

   private Long userId;

   public Coupon() {
   }

   public Coupon(Long id, Long userId) {
      this.id = id;
      this.userId = userId;
   }

   public Coupon(Long userId) {
      this.userId = userId;
   }
}

 

레포지토리와 서비스를 작성해준다. 

 

public interface CouponRepository extends JpaRepository<Coupon, Long> {
}
@RequiredArgsConstructor
@Service
public class ApplyService {

   private final CouponRepository couponRepository;

   public void apply(Long userId) {
      long count = couponRepository.count();

      if (count > 100) {
         return;
      }

      couponRepository.save(new Coupon(userId));
   }
}

 

1개가 정상적으로 등록되는지를 테스트 해보자. 

 

@Test
public void applyOnce(){
   applyService.apply(1L);

   long count = couponRepository.count();
   assertThat(count).isEqualTo(1);
}

 

문제 없이 등록되는 것을 볼 수 있다. 

 

 

 

 

문제점 

 

그러나 위에서 작성한 로직은 문제가 있다.

 

동시에 요청이 여러개 오는 경우 다음과 같은 문제들이 발생한다.

 

- 쿠폰이 정해진 수량 (100개) 보다 많이 발급된다. 

- 이벤트 페이지 접속이 안 된다.

- 이벤트와 전혀 상관 없는 페이지들도 느려진다. 



왜 그럴까? 동시에 요청이 여러개 오는 경우에 대한 테스트 케이스를 작성해보자. 

 

@Test
public void applyMulti() throws InterruptedException {
   int threadCount = 1000;
   ExecutorService executorService = Executors.newFixedThreadPool(32);
   CountDownLatch countDownLatch = new CountDownLatch(threadCount);

   for (int i = 0; i < threadCount; i++) {
      long userId = i;
      executorService.submit(() -> {
         try {
            applyService.apply(userId);
         } finally {
            countDownLatch.countDown();
         }
      });
   }

   countDownLatch.await();

   long count = couponRepository.count();

   assertThat(count).isEqualTo(100);
}

 

100개가 생성되길 기대했는데 그보다 많은 쿠폰이 발급되는 것을 볼 수 있다. 

 

 

 

 

왜 이런 문제가 발생할까? 

경합 조건이 발생하기 때문이다. 

 

apply 메서드에 로그를 찍어서 살펴보자. 

100개가 이상인 경우 더 이상 쿠폰 생성을 하면 안 되지만 100개가 넘었음에도 계속해서 생성되는 것을 볼 수 있다. 

 

 

이때 스레드 현황을 살펴보면 각각 다른 스레드가 쿠폰을 생성하는 것을 볼 수 있다. 문제는 특정 스레드가 이미 100개 째 쿠폰을 생성하였음에도 그 이전에 apply 메서드에 접근하여 쿠폰 개수를 100개 미만으로 획득했던 경우 실제로는 100개가 이미 다 생성이 되었음에도 불구하고 해당 스레드가 인지하는 숫자는 100이 아니기 때문에 추가로 계속해서 생성을 한다는 것이다. 

 

이렇게 2개 이상의 스레드가 공유 자원에 접근하여 작업을 하려고 할 때 발생하는 문제점을 race condition이라고 한다. 

 

 

 

해결 방안 

 

redis를 활용한 쿠폰 발급 개수 한정하기 

 

incr 명령어를 활용해서 발급되는 쿠폰의 개수를 제어할 수 있다. 발급된 쿠폰 개수를 카운트하고, 카운트가 정해진 수량(예: 100개)을 초과하면 더 이상 쿠폰을 발급하지 않도록 한다.

 

쿠폰을 발급하는 로직에서 다음과 같이 Redis를 활용한다.

- 쿠폰 발급 전에 Redis의 카운터를 확인한다.
- 카운터가 정해진 수량을 초과하지 않는 경우에만 쿠폰을 발급하고, Redis의 카운터를 증가시킨다.
- 카운터가 정해진 수량을 초과한 경우에는 쿠폰 발급을 거부한다.

 

이 방식은 Redis의 원자적(atomic)인 incr 명령어를 사용하여 카운터를 증가시키므로, 여러 스레드가 동시에 접근하더라도 정확한 쿠폰 발급 개수를 유지할 수 있게 해준다.

 

로직을 작성해보자. 먼저 레디스를 이용한 레포지토리를 작성한다. 

 

@Repository
@RequiredArgsConstructor
public class CouponCountRepository {

   private final RedisTemplate<String, String> redisTemplate;

   public Long increment() {
      return redisTemplate.opsForValue().increment("coupon count");
   }
}

 

apply 메서드는 다음과 같이 변경한다. 

 

public void apply(Long userId) {
   // long count = couponRepository.count();
   Long count = couponCountRepository.increment();
   log.info("쿠폰 개수: {}", count);
   
   if (count > 100) {
      return;
   }

   couponRepository.save(new Coupon(userId));
}

 

테스트 코드가 성공한다. 왜 성공하는 것일까? 

 

Redis는 인메모리 데이터베이스로서 싱글 스레드 기반으로 동작하기 때문이다. Redis의 incr 명령어는 원자적(atomic)으로 카운터를 증가시키므로 여러 스레드가 동시에 접근하더라도 증가된 값을 정확히 반환한다. 다음 그림을 살펴보자.

시간 Thread 1 Redis - count Thread 2
10:00 start - 10:00

incr -> count

end - 10: 02
99  
10:01 99 wait...
wait...

start - 10:02

incr count 

end - 10:03
10:02 100
10:03 create -> faile 100
    101 create -> faile 

 

 

즉, 여러 스레드가 동시에 Redis에 접근하더라도 Redis 자체에서 동시성 문제를 해결하여 정확한 쿠폰 발급 개수를 유지할 수 있게 된다. 

 

그렇다면 이러한 로직은 완벽할 것인가? 

 

여전히 발생할 수 있는 문제점이 있다. 

 

첫 번째는 Redis 장애이다. Redis가 장애 상태인 경우에는 카운터를 증가시키지 못하고, 쿠폰 발급이 중단될 수 있다. 이를 해결하기 위해서는 Redis의 가용성과 장애 복구 전략을 고려해서 사용해야 한다. 

두 번째는 데이터베이스 부하 문제이다. 예를 들어 mysql이 1분에 100개의 insert가 가능하다고 가정해보자. 10:00시에 쿠폰 10,000개 생성 요청이 들어온다면 100분이 걸리게 되고 이후 로직들은 100분 이후에 처리되게 된다. 

 

 

 

 

카프카를 활용해서 두 번째 문제 사항을 해결해보는 방법을 살펴보자. 

 

 

kafka를 활용한 페이지 독립성 갖추기 

 

먼저 카프카 producer 관련 설정 클래스를 다음과 같이 작성한다. 

 

@Configuration
public class KafkaProducerConfig {

   @Bean
   public ProducerFactory<String, Long> producerFactory(){
      Map<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, LongSerializer.class);
      
      return new DefaultKafkaProducerFactory<>(config);
   }
   
   @Bean
   public KafkaTemplate<String, Long> kafkaTemplate(){
      return new KafkaTemplate<>(producerFactory());
   }
}

 

Producer 클래스는 다음과 같이 작성한다. 

 

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

   public void create(Long userId) {
      kafkaTemplate.send("coupon create", userId);
   }
}

 

서비스에서 이를 활용해보자. apply 메서드를 다음과 같이 변경한다. 

 

public void apply(Long userId) {
   // long count = couponRepository.count();
   Long count = couponCountRepository.increment();
   log.info("쿠폰 개수: {}", count);

   if (count > 100) {
      return;
   }

   // couponRepository.save(new Coupon(userId)); // 직접 쿠폰을 생성하는 로직을 삭제

   couponCreateProducer.create(userId);
}

 

 

직접 쿠폰을 생성하는 로직을 삭제하고 producer에게 create를 맡긴다. 

 

컨슈머를 실행해보면 정상적으로 데이터를 가져오는 것을 확인할 수 있다. 

 

docker에 kafka를 올렸을 경우 consumer 예제 코드 

 

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"

 

이제 컨슈머에서 해당 데이터를 읽고 처리하도록 해보자. 

 

producer config를 만든 것처럼 다음과 같이 consumer config를 설정한다. 

 

@Bean
public ConsumerFactory<String, Long> 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, LongDeserializer.class);

   return new DefaultKafkaConsumerFactory<>(config);
}

 

consumer의 경우 listener를 함께 구현해주어야 한다. 

 

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

 

 

이후 consume 후 로직을 수행할 클래스를 작성한다. listen 하는 메서드를 다음과 같이 작성한다. 

 

@KafkaListener(topics = "coupon create", groupId = "group_1")
public void listener(Long userId){
   
}

 

listener는 받는 데이터를 저장해주는 로직을 작성하면 되므로 기존의 쿠폰 서비스의 insert 로직을 가져오면 된다. 

 

@KafkaListener(topics = "coupon create", groupId = "group_1")
public void listener(Long userId){
   couponRepository.save(new Coupon(userId));
}

 

Consumer는 대기하고 있다가 토픽에 데이터가 생성되면 이를 받는다. 그런데 이때 데이터를 처리하는 순서는 producer 쪽에서 데이터를 보내는 시차와 동기화 되지 않으므로, 즉 비동기적으로 처리하게 되므로 기존 테스트 코드를 그대로 사용하면 실패한다. 

 

예를 들어 10:00 테스트케이스가 시작되고 producer가 데이터를 10:01에 보낸다고 하자. 이때 테스트케이스는 producer가 일을 다 하였으므로 10:02에 종료한다. 그러나 Consumer 입장에서는 데이터 수신을 10:01부터 시작하여 10:03에 마치고 모든 처리를 10:05에 마칠 수 있다. 이런 경우 producer 쪽의 테스트는 실패로 완료되는 것이다. 

 

thread를 넉넉하게 10초간 대기시킨다면 통과하는 것을 볼 수 있다. 

 

@Test
public void applyMulti() throws InterruptedException {
   int threadCount = 1000;
   ExecutorService executorService = Executors.newFixedThreadPool(32);
   CountDownLatch countDownLatch = new CountDownLatch(threadCount);

   for (int i = 0; i < threadCount; i++) {
      long userId = i;
      executorService.submit(() -> {
         try {
            applyService.apply(userId);
         } finally {
            countDownLatch.countDown();
         }
      });
   }

   countDownLatch.await();

   Thread.sleep(10000);

   long count = couponRepository.count();

   assertThat(count).isEqualTo(100);
}

 

이와 같이 Producer가 쿠폰 생성 이벤트를 Kafka에 전송하고, Consumer가 해당 이벤트를 소비하여 실제 쿠폰 생성을 처리한다. 이를 통해 쿠폰 생성 작업을 백그라운드로 처리하여 API에서 직접 쿠폰을 생성할 때에 비해서 처리량을 조절할 수 있다. 따라서 db 생성량에 대한 부하를 해결할 수 있다. 

 

 

 

요구 사항이 변경된다면 ?

 

발급가능한 쿠폰 개수에 대해 1인 당 1개로 제한하도록 비즈니스 요구사항이 추가되는 경우를 생각해보자. 

 

값을 unique 하게 저장할 수 있는 Set 자료 구조를 사용해보자. 

 

Redis에서 지원하는 Set을 사용하면 된다. 다음과 같이 유저의 신청 횟수를 카운트하는 Redis repository를 작성한다. 

 

 

@Repository
@RequiredArgsConstructor
public class AppliedUserRepository {

   private final RedisTemplate<String, String> redisTemplate;

   public Long add(Long userId){
      return redisTemplate.opsForSet().add("applied_user", userId.toString());
   }
}

 

쿠폰을 발급하는 로직에 앞서 먼저 유저를 add 시켜주고, 이 user의 id에 대해서 1이 아니라면 이미 신청을 한 것으로 판단 할 수 있다. 

 

다음과 같이 작성한다. 

 

 

public void apply(Long userId) {

   Long appliedUser = appliedUserRepository.add(userId);

   if (appliedUser != 1){
      return;
   }
   
   //..

 

 

1명의 유저가 1개의 쿠폰만 발급되는지를 다음과 같은 테스트코드로 확인한다. 

 

@Test
public void applyMultiWithUniqueUserPerOne() throws InterruptedException {
   int threadCount = 1000;
   ExecutorService executorService = Executors.newFixedThreadPool(32);
   CountDownLatch countDownLatch = new CountDownLatch(threadCount);

   for (int i = 0; i < threadCount; i++) {
      long userId = i;
      executorService.submit(() -> {
         try {
            applyService.apply(1L);
         } finally {
            countDownLatch.countDown();
         }
      });
   }

   countDownLatch.await();

   Thread.sleep(10000);

   long count = couponRepository.count();

   assertThat(count).isEqualTo(1);

 

 

 

 

결론 

동시에 여러 요청이 들어올 때 쿠폰이 정해진 수량을 초과하여 발급되는 문제를 중점으로 Redis와 Kafka를 통한 해결법을 살펴보았다. Redis를 활용한 쿠폰 발급 개수 한정 방법은 Redis의 원자적인 incr 명령어를 사용하여 쿠폰 발급 개수를 카운트하고, 카운트가 정해진 수량을 초과하는 경우 쿠폰 발급을 거부하는 방식이다. 이를 통해 동시성 문제를 해결한다.

 

보다 안정적으로 Redis를 활용하기 위해 Kafka를 함께 사용하면 쿠폰 생성을 비동기적으로 처리하고, Consumer가 해당 이벤트를 소비하여 쿠폰 처리 로직을 수행하도록 할 수 있다. 이 경우 API에서 직접 쿠폰을 생성하는 경우보다 처리량을 조절할 수 있어 부하를 관리할 수 있었다.

마지막으로, 요구사항이 변경될 경우에 대비해 1인 당 1개의 쿠폰 발급으로 제한하는 방법에 대해, Redis의 Set 자료 구조를 사용하여 유저의 신청 횟수를 카운트하고, 이미 신청한 유저의 경우 추가적인 쿠폰 발급을 거부하는 방식을 살펴보았다. 이를 통해 중복 신청 문제를 해결할 수 있었다.

 

 

 


 

참고 자료 

인프런 - 실습으로 배우는 선착순 이벤트 시스템

 

 

반응형