상세 컨텐츠

본문 제목

화상회의 프로젝트 에피소드 5 - Virtual Socket

프로젝트/Ah!FreeCa

by ryujt 2010. 12. 13. 14:22

본문

소켓을 다루다 보면, 하나의 어플리케이션이 다양한 종류의 패킷을 다뤄야하는 상황이 종종 발생합니다.  이때 이를 처리하는 가장 손 쉬운 방법은 각 패킷의 종류 마다 다른 포트를 사용하는 것 입니다.  이렇게 되면 전혀 다른 패킷의 처리 과정이 서로 섞이지 않기 때문에 효율적인 코드 관리가 가능합니다.  (단일 책임의 원칙)

하지만, 하나의 사용자 또는 목적으로 인해서 복수 개의 커넥션을 관리하는 것이 문제가 될 경우가 있습니다.  상황에 따라 코드의 효율성이 떨어 질 수도 있고, 보안의 이유로 복수 개의 포트를 사용 할 수 없는 경우도 발생합니다.  

이번에는 위와 상황에서 사용 할 수 있도록 만들어진 Virtual Socket 을 소개하도록 하겠습니다.

[그림 1] Virtual Socket의 Object 의존도

[그림 1]은 화상회의 프로젝트에서 사용된 Virtual Socket의 Object 의존도를 간략하게 그려 본 것 입니다.  서버와 클라이언트가 기본적인 구조가 같기 때문에 서버의 기준으로 그려봤습니다.  화상회의 프로젝트에서 사용되는 문자, 음성, 비디오 등 세 종류의 패킷을 각각의 별도의 객체로 다루고 있습니다.
  • Provider는 실제로 패킷을 처리하는 객체이며, 접속 및 패킷 송수신의 기능을 담당합니다.  패킷을 다루는 정책이나 소켓의 종류 등이 변경하더라도 실제 사용에 영향을 주지 않도록 설계되어 있습니다.
  • VirtualServer 또는 VirtualClient 는 가상의 포트(Channel)를 제공하여, 하나의 커넥션으로 전혀 다른 패킷을 격리하여 다룰 수 있도록 합니다.
  • TextSocket, VoiceSocket, VideoSocket 등은 각각 자신의 패킷을 다루기 위한 객체 입니다.  패킷 처리에 대한 논리적인 부분을 담당합니다.

VirtualSocket에서 여러 종류의 패킷을 가상 포트로 나눠서 다루는 방법은 상당히 간단합니다.

VirtualSocket에서 [메시지]를 보내면, VirtualServer(또는 VirtualClient)는 Provider에게 [채널정보][메시지] 형태로 헤더를 추가해서 전송합니다.  반대로, Provider가 [채널정보][메시지]를 수신하면, 이를 해당 VirtualSocket에 채널정보를 이용하여, [메시지]만을 전달하게 됩니다.

즉, Provdier를 통할 때, 마다 패킷 앞에 [채널정보]를 덧붙여서 접속은 하나이지만, 어떤 가상 포트(채널)가 해당 메시지를 받거나 사용하게 될 지를 구분하는 것 입니다.

[그림 2]는 서버의 일부를 Class Diagram 으로 표기 한  것 입니다.

[그림 2] 서버의 Class Diagram (일부)

다소 복잡하게 보이긴 하지만, Provider와 VirtualServer의 의존성을 Interface를 통해서 감추고 있습니다.  VirtualSever는 단순하게 실체화하여 사용하기 때문에 어떠한 요구사항 변화에도 수정없이 그대로 사용하게 됩니다. 

하지만, Provider의 경우에는 사용 할 때마다 IVirtualServerProvider 인터페이스를 구현하도록 하고 있습니다.  그 이유는  상황에 따라 소켓의 종류가 달라질 수도 있기 때문입니다.  현재 제가 사용하는 소켓은 Indy 9 입니다.  그런데, 만약 제가 본 프로젝트에서 사용 중인 델파이의 버전을 업그레이드하게 된다면, Indy 10을 사용해야 합니다.  이때, VirtualServer는 IVirtualServerProvider 인터페이스를 통해서 Provider에게 의존하게 되므로, 내부 구현이 어떻게 변하더라도 전혀 문제가 될 것이 없습니다.

