한 고객이 간단한 관찰 결과를 알려왔는데, 그 지적이 정확히 맞았습니다. HTTP 서버에서 파일을 제공할 때 메모리를 많이 사용한다는 것이었습니다. TsgcWebSocketHTTPServer가 DocumentRoot에서 대용량 파일을 제공하면, 모든 연결마다 파일 전체가 RAM으로 읽혀 들어갔습니다. 같은 1 GB 파일을 100명의 클라이언트가 다운로드하면 약 100 GB의 RAM이 필요했습니다. 이번 릴리스에서 이 문제를 해결했습니다. 이제 파일은 디스크에서 스트리밍되며, 파일 크기와 연결된 클라이언트 수에 관계없이 서버 메모리는 일정하게 유지됩니다. 이 기능은 sgcWebSockets 2026.6.0에 포함되어 있습니다.
문제: 연결마다 전체 복사본 하나씩
HTTP/1.x용 DoResponseHTTP와 HTTP/2용 DoHTTP2DocumentRoot, 두 정적 파일 경로 모두 응답을 같은 방식으로 구성했습니다. TMemoryStream을 생성하고, LoadFromFile을 호출한 다음, 그 스트림을 응답에 넘겨주었습니다. LoadFromFile은 파일 전체를 한 번에 메모리로 읽어 들이므로, 요청당 비용은 파일 전체 크기였고, 동시에 진행되는 모든 다운로드는 각각 자신만의 복사본을 가지고 있었습니다.
// Old behaviour, simplified
oStream := TMemoryStream.Create;
oStream.LoadFromFile(vDocument); // entire file into RAM
AResponseInfo.ContentStream := oStream;
그 결과 최대 메모리 사용량은 대략 파일 크기 × 동시 연결 수가 됩니다. 비디오, 설치 프로그램, 디스크 이미지 또는 데이터베이스 백업을 가져가는 클라이언트 몇 명만으로도 서버를 스왑 상태로 밀어 넣거나 메모리 부족 충돌을 일으키기에 충분합니다.
느린 읽기 증폭
같은 고객이 두 번째 시나리오를 설명했는데, 이것은 실제 공격입니다. 각각 응답을 매우 천천히, 초당 1바이트 정도로 읽는 다수의 연결입니다. 이것은 Slowloris의 변종인 느린 읽기 공격입니다. 읽기를 찔끔찔끔 흘려보내는 클라이언트는 연결을 매우 오랫동안 열어 두며, 기존 코드에서는 그러한 연결마다 파일의 메모리 내 전체 복사본 하나씩을 붙들고 있었습니다. 적당한 크기의 파일을 읽는 수천 명의 느린 읽기 클라이언트가 거의 아무것도 전송하지 않으면서도 수 기가바이트를 인질로 잡을 수 있었습니다.
측정 방법
우리는 작은 테스트 하니스를 만들었습니다. DocumentRoot와 큰 테스트 파일이 있는 서버, 그리고 두 가지 모드를 가진 클라이언트입니다. "빠른" 모드는 N개의 동시 일반 다운로드를 열고, "느린" 모드는 유효한 요청을 보낸 다음 본문을 초당 1바이트로 읽는 M개의 원시 연결을 엽니다. 100 MB 파일로 수정 전과 후 서버의 작업 집합을 샘플링했습니다.
| 시나리오 (100 MB 파일) | 수정 전 | 수정 후 |
|---|---|---|
| 동시 다운로드 5개 | ~511 MB | ~11 MB |
| 동시 다운로드 10개 | ~1,012 MB | ~11 MB |
| 동시 다운로드 20개 | ~1.7 GB | ~12 MB |
| 느린 읽기(1 byte/sec) 연결 100개 | ~1.67 GB 일정하게 유지 | ~19 MB |
| 파일 무결성 | 해당 없음 | SHA-256 바이트 정확, 올바른 Content-Length |
수정 전에는 메모리가 파일 크기와 연결 수의 곱을 거의 정확하게 따라갔습니다. 수정 후에는 파일이 아무리 크거나 클라이언트가 아무리 많이 연결되어도 수십 메가바이트 수준을 유지합니다.
해결책: 디스크에서 스트리밍
이제 파일은 TMemoryStream 대신 읽기 전용 공유 TFileStream으로 제공됩니다. 쓰기 측은 이미 스트리밍 방식이었기 때문에 파이프라인의 다른 부분은 바꿀 필요가 없었습니다. HTTP/1.x 서버는 콘텐츠 스트림을 32 KB 청크로 전송하고, HTTP/2 서버는 대략 64 KB DATA 프레임으로 내보냅니다. 따라서 서버는 디스크에서 작은 청크를 읽고, 전송한 다음, 다음으로 넘어갑니다. 이렇게 하면 연결당 메모리 비용이 파일 전체에서 수십 킬로바이트로 줄어듭니다.
// New behaviour, simplified
oStream := TFileStream.Create(vDocument, fmOpenRead or fmShareDenyWrite);
oStream.Position := 0;
AResponseInfo.ContentStream := oStream;
Content-Length는 TFileStream.Size에서 가져오므로 여전히 올바르며, 스트림은 이전과 정확히 동일하게 응답 후에 해제됩니다. 우리는 SHA-256 비교로 다운로드가 원본 파일과 바이트 단위로 동일하다는 것과, 헤더가 올바른 길이를 보고한다는 것을 확인했습니다. API 변경은 없고 설정할 것도 없습니다. DocumentRoot를 지정하는 것은 항상 작동하던 방식 그대로 작동하며, 다만 더 이상 파일을 버퍼링하지 않을 뿐입니다.
oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.Port := 80;
oServer.DocumentRoot := 'C:\inetpub\files'; // large files now stream from disk
oServer.Active := True;
.NET 에디션은 이 수정을 자동으로 물려받습니다. DocumentRoot 파일 제공이 Delphi 컴포넌트가 사용하는 것과 동일한 컴파일된 코어에서 실행되기 때문입니다.
느린 읽기 클라이언트 늦추기
디스크에서 스트리밍하면 메모리 증폭이 제거되므로, 느린 읽기 클라이언트는 더 이상 파일 전체를 RAM에 붙들어 둘 수 없습니다. 남는 것은 연결 자체이며, 이를 위해 전송 타임아웃이 있습니다. Options.WriteTimeOut은 소켓 쓰기에 마감 시간을 설정하므로, 데이터 수신을 멈춘 클라이언트는 서버 스레드를 영원히 차단하는 대신 끊어집니다. 이번 릴리스에서는 이 타임아웃이 Windows뿐만 아니라 Linux 및 기타 POSIX 플랫폼에서도 작동합니다. POSIX 소켓 API는 밀리초 정수가 아니라 timeval을 기대하는데, 이제 이것이 올바르게 처리됩니다.
oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.DocumentRoot := 'C:\inetpub\files';
oServer.Options.WriteTimeOut := 15000; // drop a client that stalls for 15s, Windows and POSIX
oServer.Active := True;
공개 서버의 경우 세 가지 수단을 조합하는 것을 권장합니다. 멈춘 쓰기를 끊도록 Options.WriteTimeOut을 설정하고, 단일 주소가 열 수 있는 연결 수를 제한하도록 방화벽의 RateLimit.MaxConnectionsPerIP를 사용하며, 느린 연결의 폭주가 각각 스레드 하나씩을 고갈시키지 못하도록 스레드 풀 스케줄러나 IOCP 및 EPOLL 핸들러를 고려하십시오.
업그레이드
스트리밍 수정은 그대로 적용됩니다. 업데이트하는 즉시 DocumentRoot는 코드 변경 없이 일정한 메모리로 디스크에서 파일을 제공합니다. 이 변경 사항은 Delphi 7부터 13까지, HTTP/1.x 및 HTTP/2 경로에서 검증되었으며, .NET 빌드는 공유 코어를 통해 이를 가져옵니다. 대용량 정적 파일을 제공한다면, 이것은 메모리의 의미 있는 감소이자 느린 읽기 남용에 대비하는 유용한 조치입니다.
sgcWebSockets 다운로드 페이지에서 업데이트하거나, GetIt 또는 등록된 계정을 통해 받으십시오.
질문, 피드백 또는 마이그레이션 도움이 필요하신가요? 문의하기, 코드를 작성한 사람들로부터 답변을 받으실 수 있습니다.
