서론
지난 포스팅에서는 사용자가 대기열에 진입(Entry)하는 과정을 정리해보았다. 하지만 진입만으로는 대기열 구현의 끝이 아니다. 사용자는 자신이 언제 입장할 수 있을지 끊임없이 궁금해하며 서버에 상태를 묻게 되는데, 이번 포스팅에서는 이 순번 조회 및 폴링(Polling) 로직을 어떻게 구현했는지 정리해보았다.
목표: 클라이언트에서 대기 요청을 통해 고객을 대기열에 진입하게 한 후, 대기열 순번 조회를 통해 현재 대기 순번을 UI를 통해 보여주는 것
폴링(Polling)이란?
하나의 장치(또는 프로그램)가 충돌 회피 또는 동기화 처리 등을 목적으로 다른 장치(또는 프로그램)의 상태를 주기적으로 검사하여 일정한 조건을 만족할 때 송수신 등의 자료처리를 하는 방식
왜 폴링 로직이 필요한가?
대기열 시스템에서 폴링은 사용자와 시스템이 소통하는 방식이다. 수만 명의 대기자가 1~3초 간격으로 "내 순서가 되었나요?"라고 묻는 상황에서, 이 요청을 처리하는 로직이 무겁다면 대기열 시스템 자체가 병목 지점이 되어버린다. 따라서 최소한의 리소스로 최대한 빠르게 응답하는 것이 이번 구현의 핵심 목표였다.
본론
헥사고날 아키텍처 기반 포트 정의 및 구현
외부 환경(Redis, Web)의 변화에 유연하게 대응하기 위해 인터페이스를 먼저 정의하였다. 이는 이전 포스팅에서도 정리해두었으므로 자세한 내용은 이전 포스팅을 참고해주시기를 바란다.
Inbound Port (입력 포트) - QueuePort
사용자로부터 요청을 받는 서비스의 입구
- getQueueRank: 특정 상품에 대한 유저의 현재 상태(WAIT/ACTIVE)와 대기 순번을 반환
Outbound Port (출력 포트) - QueueRepository
데이터 저장소(Redis)와 통신하는 출구
- getWaitingRank: Redis ZRANK를 이용해 실시간 순위를 가져옴
- getWaitingScore: 토큰 Score(요청 시간), Redis ZScore로 대기열 진입 시간 조회
- isActivatedToken: 활성열(Active Set)에 토큰이 존재하는지 확인
- verifyTokenOwner: 요청된 토큰이 해당 유저의 소유인지 검증
- countActiveTokens: 현재 활성화된 인원 계산 (Redis ZCard)
포트 정의에 대한 코드는 아래와 같다.
QueuePort
public interface QueuePort {
/**
* 대기열 진입 (토큰 발급)
*/
QueueRedisResponse enterQueue(EnterQueueCommand command);
/**
* 대기 상태 조회 (Polling)
*/
QueueRedisResponse getQueueRank(UUID productId, String tokenValue);
}
QueueRepository
public interface QueueRepository {
/**
* Redis의 대기열에 Sorted Set (ZSet) 타입으로 저장
* Score에 타임스탬프를 사용하는 구조 (선착순 진입 순서 보장)
* 진입 정책(Fast Track) : 현재 활성(Active) 상태인 토큰 수가 100개 미만이면 곧바로 활성열로 추가
* 100개 이상일 경우, 대기열로 추가
* @param token
*/
boolean register(QueueToken token, LocalDateTime dealEndTime, Integer activeTtl);
/**
* 토큰 활성화 (대기열 -> 활성열)
* 스케줄러용: 대기열에서 N명을 활성열로 이동
* @param productId
* @param tokens
*/
void activateTokens(UUID productId, List<String> tokens, TrafficSetting trafficSetting);
/**
* 대기열에서 Score 정렬 순서대로 N개의 토큰 조회 (범위 조회)
* 활성화 대상 토큰을 선별하기 위함
* @param productId
* @param count
* @return
*/
List<String> getWaitingTokens(UUID productId, long count);
/**
* 활성 토큰 검증 (주문 서비스에서 검증 요청 시 사용)
* @param productId
* @param tokenId
* @return
*/
boolean isActivatedToken(UUID productId, TokenId tokenId);
/**
* 현재 활성화된 인원 수 조회 (ZCard)
* @param productId
* @return
*/
Long countActiveTokens(UUID productId);
/**
* 대기열 토큰 소유권 검증 (본인 확인)
* @param productId
* @param userId
* @param token
* @return
*/
boolean verifyTokenOwner(UUID productId, Long userId, String token);
/**
* 토큰 상태/대기 순번 확인 (ZSet rank -> polling)
* (ZSet 조회 - O(logN))
* @param productId
* @param tokenId
* @return
*/
Long getWaitingRank(UUID productId, TokenId tokenId);
/**
* 토큰 Score 조회 (요청 시간)
* Redis ZSCORE로 진입 시간 조회
* @param productId
* @param tokenId
* @return
*/
Double getWaitingScore(UUID productId, TokenId tokenId);
}
순번 조회 비즈니스 로직의 단계별 흐름 (중요)
QueueService에서 구현된 조회 로직은 시스템 부하를 최소화하기 위해 크게 3단계로 진행된다.
1. 보안 및 소유권 검증
타인의 토큰으로 순번을 가로채거나 조회하는 행위를 막기 위해 USER_INDEX_KEY를 활용하여 검증한다.
// 토큰 유효성 검증: 본인 확인 (대기열 토큰 소유권 검증)
boolean isOwner = queueRepository.verifyTokenOwner(productId, userId, token);
if (!isOwner) {
log.warn("[QUEUE:ERROR] 토큰 도용 시도 감지: User {}, Token {}", userId, token);
throw new BusinessException(QueueErrorCode.TOKEN_OWNER_NOT_MATCH);
}
2. 활성 상태(ACTIVE) 우선 확인
'입장 가능' 상태를 먼저 체크합니다. 이미 활성열에 있다면 순번 계산 없이 0의 순번으로 즉시 응답한다. (활성 상태의 순번은 0이다)
TokenId tokenId = extractValidQueueTokenId(token);
// 활성 상태 여부 확인
if (queueRepository.isActivatedToken(productId, tokenId)) {
return QueueRedisResponse.builder()
.token(tokenId.getValue())
.productId(productId)
.rank(0L) // 활성 상태는 순번 0 (이미 활성열에 있으므로)
.status(QueueStatus.ACTIVE)
.enteredAt(LocalDateTime.now()) // 활성 상태일 때, 진입시간 현재시간으로 설정
.build();
}
3. 대기 순번(RANK) 계산
아직 대기 중이라면 Redis의 ZRANK를 호출한다. 이때 Redis는 Skip List 구조를 통해 수만 명 사이에서도 O(log N) 속도로 순위를 찾아내게 된다.
QueueService
// 대기열 순번 확인 (Redis ZRANK)
// rank는 0부터 시작 (내 앞의 대기 인원 수 (0이면 내가 1빠))
Long waitingRank = queueRepository.getWaitingRank(productId, tokenId);
if (waitingRank == null) {
// User Index Key는 있는데 Redis에 ZSet에 없는 경우 (만료됨)
// => 에러를 던지면 클라이언트가 다시 enterQueue를 호출하게 되고,
// 그때 QueueRepository의 register 메서드에서 Index Key 삭제하고 재진입 처리
throw new BusinessException(QueueErrorCode.QUEUE_TOKEN_NOT_AVAILABLE);
}
RedisQueueRepository
getWaitingRank 함수의 구현을 통해 Redis ZRANK를 활용하여 사용자의 현재 순번(index)를 반환한다.
Redis의 rank 메서드는 0부터 시작하는 인덱스를 반환한다.
- 반환값이 0이면? -> 내가 1등 (내 앞에 0명).
- 반환값이 5이면? -> 내 앞에 5명 있음 (나는 6번째).
즉, ZRANK의 반환값이 곧 "내 앞의 대기 인원 수"가 된다.
@Override
public Long getWaitingRank(UUID productId, TokenId tokenId) {
// ZRANK key member (ZSet 조회)
// Redis ZRANK 통하여 대기 순번 조회 성능을 O(log N)으로 최적화
return redisTemplate.opsForZSet()
.rank(getWaitingKey(productId),
tokenId.getValue().toString()
);
}
트러블슈팅 - tokenId.timestamp()와 Score의 관계
대기열 순번 조회를 구현하면서, 클라이언트에게 "언제 진입했는지(enteredAt)"와 "내 앞에 몇 명 있는지(rank)"를 API 응답값으로 알려줘야 했다. 처음에는 TokenId가 UUID이니, 여기서 타임스탬프를 추출하면 된다고 생각했었다.
특정 토큰ID에서 위에서 설명한 요청시점의 시간 정보를 가져오기 위해 아래와 같은 코드를 썼었다. 그러나 실행과 검증을 통해 아래의 코드가 생가가보다 에러 위험이 큰 코드인 것을 발견할 수 있었다.
/**
* 대기열 순번, 상태 조회 (polling)
*/
@Override
public QueueRedisResponse getQueueRank(UUID productId, String tokenValue) {
TokenId tokenId = TokenId.of(UUID.fromString(tokenValue));
// 오류가 발생하는 코드
// TokenId의 UUID에서 timestamp를 뽑아와서 진입 요청 시간을 가져오려고 했었음.
// 그러나 UUID에는 시간이 없을 수 있다!
long timestamp = tokenId.getValue().timestamp();
// 요청시간 LocalDateTime 타입으로 변환
LocalDateTime enteredAt = convertLocalDateTime(timestamp);
...
}
앞서 TokenId를 만들 때 UUID.randomUUID()를 사용했었기 때문이다. UUID.randomUUID()는 V4(완전 랜덤 방식)이다. 이 방식은 내부에 시간 정보를 담고 있지 않으므로, .timestamp()를 호출하면 UnsupportedOperationException 예외가 터지거나, 의미없는 값이 나오게 된다.
따라서 토큰ID로부터 Score의 시간값을 올바르게 가져오려면 해결책은 아래와 같다.
Redis ZSet에 넣을 때 System.currentTimeMillis()를 Score로 넣었으므로, Redis에서 Score를 조회(ZSCORE)해서 가져오는 것이 가장 정확하다.
해결 전략: Redis Sorted Set(ZSet) 활용
Redis의 ZSet은 Key-Value-Score 구조를 가진다.
- Value: 토큰 ID (유저 식별)
- Score: System.currentTimeMillis() (진입 시간)
이 구조 덕분에 아래 두 가지를 아주 쉽게 얻을 수 있다.
- 진입 시간: ZSCORE 명령어로 점수(시간) 조회
- 대기 순번: ZRANK 명령어로 내 등수(0부터 시작) 조회
// Redis에서 해당 토큰의 Score(진입시간) 조회
Double score = redisTemplate.opsForZSet().score(getWaitingKey(productId), tokenValue);
if (score != null) {
long requestTime = score.longValue();
LocalDateTime enteredAt = convertLocalDateTime(requestTime);
}
QueueService
위 해결 전략을 통해 서비스 계층에서는 UUID에서 시간을 뽑아내지 않고, 인프라 계층(RedisQueueRepository)에 물어보는 식으로 구현한다.
/**
* 대기열 순번, 상태 조회 (polling)
*/
@Override
public QueueRedisResponse getQueueRank(UUID productId, String token, Long userId) {
// 토큰 유효성 검증: 본인 확인 (대기열 토큰 소유권 검증)
boolean isOwner = queueRepository.verifyTokenOwner(productId, userId, token);
if (!isOwner) {
log.warn("[QUEUE:ERROR] 토큰 도용 시도 감지: User {}, Token {}", userId, token);
throw new BusinessException(QueueErrorCode.TOKEN_OWNER_NOT_MATCH);
}
TokenId tokenId = extractValidQueueTokenId(token);
// 활성 상태 여부 확인
if (queueRepository.isActivatedToken(productId, tokenId)) {
return QueueRedisResponse.builder()
.token(tokenId.getValue())
.productId(productId)
.rank(0L) // 활성 상태는 순번 0 (이미 활성열에 있으므로)
.status(QueueStatus.ACTIVE)
.enteredAt(LocalDateTime.now()) // 활성 상태일 때, 진입시간 현재시간으로 설정
.build();
}
// 대기열 순번 확인 (Redis ZRANK)
// rank는 0부터 시작 (내 앞의 대기 인원 수 (0이면 내가 1빠))
Long waitingRank = queueRepository.getWaitingRank(productId, tokenId);
if (waitingRank == null) {
// User Index Key는 있는데 Redis에 ZSet에 없는 경우 (만료됨)
// => 에러를 던지면 클라이언트가 다시 enterQueue를 호출하게 되고,
// 그때 QueueRepository의 register 메서드에서 Index Key 삭제하고 재진입 처리
throw new BusinessException(QueueErrorCode.QUEUE_TOKEN_NOT_AVAILABLE);
}
// 요청시간 LocalDateTime 타입으로 변환
Long requestTime = getRequestTime(productId, tokenId);
LocalDateTime enteredAt = convertLocalDateTime(requestTime);
return QueueRedisResponse.builder()
.token(tokenId.getValue())
.productId(productId)
.rank(waitingRank + 1) // 사용자 친화적 순번 (0번대신 1번부터 표시)
.status(QueueStatus.WAITING)
.enteredAt(enteredAt)
.build();
}
API 요청 시, 대기열 토큰을 왜 Header에 담는가?
올바른 예시
/**
* 대기열 순번 조회(Polling) API
*/
@GetMapping("/rank")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<ApiResponse<QueueResponse>> getQueueRank(
@RequestParam UUID productId,
@RequestHeader(QUEUE_TOKEN_HEADER) String queueToken,
@AuthenticationPrincipal UserDetailsImpl principal
) {
QueueRedisResponse redisResult = queuePort.getQueueRank(productId, queueToken, principal.userId());
QueueResponse response = queueMapper.toQueueResponse(redisResult);
return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(response));
}
대기열 순번 조회의 전제 조건은 대기열 진입을 완료해서 토큰을 발급받은 상황이다.
대기열 순번 조회 시, 대기열 진입 API 요청을 통해 발급받은 QueueToken을 URL의 Query Parameter(?token=...)나 Body에 담지 않고, Header에 담는 이유는 아래와 같다.
1. 메타데이터와 비즈니스 데이터의 분리
- Body/Query Param: "무엇을 살 것인가?"와 같은 비즈니스 데이터 (상품 ID, 수량, 옵션 등 실질적 데이터)
- Header: "누가 요청하는가?", "어떤 자격 증명을 가지고 있는가?"와 같은 인증 토큰, 대기열 토큰, 클라이언트 정보 (메타데이터)
대기열 토큰은 로그인 토큰과 같이 입장권(Authorization)의 성격이 강하다. 우리가 JWT 토큰을 헤더에 담는 것과 같은 이치이다.
2. API Gateway 및 인프라 레벨 처리
- MSA 환경에서 요청은 Nginx(선택) -> API Gateway -> Service 순으로 들어온다.
- 게이트웨이나 로드밸런서는 Body를 파싱(읽기)하는 비용이 크지만, Header는 매우 가볍게 읽을 수 있다.
- 만약 Body에 토큰이 있다면, Gateway가 JSON 본문을 파싱해야 하므로 성능이 떨어지고 구조가 복잡해진다.
- 나중에 "대기열 토큰이 없으면 아예 서비스 도달 전에 게이트웨이에서 컷"하는 로직이 필요할 때, 토큰이 Header에 있어야 구현이 훨씬 쉽고 성능이 좋기 때문이다.
3. 로깅 및 보안
- 보통 서버 로그 남길 때, URL은 통째로 남기지만 Header는 선택적으로 남긴다.
- URL에 토큰이 들어가면 브라우저 히스토리나 로그 등에 토큰이 그대로 노출될 위험이 있다. 그에 반해 Header는 상대적으로 은닉성이 높다.
기술적 최적화
Lazy CleanUp
폴링 요청이 들어올 때마다 시스템은 스스로 데이터를 정돈한다. 별도의 배치 작업 없이, countActiveTokens 등을 조회할 때 현재 시간보다 만료 시간이 지난 토큰들을 ZREMRANGEBYSCORE로 즉시 삭제한다.
/**
* 활성 토큰 수 확인 (ZSet Size 조회)
* 조회 직전에 이미 만료된 토큰을 일괄적으로 삭제하여 정확한 수를 반환함 (Lazy cleanup)
*/
@Override
public Long countActiveTokens(UUID productId) {
String activeKey = getActiveKey(productId);
// lazy cleanup
// ZSet의 Score(만료시간)가 현재 시간보다 작은(과거인) 멤버들 삭제
// ZREMRANGEBYSCORE key -inf current_timestamp
double now = System.currentTimeMillis();
redisTemplate.opsForZSet().removeRangeByScore(
activeKey,
Double.NEGATIVE_INFINITY,
now
);
// 청소 후 남은 개수 반환
Long count = redisTemplate.opsForZSet().zCard(activeKey);
return count != null ? count : 0L;
}
자가 치유(Self-Healing)
USER_INDEX_KEY는 존재하지만 실제 큐에 데이터가 없는 '좀비 토큰'상태를 감지하면, 대기열 진입(register) 시점에 이를 자동으로 삭제하고 재진입을 허용하여 사용자 경험의 불편함을 방지하였다.
/**
* 대기열 등록 (ZSet : Sorted Set)
*
* 추가 처리 : Lazy Cleanup으로 인해 ZSet(대기열/활성열)에서는 토큰이 삭제되었는데
* USER_INDEX_KEY만 덩그러니 남아있는 상황이 발생하면, 해당 유저는 영원히 재진입이 불가능해짐.
* 따라서 진입 시도 시 USER_INDEX_KEY가 이미 있다면,
* "진짜 대기열/활성열에 살아있는지" 더블 체크. 없다면 좀비 키로 간주하고 삭제 후 재진입을 허용하는 방식으로 수정
*/
@Override
public boolean register(QueueToken token, LocalDateTime dealEndTime, Integer activeTtl) {
...
// 유저별 대기열 키(USER_INDEX_KEY)가 이미 존재하는 경우 -> 진짜 유효한지 검증
if (Objects.equals(isNewUser, Boolean.FALSE)) {
// USER_INDEX_KEY가 바라보는 큐 토큰 (해당 유저가 가지고 있던 토큰의 ID(UUID))
String oldToken = redisTemplate.opsForValue().get(userIndexKey);
// 기존 토큰이 대기열이나 활성열에 실제로 존재하는지 확인
if (oldToken != null && !isTokenAlive(token.getProductId(), oldToken)) {
// 존재하지 않음 = 만료되었거나 삭제된 좀비 키(USER_INDEX_KEY)임 -> 삭제 후 재등록 허용
log.info("[QUEUE:REPAIR] 좀비 키(USER_INDEX_KEY) 발견. 삭제 후 재진입 처리 userId={}, oldToken={}", token.getUserId(), oldToken);
redisTemplate.delete(userIndexKey);
// 삭제하고 재시도 (재귀 호출)
return register(token, dealEndTime, activeTtl);
}
// 이미 대기 중인 유저 (중복 진입 거부)
log.warn("[QUEUE:ERROR] 이미 대기 중인 사용자입니다. userId={}", token.getUserId());
return false;
}
...
}
'Project > RUSH DEAL' 카테고리의 다른 글
| [RUSH_DEAL] AWS ECS Auto Scaling으로 트래픽 급증 대응하기 (타임딜 대기열 서비스 부하 테스트) - 20만원 증발기 (0) | 2026.01.05 |
|---|---|
| [RUSH_DEAL] 5. 대기열 진입 로직 (0) | 2025.12.04 |
| [RUSH_DEAL] 4. 대기열 아키텍처&도메인 구성 (0) | 2025.12.04 |
| [RUSH_DEAL] 3. 대기열 Redis 데이터 구조 설계 (0) | 2025.12.03 |
| [RUSH_DEAL] 2. DDD에서의 VO(Value Object) (0) | 2025.12.02 |