문제 상황
대규모 이벤트 티켓 예매나 한정 상품 선착순 판매처럼, 짧은 시간 안에 수많은 사용자가 동시에 몰리는 상황에서 대기열(Queue) 시스템은 필수적입니다. 모든 요청을 동시에 처리하면 DB와 서버가 즉시 다운되기 때문에,
입장 순서를 제어하는 대기열을 두고 순차적으로 처리했습니다.
이 시스템의 핵심 기능 중 하나가 바로 "내 순번이 몇 번인지"를 사용자에게 지속적으로 알려주는 것이었습니다.
사용자 입장에서는 자신이 언제 입장할 수 있는지 알아야 이탈하지 않고 기다리기 때문입니다.
시스템 구조는 다음과 같았습니다.
- Redis Sorted Set에 사용자를 점수(timestamp) 기준으로 적재
- ZRANK 명령으로 현재 순번을 O(log N)에 조회
- k6 부하 테스트 기준: 10,000명 / 300s 조건에서 검증
이 상황에서 가장 먼저 마주한 질문은 단순했습니다.
클라이언트에게 순번 정보를 어떻게 전달할 것인가?
'실시간'이라는 단어가 붙는 순간 저도 WebSocket을 먼저 떠올렸습니다. 하지만 기술 선택은 직관이 아니라 트레이드오프의 분석에서 출발해야 한다고 생각했습니다.
대기열 조회 방식 후보
1. WebSocket
WebSocket은 HTTP 핸드셰이크를 통해 최초 연결을 맺은 뒤, 그 연결을 TCP 소켓으로 업그레이드해 유지하는 프로토콜입니다. 한 번 연결이 맺어지면 클라이언트와 서버 양쪽 모두 원하는 시점에 자유롭게 데이터를 주고받을 수 있습니다. HTTP처럼 요청-응답 사이클이 없고, 연결이 끊어지기 전까지 양방향 채널이 열려 있는 상태로 유지됩니다.
연결 수립 과정을 간단히 보면 다음과 같습니다.

