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();
}
}
}