유랑하는 나그네의 갱생 기록

だけど素敵な明日を願っている -HANABI, Mr.children-

Devlog/SpringBoot

대기열의 Thundering Herd를 2단계로 제어해보자 - 배치 크기 산정과 Jitter

Madirony 2026. 4. 3. 12:45
반응형

tl;dr - Redis 대기열에서 배치 단위로 토큰을 발급하면 Thundering Herd가 발생한다. 1단계로 HikariCP 역산 기반의 배치 크기 산정(Macro 제어)으로 동시 요청 규모를 시스템 용량 안에 가두고, 2단계로 Jitter + 적응형 폴링(Micro 제어)으로 배치 내 요청을 시간축에 흩뿌렸다. k6로 배치 크기별 부하를 측정한 결과를 함께 정리했다.

 

 

그들이 온다..


1. 대기열은 문제를 해결했지만, 새로운 문제를 만들었다..

커머스 시스템에 대기열을 도입한 이유는 단순합니다. 인기 상품 오픈 시 수천 명이 동시에 주문 API를 호출하면 DB 커넥션이 고갈되니까, 앞단에서 흐름을 제어하자는 것이었습니다.

 

Redis Sorted Set으로 대기열을 구현했습니다. ZADD NX로 진입 순서를 보장하고, 스케줄러가 100ms마다 N명씩 꺼내서 토큰을 발급합니다. 토큰을 받은 유저만 주문 API를 호출할 수 있습니다.

 

유저 → [Redis 대기열] → 스케줄러(N명/100ms) → 토큰 발급 → 주문 API

 

여기서 질문이 생깁니다. N을 몇으로 잡아야 하는가? 그리고 N명이 동시에 토큰을 받으면 동시에 주문을 시도하지 않는가?

 

이 두 질문에 대한 답이 각각 1단계 방어와 2단계 방어입니다.하지만 그 전에, 대기열 자체를 어떻게 구현했는지를 먼저 짚어야 합니다.

 

 


2. 왜 Redis Sorted Set인가

대기열에 필요한 연산은 네 가지입니다.

 

연산 설명 Redis 명령
진입 유저를 대기열 끝에 추가 (중복 방지) ZADD NX
순번 조회 내 앞에 몇 명이 있는지 ZRANK
배치 추출 앞에서 N명을 꺼내기 ZRANGE
제거 토큰 발급 후 대기열에서 빼기 ZREM

 

Redis List(LPUSH + RPOP)로도 큐는 만들 수 있습니다. 하지만 List에서 "내가 몇 번째인지"를 알려면 전체를 순회해야 합니다. LPOS는 O(N)이고, 수천 명이 대기 중이면 매 폴링마다 O(N) 연산을 쏘는 셈입니다.

 

Sorted Set은 ZRANK가 O(log N)입니다. score에 System.currentTimeMillis()를 넣으면 진입 순서가 자연스럽게 보장되고, ZADD NX의 NX 플래그로 같은 유저의 중복 진입을 원자적으로 차단합니다.

 

@Component
@RequiredArgsConstructor
public class RedisQueueService implements QueueService {

    private static final String QUEUE_KEY = "order:waiting-queue";
    private final StringRedisTemplate redisTemplateMaster;

    @Override
    public long enter(Long userId) {
        redisTemplateMaster.opsForZSet()
            .addIfAbsent(QUEUE_KEY, String.valueOf(userId), System.currentTimeMillis());
        return getRank(userId);
    }

    @Override
    public long getRank(Long userId) {
        Long rank = redisTemplateMaster.opsForZSet()
            .rank(QUEUE_KEY, String.valueOf(userId));
        if (rank == null) {
            throw new CoreException(ErrorType.QUEUE_NOT_FOUND);
        }
        return rank;  // 0-indexed, 비즈니스 레이어에서 +1
    }

    @Override
    public List<Long> peekBatch(int count) {
        Set<String> values = redisTemplateMaster.opsForZSet()
            .range(QUEUE_KEY, 0, count - 1);
        if (values == null) return List.of();
        return values.stream().map(Long::parseLong).toList();
    }

