저작자 표시 비영리 변경 금지
신고

Posted by 류종택


libvpx를 NDK를 이용해서 빌드하는 방법입니다.


우선 libvpx를 다운 받습니다.

  • $ git clone https://chromium.googlesource.com/webm/libvpx

libvpx 폴더에서 아래와 같이 configure를 실행합니다.
  • $ ./configure --target=armv7-android-gcc --disable-examples --sdk-path=[NDK가 설치 된 폴더] --extra-cflags='-O3 -mfloat-abi=softfp -mfpu=neon' 
  • cygwin에서 에러가 나는데 정확한 이유는 모르겠고, 우분투에서 빌드하니 바로 됩니다.  혹시 cygwin에서 에러나시는 분들은 우분투에서 시도해보시기 바랍니다.
  • NDK는 미리 설치되어 있어야 합니다.

make를 실행합니다.

jni 폴더에서 NDK를 실행합니다.
  • $ [NDK가 설치 된 폴더]/ndk-build
빌드가 성공하면, /libs/armeabi-v7a 폴더에 libRyuVPX.so 파일이 생성 됩니다.

제대로 빌드되었는 지 확인하기 위한 테스트 프로그램 소스입니다.

  • 소스 위치 가기
  • TextView에 오픈 된 디코더의 핸들 값이 표시 됩니다.  0이 아닌 숫자이기만 하면 됩니다.
  • 인코더의 경우에는 에러가 나는데, 하도 오래 전에 만든 넘을 손 본 것이라 이번에는 디코더만 테스트합니다.  추후 수정하여 계속 포스트를 올리도록 하겠습니다.




저작자 표시 비영리 변경 금지
신고

