토큰 발급 방식으로 선착순 시스템 구현하기: 입장권부터 봇 방지까지
서론
이전 글에서 대기열로 트래픽을 순서대로 흡수하는 방법을 다뤘다. 대기열은 사용자 경험을 크게 개선하지만, 모든 사용자가 결국 구매 페이지에 도달한다는 전제가 있다.
토큰 방식은 다르다. 입장 토큰을 먼저 발급하고, 토큰을 가진 사람만 구매할 수 있다. 콘서트 티켓팅, 한정판 스니커즈, 한정 수량 이벤트에서 많이 쓰는 방식이다.
대기열과의 핵심 차이: 대기열은 “기다리면 결국 들어간다”이고, 토큰은 “토큰을 받아야만 들어갈 수 있다”이다. 트래픽을 두 단계로 분리해서 구매 서버의 부하를 극적으로 줄인다.
1. 토큰 방식의 구조
[Phase 1: 토큰 발급]
10,000명 → [토큰 발급 서버] → 200명에게 토큰 발급
→ 9,800명은 "발급 종료" 응답
[Phase 2: 구매]
200명 (토큰 보유) → [구매 서버] → 토큰 검증 → 재고 차감 → 주문 완료
두 서버를 분리하는 것이 핵심이다:
- 토큰 발급 서버: 전체 트래픽을 받는다 (가벼운 연산)
- 구매 서버: 토큰 보유자만 접근한다 (무거운 연산)
10,000명의 트래픽이 200명으로 줄어든다. 구매 서버는 여유롭게 처리할 수 있다.
2. 토큰 설계: JWT vs Opaque
2.1 JWT (JSON Web Token)
{
"sub": "user-12345",
"productId": 1,
"type": "PURCHASE_TOKEN",
"iat": 1711267200,
"exp": 1711267500
}
서명으로 위변조를 방지하고, 토큰 자체에 정보가 담겨 있어서 별도 저장소 조회 없이 검증할 수 있다.
2.2 Opaque Token
token: "a3f8b2c1-9d4e-4f5a-b6c7-8e9f0a1b2c3d"
랜덤 문자열이고, 검증할 때 Redis에서 조회해야 한다. 토큰 자체에는 정보가 없다.
2.3 비교
| 항목 | JWT | Opaque Token |
|---|---|---|
| 검증 방식 | 서명 검증 (서버 자체) | Redis 조회 |
| 네트워크 호출 | 불필요 | 필요 |
| 즉시 무효화 | 어려움 (만료까지 유효) | 쉬움 (Redis에서 삭제) |
| 토큰 크기 | 큼 (~300 bytes) | 작음 (~36 bytes) |
| 탈취 시 위험 | 만료까지 사용 가능 | 즉시 무효화 가능 |
선착순 시스템에서는 JWT + Redis 블랙리스트 조합이 실용적이다. JWT로 빠르게 검증하되, 사용 완료된 토큰은 Redis에 기록해서 재사용을 방지한다.
3. 토큰 발급 서비스
3.1 토큰 생성
@Service
@RequiredArgsConstructor
public class PurchaseTokenService {
private final RedissonClient redissonClient;
@Value("${jwt.secret}")
private String jwtSecret;
private static final long TOKEN_TTL_MINUTES = 5;
/**
* 토큰 발급
*/
public TokenIssueResult issueToken(Long productId, Long userId) {
String quotaKey = "token-quota:" + productId;
String issuedKey = "token-issued:" + productId;
// 1. 이미 발급받았는지 확인
RSet<String> issued = redissonClient.getSet(issuedKey);
if (issued.contains(userId.toString())) {
return TokenIssueResult.alreadyIssued();
}
// 2. 남은 토큰 수량 확인 + 차감 (Lua 스크립트로 원자적 처리)
Long remaining = executeQuotaScript(quotaKey, issuedKey, userId.toString());
if (remaining == null || remaining < 0) {
return TokenIssueResult.exhausted();
}
// 3. JWT 생성
String token = generateJwt(productId, userId);
return TokenIssueResult.success(token, TOKEN_TTL_MINUTES);
}
private String generateJwt(Long productId, Long userId) {
return Jwts.builder()
.setSubject(userId.toString())
.claim("productId", productId)
.claim("type", "PURCHASE_TOKEN")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis()
+ TOKEN_TTL_MINUTES * 60 * 1000))
.signWith(Keys.hmacShaKeyFor(jwtSecret.getBytes()),
SignatureAlgorithm.HS256)
.compact();
}
/**
* 토큰 수량 확인 + 차감 + 발급 기록을 원자적으로 처리
*/
private Long executeQuotaScript(String quotaKey, String issuedKey, String userId) {
RScript script = redissonClient.getScript();
String lua =
"if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then " +
" return -2 " +
"end " +
"local quota = tonumber(redis.call('GET', KEYS[1])) " +
"if quota == nil or quota <= 0 then " +
" return -1 " +
"end " +
"redis.call('DECR', KEYS[1]) " +
"redis.call('SADD', KEYS[2], ARGV[1]) " +
"return quota - 1";
return script.eval(
RScript.Mode.READ_WRITE, lua,
RScript.ReturnType.INTEGER,
List.of(quotaKey, issuedKey),
userId
);
}
}
5편의 Redis Lua 스크립트와 거의 동일한 구조다. 차이점은 재고가 아니라 “토큰 수량”을 차감한다는 것이다. 재고 200개라면 토큰도 200개 — 또는 여유분을 두고 250개를 발급할 수도 있다.
3.2 응답 모델
public record TokenIssueResult(
TokenIssueStatus status,
String token,
long expiresInMinutes
) {
public static TokenIssueResult success(String token, long minutes) {
return new TokenIssueResult(TokenIssueStatus.SUCCESS, token, minutes);
}
public static TokenIssueResult exhausted() {
return new TokenIssueResult(TokenIssueStatus.EXHAUSTED, null, 0);
}
public static TokenIssueResult alreadyIssued() {
return new TokenIssueResult(TokenIssueStatus.ALREADY_ISSUED, null, 0);
}
}
public enum TokenIssueStatus {
SUCCESS,
EXHAUSTED,
ALREADY_ISSUED
}
3.3 토큰 수량 초기화
public void initTokenQuota(Long productId, int quota) {
RAtomicLong quotaCounter = redissonClient
.getAtomicLong("token-quota:" + productId);
quotaCounter.set(quota);
quotaCounter.expire(Duration.ofHours(24));
}
토큰 수량 = 재고 수량 + α. 여유분(α)은 토큰을 받고 구매하지 않는 비율을 감안해서 설정한다. 보통 재고의 10~30%를 추가한다.
4. 토큰 검증과 구매
토큰 검증을 Service에서 수동으로 호출하면 컨트롤러마다 검증 코드가 반복된다. Spring Security Filter로 분리하면 구매 API 진입 전에 자동으로 검증이 수행되고, 서비스 코드는 비즈니스 로직에만 집중할 수 있다.
4.1 Spring Security 토큰 검증 필터
@Component
@RequiredArgsConstructor
public class PurchaseTokenAuthFilter extends OncePerRequestFilter {
private final RedissonClient redissonClient;
@Value("${jwt.secret}")
private String jwtSecret;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("X-Purchase-Token");
if (token == null) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "구매 토큰이 필요합니다");
return;
}
// 1. JWT 서명 + 만료 검증
Claims claims;
try {
claims = Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes()))
.build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "토큰이 만료되었습니다");
return;
} catch (JwtException e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않은 토큰입니다");
return;
}
// 2. Redis 1회 사용 보장
String tokenId = claims.getId();
RBucket<String> used = redissonClient.getBucket("token-used:" + tokenId);
boolean firstUse = used.setIfAbsent("1", Duration.ofMinutes(10));
if (!firstUse) {
response.sendError(HttpServletResponse.SC_CONFLICT, "이미 사용된 토큰입니다");
return;
}
// 3. 인증 정보를 SecurityContext에 저장
Long productId = claims.get("productId", Long.class);
Long userId = Long.parseLong(claims.getSubject());
PurchaseTokenAuth auth = new PurchaseTokenAuth(userId, productId);
SecurityContextHolder.getContext().setAuthentication(auth);
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
// 구매 API에만 필터 적용
return !request.getRequestURI().startsWith("/api/purchase");
}
}
4.2 인증 객체
public class PurchaseTokenAuth extends AbstractAuthenticationToken {
private final Long userId;
private final Long productId;
public PurchaseTokenAuth(Long userId, Long productId) {
super(List.of());
this.userId = userId;
this.productId = productId;
setAuthenticated(true);
}
public Long getUserId() { return userId; }
public Long getProductId() { return productId; }
@Override public Object getCredentials() { return null; }
@Override public Object getPrincipal() { return userId; }
}
4.3 Security 설정
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final PurchaseTokenAuthFilter purchaseTokenFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/tokens/**").permitAll() // 토큰 발급은 공개
.requestMatchers("/api/purchase/**").authenticated() // 구매는 토큰 필요
.anyRequest().permitAll()
)
.addFilterBefore(purchaseTokenFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
4.4 구매 서비스
필터에서 이미 토큰 검증이 완료되었으므로, 서비스는 비즈니스 로직에만 집중한다.
@Service
@RequiredArgsConstructor
public class TokenBasedOrderService {
private final RedisLuaStockService stockService;
private final OrderRepository orderRepository;
@Transactional
public OrderResult purchase(Long productId, Long userId) {
// 토큰 검증은 이미 Security Filter에서 완료됨
// → 여기에 도달했다면 유효한 토큰 보유자
// 1. Redis 재고 차감
PurchaseResult stockResult = stockService.tryPurchase(productId, userId);
if (stockResult != PurchaseResult.SUCCESS) {
return OrderResult.soldOut();
}
// 2. DB 주문 저장
Order order = Order.create(productId, userId, 1);
orderRepository.save(order);
return OrderResult.success(order.getId());
}
}
@RestController
@RequiredArgsConstructor
public class PurchaseController {
private final TokenBasedOrderService orderService;
@PostMapping("/api/purchase")
public ResponseEntity<OrderResult> purchase() {
PurchaseTokenAuth auth = (PurchaseTokenAuth)
SecurityContextHolder.getContext().getAuthentication();
OrderResult result = orderService.purchase(auth.getProductId(), auth.getUserId());
return ResponseEntity.ok(result);
}
}
왜 Spring Security Filter 방식이 나은가?
| 항목 | Service에서 수동 검증 | Security Filter |
|---|---|---|
| 검증 누락 위험 | 컨트롤러마다 verify() 호출 필요 → 실수 가능 | 필터가 자동 적용 → 누락 불가 |
| 관심사 분리 | 서비스가 토큰 검증 + 비즈니스 로직 혼합 | 검증은 필터, 서비스는 비즈니스만 |
| 테스트 | 서비스 테스트에 토큰 생성 로직 필요 | @WithMockUser 등으로 분리 테스트 가능 |
| 확장성 | 새 엔드포인트마다 검증 추가 | URL 패턴으로 일괄 적용 |
3단계 검증은 동일하다:
- JWT 서명 + 만료 검증 — 위변조, 만료된 토큰 차단
- Redis 블랙리스트 — 이미 사용된 토큰 차단 (
setIfAbsent로 원자적 1회 사용 보장) SecurityContext에 인증 정보 저장 — 이후 컨트롤러/서비스에서 바로 사용
5. 봇 방지
선착순 시스템의 가장 큰 적은 봇이다. 자동화 스크립트가 밀리초 단위로 토큰 발급 API를 호출하면, 실제 사용자는 기회조차 얻지 못한다.
5.1 Rate Limiting
Rate Limiting은 직접 구현하기보다 검증된 도구를 사용하는 것이 실무 표준이다.
| 계층 | 도구 | 설정 위치 | 특징 | 적합한 상황 |
|---|---|---|---|---|
| CDN/Edge | Cloudflare, AWS WAF | 도메인 DNS 설정 (Spring 코드 변경 없음) | 앱 서버 도달 전에 차단 | DDoS, 봇 대량 공격 |
| API Gateway | Spring Cloud Gateway, Kong | Gateway 서버 설정 | 라우팅 + Rate Limiting 통합 | MSA 환경, 서비스 앞단 |
| 애플리케이션 | Resilience4j @RateLimiter | Spring Boot 코드 + yml | 코드 레벨 선언적 제어 | 특정 API 단위 세밀한 제어 |
Cloudflare는 Spring에서 설정하는 것이 아니다. 도메인의 DNS를 Cloudflare 네임서버로 향하게 하면, 모든 트래픽이 Cloudflare를 먼저 거치고 나서 우리 서버에 도달한다. Rate Limiting 룰은 Cloudflare 대시보드에서 도메인 단위로 설정한다. Spring 코드에는 아무것도 추가할 필요 없다.
사용자 → DNS → Cloudflare (IP 기반 봇/DDoS 차단) → 우리 서버 (Spring Boot)
실무에서는 이들을 조합한다. Cloudflare가 IP 기반 대량 공격을 앱 서버 도달 전에 막고, 애플리케이션에서 사용자 단위의 세밀한 제어를 수행한다.
Resilience4j @RateLimiter
이 시리즈에서 이미 사용 중인 Resilience4j로 Rate Limiting도 처리할 수 있다.
# application.yml
resilience4j:
ratelimiter:
instances:
tokenIssue:
limitForPeriod: 3 # 주기당 허용 요청 수
limitRefreshPeriod: 10s # 주기 (10초마다 3회 리셋)
timeoutDuration: 0s # 대기 없이 즉시 거부
@RestController
@RequiredArgsConstructor
public class TokenController {
private final PurchaseTokenService tokenService;
@PostMapping("/api/tokens/issue")
@RateLimiter(name = "tokenIssue", fallbackMethod = "rateLimitFallback")
public ResponseEntity<?> issueToken(
@RequestParam Long productId,
@AuthenticationPrincipal UserDetails user) {
TokenIssueResult result = tokenService.issueToken(
productId, Long.parseLong(user.getUsername())
);
return ResponseEntity.ok(result);
}
private ResponseEntity<?> rateLimitFallback(Long productId,
UserDetails user, RequestNotPermitted ex) {
return ResponseEntity.status(429)
.body("요청이 너무 많습니다. 잠시 후 다시 시도해주세요.");
}
}
주의: Resilience4j
@RateLimiter는 기본적으로 인스턴스(JVM) 단위로 동작한다. Pod가 여러 개인 환경에서 사용자별 글로벌 Rate Limiting이 필요하면, Spring Cloud Gateway의RequestRateLimiter+ Redis 조합이나 Cloudflare 등 외부 도구를 사용해야 한다.
5.2 CAPTCHA 조합
토큰 발급 전에 CAPTCHA를 요구하면 봇을 효과적으로 차단할 수 있다.
[사용자] → [CAPTCHA 통과] → [토큰 발급 요청] → [토큰 발급]
[봇] → [CAPTCHA 실패] → 차단
@PostMapping("/api/tokens/issue")
public ResponseEntity<?> issueToken(
@RequestParam Long productId,
@RequestParam String captchaToken,
@AuthenticationPrincipal UserDetails user) {
// 1. CAPTCHA 검증
if (!captchaService.verify(captchaToken)) {
return ResponseEntity.badRequest().body("CAPTCHA 검증 실패");
}
// 2. Rate Limiting
// ...
// 3. 토큰 발급
// ...
}
5.3 다층 방어
| 계층 | 방어 수단 | 차단 대상 |
|---|---|---|
| 1 | Cloudflare / WAF | DDoS, IP 기반 대량 공격 |
| 2 | CAPTCHA | 자동화 스크립트 |
| 3 | Rate Limiting (Resilience4j) | 고속 반복 요청 |
| 4 | 중복 발급 방지 (Lua) | 1인 다중 토큰 |
| 5 | JWT 서명 | 토큰 위조 |
| 6 | 1회 사용 (Redis) | 토큰 재사용 |
6. 토큰 만료와 재발급
6.1 만료 정책
토큰 발급 → 5분 TTL → 미사용 시 자동 만료
→ 만료된 토큰 수량 → 다음 사용자에게 재발급 가능
/**
* 만료된 토큰 수량만큼 재발급 풀에 복귀
*/
@Scheduled(fixedRate = 30000) // 30초마다
public void reclaimExpiredTokens() {
for (Long productId : getActiveProductIds()) {
int expired = countExpiredTokens(productId);
if (expired > 0) {
RAtomicLong quota = redissonClient
.getAtomicLong("token-quota:" + productId);
quota.addAndGet(expired);
log.info("상품 {}: 만료 토큰 {}개 회수 → 재발급 가능", productId, expired);
}
}
}
6.2 토큰 수량 전략
| 전략 | 토큰 수량 | 특징 |
|---|---|---|
| 보수적 | 재고 = 토큰 | 정확하지만 미사용 토큰 낭비 |
| 여유분 | 재고 × 1.2 | 미사용 감안, 가장 보편적 |
| 공격적 | 재고 × 1.5 | 토큰 소진 빠름, 재발급 의존 |
실무에서는 재고 × 1.2 + 만료 회수 스케줄러 조합이 가장 안정적이다.
7. 대기열 vs 토큰: 언제 무엇을 쓸까?
| 항목 | 대기열 (6편) | 토큰 (이번 글) |
|---|---|---|
| 사용자 경험 | ”대기 중 (342번째)" | "토큰 발급 성공/실패” |
| 진입 방식 | 순서대로 진입 | 토큰 보유자만 진입 |
| 공정성 | 순서 보장 (선착순) | 먼저 발급받은 사람 (선착순) |
| 서버 분리 | 대기열 + 구매 | 토큰 발급 + 구매 |
| 봇 방지 | 대기열 진입 자체가 방어 | CAPTCHA + Rate Limiting 필요 |
| 적합한 상황 | 콘서트 예매 (순서 중요) | 한정판 판매 (속도 중요) |
| 구현 복잡도 | 높음 (폴링/웹소켓) | 중간 (JWT + Redis) |
실무에서는 둘을 조합하기도 한다:
- 대기열로 사용자를 순서대로 세운다
- 순서가 되면 토큰을 발급한다
- 토큰으로 구매한다
이 조합이 네이버 예매, 인터파크 티켓에서 사용하는 방식이다.
8. 전체 흐름
[1] 사용자 → CAPTCHA 통과
→ POST /api/tokens/issue?productId=1
→ Rate Limiting 통과
→ Lua: 토큰 수량 확인 + 차감 + 발급 기록
→ JWT 생성 → 응답: { token: "eyJ...", expiresIn: 300 }
[2] 사용자 → POST /api/orders (Authorization: Bearer eyJ...)
→ JWT 서명 검증 + 만료 확인
→ Redis: 이미 사용된 토큰? (setIfAbsent로 원자적 확인)
→ Redis Lua: 재고 차감
→ DB: 주문 저장
→ 응답: { orderId: 12345, status: "SUCCESS" }
[3] 스케줄러 → 30초마다 만료 토큰 회수 → 재발급 풀 복귀
정리
| 핵심 포인트 | 내용 |
|---|---|
| 토큰의 본질 | 트래픽을 두 단계로 분리 — 발급(가벼움)과 구매(무거움) |
| JWT + Redis | JWT로 빠른 검증, Redis 블랙리스트로 1회 사용 보장 |
| Lua 스크립트 | 수량 확인 + 차감 + 중복 방지를 원자적 처리 |
| 봇 방지 | CAPTCHA + Rate Limiting + 중복 방지의 다층 방어 |
| 만료 전략 | 5분 TTL + 스케줄러로 미사용 토큰 회수 |
| 대기열과의 조합 | 대기열 → 토큰 발급 → 구매의 3단계가 실무 표준 |
토큰 방식은 “누가 구매할 수 있는가”를 사전에 결정한다. 구매 서버 입장에서는 소수의 검증된 사용자만 오기 때문에 안정적으로 운영할 수 있다.
다음 글에서는 k6 부하 테스트로 전체 방식의 성능을 직접 비교한다. DB 락, Redis, 대기열, 토큰 — 동일 조건에서 누가 가장 빠르고, 어떤 상황에 어떤 방식이 적합한지 숫자로 확인한다.