    @Override
    public void remove(Long userId) {
        redisTemplateMaster.opsForZSet()
            .remove(QUEUE_KEY, String.valueOf(userId));
    }
}

addIfAbsent는 Spring Data Redis가 ZADD NX로 변환합니다. 이미 대기열에 있는 유저가 새로고침으로 다시 진입해도, score(진입 시각)가 덮어써지지 않고 기존 순번이 유지됩니다.

 

동시성은 Redis가 해결한다

수천 명이 동시에 ZADD를 호출하면 순서가 꼬이지 않을까? Redis는 싱글 스레드로 명령을 순차 처리하니까, Application 레벨에서 락을 잡을 필요가 없었습니다. 이걸 100명 동시 진입 테스트로 검증했습니다.

@Test
void 동시에_여러_유저가_진입해도_모두_대기열에_정확히_등록된다() throws InterruptedException {
    int threadCount = 100;
    ExecutorService executor = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for (long userId = 1; userId <= threadCount; userId++) {
        long id = userId;
        executor.submit(() -> {
            try { queueService.enter(id); }
            finally { latch.countDown(); }
        });
    }

    latch.await();

    assertThat(queueService.getSize()).isEqualTo(threadCount);

    List<Long> finalRanks = new ArrayList<>();
    for (long userId = 1; userId <= threadCount; userId++) {
        finalRanks.add(queueService.getRank(userId));
    }
    // 100명 모두 고유한 rank를 가져야 한다
    assertThat(finalRanks.stream().distinct().count()).isEqualTo(threadCount);
}

100개 스레드가 동시에 enter()를 호출해도, 100명 전원이 고유한 rank를 받습니다. RDB로 구현했으면 SELECT FOR UPDATE나 분산 락이 필요했을 부분입니다.

 

 


3. 1단계 방어(Macro) - 배치 크기를 시스템 용량에서 역산하기

배치 크기를 아무 값이나 쓸 수는 없습니다. 배치가 너무 크면 대기열이 의미가 없고, 너무 작으면 처리량이 부족합니다. 우리 시스템의 병목은 DB 커넥션이므로 거기서 역산했습니다.

 

HikariCP 최대 커넥션: 40개
주문 1건 평균 처리 시간: 200ms (SELECT FOR UPDATE × N + INSERT)
이론적 최대 TPS: 40 / 0.2 = 200 TPS
안전 마진 70% 적용: 140 TPS
스케줄러 주기: 100ms → 100ms당 처리 가능 인원 = 140 × 0.1 = 14명

HikariCP 40개 커넥션이 주문 1건에 200ms를 쓴다면 초당 200건을 처리할 수 있습니다. 70% 마진을 적용해서 140 TPS. 스케줄러가 100ms마다 도는데, 100ms 동안 들어올 수 있는 주문이 14건이니까 배치 크기는 14입니다.

 

이 숫자는 QueueAppService에서 TPS 계산에도 사용됩니다.

 

@Value("${queue.scheduler.batch-size:14}")
private int batchSize;

@Value("${queue.scheduler.fixed-rate:100}")
private long fixedRateMs;

private long tps() {
    return batchSize * (1000L / fixedRateMs);  // 14 * 10 = 140 TPS
}

대기열 순번에서 예상 대기 시간을 계산할 때 이 TPS가 기준이 됩니다. 700번째 유저라면 ceil(700 / 140) = 5초 대기입니다.

 

k6로 증명해보자.. 배치 크기가 잘못되면 대기열은 무의미하다

"배치 크기가 그렇게 중요한가?"를 확인하기 위해 배치 크기를 14, 500, 1000으로 바꿔가며 k6로 부하 테스트를 돌렸습니다.

 

테스트 A: 500 VU 스루풋 테스트

500명이 동시에 대기열에 진입한 후 주문까지 완료하는 전체 흐름입니다.

 

