본문 바로가기
자바/스프링부트

[Java / Spring] Redis 이용한 대기열 시스템 구현

by huffpuffkin 2025. 10. 27.

이전에 야구 예매 사이트를 구현하면서 추가 기능으로 대기열 시스템을 구현했었습니다. 해당 프로젝트에서 어떤 방식으로 대기열 시스템을 구현했는지에 대해 작성해보려고 합니다.

 

 

 

 

먼저 고려했던 방법은 두 가지입니다. 

  1. 유저 N명씩 M시간마다 입장시키는 방식
  2. 입장 가능한 유저 수를 N명으로 설정하고 N명이 초과되면 대기열에 입장시킨 후 순차적으로 입장시키는 방식

 

 

 

 

 

프로젝트의 배포환경은 AWS 프리티어 기준 EC2였기 때문에 서버 자원이 제약되어 있었습니다. 따라서 1번 방식을 사용하게 되면 순간적으로 트래픽이 몰리는 상황에 서버가 쉽게 버티지 못할 것이라고 예상하였습니다. 따라서 2번 방식을 채택했습니다.

 

 

 

 

 

목표 세팅

따라서 기본 목표를 다음과 같이 세팅했습니다.

  1. 동시 예매 트래픽을 관리하기 위해 한 번에 일정 인원만 예매 페이지에 입장할 수 있도록 구현
  2. 예매 가능 인원이 가득 찼을 경우 나머지는 대기열에 진입해 순차적으로 입장할 수 있도록 구현

 

 

 

Redis 구조

KEY DESCRIPTION VALUE
queue:{gamePk} 게임별 대기열 value = userPk
score = timestamp → 정렬 기준
available:{gamePk}:{userPk} 예매 가능 상태 value = "allowed"

 

 

 

 

 

동작 흐름

1. 대기열 진입

예매 페이지 접근 시 enqueueUser 메서드를 실행합니다. 이때 사용자는 한 번에 한게임만 대기가 가능하기 때문에 사용자가 다른 게임의 큐 또는 available key에 존재하면 모두 삭제해줍니다 그 후 큐에 key = queue:{gamePk}, value = {userPk, 현재 시간} 형태로 추가해줍니다. 

 

 

    public boolean enqueueUser(String gamePk, String userPk) {
        String queueKey = QUEUE_KEY_PREFIX + gamePk;
        double score = System.currentTimeMillis();

        // 모든 기존 queue에서 제거 (한 게임만 대기 허용)
        Set<String> qkeys = new HashSet<>();
        
        //queue로 시작하는 키를 모두 스캔
        ScanOptions scanOptions = ScanOptions.scanOptions().match("queue:*").count(1000).build();

        redisTemplate.execute((RedisConnection connection) -> {
            Cursor<byte[]> cursor = connection.scan(scanOptions);
            while(cursor.hasNext()) {
                qkeys.add(new String(cursor.next()));
            }

            return null;
        });
//        Set<String> qkeys = redisTemplate.keys("queue:*");
        for (String key : qkeys) {
            redisTemplate.opsForZSet().remove(key, String.valueOf(userPk));
        }

//        Set<String> akeys = redisTemplate.keys("available:*");
        Set<String> akeys = new HashSet<>();
        
        //available로 시작하는 키를 모두 스캔
        ScanOptions scanOptions2 = ScanOptions.scanOptions().match("available:*").count(1000).build();

        redisTemplate.execute((RedisConnection connection) -> {
            Cursor<byte[]> cursor = connection.scan(scanOptions2);
            while(cursor.hasNext()) {
                akeys.add(new String(cursor.next()));
            }

            return null;
        });

        for (String key : akeys) {
            String[] parts = key.split(":");
            if (parts.length == 3 && parts[2].equals(String.valueOf(userPk))) {
                redisTemplate.delete(key);
            }
        }

        redisTemplate.opsForZSet().add(queueKey, String.valueOf(userPk), score);

        return true;
    }

 

이 부분에서 애를 먹었던 부분이 있다. 

 

바로 keys 메서드를 사용한 부분인데, keys 메서드는 해당 메서드가 실행 중일 경우 다른 모든 Redis 동작이 블락되기 때문에 이에 따라 오류가 발생합니다. 따라서 scan 메서드로 모두 변경해주는 작업을 진행했습니다. 하지만 우리 프로젝트에서는 Redis CLI가 아니라 Redis Cloud를 사용하는데, Redis Cloud에는 명시적으로 scan 함수가 존재하지 않습니다. 따라서 scan과 유사하게 동작하도록 변경했습니다. 

 

 

 

 

ScanOptions scanOptions = ScanOptions.scanOptions()
    .match("queue:*")  
    .count(1000)          
    .build();