VirtualSover는 멤버로 복수 개의 VirtualSocket 객체를 목록으로 관리하게 됩니다.  이때, VirtualSocket은 IServerSocket 인터페이스를 구현해서 사용하게 됩니다.

Class Diagram이 다소 복잡하게 보일 지는 모르지만 사용 방법은 [소스 1]에서처럼 상당히 간단합니다.  

[소스 1]
procedure TfmMain.FormCreate(Sender: TObject);
begin
  FVirtualServer := TVirtualServer.Create(TVirtualServerProvider.Create);
  FVirtualServer.Port := 1234;

  // 클라이언트와 서버의 AddSocket 순서가 동일해야 한다.
  FVirtualServer.AddSocket(TTextServer.Create);
  FVirtualServer.AddSocket(TVoiceServer.Create);
  FVirtualServer.AddSocket(TVideoServer.Create);

  FVirtualServer.Start;
end;
3: 라인에서처럼 VirtualServer를 실체화 할 때, 파라메터로 Provider를 넘겨주면 됩니다.  이렇게 하면, 내부에서 서로의 인터페이스를 교환하여 간직하게 되며, 이후 발생하는 이벤트에 의해서 의존적인 메소드를 호출하게 됩니다.

7-9: 라인의 경우에는 패킷 종류마다 이를 처리하려는 객체를 VirtualServer에 Add 하여 목록으로 관리하도록 합니다.  이때, 서버와 클라이언트으 순서가 같아야 합니다.  0번에서 하나씩 증가하면서 가상 포트(채널)을 부여하도록 합니다.

11: 라인에서 Start를 통해서 실제 서버 소켓을 열도록 합니다.

[그림 3] 클라이언트의 Class Diagram

[그림 3]은 클라이언트에 대한 Calss Diagram 입니다.  서버와 동일한 구조로 되어 있는 것을 확인하실 수가 있습니다.  사용법 역시 서버와 같이 간단합니다.

[소스 2]
procedure TfmMain.FormCreate(Sender: TObject);
begin
  FVirtualClient := TVirtualClient.Create(TVirtualClientProvider.Create);
  FVirtualClient.Port := 1234;

  // 클라이언트와 서버의 AddSocket 순서가 동일해야 한다.
  FVirtualClient.AddSocket(TTextClient.Create);
  FVirtualClient.AddSocket(TVoiceClient.Create);
  FVirtualClient.AddSocket(TVideoClient.Create);

  FVirtualClient.Host := '127.0.0.1';
  FVirtualClient.Port := 1234;
  if not FVirtualClient.Connect then begin
    Application.Terminate;
  end;
end;
11-15: 라인에서는 서버로 접속을 시도하고, 실패하면 프로그램을 종료하도록 되어 있습니다.

예제에 대한 소스는 아래의 주소를 참고하시기 바랍니다.  아래 예제 소스에서는 클라이언트 풀링을 토대로 Provider가 작성되어 있습니다.

Web site : http://dev.naver.com/projects/ryulib4delphi
Subversion : https://dev.naver.com/svn/ryulib4delphi/trunk (password is 'anonsvn')
  * 예제 소스 : /VirtualSocket/test 

지금부터는 실제 코드 내용을 살펴보면서 설명을 이어가도록 하겠습니다.  우선 서버 쪽부터 시작합니다.

VirtualSocket을 사용하기 위해 제일 먼저 해야 할 일은 Provider를 구현하는 하는 것 입니다.  Provider의 구현은 아래와 같은 요소 등에 따라 달라 질 수 있습니다.  예제에서는 이미 언급한 대로, 클라이언트 풀링을 사용하며, Indy 9을 래핑해서 사용합니다.  제한 속도 등과 같은 디테일한 설정은 예제에서 제외되어 있습니다.  이와 같은 부분들은 실제 프로젝트의 소스를 소개 할 때 다루도록 하겠습니다.
  • 소켓은 어떤 소켓을 사용 할 것 인지?  
  • 또는 자신이 직접 소켓을 구현 할 것 인지?  
  • 패킷 전송 정책은 어떻게 할 것 인지?  (전송 속도 제한, 풀링 or 푸시 등)
  • 패킷의 종류에 따라 우선 순위를 둘 것 인지?