메트릭 batch=14 batch=500 batch=1000
http_req_duration p(95) 11.62s 6.4s 12.79s
token_wait p(95) 11.39s 6.46s 12.11s
주문 성공률 100% 99.8% 99.8%
iteration_duration avg 24.59s 14.45s 28.17s

 

batch=14가 가장 느립니다. 500명을 14명씩 나눠서 처리하니까 뒤쪽 유저가 오래 기다립니다. 이건 느린 게 아니라 대기열이 설계대로 동작하는 겁니다. 대기열의 존재 이유가 "한 번에 다 처리하지 않겠다"니까요.

 

batch=500은 사실상 대기열을 안 쓴 것과 같습니다. 500명 전원에게 한 번에 토큰을 발급하니까 빠르지만, 그만큼 DB에 동시 부하가 걸립니다. 로컬 테스트에서는 HikariCP 40개가 500건을 감당했지만, 프로덕션에서 동시 접속자가 수천 명이면 커넥션 풀이 터지는 직접적 원인이 됩니다.

 

batch=1000은 가장 느립니다. 500명밖에 안 되는데 배치를 1000으로 잡으면, 배치 처리 자체의 내부 오버헤드(ZRANGE로 1000건 조회 시도, 반복문 순회, ScheduledExecutorService에 1000건 스케줄링)가 커집니다. 배치 크기를 키운다고 성능이 좋아지는 게 아닙니다.

 

 

[잠깐!!] DB 커넥션은 40개인데, 어떻게 500명이 한 번에 몰린 batch=500이 안 터지고 제일 빠를까요?

지표만 보면 500명 전원에게 한 번에 토큰을 발급한 batch=500의 처리 시간이 가장 짧습니다. 하지만 이건 '로컬 환경의 압도적인 속도'와 'HikariCP의 대기열(Wait Queue)'이 만들어낸 성능 테스트의 함정입니다.

 

HikariCP 대기열? : 40명은 즉시 커넥션을 잡지만, 나머지 460명은 에러를 뱉는 것이 아니라 HikariCP 내부 대기열에서 줄을 섭니다. (기본 타임아웃 30초)

 

로컬 DB의 스펙 착시: 로컬 환경은 네트워크 지연(Latency)이 0입니다. 앞사람이 쿼리를 수 밀리초 만에 끝내고 반납하므로, 500명 전원이 30초 안에 커넥션을 물려받아 정상 처리된 것입니다. DB가 1밀리초도 쉬지 않고 풀가동했기에 전체 처리 시간은 짧게 나옵니다.

 

프로덕션(Prod) 환경에서는...?! : 하지만 실무에서 500명이 아니라 수만 명이 몰리고, 실제 네트워크 지연과 동일한 재고(Row)를 향한 SELECT FOR UPDATE 락(Lock) 경합이 발생한다면 어떨까요? 30초 안에 커넥션을 받지 못하는 유저가 속출하며 연쇄적인 ConnectionTimeoutException과 함께 DB 서버가 완전히 뻗어버립니다.

 

대기열의 목적은 "트래픽을 빨리 쳐내는 것"이 아니라, "어떤 스파이크성 트래픽이 몰려와도 DB 서버가 락 경합으로 터지지 않고 일정한 속도로 살아남게(Safety) 하는 것"입니다. HikariCP 역산을 통해 굳이 느릿느릿한 batch=14를 선택한 이유가 여기에 있습니다.

 

 

테스트 B: 140 VU Thundering Herd 테스트

140명이 동시에 진입해서 주문하는 시나리오입니다. 배치 크기에 따라 동시 주문 규모가 달라집니다.

 

메트릭 batch=14 batch=500 batch=1000
order_latency p(95) 2.98s 1.47s 3.48s
http_req_duration p(95) 3.69s 1.65s 5.42s
threshold 위반 NO NO YES

 

batch=500에서 140명이 빠른 건, 로컬 HikariCP 40개 커넥션이 140명을 감당할 수 있기 때문입니다. batch=14는 14명씩 쪼개서 처리하니까 느리지만 threshold 안에 들어옵니다. batch=1000은 140명밖에 안 되는데도 threshold를 위반했습니다.

 

