카테고리 없음

대기열 순번 전달에서 SSE 대신 Polling을 선택한 이유

kimong 2026. 4. 3. 16:24

트래픽이 몰리는 순간 모든 사용자의 요청을 한 번에 처리하려고 하면, 애플리케이션과 DB가 순간 부하를 감당하지 못할 수 있다.
이럴 때는 모든 요청을 즉시 처리하기보다, 일정 수만 먼저 통과시키고 나머지는 대기시키는 방식이 더 안전하다.

 

대기열은 이런 상황에서 사용자에게 순번을 부여하고, 자신의 차례가 되었을 때만 다음 단계로 진입시키는 구조다.
문제는 여기서 끝나지 않는다. 대기열에 들어온 사용자는 이제 궁금해진다. **“내가 지금 몇 번째인지”**를 어떻게 보여줄 것인가.

 

후보는 크게 세 가지다. Polling, Long Polling, SSE.
지연만 보면 Long Polling과 SSE가 더 좋아 보인다. 하지만 이 프로젝트에서는 Polling이 더 적절했다.

 

이 글에서는 세 가지 방식을 비교한 뒤, 왜 이 맥락에서 Polling을 선택했는지 정리한다.

 


 

세가지 방식 비교

  Poliing Long Polling SSE
방식 클라이언트가 주기적으로 질의 클라이언트 요청 후 서버가 변경 시점까지 응답 보류 서버가 변경 시점에 Push
구현 복잡도 낮음 중간 중간
서버 부하 대기 인원 x 조회 주기 대기 인원 x 1 커넥션 유지 대기 인원 x 1 커넥션 유지
지연 조회 주기만큼 거의 없음 거의 없음

 

세 방식의 차이는 단순히 “누가 먼저 요청하느냐” 정도가 아니다.
서버가 상태를 얼마나 오래 들고 있어야 하는지, 수평 확장에서 무엇이 추가로 필요한지까지 달라진다.

 


 

Polling

클라이언트가 일정 주기로 서버에 요청을 보내는 방식이다

클라이언트 → GET /queue/position  (1초마다)
서버       → { position: 128, estimatedWaitSeconds: 43 }
클라이언트 → GET /queue/position
서버       → { position: 105, estimatedWaitSeconds: 35 }
...
클라이언트 → GET /queue/position
서버       → { token: "abc123" }  ← 입장 토큰 발급됨, 폴링 종료

 

구현이 단순하다.

클라잉너트는 타이머를 돌리고, 서버는 요청이 오면 Redis에서 현재 순번을 읽어 반환하면 된다. 서버에 별도 상태를 유지할 필요가 없다.

 

대신 단점도 분명하다. 순번이 바뀌지 않아도 요청은 계속 들어온다.

예를 들어 대기 인원이 1,000명이고 1초마다 조회한다면, 순번 조회만으로도 초당 1,000건의 요청이 발생한다.

 


 

Long Polling

Long Polling은 클라이언트가 요청을 보내면 서버가 즉시 응답하지 않고, 순번이 바뀌는 시점까지 응답을 보류하는 방식이다.

클라이언트 → GET /queue/position
서버: 아직 변경 없음 → 응답 보류 (커넥션 유지)
서버: 순번 변경 감지 → 응답 반환
클라이언트: 응답 받으면 즉시 다음 요청

 

Polling보다 불필요한 요청 수를 줄일 수 있고, 변경이 발생하면 거의 즉시 응답할 수 있다.

 

하지만 구현은 생각보다 단순하지 않다.
단순히 요청을 오래 붙잡고 있다고 Long Polling이 되는 것은 아니다. 순번 변경이 발생했을 때 어떤 요청을 깨워 응답할지 관리할 수 있어야 한다. 결국 내부적으로는 변경 이벤트를 감지하고 대기 중인 요청에 연결하는 구조가 필요하다.

 

즉, Long Polling은 Polling보다 실시간성은 좋지만, 그만큼 대기 요청 관리 비용이벤트 연결 복잡도가 생긴다.

 


 

SSE (Server-Sent Events)

SSE는 서버가 클라이언트로 이벤트를 push하는 방식이다.

클라이언트는 한 번 연결을 맺고 나면, 서버가 순번 변경이나 토큰 발급 시점에 데이터를 내려보낸다.

클라이언트 → GET /queue/stream  (EventSource 연결)
서버       ← data: { position: 128 }
서버       ← data: { position: 105 }
서버       ← data: { token: "abc123" }
클라이언트: 커넥션 종료, 주문 API 호출

 

