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

hgs-study·2022년 5월 7일
19
post-thumbnail

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

1. 왜 레디스로 구현해야하나?

💡 선착순 이벤트에서 레디스가 사용되는 이유?

  • 보통 선착순 이벤트는 특정 시간에 트래픽이 몰리기 때문에 서버가 다운되거나 원활하지 못한 이벤트를 참여해보신 적이 한 번씩 경험해보셨을 겁니다.
  • 이번 포스팅에선, 레디스에서 제공하는 자료구조 중 하나인 Sorted Set을 활용하여 모든 요청이 DB에 바로 부하가 가지 않고 차례대로 일정 범위만큼씩 처리하는 구성을 해보려고합니다.
  • 선착순 이벤트 시 대기하고 있는 인원에 대해 대기열 순번을 표출하기 용이합니다.

2. 레디스 Sorted Set

Sorted Set이란?

  • Sorted Sets은 key 하나에 여러개의 score와 value로 구성하는 자료구조입니다.
  • Value는 score로 sort되며 중복되지 않습니다.
  • Score가 같으면 value로 sort됩니다.
  • Sorted Sets에서는 집합이라는 의미에서 value를 member라 부릅니다.
  • Sorted Sets은 주로 sort가 필요한 곳에 사용됩니다.

간단히 정리하자면, 한 Key에 여러 value와 score를 가지고 있으며 중복되지 않는 value로 score순으로 데이터를 정렬합니다.

3. 기프티콘 선착순 이벤트 구조

📌 Sorted Set을 활용한 선착순 이벤트

  • Sorted Set Key에는 GIFTICON_EVENT 를 설정합니다.
  • Value에는 사용자명(Pir, David, Foo, John)을 설정합니다. 해당 예제는 사용자명으로 했지만 사용자명이 중복일 경우, 사용자에 대한 고유한 값으로 세팅하면 됩니다.
  • Score에는 참여한 사람들을 순서대로 정렬하기 위해, 이벤트를 참여한 시간을 유닉스타임(m/s) 값으로 넣어줍니다.

4. 어플리케이션 구성

📌 기프티콘 30개 선착순 이벤트 시 100명이 요청했을 경우

(1) 100명의 유저가 기프티콘 발급 요청을 합니다.
(2) 100명의 유저는 대기열에 쌓이게 됩니다.
(3) 1초마다 동기화 돼어 기프티콘 발급 성공, 실패 로직을 수행합니다.
(4) 성공시, 이벤트가 종료되지 않았으면 100명의 유저중 먼저 들어온 순서대로 10명씩 기프티콘 발급합니다.
(5) 실패시, 다음 대기열로 돌아가면서 남은 대기열 순번을 표출합니다.
(6) 해당 과정을 반복하면서 이벤트는 종료(30개 발급완료)합니다

10개씩 발급하는 이유는 DB 부하를 줄이기 위해입니다. 해당 예시는 10개이지만 실제 서비스에선 10000개의 요청 중 1000개의 기프티콘을 발급할 경우 1초마다 50개씩 순차적으로 발급해서 DB부하를 줄일 수 있을 것 같습니다.

5. 치킨 기프티콘 선착순 이벤트 실습

30개의 치킨 기프티콘 선착순 이벤트를 진행합니다. 100명의 사용자가 요청하고, 1초마다 더 빨리 들어온 10명의 사용자에게 치킨 기프티콘을 발급합니다. 치킨을 발급 받지 못한 사용자는 대기열 순번이 1초마다 동기화됩니다. 30개의 치킨 기프티콘이 모두 발급되면 해당 이벤트는 종료됩니다.

의존성 설정

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

application.yml 설정

spring:
  redis:
    host: localhost
    port: 6379
  • 레디스 기본 설정 host와 port 설정

EventScheduler.java

@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);
    }
}
  • 1초마다 도는 대기열 동기화 스케줄러 구성
  • 기프티콘이 30개 모두 발급되면 선착순 이벤트 종료
  • 이벤트가 종료되지 않았으면, 기프티콘을 발급하고 남은 대기열에 순번 표출

