프로덕션 배포: WebSockets용 Nginx, uWSGI 및 Gunicorn

발행: (2025년 12월 17일 오전 05:32 GMT+9)
13 min read
원문: Dev.to

Source: Dev.to

개발 서버를 넘어서는 단계

Python WebSocket 개발에서 흔히 발생하는 실수는 로컬 환경에서 socketio.run(app) 로 완벽히 동작하던 코드를 바로 프로덕션 컨테이너에 옮기는 것입니다. socketio.run() 은 애플리케이션을 개발 서버(보통 Werkzeug 혹은 기본 Eventlet/Gevent 러너)로 감싸지만, 공개 인터넷에 필요한 견고함이 부족합니다. 프로세스 관리가 없고, 로깅 기능이 제한적이며, SSL 종료를 효율적으로 처리하지 못합니다.

프로덕션 WebSocket 아키텍처는 특화된 스택을 요구합니다. Python 애플리케이션 서버(Gunicorn 또는 uWSGI)가 동시 그린릿을 관리하고, 리버스 프록시(Nginx)가 연결 협상, SSL 종료 및 정적 자산 제공을 담당합니다. 이러한 역할 분리는 Python 프로세스가 애플리케이션 로직과 메시지 전달에 집중하도록 하며, 소켓 버퍼 관리나 암호화 오버헤드에 신경 쓰지 않게 합니다.

Architecture Overview

견고한 프로덕션 환경에서는 요청 흐름이 일반적인 HTTP REST API와 크게 다릅니다. 지속적인 연결을 설정하고 유지해야 합니다.

아키텍처는 다음과 같은 흐름을 따릅니다:

  • Client: HTTP(폴링) 또는 직접 WebSocket(지원/구성된 경우)을 통해 연결을 시작합니다.
  • Nginx (Reverse Proxy): SSL을 종료하고 정적 자산을 제공하며 헤더를 검사합니다. 특히 Upgrade 헤더를 식별하고 TCP 연결을 열어 둬서 클라이언트를 업스트림 서버와 연결합니다.
  • Application Server (Gunicorn/uWSGI): WSGI 컨테이너. 표준 동기 워커(블로킹)와 달리, 이 레이어는 비동기 워커(eventlet 또는 gevent)를 사용하여 단일 OS 스레드에서 수천 개의 동시 열린 소켓 연결을 유지해야 합니다.
  • Flask‑SocketIO: 이벤트 로직, 룸, 네임스페이스를 처리하는 애플리케이션 레이어입니다.

System Architecture diagram showing WebSocket communication flow

WebSocket을 위한 Nginx 설정

Nginx는 기본적으로 WebSocket을 프록시하지 않습니다. 초기 핸드쉐이크 요청을 일반 HTTP로 처리하며, 별도의 설정이 없으면 프로토콜 전환에 필요한 Upgrade 헤더를 제거합니다. 또한, HTTP 응답을 최적화하기 위해 설계된 Nginx의 기본 버퍼링 메커니즘은 버퍼가 가득 찰 때까지 패킷을 보류함으로써 WebSocket의 실시간 특성을 심각하게 손상시킵니다.

핵심 지시문

WebSocket을 정상적으로 프록시하려면 Nginx location 블록에 다음 세 가지 수정을 적용해야 합니다:

  1. 프로토콜 업그레이드: UpgradeConnection 헤더를 명시적으로 전달합니다. Connection 헤더 값은 "Upgrade" 로 설정해야 합니다.
  2. 버퍼링 비활성화: proxy_buffering off; 은 Flask‑SocketIO 이벤트가 클라이언트에게 즉시 플러시되도록 보장합니다.
  3. HTTP 버전: WebSocket은 HTTP/1.1을 필요로 합니다; proxy_pass의 기본값인 HTTP/1.0은 Upgrade 메커니즘을 지원하지 않습니다.

프로덕션 구성 블록

# Define the upstream - crucial for load balancing later
upstream socketio_nodes {
    ip_hash;               # Critical for Sticky Sessions (see Section 5)
    server 127.0.0.1:5000;
}

server {
    listen 80;
    server_name example.com;

    location /socket.io {
        include proxy_params;
        proxy_http_version 1.1;
        proxy_buffering off;

        # The Upgrade Magic
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";

        # Forward to Gunicorn/uWSGI
        proxy_pass http://socketio_nodes/socket.io;

        # Prevent Nginx from killing idle websocket connections
        proxy_read_timeout 86400;
    }
}

proxy_read_timeout은 매우 중요합니다. 기본적으로 Nginx는 60초 동안 데이터가 전송되지 않으면 연결을 종료할 수 있습니다. Socket.IO는 하트비트를 제공하지만, 이 타임아웃을 늘리면 조용한 클라이언트를 과도하게 정리하는 것을 방지할 수 있습니다.

Nginx를 통한 WebSocket 핸드쉐이크

Gunicorn vs uWSGI for WebSockets

올바른 애플리케이션 서버를 선택하는 것은 종종 논쟁거리가 됩니다. Gunicorn과 uWSGI 모두 충분히 사용할 수 있지만, Flask‑SocketIO에 대한 비동기 모드 처리 방식은 근본적으로 다릅니다.

Gunicorn: 권장 표준

Gunicorn은 Flask‑SocketIO 배포 시 eventlet 및 gevent 워커를 별도의 복잡한 컴파일 플래그나 오프로드 메커니즘 없이 기본적으로 지원하기 때문에 일반적으로 선호됩니다.

  • Worker Class: greenlet 기반 워커를 지정해야 합니다. 표준 sync 워커는 첫 번째 WebSocket 연결에서 차단되어 다른 사용자에 대한 응답이 불가능해집니다.¹
  • Command: gunicorn --worker-class eventlet -w 1 module:app
  • Concurrency: Eventlet을 사용하는 단일 Gunicorn 워커만으로도 수천 명의 동시 클라이언트를 처리할 수 있습니다. 워커를 추가(-w 2+)하려면 메시지 큐(Redis)와 스티키 세션이 필요합니다.