장점
첫째, 진정한 실시간 양방향 통신이 가능합니다. 서버가 클라이언트의 요청 없이도 원하는 순간에 데이터를 push할 수 있습니다. 클라이언트 역시 언제든 서버에 메시지를 보낼 수 있습니다.
둘째, 오버헤드가 낮습니다. HTTP는 요청마다 헤더를 포함한 전체 패킷을 주고받지만, WebSocket은 연결 이후 작은 프레임 단위로 데이터를 주고받습니다. 빈번하게 데이터를 주고받는 상황에서 HTTP 반복 요청보다 네트워크 오버헤드가 훨씬 적습니다.
셋째, 지연 시간이 최소화됩니다. 연결이 이미 열려 있기 때문에 데이터 전송 시 새 연결을 맺는 시간(TCP 핸드셰이크, TLS 협상 등)이 필요 없습니다.
WebSocket을 선택하지 않은 이유
WebSocket의 가장 큰 특성은 Stateful이라는 점입니다. 연결이 맺어지는 순간부터 끊어지는 순간까지 소켓 객체가 서버 메모리에 상주합니다. 이 특성이 대규모 환경에서 세 가지 치명적인 문제를 만들어낸다고 판단했습니다.
문제 1 — 메모리의 선형 증가
소켓 하나당 운영체제 수준에서 파일 디스크립터(File Descriptor)를 소비하며 커널 수신, 송신 버퍼와 애플리케이션 레벨 소켓 객체가 함께 메모리를 점유합니다. 실측 기준으로 연결 하나당 약 10KB~50KB의 메모리가 필요합니다.
연결 수가 늘어날수록 메모리 점유는 선형으로 증가합니다. 서버가 아무 비즈니스 로직을 처리하지 않아도 연결 유지만으로 이미 상당한 메모리가 소비됩니다. 실제 예매 처리, DB 조회, 캐시 연산 등을 위한 메모리 여유가 그만큼 줄어들기 때문에 트래픽이 커지는 상황을 고려했을 때 이 비용은 확장의 발목을 잡는 주요 원인이 될 것이라고 생각했습니다.
문제 2 — 로드밸런서 병목
WebSocket은 연결이 맺어질 때 반드시 동일 서버에 고정(Sticky Session) 되어야 합니다. 클라이언트 A의 소켓이 서버 1에 연결되어 있다면 A의 모든 후속 메시지는 서버 1로 라우팅되어야 하기 때문입니다. 이는 로드밸런서에 세션 어피니티 설정이 필수임을 의미합니다.
더 근본적인 문제는 로드밸런서 자체도 동시 연결 수 한계가 있다는 점이었습니다. AWS ALB 기준으로 WebSocket 업그레이드 이후에는 연결이 해제될 때까지 LB 리소스를 계속 점유합니다. 서버를 아무리 늘려도(Scale-out) 입구인 로드밸런서가 연결을 감당하지 못하면 병목은 해소되지 않습니다. 백엔드를 수평 확장해봤자 앞단에서 막히는 구조라는 점이 가장 큰 문제라고 생각했습니다.
문제 3 — Thundering Herd (재접속 폭풍)
네트워크 장애나 서버 재시작으로 인해 연결이 일시적으로 끊겼다고 가정해 보겠습니다. 수많은 클라이언트가 동시에 재접속을 시도하면 다음과 같은 상황이 발생합니다.
- 브라우저 기본 재연결 로직이 일제히 실행됩니다
- 대량의 WebSocket 핸드셰이크 요청이 동시에 쏟아집니다
- 로드밸런서가 연결 요청을 감당하지 못하고 503을 반환합니다
- 503을 받은 클라이언트들이 다시 재접속을 시도합니다
- 시스템이 복구되기 전에 또 다른 폭풍이 발생해 복구 불가 상태에 빠집니다
이것이 Thundering Herd입니다. 짧은 장애가 시스템 전체를 마비시키는 재귀적 폭풍으로 증폭됩니다. Exponential Backoff를 클라이언트에 적용해도, 동시 접속자가 많을수록 그 효과는 제한적이라고 판단했습니다.
결론적으로 WebSocket은 이 대기열 시스템에 있어 과도한 기술이라고 판단했습니다. 양방향 통신이 필요하지 않은 단방향 상태 조회에 Stateful 연결의 모든 비용을 지불하는 것은 비효율적이며, 서버를 늘려도 해결되지 않는 로드밸런서 병목은 특히 치명적이었습니다.
주로 사용하는 상황
WebSocket은 다음과 같이 서버와 클라이언트 사이에 빈번하고 즉각적인 양방향 데이터 교환이 필요한 경우에 적합합니다.
- 채팅 서비스: 메시지를 보내는 동시에 상대방의 메시지를 실시간으로 수신해야 합니다. 타이핑 중 표시(typing indicator)처럼 상태 변화도 즉시 전달해야 합니다.
- 멀티플레이어 게임: 플레이어의 위치, 행동, 이벤트가 밀리초 단위로 서버와 클라이언트 사이를 오가야 합니다.
- 실시간 주식, 코인 시세: 체결가, 호가, 거래량이 초 단위 이하로 변하며 사용자도 주문을 실시간으로 전송합니다.
- 협업 도구: Google Docs처럼 여러 사람이 동시에 같은 문서를 편집할 때 각자의 변경 사항을 즉시 모든 참여자에게 동기화해야 합니다.
2. SSE (Server-Sent Events)
SSE는 HTTP 프로토콜 위에서 동작하는 단방향 서버 push 기술입니다. 클라이언트가 서버에 HTTP 요청을 보내면 서버는 응답을 즉시 종료하지 않고 연결을 유지한 채 이벤트가 발생할 때마다 데이터를 스트리밍 방식으로 전송합니다.
클라이언트 → 서버 방향의 메시지는 없습니다. 데이터는 오직 서버 → 클라이언트 방향으로만 흐릅니다.