GifticonService.java

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

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

    public void publish(Event event){
        final long start = FIRST_ELEMENT;
        final long end = PUBLISH_SIZE - LAST_INDEX;

        Set<Object> queue = redisTemplate.opsForZSet().range(event.toString(), start, end);
        for (Object people : queue) {
            final Gifticon gifticon = new Gifticon(event);
            log.info("'{}'님의 {} 기프티콘이 발급되었습니다 ({})",people, gifticon.getEvent().getName(), gifticon.getCode());
            redisTemplate.opsForZSet().remove(event.toString(), people);
            this.eventCount.decrease();
        }
    }
  • addQueue() : 사람들의 요청을 value : 사람의 고유한 값, score : 현재 시간(m/s)으로 대기열에 추가
  • getOrder() : 차례대로 들어온 사람들의 요청을 기반으로 대기열 순번 표출
  • publish() : 1초마다 이벤트에 참여하는 사람수(10명)씩 기프티콘 발급 후 대기열에서 제거

테스트 목록


선착순이벤트 100명에게 기프티콘 30개 제공

    @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();
        }
    }
  • 대기열에 사람들을 추가하는 AddQueueWorker 생성
  • 1초마다 도는 기프티콘 발급, 남은 대기열 동기화 스케줄러의 작업을 위해 Thread.sleep(5000) 설정
  • 결과 : 100명의 사용자 요청, 30개의 기프티콘을 제외하면 70명이 기프티콘을 받지 못한 것을 확인할 수 있다.

✅ 테스트 통과

100명이 대기열 참여 후 기프티콘 발급

가장 빨리 참여한 "Thread-2", "Thread-3"가 치킨 기프티콘을 먼저 받는 것을 확인할 수 있다.

대기열 순번 노출

치킨을 받지 못한 사람들의 화면에 표출될 순번을 표출하고 있다.

선착순 이벤트 종료

100명의 사람중 30명이 치킨 기프티콘을 받아서 이벤트가 종료된 것을 확인할 수 있다.

주의사항

Sorted Set 명령어는 이번 글에선 설명하지 않고 기프티콘 선착순 이벤트 구현에 초점을 맞춰 포스팅했습니다. 제 글만 접하고 명령어(add,range,rank 등)에 대한 이해가 쉽지 않을 수 있습니다. Sorted Set 명령어는 해당 사이트에서 추가적으로 확인해보면 좋을 것 같습니다.

느낀 점

💡

대용량 트래픽에 관해 영상을 보던 중 우연히 해당 참고 영상을 접해서 레디스 SortedSet이라는 자료구조를 알게 되었습니다. 그래서 언젠간 한 번 나도 구현해봐야겠다 생각하다가 이번에 마침 구현하게 되었습니다!

많은 요청이 바로 DB로 이어지지 않고 중간 정재 단계를 레디스로 거칠 수 있는 것을 해당 글을 쓰게 되면서 터득할 수 있었습니다. 추후에 팀 내에서 선착순 이벤트를 진행하거나 동일 시간에 민감한 데이터를 다루고, 대기열을 표출해야할 때 사용할 수 있는 하나의 수단이 될 것 같습니다!

GitHub Repository

출처

profile
흉내내는 사람이 아닌, 이해하는 사람이 되자

1개의 댓글

comment-user-thumbnail
2022년 5월 13일

timestamp값을 sorted set으로 사용하는게 인상적이네요 😮

애플리케이션에서 기록한 timestamp값을 레디스에 저장하면서, 이슈가 발생할 수도 있을 것 같다고 생각이 들었는데요

만약 A가 일찍요청하고, B가 비슷한시간에 요청하는 가정으로, A가 레디스에 저장할 때 지연이 발생하면 B가 레디스에 먼저 저장될 것 같은데요.

또 찰나의순간에 publish 스케줄러가 실행되면, A가 빨리요청했지만 선착순에 실패할 수도 있지않을까요?

답글 달기