uWSGI: 강력하지만 복잡

uWSGI는 성능이 뛰어난 C 기반 서버이지만 WebSocket 사용 시 학습 곡선이 가파릅니다. 자체 네이티브 WebSocket 지원이 Flask‑SocketIO 라이브러리에서 사용하는 Gevent/Eventlet 루프와 충돌하는 경우가 많습니다.

uWSGI를 작동시키려면 일반적으로 두 가지 경로가 있습니다:

  • Gevent Mode: Gevent 루프를 활성화한 상태로 uWSGI를 실행합니다(--gevent 1000).
  • Native … (내용 생략)

bSocket Offloading
uWSGI의 HTTP WebSocket 지원(--http-websockets)을 사용합니다. 이를 위해서는 SSL 및 WebSocket 지원을 포함하여 uWSGI를 컴파일해야 하는데, pip 패키지에서는 기본값이 아닐 수 있습니다.¹

Verdict
Flask‑SocketIO와 함께 간단하고 안정적인 운영을 원한다면 Gunicorn을 사용하십시오. 특정 고급 기능이 필요하거나 기존 인프라가 uWSGI를 강제하는 경우에만 uWSGI를 고려하세요.

Gunicorn vs. uWSGI

일반적인 운영 오류

WebSocket을 배포하면 종종 난해한 오류가 발생합니다. 가장 흔한 운영 문제는 다음과 같습니다.

“400 Bad Request” (Session ID Unknown)

원인: 로드 밸런싱 오류. Socket.IO는 HTTP 롱 폴링으로 시작하며 여러 요청(핸드쉐이크, 포스트 데이터, 폴 데이터)을 보냅니다. Gunicorn 워커가 여러 개(-w 2 등) 있거나 서버 노드가 여러 대인 경우, 로드 밸런서(Nginx)가 두 번째 요청을 첫 번째와 다른 워커에 전달하면 새 워커가 세션 정보를 기억하고 있지 않아 연결이 실패합니다.

해결: 스티키 세션을 활성화합니다. Nginx에서는 upstream 블록에 ip_hash 지시자를 사용해 클라이언트를 IP 기반으로 동일한 백엔드에 라우팅합니다.

upstream backend {
    ip_hash;
    server 127.0.0.1:8000;
    server 127.0.0.1:8001;
}

“400 Bad Request” (Handshake Error)

원인: Upgrade 헤더가 제거되었거나 형식이 잘못되었습니다.

해결: Nginx 설정에 다음 라인이 포함되어 있는지 확인합니다.

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";

“502 Bad Gateway”

원인: Gunicorn/uWSGI에 접근할 수 없거나 충돌하고 있습니다.

해결:

  1. 애플리케이션이 올바른 인터페이스(0.0.0.0 vs. 127.0.0.1)에 바인드되어 있는지 확인합니다.
  2. Nginx의 upstream 포트가 Gunicorn 바인드 포트와 일치하는지 확인합니다.
  3. 비동기 워커 내부에 블로킹 호출이 있어 greenlet 루프가 멈추고 헬스‑체크가 실패하지 않는지 점검합니다.

SSL 종료와 WSS

운영 환경에서는 절대로 Python 애플리케이션 내부에서 SSL/TLS를 처리하지 마세요—암호화는 CPU 집약적입니다. SSL 종료는 Nginx 수준(또는 클라우드 로드 밸런서)에서 수행합니다.

흐름

  1. 클라이언트가 wss://example.com(Secure WebSocket)으로 연결합니다.
  2. Nginx가 SSL 인증서를 사용해 트래픽을 복호화합니다.
  3. Nginx가 복호화된 트래픽을 로컬 루프백 네트워크를 통해 http://(또는 ws://) 형태로 Gunicorn에 전달합니다.

헤더 전달

Flask‑SocketIO가 원래 요청이 보안 연결이었다는 것을 알 수 있도록 프로토콜 헤더를 전달합니다.

proxy_set_header X-Forwarded-Proto $scheme;

flask‑talisman 등 보안 확장을 사용할 경우, 이 헤더를 전달하지 않으면 Nginx가 이미 HTTPS 업그레이드를 수행했음에도 애플리케이션이 계속 HTTPS 강제 적용을 시도해 무한 리다이렉트 루프가 발생합니다.

Nginx 또는 로드 밸런서에서 SSL 종료

결론

Flask‑SocketIO를 프로덕션에 배포하려면 아키텍처적 사고 전환이 필요합니다. 간단한 socketio.run(app) 명령을 고성능 Gunicorn 배포로 교체하고, eventlet 또는 gevent 워커를 사용해 높은 동시성을 처리해야 합니다. Nginx는 중요한 구성 요소가 되며, WebSocket 업그레이드를 허용하고 버퍼링을 비활성화하도록 명시적인 설정이 필요합니다.

프로덕션 성공은 세 가지 핵심 요소에 달려 있습니다:

  1. 동시성 – 올바른 비동기 워커 클래스를 사용합니다.
  2. 지속성 – Socket.IO 프로토콜을 지원하기 위해 스티키 세션(ip_hash)을 구성합니다.
  3. 보안 – SSL 종료를 리버스 프록시로 오프로드합니다.

이러한 패턴을 따름으로써, 취약한 개발 프로토타입을 견고하고 확장 가능한 실시간 시스템으로 전환할 수 있습니다.

Back to Blog

관련 글

더 보기 »