Delphi용 sgcWebSockets: 실시간 통신 시스템 구축 가이드

최신 소프트웨어 개발에서 실시간 상호작용은 사용자 경험의 핵심 요소입니다. 금융 시장의 실시간 데이터부터 산업용 사물 인터넷(IoT) 센서 스트림, 그리고 다중 플랫폼 인스턴트 메시징에 이르기까지, 이 모든 것의 중심에는 WebSocket 기술이 있습니다. Delphi를 사용하여 고성능 데스크톱 또는 서버 애플리케이션을 개발하는 경우, sgcWebSockets는 안정적이고 효율적인 통신 솔루션을 제공하는 데 필수적인 라이브러리입니다.

Delphi 10부터 10.3 Rio 버전까지 지원하는 이 라이브러리는 단순히 WebSocket 프로토콜을 캡슐화하는 것을 넘어, Delphi의 Pascal 언어 특성과 비동기 I/O 모델을 활용하여 간결하고 신뢰할 수 있으며 고도로 맞춤화 가능한 프레임워크를 제공합니다. VCL 및 FMX 프레임워크를 모두 지원하며, Windows, Linux, macOS 등 다양한 운영 체제에서 원활하게 작동하여 "한 번의 코딩으로 여러 플랫폼 배포"라는 이상을 실현합니다.

sgcWebSockets의 강력함은 간결한 API에서 비롯됩니다. 다음은 클라이언트 연결을 설정하는 간단한 코드 스니펫입니다:

uses
  sgcWebSocket, sgcWSClient;

var
  ClientSocket: TWebSocketClient;
begin
  ClientSocket := TWebSocketClient.Create(nil);
  ClientSocket.URL := 'ws://localhost:8080/chat';
  ClientSocket.OnConnect := OnSocketConnection;
  ClientSocket.OnMessage := OnSocketMessageReceived;
  ClientSocket.Connect;
end;

몇 줄의 코드로 WebSocket 클라이언트 연결이 이루어졌습니다. 이러한 단순함 뒤에는 프로토콜의 복잡한 세부 사항을 추상화하여 개발자가 비즈니스 로직에 집중할 수 있도록 하는 정교한 디자인 철학이 숨어 있습니다. 이것이 바로 sgcWebSockets가 많은 개발자에게 호평받는 이유입니다.

WebSocket의 필요성: 왜 HTTP로는 부족한가?

sgcWebSockets의 가치를 이해하기 위해서는 WebSocket이 왜 필요하며, 기존 HTTP 프로토콜만으로는 충분하지 않은지 명확히 파악해야 합니다.

HTTP는 본질적으로 요청-응답(Request-Response) 방식의 반이중(Half-Duplex) 통신 프로토콜입니다. 웹 페이지 로딩이나 폼 제출과 같은 시나리오에는 적합하지만, 온라인 채팅방이나 실시간 주식 시세 업데이트와 같이 지속적인 양방향 통신이 필요한 경우에는 비효율적입니다. HTTP로 이러한 기능을 구현하려면 클라이언트가 주기적으로 서버에 새 메시지가 있는지 묻는 "폴링(Polling)" 방식을 사용해야 합니다. 이는 마치 아이가 놀이공원에 언제 도착하는지 계속 묻는 것과 같습니다.

이러한 폴링 방식은 다음과 같은 문제점을 야기합니다:

  • 상당한 대역폭과 서버 자원 낭비
  • 높은 지연 시간 (최소 수백 밀리초)
  • 모바일 기기에서 빠른 배터리 소모

WebSocket은 이러한 한계를 극복하기 위해 등장했습니다.

HTTP에서 시작하여 전이중 통신으로 전환

WebSocket은 완전히 새로운 기술이 아니라 기존 웹 아키텍처를 기반으로 한 우아한 발전입니다. HTTP 핸드셰이크 메커니즘을 활용하여 방화벽과 프록시 서버를 통과한 다음, 자체 프로토콜 채널로 전환하여 진정한 전이중(Full-Duplex) 통신을 설정합니다.

이는 다음을 의미합니다:

  • 클라이언트는 언제든지 서버에 메시지를 보낼 수 있습니다.
  • 서버도 클라이언트에게 데이터를 능동적으로 푸시할 수 있습니다.
  • 하나의 TCP 연결을 통해 양방향으로 데이터를 전송하며, 서로 간섭하지 않습니다.
  • 반복되는 헤더 오버헤드가 없고, 프레임 구조가 가벼워 지연 시간이 밀리초 단위로 단축됩니다.
비교 기준 HTTP WebSocket
통신 모드 반이중 (요청-응답) 전이중 (양방향 비동기)
연결 수명 주기 단기 연결, 요청 후 연결 종료 장기 연결, 명시적 종료까지 유지
데이터 전송 오버헤드 각 요청에 완전한 헤더 정보 포함 경량 프레임 구조, 최소한의 제어 필드만 필요
실시간 지원 낮음 (폴링 또는 롱 폴링에 의존) 강함 (능동적 푸시, 밀리초 단위 지연)
주요 사용 시나리오 페이지 로딩, 폼 제출, API 호출 채팅, 온라인 게임, 주식 시세, IoT 데이터 스트림
기본 포트 80 (HTTP), 443 (HTTPS) 동일 (HTTP 포트 재사용)

WebSocket URL은 HTTP 스타일을 따릅니다:

  • ws://example.com/socket → 비암호화 통신 (http://와 유사)
  • wss://example.com/socket → 암호화 통신 (https://와 유사)

