구현

실 유저와 가상 유저의 티켓팅 경쟁을 위한 대기열 구현기 (Redis)

키태 2025. 1. 26. 16:00
728x90

티켓팅을 위한 트래픽 발생 (서론)

요즘 새롭게 하고 있는 프로젝트인 TiCatch의 메인 아이디어는 '티켓팅 연습'이다. 많은 사람들이 한꺼번에 몰리는 티켓팅을 연습시켜주는 서비스라고 생각하면 된다. 우리가 생각하는 그런 티켓팅을 할 만큼 실제 유저를 모으기가 쉽지 않기에 가상 유저들과 경쟁을 하는 서비스이다. 사람들이 한꺼번에 몰리기 때문에 실제 티켓팅을 하기 전의 '대기열'이 상당히 중요한데 이번에는 이 '대기열'을 어떻게 설계하고 구현했는지 기록해보도록 하겠다.

 

 

처음에는 프론트에서는 실제 유저의 요청만, 나머지 가상 유저의 트래픽은 백엔드에서 쏴주는 걸로 생각을 했다. 근데 그렇게 했을 때 많은 트래픽을 대기열에서 처리하는데, 이 대기열도 백엔드이고 많은 가상 유저의 트래픽도 백엔드에서 하는게 조금 이상한 느낌이 들었다. 그래서 다른 방법을 생각하다가 프론트 기술 스택인 Next.js가 있는데 Next.js의 서버에서 우리 백엔드한테 트래픽을 보낼 수 있음을 알았다. 즉 실제 유저는 프론트에서 백엔드로 요청을 보내고, 나머지 가상 유저는 Next.js에서 제공하는 서버를 통해 백엔드로 요청을 보내는 것이다. 이 통신 아키텍처가 훨씬 괜찮아보여서 이 방식을 채택했다.

 

대기열에 필요한 기능(요구사항)

그러면 트래픽에 대한 설계는 끝났고 대기열에 대한 설계를 해보도록 하자. 우선 대기열에 필요한 기능(요구사항)을 정리해봤다.\

- 유저(실/가상)는 들어온 순서대로 티켓팅을 해야한다.

- 유저(실/가상)는 주기적으로 자신이 몇번 째 웨이팅(?)인지 알아야한다.

- 대기열에서 일정 시간마다 앞의 n명을 뽑아 티켓팅 페이지로 이동시켜야 한다.

- 많은 트래픽을 대비해 비교적 빠른 시간에 데이터 처리가 가능해야 한다.

- 티켓팅별로 별도의 대기열이 필요하다.

 

위의 요구사항들을 생각했을 때, 사실 처음에는 비동기적인 방식을 생각했다. 동기적인 방식으로는 너무 느린 방식일 것 같아서 비동기적인 방식을 생각했으나 구글링을 통해 Redis라는 적합한 기술을 찾았다. 이미 많은 사람들이 경쟁/선착순 등 비슷한 상황에서 대기열을 구현했었고 우리의 요구사항에 매우 적합한 특징을 가지고 있는 Redis를 사용하기로 했다. 정확히 말하자면 Redis에서 제공하는 SortedSet!

 

Redis에서 제공하는 SortedSet을 채택한 이유는

- 우선 Redis는 RDB와 달리 메모리 기반 데이터 구조를 사용하기 때문에, 다른 DB보다 처리 속도가 빠르다.

- SortedSet의 내부를 보면 각 요소에 대해 'score' 기반으로 정렬이 된다. 이를 통해 대기열의 우선 순위를 자연스럽게 관리할 수 있는데, 우리의 서비스 같은 경우 '들어온 순서'가 'score'가 된다. 이 우선순위를 통해 데이터 삽입/조회/삭제 등에 O(log(N))의 시간복잡도로 데이터를 처리할 수 있다.

- 필요 시 Redis 클러스터링을 통해 대기열을 확장하여 대규모 트래픽 또한 처리할 수 있다.

- key:value 형식으로 관리됨에 따라 '티켓팅별 대기열' 또한 key를 적절하게 처리함으로써 관리 가능하다.

 

이러한 이유로 Redis의 SortedSet으로 대기열을 구현하기로 했다.

 

실제 유저와 가상 유저 판별

지금부터는 대기열 구현에 따른 세부사항이다. 하지만 이 내용들은 백엔드에서만 우선 설계를 한 것이고 프론트의 상황에 따라 바뀔 수 있다! (아직 배포도 안된 내용 - just 개인적인 생각으로 구현)

