카테고리 없음

Redis 대기열로 블랙 프라이데이 살아남기 — 초당 1만 요청을 175 TPS로 길들인 과정

희담강 2026. 4. 3. 07:25
TL;DR 시스템이 감당할 수 있는 속도만큼만 유저를 흘려보내는 back-pressure를 Redis Sorted Set 기반 대기열로 구현하고, 지난주 Kafka 이벤트 파이프라인과 연결해 "상류 제어 → 하류 보호"라는 전체 구조를 완성한 과정을 정리한다.

 

 

지난주에 이벤트 기반 아키텍처를 구축했다. ApplicationEvent로 프로세스 내 경계를 나누고, Kafka로 프로세스 간 파이프라인을 만들고, 선착순 쿠폰 발급까지 구현했다. 그런데 한 가지 시나리오가 빠져 있었다. "블랙 프라이데이에 주문 요청이 100배로 폭증하면 어떻게 되는가?"

 

평소 초당 100건이던 주문 요청이 초당 10,000건으로 늘어나면, DB 커넥션 풀이 고갈되고, 응답 지연이 타임아웃으로 이어지고, 유저가 새로고침을 누르면서 트래픽이 더 증가하는 악순환이 시작된다. 서버를 10배 늘려도 DB와 PG(결제 대행사)는 스케일이 제한적이고, 오토스케일링이 반응하기 전에 피크가 지나간다.

 

이번 과제에서는 이 문제를 Redis 기반 대기열로 해결했다. 시스템이 처리할 수 있는 속도만큼만 유저를 흘려보내는 back-pressure를 구현하되, 유저에게는 "거부"가 아닌 "대기"를 제공해서 이탈을 막는 구조다.


1. 거부할 것인가, 기다리게 할 것인가

트래픽이 몰릴 때 선택지는 크게 두 가지다.

Rate Limiting은 초과 요청을 거부한다. "나중에 다시 시도하세요"라는 429 응답을 돌려준다. API 보호나 봇 차단에는 적합하지만, 블랙 프라이데이에 이걸 반환하면 유저는 떠나거나 더 세게 새로고침한다. 거부가 재시도를 유발하고, 재시도가 트래픽을 증가시키는 악순환이 생긴다.

**대기열(Queuing)**은 초과 요청을 보관한다. "잠시만 기다려주세요, 현재 512번째입니다"라는 응답을 돌려준다. 유저가 기다릴 의사가 있는 시나리오 — 블랙 프라이데이 한정 세일, 콘서트 티켓팅 같은 상황 — 에 적합하다.

이번 과제에서는 대기열을 선택했다. 핵심 판단 기준은 "유저가 원하는 것을 기다려서라도 얻을 수 있는가"이다. 블랙 프라이데이 할인 상품은 유저가 기다릴 의사가 충분하다. 거부하면 매출을 잃지만, 기다리게 하면 매출을 지킨다.


2. 대기열 자료구조 선택 — 왜 Redis Sorted Set인가

대기열을 어디에 구현할 것인가부터 결정해야 한다. 후보는 네 가지였다.

Redis List는 단순 FIFO 큐로 LPUSH/RPOP이 직관적이지만, "내가 지금 몇 번째인지" 조회하는 LPOS가 O(N)이다. 대기 인원이 1만 명이면 순번 조회 한 번에 1만 개를 훑어야 한다. 게다가 중복 진입 방지가 안 돼서 같은 유저가 여러 번 들어갈 수 있다.

Kafka 토픽은 지난주 선착순 쿠폰에서 버퍼로 활용했지만, "현재 내 순번이 몇 번인지" 조회가 구조적으로 불가능하다. Consumer offset은 순번이 아니고, 토픽에 들어간 메시지의 위치를 실시간으로 알려주는 API가 없다.

DB 테이블은 구현 가능하지만, 1만 명이 2초마다 순번을 polling하면 초당 5천 건의 SELECT가 DB에 쏟아진다. 대기열을 만든 이유가 DB 보호인데, 순번 조회로 DB 부하를 추가하면 본말전도다.

