redis

대기열을 이용한 선착순 쿠폰 발행

cocodingding 2024. 1. 4. 16:07

레디스를 이용한 기프티콘 선착순 이벤트 구현 (velog.io)

 

레디스를 이용한 기프티콘 선착순 이벤트 구현

이번 포스팅은 레디스에서 제공해주는 자료구조 중 하나인 Sorted Set을 간단하게 설명하고, Sorted Set을 이용해서 치킨 기프티콘 선착순 이벤트를 구현해봅니다. 1. 왜 레디스으로 구현해야하나?

velog.io

 

해당 코드를 그대로 구현한 후 주석처리하면서 공부한 기록

/**
 * Redis 연결을 설정하는 설정 클래스.
 * RedisProperty 클래스에서 Redis 속성(host, port)를 읽어 온다.
 * Lettuce를 사용하여 RedisConnectionFactory 빈을 정의한다.
 * RedisTemplate 빈을 정의하는데 이는 Redis와 상호 작용하기 위한 고수준 추상화이다.
 */
@Configuration
@RequiredArgsConstructor
public class RedisConfig {
    private final RedisProperty redisProperty;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisProperty.getHost(), redisProperty.getPort());
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
    /**
     * Spring Data Redis는 Redis에 두가지 접근 방법을 제공하는데
     * 하나는 RedisTemplate을 이용한 방식, 다른 하나는 RedisRepository를 이용한 방식.
     * 이번 Practice Project에서는 전자를 채택.
     */
}

 

@Getter
public enum Event {
    CHICKEN("치킨");

    private String name;

    Event(String name) {
        this.name = name;
    }
}

 

@Getter
@Setter
public class EventCount {
    private Event event;
    private int limit;

    private static final int END = 0;

    public EventCount(Event event, int limit) {
        this.event = event;
        this.limit = limit;
    }

    public synchronized void decrease(){
        this.limit--;
    }

    public boolean end(){
        return this.limit == END;
    }
}

 

@Getter
public class Gifticon {
    private Event event;
    private String code;

    public Gifticon(Event event) {
        this.event = event;
        this.code = UUID.randomUUID().toString();
    }
}

 

/**
 * Redis 구성 속성(host, port)를 보유하고 있는 클래스.
 */
@Getter
@Setter
@Component
@ConfigurationProperties("spring.redis")
public class RedisProperty {
    private String host;
    private int port;
}

 

@Slf4j
@Component
@RequiredArgsConstructor
public class EventScheduler {

    private final GifticonService gifticonService;

    @Scheduled(fixedDelay = 1000)
    private void chickenEventScheduler(){
        if(gifticonService.validEnd()){
            log.info("===== 선착순 이벤트가 종료되었습니다. =====");
            return;
        }
        gifticonService.publish(Event.CHICKEN);
        gifticonService.getOrder(Event.CHICKEN);
    }
}

 

@Slf4j
@Service
@RequiredArgsConstructor
public class GifticonService {
    /**
     * redis와 상호 작용하기 위해 RedisTemplate을 사용
     */
    private final RedisTemplate<String,Object> redisTemplate;
    private static final long FIRST_ELEMENT = 0;
    private static final long LAST_ELEMENT = -1;
    private static final long PUBLISH_SIZE = 10;
    private static final long LAST_INDEX = 1;
    private EventCount eventCount;

    public void setEventCount(Event event, int queue){
        this.eventCount = new EventCount(event, queue);
    }

    /**
     *
     * Redis의 정렬 집합(Sorted Set)은 멤버와 각 멤버에 연결된 점수(순위)로 구성된 데이터인데
     * 이 멤버를 삽입하거나 삭제할 때마다,
     * 자동으로 정렬되므로 순서를 신경쓰지 않고 데이터를 저장하고 검색하기에 이상적이다.
     *
     * 해당 코드에서는 opsForZSet() 메소드를 사용하여 Redis에 Sorted Set과 상호 작용하는 ZSetOperation 인터페이스의
     * 구현체를 얻을 수 있었다.
     */

    public void addQueue(Event event){
        final String people = Thread.currentThread().getName();
        final long now = System.currentTimeMillis();

        redisTemplate.opsForZSet().add(event.toString(), people, (int) now);
        log.info("대기열에 추가 - {} ({}초)", people, now);
        /**
         * opsForZset().add(key, value, score)을 사용하여 정렬집합에 참가자 추가
         * 해당 코드에선 key는 이벤트의 이름, value는 참가자의 이름(여기서는 Thread), score는 현재 시간을 나타낸다.
         */
    }

    public void getOrder(Event event){
        final long start = FIRST_ELEMENT;
        final long end = LAST_ELEMENT;

        Set<Object> queue = redisTemplate.opsForZSet().range(event.toString(), start, end);

        for (Object people : queue) {
            Long rank = redisTemplate.opsForZSet().rank(event.toString(), people);
            log.info("'{}'님의 현재 대기열은 {}명 남았습니다.", people, rank);
            /**
             * opsForZset().range(key, start, end)를 사용하여 정렬 집합에서 특정 범위의 멤버를 가져온다.
             * opsForZSet().remove(key, value)를 사용하여 정렬 집합에서 참가자를 제거한다.
             */
        }
    }
@EnableScheduling
@SpringBootApplication
public class PracticeApplication {

   public static void main(String[] args) {
      SpringApplication.run(PracticeApplication.class, args);
   }

}

 

테스트 코드

@SpringBootTest
class GifticonServiceTest {

    @Autowired
    private GifticonService gifticonService;

    @Test
    void 선착순이벤트_100명에게_기프티콘_30개_제공() throws InterruptedException {
        final Event chickenEvent = Event.CHICKEN;
        final int people = 100;
        final int limitCount = 30;
        final CountDownLatch countDownLatch = new CountDownLatch(people);
        gifticonService.setEventCount(chickenEvent, limitCount);

        List<Thread> workers = Stream
                .generate(() -> new Thread(new AddQueueWorker(countDownLatch, chickenEvent)))
                .limit(people)
                .collect(Collectors.toList());
        workers.forEach(Thread::start);
        countDownLatch.await();
        Thread.sleep(5000); // 기프티콘 발급 스케줄러 작업 시간

        final long failEventPeople = gifticonService.getSize(chickenEvent);
        assertEquals(people - limitCount, failEventPeople); // output : 70 = 100 - 30
    }

    private class AddQueueWorker implements Runnable{
        private CountDownLatch countDownLatch;
        private Event event;

        public AddQueueWorker(CountDownLatch countDownLatch, Event event) {
            this.countDownLatch = countDownLatch;
            this.event = event;
        }

        @Override
        public void run() {
            gifticonService.addQueue(event);
            countDownLatch.countDown();
        }
    }
}