이번 주 과제는 "블랙 프라이데이에 주문이 폭주하면 어떻게 할 것인가?"였다. 처음에는 단순히 Rate Limiting으로 요청을 거부하면 되지 않나 싶었는데, 과제를 읽으면서 생각이 바뀌었다.
Rate Limiting은 초과 트래픽에 429를 던지고 끝이다. "나중에 다시 시도하세요." 선착순 한정 판매 상황에서 이걸 받은 유저는 새로고침을 미친 듯이 누를 거다. 그럼 트래픽은 줄어들기는커녕 오히려 더 늘어난다. 이게 Thundering Herd 문제라는 걸 이번에 처음 제대로 이해했다.
반면 대기열은 "현재 23번째, 약 9초 후 입장"이라고 알려준다. 유저 입장에서 순번이 보이니까 새로고침할 이유가 없다. 거부 vs 줄 세우기 — 이 차이가 단순한 구현 차이가 아니라 유저 행동 자체를 바꾼다는 걸 체감했다.
대기열의 핵심 요구사항은 세 가지다: 순서 보장, 중복 방지, 원자적 꺼내기.
Redis Sorted Set은 이 세 가지를 전부 기본 명령어로 해결해준다.
ZADD NX — 이미 있는 멤버는 추가 안 함 (중복 방지)ZRANK — O(log N)으로 순번 조회ZPOPMIN — 가장 앞에 있는 N명을 원자적으로 꺼냄특히 ZPOPMIN이 인상적이었다. 처음에는 ZRANGE로 조회하고 ZREM으로 삭제하는 2단계로 생각했는데, 이렇게 하면 두 명령 사이에 다른 서버가 같은 유저를 또 꺼내는 Race Condition이 생긴다. ZPOPMIN은 Redis의 단일 스레드 모델 덕분에 이걸 한 방에 해결한다. 분산 락 같은 거 없이도.
Sorted Set의 score로 뭘 쓸지 고민했다. Redis INCR로 시퀀스를 만들면 절대 충돌이 없지만 추가 Redis 호출이 필요하고, UUID는 순서를 보장할 수 없다.
결국 System.currentTimeMillis()를 선택했다. 동일 밀리초에 두 유저가 진입하면 순서가 비결정적이지만, 그 확률이 실무에서 문제가 될 수준인가? 라고 생각하면 아니었다. 추가 인프라 호출 없이 자연스러운 FIFO를 달성할 수 있는 게 더 큰 장점이라고 판단했다.
스케줄러가 대기열에서 유저를 꺼내서 토큰을 발급하고, 유저는 그 토큰으로 주문 API에 진입한다. 여기까지는 자연스러운데, 문제는 토큰 검증과 삭제를 어디에 두느냐였다.
처음에는 OrderFacade에 QueueTokenService를 주입해서 주문 시작할 때 검증하고, 주문 끝나면 삭제하려고 했다. 근데 이렇게 하면 주문 로직이 대기열 토큰을 알아야 한다. 대기열이 없는 일반 주문 시나리오에서는? 토큰 검증 로직이 걸리적거린다.
결국 HandlerInterceptor를 선택했다. preHandle에서 토큰을 검증하고, afterCompletion에서 주문이 성공하면 토큰을 삭제한다. 이렇게 하면 OrderFacade.placeOrder()는 대기열 토큰의 존재를 전혀 모른다. HTTP 레이어에서 관문 역할을 하고, 비즈니스 로직은 순수하게 유지된다.
여기서 하나 더 고민한 게 있다. 토큰을 preHandle에서 삭제하면 안 되는 이유다. 만약 토큰을 검증하자마자 삭제해버리면, 주문 처리 중 에러가 나도 토큰은 이미 사라진 상태다. 유저는 재시도할 수 없다. 그래서 afterCompletion에서 status < 400 && ex == null일 때만 삭제하도록 했다. 실패하면 토큰이 살아있으니 TTL(5분) 안에 재시도할 수 있다.
"스케줄러가 한 번에 몇 명을 꺼낼 것인가?"를 처음에는 적당히 50명쯤 하려고 했다. 근데 과제에서 "배치 크기 산정 근거를 문서화하라"고 해서 진지하게 계산해봤다.
HikariCP 최대 풀: 10
평균 주문 처리 시간: ~200ms
3초 내 이론적 최대 처리: 10 × (3000 / 200) = 150건
이론적으로는 150건을 처리할 수 있지만, 주문 API 외에 조회 API도 커넥션을 써야 한다. 배치 크기를 150으로 잡으면 3초간 커넥션 풀을 독점하게 된다. 결국 풀 크기와 동일한 10건으로 잡았다. 보수적이지만, 다른 API가 굶지 않는다.
fixedDelay vs fixedRate도 처음에는 차이를 몰랐다. fixedRate는 이전 실행이 끝나지 않아도 다음 실행을 시작한다. 배치 처리가 3초 이상 걸리면 배치가 겹친다. fixedDelay는 이전 실행이 끝난 후 3초를 기다리니까 겹칠 일이 없다. 대기열 스케줄러에는 fixedDelay가 맞다.
처음에는 전부 Counter로 만들려고 했다. 근데 "지금 대기 중인 인원"을 Counter로 하면 안 된다. Counter는 단조 증가만 한다. 누군가 대기열에서 빠지면 줄어들어야 하는데 Counter는 줄어들 수 없다.
결국 이렇게 나눴다:
queue.enter.total → Counter (누적 진입 수, 절대 줄지 않음)queue.token.issued.total → Counter (누적 발급 수)queue.waiting.size → Gauge (현재 대기 인원, 줄었다 늘었다 함)"이 값이 줄어들 수 있는가?" — 이 한 줄짜리 판별 기준이 깔끔하게 나눠줬다.