다음으로 작성해야 할 것은 패킷의 종류 마다 VirtualSocket을 구현하는 것 입니다.  예제에서는 Text, Voice, Video로 나누어서 작업이 되어 있지만, 실제 구현이 완료되어 있는 것은 Text 뿐 입니다.  Voice와 Video에 대해서는 다른 포스트에서 다루도록 하겠습니다.

VirtualSocket을 구현할 때는, [소스 3]과 같이 TVirtualServerSocket 클래스를 상속받아서 사용합니다.  [소스 3]에는 지금까지 설명이 생략된 TChannelInfo 클래스가 나타납니다.  이는 현재 접속한 사용자의 각 채널에 대한 정보를 관리하는 클래스 입니다.  모든 채널에 공동적으로 사용되는 표준적인 방법을 따른다면 구태여 10: 라인처럼 상속하여 새로운 클래스를 작성 할 필요는 없습니다.

TChannelInfo 클래스를 확장하여 사용하는 경우에 대해서는 역시 실제 프로젝트 소스를 통해서 설명하도록 하겠습니다.

[소스 3]
unit TextServer;

interface

uses
  VirtualSocketUtils, VirtualServerSocket,
  Classes, SysUtils;

type
  TTextChannelInfo = class (TChannelInfo)
  private
  public
  end;

  TTextServer = class (TVirtualServerSocket)
  private
  public
  protected
    function do_CreateChannelInfo(AConnection:IConnection):TChannelInfo; override;
    procedure do_OnConnected(AChannelInfo:TChannelInfo); override;
    procedure do_OnDisonnected(AChannelInfo:TChannelInfo); override;
    procedure do_OnReceived(AChannelInfo:TChannelInfo; AData:pointer; ASize:integer); override;
  end;

implementation

{ TTextServer }

function TTextServer.do_CreateChannelInfo(
  AConnection: IConnection): TChannelInfo;
begin
  Result := TTextChannelInfo.Create(AConnection, FChannel);
end;

procedure TTextServer.do_OnConnected(AChannelInfo: TChannelInfo);
begin
  // Todo :
  AChannelInfo.Certified := true;

end;

procedure TTextServer.do_OnDisonnected(AChannelInfo: TChannelInfo);
begin

end;

procedure TTextServer.do_OnReceived(AChannelInfo: TChannelInfo; AData: pointer;
  ASize: integer);
begin
  if ASize > 0 then SendToAll(AData, ASize)
  else AChannelInfo.SendData;
end;

end.

19-22: 라인에서는 TVirtualServerSocket 에서 정의한 abstract 메소드를 재 정의하고 있습니다.  이에 대한 설명은 각각 아래와 같습니다.
  • do_CreateChannelInfo 
    • 채널 정보를 관리 할 객체를 생성하여, 부모 클래스가 이를 통해 VirtualSocket 하부 로직을 처리하도록 합니다.
    • 32: 라인에서는 TTextChannelInfo 을 생성하고 있지만, 현재 예제의 상태로는 TChannelInfo를 생성하여 리턴하여도 무방합니다.
    • AConnection은 실제 접속 정보를 관리하는 객체입니다.  자동으로 생성되어 인자로 넘어옵니다.
    • FChannel은 자신의 채널 정보입니다.  0번부터 차례로 채널 번호를 자동으로 부여 받게 됩니다.
  • do_OnConnected
    • 새로운 커넥션이 접속되었을 때, 발생하는 이벤트 호출 입니다.
    • 38: 라인에서는 접속된 커넥션을 바로 인증을 하여 줍니다.  (일종의 로그인)  실제로는 로그인 과정을 거쳐서 사용자 인증을 거친 사용자를 따로 관리하고자 할 때 사용 합니다.
    • AChannelInfo.Certified := true; 와 같은 코드는 현재 체널이 아닌 전체 커넥션에 대한 인증을 true로 변경합니다.
  • do_OnDisonnected 
    • 접속이 끊어질 때, 발생하는 이벤트 호출 입니다.
  • do_OnReceived
    • 새로운 패킷이 수신되었을 때 발생하는 이벤트 호출 입니다.
    • 50: 라인에서 패킷의 크기가 0보다 크면, 자신을 포함한 전체 사용자에게 같은 메시지를 전달하도록 하고 있습니다.
    • SendToAll은 TVirtualServerSocket에 미리 구현된 메소드이며, SendTo, SendToOther 등이 준비되어 있습니다.  이 부분에 대한 설명도 실제 프로젝트 소스 설명을 할 때, 진행하도록 하겠습니다.
    • 패킷의 크기가 0보다 작으면, 지금까지 자신의 채널에 쌓인 메시지를 클라이언트에 보내도록 합니다. 
    • 예제에서 클라이언트는 패킷의 크기가 0인 메시지를 수시로 서버에게 보내면서 자신에게 온 메시지를 풀링 해 가도록 Provider가 작성되어 있습니다.