1단계 방어의 결론: 배치 크기는 "충분히 크게"가 아니라 "시스템이 안전하게 소화할 수 있는 동시 처리량"에 맞춰야 합니다. HikariCP 역산으로 14를 도출한 근거가 여기서 확인됩니다.

 

 


4. 2단계 방어(Micro) - 배치 내 14명을 Jitter로 흩뿌리기

1단계에서 배치 크기를 14로 제한했으니 큰 불은 꺼졌습니다. 하지만 14명이 정확히 같은 밀리초에 토큰을 받으면, 그 14명이 다음 폴링에서 동시에 주문 API를 호출합니다.

 

// Jitter 없는 스케줄러 — 14개 토큰이 동시에 발급됨
@Scheduled(fixedRateString = "${queue.scheduler.fixed-rate:100}")
public void process() {
    List<Long> users = queueService.peekBatch(batchSize);
    users.forEach(userId -> {
        queueService.remove(userId);
        tokenService.issue(userId);  // ← 14개 동시 발급
    });
}

14건이 한꺼번에 오면 HikariCP 커넥션 40개 중 14개를 순간적으로 잡아먹습니다. 다른 API 요청까지 영향을 받을 수 있는 미세한 Thundering Herd입니다. 대기열 없이 1000명이 동시에 오는 것과는 규모가 다르지만, 100ms 주기로 반복되면 응답시간이 들쭉날쭉해집니다.

 

Jitter 적용

해결 아이디어는 간단합니다. 14명에게 토큰을 동시에 발급하지 말고, 0~100ms 사이의 랜덤한 시점에 발급하는 겁니다. 실제 스케줄러 코드입니다.

@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "queue.scheduler.enabled", havingValue = "true", matchIfMissing = false)
public class QueueScheduler {

    private final QueueService queueService;
    private final TokenService tokenService;

    @Value("${queue.scheduler.batch-size:14}")
    private int batchSize;

    @Value("${queue.scheduler.fixed-rate:100}")
    private long fixedRate;

    private final ScheduledExecutorService jitterExecutor = Executors.newScheduledThreadPool(4);

    @PreDestroy
    public void destroy() {
        jitterExecutor.shutdown();
    }

    @Scheduled(fixedRateString = "${queue.scheduler.fixed-rate:100}")
    public void process() {
        List<Long> users = queueService.peekBatch(batchSize);
        if (users.isEmpty()) {
            return;
        }
        users.forEach(userId -> {
            queueService.remove(userId);           // 즉시 큐에서 제거
            if (tokenService.validate(userId)) {
                return;                            // 이미 토큰 있음
            }
            long jitterMs = ThreadLocalRandom.current().nextLong(0, fixedRate);
            jitterExecutor.schedule(
                () -> {
                    tokenService.issue(userId);
                    log.debug("입장 토큰 발급 (jitter={}ms): userId={}", jitterMs, userId);
                },
                jitterMs, TimeUnit.MILLISECONDS
            );
        });
    }
}

 

짚어야 할 설계 결정이 몇 가지 있습니다.

 

@ConditionalOnProperty로 스케줄러 ON/OFF. 테스트 프로필에서 스케줄러가 돌면 Redis 상태가 예측 불가능하게 바뀌어서 테스트가 깨집니다. queue.scheduler.enabled=false로 테스트 시 비활성화하고, 부하 테스트나 로컬 개발 시에만 켭니다.

 

@PreDestroy로 jitterExecutor 종료. ScheduledExecutorService는 non-daemon 스레드풀이니까, 앱 종료 시 명시적으로 shutdown()을 호출해야 합니다. 안 그러면 JVM이 종료되지 않거나, 종료 중에 토큰 발급이 실행될 수 있습니다.

 

tokenService.validate() 선행 체크. 중복 진입(새로고침)으로 같은 유저가 큐에 다시 들어올 수 있습니다. 이미 토큰이 있는 유저에게 토큰을 다시 발급하면 TTL이 리셋되니까, 발급 전에 존재 여부를 확인합니다.

 