우리 서비스에서 '어떤 유저'인지 판별하기 위해 JWT 방식의 AccessToken과 RefreshToken을 사용한다. 그렇기에 로그인 후 사용되는 API에서는 모두 '토큰'이 필요하다. 하지만 Next.js의 서버에서 전송되는 가상 유저의 API에는 토큰이 없을 수 밖에 없다. 그렇기에 나는 실제 유저와 가상 유저가 대기열 진입을 위한 같은 API를 사용하되 Controller 단에서 이를 분리하고자 했다.

@GetMapping("/waiting/{ticketingId}/{userType}")
public ResponseEntity<SingleResponseResult<TicketingWaitingResponseDto>> startTicketing(HttpServletRequest request, @Parameter(description = "티켓팅 ID") @PathVariable("ticketingId") Long ticketingId, @Parameter(description = "유저 유형 (ACTUAL 또는 VIRTUAL)") @PathVariable("userType") String userType) {
    String userId;
    if (userType.equals("ACTUAL")) {
        userId = userService.getUserFromRequest(request).getUserId().toString();
    } else {
        userId = "VIRTUAL:" + UUID.randomUUID().toString();
    }
    return ResponseEntity.ok(new SingleResponseResult<>(ticketingService.addTicketingWaitingQueue(ticketingId, userId)));
}

 

