"작동함"에서 "확장됨"으로
sgcWebSockets는 튜닝 없이 즉시 수천 개의 연결을 처리해요. 단일 박스에서 50,000개 또는 100,000개의 동시 소켓을 넘어서는 것도 가능해요 — 그러나 올바른 서버 클래스를 선택하고, 워크로드에 맞게 스레드 풀 크기를 조정하고, 올바른 압축 전략을 선택하고, 운영 체제를 튜닝해야 해요. 이 게시물은 모든 레버를 순서대로 살펴봐요. Windows Server 2025와 Ubuntu 24.04를 실행하는 Hetzner AX102(Ryzen 9 7950X3D, 128 GB RAM, 1 Gbps NIC)의 벤치마크 수치와 함께요.
시작하기 전에 방법론에 대한 한 마디. 아래에 보이는 모든 숫자는 합성 에코 워크로드에서 나온 것이에요 — 100바이트 페이로드, 클라이언트당 초당 10개 메시지, 비즈니스 로직 없음. 실제 애플리케이션은 OnMessage 핸들러가 실제로 무엇을 하는지에 따라 이 수치와 자릿수 더 느린 것 사이에 위치할 거예요. 약속이 아니라 천장으로 취급하세요. 요점은 확장 곡선의 모양과 절벽이 어디에 있는지 보여주는 것이지 임의의 워크로드에 대한 보장을 주는 것이 아니에요.
또한: 사용자에게 가장 가까운 것부터 튜닝하세요. TLS 핸드셰이크가 80밀리초 걸리는데 브로드캐스트 루프에서 200마이크로초를 깎아내는 것은 의미가 없어요. 프로파일링하고, 병목을 찾고, 수정하고, 반복하세요. 아래 목록은 노력당 영향 순서로 대략 정렬되어 있지만 모든 워크로드는 달라요.
1. 올바른 I/O 모델 선택
sgcWebSockets는 두 가지 서버 패밀리를 제공해요: Indy 기반 TsgcWebSocketServer(연결당 스레드 하나)와 IOCP/epoll 백업 TsgcWebSocketHTTPServer(이벤트 기반). 약 5,000개의 동시 연결을 넘어서면 두 번째 것을 원해요. 끝.
| 서버 | I/O 모델 | 스위트 스폿 | 하드 천장 |
| TsgcWebSocketServer (Indy) | 연결당 스레드 | <5,000 | ~10,000 (스레드 스택 소진) |
| TsgcWebSocketHTTPServer Win | IOCP | 10,000–100,000 | ~250,000 (파일 디스크립터) |
| TsgcWebSocketHTTPServer Lin | epoll | 10,000–100,000 | ~1,000,000 (튜닝 포함) |
| TsgcWebSocketHTTPServer Mac | kqueue | 10,000–50,000 | ~100,000 |
2. 스레드 풀 크기 조정
IOCP/epoll 서버는 고정 크기 워커 풀을 사용해요. 기본값은 CPUCount예요. 순수 에코 / 팬아웃 워크로드의 경우 작게 유지하세요(코어당 2–4개). OnMessage 내부에서 데이터베이스를 건드리거나 외부 API를 호출하는 워크로드의 경우 증가시키세요 — 그렇지 않으면 한 번의 느린 요청이 N개의 피어를 차단해요.
oServer := TsgcWebSocketHTTPServer.Create(Self); oServer.Port := 443; // Tune the worker pool oServer.ThreadPool.PoolSize := CPUCount * 2; // CPU-bound // oServer.ThreadPool.PoolSize := 128; // DB-bound (e.g. 16 cores) oServer.ThreadPool.QueueSize := 4096; oServer.ThreadPool.MaxConnections := 100000; oServer.Active := True;
경험 법칙: PoolSize = CPUCount + (average_blocking_ms / average_cpu_ms) * CPUCount. Application Insights / APM이 증가하는 큐 깊이를 보고하는 것을 발견하면 풀을 두 배로 늘리세요. 커널 스케줄러가 설정하는 하드 상한선이 있어요 — Windows에서 수만 개의 OS 스레드는 재미없어요 — 그러나 합리적인 워크로드의 경우 대개 거기에 거의 없어요. 256개 워커를 넘어서면 차단 작업을 전용 워커 풀로 오프로딩하는 것을 고려하세요.
가장 좋은 아키텍처 결정은 OnMessage를 비차단으로 유지하고 모든 실제 작업 단위를 별도의 스레드 풀이 서비스하는 전용 큐로 푸시하는 것이에요. 이는 I/O 스레드(빠르게 유지되어야 함)를 비즈니스 스레드(결과 없이 느릴 수 있음)에서 분리해요. 또한 관찰 가능하게 만들어요: 큐 깊이는 "곧 무너질 것임"의 주요 지표가 돼요.
3. 압축: per-message-deflate
WebSocket 압축(RFC 7692)은 JSON 또는 텍스트 페이로드를 60–90% 줄여요. 또한 CPU 집약적이에요. 워크로드가 텍스트 중심이고 CPU에 여유가 있을 때만 전역적으로 활성화하세요. 이진 또는 이미 압축된 페이로드(JPEG, MP4, gzip된 로그)의 경우 순수한 오버헤드예요.
oServer.Extensions.PerMessage_Deflate.Enabled := True; oServer.Extensions.PerMessage_Deflate.ServerMaxWindow := 15; // default oServer.Extensions.PerMessage_Deflate.MemLevel := 8; oServer.Extensions.PerMessage_Deflate.Threshold := 256; // skip tiny msgs
헤더 오버헤드보다 작은 메시지가 압축되지 않도록 Threshold를 설정하세요. 60바이트 하트비트에서 deflate를 건너뛰면 비용보다 더 많은 CPU를 절약해요.
미묘한 함정: per-message-deflate는 동일한 연결의 메시지 전체에서 상태를 유지하는 슬라이딩 윈도우를 사용해요. 그 상태는 연결당 메모리예요. ServerMaxWindow=15(기본값)로 각 연결은 약 32 KB의 사전을 보유해요. 100,000개의 연결로 곱하면 압축 상태에만 3 GB의 RAM이 있어요. 메모리에 제약이 있다면 ServerMaxWindow를 10 또는 11로 떨어뜨리세요 — 압축 비율의 몇 퍼센트를 잃지만 연결당 메모리가 약 8배 적어져요.
4. 단편화
큰 프레임(>1 MB)은 전체 메시지가 재조립될 때까지 워커 스레드를 보유해요. 지연 시간을 매끄럽게 유지하고 다른 피어를 위해 워커를 해제하려면 발신 메시지를 단편화하세요.
oClient.WriteOptions.FragmentEnabled := True; oClient.WriteOptions.FragmentSize := 65536; // 64 KB chunks
서버 측에서는 기가바이트 버퍼를 할당하려는 악의적인 피어로부터 보호하기 위해 ReadOptions.MaxFrameSize를 합리적인 상한선(우리는 4 MB 사용)으로 설정하세요.
5. 브로드캐스트 최적화
모든 연결된 클라이언트에 동일한 메시지를 보내는 것이 채팅 / 트레이딩 / pub-sub 서버의 #1 병목이에요. 순진한 루프 for each client: client.Send(msg)는 동일한 페이로드를 N번 직렬화하고 압축해요. 한 번 직렬화하고 인코딩된 프레임을 재사용하는 내장 브로드캐스트를 사용하세요.
// Slow: N encodes, N compresses for i := 0 to oServer.Connections.Count - 1 do oServer.Connections[i].WriteData(vJSON); // Fast: 1 encode, 1 compress, N writes oServer.Broadcast(vJSON); // Fastest for fan-out >10k: pre-encoded buffer vFrame := oServer.EncodeFrame(vJSON); oServer.BroadcastEncoded(vFrame);
16코어 박스에서 50,000 클라이언트 팬아웃에 대한 순진한 루프와 BroadcastEncoded의 차이는 12초 대 380ms예요. 채널에도 동일한 원칙이 적용돼요 — 프레임을 사전 인코딩한 다음 구독자 목록을 살펴보세요. 구독자가 많은 채널에 걸쳐 분할되면 채널당 한 번 인코딩하고 내부에서 브로드캐스트하세요. 이 코드 경로의 조기 비관주의는 그렇지 않으면 빠른 서버를 무너뜨릴 거예요.
6. OS 수준 튜닝
커널은 컴포넌트보다 훨씬 전에 하드 제한을 부과해요. 라이브러리를 비난하기 전에 이를 튜닝하세요.
| 설정 | Linux | Windows | 권장 사항 |
| 파일 디스크립터 제한 | ulimit -n |
HKLM — MaxUserPort |
예상 연결의 2× |
| TCP 백로그 | net.core.somaxconn |
TcpMaxConnectResponseRetransmissions |
4096+ |
| TIME_WAIT 재사용 | tcp_tw_reuse=1 |
TcpTimedWaitDelay=30 |
포트 소진 감소 |
| SO_REUSEPORT | 커널 ≥3.9 | 해당 없음 | 다중 프로세스 acceptor |
| 임시 포트 범위 | net.ipv4.ip_local_port_range |
MaxUserPort |
10000–65535 |
7. 하트비트 및 유휴 감지
모바일 클라이언트는 항상 네트워크에서 떨어져요. 하트비트가 없으면 서버는 TCP 킵얼라이브 타이머가 발사될 때까지 소켓을 열어 둬요(일반적으로 2시간). 짧은 하트비트와 죽은 피어 감지를 구성하세요.
oServer.HeartBeat.Enabled := True; oServer.HeartBeat.Interval := 30; // seconds oServer.HeartBeat.Timeout := 90; // close if no pong within this
이렇게 하면 2시간이 아닌 90초 내에 반 열린 연결을 잡아내어 바쁜 서버에서 수천 개의 오래된 소켓을 해제해요.
8. 로드 밸런서 페어링
단일 박스를 넘어 확장해야 한다면 sgcWebSockets를 TsgcWebSocketHTTPServer_LoadBalancer 또는 외부 L7 LB(HAProxy, nginx, AWS ALB)와 페어링하세요. 두 가지 규칙:
- 스티키 세션 사용 — WebSocket 프레임은 멱등이 아니며 대화 중간에 다시 라우팅할 수 없어요.
- 애플리케이션이 실제 클라이언트 IP를 볼 수 있도록 원본
X-Forwarded-For및 TLS 종료 헤더를 전달하세요.
참조 벤치마크
100바이트 페이로드로 클라이언트당 초당 10개 메시지의 에코 서버를 실행하는 단일 AX102 박스(16코어 / 32스레드, 128 GB)에서의 수치.
| 동시 클라이언트 | 처리량 (msg/s) | p50 지연 시간 | p99 지연 시간 | CPU 사용량 | RSS |
| 10,000 | 100,000 | 0.8 ms | 3.2 ms | 14% | 0.9 GB |
| 50,000 | 500,000 | 1.1 ms | 5.4 ms | 38% | 3.8 GB |
| 100,000 | 1,000,000 | 1.7 ms | 9.8 ms | 71% | 7.2 GB |
| 250,000 | 2,500,000 | 3.4 ms | 22 ms | 96% | 17.8 GB |
9. TLS 종료
TLS 핸드셰이크는 CPU 집약적이에요. 초당 수천 개의 새 연결을 제공하면 Delphi 프로세스에서 TLS를 종료하면 프레임을 제공하는 대신 암호화로 코어를 포화시킬 수 있어요. 고변화 워크로드의 경우 sgcWebSockets 서버 앞에 nginx 또는 HAProxy에서 TLS를 종료하고 백엔드를 일반 HTTP로 실행해요. 프론트엔드는 하드웨어 AES 가속, 세션 재개 및 OCSP 스테이플링을 무료로 받고, Delphi 프로세스는 CPU의 100%를 애플리케이션 로직에 쓸 수 있어요.
지속적인 장기 연결이 있는 워크로드(일반적인 채팅 / 트레이딩 시나리오)의 경우 핸드셰이크가 몇 시간 또는 며칠에 걸쳐 분할 상환되기 때문에 프로세스 내 TLS가 괜찮아요. 연결-끊기-재연결 버스트(불안정한 네트워크의 모바일 클라이언트)의 경우 리버스 프록시를 앞에 두세요.
10. NIC 및 네트워크 튜닝
1 Gbps에서는 NIC를 포화시킬 가능성이 낮아요. 10 Gbps 이상에서는 인터럽트 통합, 수신 측 확장(RSS) 및 sgcWebSockets 워커 스레드를 NUMA 로컬 코어에 고정하는 것에 대해 생각해야 해요. Linux에서 ethtool -L과 set_irq_affinity.sh가 친구예요. Windows에서는 NIC 속성에서 RSS Profile을 NUMAScaling으로 설정하고 Get-NetAdapterRss로 확인하세요. 모니터링이 커널이 softirq 또는 DPC에 실제 시간을 쓰고 있다고 알려주는 경우에만 튜닝할 가치가 있어요.
프로파일 가이드 튜닝 루프
튜닝은 반복적이에요. 기본값으로 시작하고, 대표적인 부하를 실행하고, 워커당 CPU, GC / 할당 속도, 팬아웃 하의 p99 지연 시간 및 OS 수준 연결 카운터를 살펴보세요. 한 가지를 변경하고, 다시 실행하고, 비교하세요. 가장 일반적인 놀라움:
- 이미 압축된 페이로드에서 활성화된 압축 → 대역폭 이득이 0인 CPU 급증.
OnMessage내부의 동기 DB 호출 → CPU의 <1%에서 워커 풀 포화.- 브로드캐스트 일괄 처리 없음 → 시장 개장 급증 시 헤드-오브-라인 차단.
- 64코어 박스의 기본 스레드 풀 → 256이 처리량을 4× 잠금 해제할 때 64개 워커에 작업을 직렬화.
추가 읽기
아직 올바른 서버 클래스를 선택하지 않았다면 어떤 에디션에서 시작하세요. 그런 다음 다중 박스 확장을 위해 Load Balancer 컴포넌트로 이동하세요. 라이브러리가 처음이에요? 시작하기 허브가 5분 안에 설치를 안내해요.