이러한 방식은 대부분의 기업 네트워크 정책을 통과할 수 있으며, 기존 HTTPS 인증서 및 로드 밸런싱 구성을 재사용할 수 있게 합니다.

WebSocket 핸드셰이크: 프로토콜 전환 과정

WebSocket 연결은 바로 시작되지 않고, RFC 표준에 따라 정의된 "업그레이드 핸드셰이크" 과정을 거칩니다. 이 과정의 핵심 목표는 클라이언트와 서버가 "HTTP에서 WebSocket으로 프로토콜을 전환하겠다"는 합의에 도달하는 것입니다.

전체 흐름은 다음 Mermaid 다이어그램으로 시각화할 수 있습니다:

sequenceDiagram
    participant C as 클라이언트
    participant S as 서버
    C->>S: GET /chat HTTP/1.1<br>Upgrade: websocket<br>Connection: Upgrade<br>Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==<br>Sec-WebSocket-Version: 13
    S-->>C: HTTP/1.1 101 Switching Protocols<br>Upgrade: websocket<br>Connection: Upgrade<br>Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
    Note right of S: Sec-WebSocket-Key 검증 및 Accept 값 생성
    activate S
    deactivate S
    Note over C,S: WebSocket 프로토콜로 전환 완료

1단계: 클라이언트의 "가장된 요청"

클라이언트는 일반 HTTP 요청처럼 보이지만, 몇 가지 중요한 헤더 필드가 추가된 요청을 보냅니다:

  • Upgrade: websocket — "WebSocket으로 업그레이드하고 싶습니다."
  • Connection: Upgrade — "연결을 유지해주세요."
  • Sec-WebSocket-Key — 무작위로 생성된 Base64 문자열 (캐시 오염 공격 방지 목적)
  • Sec-WebSocket-Version: 13 — 현재 표준 버전

이것은 일종의 "가짜 동작"이며, 서버가 업그레이드에 동의하면 이후의 모든 통신은 HTTP가 아닌 WebSocket 프레임 구조로 이루어집니다.

2단계: 서버의 "확인 신호"

서버는 요청을 받은 후 프로토콜 전환을 성공시키기 위해 다음 단계를 수행해야 합니다:

  1. UpgradeConnection 헤더가 올바른지 확인합니다.
  2. 버전 번호 (현재 v13만 지원)가 지원되는지 확인합니다.
  3. 클라이언트가 제공한 Sec-WebSocket-Key와 고정된 GUID 문자열을 결합합니다:
    dGhlIHNhbXBsZSBub25jZQ== + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11
  4. 결합된 문자열에 SHA-1 해싱을 적용합니다.
  5. 해시 값을 Base64로 인코딩하여 Sec-WebSocket-Accept 헤더로 반환합니다.
  6. 101 Switching Protocols 응답을 보내 전환이 성공했음을 알립니다.

Pascal에서 이 값을 계산하는 함수 예시입니다:

uses
  System.SysUtils, System.EncdDecd, IdHashSHA1;

function GenerateWebSocketAcceptHeader(const ClientKeyString: string): string;
const
  MagicGuidString = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
var
  Sha1Hasher: TIdHashSHA1;
  HashedBytes: TBytes;
begin
  Sha1Hasher := TIdHashSHA1.Create;
  try
    HashedBytes := Sha1Hasher.HashStringAsBytes(ClientKeyString + MagicGuidString);
    Result := EncodeBase64(HashedBytes);
  finally
    Sha1Hasher.Free;
  end;
end;

이 "마법의 문자열"은 RFC 6455에 따라 변경 불가능하며 전 세계적으로 통일되어 호환성을 보장합니다. sgcWebSockets 내부적으로는 TWebSocketServer.Handshake 로직에서 이 모든 과정을 자동으로 처리합니다. 개발자는 OnHandshake 이벤트를 통해 연결 결정을 개입할 수 있습니다:

procedure TMainForm.WebSocketServerHandshake(Sender: TObject;
  const HttpRequestDetails: IHTTPRequest; var AllowConnection: Boolean);
begin
  // 특정 Origin에서 온 요청만 허용
  if HttpRequestDetails.Headers['Origin'] <> 'https://mysecureapp.com' then
    AllowConnection := False  // 비정상적인 출처 거부
  else
    AllowConnection := True;
  // 추가적으로 IP 블랙리스트, 권한 검증 등 수행 가능
end;

핸드셰이크 단계에서 이미 권한 검증, IP 블랙리스트 차단, 속도 제한 등과 같은 보안 조치를 구현하여 잠재적인 문제를 미리 방지할 수 있습니다.

데이터 전송: WebSocket 프레임 구조

핸드셰이크가 완료되면 실제 데이터는 "프레임(Frame)" 형태로 전송됩니다. 수백 바이트에 달하는 HTTP 헤더와 달리, WebSocket 프레임은 매우 간결하며 최소 제어 오버헤드가 2바이트에 불과하여 고빈도 소량 데이터 패킷 시나리오에 매우 적합합니다.

RFC 6455에 따르면, 일반적인 WebSocket 프레임 구조는 다음과 같습니다:

