토큰 발급 방식으로 선착순 시스템 구현하기: 입장권부터 봇 방지까지

토큰 발급 방식으로 선착순 시스템 구현하기: 입장권부터 봇 방지까지


서론

이전 글에서 대기열로 트래픽을 순서대로 흡수하는 방법을 다뤘다. 대기열은 사용자 경험을 크게 개선하지만, 모든 사용자가 결국 구매 페이지에 도달한다는 전제가 있다.

토큰 방식은 다르다. 입장 토큰을 먼저 발급하고, 토큰을 가진 사람만 구매할 수 있다. 콘서트 티켓팅, 한정판 스니커즈, 한정 수량 이벤트에서 많이 쓰는 방식이다.

대기열과의 핵심 차이: 대기열은 “기다리면 결국 들어간다”이고, 토큰은 “토큰을 받아야만 들어갈 수 있다”이다. 트래픽을 두 단계로 분리해서 구매 서버의 부하를 극적으로 줄인다.


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 비교

항목JWTOpaque 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단계 검증은 동일하다:

  1. JWT 서명 + 만료 검증 — 위변조, 만료된 토큰 차단
  2. Redis 블랙리스트 — 이미 사용된 토큰 차단 (setIfAbsent로 원자적 1회 사용 보장)
  3. SecurityContext에 인증 정보 저장 — 이후 컨트롤러/서비스에서 바로 사용

5. 봇 방지

선착순 시스템의 가장 큰 적은 이다. 자동화 스크립트가 밀리초 단위로 토큰 발급 API를 호출하면, 실제 사용자는 기회조차 얻지 못한다.

5.1 Rate Limiting

Rate Limiting은 직접 구현하기보다 검증된 도구를 사용하는 것이 실무 표준이다.

계층도구설정 위치특징적합한 상황
CDN/EdgeCloudflare, AWS WAF도메인 DNS 설정 (Spring 코드 변경 없음)앱 서버 도달 전에 차단DDoS, 봇 대량 공격
API GatewaySpring Cloud Gateway, KongGateway 서버 설정라우팅 + Rate Limiting 통합MSA 환경, 서비스 앞단
애플리케이션Resilience4j @RateLimiterSpring 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 다층 방어

계층방어 수단차단 대상
1Cloudflare / WAFDDoS, IP 기반 대량 공격
2CAPTCHA자동화 스크립트
3Rate Limiting (Resilience4j)고속 반복 요청
4중복 발급 방지 (Lua)1인 다중 토큰
5JWT 서명토큰 위조
61회 사용 (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)

실무에서는 둘을 조합하기도 한다:

  1. 대기열로 사용자를 순서대로 세운다
  2. 순서가 되면 토큰을 발급한다
  3. 토큰으로 구매한다

이 조합이 네이버 예매, 인터파크 티켓에서 사용하는 방식이다.


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 + RedisJWT로 빠른 검증, Redis 블랙리스트로 1회 사용 보장
Lua 스크립트수량 확인 + 차감 + 중복 방지를 원자적 처리
봇 방지CAPTCHA + Rate Limiting + 중복 방지의 다층 방어
만료 전략5분 TTL + 스케줄러로 미사용 토큰 회수
대기열과의 조합대기열 → 토큰 발급 → 구매의 3단계가 실무 표준

토큰 방식은 “누가 구매할 수 있는가”를 사전에 결정한다. 구매 서버 입장에서는 소수의 검증된 사용자만 오기 때문에 안정적으로 운영할 수 있다.

다음 글에서는 k6 부하 테스트로 전체 방식의 성능을 직접 비교한다. DB 락, Redis, 대기열, 토큰 — 동일 조건에서 누가 가장 빠르고, 어떤 상황에 어떤 방식이 적합한지 숫자로 확인한다.

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.