WebSocket 대규모 연결을 위한 설계

강동현·2025년 4월 27일
0

Spring Websocket

목록 보기
8/9

websocket을 수천, 수만 개의 동시 연결 처리해야하는 경우가 많고 이는 서버에 상당한 부담을 주게 된다. 단순한 기능 구현을 넘어서 대규모 트래픽에서도 안정적인 서비스를 제공하기 위한 아키텍처의 설계에 대한 얘기를 해보려고 한다.

서버 리소스 관리

WebSocket 서버는 연결 자체가 리소스를 소모하게 되는데 대규모 연결 시 부터 가장 먼저 부딪히는 병목 지점은 서버의 기본 자원들이다.

메모리 : 연결 수와 압축의 영향

WebSocket 연결 하나하나는 서버의 메모리를 점유하게 되는데 연결의 수가 많아지면 메모리 부족은 심각한 문제가 될 수 있다. 각 연결의 메모리 사용량은 라이브러리 자체 사용량, 애플리케이션 로직, 송수신 버퍼등이 영향을 받을 수 있다.

특히 메시지 압축 사용 여부에 따라 메모리 사용량에 큰 영향을 준다. 네트워크 대역폭은 크게 절약하지만 각 연결마다 압축 상태를 유지해야 되기 때문에 연결당 메모리 요구량이 크게 증가한다. 메모리가 극도로 제한된 환경이라면 압축은 비활성화 하는 것이 좋다.

또한 메시지 송수신 버퍼 크기를 관리하고 제한하는 것이 메모리 사용량을 예측가능하게 하니까 이도 생각하면서 설계를 하면 좋다.

CPU : 단순 연결 유지를 넘어서

일반적으로 유휴 상태의 WebSocket 연결은 CPU를 많이 소모하지 않는다. Nginx와 같은 효율적인 프록시와 같은 경우 5만 개의 유휴 연결도 CPU 1코어 미만으로 처리가 가능하다. 하지만 다음과 같은 경우 CPU가 병목이 될 수 있다.

  • 높은 메시지 처리량 : 초당 처리 메시지 수가 많을수록 부하 증가
  • 복잡한 처리 로직 : 메시지당 수행 작업이 복잡할수록 증가
  • 압축/해제 : 상당한 CPU 자원을 소모함
  • 비효율적인 역직렬화 : JSON 파싱 등은 바이너리 형식보다 CPU 사용량이 높다.
  • 서버 구현 : 블로킹 vs 논블로킹에 따른 효율성 차이도 존재한다.

파일 디스크립터(FD) : 첫 번째 문

리눅스와 유닉스 시스템에서 모든 네트워크 연결은 파일 디스크립터를 사용한다. 즉 WebSocket 연결 1개 = FD 1개이다. 대부분의 OS는 프로세스당 FD 수에 기본 제한을 두는데 리눅스의 경우 기본 제한이 1024이다.

이는 대규모 연결 처리 시 가장 먼저 부딪히는 장벽이다. CPU와 메모리가 아무리 충분해도 FD 제한에 걸리면 더이상 연결을 받을 수 없다.

  • 연결 제한 확인
    • 시스템 전체 : cat /proc/sys/fs/file-max
    • 프로세스당 : ulimit -Sn, ulimit -Hn (각각 소프트와 하드)
  • 제한 늘리기(영구 변경)
    • 시스템 전체: /etc/sysctl.conf파일에 fs.file-max = NEW_LIMIT 추가후 sysctl -p 실행
    • 프로세스당 : /etc/security/limits.conf 파일에 WebSocket 서버 실행 계정에 대해 soft nofile 및 hard nofile 값 설정 변경 후 재로그인 필요

수평 확장과 로드 밸런싱

단일 서버의 리소스에는 한계가 존재한다. 수만, 수십만 연결 처리가 요구된다면 이를 여러 서버로 부하를 분산하는 수평 확장이 필수적이다.

  • 로드밸런서 : 클라이언트 연결 요청을 여러 백엔드 서버 인스턴스로 분산시키는 역할을 함
    - L4 로드 밸런서 : TCP/UDP 레벨에서 작동. 빠르지만 애플리케이션 정보 활용 불가
    • L7 로드 밸런서 : HTTP 헤더 등 애플리케이션 레벨 정보 활용 가능. WebSocket 핸드셰이크 헤더 분석