필드명 길이 (bit) 설명
FIN 1 메시지의 마지막 조각인지 여부
RSV1/RSV2/RSV3 각 1 예약 비트 (현재 0이어야 함)
Opcode 4 작업 코드 (텍스트/바이너리/제어 프레임)
Mask 1 페이로드 데이터에 마스킹 적용 여부 (클라이언트 → 서버 필수 1)
Payload length 7 / 7+16 / 7+64 실제 페이로드 길이
Masking-key (optional) 32 마스킹 키 (Mask=1일 때만 존재)
Payload Data 가변 길이 애플리케이션 데이터 (마스킹될 수 있음)

예를 들어, "Hello" 문자열을 전송하는 원시 프레임은 다음과 같을 수 있습니다:

81 85 37 FA 21 3D 7F 9F 4D

분석:

  • 81: 1000 0001
    • FIN = 1 → 완전한 메시지
    • Opcode = 0001 → 텍스트 프레임
  • 85: 1000 0101
    • Mask = 1 → 데이터가 마스킹됨
    • Payload Length = 5
  • 37 FA 21 3D: 마스킹 키
  • 7F 9F 4D ...: XOR 디코딩 후의 원시 데이터

디코딩 로직은 다음과 같습니다:

procedure UnmaskPayloadData(var DataBuffer: TBytes; const MaskingKeyBytes: TBytes);
var
  i: Integer;
begin
  for i := 0 to High(DataBuffer) do
    DataBuffer[i] := DataBuffer[i] xor MaskingKeyBytes[i mod 4];
end;

마스킹은 주로 초기 단계의 특정 중간 프록시 서버에 존재했던 캐시 오염 취약점을 방지하기 위함입니다. 따라서 RFC는 클라이언트가 전송하는 데이터는 반드시 마스킹해야 한다고 강제하며, 서버 응답은 마스킹이 필요하지 않습니다 (CPU 절약). 이는 매우 실용적인 보안 절충안입니다.

sgcWebSockets에서는 이러한 프레임의 조립 및 분석이 내부 TWebSocketFrame 클래스에 의해 자동으로 처리됩니다. 개발자는 다음 한 줄만으로 메시지를 보낼 수 있습니다:

ClientSocket.SendText('Hello');

이 코드는 인코딩, 마스킹, 소켓 쓰기와 같은 일련의 하위 레벨 작업을 자동으로 처리합니다.

하지만 프레임 구조를 이해하는 것은 여전히 중요합니다:

  • 패킷 캡처 시 비정상적인 프레임을 빠르게 식별할 수 있습니다.
  • 잦은 소규모 프레임 전송으로 인한 성능 병목 현상을 분석할 수 있습니다.
  • 사용자 정의 확장 프로토콜을 구현할 때 RSV 비트를 활용할 수 있습니다.

전이중 통신: Delphi로 실시간 시스템 구축

반이중 통신이 "말하면 듣고, 들으면 말하는" 방식이라면, 전이중 통신은 "말하면서 듣고, 들으면서 말하는" 방식입니다. TCP 자체는 전이중 전송 계층 프로토콜이며, WebSocket은 그 위에 애플리케이션 계층 프레임 관리 메커니즘을 추가하여 양 끝단이 독립적이고 동시에 데이터를 주고받을 수 있도록 합니다.

sgcWebSockets는 이러한 특징을 최대한 활용하여 TWebSocketClientTWebSocketServer 내부에 독립적인 수신 스레드와 전송 버퍼를 내장하고 있습니다. 이는 다음을 의미합니다:

  • 클라이언트가 대용량 바이너리 메시지를 처리 중이더라도, 언제든지 서버에 하트비트 명령을 보낼 수 있습니다.
  • 서버는 메시지를 에코하는 동시에, 모든 연결된 클라이언트에게 주기적으로 타임스탬프를 브로드캐스트할 수 있습니다.
  • 여러 작업이 서로 영향을 주지 않으며, 진정한 병렬 처리가 가능합니다.

다음은 전형적인 서버 예시입니다:

// 메시지 수신 즉시 에코
procedure TMainForm.ServerMsgHandler(Sender: TObject; const ReceivedTextMsg: string);
begin
  (Sender as TWebSocketServer).SendTextToAll('Echo: ' + ReceivedTextMsg);
end;

// 동시에 타이머를 시작하여 5초마다 시간 푸시
procedure TMainForm.PeriodicBroadcastTimer(Sender: TObject);
var
  ClientContext: IWebSocketContext;
begin
  // FConnectedClients 대신 TWebSocketServer의 ActiveContexts를 직접 사용하는 예시
  for ClientContext in WebSocketServerInstance.Contexts do
    if ClientContext.Connected then
      ClientContext.Send(FormatDateTime('yyyy-mm-dd hh:nn:ss', Now));
end;

동일한 서버가 요청에 응답하고 동시에 능동적으로 데이터를 푸시할 수 있습니다. 이것이 현대 실시간 시스템의 올바른 접근 방식입니다.

명확한 역할 분담: 클라이언트 vs. 서버

WebSocket 통신에서 클라이언트와 서버의 역할은 매우 명확합니다:

역할 주요 책임 sgcWebSockets 컴포넌트
클라이언트 연결 시작, 요청 전송, 푸시 메시지 수신 TWebSocketClient
서버 연결 수락, 메시지 처리, 데이터 브로드캐스트 TWebSocketServer

역할은 고정되어 있지만 통신 내용은 제한되지 않습니다. 서버가 클라이언트에게 "주문이 완료되었습니다"라고 능동적으로 알리거나, 클라이언트가 암호화된 음성 메시지를 서버에 업로드하여 분석할 수 있습니다.

양측은 통합된 이벤트 모델을 통해 상호 작용합니다:

