본문 바로가기
C.W.K.
Stream
Lesson 01 of 05 · published

폴링에서 롱 폴링까지 — 기다림의 비용

~10 min · streaming-async, polling, long-polling

Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"서버의 변경을 알아내는 가장 단순한 방법은 반복해서 묻는 거야. 고정 주기 폴링은 단순한 대신 빈 왕복과 대기 시간이 생기고, 롱 폴링은 응답을 잠시 보류해 그 낭비를 줄여. SSE와 WebSocket이 널리 쓰이는 지금도 환경과 요구 사항에 따라 두 방식은 여전히 유효해."

고정 주기 폴링 — 같은 질문을 되풀이하기

클라이언트가 N초마다 서버에 "새로 바뀐 게 있어?"라고 물어봐. 변경 사항이 없으면 서버는 빈 결과를 반환하고, 클라이언트는 잠시 기다렸다가 다시 요청해. 구현과 디버깅이 쉽고 일반적인 HTTP 인프라에서 잘 동작하지만, 모든 프록시와 네트워크 환경에서 무조건 안전하다고 볼 수는 없어.

그 단순함에는 다음과 같은 비용이 따라:

  • 지연. 이벤트 도착 시점이 고르게 분포한다면 추가 대기 시간은 평균 N/2야. 5초마다 확인하면 0~5초, 평균 2.5초를 더 기다릴 수 있어.
  • 전송량. 빈 응답도 요청 줄과 헤더, 상태 줄과 응답 헤더를 주고받아. 클라이언트 10,000개가 5초마다 확인하면 아무 변화가 없어도 초당 2,000건의 요청이 발생해.
  • 서버 부하. 새로운 정보가 없는 유휴 클라이언트도 엔드포인트와 그 뒤의 저장소를 계속 두드릴 수 있어.

빌드 진행률처럼 몇 초의 지연을 허용할 수 있는 상태 조회, 백그라운드 작업 감시, 구현 단순성이 더 중요한 기능에는 충분히 좋은 선택이야.

롱 폴링 — 응답을 잠시 보류하기

롱 폴링도 클라이언트가 요청을 시작한다는 점은 같아. 다만 서버에 새 데이터가 없으면 바로 빈 응답을 보내지 않고, 이벤트가 생기거나 정해 둔 대기 시간이 끝날 때까지 요청을 보류해. 서버는 이벤트나 시간 만료를 나타내는 응답을 보내고, 클라이언트는 응답을 받은 직후 다음 롱 폴링 요청을 시작해. 약 30초는 흔한 예일 뿐이며 실제 값은 서버와 중간 장치의 제한에 맞춰 정해야 해.

고정 주기 폴링과 비교하면 다음 두 가지가 좋아져:

  • 짧은 알림 지연. 이미 대기 중인 요청이 있으므로 서버는 데이터가 준비되는 즉시 그 응답에 실어 보낼 수 있어. 다음 확인 시점까지 기다릴 필요가 없어.
  • 낮은 유휴 요청률. 5초마다 새 요청을 보내는 대신 이벤트가 발생하거나 서버의 대기 시간이 끝날 때마다 한 번씩 요청해.

대신 이런 비용을 고려해야 해:

  • 오래 유지되는 대기 요청. HTTP/1.1에서는 대기 요청이 연결 하나를 점유할 수 있고, HTTP/2에서는 스트림을 점유해. FastAPI, Node.js, Go처럼 비동기 I/O를 잘 지원하는 런타임은 많은 대기 요청을 비교적 효율적으로 다루지만, 요청마다 작업 스레드나 프로세스를 붙잡는 구성은 빠르게 한계에 닿을 수 있어.
  • 프록시와 시간 제한의 간섭. 프록시, 로드 밸런서, CDN이 일정 시간 동안 바이트가 흐르지 않는 연결을 닫을 수 있어. 서버의 롱 폴링 대기 시간은 실제 경로에서 가장 짧은 유휴 시간 제한보다 여유 있게 짧아야 해.

동시성 모델이 비용을 바꾼다

요청마다 스레드나 프로세스를 점유하는 동기식 구성에서는 대기 중인 롱 폴링 요청 수만큼 실행 자원이 묶여 대규모 운영이 어려워져. 반면 Python asyncio, Node.js의 이벤트 루프, Go의 고루틴처럼 대기 작업을 비동기적으로 처리하는 모델은 같은 수의 요청을 더 적은 실행 자원으로 유지할 수 있어. 다만 소켓, 버퍼, 애플리케이션 상태 같은 연결별 비용은 여전히 남으므로 실제 측정과 용량 계획이 필요해.

롱 폴링은 일반 HTTP 요청과 응답만으로 낮은 지연의 알림을 구현하는 호환성 높은 패턴이야. 별도의 프로토콜 업그레이드는 필요 없고 클라이언트도 보통 기존 HTTP 기능으로 구현할 수 있어. 장기 스트림을 안정적으로 통과시키기 어려운 환경에서는 SSE나 WebSocket의 현실적인 대체 경로가 될 수 있지만, 프록시의 시간 제한과 서버의 동시성 비용은 반드시 확인해야 해.