Nginx 설정 (WebSocket 프록시)
Nginx 1.3.13버전부터 WebSocket을 지원한다. 핵심 설정은 다음과 같다.

http {
    # Upgrade 헤더 유무에 따라 Connection 헤더 값을 동적으로 설정
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    upstream backend_servers {
        server 192.168.1.10:8080;
        server 192.168.1.11:8080;
        # ip_hash; # 고정 세션 필요시 (IP 기반)
    }

    server {
        listen 80;
        server_name yourdomain.com;

        location /ws/ {
            proxy_pass http://backend_servers;
            proxy_http_version 1.1; # HTTP/1.1 필수
            proxy_set_header Upgrade $http_upgrade; # Upgrade 헤더 전달
            proxy_set_header Connection $connection_upgrade; # Connection 헤더 설정
            proxy_set_header Host $host;
            # (X-Forwarded-* 헤더 설정)
            proxy_read_timeout 86400s; # 긴 유휴 타임아웃 설정 (예: 24시간)
        }
    }
}

Upgrade 와 Connection 헤더를 명시적으로 설정해서 백엔드 서버로 WebSocket 업그레이드 요청임을 알려주는 것이 중요하다. proxy_read_timeout을 길게 설정하여 유휴 연결이 끊어지지 않도록 하자.

고정 세션의 딜레마
WebSocket은 상태 기반(stateful) 연결이다. 그래서 동일 클라이언트의 연결이 항상 같은 벡엔드 서버로 가도록 하는 고정 세션 기능이 필요하다고 생각하기 쉬운데 로드 밸런서들은 IP 해싱, 쿠키(sticky cookie, appsession)등 다양한 고정 세션 방법을 제공한다.

  • 필요해 보이는 이유 : 클라이언트 상태가 특정 서버 메모리에 있다면 다른 서버로 가면 상태가 유실되기 때문에
  • 문제점은?
    - 부하 불균형 : 특정 서버에 연결이 쏠릴 수 있다.
    • 장애/확장 복잡성 : 특정 서버가 죽거나 스케일 인되면 해당 서버에 붙어있던 모든 클라이언트 연결이 끊기고 상태를 잃게 된다.

세션 상태 공유 아키텍처
고정 세션의 한계를 극복하고 진정한 확장성과 내결함성을 얻으려면, 애플리케이션 상태를 특정 서버 인스턴스에 종속시키지 않는게 좋다. jwt 처럼 비상태 저장 또는 상태 공유를 지향해야 된다.

  • 외부 저장소 : Redis, Memcached 등에 세션 상태를 저장하고 모든 서버가 접근한다. -> messageque를 사용하여 서버 간 상태 변경을 전파.

고성능이 필요하다면 : 비동기 논블로킹...
대규모 연결 처리를 위해서는 서버 자체에서 I/O 처리 방식도 중요한데 spring boot에서는 비동기 논블로킹 모델 WebFlux가 존재한다. 기본 전통적인 블로킹 I/O 모델은 연결 수가 많아지면 스레드 생성 및 컨텍스트 스위칭 오버헤드로 성능이 급격히 저하된다.

비동기 논블로킹은 소수의 스레드가 다수의 연결을 효율적으로 관리하기 때문에 적은 리소스로 높은 동시성을 달성할 수 있다.

Spring WebFlux와 Netty

  • spring의 논블로킹 webflux는 기본적으로 고성능 NIO 프레임워크인 Netty를 사용한다.
    -> 적은 스레드와 메모리로 확장 가능, 부하 상태에서도 예측 가능한 성능

마무리

이번에는 대규모 연결 처리하기위한 아키텍처에 대해서 얘기를 해봤는데 본인은 redis로 상태 공유까지만 생각해서 진행해봤기 때문에 다른 부분에 대해서는 많은 공부가 되었다. 다음은 메시지 처리량 최적화에 대해서 파헤쳐보려고 한다.

profile
스스로에게 질문하고 답을 할 줄 아는 개발자

0개의 댓글