// 클라이언트 측에서 메시지 수신 대기
procedure TMainForm.ClientMsgEventHandler(Sender: TObject; const IncomingMessage: string);
begin
  LogMemo.Lines.Add('← ' + IncomingMessage);
end;

// 서버 측에서 모든 클라이언트에게 메시지 브로드캐스트
procedure TMainForm.DistributeMessageToAll(const MessageToBroadcast: string);
var
  ContextItem: IWebSocketContext;
begin
  for ContextItem in WebSocketServerInstance.Contexts do
    ContextItem.Send(MessageToBroadcast);
end;

이러한 느슨한 결합 설계는 시스템의 확장성을 크게 향상시킵니다. 예를 들어, 나중에 Redis Pub/Sub을 도입하여 서버 간 메시지 동기화를 구현하더라도, DistributeMessageToAll 구현만 변경하면 됩니다.

메시지 유형: 텍스트와 바이너리 중 선택

WebSocket은 두 가지 주요 메시지 유형을 지원합니다:

유형 Opcode 설명
텍스트 1 UTF-8 인코딩 문자열, JSON/XML에 적합
바이너리 2 원시 바이트 스트림, 이미지, 오디오, 직렬화된 객체에 적합

이외에도 몇 가지 제어 프레임이 있습니다:

  • Close (8)
  • Ping (9)
  • Pong (10)

sgcWebSockets는 이들을 명확하게 처리하기 위한 API를 제공합니다:

procedure TMainForm.WebSocketServerDataReceived(Sender: TObject;
  const RawDataBuffer: TBytes; DataTypeIndicator: TWebSocketDataType);
begin
  case DataTypeIndicator of
    wdtText:
      ProcessTextMessage(GetStringFromBytes(RawDataBuffer, TEncoding.UTF8));
    wdtBinary:
      ProcessBinaryData(RawDataBuffer);
    wdtPing, wdtPong:
      Exit; // 자동 응답 처리되므로 수동 처리 불필요
    wdtClose:
      HandleConnectionClose(Sender as IWebSocketConnection);
  end;
end;

실제 개발에서는 다음을 권장합니다:

  • wdtText는 JSON 형식의 채팅 메시지 전송에 사용합니다.
  • wdtBinary는 압축된 로그, Protobuf 직렬화 데이터, 암호화된 오디오/비디오 패킷 전송에 사용합니다.

sgcWebSockets는 메시지 조각화(Fragmentation)도 지원합니다. 대용량 파일 전송 시 자동으로 여러 연속된 프레임으로 분할되며, FIN 비트가 0이면 아직 메시지가 끝나지 않았음을 나타냅니다. 컴포넌트는 이를 자동으로 재조립하므로 개발자는 이를 인지할 필요가 없습니다.

// 프레임워크 내부 로직 예시
if not CurrentFrame.FIN then
  AppendToCurrentMessageBuffer(CurrentFrame.Payload)
else
  FinalizeMessageAndTriggerEvent();

이 메커니즘은 불안정한 네트워크 환경에서 특히 중요하며, 대용량 데이터 전송의 안정성을 보장합니다.

개발 환경 설정: 첫걸음부터 안정적으로

흥미로운 기능을 구현하기 전에, "컴포넌트가 없다", "dcu 파일이 없다", "IDE가 충돌한다" 등의 문제로 첫 단계부터 좌절할 수 있습니다. 차근차근 해결해 봅시다.

Delphi 버전 호환성 확인

sgcWebSockets_4.2.3_For_Delphi_10-10.3_Rio는 Delphi 10 Seattle부터 10.3 Rio 버전까지만 지원합니다.

다음 코드로 컴파일러 버전을 확인할 수 있습니다:

program VersionInfo;

{$APPTYPE CONSOLE}

uses
  System.SysUtils;

begin
  Writeln('현재 Delphi 컴파일러 버전: ', FloatToStr(CompilerVersion));
  Readln;
end.

출력 결과와 다음 표를 대조합니다:

Delphi 버전 CompilerVersion 지원 여부
Delphi 10 Seattle 27.0
Delphi 10.1 Berlin 28.0
Delphi 10.2 Tokyo 29.0
Delphi 10.3 Rio 30.0
Delphi 10.4 Sydney 31.0

주의: 높은 버전의 Delphi에서 강제로 컴파일에 성공하더라도, VCL/FMX 인터페이스 변경으로 인해 런타임에 오류가 발생할 수 있습니다.

올바른 검색 경로 설정

압축 해제된 sgcWebSockets 라이브러리 경로가 C:\Libs\sgcWebSockets라고 가정할 때, 프로젝트 옵션에 다음 경로를 추가합니다:

$(BDSLIB)\Win32\Release;
C:\Libs\sgcWebSockets\Source\Core;
C:\Libs\sgcWebSockets\Source\Common;
C:\Libs\sgcWebSockets\Source\WebSocket;
C:\Libs\sgcWebSockets\Source\SSL;

또한, .dproj 파일에 다음 XML 조각이 포함되어 있는지 확인해야 합니다:

<PropertyGroup Condition="'$(Config)'=='Release'">
  <DCC_UnitSearchPath>
    $(DCC_UnitSearchPath);
    C:\Libs\sgcWebSockets\Source\Core;
    C:\Libs\sgcWebSockets\Source\WebSocket
  </DCC_UnitSearchPath>
  <DCC_PackageDLLOutput>C:\Libs\sgcWebSockets\Lib</DCC_PackageDLLOutput>