먼저 스캔 옵션을 설정해줍니다. 레디스의 모든 키 중 queue:로 시작하는 것만 필터링하고, 한 번의 scan 호출에서 최대 1000개의 key를 가져옵니다. 

 

 

 

 

redisTemplate.execute((RedisConnection connection) -> {
    Cursor<byte[]> cursor = connection.scan(scanOptions);
    while(cursor.hasNext()) {
        qkeys.add(new String(cursor.next()));
    }
    return null;
});

execute 메서드를 통해 레디스의 low-level 커넥션을 다루어줍니다. 이를 통해 내부적으로 connection을 가져와서 람다 안에서 native 명령어를 수행할 수 있고, scan 메서드 또한 사용할 수 있습니다! 따라서 내부에서 scan 명령어를 실행해줍니다. scan 메서드는 전체 key를 한 번에 가져오는 것이 아닌 커서를 이용해 조금씩 반복해서 조회하기 때문에 안정적으로 조회가 가능합니다.

 

 

 

 

 

 

2. 예약 가능 여부 확인

pollFront 메서드가 스케줄러로 주기적으로 실행됩니다. 따라서 해당 게임의 available 키 개수를 확인해 예매 창에 접속해있는 인원 수를 체크합니다. 테스트용으로 5명만 예매 가능하도록 구현했기 때문에 예매 가능 중인(가능한) 인원이 5명 미만이면 대기열의 앞 부분 인원을 available로 전환해줍니다. 

 

    public void pollFront(String gamePk) {
        String lockKey = "lock:pollFront:" + gamePk;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
//                Set<String> availableKeys = redisTemplate.keys(AVAILABLE_KEY_PREFIX + gamePk + ":*");
                Set<String> availableKeys = new HashSet<>();
                ScanOptions scanOptions = ScanOptions.scanOptions().match(AVAILABLE_KEY_PREFIX + gamePk + ":*").count(1000).build();

                redisTemplate.execute((RedisConnection connection) -> {
                    Cursor<byte[]> cursor = connection.scan(scanOptions);
                    while(cursor.hasNext()) {
                        availableKeys.add(new String(cursor.next()));
                    }

                    return null;
                });

                int available = availableKeys.size();
                int remaining = ALLOWED_ENTRANCE_COUNT - available;

                log.info(available + "");
                if(available >= ALLOWED_ENTRANCE_COUNT) {
                    log.info("현재 예약 가능 인원이 {}명이므로 입장 보류: gamePk={}", available, gamePk);
                    return;
                }

                log.info("진입");
                Set<String> toRemove = redisTemplate.opsForZSet().range(QUEUE_KEY_PREFIX + gamePk, 0, remaining - 1);

                if(toRemove == null || toRemove.isEmpty()) {
                    return;
                }

                for(String user : toRemove) {
                    redisTemplate.opsForZSet().remove(QUEUE_KEY_PREFIX + gamePk, user);
                    redisTemplate.opsForValue().set(AVAILABLE_KEY_PREFIX + gamePk + ":" + user, "allowed", TTL_MILLIS, TimeUnit.MILLISECONDS);
                    log.info("예약 가능 상태 갱신: gamePk={}, userPk={}", gamePk, user);
                }

            } else {
                log.warn("pollFront lock 획득 실패 gamePk={}", gamePk);
            }
        } catch (InterruptedException e) {
            log.error("pollFront lock 중단", e);
            Thread.currentThread().interrupt();
        } catch (Exception e) {
            log.error("pollFront 에러", e);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

 

 

3. 예매 페이지 확인

클라이언트는 주기적으로 서버에 요청해 canReserve 메서드를 통해 자신의 available 키가 존재하는지 확인하고, 있다면 예매 페이지로 진입합니다. 

 

    public boolean canReserve(String gamePk, String userPk) {
        String availableKey = AVAILABLE_KEY_PREFIX + gamePk + ":" + userPk;

        return redisTemplate.hasKey(availableKey);
    }

 

 

 

 

 

4. 결제/TTL 만료

사용자가 결제를 완료하는 경우 또는 available 키의 TTL이 만료되는 경우 available key를 삭제합니다. 

 

 

 

 

 

 

 

 

돌아보면

이제 와 돌아보면 다른 예매 사이트에서는 1번 방식으로 작동하는 것 같았습니다. 몇만명이 넘는 사용자를 수용하기 위해서는 1번 방식이 더 적절할 것 같다는 생각이 들었습니다. 2번 방식으로 구성한다면 사용자를 천 명으로 설정한다고 가정해도 나머지 인원들은 그 천 명이 결제할 때까지 대기해야하기 때문에 대기 시간이 무한히 늘어나지 않을까...싶습니다.