Redis Sorted Set을 선택한 이유는 세 가지다. 첫째, ZRANK가 O(log N)이라 10만 명이 대기 중이어도 순번 조회가 빠르다. 둘째, Set 특성상 동일 member가 자동으로 중복 방지된다. 셋째, ZPOPMIN으로 "가장 앞에 있는 N명을 꺼내기"가 원자적으로 처리된다.

단점은 Redis 장애 시 대기열 전체가 날아간다는 것이다. DB는 디스크에 남지만 Redis는 인메모리라 영속성이 약하다. 이건 나중에 Redis AOF/RDB 영속화 설정이나 Graceful Degradation 전략으로 대응해야 할 부분이다.


3. ZADD NX — 중복 진입 방지의 함정

Sorted Set에 유저를 추가할 때 ZADD waiting-queue {timestamp} {userId} 를 사용한다. score가 진입 시각이고, member가 userId다. 먼저 들어온 사람이 작은 score를 가지므로 앞 순번이 된다.

여기서 함정이 하나 있다. ZADD의 기본 동작은 "member가 이미 존재하면 score를 갱신"한다. 즉, 유저가 대기열에 이미 있는 상태에서 다시 진입 요청을 보내면, member(userId)는 중복 추가가 안 되지만 score(timestamp)가 현재 시각으로 바뀐다. 원래 100번째였던 유저가 재진입하면 score가 뒤로 밀려서 3000번째가 될 수 있다.

이걸 방지하려면 ZADD NX 옵션을 사용해야 한다. NX는 "member가 없을 때만 추가"하고, 이미 존재하면 아무것도 하지 않는다. score가 갱신되지 않으므로 순번이 유지된다.

대안으로 ZSCORE로 먼저 존재 여부를 확인한 뒤 조건부로 ZADD하는 방식도 있었지만, 조회와 삽입이 분리돼 있어서 두 요청이 동시에 "없음"을 확인하고 둘 다 ZADD를 시도하는 race condition이 발생할 수 있다. ZADD NX는 Redis 내부에서 원자적으로 처리되므로 이 문제가 없다.


4. 스케줄러 설계 — Thundering Herd를 완화하는 주기 분산

대기열에 유저가 쌓이면, 누군가 주기적으로 앞에서 N명을 꺼내 입장 토큰을 발급해야 한다. 이 역할을 스케줄러가 한다.

처리량 산정

스케줄러가 한 번에 몇 명을 꺼낼지는 시스템이 감당할 수 있는 TPS에서 역산한다.

 
 
DB 커넥션 풀: 50
주문 1건 평균 처리 시간: 200ms
이론적 최대 TPS: 50 / 0.2 = 250
안전 마진 70% 적용: 175 TPS

이 값은 이론적 추정이고, 실제로는 PG 응답 시간 편차나 GC 정지 시간 등이 반영되지 않는다. 실무에서는 이 값으로 시작하고 부하 테스트로 보정하는 게 맞지만, 과제 범위에서는 이론 산정으로 충분하다고 판단했다.

왜 100ms 간격인가

1초마다 175명을 한 번에 발급하면, 175명이 동시에 주문 API를 호출한다. 이건 원래 문제의 축소판이다. DB 커넥션 175개가 동시에 점유되면서 순간 부하 스파이크가 발생한다. 이걸 Thundering Herd 문제라고 한다.

100ms마다 약 18명씩 발급하면 같은 초당 175명이지만, 순간 부하가 18명 수준으로 줄어든다. 부하가 10배 평탄화되는 것이다.

여기서 지난주 Outbox Polling과 비교하면 흥미로운 차이가 있다. Outbox Publisher도 @Scheduled로 1초마다 실행했는데, 그때는 "polling 주기만큼 지연이 생긴다"가 단점이었다. 이번에는 "100ms마다 실행해서 부하를 평탄화한다"가 장점이다.