장점
첫째, HTTP 위에서 동작하기 때문에 기존 인프라(프록시, 방화벽, 로드밸런서)와 호환성이 좋습니다. WebSocket처럼 별도의 프로토콜 업그레이드가 필요 없습니다.
둘째, 브라우저가 자동 재연결을 기본으로 지원합니다. 연결이 끊어지면 EventSource가 자동으로 재연결을 시도하며 Last-Event-ID 헤더를 통해 마지막으로 수신한 이벤트 이후부터 이어받을 수 있습니다.
셋째, WebSocket보다 구현이 단순합니다. 단방향 push만 필요한 경우 WebSocket의 양방향 연결 관리보다 훨씬 간단하게 구현할 수 있습니다.
넷째, HTTP/2 환경에서는 멀티플렉싱을 통해 하나의 TCP 연결 위에 여러 SSE 스트림을 동시에 처리할 수 있어 연결 오버헤드가 줄어듭니다.
SSE 선택하지 않은 이유
WebSocket의 양방향 통신이 문제라면, 단방향인 SSE는 어떨지 검토해봤습니다. 실제로 SSE는 WebSocket보다 가볍고 대기열의 단방향 특성과도 일치하는 것처럼 보였습니다.
문제 1 - 연결 유지 비용은 동일하다
SSE는 단방향이지만 여전히 HTTP 연결을 지속적으로 유지합니다. 접속자 수만큼의 HTTP 연결이 서버에 열려 있는 상태가 됩니다. 메모리 점유와 파일 디스크립터 소비 문제는 WebSocket과 본질적으로 동일하다고 판단했습니다.
WebSocket보다 핸드셰이크 오버헤드가 적고 HTTP/2 위에서 멀티플렉싱이 가능하다는 이점은 있었습니다. 하지만 지속 연결이라는 근본 문제는 해소되지 않는다고 생각했습니다.
문제 2 - 서버 쪽 폴링이 필연적으로 발생한다
SSE로 대기 순번을 push하려면 서버가 각 사용자의 현재 순번을 언제 push할지 스스로 판단해야 합니다.
대기 순번은 매 초마다 변하는 연속적인 상태 값이지, 명확한 이벤트 발생 시점이 있는 게 아닙니다.
결국 서버는 내부적으로 Redis를 주기적으로 확인하는 로직이 필요하게 됩니다. 클라이언트 폴링을 없애려고 SSE를 도입했더니 서버 내부에서 Redis 폴링이 생겨버리는 구조가 된 것입니다. 클라이언트는 연결을 유지해야 하고 서버도 Redis를 주기적으로 확인해야 하니 비용이 오히려 더 크다고 판단했습니다.
반면 Polling은 클라이언트가 직접 묻기 때문에 서버는 Redis ZRANK 하나 읽고 응답하면 끝입니다.
주로 사용하는 상황
SSE는 서버가 이벤트 발생 시점을 명확히 알고 있고, 그 시점에만 클라이언트에 단방향으로 데이터를 전달하면 충분한 경우에 적합합니다.
- 알림 시스템: 결제 완료, 배송 상태 변경, 입장권 발급처럼 서버에서 특정 상태 전이가 발생하는 시점이 명확하고 그 순간에 사용자에게 push하면 되는 경우입니다.
- 실시간 피드: 뉴스 피드, SNS 타임라인처럼 새 콘텐츠가 생성되는 시점에 구독자에게 push하는 경우입니다.
- 진행률 표시: 파일 변환, 영상 인코딩처럼 서버에서 작업 진행률을 알고 있고 주기적으로 클라이언트에 전달하는 경우입니다.
Polling을 선택한 이유