Posted by 류종택
  • Ah! Free Ca(AFC)는 간단한 화상회의 프로젝트입니다.
  • 교육을 목적으로 진행되고 있는 프로젝트입니다.
  • 수 년 간 실제 서비스를 통해서 검증 된 실무 노하우가 포함되어 있습니다.  (http://www.himytv.com/)


TO DO

  • 비디오 인코더를 VPX에서 Intel Media SDK로 변경 할 예정입니다.  VPX는 고해상도에서 CPU 사용률이 매우 높지만, Intel Media SDK를 이용하면, CPU가 H264 인코딩에 대한 하드웨어 가속을 지원 할 경우 비약적인 성능을 보여 줍니다.  (http://goo.gl/bbM1Dz)
  • Speex를 MP3 또는 OPUS로 변경하여 음질을 높일 예정입니다.
  • 음성과 오디오의 완벽한 싱크를 위한 모듈이 아직 마무리가 되지 않았습니다.
  • 그 밖에도 사소한 부분들이 아직 원래 소스로부터 옮겨오지 못한 부분들이 있습니다.
    • 실무 코드 자체를 강의하기에는 무리가 많으며, 공개를 아직 결정하지 못한 부분들을 정리하는 중 입니다.



소스 코드 다운로드


AhFreeCa.zip


RyuLib.zip


(편의를 위해서 2015.02.23일자 소스 코드를 위의 첨부파일로 첨부하였습니다)




데모


AhFreeCa.zip

  • 먼저 SingleServer.exe를 실행합니다.  방화벽 대화창이 나오면 허용을 클릭하여 주시기 바랍니다.
  • Receiver.bat 파일을 실행하면 청취자(수강자) 모드로 실행 됩니다.  수강자는 아이디만 바꾸면 계속 추가하여 로그인 할 수가 있습니다.  사용자 암호 인증 및 중복 로그인 처리는 아직 적용되어 있지 않습니다.
  • Sender.bat 파일을 실행하면 BJ (강사) 모드로 실행됩니다.
    • On Air 버턴을 클릭하면 BJ의 캠과 음성이 청취자에게 전달 됩니다.  같은 PC에서 실행 할 경우 하울링 조심하세요.
    • 캠을 켜거나 On Air 중에는 해상도 변경이 안됩니다.
  • 서버를 외부에서 실행하고 싶을 때는 bat 파일의 내용을 아래처럼 변경하시면 됩니다.
    • start Client.exe -IsSender -Host=서버주소 -UserID=Ryu -UserPW=1234
    • start Client.exe -Host=서버주소 -UserID=Lee -UserPW=1234
    • UserPW는 아직 사용되지 않기 때문에 생략하셔도 됩니다.




저작자 표시 비영리 변경 금지
신고

Posted by 류종택


"아!  이 아프리카가 아니지 ㅡ.ㅡ"



BJ가 자신의 캠과 마이크를 이용해서 다수의 청취자에게 방송을 하는 시스템을 구축하는 교육 과정에서 사용하고 있는 소스입니다.  


http://www.himytv.com/ 에서 실제 서비스하는 코드를 강의용으로 정리한 것 입니다.  수 년 간 실무에서 겪은 노하우를 담고 있습니다.



실습 소스 및 라이브러리

  • 메인소스는 https://code.google.com/p/ahfreeca/ 사이트에서 받으시면 됩니다.
  • "RyuLib for Delphi"가 추가로 필요합니다.
    • https://code.google.com/p/ryulib4delphi/ 사이트에서 받으시면 됩니다.
    • RyuLib는 제 개인 저장소에서 관리하다가 안정화되면 구글 사이트에 가끔씩 덮어 쓰곤 합니다.  그러다 보니 구글 사이트에는 커밋 메시지가 전혀 없습니다.
    • RyuLib 소스를 받으시면 
      • /Ryu Open Tools API/SetReg.exe 파일을 실행시키세요.  폴더 위치가 변하면 SetReg.exe를 다시 실행시키셔야 합니다.
      • 같은 폴더의 RyuOpenToolsAPI.dpk 를 Install 하세요.
      • /XE7/RyuLib.dpk 를 Install 하세요.
      • /XE7 폴더를 Liblary Path에 추가하세요.


강의 내용

  • 초급 과정
    • 코어 모듈은 이미 작성 된 상태에서 UI 등의 마무리 작업을 하는 시나리오를 통해서 실무에 대한 감각을 익힌다.
  • 중급 과정
    • 코어 모듈을 직접 제작하는 과정

강의 일정
  • 3월은 교재를 보강하기위해 쉬고 4월부터 다시 강의를 시작합니다.
  • ryujt658@hanmail.net 으로 메일 주시면, 다음 강의가 있을 때 마다 알려드립니다.


저작자 표시 비영리 변경 금지
신고

Posted by 류종택




무료체험: http://www.himytv.com/asp/trial


기타 문의: http://www.himytv.com/main/customer



증권 방송 및 PC 화면을 위주로하는 방송 솔루션입니다.  

  • 고해상도 지원  (Full HD 이상도 실시간 강의가 가능합니다)
  • Active-X를 사용하지 않고, 모든 브로우져 지원
  • 안드로이드 아이폰 지원
  • 각종 API를 통해서 고객 사이트 및 카페 지원
  • 실시간 방송
  • True Color


저작자 표시 비영리 변경 금지
신고

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

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

이번에는 위와 상황에서 사용 할 수 있도록 만들어진 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는 개발자 스스로가 마음에 드는 것으로 변경해서 사용해도 상관없습니다.

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


저작자 표시 비영리 변경 금지
신고

Posted by 류종택
이번에 다뤄 볼 주제는 화상압축 라이브러리 구축 과정입니다.  화상압축에 관한 알고리즘 문제는 다른 포스트를 통해서 다루도록 하고, 여기서는 라이브러리 구축 과정 자체에만 포커스를 가져가겠습니다.

우선 이 포스트에서 다루는 라이브러리 및 소스는 아래 주소를 참고하시기 바랍니다.

Subversion : https://dev.naver.com/svn/megacast (password is 'anonsvn')

아래 [그림 1]은 압축용 클래스와 디코딩용 클래스에 대한 다이어그램 입니다.  상당히 허전해서 함께 올렸습니다.

[그림 1] 엔코더와 디코더의 Class Diagram


우선 엔코더 클래스인 TMegaCastEncoder의 멤버들을 살펴보겠습니다.
  • Method
    • procedure Clear;
    • procedure Execute(AData:pointer; AWidth,AHeigh:integer);
  • Event
    • property OnNewData : TDataEvent;

procedure Clear;
엔코더는 이전 화면과 비교하면서 압축을 진행합니다.  완전히 새로운 동영상을 압축하려면, Clear를 통해서 이전에 진행하였던 작업을 삭제합니다.

procedure Execute(AData:pointer; AWidth,AHeigh:integer);
화면(프레임)을 압축합니다.
  • AData에는 Bitmap raw 데이터의 주소를 입력하시면 됩니다.
    • Bitmap은 무조건 32비트 컬러만 사용해야 합니다.  특별한 이유가 있었던 것은 아니고, 32비트 컬러를 사용해도 충분한 압축이 이뤄지기 때문에 인터페이스를 간략하게 고정하였습니다.
  • AWidth,AHeigh에는 Bitmap의 가로 세로 Picxel 크기를 입력하시면 됩니다.
property OnNewData : TDataEvent;
Execute를 실행하고 압축이 이뤄지면서 발생하는 데이터에 대한 이벤트 입니다.
  • 하나의 프레임을 32x32 크기의 블럭으로 나눠서 압축하게 됩니다.  따라서, 최대 전체 화면의 크기를 32x32 블록으로 나눈 숫자만큼 이벤트가 발생합니다.
  • 화면의 데이터의 변화가 없는 블록은 생략됩니다.


이번에는 디코더 클래스 TMegaCastDecoder의 멤버를 살펴보겠습니다.
  • Method
    • procedure DataIn(AData:pointer; ASize:integer);
    • function GetBitmap(ABitmap:TBitmap):boolean;
procedure DataIn(AData:pointer; ASize:integer);
엔코더에서 압축된 블록의 데이터의 포인터(메모리 주소)와 데이터 크기를 입력하시면 압축을 해제하고, 내부의 Bitmap 데이터에 복원 시켜둡니다.

function GetBitmap(ABitmap:TBitmap):boolean;
압축 해제된 전체 화면을 보고 싶을 때, 사용합니다.  이미 한 번 받은 화면이 변화가 없다면 Result는 false가 되며, 화면은 가져오지 않습니다.


아래의 [소스 1]은 압축을 하는 과정의 일부입니다.  전체 소스는 위에서 설명한 Subversion 저장소의 Samples/Screen Thief 폴더에 있습니다.

[소스 1]
procedure TfmMain.on_Schedule(Sender: TObject; ATick: cardinal);
begin
  if FSocket.Connected then begin
    FScreenCapture.Capture;
    FMegaCastEncoder.Execute(
      FScreenCapture.Bitmap.ScanLine[FScreenCapture.Bitmap.Height-1],
      FScreenCapture.Bitmap.Width,
      FScreenCapture.Bitmap.Height
    );
  end;
end;
5: 라인에 보시면 엔코더의 Execute 메소드의 실행 과정이 보입니다.  화면을 캡쳐하는 클래스에서 Bitmap의 ScanLine 멤버를 이용하여, Bitmap raw 데이터의 주소를 얻어옵니다.  

이때, Bitmap의 가로 라인이 raw 데이터에는 반대로 저장되어 있기 때문에 , ScanLine[0]가 아닌 ScanLine[FScreenCapture.Bitmap.Height-1]으로 지정해야 하는 것을 유의하시기 바랍니다.

이후, Bitmap의 가로 세로 크기를 파라메터로 넘겨주면 압축은 완료됩니다.  (어때요, 참쉽죠? ㅡ.ㅡ)

아래의 [소스 2]는  압축이 된 후 발생하는 OnNewData 이벤트 처리 과정입니다.  파라메터로 넘어 온 AData와 ASize를 소켓 클래스의 Send 메소드에 바로 대입하여 전송하는 것이 전부입니다.

[소스 2]
procedure TfmMain.on_Received(Sender: TObject; AData: pointer; ASize: integer);
begin
  FMegaCastDecoder.DataIn(AData, ASize);
end;

procedure TfmMain.tmRefreshTimer(Sender: TObject);
begin
  tmRefresh.Enabled := false;
  try
    if FMegaCastDecoder.GetBitmap(Image.Picture.Bitmap) then Image.Repaint;
  finally
    tmRefresh.Enabled := true;
  end;
end;


아래의 [소스 3]은 수신된 데이터를 처리하는 과정입니다.

[소스 3]
procedure TfmMain.on_Received(Sender: TObject; AData: pointer; ASize: integer);
begin
  FMegaCastDecoder.DataIn(AData, ASize);
end;

procedure TfmMain.tmRefreshTimer(Sender: TObject);
begin
  tmRefresh.Enabled := false;
  try
    if FMegaCastDecoder.GetBitmap(Image.Picture.Bitmap) then Image.Repaint;
  finally
    tmRefresh.Enabled := true;
  end;
end;

3: 라인에서는 수신된 데이터를 DataIn 메소드를 통해서 압축을 해제하는 과정입니다.

10: 라인은 압축 해제된 화면을 Image 컴포넌트를 이용해서 화면에 표시하는 과정입니다.  변화가 없으면 GetBitmap 메소드의 결과 값이 false 이기 때문에 화면 갱신을 하지 않도록 하였습니다.  데이터가 들어 올 때마다 화면을 갱신하는 것은 비효율적이기 때문에 주기적으로 걸러서 표시하고 있습니다.

원격제어의 경우에는 속도가 중요하기 때문에 바로 바로 표시 할 수 있도록 하면서 블록을 그리는데, 전체가 갱신되지않도록 해야 합니다.  데이터가 입력될 때마다 화면을 갱신하면 CPU 사용률이 증가합니다.


위에서 설명한 데로, 압축 --> 전송 --> 해제 의 과정은 상당히 간단한 인터페이스로 구현 될 수 있습니다.  이제 실제 내부의 동작을 하나씩 추적해보도록 하겠습니다.

우선 [그림 2]는 MegaCastEncoder의 동적 분석입니다.

[그림 2] MegaCastEncoder의 동적 분석

  • MegaCastEncoder는 Slice, FrameFilter, BlockEncoder으로 구성되어 있습니다.
  • Slice는 하나의 프레임(화면)을 정해진 크기의 블록으로 자르는 기능을 제공합니다.
  • FrameFilter는 이전 화면과 달라진 블록만을 찾아내어 압축하도록 합니다.
  • BlockEncoder는 걸러진 블록을 실제 압축하는 기능을 제공합니다.
  • MegaCastEncoder.Execute()가 실행되면 Slice.Slice()를 통해서 화면을 블록으로 조각냅니다.
  • 이어서 Slice.GetBuffer()를 이용하여 잘라진 데이터 전체를 가져 옵니다.
    • 결과로 nil 또는 Data가 리턴 될 수 있음을 표시하였는데, 실제로 nil은 발생하지 않습니다.  코드 상에서는 그런 일이 벌어지면 무시하도록 되어 있습니다.
  • Data를 리턴 받으면 FrameFilter.IsClear()를 통해서 이전 데이터가 없는 빈상태인지 확인합니다.  즉, 처음으로 압축하는 것이라면, FrameSize(화면크기)를 OnNewData 이벤트를 통해서 처리합니다.  
    • 이벤트 이후의 프로세스는 생략되어 있습니다.  
    • 이것은 MegaCastEncoder를 사용하는 Actor 측에서 MegaCastEncoder.OnNewData 이벤트를 활용하여 자유롭게 처리하면 됩니다.  (소켓으로 보내거나, 파일로 저장하는 등)
  • 첫 번째 프레임이든 아니든 일단 FrameFilter.Filtering()을 통해서 변화된 블록만 걸러냅니다.
    • 첫 번째 프레임이면 모든 블록이 사용되게 됩니다.
  • 변화된 블록은 FrameFilter.OnFiltered 이벤트를 통해서 리턴 받습니다.  리턴받은 블록 데이터는 곧바로, BlockEncoder.Encode() 메소드를 통해서 실제 압축되고, MegaCastEncoder.OnNewData 이벤트를 발생 시킵니다.

아래의 [그림 3]은 MegaCastDecoder의 동적 분석 내용입니다.

[그림 3] MegaCastDecoder의 동적 분석

  • BlockDecoder와 FrameSticker는 MegaCastDecoder의 내부 객체 입니다.
  • BlockDecoder는 압축된 블록 데이터의 압축을 해제 합니다.
  • FrameSticker는 블록을 원래의 위치에 붙여서 Bitmap으로 재 구성해 줍니다.
  • 우선, Actor에 의해서 MegaCastDecoder.DataIn() 메소드가 호출되는 것으로 표현했습니다.
    • 소켓 등을 통해서 들어온 데이터를 DataIn() 메소드의 파라메터로 전달해 주시면 됩니다.
    • 데이터(패킷)의 종류가 다양하지만, 여기서는 간단하게 FrameSize와 BlockData 두 종류의 데이터에 대해서만 설명합니다.
  • FrameSize 데이터가 수신된 경우에는 FrameSticker.SetFrameSize() 메소드를 통해서 Bitmap의 크기를 지정합니다.
  • BlockData가 수신 된 경우에는 BlockEncoder.DataIn()을 통해서 압축을 해제한 뒤 FameSticker.DrawBitmap()으로 Bitmap 위에 그려주게 됩니다.
  • Actor가 화면의 데이터를 요구하는 경우에는 MegaCastDecoder.GetBitmap() 메소드를 호출하게 됩니다.  이후, FrameSticker에 있는 실제 Bitmap 데이터를 가져오면서 메소드 호출으 결과 값으로는 불린 데이터를 사용합니다.  ([소스 3]의 10: 라인 참고)

디미터 법칙에 의해서 MegaCastDecoder.FrameSticker.GetBitmap 대신 MegaCastDecoder.GetBitmap을 사용하도록 하였습니다.
그리고, FrameSticker는 외부에서 존재를 알 필요가 없기 때문에 private 속성으로 지정되어 있습니다.

[첨부 1] 본문에 사용된 Visio 파일

신고

Posted by 류종택
네트워크 프로그래밍을 쉽게 보고 프로젝트에서 고전을 한 뒤, 네트워크 프로그래밍에 대한 상당히 지루한 공부를 한 적이 있었습니다.  이렇게도 해보고 저렇게도 해보고, 여기 저기 물어도 봤지만, 뚜렷한 해답을 찾을 수는 없었습니다.  

책이나 인터넷 자료로는 근본적인 문제에 접근 할 수가 없었고, 경험자들의 가벼운 조언은 실전에 직접적인 도움이 되지 못했습니다.  결국, 최대한 알아낸 기본지식을 토대로 시행착오를 거치면서 스스로 깨우칠 수 밖에 없었습니다.  

이후, 가끔 "서버 좀 살려주세요!" 라는 요청을 받아서 처리해 주곤 했었는데, 막상 저에게 방법을 물어올 때마다 저도 상당히 피상적인 대답을 할 수 밖에 없음을 인지하게 되었습니다.  그래서, 이건 안되겠다 싶어 몇 번이나 정리하고 강의도 진행해봤지만, 무엇인가 상당히 아쉬움만 남곤 하였습니다.

결과적으로는, "네트워크 프로그래밍은 겉보기와 달리 상당한 기량이 필요한 작업이구나" 라는 것을 깨닫게 되었습니다.  사실 메신저와 같은 프로그램은 졸업작품으로 자주 등장하는 메뉴입니다.  그래서, 메신저 만들기나 이를 응용한 온라인 게임 서버 구축과 같은 것을 상당히 우습게 생각할 때가 많습니다.  이미 지난 에피소드에서 화상데이터를 전달하는 서버에의 안정성에 관한 언급을 잠시 했었는데, 안정적인 서버를 구축하는 것은 생각보다 험난한 여정입니다.

이러한 이유 때문에 이번 포스트는 쓸까 말까 고민을 했었지만, 그래도 혹시나 도움이 되실 분들을 위해서 정리하고자 합니다.

일단 서버의 안정성을 위협하는 가장 큰 두 가지 요소는 다음과 같습니다.
  • 코드상의 문제
  • 병목 현상
    • 네트워크 병목현상
    • 임계영역 병목현상
    • 프로세스 병목현상 (CPU 사용률의 문제)

코드상의 문제
사실 코드상의 문제는 너무 당연한 것이기 때문에 설명을 해야 하나 고민 스러웠지만, 실재 제가 필드에서 만난 문제있는 소스들의 상당한 비중은 차지하는 이슈였기 때문에 잠시 언급하고 넘어가려고 합니다.  

개인적으로 프로그래밍에 있어서 99.9%는 개발자가 코딩을 잘 못 했기 때문이라고 생각합니다.

코드 상의 문제는 다시 두 가지로 분류할 수 있는데, "기본적인 실수"와 "동기화 설계 능력 부족"이 있습니다.  

이중에서 "기본적인 실수"의 경우에는 특별히 설명할 내용이 없습니다.  좀 더 공부를 해서 기량을 키우는 수 밖에 없고, 사실 자신의 능력에 맞지 않는 일을 하는 것 자체가 문제라고 생각합니다.  

실무와 학습과정을 따로 생각하지 않고, 경험도 기량도 충분하지 않는데도 불구하고 해당 개발자를 실무에 투입하는 것 자체가 상당히 심각한 오류입니다.  하지만, 우리 현실이 항상 준비된 것 또는 충분히 준비할 수 있는 것만을 하고 살아 갈 수는 없기 때문에, 이러한 충고가 마음에 와닫지 않을 것 입니다.

동기화 설계 문제에 관해서는 최소한의 문서화를 갖춰야 한다고 생각합니다.  심각한 경우에는 아예 네트워크 설계에 대한 문서가 없는 경우도 있었으며, 프로토콜의 종류나 데이터 구조 정도에 관한 문서만 가진 경우, 동적 설계 문서가 있긴 하지만, 의미 없는 경우 등이 있었습니다.

이번 포스트에서는 동적 설계에 관한 이슈만 살펴보도록 하겠습니다.

[그림 1] 클래스와 객체에 대한 이해 부족

동적 설계에 대한 첫 번째 이슈는 제가 투입된 곳에서 가끔 경험했었던, 클래스와 객체에 대한 이해 부족입니다.  [그림 1]의 제목이 참으로 와닫지 않습니다.  일단 설명을 이어가도록 하겠습니다.

[그림 1]과 같은 상황에서는 사실 크게 문제 있다고 볼 수 없기 때문에, 문제를 좀더 확장해보겠습니다.

[그림 2] 같은 아이디로 이미 접속한 사용자를 점검해야 하는 경우

[그림2]는 [그림1]과 달리 아이디 암호만 검사하지 않고, 이후 같은 아이디로 이미 로그인 한 사용자가 있으면 "IDinUse"라는 메시지를 해당 접속자에게 알려서 접속을 끊으려는 의도로 설계되었습니다.  

문제는 [그림 2]에서 새로 로그인한 사용자도 이미 로그인 한 사용자도 동일하게 표현되었다는 것 입니다.  즉, 개념적으로 Client를 접근했을 때 클래스의 입장에서 설계를 한 것 입니다.

동적 설계는 언제나 클래스가 아닌 객체 중심적이어야 합니다.  클래스는 동작을 하는 대상이 아닙니다.

그렇다면 예상 접속자 수만큼의 객체를 표현해야 할까요?  설마요 ㅡ.ㅡ;  저의 경우에는 Client를 A, B 그리고 All로 구분합니다.  A는 현재 관심을 두고 설계하는 대상 객체를 의미하고, B는 특정한 다른 객체, All은 불특정 다수를 표현합니다.  네트워크 프로그래밍에 있어서 클라이언트는 이 세 가지를 통해서 대부분 표현 할 수 있습니다.

[그림 3] 객체를 대상으로 하는 동적 설계

[그림 3]은 A가 로그인을 시도했고, 아이디 암호 검사를 통과했을 때, 이미 같은 아이디로 접속했던 B에게 해당 아이디가 사용 중임을 알리는 과정을 표현했습니다.  그리고, A가 로그인 완료되면 불특정 다수인 전체 사용자에게 이를 알려서, 현재 접속 중인 사용자의 목록을 갱신하도록 할 예정입니다.  이때, A 자신은 이 메시지를 받을 필요가 없으므로, 화살표에 All-A 를 표시하여 자신은 A는 이 메시지를 안받을 것이라는 것을 표현했습니다.  이처럼, All의 경우에는 All-A로도 중복 사용하도록 했습니다.  All-A 라는 의미상 객체를 따로 만드는 것도 괜찮겠지만, 개인적으로는 [그림 3]과 같은 표현을 사용합니다.

물론 꼭 Job Flow를 사용해야 하는 것은 아니고, UML의 시퀀스 다이어 그램을 이용하는 곳이 많은 듯 합니다.

이렇게 표현하면 메시지의 흐름이 한 눈에 들어오는 것이 이득이라고 할 수는 있지만, 역시 문서만으로 동기화의 문제를 해결 할 수는 없습니다.  하지만, 문서를 통해서 기본적인 흐름을 정리 할 수 있다면, 동기화 설계는 상당히 수월해집니다.

문서로 동기화를 표현하는 것은 불가능에 가깝다고 봐야 합니다.  
표현을 세밀하게 한다면, 결국 문서 수준을 넘어서 코딩에 가까운 결과물이 될 것 입니다.

이제 객체의 메시지의 흐름에 따른 동기화의 관점보다, 서버의 메시지 처리 프로세스의 관점에서의 동기화를 고민해보겠습니다.  딱 알맞는 문서를 찾는데 시간이 걸려서 일단 비교적 최근에 그린 문서를 예로 들어보겠습니다.

개인적으로 문서는 "커뮤니케이션의 수단이다" 라는 데, 가장 큰 의미를 둡니다.
따라서, 문서를 정리하는 시간을 최대한 줄이고 있습니다.
칠판이나 종이를 통해서 분석 및 설계를 진행하는 경우가 더 많고,
보관이 필요 할 때는 디지털 카메라로 촬영해서 이미지를 보관하고 있습니다.

[그림 4] 온라인 화상 강의 서버 설계 문서 중 일부

[그림 4]의 실제 의미를 이해하실 필요는 없습니다.  추후 시간이 된다면 좀 더 설명이 쉽고 주제에 알맞는 이미지로 바꿔서 설명을 할 생각입니다.  즉, "별로 좋은 예는 못 된다"라는 뜻 입니다.  그림을 이해하려고 너무 에너지를 낭비하지 마시기 바랍니다.

[그림 4]에서는 서버(MegaCastServer)가 두 개의 하위 객체(MegaCastRecorder, SocketSender)로 구성되어 있음을 표현하고 있습니다.  그리고, 클라이언트에서 요청된 메시지를 내부에서 어떻게 처리하는 가에 대해서 표현하고 있습니다.  이 문서를 통해서 우리는 서버의 프로세스의 흐름을 한 눈에 파악 할 수 있으며, 이를 통해서 논리적인 프로세스 흐름은 물론 동기화에 대한 고민을 좀 더 효율적으로 해결 할 수가 있게 됩니다.

시스템이 복잡한 경우 문서 한 장에 모든 흐름을 담기가 어려울 경우도 있습니다.  이런 경우에는 시스템을 최대한 레이어로 설계하여, 같은 레이어 안에서만 동기화 문제를 다룰 수 있도록 분리 합니다.

개인적으로 동기화에 대한 기호를 문서에 직접 표현하지는 않습니다.
제가 공부가 부족해서 그런 탓일 수 있지만,
문서에 동기화를 표현하는 것이 큰 도움이 되는 경우가 없었습니다.

요점은 동적 흐름을 제대로 파악하면 동기화 이슈에 대해서 좀 더 쉽게 접근 할 수 있다는 것 입니다.


네트워크 병목현상
서버에서 클라이언트로 전송되어야 할 데이터의 양이 많지만, 클라이언트의 네트워크 상태(속도)가 나빠서 정체현상이 발생하면 당연히 서버에 심각한 문제가 생깁니다.  심지어는 일부 또는 하나의 접속자의 상태가 전체 접속자에게 영향을 줄 수가 있습니다.  또한, 이러한 영향은 피드백 되어 임계점에서부터 사용자 수가   조금만 늘어나도 서버의 상황은 극적으로 나빠질 수가 있습니다.  이러한 네트워크 병목현상은 쓰레드를 이용하면 어느 정도 쉽게 해결할 수가 있습니다.  

델파이 개발자 중에서는 기본으로 내장되어 있는 Indy Socket을 사용하는 경우가 많기 때문에, 위에서 거론한 문제에서 비교적 자유롭다고 볼 수 있습니다.  Indy Socket의 경우에는 클라이언트에서 수신되는 메시지 마다 서로 다른 쓰레드가 동작하기 때문에, 네트워크 병목을 자연스럽게 피해갈 수 있는 편입니다.

하지만, 이때, 조심해야 할 것은, 아래의 [소스 1]과 같이, Indy Socket 라이브러리와 함께 배포된 채팅 예제에서처럼 전체 접속자 리스트를 락을 걸고 데이터를 전송하는 경우가 있어서는 안 됩니다.  이렇게 되면, 쓰레드로 처리하는 이점을 전혀 살릴 수가 없습니다.  

[소스 1] 문제가 발생 할 수 있는 메시지 처리 방식
[Code delphi]
procedure TForm1.IdTCPServer1Execute(AThread: TIdPeerThread);
var
  List : TList;
  Loop: Integer;
begin
  List := IdTCPServer1.Threads.LockList;
  try
    for Loop := 0 to List.Count-1 do
      TIdPeerThread(List[Loop]).Connection.WriteBuffer(보낼_데이터, 데이터_크기, true);
  finally
    IdTCPServer1.Threads.UnlockList;
  end;
end;[/Code]

이러한 경우 간단한 해결 방법은 서버가 메시지를 클라이언트에게 직접 푸시(Push)하지 않고, 클라이언트가 자신에게 온 메시지가 있는 지 확인하여 가져가는 클라이언트 풀링(Pulling) 방식을 사용하는 것 입니다.
  • 각 커넥션 정보마다 버퍼를 둔다.
  • 데이터를 전송하는 대신 버퍼에 쌓아둔다.
  • 클라이언트에서 버퍼를 확인하는 메시지를 보낸다.
  • 자신의 버퍼에 데이터가 있으면 클라이언트에게 보낸다.

이때에도, 네트워크 전송속도가 상당히 낮은 클라이언트 접속에 대해서는 무한정 버퍼링을 할 수가 없기 때문에, 심각한 경우에는 접속을 해제해야 합니다.

클라이언트에서 메시지를 풀링하는 경우에는 매번 자신의 메시지를 확인해야 하기 때문에 생기는 문제가 발생 할 수도 있습니다.  이때에는 서버에서 푸시를 하되 유연한 방식을 고려해야 합니다.  저는 이런 경우를 스마트 푸시라고 부르고 있습니다.  클라이언트의 상황을 예측해서 거기에 알맞는 데이터를 전송하는 것으로 네트워크 병목을 피해가는 방식입니다.

또한, 경우에 따라서는 모든 커넥션 마다 전송 쓰레드를 개별로 두고 메시지를 푸시를 하기도 합니다.  쓰레드 개수가 문제가 될 경우에는 쓰레드 풀링(Pooling)을 이용 합니다.

"어떠한 방식이 무조건 좋다"라고 할 수는 없기 때문에, 상황에 맞춰서 알맞는 방식을 선택해야 합니다.  

추후 오픈 프로젝트를 통해서 소켓 프로그래밍에 관련된 전체 소스를 올릴 예정입니다.
(http://ryujt.textcube.com/64)


임계영역 병목현상
쓰레드와 쓰레드 사이에서 공유되는 자원들을 관리하기 위해서는 임계영역을 사용하게 됩니다.  그러나, 임계영역을 사용하는 동안 쓰레드들이 대기 상태에 빠지면서 병목을 일으킬 수 있습니다.  더구나, 접속자 수가 많은 상태에서 발생하는 빈번한 메시지를 처리하다 보면 문제가 심각해질 수 있습니다.

가장 심각한 경우는 Dead-Lock을 일으키는 경우입니다.  이것은 논리적인 오류라고 볼 수 있기 때문에 특별한 해법이 존재하는 것이 아니고, 알고리즘을 개선하는 수 밖에 없습니다.

또한, 당연하지만, 락을 걸고 락을 푸는 사이의 프로세스를 최대한 간단하고 효율적으로 작성해야 합니다.  간혹 락을 걸고 임계영역 안에서 동작하는 코드가 쓸 때 없이 복잡하거나 비효율적인 경우를 보게 됩니다.  

[소스 2]
[Code delphi]
procedure TClassXXX.do_SomethingA;
var
  Loop: Integer;
begin
  CriticalSection.Enter;
  try
    for Loop := 0 to FList.Count-1 do do_Process(FList[Loop]);
    FList.Clear;
  finally
    CriticalSection.Leave;
  end;
end;

function TClassXXX.do_SomethingB:boolean;
var
  Loop: Integer;
  Data : pointer;
begin
  Result := false;

  CriticalSection.Enter;
  try
    if FList.Count = 0 then Exit;

    Data := FList[0];
    FList.Delete(0);

    Result := true;
  finally
    CriticalSection.Leave;
  end;

  do_Process(Data);
end;

procedure TClassXXX.on_NeedSomething;
begin
  while do_Something do
    ;
end;[/Code]

[소스 2]에서 FList에 데이터를 Thread-Safe 하게 접근하여 데이터를 가공하는 프로세스를 실행하는 두 가지 방식을 보여 주고 있습니다.  이때, 비동기 상황에서 보호되어야 하는 것이 프로세스를 포함하는 것이 아니고 데이터 자체라면, 굳이 프로세스를 임계영역 안에 두어야 하는 가를 고려해 볼 수 있는 상황을 연출한 것 입니다.

또 하나의 해결책이자 적극적인 방법은 최대한 임계영역 사용을 자제하는 것 입니다. 이에 대해서는 Lock-Free 알고리즘을 참고하시기 바랍니다.  하지만, 임계영역 없이 처리하기 어려운 상황도 생길 수 있으며, Lock-Free를 적용하고도 생각보다 효과가 미비할 수도 있습니다.

http://ryujt.textcube.com/68 에서도 특수한 상황에서 간단하게 구현 할 수 있는 Lock-Free 알고리즘을 소개하고 있습니다.  하지만, 이것은 상당히 제한된 상황에서만 사용 할 수 있는 방법일 뿐입니다.

델파이 개발자라면, 읽기와 쓰기 등으로 구별 할 수 있는 경우에
TMultiReadExclusiveWriteSynchronizer를 사용하는 것도 고려해 볼만 합니다.
자바의 경우에는 ReentrantReadWriteLock를 참고하시기 바랍니다.


프로세스 병목현상
서버의 CPU를 바쁘게 하면, 당연히 서버가 괴로워합니다.  그리고, 프로세스 병목의 경우에는 단편적인 해법이 있을 수 없습니다.  효율적인 알고리즘을 발견 할 수 있도록 최선을 다하는 수 밖에 없습니다.

우선  CPU를 괴롭히는 요인들은 아래와 같습니다.
  • 암호화 처리 (쓸모 있는 수준의 암호화는 CPU를 충분히 괴롭힌다)
    • 서버에 집중된 비즈니스 로직
      • 클라이언트로 로직을 옮겨간다.  이때, 보안 문제가 발생할 수가 있다.
  • 잘못된 패킷 처리
    • 패킷 = [데이터] + [패킷 구분자]
      • CPU를 생각보다 많이 괴롭힌다.
    • 패킷 = [헤더] + [데이터]
      • 헤더에는 데이터의 크기 등을 기록한다.
  • 기타

암호화 처리의 경우에는 내용이 공개되는 것 자체가 중요하지 않은 패킷에 대해서는 패킷변조와 패킷에 대한 삽입/삭제를 막는 간단한 방법이 있습니다.

예를 들어 아래와 같은 구조를 생각해보도록 하겠습니다.  

패킷 = [Chcek Sum] + [Serial Key] + [Size] + [Data]

[그림 5] 가벼운 암호화

우선 Check Sum을 통해서 데이터가 변조되는 것을 감지 할 수 있는 1차 방어를 제공할 수 있습니다.

그리고, Serial Key는 패킷에 대한 삽입/삭제를 막기 위해 사용됩니다.  [그림 5]에서는 새로운 클라이언트가 서버로 접속하게 되면, 서버는 랜덤으로 Serial Key를 생성하고 이를 클라이언트에게 보내 줍니다.  이후, 클라이언트는 메시지를 서버로 전송 할 때마다, 방금 전에 받은 Serial Key를 이미 약속한 함수 f(x)에 넣어 결과값을 Serial Key로 지정하여 보냅니다.  서버는 가장 최근에 받은 Serial Key 또는 최초 접속 때 클라이언트로 보낸 Serial Key를 f(x)에 넣어 서로 비교합니다.  서로 갖지 않으면 잘못된 패킷이며, 클라이언트 접속을 끊어 버립니다.  서로 갖다면 이제 서버는 방금 받은 2468을 기억하고 있다가, 클라이언트에서 새로 보내지는 Serial Key와 같은 방식으로 비교를 하게 됩니다.

[그림 5]는 f(x) = 2x 에 해당하는 SerialKey 인코딩 과정을 설명하고 있습니다.



신고

Posted by 류종택
오래 전, 1:n 으로 동영상을 전송해야하는 상황을 처음으로 접하게 된 적이 있었습니다.  1:n 전송의 특성 상, 모든 사용자가 같은 품질(속도)의 네트워크 상황이 아닐 수 있다는 가정이 필요 했습니다.  따라서, 동영상 압축 품질을 개별 사용자에게 맞춰서 엔코딩을 할 수 없는 한, 다른 해결책이 필요해졌습니다.

스틸 이미지의 경우에는 아주 쉽게 해결할 수 있는 방법이 있습니다.  네트워크 속도에 맞춰서 단위 시간당 보내지는 프레임(이미지)의 수를 달리 하는 것 입니다.

[그림 1] 스틸 이미지로 전송되는 경우

[그림 1]은 동영상을 각 프레임마다 스틸 이미지로 보내는 경우를 표현한 것 입니다.  이때, 중간에 있는 이미지를 전송하지 않고 생략하더라도 마지막 결과는 같아집니다.

하지만, 동영상의 경우에는 [그림 2]와 같이, 기본적으로 변하지 않는 부분은 전송하지 않고 생략해서 크기를 줄이게 됩니다.  각 변화된 데이터를 합성하게 되면, [그림 1]의 마지막 이미지와 동일한 영상을 얻을 수가 있으며, 상대적으로 작은 양의 데이터만을 전송할 수가 있게 됩니다.

[그림 2] 동영상 압축에 의한 데이터 전송의 예

하지만, 중간 프레임을 생략하게 되면 [그림 3]과 같이 전혀 다른 결과를 얻게 됩니다.  소위, "화면이 깨졌다" 라고 합니다.

 [그림 3] 동영상 전송 중 중간 프레임을 생략한 결과

따라서, 동영상의 경우에는 사용자의 네트워크 상황에 맞춰서 프레임을 떨어트리는 것을 구현하기 위해서는 다른 방법을 사용해야 합니다.  [그림 4]는 이러한 과정을 추상적으로 설명하는 자료입니다.

[그림 4] 사용자의 네트워크 상태에 따라서 프레임 수를 조절하기 위한 동적 분석

우선 [그림 4]의 내용을 설명하자면 아래와 같습니다.
  • Sender가 동영상 데이터를 Server에 전송하고, Server에서는 Received 이벤트가 발생합니다.
  • Server는 개별 접속자들 마다 따로 생성된 객체 Connection의 버퍼에 이를 저장합니다.  [그림 4]에서는 간단하게 하나만 그렸습니다.
  • 데이터는 좌표와 함께 전달되며, 해당 좌표에 이전에 받은 데이터가 존재하면 이를 삭제하고 새로운 데이터를 버퍼에 둡니다.  이전 좌표의 버퍼가 비워져 있으면 그냥 저장합니다.
  • 데이터를 전송하고자 하는 순간을 NeedToSend 이벤트로 표현했습니다.
  • 이제 버퍼에서 데이터를 찾아서 클라이언트에게 보내 줍니다.  만약 버퍼에 데이터가 없으면 false가 리턴되어 아무 것도 하지 않습니다.
처음에는 버퍼의 형태를 2차원 배열을 사용하였습니다.  내부적으로는 1차원 배열이지만, 2차원처럼 사용 할 수 있는 인터페이스를 사용했습니다.  테스트를 해보니 상당히 잘돌아갑니다.  장시간 진행된 스트레스 테스트도 무사히 통과했습니다.  

그러나, 비극은 언제나처럼 제품이 고객에게 넘겨지고 나서 발생하였습니다.  서버의 CPU 사용률이 높아지면서 안정성에 문제가 생긴 겁니다.  이슈를 보고 받고 나니 바로 문제점이 보였습니다. 데이터가 만약 하나도 없는 경우라면 최악의 상태가 되는데, 2차원 배열을 모두 뒤져야지만, 데이터가 없다는 것을 리턴하고 있었습니다.  아래는 그 당시의 소스 상태를 간단하게 표현한 것 입니다.  실행되는 코드가 아닌 의사코드 수준이라고 생각하시면 됩니다.

[Code delphi]
function Get(var Data:pointer; Size:integer):boolean;
var
  LoopX, LoopY : integer;
begin
  Result := false;
  for LoopY := 0 to Height-1 do
  for LoopX := 0 to Width-1 do begin
    if Buffer[LoopX, LoopY].Data <> nil then begin
      Result := true;
      Data := Buffer[LoopX, LoopY].Data;
      Buffer[LoopX, LoopY].Data := nil;
      Size := Buffer[LoopX, LoopY].Size;
      Break;
    end;
  end;
end;[/Code]

서비스가 급하게 진행되고 있어서 우선은 편법을 동원해서 불을 끄고 해결책을 찾기 시작했습니다.

편법이 혹시 궁굼하신 분을 위해서 간략하게 설명하자면, 버퍼 속에 존재하는 데이터의 갯수를 관리하고, 방금 전 호출에서 데이터를 검색한 곳의 인덱스를 기억하도록 하는 것 입니다.  데이터 갯수가 0으로 되면 바로 false를 리턴하고, 그 이외에는 방금 데이터를 검색한 인덱스 이후부터 뒤지는 것 입니다.  이것으로 상황은 조금 나아졌습니다.

이후 제가 찾아낸 방법은 버퍼 인덱스를 이중으로 관리하는 것이었습니다.  2차원 좌표형태로 버퍼를 관리하는 것은 그대로 두고, 추가로 List로 관리하는 인덱스를 두는 것이었습니다.  List는 중복되는 것을 개의치 않고, 데이터의 좌표를 계속 저장합니다.  의사코드로 아래와 같이 표현해봤습니다.  실제 코드는 자연스러운 동작과 임계영역 처리 등에 의해서 복잡해졌습니다.

[Code delphi]
function Get(var Data:pointer; Size:integer):boolean;
var
  Loop : integer;
begin
  Result := false;

  while (List.Count > 0) and (Sent < 1회 전송한계) do begin
    Index := List[0];
    List.Delete(0);

    // List에 있지만, Buffer에 없다는 것은
    // 데이터가 전송 이전에 중복되어 덮어 써졌다는 것이다.
    if Index.Data <> Buffer[Index.X, Index.Y].Data then Continue;

    Result := true;
    Data := Buffer[Index.X, Index.Y].Data;
    Buffer[Index.X, Index.Y].Data := nil;
    Size := Buffer[Index.X, Index.Y].Size;
    Break;
  end;
end;[/Code]

안정성이 상당히 좋아졌습니다.  그러나, 무엇인가 수정하면 새로운 문제가 발생하기 마련 인가 봅니다.  위에서 설명한 대로 동영상 압축의 기본은 변화되지 않는 곳은 생략하는 것 입니다.  따라서, 첨부터 접속되어 있던 사용자가 아닌 경우에는 전체 영상을 담은 이미지를 한 번은 전송 받아야 합니다.  이를 위해서 서버에서는 ScreenShot 이라는 객체가 항상 최신 버전의 이미지를 저장하고 있다가, 새로운 사용자가 접속하면 해당 사용자에게만 전체 영상을 보내주도록 되어 있었습니다.

서비스가 만족할만한 수준이었기는 하지만, 프로파일링 결과, 해당 부분에서 많은 성능 저하가 일어나고 있었습니다.  사용자가 많아지고 빈번하게 들락이면서 임계영역으로 인한 병목현상이 발생하게 된 것 입니다.  역시나 서비스가 급하게 돌아가고 있는 지라, 편법을 동원해서 불을 끄고 시간을 들여서 수정하기로 결정을 내렸습니다.

사실 운영 중인 시스템을 수정한다는 것은, 달리는 자동차에 매달려서 수리하는 것과 같습니다.

편법은 Sender가 주기적으로 KeyFrame을 보내고, 처음 접속한 사용자 이외에는 이것을 무시하도록 하는 것 입니다.  KeyFrame을 받은 적이 없는 사용자는 KeyFrame이 들어오기 전까지는 아무런 화면을 전송하지 않도록 하였습니다.  간단하고 효율적이긴 했지만, 마음에 들지 않았습니다.

최종적으로 선택한 해결책은 Lock-Free 알고리즘을 적용하는 것이었습니다.  아래 코드는 Lock-Free 알고리즘을 응용한 것 입니다.  데이터를 Push 하는 쪽은 스레드 하나로 정해져 있고, 받아가는 쪽은 복수 개의 스레드일 경우 사용할 수 있습니다.  델파이에서 포인터를 덮어쓰는 것은 언제나 Thread-safe 한 것을 이용한 것 입니다.

델파이만 포인터에 주소를 입력하는 과정이 Thread-safe 한 것은 아니고, 현재 대부분의 상황에서 32bit 데이터의 읽고 쓰기는 Atomic Operation이 보장 됩니다.

그리고, 덮어 써진 쪽의 경우 바로 지우지 않고, 최근 덮어 쓰여진 데이터가 다시 덮어 쓰여 질 때 지우도록 하여, 혹시나 덮어 써지기 전에 데이터를 가져간 쪽에서 Access Violation 에러가 나는 것을 방지하였습니다.  데이터를 가져가서 처리하는 속도보다 데이터를 갱신하는(덮어쓰는) 속도가 더욱 빠르다면, 삭제를 지연하는 횟수를 늘려야 하고 이로 인해서 효율성이 떨어지게 될 것이니 유의해야 합니다.

쉽게 설명하면, 삭제하는 동작을 1회 지연 시키기 위해 임시 저장소를 두어 삭제할 데이터를 그곳에 두고, 다음 프로세스에서 지우도록 하였다는 뜻 입니다.

[Code delphi]
procedure TScreenShot.Push(X,Y:integer; Data:pointer...);
begin
  // 임시 저장소에서 삭제하기를 한 번 대기한 놈을 가져온다.
  OldBuffer := 임시대기[X, Y];

  // 임시 저장소에는 지난 번 데이터를 저장한다.
  임시대기[X, Y] := Buffer[X, Y];

  // 데이터 저장소에 방금 들어온 데이터를 저장한다.
  Buffer[X, Y] := Data;
 
  if OldBuffer <> nil then FreeMem(OldBuffer);  
end;[/Code]

[첨부 1] 본문에 사용된 문서
신고

Posted by 류종택
2000년 즈음, 온라인 강의 시스템을 개발하면서 진행하게 된 프로젝트 중 생긴 일입니다.  주 목적은 온라인 바둑 강좌 시스템을 구축하는 것이었습니다.  일반적인 화상회의 시스템 구조에서 강좌용 바둑판을 강사와 수강자가 공유하는 것을 추가하는 형식으로 요구사항이 마무리 되었습니다.

제가 강의했던 개발 방법론 주제에서 자주 거론 하듯이, 저는 주로 Top-Down 방식의 설계를 하는 편입니다.  하지만, 분석과 설계 과정에 중에 필요성이 발견되는 컴포넌트 모듈 중, 반드시 필요할 것으로 생각되는 것들은 분석과정 중에도 테스트 및 프로토타입을 작성하는 편입니다.  Top-Down과 Bottom-Up을 조금 섞어 쓴다고 할 수 있는 정도입니다.

프로젝트 중에 사용되었던 기술(또는 콤포넌트)의 결함을 나중에 발견하게 되어 심각한 피해를 입은 경험이 있은 후로는, 개발 초기에 사용하게 될 기술을 미리 검증하는 단계를 반드시 거치고 있습니다.  그러다 보니, 분석 과정에 있는데도, 구현 모듈에 대한 프로토타입 개발 및 테스트를 병행하고 있습니다.

우선 화상 전달은 각 프레임을 JPEG으로 압축해서 보내기로 했습니다.  음성은 GSM 코덱을 사용하게 됐는데, 이때, 음성 압축은 아는 후배에게 부탁해서 외주로 제작하면서 후배가 어디선가 주어 온 소스 때문이었습니다.

간단하게 캠에서 영상을 JPEG으로 압축하고, 동시에 음원을 GSM으로 압축하여 소켓을 통해서 Receiver 모듈로 전송하고, Receiver 모듈은 전송 받은 데이터를 압축 해제하고 재생하는 간단한 테스트 모듈이었습니다.

아주 쉽게 테스트는 통과 되었고, 만족할 만한 결과라고 생각했습니다.  이후, 안정성 테스트를 위해서 강사 하나에 다수의 수강생 모듈을 접속하게 하여 하루 종일 실행시켜봤습니다.  이때, 화상은 잘 전달되고 있으나, 음성 전달이 상당히 지연되는 것을 발견하게 되었습니다.

버그가 발생하는 시나리오를 유추하기 위해서 갖 가지 상상을 하다가, 화상과 음성이 다른 결과를 가진 것을 실마리 삼아서, 데이터가 네트워크를 타고 전달되는 속도가 일정하지 않을 때, 음성이 병목을 일으킬 가능성이 있다는 것을 찾아냈습니다.

[그림 1] 음성 데이터의 발생

즉, [그림 1]과 같이 음성 데이터가 아주 정확한 시간에 발생한다고 하더라도, 네트워크를 통해 전달되면서 [그림 2]와 같은 현상을 겪을 가능성이 있다는 것 입니다.  이때, [음성 1]이 전달 되어 재생이 끝났지만, 다음 데이터를 기다리는 동안 지연이 발생합니다.  [음성 2]가 전달되어 다시 재생에 들어가고, [음성 3]은 다소 일찍 들어왔지만, [음성 2]의 재생이 완료되기 전까지는 대기해야 합니다.  바로 재생한다면 음성 데이터 두 개가 섞여서 이상한 소리를 내게 될 것 입니다.

[그림 2] 음성 데이터 전달의 지연

결과적으로 네트워크 전달이 지연된 만큼 음성 재생 지연은 늘어나지만, 그로 인해서 나중에 일찍 들어온 데이터는 지연을 줄이지 못하고 대기 상태에 빠지게 된다는 것 입니다.  즉, 지연은 늘어나는 쪽으로만 진행된다는 것 입니다.

지연 현상은 네트워크를 사용하지 않고 어플리케이션 내에서 직접 데이터를 전달해도 발생합니다.  그것은 사운드 카드에서 음원을 채취하고 압축하고 해제하고 재생하는 과정 중에서도 지연이 조금씩 발생되기 때문입니다.

이것을 해결하기 위해서는 지연된 만큼 데이터를 삭제하는 수 밖에 없다고 생각했습니다.  처음에는 가장 오래된 데이터를 우선으로 삭제하여 허용된 지연 한계를 넘어선 데이터를 모두 삭제하는 방식을 사용했습니다.  그러다가, 음질을 최대한 보존하기 위해서 가장 낮은 볼륨 데이터를 삭제하는 방식으로 발전시켰고, 그 이후에는 좀 더 세련된 방식을 찾아내는데 성공했습니다.  세련된 방식이란 PSOLA를 이용해서 지연된 만큼 음성 재생 속도를 잠시 빠르게 하는 것 입니다.

이쯤 되자, 앞으로도 지연을 제거하는 방식을 계속 변경해야 할 가능성이 보이기 시작했습니다.  어떻게 해야 할까요?  그렇죠!  바로 캡슐화가 떠오르게 마련입니다.  변화 요소를 분리하여 격리 시키기 위해서 설계를 수정하기 시작했습니다.

변화에 대응하는 방법 중에는 캡슐화 이외에 다형성을 활용하는 방법도 있습니다.  저는 개인적으로 다형석도 캡슐화의 한 부류라고 생각하고 있습니다.  일단 "변화의 가능성이 보이면 격리해야 한다"를 기본으로 해서 위임 등을 통해서 해당 변화를 감추거나, 다형성을 이용하여 같은 메시지(요청)이지만 상황에 따라 다른 결과(행동)을 결정하는 것을 미룰 수 있도록 합니다.

[그림 3] 데이터 수신 이후 재생까지의 동적 분석

[그림 3]처럼 재생 지연을 제거하는 것을 Buffer 내부에 감추고 외부 객체는 데이터 지연이라는 문제를 의식하지 않고 인터페이스를 구축하도록 하였습니다.  이후 Buffer 내부에서 어떤 알고리즘을 사용한다고 해도, 그 변화가 Socket과 VoicePlayer에게는 아무런 영향을 주지 않게 됩니다.

[그림 3]의 간략한 설명은 아래와 같습니다.
  • Socket 객체에서 새로운 데이터를 받고 Received라는 이벤트를 발생시킵니다.
  • Socket은 Buffer에게 Add 메시지를 전달하여 데이터를 저장하게 합니다.  (Buffer.Add() 메소드 호출)
  • Buffer는 내부적으로 저장된 데이터가 어떤 수준 이상으로 쌓이게 되면, 이것을 지연으로 보고 데이터를 임계 수준 이하로 삭제하여 보관합니다.  이 과정은 캡슐화로 감춰져 있습니다.
  • VoicePlayer는 다음 데이터를 재생해야하는 순간 NeedData 이벤트를 발생합니다.  이후, Buffer에게 데이터를 요청합니다.  이때, 데이터가 없으면 false를 리턴 받고 아무런 동작도 하지 않습니다.  true를 리턴 받으면 데이터가 존재한다는 의미이며, 받아온 데이터를 재생합니다.
[그림 3]은 이해를 돕기 위해 간락하게 작성된 다이어그램 입니다.  지연 효과를 없애기 위해 볼륨 정보가 필요했기 때문에, 원래 VoicePlayer 내부에 있었던 코덱 디코더를 밖으로 꺼내야 했습니다.

설계는 [그림 4]와 [그림 5]와 같이 정리가 되었습니다.

[그림 4] Job Flow

[그림 5] Class Diagram

위의 설계는 TSocket이 TVoicePlayer의 내부를 알고 있다는 것이 문제입니다.  따라서, 캡슐화를 더욱 진행시켜보도록 하겠습니다.

필자가 실제 작업할 때는 하나의 과정으로 진행하지만, 이해를 돕기 위해서 단계를 거치면서 다듬어 가고 있습니다.

TVoicePlayer의 멤버인 Decode, Buffer, Player를 Private로 옮기고, TSocket이 TVoicePlayer에 의존적인 동작을 모두 TVoicePlayer의 Public 메소드로 구현하도록 하겠습니다.  그 결과는 아래와 같으며, 이것이 현재까지 제가 사용하고 있는 음성 통신 모듈의 기본적인 구조입니다.

[그림 6] 최종 결정된 JobFlow

[그림 7] 최종 결정된 Class Diagram


[첨부 1] 본문에 사용된 Job Flow (Visio)


신고

Posted by 류종택


티스토리 툴바