이유는 설계 목표가 다르기 때문이다. Outbox Polling의 목표는 "빠짐없이 전달"이라 주기가 짧을수록 좋지만 DB 부하 때문에 제한된다. 대기열 스케줄러의 목표는 "일정 속도로 제어"라 주기가 짧을수록 부하가 평탄화돼서 오히려 좋다. 같은 @Scheduled지만 방향이 반대인 셈이다.

fixedRate가 아닌 fixedDelay를 사용한 것도 의도적인 선택이다. fixedRate는 이전 실행이 끝나지 않아도 다음 실행을 시작하지만, fixedDelay는 이전 실행 완료 후 100ms 뒤에 다음 실행을 시작한다. Redis나 네트워크 지연으로 배치 처리가 100ms 이상 걸려도 실행이 겹치지 않는다.


5. ZPOPMIN vs Outbox Polling — "꺼내기"의 근본적 차이

스케줄러가 대기열에서 유저를 꺼내는 데 ZPOPMIN을 사용했다. 이것이 지난주 Outbox의 SELECT WHERE published = false + UPDATE published = true와 구조적으로 다르다.

Outbox Polling은 2단계 연산이다. 조회와 상태 변경이 분리돼 있어서, 조회 후 상태 변경 전에 서버가 죽으면 다음 주기에 같은 row를 다시 읽는다. 이게 "At Least Once"가 보장되는 이유이자, 중복 발행이 생기는 이유다. 장애에 강하지만 중복 처리는 Consumer 쪽 멱등 처리로 커버해야 한다.

ZPOPMIN은 1단계 원자적 연산이다. 읽는 순간 Sorted Set에서 사라진다. 별도의 상태 갱신 단계가 없다. 중복으로 꺼낼 수 없지만, 꺼낸 후 토큰 발급이 실패하면 데이터가 유실된다.

이 차이가 보상 전략의 필요성으로 이어진다. ZPOPMIN으로 유저를 꺼냈는데 Redis SET(토큰 발급)이 실패하면, 유저는 대기열에서 빠졌는데 토큰이 없는 상태가 된다. Outbox는 이런 경우 다음 주기에 자동 복구되지만, ZPOPMIN은 자동 복구가 안 된다.

이 문제의 해결로 ZPOPMIN 결과에 포함된 원래 score(진입 시각)를 보존해두고, 토큰 발급 실패 시 같은 score로 ZADD해서 대기열에 재진입시키는 보상 로직을 구현했다. 완벽하지는 않다. 재진입 사이에 다른 유저가 들어왔으면 순번이 한두 칸 밀릴 수 있다. 하지만 "대기열에서 사라져서 아무 일도 안 일어나는 것"보다는 훨씬 낫다.

Lua Script로 ZPOPMIN + SET을 하나의 원자적 연산으로 묶는 것도 검토했지만, 현재 단일 스케줄러 구조에서는 보상 로직으로 충분하다고 판단했다.


6. 입장 토큰 — TTL로 "자리 차지" 문제를 해결한다

스케줄러가 대기열에서 꺼낸 유저에게 입장 토큰을 발급한다. 토큰은 Redis String에 저장하고 TTL을 설정한다.

 
 
SET entry-token:{userId} {uuid-token} EX 300   // 5분 TTL

유저는 이 토큰을 헤더(X-Entry-Token)에 담아서 주문 API를 호출하고, 주문 완료 후 토큰은 삭제된다.

왜 Redis String + TTL인가

토큰 저장소로 Redis Hash, DB 테이블도 검토했다. Redis Hash는 하나의 키에 모든 토큰을 모을 수 있어서 관리는 편하지만, 개별 필드에 TTL을 설정할 수 없다. 전체 Hash에 TTL을 걸거나 만료 체크를 직접 구현해야 한다. DB 테이블은 영속성은 좋지만, 토큰 검증이 매 주문 요청마다 발생하므로 DB 부하가 추가된다. 대기열을 만든 이유가 DB 보호인데, 토큰 검증으로 DB를 쓰면 모순이다.