</PropertyGroup>

그렇지 않으면 F2613 Unit ‘sgcWebSocket’ not found. 오류가 발생할 수 있습니다.

타사 의존성 확인

sgcWebSockets는 세 가지 핵심 컴포넌트에 의존합니다:

graph TD
    A[sgcWebSockets] --> B[TWebSocketServer]
    A --> C[TWebSocketClient]
    B --> D[Indy 10 TCP 스택]
    C --> D
    D --> E[Windows API / POSIX Sockets]
    A --> F[OpenSSL DLLs (SSL용)]
    F --> G[libeay32.dll, ssleay32.dll 또는 libssl-1_1-x64.dll]
    A --> H[Delphi RTL]
    H --> I[문자열 처리, 메모리 관리]

Indy 누락 여부는 다음 코드로 확인할 수 있습니다:

uses IdHTTP;

procedure CheckIndyInstallation;
var
  HttpClient: TIdHTTP;
begin
  HttpClient := TIdHTTP.Create(nil);
  try
    Writeln('Indy를 사용하여 IP 정보 요청: ' + HttpClient.Get('https://httpbin.org/ip'));
  except
    on E: Exception do
      Writeln('오류: Indy 컴포넌트 설치 또는 설정 문제 - ' + E.Message);
  end;
  HttpClient.Free;
end;

EIdSocketError가 발생하면 Indy가 올바르게 설치되지 않은 것이므로, Delphi 설치 프로그램을 다시 실행하고 "Indy Components"를 선택하여 설치해야 합니다.

설치 방법 선택

두 가지 주요 설치 방법이 있습니다:

방법 1: Install.bat 실행 (초보자 권장)

라이브러리 패키지 내의 Install.bat 파일을 실행합니다. 이 스크립트는 컴파일 및 설치 과정을 자동화합니다.

@echo off
set BDS=C:\Program Files (x86)\Embarcadero\Studio\20.0  REM Delphi 설치 경로에 맞게 조정
set PATH=%BDS%\bin;%PATH%

echo sgcWebSockets 패키지 빌드 시작...
"%BDS%\bin\bprebuild.exe" -B "Packages\sgcWSServerD10R.dpk"
"%BDS%\bin\dcc.exe" -B "Packages\sgcWSServerD10R.dpk"

echo 패키지 빌드 완료.
pause

실행 후 IDE 컴포넌트 패널에서 "sgc WebSockets" 분류를 확인할 수 있습니다.

방법 2: BPL 패키지 수동 등록

  1. Delphi IDE를 엽니다.
  2. 상단 메뉴에서 Component → Install Packages…를 선택합니다.
  3. "Add…" 버튼을 클릭합니다.
  4. Packages\Win32\sgcWSServerD10R.bpl 파일을 찾아 선택합니다.
  5. "열기"를 클릭하여 설치를 완료합니다.

일반적인 문제 해결 표:

점검 항목 정상적인 동작 이상 발생 시 처리 제안
컴포넌트 팔레트 가시성 "sgc WebSockets" 분류가 나타남 Install.bat을 다시 실행하거나 수동으로 패키지 추가
유닛 참조 가능 (uses) 구문 오류 없음 Search Path 확인
객체 인스턴스화 TWebSocketServer 인스턴스 생성 가능 BPL 로드 여부 확인
컴파일 성공 EXE 생성 및 링커 오류 없음 Missing Package 목록 확인
런타임 서비스 시작 포트 성공적으로 수신 대기, Access Violation 없음 권한, 방화벽, 포트 충돌 확인

크로스 플랫폼 배포: Windows를 넘어

FMX 프레임워크의 발전으로 Delphi는 Linux 및 macOS 배포를 지원합니다. sgcWebSockets 또한 크로스 플랫폼에서 작동하지만, 몇 가지 주의할 점이 있습니다.

대상 플랫폼 설정

IDE에서 다음 단계를 따릅니다:

  1. Project → Target Platforms 메뉴를 엽니다.
  2. Linux 64-bit 또는 macOS 64-bit를 추가합니다.
  3. 원격 빌드 에이전트(Paserver)를 활성화합니다.

Linux 서버의 경우 다음 런타임 라이브러리를 설치해야 합니다:

sudo apt-get update
sudo apt-get install libssl1.1 libxcursor1 libxrandr2 libxss1 \
                     libgconf-2-4 libnss3 libasound2

조건부 컴파일을 통한 플랫폼별 분리

uses
  System.IOUtils;

function DetermineApplicationLogPath: string;
begin
{$IFDEF MSWINDOWS}
  Result := TPath.GetDocumentsPath + TPath.DirectorySeparatorChar + 'AppLogs';
{$ENDIF}
{$IFDEF LINUX}
  Result := '/var/log/mywebsocketapp';
{$ENDIF}
{$IFDEF MACOS}
  Result := TPath.GetHomePath + TPath.DirectorySeparatorChar + 'Library' + TPath.DirectorySeparatorChar + 'Logs' + TPath.DirectorySeparatorChar + 'MyApp';
{$ENDIF}
  TDirectory.ForceCreate(Result);
end;

.dproj 파일의 출력 경로 관리와 함께 사용합니다:

<PropertyGroup Condition="'$(Platform)'=='Win32'">
  <OutputPath>.\Output\Win32\</OutputPath>
</PropertyGroup>
<PropertyGroup Condition="'$(Platform)'=='Linux64'">
  <OutputPath>.\Output\Linux\</OutputPath>