롱 폴링과 SSE·WebSocket의 경계

다음과 같은 상황에서는 롱 폴링이 더 다루기 쉬울 수 있어:

  • 중간 장치가 무기한 스트림은 끊지만 일정 시간 안에 끝나는 보류 요청은 허용하는 환경.
  • 각 응답을 기존 HTTP 인증·권한·관측 체계 안에서 독립된 요청 단위로 처리하고 싶은 경우. 다만 응답 성공 자체가 애플리케이션 이벤트 처리 완료를 보장하는 확인 응답은 아니야.

반대로 다음 상황에서는 SSE나 WebSocket이 더 잘 맞아:

  • 이벤트 빈도가 높아 응답할 때마다 롱 폴링 요청을 다시 여는 비용이 커지는 경우.
  • 작은 이벤트가 계속 이어지는 단방향 스트림이 필요한 경우. 이 흐름에는 SSE가 맞아.
  • 양쪽이 서로 독립적으로 자주 데이터를 보내야 하는 경우. 이때는 WebSocket을 검토해.

cwkPippa의 스트리밍 구성

cwkPippa는 AI 토큰 스트림에 SSE를 사용하고, 스트리밍이 필요 없는 엔드포인트는 일반적인 요청과 응답으로 처리해. Cinder Sidekick처럼 양쪽이 독립적으로 상태와 명령을 보내야 하는 채널에는 WebSocket을 사용해. 현재 배포된 클라이언트가 필요한 스트리밍 방식을 지원하므로 롱 폴링은 사용하지 않지만, 제한된 네트워크 환경을 지원해야 하는 서비스에서는 롱 폴링을 호환성 대체 경로로 둘 수 있어.

Code

고정 주기 폴링 — N초마다 확인하고 빈 응답도 감수하기·python
# Naive polling — client 쪽
import httpx, time

while True:
    resp = httpx.get('https://api.example.com/inbox/poll?since_id=last_seen')
    events = resp.json()['events']
    for event in events:
        handle(event)
    time.sleep(5)  # 5초 마다 poll

# 낭비: 대부분 poll 이 빈 응답.
FastAPI 롱 폴링 서버 — 이벤트 또는 시간 만료까지 요청 보류·python
# Long-polling — server 쪽 (FastAPI + asyncio)
import asyncio
from fastapi import FastAPI, Request

app = FastAPI()
_event_queues: dict[str, asyncio.Queue] = {}

@app.get('/inbox/poll')
async def long_poll(client_id: str, request: Request):
    queue = _event_queues.setdefault(client_id, asyncio.Queue())
    # 최대 30s 동안 event 기다림 (server-쪽 timeout)
    try:
        event = await asyncio.wait_for(queue.get(), timeout=30.0)
        return {'event': event}
    except asyncio.TimeoutError:
        # 30s 내 event 없음 — client 가 즉시 재 poll 하도록 빈 거 돌려줌
        return {'event': None}

# 뭔가 일어나면 queue 에 push (어느 consumer 든):
async def publish_to(client_id: str, event: dict):
    queue = _event_queues.get(client_id)
    if queue:
        await queue.put(event)

# Client 쪽 — naive polling 과 같은 shape, sleep 불필요:
# while True:
#     resp = httpx.get('https://api.example.com/inbox/poll', params={'client_id': 'me'})
#     event = resp.json()['event']
#     if event: handle(event)
#     # 즉시 loop — server 가 다음 request 보유
롱 폴링 클라이언트 — 응답 직후 다음 요청 시작·javascript
// Long-polling — client 쪽 (브라우저 fetch loop)
async function longPoll(clientId) {
  while (true) {
    try {
      const resp = await fetch(`/inbox/poll?client_id=${clientId}`);
      const data = await resp.json();
      if (data.event) {
        handle(data.event);
      }
      // sleep 없음 — 즉시 재-poll. Server 가 다음 event 나 timeout 까지 보유.
    } catch (e) {
      // 네트워크 에러 — flapping server 두드림 피하려 잠시 back off
      await new Promise(r => setTimeout(r, 1000));
    }
  }
}

longPoll('me');

External links

Exercise

새 이벤트가 도착하거나 30초가 지날 때까지 GET /events?since_id=X 요청을 보류하는 FastAPI 롱 폴링 엔드포인트를 만들어 봐. client_id마다 asyncio.Queue를 두고, POST /publish가 해당 대기열에 이벤트를 넣도록 구현해. 롱 폴링과 5초 간격의 고정 주기 폴링을 각각 실행한 뒤 게시 시각부터 수신 시각까지의 지연을 여러 번 측정해 비교해.
Hint
client_id를 asyncio.Queue에 매핑하는 dict를 만들고, asyncio.wait_for로 queue.get()의 대기 시간을 제한하면 돼. 게시 엔드포인트에서는 queue.put_nowait(이벤트)를 호출해. 롱 폴링 클라이언트는 응답을 처리한 직후 다음 요청을 시작하고, 고정 주기 폴링 클라이언트는 5초마다 조회해. 단 한 번의 수치나 약속된 10ms를 가정하지 말고 여러 번 측정해 분포와 평균을 비교해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 피파warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.