Redis String + TTL은 만료 시 자동 삭제되므로 별도 정리 로직이 불필요하고, GET 한 번으로 검증이 끝난다.

토큰 검증 위치

토큰 검증을 어디에 둘 것인가도 결정이 필요했다. 주문 서비스 내부에서 직접 검증하면 Controller → Service 어디에서든 호출할 수 있어서 우회 가능성이 있다. 횡단 관심사는 인프라 레이어에서 처리하는 게 맞다.

Spring Interceptor를 선택해서 주문 API 경로에만 적용했다. URL 패턴으로 적용 범위를 지정할 수 있어서 다른 API에는 영향이 없다. 다만 기존 주문 API 테스트가 Interceptor 때문에 403으로 실패할 수 있어서, 테스트 프로파일에서 비활성화하거나 MockBean으로 TokenService를 항상 true 반환하도록 처리해야 했다.

토큰 삭제와 원자성 문제

주문이 완료되면 토큰을 삭제하는데, 주문 트랜잭션은 DB에서, 토큰 삭제는 Redis에서 일어난다. 주문 DB 커밋은 성공했는데 Redis 토큰 삭제가 실패하면, 같은 토큰으로 중복 주문이 가능해진다. 지난주 Outbox에서 다뤘던 "DB와 외부 시스템의 원자성" 문제와 같은 구조다.

이번에는 Outbox 패턴까지 적용하지 않았다. 토큰 삭제가 실패해도 TTL(5분)이 지나면 자연 만료되고, 5분 안에 같은 유저가 같은 상품을 다시 주문하더라도 주문 서비스 내부의 재고 확인 로직에서 걸러진다. 완벽하지는 않지만, 토큰 삭제를 위해 Outbox를 도입하는 것은 복잡도 대비 이점이 부족하다고 판단했다.


7. 실시간 순번 조회 — Polling으로 시작하는 이유

대기열의 성패는 유저가 기다리는 동안 이탈하지 않느냐에 달려있다. 순번이 보이지 않으면 유저는 새로고침을 누르거나 떠난다.

Polling vs SSE

순번을 알려주는 방식으로 Polling, SSE, WebSocket 세 가지를 검토했다.

SSE(Server-Sent Events)는 서버가 클라이언트에게 일방적으로 데이터를 Push하는 방식이다. 변경이 있을 때만 보내니까 불필요한 요청이 없고, 서버가 발급하는 즉시 클라이언트에 도달한다. 하지만 대기 인원 1만 명이면 서버가 1만 개의 HTTP 연결을 동시에 유지해야 한다. 각 연결이 스레드를 점유하면 스레드 풀이 고갈될 수 있다.

WebSocket은 양방향 통신인데, 대기열 순번 조회는 서버→클라이언트 단방향이면 충분해서 과도하다.

Polling을 선택한 이유는 구현이 가장 단순하기 때문이다. REST API 하나 추가하면 되고, 서버 측 커넥션 유지가 불필요하다. 단점은 대기 인원이 많으면 polling 자체가 부하가 된다는 것이지만, Redis 기반 ZRANK 조회는 μs 단위라 초당 5천 건 정도는 감당 가능하다.

예상 대기 시간

유저에게 순번만 보여주는 것보다 "약 N초 남았습니다"가 훨씬 효과적이다. 기본 계산은 position / throughputPerSecond인데, 여기에 안전 마진 20%를 추가했다.

마진을 추가한 이유는, 토큰 미사용(만료)이나 PG 응답 지연 등으로 실제 처리 속도가 이론값보다 느릴 수 있기 때문이다. "약 6초"라고 알려줬는데 5초 만에 입장하면 긍정적이지만, 8초가 걸리면 부정적이다. 약간 보수적으로 추정하는 게 유저 경험에 더 낫다.

토큰 발급 알림

토큰이 발급됐을 때 클라이언트에 어떻게 알려줄 것인가. 별도 Push 알림이나 SSE를 사용하지 않고, 기존 polling 응답에 토큰을 포함시켰다. 클라이언트가 2초마다 순번을 조회하다가, 토큰이 발급되면 다음 조회 응답에서 status: "TOKEN_ISSUED"와 함께 토큰값을 받는다.

