본문 바로가기

Spring

[Spring] Redis ZSet 기반 대기열 시스템 정리

1. 구현 목적

예매 요청이 한 번에 몰렸을 때 모든 사용자를 바로 예매 페이지로 보내지 않고, Redis 대기열을 통해 순서대로 입장시키는 구조를 만들었다.

핵심은 다음과 같다.

  • 사용자는 먼저 대기열에 진입한다.
  • 서버는 사용자에게 token을 발급한다.
  • token은 Redis ZSet에 저장된다.
  • 입장 가능한 token만 예매 페이지로 이동할 수 있다.
  • 실제 예매 요청 시에도 token을 다시 검증한다.

 

 

2. Redis 구조

대기열은 Redis ZSet 2개로 분리했다.

waiting:{productId}
entry:{productId}

 

waiting:{productId}

아직 대기 중인 token 저장소다.

waiting:1
- token119
- token120
- token121
  • member: token
  • score: 대기열 진입 시간
redisTemplate.opsForZSet().add(
        waitingKey,
        token,
        System.currentTimeMillis()
);
 

entry:{productId}

입장 가능한 token 저장소다.

entry:1
- token1
- token2
- token3
  • member: token
  • score: 입장 만료 시간
long expireAt = System.currentTimeMillis() + Duration.ofMinutes(20).toMillis();

redisTemplate.opsForZSet().add(
        entryKey,
        token,
        expireAt
);
 
 

3. 대기열 입장 흐름

  1. Client가 대기열 입장 요청
  2. Backend가 token 생성
  3. Redis waiting:{productId} ZSet에 token 저장
  4. Backend가 token을 Client에게 반환
  5. Client는 token을 저장하고 대기 화면으로 이동

API :

@Operation(summary = "대기열 진입 후 토큰 받기")
@PostMapping
public ResponseEntity<String> waitingRequest(@RequestBody WaitingRequest req){
    String token = waitingService.setWaiting(req);
    return ResponseEntity.status(HttpStatus.ACCEPTED).body(token);
}

 

응답 :

e3222360-b472-4151-9ca7-6510a35891dc
 
 

4. waiting → entry 이동 흐름

@Scheduled를 사용해서 주기적으로 entry 공간을 확인한다.

  1. entry:{productId}에서 만료된 token 제거
  2. 현재 entry 인원 수 조회
  3. entry에 남은 자리 계산
  4. waiting 앞순번 token 조회
  5. waiting에서 제거
  6. entry에 추가

구현 코드 :

public void waitingMoveEntry(Long productId) {

		// 1. 시간 및 키 세팅
    String waitingKey = WAITING_PREFIX + productId;
    String entryKey = ENTRY_PREFIX + productId;

    long curTime = System.currentTimeMillis();
    long maxEntry = 1000L; 
    long batchSize = 1000L;
		Duration minutes = Duration.ofMinutes(20);
    long expire = curTime + minutes.toMillis();

    // 2. 현재 Entry 에서 만료된 토큰들 제거하기
    redisTemplate.opsForZSet().removeRangeByScore(entryKey, 0, curTime);

    // 3. 현재 Entry 에 있는 토큰 수 조회하기
    Long entrySize = redisTemplate.opsForZSet().size(entryKey);

    // 4. 방어 코드 추가하기 (curEntrySize null 방지)
    long curEntrySize = entrySize == null ? 0 : entrySize; // null 이라면 0 , 아니라면 그대로 사용

    // 5. 들어갈 수 있는 사이즈 구하기
    long availableEntrySize = maxEntry - curEntrySize;

    // 6. 방어 코드 추가하기 (이용가능한 사이즈 음수 주의)
    if (availableEntrySize <= 0) {
        return;
    }

    // 7. Batch Size와 availableEntrySize 중 더 작은 값 뽑기
    long allowSize = Math.min(availableEntrySize, batchSize);

    // 8. waiting 하고 있는 토큰중 allowSize 만큼 조회
    Set<Object> tokens = redisTemplate.opsForZSet().range(waitingKey, 0, allowSize-1);

    log.info("들어갈 수 있는 allowSize 는 {} 입니다! ", allowSize);

    // 9. 방어 코드 추가 null 조심
    if (tokens == null || tokens.isEmpty()) {
        return;
    }

    // 10. 해당 토큰 entry 로 이동 시켜주면서 기존 waiting 에서 제거해주기
    tokens.forEach(t -> {

        // 해당 토큰 String 타입으로 변환
        String token = t.toString();

        // waiting 에서 token 제거해주기
        redisTemplate.opsForZSet().remove(waitingKey, token);

        // entry 에 넣어주기
        redisTemplate.opsForZSet().add(entryKey, token, expire);
    });

}
 

 

5. 개별 token 만료 처리

Redis ZSet 내부 member에는 TTL을 직접 걸 수 없다.

그래서 entry ZSet의 score에 만료 시간을 넣었다.

long expireAt = System.currentTimeMillis() + Duration.ofMinutes(5).toMillis();

redisTemplate.opsForZSet().add(entryKey, token, expireAt);

 

만료된 token은 score 기준으로 제거한다.

redisTemplate.opsForZSet().removeRangeByScore(
      entryKey,
      0,
      System.currentTimeMillis()
);

