🌍 네트워크

Long Polling

Long Polling

Long Polling은 서버가 새 데이터를 보유할 때까지 연결을 유지하는 HTTP 기반 실시간 통신 기법. WebSocket/SSE 이전 기술로 타임아웃 처리가 핵심.

📖 상세 설명

Long Polling(롱 폴링)은 HTTP 프로토콜 위에서 실시간에 가까운 통신을 구현하기 위한 기법입니다. 클라이언트가 서버에 요청을 보내면, 서버는 새로운 데이터가 있을 때까지 응답을 보류하고 연결을 유지합니다. 데이터가 준비되면 응답을 보내고, 클라이언트는 즉시 다시 새로운 요청을 시작합니다. 이 사이클이 반복되면서 실시간 통신 효과를 얻습니다.

일반 Polling과의 차이점이 중요합니다. 일반 Polling은 클라이언트가 주기적으로(예: 1초마다) 서버에 요청을 보내 새 데이터가 있는지 확인합니다. 반면 Long Polling은 서버가 데이터를 가질 때까지 연결을 유지하므로, 불필요한 요청을 줄이고 데이터 전달 지연시간을 최소화합니다. 2005년경 Gmail, Google Maps 등에서 채택되면서 널리 알려졌습니다.

Long Polling의 핵심은 타임아웃 처리입니다. 서버는 영원히 연결을 유지할 수 없으므로, 일정 시간(보통 20~30초) 후 빈 응답이나 heartbeat를 보내고 연결을 종료합니다. 클라이언트는 이를 받으면 즉시 새 요청을 시작합니다. 이 타임아웃은 방화벽, 프록시 서버의 연결 종료를 방지하고, 끊어진 연결을 감지하는 역할을 합니다.

WebSocket과 SSE(Server-Sent Events)가 등장하면서 Long Polling의 사용은 줄었지만, 여전히 레거시 시스템이나 HTTP만 지원하는 환경(일부 기업 방화벽, 오래된 프록시)에서 사용됩니다. Facebook 메신저가 2010년대 초까지 Long Polling을 사용했던 대표적인 사례입니다.

📊 실시간 통신 방식 비교

방식 연결 유지 지연시간 서버 부하 브라우저 지원
Polling 매 요청마다 새 연결 폴링 간격만큼 높음 (불필요한 요청) 모든 브라우저
Long Polling 데이터까지 유지 거의 실시간 중간 모든 브라우저
SSE 단방향 지속 연결 실시간 낮음 IE 제외 대부분
WebSocket 양방향 지속 연결 실시간 낮음 모던 브라우저

💻 코드 예제

// Long Polling 클라이언트 구현
class LongPollingClient {
    constructor(url) {
        this.url = url;
        this.isRunning = false;
        this.retryDelay = 1000;  // 에러 시 재시도 지연
        this.maxRetries = 5;
        this.retryCount = 0;
    }

    // 폴링 시작
    async start() {
        this.isRunning = true;
        this.poll();
    }

    // 폴링 중지
    stop() {
        this.isRunning = false;
    }

    async poll() {
        if (!this.isRunning) return;

        try {
            // 타임아웃 설정 (서버보다 길게)
            const controller = new AbortController();
            const timeout = setTimeout(() => controller.abort(), 35000);

            const response = await fetch(this.url, {
                method: 'GET',
                signal: controller.signal,
                headers: { 'Accept': 'application/json' }
            });

            clearTimeout(timeout);

            if (response.ok) {
                const data = await response.json();
                this.retryCount = 0;  // 성공 시 재시도 카운트 리셋

                if (data.type !== 'timeout') {
                    this.onMessage(data);  // 새 데이터 처리
                }
            }

            // 즉시 다음 폴링 시작
            this.poll();

        } catch (error) {
            if (error.name === 'AbortError') {
                console.log('요청 타임아웃, 재연결...');
                this.poll();
            } else {
                console.error('폴링 에러:', error);
                this.handleError();
            }
        }
    }

    handleError() {
        if (this.retryCount < this.maxRetries) {
            this.retryCount++;
            const delay = this.retryDelay * Math.pow(2, this.retryCount);
            console.log(`${delay}ms 후 재시도 (${this.retryCount}/${this.maxRetries})`);
            setTimeout(() => this.poll(), delay);
        } else {
            console.error('최대 재시도 횟수 초과');
            this.onError('연결 실패');
        }
    }

    onMessage(data) { console.log('새 메시지:', data); }
    onError(error) { console.error('에러:', error); }
}

// 사용 예시
const client = new LongPollingClient('/api/events');
client.onMessage = (data) => {
    document.getElementById('messages').innerHTML += `

${data.message}

`; }; client.start();
const express = require('express');
const app = express();

// 대기 중인 클라이언트들
const waitingClients = new Map();
let clientId = 0;

// Long Polling 엔드포인트
app.get('/api/events', (req, res) => {
    const id = ++clientId;
    const timeout = 30000;  // 30초 타임아웃

    console.log(`클라이언트 ${id} 연결`);

    // 타임아웃 설정
    const timer = setTimeout(() => {
        waitingClients.delete(id);
        res.json({ type: 'timeout', message: 'No new data' });
    }, timeout);

    // 클라이언트 등록
    waitingClients.set(id, { res, timer });

    // 연결 종료 시 정리
    req.on('close', () => {
        clearTimeout(timer);
        waitingClients.delete(id);
        console.log(`클라이언트 ${id} 연결 종료`);
    });
});