지연은 거의 없고, “변경이 생기면 바로 알려준다”는 요구와도 잘 맞는다.
표면적으로는 대기열 순번 전달에 가장 자연스러운 방식처럼 보인다.

 

문제는 SSE가 단순히 응답 형식 하나를 바꾸는 선택이 아니라는 점이다.
SSE를 선택하는 순간, 순번 조회 문제는 장시간 커넥션 유지, 로드밸런서 설정, 인스턴스 간 이벤트 전파 문제로 확장된다.

 


 

SSE를 선택하지 않은 이유

SSE가 더 실시간에 가까운데 왜 Polling을 선택했을까?

 

1. 로드밸런서와 idle timeout 문제

일반적인 HTTP 요청은 요청과 응답이 끝나면 연결이 바로 닫힌다.

클라이언트 → [로드밸런서] → 서버
              요청 → 응답 → 종료 (수십 ms)

 

반면 SSE는 연결을 오래 유지해야 한다.

클라이언트 → [로드밸런서] → 서버
              연결 유지 중... (수분~수십분)
                    ↓
              로드밸런서: "60초 지났는데 아무 응답도 없네? 죽은 연결이구나" → 강제 종료

 

문제는 로드밸런서가 일정 시간 동안 트래픽이 없으면 해당 연결을 idle 상태로 보고 끊어버릴 수 있다는 점이다. 예를 들어 AWS ALB의 기본 idle timeout은 60초다. 대기열 유저는 수분 이상 기다릴 수 있는데, 그동안 아무 이벤트가 없으면 SSE 연결은 중간에 종료될 수 있다.

 

이를 막으려면 두 가지가 필요하다.

  • LB timeout 연장 : 로드밸런서의 idle timeout을 서비스에 맞게 늘린다 (예: 5분)
  • Heartbeat : 서버가 주기적으로 빈 메시지(:\n\n)를 보내 "아직 살아있어"를 알린다. 로드밸런서가 트래픽이 흐른다고 인식해 커넥션을 유지한다

서버는 주기적으로 빈 이벤트를 보내 “연결이 살아 있다”는 신호를 줘야 하고, 인프라도 그 연결을 충분히 오래 유지하도록 설정해야 한다.

 

즉, SSE는 단순한 실시간 전송 방식이 아니라 연결 유지 전략까지 포함한 선택이다.

 

 

2. 수평 확장에서 이벤트 전파 문제가 생긴다

서버 인스턴스가 하나일 때는 문제없다. 

스케줄러가 토큰을 발급하면, 해당 서버에 연결된 SSE 커넥션으로 바로 이벤트를 보내면 된다.

[인스턴스 A]
  ├─ 유저 1의 SSE 커넥션
  ├─ 유저 2의 SSE 커넥션
  └─ 스케줄러: 유저 1 토큰 발급 → 유저 1 커넥션에 push ✓

 

인스턴스가 여러 개로 늘어나면 문제가 생긴다.

[인스턴스 A]              [인스턴스 B]
  └─ 유저 1의 SSE 커넥션    └─ 스케줄러: 유저 1 토큰 발급

 

유저 1의 연결은 인스턴스 A에 있는데, 토큰 발급 로직은 인스턴스 B에서 실행될 수 있다.
이 경우 인스턴스 B는 유저 1의 SSE 연결에 직접 접근할 수 없다.

 

결국 인스턴스 간에 이벤트를 전달할 수 있는 별도 채널이 필요하다. 예를 들어 Redis Pub/Sub 같은 구조를 두고, 토큰 발급 이벤트를 발행한 뒤 각 인스턴스가 자신에게 연결된 유저에게 전달해야 한다.

[인스턴스 B] 토큰 발급 → Redis Pub/Sub 채널에 발행
[인스턴스 A] 구독 중 → 유저 1 토큰 수신 → 유저 1 SSE 커넥션에 push ✓

 

즉, SSE를 도입하면 단순한 순번 전달이 아니라 이벤트 브로커 구성과 인스턴스 간 라우팅 문제까지 함께 고려해야 한다.

 

다만 SSE는 서버에서 클라이언트로 이벤트를 지속적으로 전달해야 하는 경우에 매우 잘 맞는다.
알림, 진행 상태 스트리밍, 실시간 모니터링처럼 단방향 Push가 핵심인 문제에서는 Polling보다 훨씬 자연스러운 모델이 된다.

 


 

Long Polling을 선택하지 않은 이유