위에서 등장하는 IConnection의 멤버 구성은 아래와 같습니다.
  • procedure Send(AChannel:byte; AData:pointer; ASize:integer);  
    • 실제 데이터를 클라이언트에 전송합니다.
    • 사용자가 직접 사용하는 것보다, TChannelInfo를 통해서 전달 되도록 합니다.  전체 구조를 이해하고 자신만의 로직을 적용하는 것이 아니라면 사용하지 마시기 바랍니다.
  • procedure setCertified(AValue:boolean);  
    • 사용자 인증을 지정합니다.  true가 지정되면 해당 커넨셕의 인증은 허가된 상태입니다.
    • TVirtualServerSocket.SendToAll() 등은 사용자 인증이 안되어 있는 경우 해당 커넨셕에게는 패킷을 전달 하지 않습니다.
  • function getCertified:boolean;  
    • 현재 커넥션의 인증 상태를 리턴합니다.
  • function isReadyToSend:boolean;
    • Provider 쪽에서 패킷의 우선 순위 또는 전송 속도를 제어하고 싶을 때 사용합니다.  false가 리턴되면 아직 패킷을 보낼 허가가 안 떨어졌으니 보내지 말라는 의이이며, TChannelInfo에서는 isReadyToSend:boolean에 의존하여 패킷을 보낼 수 있는 지를 확인하고 보내도록 되어 있습니다.
  • procedure setUserData(AChannel:byte; AData:pointer);
    • IConnection 마다 연결된 TChannelInfo로 생성된 객체를 관리하기 위한 메소드 입니다.
    • TVirtualServerSocket 내부에서 사용되며, 사용자가 직접 사용 할 일은 없습니다.
  • function getUserData(AChannel:byte):pointer;  
    • IConnection 마다 연결된 TChannelInfo로 생성된 객체를 관리하기 위한 메소드 입니다.
    • TVirtualServerSocket 내부에서 사용되며, 사용자가 직접 사용 할 일은 없습니다.

TChannelInfo 멤버 구성은 아래와 같습니다.
  • procedure ClearData;  
    • 현재 채널에 저장된 패킷을 모두 삭제합니다.
    • TChannelInfo은 자신과 연결된 클라이언트에게 보낸 메시지를 버퍼에 간직하고 있다가, 이것을 전달되어야 하는 시기가 되면, 버퍼에서 꺼낸 후 클라이언트에게 보내도록 되어 있습니다.
  • procedure AddData(AData:pointer; ASize:integer); virtual;  
    • 자신과 연결된 클라이언트의 같은 채널에게 보낼 패킷을 버퍼에 저장한다.
    • 본 메소드가 virtual로 선언되어 있기 때문에 사용자는 다른 정책을 사용하여 패킷을 처리 할 수가 있습니다.  예를 들어 음성의 경우에는 버퍼에 너무 많은 데이터가 적재되면, 음성 전달이 지연되므로, 적정량 이상의 패킷은 삭제하는 경우가 되겠습니다.
  • procedure SendData; virtual;  
    • 자신과 연결된 클라이언트의 같은 채널에게 패킷을 보낸다.
    • [소스 4]의 6: 라인과 12: 라인에 보면, 이미 언급한 바와 같이 IConnection.isReadyToSend를 의존하여 계속 패킷을 전송 할 것인지를 결정하고 있습니다.
    • 본 메소드가 virtual로 선언되어 있기 때문에 사용자는 다른 정책을 사용하여 패킷을 처리 할 수가 있습니다.
  • property Certified : boolean read GetCertified write SetCertified;
    • 사용자 인증 여부를 지정하거나 참조합니다.