근거 1 - Stateless = 수평 확장의 자유
폴링은 클라이언트가 서버에 요청을 보내고 서버가 응답하면 연결이 끊어집니다. 서버는 클라이언트의 상태를 전혀 기억하지 않습니다.
완전한 Stateless입니다.
이는 로드밸런서가 어떤 서버로 요청을 보내든 상관없다는 뜻입니다. 트래픽이 증가하면 API 서버를 추가(Scale-out)하는 것만으로 대응이 가능합니다. WebSocket의 Sticky Session 제약이 없고 연결이 즉시 종료되기 때문에 LB의 동시 연결 한계 문제도 발생하지 않습니다.
근거 2 - 요청당 처리 비용이 극소화된다
대기열 순번 조회의 서버 로직은 단순했습니다.
사용자 ID → Redis ZRANK → 순번 반환
Redis ZRANK는 O(log N) 연산입니다. DB 조회도 없고, 복잡한 비즈니스 로직도 없습니다. Redis 응답 시간은 보통 1ms 이하입니다. 요청 하나를 처리하는 비용이 극도로 낮다는 점이 폴링을 선택하는 데 있어 핵심 근거였습니다.
결국 이런 비교로 귀결되었습니다.
WebSocket이 수만 개의 연결을 항상 메모리에 유지하는 비용 vs Polling이 5초마다 Redis 하나를 읽는 비용
서버 관점에서 "연결을 유지하는 것"이 "자주 질문을 받는 것"보다 훨씬 비싸다는 결론을 내렸습니다.
근거 3 — 요청 처리 모델이 단순하다
폴링의 각 요청은 완전히 독립적인 HTTP 요청입니다. 서버는 상태를 공유할 필요가 없고 어떤 서버가 요청을 받아도 동일한 결과를 반환합니다. 이는 디버깅, 모니터링, 장애 추적 모든 면에서 훨씬 단순하다는 뜻이기도 했습니다.
Polling의 문제점 발견
기본적인 폴링 구현은 간단합니다. 클라이언트가 매 5초마다 서버에 현재 순번을 요청하는 방식이었습니다.
그런데 이 단순한 구현에서 두 가지 문제를 발견했습니다.
문제 1 - Thundering Herd (동시 요청 폭풍)
k6 부하 테스트(10,000명 / 300s)에서 균일 주기 폴링을 적용했을 때 피크 시 약 2,000 req/s가 측정되었습니다. 사용자들이 비슷한 시각에 서비스에 진입했고 동일한 5초 주기로 폴링하다 보니 요청이 특정 시점에 집중적으로 몰리는 패턴이 나타났습니다.
T=0s : ████████████████ (요청 집중, ~2,000 req/s)
T=1s : ▌
T=2s : ▌
T=3s : ▌
T=4s : ▌
T=5s : ████████████████ (다시 집중)
폴링 브레이크포인트 테스트 결과 1,000 req/s에서는 정상 처리, 1,500 req/s에서는 실패가 발생했습니다. 피크 2,000 req/s는 시스템 한계를 초과하는 수치였습니다.
이 패턴의 문제는 서버가 평소에는 여유롭지만 특정 순간에만 한계를 넘는다는 점이었습니다. 평균 처리량 기준으로 서버를 프로비저닝하면 피크에 무너지고 피크 기준으로 대부분의 시간 동안 리소스를 낭비하게 됩니다.
문제 2 - 순번과 무관한 균일 폴링 주기
대기열에서 순번이 5,000번인 사용자와 5번인 사용자가 동일한 5초 주기로 폴링하는 것은 비효율적이라고 생각했습니다. 순번이 5,000번인 사람은 앞으로 한참을 기다려야 합니다. 5초마다 확인하는 것은 서버에 불필요한 부하만 줄 뿐입니다.
반면 순번 5번인 사람은 수십 초 안에 입장하므로, 오히려 더 자주 확인해야 합니다.
이 두 문제를 해결하지 않으면 폴링은 오히려 WebSocket보다 나쁜 트래픽 패턴을 만들어낼 수 있다는 것을 확인했습니다.
Adaptive Polling + Jitter 적용
두 가지 문제를 각각 독립적인 기법으로 해결했습니다.
해결책 1 - Adaptive Polling (적응형 주기)
사용자의 현재 대기 순번에 따라 폴링 주기를 동적으로 조정했습니다. 핵심 아이디어는 서버가 각 응답에 다음 폴링 주기를 명시적으로 내려주는 것이었습니다.
순번 > 1,000 → 다음 폴링 간격 10초
순번 ≤ 1,000 → 다음 폴링 간격 5초
서버 구현
// ConcertQueueApiController.java
@GetMapping("/{concertId}/queue/me")
public ResponseEntity<?> myQueue(@PathVariable Long concertId,
@AuthenticationPrincipal CustomPrincipal principal) {
Long position = queueService.getPosition(concertId, principal.getUserId());
// 순번에 따라 다음 폴링 주기 결정
int nextPollMs = position > 1000 ? 10_000 : 5_000;
return ResponseEntity.ok(Map.of(
"status", "QUEUED",
"position", position,
"nextPollMs", nextPollMs
));
}
클라이언트 구현
// hooks.js
function withJitter(ms) {
return ms + Math.floor(Math.random() * 501) - 250; // ±250ms
}
async function pollQueueStatus() {
const { position, nextPollMs } = await fetchQueueStatus();
updateUI(position);
// Adaptive Polling 주기에 Jitter 적용
setTimeout(pollQueueStatus, withJitter(nextPollMs));
}
이 구조의 장점은 서버가 부하 상황에 따라 주기를 동적으로 조절할 수 있다는 점이었습니다. 서버 CPU나 Redis 부하가 높아지면 nextInterval을 늘려서 자연스럽게 트래픽을 줄일 수 있는 구조였습니다.
해결책 2 - Jitter (랜덤 편차)
Adaptive Polling만으로는 Thundering Herd를 완전히 막을 수 없었습니다. 5초 주기로 폴링하는 사람들이 여전히 같은 시각에 요청을 보낼 수 있었기 때문입니다.
Jitter는 각 폴링 요청에 ±250ms의 랜덤 편차를 추가하는 기법입니다. 단순해 보이지만, 수많은 클라이언트의 요청 타이밍이 이 범위 내에서 균등하게 분산되었습니다.
// hooks.js
function withJitter(ms) {
return ms + Math.floor(Math.random() * 501) - 250; // integer ±250ms
}
async function pollQueueStatus() {
const { rank, nextInterval } = await fetchQueueStatus();
updateUI(rank);
// Adaptive Polling 주기에 Jitter 적용
setTimeout(pollQueueStatus, withJitter(nextInterval));
}
Jitter의 원리는 간단합니다. 클라이언트들이 동일한 5,000ms 후에 요청을 보내는 대신, 4,750ms~5,250ms 사이의 균등 분포로 요청을 보내게 됩니다. 500ms 구간에 요청이 분산되므로, 순간 최대 req/s가 이론적으로 절반 이하로 줄어드는 효과가 있었습니다.
두 기법의 역할 분담을 정리하면 다음과 같습니다.
- Adaptive Polling — 불필요한 요청 자체를 줄입니다. 멀리 기다리는 사람은 덜 자주 묻게 만듭니다.
- Jitter — 동시에 발생하는 요청의 타이밍을 분산시킵니다. 같은 주기라도 동시에 쏟아지지 않게 만듭니다.
두 가지는 보완적이며, 어느 하나만 적용해서는 완전한 효과를 얻을 수 없었습니다.
성능 측정 결과
k6 부하 테스트를 통해 서버 구성과 부하 조건별로 측정했습니다.