최대 2초의 지연이 생기지만, 토큰 TTL이 5분이므로 2초는 무의미한 수준이다. 추가 인프라 없이 기존 흐름을 그대로 활용할 수 있다는 장점이 더 크다.


8. 지난주 이벤트 파이프라인과의 연결

대기열은 주문 API 앞단의 관문이다. 토큰을 가진 유저만 주문 API에 진입할 수 있고, 주문 API 이후의 흐름은 지난주에 구축한 이벤트 파이프라인이 그대로 동작한다.

 
 
[대기열] → 토큰 검증 → [주문 API]
                         → OrderCompletedEvent 발행 (ApplicationEvent)
                         → Outbox INSERT (BEFORE_COMMIT)
                         → Outbox Publisher → Kafka 발행
                         → commerce-collector → product_metrics 집계

대기열이 상류(유저 요청)를 제어하면, 하류(Outbox → Kafka → collector)에 들어오는 이벤트량도 자연스럽게 제한된다. 초당 최대 175건의 주문만 발생하므로, Outbox polling 1초 간격과 Kafka Consumer concurrency=1로도 충분히 소화할 수 있다.

쿠폰(R7) vs 주문(R8) — back-pressure 방식의 차이

지난주 선착순 쿠폰은 "Kafka에 넣고 Consumer가 순차 처리"하는 방식이었고, 이번 주 주문은 "대기열에서 토큰을 발급받아야 진입 가능"한 방식이다. 둘 다 시스템을 보호하는 목적이지만, 유저 경험이 완전히 다르다.

쿠폰은 fire-and-forget이다. 유저가 발급 요청을 보내면 즉시 "접수됐습니다" 응답을 받고, 결과는 나중에 polling으로 확인한다. 유저가 대기하는 동안 화면을 보고 있을 필요가 없다.

주문은 유저가 직접 결제까지 수행해야 한다. 대기열에서 기다렸다가, 내 차례가 오면 직접 상품을 선택하고 결제 정보를 입력하고 주문 버튼을 누른다. 유저가 화면 앞에서 기다리고 있으므로, 순번과 예상 시간을 실시간으로 보여줘야 이탈을 막을 수 있다.

이 차이는 "유저 개입이 필요한가"에서 온다. 쿠폰 발급은 서버가 알아서 처리할 수 있지만, 주문은 유저가 결제 정보를 입력해야 완료된다. 유저 개입이 필요한 작업은 대기열 + 토큰 방식이, 서버가 단독 처리할 수 있는 작업은 Kafka 버퍼링 방식이 적합하다.


돌아보며

이 과제를 통해 가장 크게 깨달은 것은 "back-pressure는 기술이 아니라 전략"이라는 점이다. Redis Sorted Set이나 @Scheduled는 도구일 뿐이고, 핵심은 "시스템이 감당할 수 있는 속도로 요청을 조절한다"는 설계 원칙이다.

또 하나는, 대기열 자체가 새로운 장애 포인트가 된다는 것이다. Redis가 죽으면 대기열도 토큰도 전부 사라진다. 스케줄러가 멈추면 아무도 입장하지 못한다. ZPOPMIN 후 토큰 발급이 실패하면 유저가 사라진다. 대기열은 문제를 해결하는 동시에 새로운 문제를 만든다. 이 새로운 문제들에 대한 대비(보상 로직, Graceful Degradation, 모니터링)까지가 대기열 설계의 범위다.

지난주 이벤트 파이프라인과 이번 주 대기열을 합치면, 커머스 시스템의 전체 그림이 보인다. 대기열이 상류를 제어하고, 이벤트 파이프라인이 하류를 처리한다. 상류에서 속도를 조절하면 하류는 자연스럽게 보호된다. 이 "상류 제어 → 하류 보호"의 원리가 back-pressure의 본질이다.