Long Polling도 Polling보다 실시간성이 좋다.
하지만 이 프로젝트에서는 그 이점이 결정적이지 않았다.

 

Long Polling은 요청 수를 줄일 수 있지만, 결국 서버가 대기 요청을 붙잡고 있어야 하고, 순번 변경 시점에 어떤 요청에 응답할지 관리해야 한다. 구현 난이도는 Polling보다 높고, 구조적으로는 이벤트 기반 감지가 필요해진다.

 

반면 이 프로젝트의 순번 조회는 매우 단순했다.
요청이 들어오면 Redis에서 현재 순번을 읽어 반환하면 끝이다.

 

즉, 조회 비용이 충분히 낮은 상황에서 Long Polling의 복잡도를 감수할 이유가 크지 않았다.

 

다만 Long Polling은 변경이 자주 발생하지 않지만, 발생 시 즉시 반영이 필요한 경우에는 여전히 유효하다.
SSE까지 도입할 정도는 아니지만, Polling의 불필요한 요청 수를 줄이고 싶을 때 좋은 절충안이 될 수 있다.

 


 

Polling으로도 충분했던 이유

이 프로젝트에서 순번 조회는 Redis ZRANK 한 번으로 끝난다.
조회 비용은 매우 낮고, 응답도 빠르다.

 

그렇다면 남는 질문은 하나다.

굳이 더 복잡한 실시간 전송 구조가 필요한가?

 

이 시스템에서는 그렇지 않았다.
대기열 순번은 주식 호가처럼 밀리초 단위 반응이 중요한 데이터가 아니다. 사용자는 “대략 지금 어느 정도 남았는지”를 확인하면 된다. 이 맥락에서는 1~3초 수준의 지연은 충분히 허용 가능하다.

 

결국 이 문제는 실시간성 자체보다 복잡도 대비 이득의 문제였다.

 


 

Polling의 부하 문제와 적응형 폴링

Polling의 단점은 대기 인원이 많을수록 요청이 많아진다는 것이다.

대기 인원 1,000명 × 1초 주기 = 초당 1,000건 (순번 조회만으로)
대기 인원 5,000명 × 1초 주기 = 초당 5,000건

 

하지만 모든 유저가 같은 주기로 순번을 확인할 필요는 없다.
순번이 높을수록 입장까지 시간이 더 남아 있으므로, 조회 주기를 길게 잡아도 사용자 경험에는 큰 차이가 없다.

 

async function pollPosition(userId) {
  const res = await fetch(`/api/v1/queue/position`, { headers: { 'X-User-Id': userId } });
  const { position, token } = await res.json();

  if (token) {
    startOrder(token); // 토큰 발급됨 → 주문 진행
    return;
  }

  // 순번에 따라 다음 조회 주기 결정
  const delay = position <= 100 ? 1000
              : position <= 500 ? 3000
              : 5000;

  setTimeout(() => pollPosition(userId), delay);
}

 

이렇게 하면 순번이 낮은 유저에게는 더 자주 업데이트를 보여주고, 순번이 높은 유저에게는 요청 빈도를 줄여 부하를 완화할 수 있다.

 

예를 들어 대기 인원이 5,000명일 때:

대기 인원 5,000명
  순번 1~100   (100명) × 1초  =  100건/s
  순번 101~500 (400명) × 3초  =  133건/s
  순번 501~    (4500명) × 5초 =  900건/s
  합계: 1,133건/s  (단일 주기 대비 77% 감소)

 

모든 유저가 1초마다 조회하는 경우의 5,000건/s와 비교하면, 요청 수를 크게 줄일 수 있다.

 


 

정리

Polling, Long Polling, SSE 중 무엇이 더 “좋은가”는 절대적인 문제가 아니다.
중요한 것은 지금 시스템에서 무엇이 더 적절한가다.

 

Long Polling과 SSE는 더 실시간에 가깝지만, 그만큼 서버가 연결과 이벤트를 더 오래, 더 복잡하게 관리해야 한다. 특히 SSE는 로드밸런서 설정, heartbeat, 인스턴스 간 이벤트 전파까지 고려해야 하므로 단순한 전송 방식 이상의 선택이 된다.

 

반면 이 프로젝트에서는 순번 조회가 Redis 기반으로 충분히 빠르고, 수초의 지연도 허용 가능했다. 그렇다면 실시간성을 위해 구조를 복잡하게 만들기보다, 단순한 Polling을 선택하고 필요하면 적응형 폴링으로 부하를 줄이는 쪽이 더 합리적이다.