이번에 Sorted Set, String(+TTL) 두 가지를 썼는데, 자료구조 선택이 끝나면 구현은 거의 자동으로 따라왔다. "대기열은 Sorted Set, 토큰은 String+TTL"이라는 결정 하나가 전체 아키텍처를 결정했다. 앞으로 Redis를 쓸 때 "어떤 자료구조가 이 문제에 맞는가?"를 먼저 고민하는 습관을 들여야겠다.
토큰 검증은 주문 로직이 아니라 HTTP 관문이다. 이걸 Interceptor로 빼면서 OrderFacade가 깔끔해졌다. 인증/인가, 로깅, 감사 같은 횡단 관심사도 같은 패턴으로 분리할 수 있겠다는 감이 생겼다. preHandle에서 검증, afterCompletion에서 후처리 — 이 라이프사이클을 이해한 게 이번 주 가장 큰 수확이다.
ZPOPMIN이 원자적이라서 분산 락이 필요 없다는 걸 머리로는 알겠는데, 동시성 테스트를 돌려보기 전까지는 확신이 없었다. CountDownLatch로 100개 스레드를 동시에 쏘고, 대기열에 정확히 100명이 들어있는 걸 확인했을 때 비로소 "아, 진짜 되는구나"라는 확신이 생겼다. 테스트 없이 "원자적이니까 괜찮겠지"라고 넘어가면 안 된다는 교훈.
배치 크기 10, TTL 300초, 스케줄러 간격 3초 — 이 숫자들을 "적당히" 정하지 않고 근거를 달아본 게 처음이었다. HikariCP 풀 크기에서 배치 크기를 도출하고, 주문 프로세스 소요 시간에서 TTL을 계산하는 과정이 실무에서도 그대로 쓸 수 있는 사고 방식이라고 느꼈다.
ZPOPMIN과 토큰 발급 사이의 원자성 간극을 해결하지 못한 게 아쉽다. 현재는 Java 루프 안에서 ZPOPMIN 후 SET+TTL을 순차 실행하는데, 그 사이에 서버가 죽으면 유저가 대기열에서 빠졌지만 토큰을 못 받는 상태가 된다. Lua Script로 두 연산을 원자화할 수 있지만, 이번에는 "간극이 < 1ms이고 실패 시 재진입하면 된다"는 판단으로 넘어갔다. 근거가 있긴 한데, 찝찝함이 남는다.
멀티 서버 환경에서 스케줄러 중복 실행 문제도 인지하고 있지만 해결하지 못했다. ZPOPMIN이 원자적이라 중복 처리는 없지만, 불필요한 Redis 호출이 서버 수만큼 발생한다. ShedLock 같은 도구로 단일 실행을 보장할 수 있는데, 현재 규모에서 그게 오버엔지니어링인지 기본 세팅인지 아직 판단이 서지 않는다.
사실 시간이 부족해서 도전하지 못한 부분들이 많다. 회사에서 SSE를 사용해봤음에도 불구하고 커넥션 풀이나 자원관리에 대해서 깊게 고민하지 못한 포인트도 있다고 생각한다. 그리고 Redis가 죽으면 지금은 서비스 전체가 멈춘다. Redis 장애 시 대기열을 우회해서 직접 주문을 받는 fallback을 구현해보고 싶다. "대기열이 없는 게 대기열이 죽은 것보다 낫다"는 판단이 맞는지도 고민해볼만한 포인트라고 생각한다. 마지막으로 대기 인원에 따라 토큰 TTL을 조절하면 어떨까? 라는 생각이 들었다. 대기 10명일 때와 10,000명일 때 같은 5분이면, 앞사람이 토큰을 안 쓰고 버틸 때 뒷사람에게 미치는 영향이 달라질 거라고 생각이 들어 추가로 고민해볼 포인트 같다.