score가 현재 시간보다 작거나 같은 token 제거라는 의미다.

 

 

6. 상태 조회 흐름

Client는 발급받은 token으로 자신의 상태를 조회한다.

  1. entry:{productId}에 token이 있는지 확인
  2. 있으면 AVAILABLE 반환
  3. entry에 없으면 waiting:{productId}에서 rank 조회
  4. waiting에 있으면 WAITING + 대기 순번 반환
  5. 둘 다 없으면 EXPIRED 반환

entry를 먼저 조회하는 이유는, entry로 이동된 token은 waiting에서 제거되기 때문이다.

 

 

7. entry token 확인

ZSet에서 특정 token이 있는지 확인할 때는 score()를 사용한다.

Double score = redisTemplate.opsForZSet().score(entryKey, token);

 

결과:

score == null   -> token 없음
score != null   -> token 있음

 

entry의 score는 만료 시간이기 때문에 만료 여부도 함께 확인할 수 있다.

if(score != null){
    return new QueueStatusResponse(QueueStatus.AVAILABLE, 0L, token, "사용가능한 토큰입니다.");
}

 

 

8. waiting 순번 조회

waiting에서 내 순번을 확인할 때는 rank()를 사용한다.

Long rank = redisTemplate.opsForZSet().rank(waitingKey, token);

Redis rank는 0부터 시작하므로 사용자에게 보여줄 때는 +1 한다.

 

 

9. 상태 조회 코드

public QueueStatusResponse check(String token, Long productId) {

    String entryKey = ENTRY_PREFIX + productId;
    String waitingKey = WAITING_PREFIX + productId;

    // 1. 현재 사용능한 영역에 들어있는지 확인
    Double score = redisTemplate.opsForZSet().score(entryKey, token);

    if(score != null){
        return new QueueStatusResponse(QueueStatus.AVAILABLE, 0L, token, "사용가능한 토큰입니다.");
    }

    // 2. 대기 중인 영역에 들어있는지 확인
    Long rank = redisTemplate.opsForZSet().rank(waitingKey, token);

    if(rank == null){
        return new QueueStatusResponse(QueueStatus.EXPIRED, 0L, token, "존재하지 않는 토큰입니다. 새로 고침 후 다시 입장해주세요.");
    }

    return new QueueStatusResponse(QueueStatus.WAITING, rank+1L, token, "대기 중인 토큰입니다.");
}

 

상태값 :

public enum QueueStatus {
    WAITING,   // 대기 상태
    AVAILABLE, // 사용가능 상태
    EXPIRED    // 만료 상태
}
 
 

10. 예매 요청 시 token 재검증

Client가 예매 페이지로 이동했다고 해서 신뢰하면 안 된다.

실제 예매 API에서도 반드시 token을 다시 검증해야 한다.

// 기존 예매 Service 내부 상단에 추가
@Transactional
public boolean addReservationV3(CreateReservationRequest req) {

    // 예매 가능한 토큰 검증 추가하기
    long now = System.currentTimeMillis();
    String entryKey = ENTRY_PREFIX + req.getProductId();
    Double score = redisTemplate.opsForZSet().score(entryKey, req.getToken());
    if (score == null || score <= now) {
        redisTemplate.opsForZSet().remove(entryKey, req.getToken());
        throw new ApiException(HttpStatus.FORBIDDEN, "403", "FORBIDDEN", "예매 가능한 권한이 없습니다. (토큰 문제)");
    }
 

예매 성공 후에는 같은 token 재사용을 막기 위해 entry에서 제거한다.

redisTemplate.opsForZSet().remove(entryKey, req.getToken());
 
 

11. 최종 흐름 요약

[대기열 입장]

Client -> Backend -> token 생성 -> waiting:{productId} 저장 -> token 반환

 

[입장 처리]

Scheduler -> entry 남은 자리 확인 -> waiting 앞순번 token 이동 -> entry 저장

 

[상태 조회]

Client -> token 상태 조회 entry에 있으면 AVAILABLE waiting에 있으면 WAITING + 순번 둘 다 없으면 EXPIRED

 

[예매 요청]

AVAILABLE 상태에서 예매 페이지 이동 예매 API에서 token 재검증 성공 후 entry에서 token 제거

 

 

12. 회고

대기열을 구현하면서, 반드시 Kafka 같은 메시지 큐를 사용해야 하는 것은 아니라는 점을 새롭게 배웠다.

 

처음에는 “순차적으로 처리해야 한다면 당연히 메시지 큐를 사용해야 하지 않을까?”라고 생각했다.

하지만 Redis에는 ZSet이라는 자료구조가 있고, score를 기준으로 데이터를 정렬할 수 있었다.

 

특히 ZSet은 rank를 통해 특정 token이 현재 몇 번째 순서에 있는지도 확인할 수 있어서, 대기열 구현에 잘 맞는 자료구조라고 느꼈다.

 

이번 구현을 통해 Redis가 단순한 캐시 저장소뿐만 아니라, 대기열처럼 순서가 필요한 기능에도 활용될 수 있다는 것을 알게 되었다. Redis의 다양한 활용 방식을 배울 수 있어서 의미 있는 경험이었다.


GitHub