</PropertyGroup>

자동 배포를 위한 deploy.sh 스크립트 예시:

#!/bin/bash
BUILD_TARGET=$1

case $BUILD_TARGET in
  win32)
    echo "Windows 배포: EXE 파일 복사"
    cp ./Output/Win32/*.exe /mnt/windows-shared-folder/
    ;;
  linux)
    echo "Linux 배포: 서버로 바이너리 전송 및 서비스 재시작"
    strip ./Output/Linux/mywebsocketserver
    scp ./Output/Linux/mywebsocketserver user@linux-server-ip:/opt/mywebsocketapp/
    ssh user@linux-server-ip "sudo systemctl restart mywebsocketappservice"
    ;;
  macos)
    echo "macOS 배포: 애플리케이션 번들 서명 및 압축"
    codesign -s "Developer ID Application: Your Developer Name (XXXXXXXXXX)" ./Output/MacOS/MyWebSocketApp.app
    zip -r MyWebSocketApp_macOS.zip ./Output/MacOS/MyWebSocketApp.app
    ;;
  *)
    echo "사용법: $0 {win32|linux|macos}"
    exit 1
    ;;
esac

자동화는 생산성을 높이는 핵심입니다.

연결 관리: 생성부터 소멸까지의 제어

견고한 WebSocket 시스템은 단순히 연결이 잘 되는 것을 넘어, 연결을 효율적으로 관리할 수 있어야 합니다.

서버 초기화

WebSocketService := TWebSocketServer.Create(nil);
WebSocketService.Port := 8080;
WebSocketService.AutoStart := False; // 수동으로 Active 설정
WebSocketService.OnConnect := OnClientConnected;
WebSocketService.OnDisconnect := OnClientDisconnected;
WebSocketService.OnMessage := OnClientMessageReceived;

try
  WebSocketService.Active := True;
  Writeln('WebSocket 서버가 포트 ' + IntToStr(WebSocketService.Port) + '에서 시작되었습니다.');
except
  on E: Exception do
    Writeln('서버 시작 실패: ' + E.Message);
end;

주요 속성 설명:

속성명 설명
Port 수신 대기 포트 번호 (충돌 피하기)
Active 서비스 시작 및 중지 제어
MaxConnections 최대 동시 연결 수 제한
ReceiveBufferSize 수신 버퍼 크기

연결 풀 및 세션 추적

활성 연결을 스레드 안전한 컨테이너로 관리합니다:

type
  TClientSessionInfo = class
    ConnectionID: string;
    UserName: string;
    ConnectedTimestamp: TDateTime;
    SocketConnection: IWebSocketConnection;
  end;

var
  ClientSessionsManager: TThreadList<TClientSessionInfo>; // 애플리케이션 전역 또는 데이터 모듈에 선언

OnConnect 이벤트 핸들러에 추가합니다:

procedure TMainForm.OnClientConnected(Sender: TObject; const AConnection: IWebSocketConnection);
var
  SessionInfo: TClientSessionInfo;
  LockGuard: IInterface;
begin
  LockGuard := ClientSessionsManager.Lock; // 스레드 안전을 위한 잠금
  try
    SessionInfo := TClientSessionInfo.Create;
    SessionInfo.ConnectionID := AConnection.ID;
    SessionInfo.ConnectedTimestamp := Now;
    SessionInfo.SocketConnection := AConnection;
    // 실제 사용자 이름은 인증 로직 후에 할당
    SessionInfo.UserName := 'Guest_' + AConnection.ID; // 임시 이름
    ClientSessionsManager.Add(SessionInfo);
  finally
    LockGuard := nil; // 잠금 해제
  end;
  Writeln('클라이언트 연결: ' + SessionInfo.ConnectionID);
end;

OnDisconnect 이벤트 핸들러에서 적절히 정리합니다:

procedure TMainForm.OnClientDisconnected(Sender: TObject; const AConnection: IWebSocketConnection);
var
  SessionInfo: TClientSessionInfo;
  i: Integer;
begin
  with ClientSessionsManager.Lock do
  try
    for i := Count - 1 downto 0 do
    begin
      SessionInfo := Items[i];
      if SameText(SessionInfo.ConnectionID, AConnection.ID) then
      begin
        Delete(i);
        SessionInfo.Free;
        Writeln('클라이언트 연결 해제: ' + AConnection.ID);
        Break;
      end;
    end;
  finally
    nil; // 잠금 해제
  end;
end;

이는 메모리 누수를 방지하고 시스템이 장기적으로 안정적으로 작동하도록 보장합니다.

클라이언트의 지능형 재연결

네트워크 불안정은 피할 수 없으므로, 자동 재연결 메커니즘은 필수입니다. 여기서는 지수 백오프(Exponential Backoff) 전략을 추천합니다:

type
  TConnectionRetryHandler = class
  private
    FRetryCount: Integer;
    FMaxRetries: Integer;
    FBaseDelayMs: Integer;
    FTimer: TTimer;
    procedure OnRetryTimer(Sender: TObject);
  public
    constructor Create(AParent: TComponent);
    destructor Destroy; override;
    procedure StartReconnect(const TargetClientSocket: TWebSocketClient);
    procedure Reset;
  end;

constructor TConnectionRetryHandler.Create(AParent: TComponent);
begin
  inherited Create;
  FMaxRetries := 10;
  FBaseDelayMs := 1000; // 1초
  FRetryCount := 0;
  FTimer := TTimer.Create(AParent);
  FTimer.OnTimer := OnRetryTimer;
  FTimer.Enabled := False;
end;

procedure TConnectionRetryHandler.StartReconnect(const TargetClientSocket: TWebSocketClient);
begin
  if FRetryCount >= FMaxRetries then
  begin
    Writeln('최대 재연결 시도 횟수 초과. 재연결 중단.');
    Exit;
  end;

  Inc(FRetryCount);
  // 지수적으로 증가하는 재시도 간격: 1초, 2초, 4초, 8초... (또는 조금 더 복잡한 공식 적용 가능)
  FTimer.Interval := FBaseDelayMs * Round(Power(2, FRetryCount - 1));
  FTimer.Tag := NativeInt(TargetClientSocket); // 타이머 이벤트에서 클라이언트 객체 참조
  FTimer.Enabled := True;
  Writeln(Format('재연결 시도 #%d, 다음 시도까지 %d ms 대기...', [FRetryCount, FTimer.Interval]));
end;

procedure TConnectionRetryHandler.OnRetryTimer(Sender: TObject);
var
  ClientToReconnect: TWebSocketClient;
begin
  FTimer.Enabled := False;
  ClientToReconnect := TWebSocketClient(FTimer.Tag);
  if Assigned(ClientToReconnect) and not ClientToReconnect.Connected then
  begin
    try
      ClientToReconnect.Connect;
    except
      on E: Exception do
        Writeln('재연결 시도 중 오류: ' + E.Message);
      StartReconnect(ClientToReconnect); // 실패 시 다음 재시도 예약
    end;
  end else if Assigned(ClientToReconnect) and ClientToReconnect.Connected then
  begin
    Writeln('재연결 성공.');
    Reset; // 성공 시 재시도 카운트 초기화
  end;
end;

procedure TConnectionRetryHandler.Reset;
begin
  FRetryCount := 0;
  FTimer.Enabled := False;
end;

초기 지연 1초부터 시작하여 매회 약 2배씩 증가하며 최대 10회까지 시도합니다. 이는 서버에 과도한 부담을 주지 않으면서도 쉽게 연결을 포기하지 않는 균형 잡힌 접근 방식입니다.

다중 스레드에서 UI 안전하게 접근

OnMessage 이벤트 핸들러에서 직접 UI 컨트롤을 업데이트해서는 안 됩니다!

올바른 방법은 TThread.Synchronize를 사용하는 것입니다:

procedure TMainForm.ClientMessageProcessor(Sender: TObject; const IncomingMessage: string);
begin
  // UI 업데이트는 메인 스레드에서 수행해야 함
  TThread.Synchronize(nil,
    procedure
    begin
      LogMemo.Lines.Add(DateTimeToStr(Now) + ' - ' + IncomingMessage);
    end);
end;

또는 메시지 메커니즘을 사용하여 컨텍스트 전환을 줄일 수 있습니다:

const WM_APP_NEW_DATA = WM_USER + 100;

procedure TMainForm.ClientMessageProcessor(Sender: TObject; const IncomingMessage: string);
begin
  // 메시지 데이터를 동적으로 할당하고 PostMessage로 전달
  // 수신측에서 PWideChar로 캐스팅하여 사용 후 메모리 해제
  var DataPtr: PWideChar;
begin
  DataPtr := PWideChar(TStringHelper.ToWideChar(IncomingMessage)); // String to PWideChar 복사본 생성
  PostMessage(Handle, WM_APP_NEW_DATA, 0, LPARAM(DataPtr));
end;

procedure TMainForm.WMAppNewData(var Msg: TMessage); message WM_APP_NEW_DATA;
var
  ReceivedMessage: string;
  DataPtr: PWideChar;
begin
  DataPtr := PWideChar(Msg.LParam);
  if Assigned(DataPtr) then
  begin
    ReceivedMessage := DataPtr;
    LogMemo.Lines.Add(DateTimeToStr(Now) + ' - ' + ReceivedMessage);
    // PostMessage로 전달된 동적 메모리 해제
    TStringHelper.FreeWideChar(DataPtr);
  end;
end;

TStringHelper.ToWideCharTStringHelper.FreeWideChar는 `System.SysUtils`에 정의되어 있지 않으므로, 직접 구현하거나 TStringList, TMemoryStream 같은 클래스를 활용하여 데이터를 전달하는 것이 더 안전하고 간편합니다. 위의 예시에서는 직접 메모리 관리가 필요하므로 주의해야 합니다.

실전 사례: 다중 사용자 채팅방 구축

지금까지 배운 지식을 활용하여 완전한 채팅방 데모를 만들어 봅시다.

사용자 로그인 및 연결 설정

프론트엔드에서 토큰을 전송합니다:

const userToken = localStorage.getItem('auth_token');
const ws = new WebSocket(`ws://server:8080/chat?token=${userToken}`);

서버에서 토큰을 파싱합니다:

uses
  System.Net.URLClient, System.JSON;

function GetUserIdentifierFromConnection(const ConnDetails: IWebSocketConnection): string;
var
  QueryString: string;
  TokenValue: string;
begin
  QueryString := ConnDetails.QueryParams;
  TokenValue := TURLClient.GetParamValue(QueryString, 'token'); // 쿼리 파라미터에서 'token' 값 추출
  if TokenValue <> '' then
    Result := DecodeJWTToken(TokenValue) // 실제 JWT 토큰 디코딩 함수 호출
  else
    Result := 'Anonymous'; // 토큰이 없으면 익명 사용자
end;

온라인 사용자 목록 동적 관리

procedure BroadcastOnlineUserList;
var
  OnlineUserIdentifiers: TStringList;
  JsonMsg: TJSONObject;
  UserArray: TJSONArray;
  SessionItem: TClientSessionInfo;
begin
  OnlineUserIdentifiers := TStringList.Create;
  UserArray := TJSONArray.Create;
  JsonMsg := TJSONObject.Create;
  try
    // ClientSessionsManager는 TThreadList<TClientSessionInfo> 타입으로 가정
    with ClientSessionsManager.Lock do
    try
      for SessionItem in Items do
        if Assigned(SessionItem.SocketConnection) and SessionItem.SocketConnection.Connected then
        begin
          OnlineUserIdentifiers.Add(SessionItem.UserName); // 실제 사용자 이름 사용
          UserArray.Add(TJSONString.Create(SessionItem.UserName));
        end;
    finally
      nil;
    end;

    JsonMsg.AddPair('type', TJSONString.Create('users_update'));
    JsonMsg.AddPair('data', UserArray);

    DistributeMessageToAll(JsonMsg.ToString);
  finally
    OnlineUserIdentifiers.Free;
    JsonMsg.Free; // UserArray는 JsonMsg에 의해 소유되므로 따로 해제할 필요 없음
  end;
end;

연결 끊김 후 오프라인 메시지 푸시

procedure PushQueuedMessagesToUser(const TargetUserID: string; const ClientConnection: IWebSocketConnection);
var
  MessageQueue: TArray<string>; // Redis 또는 메모리 큐에서 가져온 메시지 배열
  QueuedMessage: string;
begin
  // 실제 구현에서는 Redis 또는 영구 스토리지에서 메시지를 가져옴
  MessageQueue := FetchQueuedMessages(TargetUserID);

  for QueuedMessage in MessageQueue do
    if ClientConnection.Connected then
      ClientConnection.Send(QueuedMessage);
  // 메시지 전송 후 큐에서 제거
  ClearQueuedMessages(TargetUserID);
end;

Redis 또는 메모리 큐와 결합하여 메시지 누락 없이 전송을 보장합니다.

성능 최적화 및 프로덕션 배포 권장 사항

WSS 암호화 통신 활성화

공개 네트워크에서는 반드시 SSL을 활성화해야 합니다:

SecureWebSocketServer := TWebSocketServer.Create(nil);
SecureWebSocketServer.Port := 443; // WSS 기본 포트
SecureWebSocketServer.UseSSL := True;
SecureWebSocketServer.SSLCertFile := 'path/to/server_cert.pem';
SecureWebSocketServer.SSLKeyFile := 'path/to/server_key.pem';
SecureWebSocketServer.SSLPassPhrase := 'YourSecurePasswordHere'; // 개인 키 암호

클라이언트 측에서도 인증서 유효성 검사를 활성화합니다:

ClientSocket.SSLVerifyPeer := True;
ClientSocket.SSLCAFile := 'path/to/ca_bundle.crt'; // CA 인증서 묶음 파일

이를 통해 중간자 공격 위험을 제거할 수 있습니다.

고성능 동시성 튜닝 팁

  • 객체 풀(Object Pool)을 사용하여 가비지 컬렉션(GC) 부하 감소
  • 작은 메시지를 묶어 일괄 전송 (Batching)
  • 메시지 큐를 도입하여 처리 로직 분리 및 비동기화
  • Nginx를 이용한 로드 밸런싱:
upstream realtime_service_pool {
    ip_hash; # 클라이언트 IP 기반으로 세션을 고정
    server 192.168.1.10:8080;
    server 192.168.1.11:8080;
    # 더 많은 WebSocket 서버 인스턴스 추가 가능
}

server {
    listen 443 ssl;
    server_name your.domain.com;

    ssl_certificate /etc/nginx/certs/your.domain.com.crt;
    ssl_certificate_key /etc/nginx/certs/your.domain.com.key;

    location /ws {
        proxy_pass http://realtime_service_pool;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host; # 원본 Host 헤더 전달
        proxy_read_timeout 86400s; # WebSocket은 장시간 연결이므로 타임아웃 길게 설정
    }
}

로그 및 모니터링 통합

uses
  System.Net.HttpClient, System.JSON, System.Generics.Collections;

procedure RecordEventToLogService(const LogMessage: string);
var
  ApiRequestor: TNetHttpClient;
  JsonPayload: TJSONObject;
begin
  ApiRequestor := TNetHttpClient.Create(nil);
  JsonPayload := TJSONObject.Create;
  try
    JsonPayload.AddPair('timestamp', TJSONString.Create(FormatDateTime(sISO8601Format, Now)));
    JsonPayload.AddPair('level', TJSONString.Create('INFO'));
    JsonPayload.AddPair('message', TJSONString.Create(LogMessage));

    ApiRequestor.ContentType := 'application/json';
    ApiRequestor.Post('http://log-aggregator-service:8080/api/logs', TStringStream.Create(JsonPayload.ToString));
  finally
    ApiRequestor.Free;
    JsonPayload.Free;
  end;
end;

Elasticsearch, Kibana와 같은 도구와 연동하여 시각적인 운영 모니터링을 구현할 수 있습니다.

태그: Delphi sgcWebSockets websocket realtime Full-Duplex

6월 20일 22:49에 게시됨