왜 Thread.sleep()이 아닌 ScheduledExecutorService인가

방식 문제
Thread.sleep(jitterMs) @Scheduled 스레드를 블로킹 → 다음 스케줄 주기(100ms)가 밀림
CompletableFuture.delayedExecutor() 동작은 하지만 내부적으로 ForkJoinPool 사용, 스레드 풀 크기 제어 불가
ScheduledExecutorService 논블로킹 + 스레드 풀 크기 명시(4개) + @PreDestroy로 라이프사이클 관리

@Scheduled 메서드는 즉시 리턴하고, 실제 토큰 발급은 4개 스레드 풀에서 비동기로 처리됩니다. 스케줄러 주기(100ms)에 영향을 주지 않습니다.

스레드 풀을 4개로 잡은 이유는, 배치 14건이 0~100ms 구간에 분산되므로 동시에 실행되는 최대 건수가 2~3건 수준이기 때문입니다. 여유를 두고 4개면 충분합니다.

 

Jitter의 수학

14명이 0~100ms 구간에 균등 분포(Uniform Distribution)로 퍼지면, 평균 간격은 100ms / 14 ≈ 7.1ms입니다. 동시 요청 14건이 7ms 간격의 개별 요청으로 변환되는 셈입니다.

물론 균등 분포니까 운이 나쁘면 2~3건이 같은 타이밍에 겹칠 수 있습니다. 하지만 14건이 한꺼번에 오는 것과 2~3건이 겹치는 건 HikariCP 입장에서 체감이 다릅니다.

Jitter 범위를 fixedRate(100ms)와 동일하게 잡은 건, 다음 배치와 겹치지 않게 하기 위해서입니다. Jitter 범위가 100ms보다 크면 이전 배치의 지연된 토큰 발급이 다음 배치의 토큰 발급과 시간적으로 겹쳐서, 의도하지 않은 동시성이 생깁니다.

 

 


5. 토큰의 생명주기 - 발급, 검증, 만료

토큰은 대기열과 주문 API 사이의 게이트 역할입니다. 구현은 Redis SET + TTL로 단순합니다.

@Component
@RequiredArgsConstructor
public class RedisTokenService implements TokenService {

    private static final String TOKEN_KEY_PREFIX = "entry-token:";

    @Value("${queue.token.ttl-seconds:300}")
    private long tokenTtlSeconds;

    private final StringRedisTemplate redisTemplateMaster;

    @Override
    public void issue(Long userId) {
        redisTemplateMaster.opsForValue()
            .set(TOKEN_KEY_PREFIX + userId, "1", Duration.ofSeconds(tokenTtlSeconds));
    }

    @Override
    public boolean validate(Long userId) {
        return Boolean.TRUE.equals(redisTemplateMaster.hasKey(TOKEN_KEY_PREFIX + userId));
    }

    @Override
    public void delete(Long userId) {
        redisTemplateMaster.delete(TOKEN_KEY_PREFIX + userId);
    }
}

토큰 값은 "1"입니다. JWT나 UUID가 아니라 존재 여부만 확인하면 되니까, 값은 무엇이든 상관없습니다. hasKey로 존재 여부만 O(1)에 판단합니다.

 

TTL을 300초(5분)로 잡은 이유는, 유저가 토큰을 받고 주문을 완료하기까지 충분한 시간을 주되, 토큰을 받고 이탈한 유저의 슬롯이 영원히 점유되지 않게 하기 위해서입니다.

 

Interceptor로 토큰 검증하기

토큰 검증은 주문 Controller가 아니라 Interceptor에서 처리합니다.

@Component
@RequiredArgsConstructor
public class EntryTokenInterceptor implements HandlerInterceptor {

    private final TokenService tokenService;
    private final ObjectMapper objectMapper;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws Exception {
        if (!HttpMethod.POST.matches(request.getMethod())) {
            return true;  // GET 요청은 통과 (주문 조회 등)
        }
        Member member = (Member) request.getAttribute("authenticatedMember");
        if (member == null || !tokenService.validate(member.getId())) {
            sendUnauthorized(response);
            return false;
        }
        return true;
    }
}