| 구성 | 부하 | 실패율 | 평균 응답시간 | p95 |
| 2대 | 500 req/s | 0.00% | 52.32ms | 209.87ms |
| 2대 | 1000 req/s | 41.77% | 1.16s | 4.5s |
| 3대 | 500 req/s | 0.00% | 10.99ms | 19.2ms |
| 3대 | 1000 req/s | 0.00% | 19.37ms | 44.33ms |
2대 구성에서는 1,000 req/s 부하 시 실패율이 41.77%까지 치솟았고, 평균 응답시간이 1.16초, p95가 4.5초로 사실상 서비스 불가 수준이었습니다. 2대 구성의 브레이크포인트는 500 req/s와 1,000 req/s 사이에 있다고 판단했습니다.
3대 구성에서는 1,000 req/s에서도 실패율 0%, p95 44.33ms로 안정적으로 처리되었습니다. 3대 구성의 유효 처리 한계는 최소 1,000 req/s 이상임을 확인했습니다.
Adaptive Polling + Jitter 적용 효과
위 브레이크포인트를 확인한 상태에서, 균일 주기 폴링을 적용했을 때 피크 req/s가 약 2,000까지 치솟는 문제가 있었습니다. 이는 3대 구성에서도 안정적으로 처리 가능한 수준(1,000 req/s)을 크게 초과하는 수치였습니다.
Adaptive Polling과 Jitter를 적용한 결과, 피크 req/s가 2,000 → 1,100으로 45% 감소했습니다. 3대 구성 기준 브레이크포인트 이하로 안정화된 것을 확인했습니다.
어떤 상황에서 어떤 기술을 선택해야 하는가?
이 경험을 통해 기술 선택의 기준을 다음과 같이 정리할 수 있었습니다.
WebSocket / SSE를 선택해야 하는 경우:
- 서버가 이벤트 발생 시점을 명확히 알고 있을 때 (입장권 발급 완료, 결제 승인)
- 실시간 양방향 통신이 필수일 때 (채팅, 협업 도구, 멀티플레이어 게임)
- 데이터 변화가 불규칙하고, 변화 시점에만 push하면 충분할 때
- 동시 접속자가 수만 명 이하로 통제 가능한 규모일 때
Polling을 선택해야 하는 경우:
- 상태를 주기적으로 확인하면 충분할 때 (대기열 순번, 배치 작업 진행률)
- 요청당 처리 비용이 극도로 낮을 때 (Redis 단일 연산)
- 수평 확장과 인프라 단순성이 우선순위일 때
- 트래픽 규모가 커질수록 Stateless의 이점이 더욱 부각되는 구조일 때
서버가 "데이터가 언제 바뀌는지 미리 알 수 있는가?"를 먼저 물어보는 것이 좋습니다. 알 수 있다면 push(WebSocket/SSE) 모른다면 pull(Polling)이 대체로 더 적합하다고 생각합니다.
그리고 규모가 커질수록 pull의 Stateless 특성이 주는 확장성 이점을 고려해야 한다고 생각합니다.
마치며
"실시간"이라는 요구사항이 붙으면 반사적으로 WebSocket을 떠올리는 경향이 있습니다. 저도 처음에는 그렇게 생각했습니다.
하지만 기술은 요구사항의 본질에 맞게 선택해야 한다는 것을 이번 경험에서 다시 확인했습니다.
대기열 순번 조회의 본질은 "현재 상태를 알고 싶다" 는 것이지 "상태가 변하는 즉시 알아야 한다" 는 것이 아니었습니다. 5초 후에 순번을 알아도 서비스에 지장이 없다면 그 5초의 지연을 감수하고 Stateless 폴링이 제공하는 확장성을 취하는 것이 합리적인 선택이라고 판단했습니다.
Adaptive Polling과 Jitter는 단순한 최적화가 아니었습니다. 폴링이라는 기본 기법을 운영 가능한 수준으로 끌어올리기 위한 필수 장치였습니다. 실제로 이 두 가지를 적용하지 않은 균일 주기 폴링은 k6 테스트에서 브레이크포인트를 초과했고 적용 후에는 동일한 부하 조건에서 안정적으로 처리되는 것을 확인할 수 있었습니다.
연결을 유지하는 비용이 자주 묻는 비용보다 비싸다고 판단했습니다.
이 전제가 성립하는 한 - 대기열처럼 요청당 비용이 낮은 구조에서는 - Polling이 WebSocket보다 합리적인 선택이라고 생각합니다.
단, 반드시 Adaptive Interval과 Jitter를 함께 적용해야 한다는 점도 함께 기억해두면 좋겠습니다.
'프로젝트 > 공연 티켓팅 프로젝트' 카테고리의 다른 글
| 🎟️ 결제 실패 보상 이벤트 유실 방지하기 - Outbox 패턴 적용기 (1) | 2026.06.01 |
|---|---|
| 🎟️ 결제 확정 API 중복 호출 방지 - 3계층 멱등성으로 중복 결제 막기 (0) | 2026.06.01 |
| 🎟️ Redis로 좌석 차감을 옮겼는데도 Lock Wait이 사라지지 않았다. (0) | 2026.05.31 |
| 🎟️ Docker-compose up 한 번으로 실행되는 백엔드 만들기 (1) | 2026.05.18 |
| 🎟️ MySQL 인덱스 실험: 예약 만료 배치 쿼리 성능 개선 (10만 건 실험) (1) | 2026.05.13 |