[소스 4]
procedure TChannelInfo.SendData;
var
  Data : pointer;
  Size : integer;
begin
  if not FConnection.isReadyToSend then Exit;

  FBuffer.GetPacket(Data, Size);
  while (Size > 0) do begin
    FConnection.Send(FChannel, Data, Size);

    if not FConnection.isReadyToSend then Break;

    FBuffer.GetPacket(Data, Size);
  end;
end;

클라이언트의 경우에도 서버와 마찬가지로 Provider를 우선 구현해야 합니다.  이후에는 VirtualSocket을 구현해야 합니다.  클라이언트의 VirtualSocket의 경우에는 TVirtualClientSocket 상속 받아서 구현 합니다.

클라이언트 역시 Text만이 구현된 상태이며, 소스는 [소스 5]와 같습니다.  [소스 5]에 나오는 ObserverList의 자세한 설명은 http://ryulib.tistory.com/85 포스트를 참고하시기 바랍니다.

[소스 5]
unit TextClient;

interface

uses
  VirtualClientSocket, ObserverList,
  Classes, SysUtils, ExtCtrls;

type
  TTextClient = class (TVirtualClientSocket)
  private
    FTimer : TTimer;
    FObserverList : TObserverList;
    procedure on_Timer(Sender:TObject);
  protected
    FChannel : byte;
    procedure do_OnConnected; override;
    procedure do_OnDisonnected; override;
    procedure do_OnReceived(AData:pointer; ASize:integer); override; 
  public
    constructor Create;
    destructor Destroy; override;

    procedure SendText(AText:string);

    property ObserverList : TObserverList read FObserverList;
  end;

implementation

{ TTextClient }

constructor TTextClient.Create;
begin
  inherited;

  FObserverList := TObserverList.Create(nil);

  FTimer := TTimer.Create(nil);
  FTimer.Interval := 5;
  FTimer.Enabled := true;
  FTimer.OnTimer := on_Timer;
end;

destructor TTextClient.Destroy;
begin
  FreeAndNil(FObserverList);
  FreeAndNil(FTimer);

  inherited;
end;

procedure TTextClient.do_OnConnected;
begin

end;

procedure TTextClient.do_OnDisonnected;
begin

end;

procedure TTextClient.do_OnReceived(AData: pointer; ASize: integer);
var
  ssData : TStringStream;
begin
  ssData := TStringStream.Create('');
  try
    ssData.Write(AData^, ASize);

    ssData.Position := 0;
    FObserverList.AsyncBroadcast(ssData.DataString);
  finally
    ssData.Free;
  end;
end;

procedure TTextClient.on_Timer(Sender: TObject);
begin
  // 0바이트 데이터를 보내면 서버에 저장되어 있는 메시지를 풀링 해 온다.
  Send(nil, 0);
end;

procedure TTextClient.SendText(AText: string);
begin
  if AText <> '' then Send(@AText[1], Length(AText));
end;

end.

17-19: 라인에서는 TVirtualClientSocket에 정의된 abstract 메소들 재 정의하고 있습니다.  이들은 서버와 동일하므로 설명을 생략 합니다.

78-82: 라인에서는 TTimer를 이용하여 주기적으로 서버에게 0 크기의 패킷을 보내서 자신에게 메시지가 온 것이 있는 지 확인하고 있습니다.  서버는 이 패킷을 받게되면 이미 설명한 바와 같이 해당 클라이언트에게 전달해야 할 패킷을 버퍼에서 꺼내서 전송하게 됩니다.

63-76: 라인은 패킷을 수신 받았을 경우 처리하는 과정이 표현되어 있습니다.

72: 라인에서는 FObserverList.AsyncBroadcast() 메소드를 통해서 수신 된 패킷을 임의의 객체에게 비동기식으로 전달합니다.  쓰레드를 통해서 전달 받은 메시지를 동기식으로 처리 할 경우 발생 될 문제를 피하기 위함입니다.