이렇게 URI 단에서 userType을 구분한다. userType은 ACTUAL(실제 유저) / VIRTUAL (가상 유저)로 구분된다. Redis의 SortedSet에서 {userId:score(들어온시간} 이렇게 쌓이길 원하기 때문에 가상 유저들도 userId가 필요했다. 각각의 고유한 id를 갖게 하기 위해서 VIRTUAL로 들어온 요청에는 'VIRTUAL:'로 prefix를 두고 뒤에는 랜덤 UUID를 뒀다. 이를 통해 실제 유저와 가상 유저를 분리할 수 있었고, 가상 유저들끼리도 고유한 값으로 분리할 수 있었다.

 

대기열 생성

프론트로부터 '대기열 진입' 요청을 받았으면 대기열을 생성해야한다. 각 티켓팅이 동시에 열릴 수도 있기 때문에 티켓팅별로 다른 대기열을 생성해줘야한다. 나는 'queue:ticket:{ticketingId}'를 통해 티켓팅 id 별로 고유한 티켓팅 대기열을 만들도록 했다.

public void addToWaitingQueue(Long ticketId, String userId) {
    String queueKey = WAITING_QUEUE_PREFIX + ticketId;
    double score = System.currentTimeMillis();
    redisTemplate.opsForZSet().add(queueKey, userId, score);
}

 

여기서 queueKey는 앞에서 말한 각 티켓팅 별 대기열의 key 값으로 생각하면 된다. score는 각 유저마다 들어온 시간이 점수화되어서 우선 순위로 분류된다. 나머지는 Spring에서 제공하는 redisTemplate을 통해 sortedSet을 생성하고 거기다 userId:score을 넣어준 것이다.

 

 

간단하게 대기열을 만들어서 가상 유저 몇명을 넣어본 사진이다. ZRANGE 명령어를 통해 해당 대기열에 존재하는 모든 유저와 스코어를 불러온 값이다. 지금 여기에는 총 5명의 가상 유저가 있는 것이고 각 가상 유저는 'VIRTUAL:{UUID}'이란 값의 userId를 고유한 값으로 가지고 있다. 짝수 행에 적혀있는 값을 들어온 시간을 점수화한 값이다. ZRANGE 뒤에 queue:ticket:4를 보면 4번 티켓팅에 대한 대기열을 불러오라는 값이다. 각 티켓팅별로 고유한 대기열이 있는 것이다.

 

대기열에서 자신의 위치 알기

대기열에 유저들을 넣었으면 이제 유저는 자신이 몇 번째 위치인지 알고 싶을 것이다. 이것이 티켓팅 서비스의 핵심이기 때문이다.

@GetMapping("/waiting-status/{ticketingId}")
public ResponseEntity<SingleResponseResult<TicketingWaitingResponseDto>> getTicketingWaitingStatus(HttpServletRequest request, @Parameter(description = "티켓팅 ID") @PathVariable("ticketingId") Long ticketingId) {
    User user = userService.getUserFromRequest(request);
    return ResponseEntity.ok(new SingleResponseResult<>(ticketingService.getTicketingWaitingStatus(ticketingId, user.getUserId())));
}

 

Redis의 SortedSet에서 key 즉 userId를 넘겨주면 O(log(N))의 속도로 빠르게 자신의 인덱스를 가르쳐준다. 하지만 그 전에 유저가 대기열에 진입한 순간, 매 순간순간 실시간(WebSocket)으로 자신의 위치를 알려줄 것이냐 아니면 프론트에서 특정 시간을 정해서 백엔드한테 서비스콜을 통해 위치를 알려줄 것이냐를 정해야한다. 우리팀에서는 WebSocket 기술을 도입하기에는 부담이 되고, 서비스 특성 상 가상 유저의 위치는 안가르쳐줘도 되고 실제 유저의 위치만 가르쳐주면 되기에 '특정 시간을 정해서 프론트->백엔드 서비스콜'을 통해 실제 유저의 인덱스를 가르쳐주기로 했다. 그렇기에 위의 코드를 보면 '대기열 진입' API와 다르게 가상 유저를 분리하지 않았다.

public Long getWaitingQueueRank(Long ticketId, String userId) {
    String queueKey = WAITING_QUEUE_PREFIX + ticketId;
    Long rank = redisTemplate.opsForZSet().rank(queueKey, userId);
    if(rank == null) {
        return -1L;
    }
    return rank+1;
}

 

위 코드를 보면 티켓팅 고유의 대기열에 접근해 userId를 통해 rank를 얻어오는 것을 볼 수 있다. rank가 없으면 해당 유저는 대기열에 빠져나간 것으로 판단해 -1을 return 하고 아니면 대기열의 인덱스+1을 return 해준다.

 

{
  "statusCode": 0,
  "messages": "string",
  "developerMessage": "string",
  "timestamp": "2025-01-26T06:43:52.830Z",
  "data": {
    "ticketingId": 4,
    "userId": 16,
    "waitingNumber": 2
  }
}

 

Swagger의 응답 형식을 가져온 것인데 나머지는 무시하고 data 부분만 보면 된다. data 부분이 의미하는 것은 '16번 유저가 4번 티켓팅의 2번째 위치에 있다' 라는 뜻이다. 프론트에서 일정 시간 fetch를 통해 해당 유저의 위치를 가져오다가 waitingNumber가 -1이 되면 티켓팅 페이지로 이동시키면 되는 것이다.

 

대기열에서 가장 빨리 들어온 상위 N명 빼기

이제 대기열에 진입도 했고 자신의 위치도 알았으면 앞의 n명을 특정 시간마다 빼내야한다. 이 특정 시간과 n명은 아직 정해진 것이 없다. 왜냐하면 이 '특정 시간'과 'n명'의 의미는 앞서 나간 n명이 '자리 선점 + 결제'까지 완료하는 시간을 적절히 예측해서 서버에 부담이 안가게하는 값이기 때문이다. 이 값은 나 혼자 정하기보다는 팀원들과 같이 논의를 해봐야할 것 같아서 TBD로 놔두고 나는 임의의 값을 정해 대기열에서 빼내는 기능만 구현했다. 나는 임의로 '30초'마다 '3명'을 뽑는 것으로 했다.

 

public void startScheduler(Long ticketingId, int batchSize) {
    if (schedulerMap.containsKey(ticketingId)) {
        return;
    }

    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
    scheduler.scheduleAtFixedRate(() -> {
        try {
            Long targetCount = ticketingBatchProcessService.processBatchInWaitingQueue(ticketingId, batchSize);
            if(targetCount == 0L) {
                stopScheduler(ticketingId);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }, 5, 30, TimeUnit.SECONDS);

    schedulerMap.put(ticketingId, scheduler);
}

 

'30초마다'를 구현하기 위해 DynamicScheduler를 도입했다. 티켓팅에 대한 대기열이 생성되면 자동으로 스케줄러가 돌아가도록 구현했다. 해당 스케줄러는 30초마다 앞의 3명을 뽑는 ticketingBatchProcessService를 실행시킨다.

 

@Transactional
public Long processBatchInWaitingQueue(Long ticketingId, int batchSize) {
    Set<ZSetOperations.TypedTuple<String>> batch = redisService.getBatchFromQueue(ticketingId, batchSize);
    if (batch == null || batch.isEmpty()) {
        return 0L;
    }
    redisService.removeBatchFromQueue(ticketingId, batchSize);
    return 1L;
}

 

BatchProcess에서는 SortedSet에 접근해서 가장 앞에 있는 3명을 뽑아서 지운다. 이렇게 되면 해당 유저의 위치를 프론트에서 fetch로 요청했을 때 -1을 리턴할 수 있도록 한다.

 

고도화

설계에 대해 검증을 위한 목적으로 빠르게 구현한 부분이라 고도화해야하는 부분이 상당히 많다. 각 로직에 대해 예외처리도 안되어있고 추가적으로 검증해야하는 부분이 많다. 하지만 이렇게 했을 때 비교적 많은 트래픽을 In-Memory의 Redis에 우선 순위에 맞춰서 저장할 수 있음을 알았고 충분히 합리적인 기술 선택인 것 같다.

728x90