왜 Controller 안에서 검증하지 않고 Interceptor로 뺐느냐?

 

주문 API가 /api/v1/orders/api/v1/orders/direct 두 개입니다. 두 Controller 메서드에 같은 토큰 검증 로직을 복붙하면 되지만, 나중에 주문 경로가 추가될 때 빼먹을 수 있습니다. WebConfig에서 경로 패턴으로 일괄 등록하면 누락을 구조적으로 방지할 수 있습니다.

 

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(entryTokenInterceptor)
            .addPathPatterns("/api/v1/orders", "/api/v1/orders/direct");
}

POST 요청만 검증하는 이유는, 주문 목록 조회(GET)까지 토큰을 요구하면 대기열을 통과하지 않은 유저가 자기 주문 내역도 못 보기 때문입니다.

 

적응형 폴링 - 클라이언트도 분산시키기

서버 쪽 Jitter만으로는 절반만 해결한 겁니다. 클라이언트가 200ms 고정 간격으로 폴링하면, 토큰 발급 시점을 분산시켜도 폴링 타이밍이 비슷한 유저들이 동시에 "토큰 발급됨"을 확인하고 동시에 주문 API를 호출합니다.

 

서버가 클라이언트에게 "다음에 언제 물어봐라"를 알려주는 적응형 폴링을 추가했습니다.

public QueuePositionResult getPosition(Long userId) {
    if (tokenService.validate(userId)) {
        return new QueuePositionResult(0, 0, true, 0L);
    }
    try {
        long rank = queueService.getRank(userId);
        long position = rank + 1;
        long estimatedWaitSeconds = (long) Math.ceil((double) rank / tps());
        long nextPollIntervalMs = calcNextPollInterval(position, estimatedWaitSeconds);
        return new QueuePositionResult(position, estimatedWaitSeconds, false, nextPollIntervalMs);
    } catch (CoreException e) {
        if (e.getErrorType() == ErrorType.QUEUE_NOT_FOUND) {
            return new QueuePositionResult(0, 0, false, 500L);
        }
        throw e;
    }
}

private long calcNextPollInterval(long position, long estimatedWaitSeconds) {
    if (position <= batchSize) {
        return 500L;  // 곧 처리될 유저: 500ms마다 폴링
    }
    return Math.min(estimatedWaitSeconds * 500L, 5000L);  // 뒤에 있는 유저: 최대 5초
}

대기열 앞쪽 14명 이내의 유저는 500ms마다 폴링합니다. 곧 토큰이 발급될 테니 빠르게 확인해야 합니다. 뒤쪽 유저는 대기 예상 시간에 비례해서 폴링 간격을 늘립니다. 100번째 유저가 200ms마다 폴링할 이유가 없으니까요.

 

QUEUE_NOT_FOUND 예외 처리가 눈에 띕니다. 스케줄러가 유저를 큐에서 ZREM으로 제거한 뒤 Jitter 딜레이를 거쳐 토큰을 발급하는데, 그 사이에 유저가 폴링하면 큐에도 없고 토큰도 없는 상태입니다. 이때 에러를 던지는 대신 500ms 뒤에 다시 확인하라고 안내합니다.

 

고정 폴링(200ms)으로 500명이 대기 중이면 초당 폴링 요청만 2,500건입니다. 적응형 폴링에서는 앞쪽 14명만 500ms, 나머지는 1~5초 간격이니 폴링 부하가 크게 줄어듭니다.

// k6 클라이언트: 서버가 알려준 간격으로 폴링
const nextInterval = (posRes.status === 200 && posRes.json('data.nextPollIntervalMs'))
  ? posRes.json('data.nextPollIntervalMs') / 1000
  : TOKEN_POLL_INTERVAL;
sleep(nextInterval);

 

 


6. At-most-once 트레이드오프 - ZREM 먼저, 토큰 나중에

스케줄러 코드를 자세히 보면 순서가 눈에 띕니다.

