서론
일반적인 타임딜 시스템에서는 오픈 직후, "폭발적 트래픽을 감당하는 것"과 "실시간 순번 보장"이라는 요구사항이 충족되어야 한다. 이번 포스팅에서는 대기열 시스템 구현 과정과 왜 해당 방법으로 대기열을 구현하게 되었는지에 대해 정리해보고자 한다.
왜 대기열이 필요할까?
타임딜의 핵심은 한정된 자원(재고)을 선착순으로 배분하는 것이다. 모든 요청을 곧바로 주문 서비스(DB)로 흘려보내면 아래와 같은 문제가 발생하게 된다.
- DB 커넥션 고갈: 수만 개의 트랜잭션이 한꺼번에 DB에 붙으려다 시스템이 멈추게 된다.
- 부정확한 순서: 네트워크 지연 등으로 인해 나중에 온 사람이 먼저 처리될 수 있다.
- 서비스 불능: 특정 서비스의 과부하가 전체 시스템(MSA)으로 전파될 수 있다.
현재 타임딜 프로젝트에서는 사용자들의 주문 요청이 폭발적으로 몰릴 경우, 그 유입량을 조절하기 위해 대기열 서비스를 설계하게 되었다.

타임딜 프로젝트에서의 요구사항
- 유입량 제어: 서버가 감당할 수 있는 만큼만 사용자를 입장시킨다
- 정확한 순서 보장: 먼저 온 사람이 먼저 입장해야 한다(FIFO)
- 실시간 상태 조회: 사용자가 자신의 대기 순번을 언제든 확인할 수 있어야 한다
- 중도 이탈 처리: 특정 시간 이상 동안 특별한 행동 없이 활성열만 차지하고 있을 경우, 제한 시간을 두어 유령 사용자를 방지해야 한다.
본론
대기열 구현 방식 (왜 Redis를 선택했는지?)
대기열 구현 방식에 대해 찾아보았는데 크게 RDB, Kafka와 RabbitMQ와 같은 메시지 큐, 그리고 Redis 등 크게 3가지 방식으로 알려져 있다.
| 구분 | RDB (MySQL 등) | 메시지 큐 (Kafka, RabbitMQ) | Redis (Sorted Set) |
| 방식 | 요청마다 DB INSERT 및 COUNT 쿼리 | 진입 이벤트를 큐에 넣어 비동기 소비 | 시간(Timestamp)을 Score로 저장 |
| 속도/성능 | 상대적으로 느림 (디스크 기반) | 매우 빠름 (처리량 최적화) | 가장 빠름 (인메모리 기반) |
| 데이터 정합성 | 트랜잭션으로 보장 | 유실 없는 처리 보장 | 싱글 스레드 기반 동시성 보장 |
| 장점 | 데이터 영속성이 완벽히 보장됨 | 대량 이벤트의 안정적 비동기 처리 | 밀리초 단위 응답 및 DB 부하 최소화 |
| 한계 및 단점 | DB 커넥션 고갈 및 순번 조회 부하 | 중도 이탈자(데이터 삭제) 처리의 어려움 | 서버 재시작 시 데이터 유실 위험 |
Redis를 선택하게 된 이유
- 대기열 처리를 인메모리 DB인 Redis로 분리함으로써, 실제 주문 데이터가 주문 서비스의 DB 부하를 원천적으로 차단시킬 수 있음
- TTL 지원: 자동 만료로 메모리 효율적 관리 (T
- 순서 보장: Sorted Set의 Score 기반 정렬 (FIFO)
- 중도 이탈자 등 대기 중 발생되는 예외 상황을 번거롭지 않게 처리할 수 있음 (ZPOPMIN 등의 명령어를 통해)
- 빠른 순번 조회: Redis ZSet은 진입 시간을 Score로 관리하기 때문에 순번 조회 시, ZRANK 명령어를 사용하면 10만 명의 대기자 중 내 순위를 찾는 데 O(logN)의 시간복잡도만 소요됨
Redis를 선택하게 된 이유로는 맨 위의 요구사항을 따르는 데에 번거로움이 없으면서도 빠른 성능을 보장하기 때문에 Redis로 대기열 시스템을 구현하도록 결정하게 되었다.
대기열 시스템 구현 과정
해당 프로젝트에서는 헥사고날 아키텍처를 적용하여 외부 기술인 Redis가 비즈니스 로직에 섞이지 않도록 설계하였다. 순번 조회 기능부터 차근차근 구현 과정을 정리해보고자 한다.
Port 정의 (Inbound & Outbound)
Port가 무엇일까?
헥사고날 아키텍처에서 중심부인 비즈니스 도메인에서는 외부 세상(DB, Web, 외부 API)에 대해 전혀 몰라야 한다. 이때 도메인이 외부와 소통하기 위해 뚫어놓은 인터페이스가 바로 포트이다.
정리하자면, 헥사고날 아키텍처(별칭 Port&Adapter 아키텍처)에서 Port는 "비즈니스 로직이 외부 세상과 소통하기 위한 표준 규격(인터페이스)"을 의미한다.
왜 Port를 분리하는가?
만약 대기열 로직 안에 redisTemplate.opsForZSet() 같은 코드가 직접 들어가 있다면, 나중에 저장소를 Redis가 아닌 다른 것으로 바꿀 때 핵심 로직을 다 뜯어고쳐야 한다. 그러나 Port라는 Interface를 두면 비즈니스 로직은 "나는 save() 기능이 필요해"라고 선언만 하고, 실제 Redis를 쓸지 MySQL을 쓸지는 외부(Adapter)에서 결정하게 할 수 있다.
- 기술 종속성 제거 : 저장소 바꾸고 싶을 경우, 도메인 로직은 건드릴 필요 없이 어댑터(구현체)만 갈아 끼우면 됨
- 테스트 용이성 : 포트가 인터페이스로 되어 있으므로, 실제 Redis 없이도 목(Mock) 객체를 만들어 도메인 로직만 순수하게 유닛 테스트 가능
- 관심사의 분리: "어떻게 저장할 것인가(기술)"와 "비즈니스 규칙이 무엇인가(도메인)"를 명확히 분리
// ❌ 헥사고날 없이 직접 의존
@RestController
public class QueueController {
private final QueueService service; // 구체 클래스 의존
// 문제:
// 1. QueueService 변경 시 Controller도 영향받음
// 2. 테스트 시 실제 Redis 필요
// 3. Service 교체 불가능
}
// 헥사고날: Port를 통한 간접 의존
@RestController
public class QueueController {
private final QueuePort queuePort; // 인터페이스 의존
// 장점:
// 1. QueueService 내부 변경에 독립적
// 2. Mock 객체로 Controller만 테스트 가능
// 3. 런타임에 다른 구현체 주입 가능
}
Inbound Port (입력 포트)
외부(Controller, Scheduler)에서 도메인 내부로 들어오는 통로이다. 즉, 외부에서 도메인 내부 비즈니스 로직을 호출한다. (UseCase를 정의하는 곳이다)
Outbound Port(출력 포트)
도메인 내부에서 외부 저장소나 시스템으로 나가는 통로이다. 즉, 비즈니스 로직이 외부 기술(DB, Kafka)을 사용하기 위해 호출한다. 보통 Repository 인터페이스가 이에 해당한다. (해당 프로젝트에서는 Domain Layer에 위치해있다.)
- QueuePort (Inbound): 사용자 대기열 진입, 순번 조회, 토큰 검증 등 유스케이스 정의
- 역할
- 구현체(QueueService)와 호출자(Controller)를 분리
- 외부(Controller)에서 호출할 수 있는 비즈니스 기능 정의
- 역할
- QueueRepository (Outbound): Redis에 데이터를 저장하고 순번을 조회하는 인프라 기능 정의
- 역할
- 저장소가 해줄 일 (Redis에 넣기, 순번 가져오기, 만료된 것 지우기)
- ZSet 활용: opsForZSet().add() (진입), rank() (순번 조회).
- 동일 사용자 중복 진입 방지: SETNX를 이용한 USER_INDEX_KEY 생성
- activateTokens 시 여러 명령어를 한 번에 묶어 네트워크 오버헤드 감소
- 역할
package com.rushcrew.queue.application.port.in;
import com.rushcrew.queue.application.command.EnterQueueCommand;
import com.rushcrew.queue.application.dto.QueueRedisResponse;
import java.util.UUID;
/**
* 대기열 유스케이스 인터페이스 (Port In)
*
* 역할:
* - 외부(Controller)에서 호출할 수 있는 비즈니스 기능 정의
* - 구현체(QueueService)와 호출자(Controller)를 분리
*
* 왜 인터페이스 형태인지?
* - 의존성 역전: Controller가 구현체가 아닌 인터페이스에 의존
* - 테스트 용이성: Mock 객체로 Controller 단독 테스트 가능
* - 확장성: 다른 구현체로 교체 가능 (예: CachedQueueService)
*/
public interface QueuePort {
/**
* 대기열 진입 (토큰 발급)
*/
QueueRedisResponse enterQueue(EnterQueueCommand command);
/**
* 대기 상태 조회 (Polling)
*/
QueueRedisResponse getQueueRank(UUID productId, String tokenValue);
}
package com.rushcrew.queue.domain.repository;
import com.rushcrew.queue.domain.entity.QueueToken;
import com.rushcrew.queue.domain.vo.TokenId;
import java.util.List;
import java.util.UUID;
/**
* 대기열 저장소 인터페이스 (Port Out)
*
* 역할:
* - 도메인이 외부 저장소에 요구하는 기능 정의
* - 구현 기술(Redis/MySQL)에 독립적
*
* 왜 Domain Layer에 위치?
* - 비즈니스가 "무엇을" 원하는지만 정의 (How는 Infrastructure에서)
* - 의존성 방향: Infrastructure -> Domain (역전-DIP)
*/
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);
/**
* 활성 토큰 검증 (주문 서비스에서 검증 요청 시 사용)
* SET ISMEMBER
* @param productId
* @param tokenId
* @return
*/
boolean isActivatedToken(UUID productId, TokenId tokenId);
/**
* 현재 활성화된 인원 수 조회 (Set Size)
* @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);
}
대기열 진입 구현
기획 당시 대기열 진입에서의 요구사항과 정책은 아래와 같다.
- 중복 진입 차단: 특정 타임딜 대기 건에 대해 유저가 단 하나의 토큰만 가질 수 있고, 대기열에 중복으로 진입하지 못하도록 보장한다.
- Fast Track: 활성 유저가 100명 미만이면 대기 없이 즉시 활성열로 이동시킨다.
- 보상 트랜잭션: Redis의 데이터 불일치(ZSet에는 없는데 유저 인덱스 키만 남은 경우)를 진입 시점에 탐지하여 좀비 데이터를 삭제하고 재등록을 허용하도록 하는 로직이다.
@Slf4j
@Repository
public class RedisQueueRepository implements QueueRepository {
private final StringRedisTemplate redisTemplate;
private static final String WAITING_KEY = "queue:wait:product:%s";
private static final String ACTIVE_KEY = "queue:active:product:%s";
private static final String USER_INDEX_KEY = "queue:user:product:%s:%s"; // String (중복방지용)
// FAST TRACK(대기열 진입 정책) 기준 인원 (100인 미만이면 대기열 토큰 생성 시 바로 활성열로 이동)
// TODO: 추후 QueuePolicy (정책 DB)에서 관리하도록 수정 예정
private static final Long MAX_ACTIVE_COUNT = 100L;
public RedisQueueRepository(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 대기열 등록 (ZSet : Sorted Set)
*/
@Override
public boolean register(QueueToken token, LocalDateTime dealEndTime, Integer activeTtl) {
// TTL 계산 : (이벤트 종료 시간 - 현재 시간)
long secondsUntilClose = Duration.between(LocalDateTime.now(), dealEndTime).getSeconds();
if (secondsUntilClose < 0) {
// 이미 종료된 이벤트면 진입 불가 처리
log.warn("[QUEUE:ERROR] 이미 종료된 이벤트입니다. productId={}", token.getProductId());
return false;
}
// redis 사용자 인덱스 키 생성
String userIndexKey = getUserIndexKey(token.getProductId(), token.getUserId());
// 중복 방지 : 유저별 대기열 키 생성 (SETNX)
// KEY: queue:user:product:{productId}:{userId} / VALUE: 토큰 UUID
Boolean isNewUser = redisTemplate.opsForValue().setIfAbsent(
userIndexKey,
token.getId().getValue().toString(),
Duration.ofMinutes(secondsUntilClose) // TTL 설정: 타임딜 종료 시간에 맞춰 자동 만료
);
if (Objects.equals(isNewUser, Boolean.FALSE)) {
// 이미 대기 중인 유저
log.warn("[QUEUE:ERROR] 이미 대기 중인 사용자입니다. userId={}", token.getUserId());
return false;
}
// FAST TRACK 판단 : 활성열 인원 조회
Long activeCount = countActiveTokens(token.getProductId());
if (activeCount != null && activeCount < MAX_ACTIVE_COUNT) {
// [Fast Track] 대기 없이 바로 활성 상태 진입
return registerFastTrack(token, dealEndTime, activeTtl, userIndexKey);
} else {
// 대기열 등록 (ZSet)
return registerWaitingQueue(token, userIndexKey);
}
}
/**
* Fast Track: 즉시 활성열 등록 (ZSet 등록)
*/
private boolean registerFastTrack(QueueToken token, LocalDateTime dealEndTime, Integer activeTtl,
String userIndexKey) {
// ActiveKey(활성열 키) 생성
String activeKey = getActiveKey(token.getProductId());
try {
// 만료 시간(score) 계산: 현재시간
double expireAt = System.currentTimeMillis() + (activeTtl * 1000L);
// active(활성열) ZSet에 저장 (Score = 만료시간)
redisTemplate.opsForZSet().add(
activeKey,
token.getId().getValue().toString(),
expireAt
);
log.info("[QUEUE:REDIS] FAST TRACK 활성열 등록 성공: user={}, token={}", token.getUserId(), token.getId());
return true;
} catch (Exception e) {
// 롤백: 문제 발생 시 유저 인덱스 삭제 (재진입 허용)
// 보상 트랜잭션 : Set 저장 실패 시, 중복 방지 키(userIndexKey)와 activeKey도 삭제해줘야 유저가 다시 시도 가능
log.error("[QUEUE:REDIS:ERROR] FAST TRACK 활성열 진입 실패로 인한 롤백 수행: userId={}, tokenId={}", token.getUserId(), token.getId());
redisTemplate.delete(userIndexKey);
redisTemplate.opsForSet().remove(activeKey, token.getId().toString());
throw e;
}
}
/**
* 대기열 등록 (ZSet 등록)
*/
private boolean registerWaitingQueue(QueueToken token, String userIndexKey) {
try {
// TODO: 추후 확인 -> System.currentTimeMillis()는 동시성 이슈가 미세하게 있을 수 있으므로 nanoTime 혼용 추천
double score = System.currentTimeMillis();
redisTemplate.opsForZSet().add(
getWaitingKey(token.getProductId()),
token.getId().getValue().toString(),
score
);
log.info("[QUEUE:REDIS] 대기열 진입 성공: user={}, score={}", token.getUserId(), score);
return true;
} catch (Exception e) {
// 보상 트랜잭션 : ZSet 저장 실패 시, 중복 방지 키(userIndexKey)도 삭제해줘야 유저가 다시 시도 가능
log.error("[QUEUE:REDIS:ERROR] 대기열 진입 실패로 인한 롤백 수행: userId={}, tokenId={}", token.getUserId(), token.getId());
redisTemplate.delete(userIndexKey);
throw e;
}
}
}
대기열 진입 로직 상세
중복 진입 차단 (USER_INDEX_KEY)
동일한 유저가 여러 번 줄을 서는 것을 방지하기 위해 Redis의 SETNX(setIfAbsent) 명령어를 사용한다.
- Key: queue:user:product:{productId}:{userId}
- Value: 발급된 토큰 UUID
- 특징: 키가 없을 때만 저장되므로 유저당 1개의 토큰만 보장된다. 이때 TTL은 타임딜 종료 시간까지로 설정하여 불필요한 메모리 점유를 방지한다.
- 중복 아닐 경우: 처음 들어온 유저이므로 진입 로직을 계속 진행한다.
- 중복일 경우: 이미 토큰이 발급된 유저이므로 중복 진입을 거부한다.
- TTL: 타임딜 종료 시간에 맞춰 자동으로 사라지도록 설정하여 메모리 낭비를 방지
// 중복 방지 : 유저별 대기열 키 생성 (SETNX)
// KEY: queue:user:product:{productId}:{userId} / VALUE: 토큰 UUID
Boolean isNewUser = redisTemplate.opsForValue().setIfAbsent(
userIndexKey,
token.getId().getValue().toString(),
Duration.ofMinutes(secondsUntilClose) // TTL 설정: 타임딜 종료 시간에 맞춰 자동 만료
);
if (Objects.equals(isNewUser, Boolean.FALSE)) {
// 이미 대기 중인 유저
log.warn("[QUEUE:ERROR] 이미 대기 중인 사용자입니다. userId={}", token.getUserId());
return false;
}
특수 요구사항 : FastTrack 정책 (활성열 바로 이동)
모든 사용자를 줄 세우는 것은 비효율적이라고 판단하여, 팀원들과의 회의를 통해 시스템 부하가 적을 때는 바로 입장시키는 Fast Track 정책을 적용하기로 하였다.
현재 시스템이 한가하고, 서버가 인원을 감당 가능할 경우(활성 유저가 100명 미만), 굳이 대기열에 줄을 세울 필요가 없으므로 바로 활성열로 보낸다.
- 로직: 대기열 등록 시점에 현재 활성열 인원(countActiveTokens) 체크
- 기준: 설정된 임계치(예: 100명) 미만이라면 대기열(WAITING_KEY)을 거치지 않고 즉시 활성열(ACTIVE_KEY)로 토큰 발행
- 효과: 사용자 경험을 개선하고 불필요한 폴링 요청 발생을 원천 차단
@Override
public boolean register(QueueToken token, LocalDateTime dealEndTime, Integer activeTtl) {
...
// FAST TRACK 판단 : 활성열 인원 조회
Long activeCount = countActiveTokens(token.getProductId());
log.warn("[QUEUE:INFO] 현재 활성열 인원 개수 count={}", activeCount);
if (activeCount != null && activeCount < MAX_ACTIVE_COUNT) {
// [Fast Track] 대기 없이 바로 활성 상태 진입
return registerFastTrack(token, dealEndTime, activeTtl, userIndexKey);
} else {
// 대기열 등록 (ZSet)
return registerWaitingQueue(token, userIndexKey);
}
...
}
/**
* Fast Track: 즉시 활성열 등록 (ZSet 등록)
*/
private boolean registerFastTrack(QueueToken token, LocalDateTime dealEndTime, Integer activeTtl,
String userIndexKey) {
// ActiveKey(활성열 키) 생성
String activeKey = getActiveKey(token.getProductId());
try {
// 만료 시간(score) 계산: 현재시간 기준 + activeTtl
double expireAt = getExpireAt(activeTtl);
// active(활성열) ZSet에 저장 (Score = 만료시간)
redisTemplate.opsForZSet().add(
activeKey,
token.getId().getValue().toString(),
expireAt
);
log.info("[QUEUE:REDIS] FAST TRACK 활성열 등록 성공: user={}, token={}", token.getUserId(), token.getId());
return true;
} catch (Exception e) {
// 롤백: 문제 발생 시 유저 인덱스 삭제 (재진입 허용)
// 보상 트랜잭션 : Set 저장 실패 시, 중복 방지 키(userIndexKey)와 activeKey도 삭제해줘야 유저가 다시 시도 가능
log.error("[QUEUE:REDIS:ERROR] FAST TRACK 활성열 진입 실패로 인한 롤백 수행: userId={}, tokenId={}", token.getUserId(), token.getId());
redisTemplate.delete(userIndexKey);
redisTemplate.opsForSet().remove(activeKey, token.getId().toString());
throw e;
}
}
활성열(Active)과 대기열(Waiting)의 차이
활성열
활성열은 단순히 "들어와 있다"는 사실뿐만 아니라 "언제 나가는가(만료)"가 중요하다.
- 구조: Redis ZSet을 활용
- Score: 현재시간 + 유효시간(TTL)을 Score로 저장 (활성열에 있을 수 있는 만료 시간)
- 이점: 나중에 스케줄러나 조회 시점에 System.currentTimeMillis()보다 Score가 낮은 것들(토큰이 만료된 것들)을 한꺼번에 지우는 Lazy Cleanup이 가능해진다.
/**
* Fast Track: 즉시 활성열 등록 (ZSet 등록)
*/
private boolean registerFastTrack(QueueToken token, LocalDateTime dealEndTime, Integer activeTtl,
String userIndexKey) {
// ActiveKey(활성열 키) 생성
String activeKey = getActiveKey(token.getProductId());
try {
// 만료 시간(score) 계산: 현재시간
double expireAt = System.currentTimeMillis() + (activeTtl * 1000L);
// active(활성열) ZSet에 저장 (Score = 만료시간)
redisTemplate.opsForZSet().add(
activeKey,
token.getId().getValue().toString(),
expireAt
);
log.info("[QUEUE:REDIS] FAST TRACK 활성열 등록 성공: user={}, token={}", token.getUserId(), token.getId());
return true;
} catch (Exception e) {
...
}
대기열
바로 활성열로 이동할 수 없을 경우에는 Redis의 대기열(Waiting Queue)에 등록한다. 대기열은 "누가 먼저 왔는가"가 가장 중요하다.
- 구조: Redis ZSet
- Score: 진입 시점의 System.currentTimeMillis() (타임스탬프)를 사용
- 이점: 들어온 순서대로 정렬되어 점수가 낮을수록(일찍 올수록) ZRANK 결과가 낮게 나와 공정한 순서 보장이 가능해진다.
Redis에서 Score란?
Redis Sorted Set(ZSet)은 점수가 붙은 데이터 저장소이다.
- Score(점수): 정렬의 기준이 되는 숫자 (실수형 double) -> '줄 서는 순서' 결정
- Member(값): 저장하려는 실제 데이터 (여기선 Token UUID)
왜 요청 시점 현재시간 + 유효시간(TTL)을 Score로 쓰나?
Redis는 Score가 Score값이 작은 순서대로(오름차순) 데이터를 정렬한다.
- 먼저 온 사람: 13:00:00 (Unix Timestamp: 100000) -> Score가 작음 (1등)
- 나중에 온 사람: 13:00:05 (Unix Timestamp: 100005)-> Score가 큼 (꼴찌)
즉, "시간=점수"로 설정하면 Redis가 알아서 FIFO 방식으로 선착 순 줄 세우기를 해준다. 별도로 정렬 로직을 짤 필요가 없기 때문에 속도도 빠르고 매우 편리하다.
보상 트랜잭션 (예외 상황 대응)
Redis는 RDB처럼 여러 명령어를 묶는 완벽한 트랜잭션 롤백이 까다롭다. 따라서 코드 레벨에서 예외 발생 시 수동으로 롤백하는 보상 트랜잭션 로직이 필수적이다. 쉽게 설명하면, 대기열 저장 실패 시, 중복 방지 키를 삭제해주고 해당 유저의 재진입을 허용해주도록 하는 것이다.
왜 필요한가?
만약 USER_INDEX_KEY는 생성됐는데, 뒤이어 대기열(ZSet)에 넣는 과정에서 네트워크 오류가 발생한다면?
만료된 토큰의 유저 인덱스 정보가 Redis에 남아있게 된다. 이렇게 되면 Redis에는 "이미 줄 서 있는 유저"로 저장되어 있어 해당 유저는 영원히 재진입이 불가능해진다. 이를 방지하기 위해 아래 코드의 catch 블록에서 생성했던 인덱스 키를 삭제함으로써 유저의 재진입을 허용해주도록 한다.
/**
* Fast Track: 즉시 활성열 등록 (ZSet 등록)
*/
private boolean registerFastTrack(QueueToken token, LocalDateTime dealEndTime, Integer activeTtl,
String userIndexKey) {
// ActiveKey(활성열 키) 생성
String activeKey = getActiveKey(token.getProductId());
try {
...
log.info("[QUEUE:REDIS] FAST TRACK 활성열 등록 성공: user={}, token={}", token.getUserId(), token.getId());
return true;
} catch (Exception e) {
// 롤백: 문제 발생 시 유저 인덱스 삭제 (재진입 허용)
// 보상 트랜잭션 : Set 저장 실패 시, 중복 방지 키(userIndexKey)와 activeKey도 삭제해줘야 유저가 다시 시도 가능
log.error("[QUEUE:REDIS:ERROR] FAST TRACK 활성열 진입 실패로 인한 롤백 수행: userId={}, tokenId={}", token.getUserId(), token.getId());
redisTemplate.delete(userIndexKey);
redisTemplate.opsForSet().remove(activeKey, token.getId().toString());
throw e;
}
}
결론
이번 포스팅에서는 타임딜 서비스의 핵심 기능의 진입 로직을 어떻게 구현하였는지 과정을 정리해보았다. 초기 기획 당시에는 단순하게 중복 진입 차단과 Fast Track 정책에만 생각을 두고 있었는데, 실제로 구현하면서 느낀 바로는 로직에 신경 써야할 부분이 꽤나 많았다는 점이었다. 역시 백문이 불여일타다..
참고
대기열 서비스 구현(+Redis)
제가 콘서트 예약시스템에서 구현한 대기열에 대한 설계에 대해서 얘기해보겠습니다. 대기열이란?대기열 시스템은 많은 사용자들이 동시에 접근하는 상황에서, 시스템의 안정성과 성능을 보
feel2.tistory.com
https://devwinnie.tistory.com/15
[KBOTicket] Kafka를 이용한 티켓팅 대기열 시스템 구현 및 순번 처리 (1)
티켓팅 시스템은 수천, 수만 명이 동시에 접속하는 상황을 고려해야 한다.특히 티켓팅 당일에는 막대한 트래픽이 몰리기 때문에, 이를 효율적으로 처리할 수 있는 대기열 시스템이 필요하다.또
devwinnie.tistory.com
'Project > RUSH DEAL' 카테고리의 다른 글
| [RUSH_DEAL] AWS ECS Auto Scaling으로 트래픽 급증 대응하기 (타임딜 대기열 서비스 부하 테스트) - 20만원 증발기 (0) | 2026.01.05 |
|---|---|
| [RUSH_DEAL] 6. 대기열 순번 조회 로직 (feat.UUID 함정) (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 |