[소스 6]
unit _fmMain;

interface

uses
  ValueList, VirtualClient, VirtualClientProvider,
  TextClient, VoiceClient, VideoClient,
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls;

type
  TfmMain = class(TForm)
    moMsg: TMemo;
    edMsg: TEdit;
    procedure FormCreate(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure edMsgKeyPress(Sender: TObject; var Key: Char);
  private
    FVirtualClient : TVirtualClient;
    FTextClient : TTextClient;
    procedure on_Disconnected(Sender:TObject);
  public
  published
    procedure rp_Talk(APacket:TValueList);
  end;

var
  fmMain: TfmMain;

implementation

{$R *.dfm}

procedure TfmMain.edMsgKeyPress(Sender: TObject; var Key: Char);
begin
  if Key = #13 then begin
    Key := #0;
    FTextClient.SendText(Format('Code=Talk<rYu>Msg=%s', [edMsg.Text]));
    edMsg.Text := '';
  end;
end;

procedure TfmMain.FormCreate(Sender: TObject);
begin
  FTextClient := TTextClient.Create;
  FTextClient.ObserverList.Add(Self);

  FVirtualClient := TVirtualClient.Create(TVirtualClientProvider.Create);
  FVirtualClient.Port := 1234;
  FVirtualClient.OnDisconnected := on_Disconnected;

  // 클라이언트와 서버의 AddSocket 순서가 동일해야 한다.
  FVirtualClient.AddSocket(FTextClient);
  FVirtualClient.AddSocket(TVoiceClient.Create);
  FVirtualClient.AddSocket(TVideoClient.Create);

  FVirtualClient.Host := '127.0.0.1';
  FVirtualClient.Port := 1234;
  if not FVirtualClient.Connect then begin
    Application.Terminate;
  end;
end;

procedure TfmMain.FormDestroy(Sender: TObject);
begin
  FVirtualClient.Free;
end;

procedure TfmMain.on_Disconnected(Sender: TObject);
begin
  Application.Terminate;
end;

procedure TfmMain.rp_Talk(APacket: TValueList);
begin
  moMsg.Lines.Add(APacket.Values['Msg'])
end;

end.

46: 라인을 통해서 [소스 5]의 FObserverList.AsyncBroadcast()를 통해서 전송되는 메시지를  Self(fmMain)이 받아서 처리하겠다는 것을 알립니다.

24: 라인은 FObserverList.AsyncBroadcast()에서 전송되는 메시지 중 "Code=Talk" 라는 라인이 있다면, 자신을 실행 시켜 달라는 뜻 입니다.  이때 메소드의 이름은 Code의 Value 값 앞에 "rp_" 붙여서 사용합니다.  파라메터로는 TStringList를 래핑한 TValueList를 사용합니다.  그리고, RTTI를 사용하기 때문에 published에 선언되어야 합니다.

이렇게 ObserverList를 통해서 메시지를 처리하면, 조건문을 사용하지 않고 문자열의 데이터를 토대로 메소드를 검색해서 직접 실행하게 되어 장.단점이 생기게 됩니다.  제가 작성하는 대부분의 패킷 처리 코드는 이렇게 구현되어 있습니다.

38: 라인에서 문자열에 있는 '<rYu>' 경우는 TStringList의 LineBreak를 '<rYu>'로 지정했기 때문에 사용 된 것 입니다.  기본 그대로 리턴 문자를 사용하면, 메시지 내부에서 발생하는 리턴 문자와 메시지의 내용을 구별하는 LineBreak가 서로 충돌하기 때문입니다.  따라서, 사용자가 '<rYu>'를 입력하지 못하도록 UI에서 방어를 하셔야 합니다.  또한, LineBreak는 개발자 스스로가 마음에 드는 것으로 변경해서 사용해도 상관없습니다.

예제는 컴파일 후 서버를 먼저 실행하시고, 클라이언트를 원하는 수 만큼 실행한 후 에디트 창에 메시지를 입력 한 다음 엔터키를 치면, 전체 클라이언트에게 메시지를 전달하면서 채팅을 할 수 있게 됩니다.


관련글 더보기