// 새 메시지 발행 (모든 대기 클라이언트에게 전송)
app.post('/api/broadcast', express.json(), (req, res) => {
    const message = req.body;

    // 모든 대기 클라이언트에게 메시지 전송
    waitingClients.forEach((client, id) => {
        clearTimeout(client.timer);
        client.res.json({
            type: 'message',
            data: message,
            timestamp: Date.now()
        });
    });

    const count = waitingClients.size;
    waitingClients.clear();

    res.json({ success: true, deliveredTo: count });
});

// 서버 상태 확인
app.get('/api/status', (req, res) => {
    res.json({
        waitingClients: waitingClients.size,
        uptime: process.uptime()
    });
});

app.listen(3000, () => {
    console.log('Long Polling 서버 실행 중 (포트 3000)');
});
from flask import Flask, request, jsonify
import time
import threading
from queue import Queue, Empty

app = Flask(__name__)

# 메시지 큐 (실제로는 Redis Pub/Sub 등 사용)
message_queues = {}
lock = threading.Lock()

@app.route('/api/events')
def long_poll():
    """Long Polling 엔드포인트"""
    client_id = request.args.get('client_id', str(time.time()))
    timeout = 30  # 30초 타임아웃

    # 클라이언트별 큐 생성
    with lock:
        if client_id not in message_queues:
            message_queues[client_id] = Queue()

    queue = message_queues[client_id]

    try:
        # 타임아웃까지 메시지 대기
        message = queue.get(timeout=timeout)
        return jsonify({
            'type': 'message',
            'data': message,
            'timestamp': time.time()
        })
    except Empty:
        # 타임아웃 - 빈 응답 반환
        return jsonify({
            'type': 'timeout',
            'message': 'No new data'
        })

@app.route('/api/broadcast', methods=['POST'])
def broadcast():
    """모든 대기 클라이언트에게 메시지 전송"""
    data = request.json

    delivered = 0
    with lock:
        for client_id, queue in message_queues.items():
            queue.put(data)
            delivered += 1

    return jsonify({
        'success': True,
        'delivered_to': delivered
    })

@app.route('/api/status')
def status():
    """서버 상태 확인"""
    with lock:
        return jsonify({
            'active_clients': len(message_queues)
        })

if __name__ == '__main__':
    app.run(port=3000, threaded=True)

🗣️ 실무에서 이렇게 말하세요

💬 기술 선택 회의에서
"실시간 알림 기능인데, 고객사 방화벽이 WebSocket을 막고 있어요. Long Polling으로 구현하면 HTTP만 사용하니까 방화벽 문제를 피할 수 있습니다. 타임아웃은 30초로 설정하고, 프록시 타임아웃보다 짧게 유지하면 됩니다."
💬 면접에서
"Long Polling은 서버가 데이터를 가질 때까지 HTTP 연결을 열어두는 Comet 기법의 하나입니다. WebSocket이 없던 시절 Gmail이 이 방식으로 실시간 메일 알림을 구현했습니다. 핵심은 타임아웃 처리와 재연결 로직인데, 클라이언트는 응답을 받자마자 새 요청을 시작해야 합니다."
💬 성능 최적화 논의에서
"현재 Long Polling으로 동시 1만 명 처리 중인데, 각 연결이 서버 스레드를 점유하고 있어서 리소스 효율이 낮습니다. Node.js나 Python asyncio처럼 비동기 I/O를 사용하거나, SSE로 마이그레이션하면 동일 서버로 10배 더 많은 연결을 처리할 수 있습니다."

⚠️ 흔한 실수 & 주의사항

타임아웃 설정 무시

서버 타임아웃 없이 영원히 연결을 유지하면 프록시/방화벽이 연결을 끊거나 좀비 연결이 누적됩니다. 반드시 20~30초 타임아웃을 설정하고, 클라이언트 타임아웃은 서버보다 길게(예: 35초) 설정하세요.

재연결 로직 누락

네트워크 에러나 서버 재시작 시 폴링이 멈춥니다. 지수 백오프(exponential backoff)를 적용한 재연결 로직을 반드시 구현하세요. 첫 재시도 1초, 이후 2, 4, 8초... 최대 30초까지.

동기 서버에서 스레드 고갈

Apache/PHP 같은 스레드 기반 서버에서 Long Polling을 구현하면, 각 연결이 스레드를 점유해 서버가 금방 마비됩니다. Node.js, Python asyncio, Go 등 비동기 I/O 기반 서버를 사용하세요.

올바른 Long Polling 구현

서버 타임아웃 30초, 클라이언트 타임아웃 35초 설정. 지수 백오프 재연결 로직 필수. 비동기 서버 사용. 연결 수 제한과 모니터링 필수. 가능하다면 WebSocket이나 SSE로 마이그레이션 검토.

🔗 관련 용어

📚 더 배우기