queueService.remove(userId);      // 1. 큐에서 제거 (ZREM)
// ... jitter delay ...
tokenService.issue(userId);       // 2. 토큰 발급 (SET + TTL)

ZREM과 토큰 발급 사이에 서버가 죽으면? 유저는 큐에서 빠졌는데 토큰은 못 받습니다. At-most-once입니다.

 

대안은 세 가지였습니다.

방식 장점 단점
A. Lua 스크립트 ZREM + SET 원자적 실행 Jitter 불가 (Lua 안에서 sleep 못 함)
B. 토큰 먼저 → ZREM 나중 토큰 유실 없음 토큰 + 큐 양쪽에 존재하는 구간 발생, 배치 추출 시 이미 토큰 받은 유저가 다시 잡힘
C. ZREM 먼저 → 토큰 나중 간단함, Jitter 호환 장애 시 토큰 유실 가능

 

B의 문제를 좀 더 풀어보면, 토큰을 먼저 발급하고 ZREM을 나중에 하면 다음 스케줄러 주기에서 peekBatch가 이미 토큰을 받은 유저를 또 꺼냅니다. tokenService.validate() 체크로 중복 발급은 막을 수 있지만, 배치 14건 중 일부가 이미 처리된 유저로 채워지면 실질적 처리량이 줄어듭니다.

 

C를 선택한 이유는 Jitter 때문입니다. Lua 스크립트 안에서 랜덤 딜레이를 줄 수 없고, B는 정합성 관리가 복잡해집니다. 토큰을 못 받은 유저는 다시 대기열에 진입하면 복구할 수 있으니, At-most-once를 허용하는 게 전체 설계를 단순하게 만듭니다.

 

스케줄러에서 tokenService.validate() 선행 체크가 여기서도 역할을 합니다. 유저가 재진입했을 때 이미 발급된 토큰이 있으면, ZREM만 하고 토큰은 건드리지 않습니다. TTL이 리셋되는 것도 방지하고, 불필요한 Redis SET 연산도 아낍니다.

 

 


7. P.S.

클라이언트가 nextPollIntervalMs를 안 따르면?

서버가 "5초 후에 다시 물어봐"라고 했는데 클라이언트가 100ms마다 폴링하면? 현재는 강제 수단이 없습니다. Rate Limiting을 걸 수도 있지만, 대기열에서 정보를 받아야 하는 유저한테 429를 던지는 건 UX가 나빠집니다. 지금은 "협조적 프로토콜"로 두고 있고, 악의적 클라이언트가 문제가 되면 그때 Rate Limiting을 추가할 계획입니다.

 

score에 System.currentTimeMillis()를 쓴 이유

시퀀스 번호(AtomicLong)를 score로 쓰면 더 깔끔해 보이지만, 멀티 인스턴스 환경에서 시퀀스 동기화가 필요합니다. System.currentTimeMillis()는 정밀도가 밀리초라서 동시 진입 시 같은 score가 나올 수 있지만, Redis Sorted Set은 score가 같으면 member를 사전순으로 정렬하므로 순서가 보장됩니다. 나노초 정밀도가 필요한 수준의 트래픽은 아직 아니고, 밀리초 정밀도에서 사전순 폴백이면 충분합니다.

 

Polling vs SSE

"왜 WebSocket이나 SSE로 푸시하지 않느냐"는 질문이 있을 수 있습니다. SSE를 쓰면 서버가 토큰 발급 시점에 클라이언트에게 즉시 알려줄 수 있으니 폴링 부하 자체가 없어집니다. 하지만 동시 접속 수천 명이 SSE 커넥션을 열면 서버의 커넥션 자원이 대기열 유지에 소모됩니다. 대기열을 도입한 이유가 서버 자원을 아끼기 위해서인데, 대기열 때문에 커넥션이 점유되면 본말이 전도됩니다. 적응형 폴링은 절충입니다. 실시간성은 SSE보다 떨어지지만, 커넥션을 점유하지 않으면서 폴링 빈도를 합리적으로 줄입니다.

반응형
TOP