상세 컨텐츠

본문 제목

인터페이스를 통한 소프트웨어 설계

소프트웨어 공학

by ryujt 2014. 9. 15. 11:54

본문

스포츠 경기에 세트 플레이라는 것이 있습니다.  미리 약속한 방식으로 공격을 진행하는 것입니다.  하지만, 상대가 어떻게 나올 지 확신 할 수가 없는데, 약속 된 방식만으로 공격이 성공 할 수 있을까요?

  • 언제나 변수가 생긴다.  계획대로 되는 법이 없다.
  • 작전에만 집착하면 변수가 발생 했을 때, 적절한 대응을 할 수가 없다.
  • 큰 줄기만을 계획하고 세밀한 부분은 필드에서 각 선수들의 역량에 맡긴다.


이제 객체들의 세트 플레이를 생각해 보겠습니다.

  • 요구사항은 언제나 변한다.  설계대로 진행 되는 법이 없다.
  • 구체적이고 절대적인 구현에 집착하면 변화가 발생 했을 때 대응 할 수가 없다.
  • 설계는 인터페이스 단계에서 멈추고, 변화에 민감한 알고리즘은 객체 안에서 수용한다.


승리라는 하나의 목표를 위해서 여러 명의 선수들이 경기에 나서듯이, 여러 개의 객체들이 하나의 목표를 위해 팀워크를 이룬다면 효율적인 개발을 할 수가 있습니다.


"복잡한 문제를 객체들로 나눠서 쉽게 해결 할 수 있는 상태를 만든다."  


좋은 아이디어입니다. 그런데 어떻게 해야 하는 걸까요?

  • 해결해야 하는 문제를 설명한다.
  • 문제를 객체 단위로 조각을 낸다.
    • 이 부분은 완벽한 해법은 없습니다.
    • 저는 직감에 의존하며, 보조 기구로써 기능 분석을 사용합니다.
  • 객체가 서로 협력하기 위한 최소한의 약속을 찾아낸다.
    • 하나의 목표를 가진 문제를 여러 개의 객체로 나눠서 해결하려면, 반드시 나누어진 객체들 사이에 협력이 필요합니다.
  • 만들어진 약속을 토대로 동작했을 때 발생 할 수 있는 시나리오들을 찾는다.
  • 설계(약속)를 수정하고, 테스트를 작성한다.


이제 간단한 예제를 통해서 지금까지 설명한 내용을 자세하게 살펴보도록 하겠습니다.  하마티(http://www.himytv.com/) 개발을 통해서 실제 있었던 개발 상황을 설명하기 쉽도록 간략하게 다듬어서 진행하겠습니다.


문제: PC 화면을 실시간으로 동영상 압축하는 라이브러리 구축  (DesktopEncoder)

  • 화면의 변화를 실시간으로 동영상 압축한다.
  • 네트워크 전송 속도에 맞춰서 필요한만큼의 비트레이트를 유지한다.
  • 압축이 필요 할 때 시작하고, 필요 없을 때 멈출 수 있어야 한다.
  • 압축하는 시간이 외부에 영향을 주지 않도록 별도의 스레드를 이용해서 비동기로 진행한다.

해결해야 할 문제가 비교적 간단합니다.  이제 기능 목록을 작성하여 어떤 객체들로 나눠야 할 지를 고민해보겠습니다.


[그림 1] 기능 목록 작성


목록들을 보니, 유사한 기능들이 있습니다.  이넘들을 묶어 보겠습니다.



[그림 2] 유사한 기능끼리 묶어 보기


이렇게 기능 분석을 통해서 해결해야 할 문제를 3 개의 객체로 나눠서 작업을 하도록 하겠습니다.  그리고, 이들 객체를 하나로 묶어 줄 DesktopEncoder 라는 객체와 이것을 사용하는 외부의 객체를 Client 라고  하겠습니다.


이제는 이들 사이의 최소한의 약속을 찾아 낼 시간입니다.


[그림 3] Job Flow


일단 [그림 3]과 같이 외부의 객체 Client와 DesktopEncoder 사이의 약속을 찾아냈습니다.  제가 만든 Job Flow 라는 문서 형식을 사용하고 있습니다.  플로우 차트를 확장한 형태입니다.  (http://ryulib.tistory.com/164)

  • Start: 동영상 압축을 시작한다.
  • Stop: 동양상 압축을 멈춘다.
  • Get: 압축된 결과 데이터를 가져온다.
    • 결과 데이터는 네트워크로 전송하기 쉽도록 여러 조각으로 나누어집니다.
    • 한 화면의 마지막 조각에는 EndOfFrame 이라는 마킹이 포함 됩니다.
    • 따라서, EndOfFrame 데이터가 나올 때까지 Get을 하면 한 화면을 완성 할 수 있는 프레임 데이터가 모아집니다.

외부 객체 Client와 DesktopEncoder 사이의 약속들이 너무 단순해서 특별히 문제가 발생 할 시나리오를 찾지 못했습니다.  이제 요청받은 일들을 나눠주려고 합니다.  그런데!  비동기로 압축을 진행해야 하기 때문에 스레드에 대한 안정성의 문제가 발생합니다.  이런 경우가 자주 발생하는 편이라 저는 TaskQueue 라는 클래스 라이브러리를 만들어서 쓰고 있습니다.  이번에도 TaskQueue 를 사용하도록 하겠습니다.


TaskQueue 는 큐에 데이터를 넣으면, 스레드에 의해서 데이터가 순서대로 이벤트로 발생합니다.  작업을 요청하는 쪽에서는 요청만하고 기다리지 않습니다.  실제 작업은 TaskQueue 내부 스레드에 의해서 진행하도록 합니다.


[그림 4] Job Flow


[그림 4]에 보시면 새로운 객체 TaskQueue 가 늘어났습니다.  


DesktopEncoder.Start() 메소드가 실행되면 TaskQueue.Add(Start) 로 인해서 큐 안에 Start 메시지가 쌓이고, 내부의 스레드를 깨웁니다.  스레드는 깨어나면 큐에 있는 모든 메시지를 하나씩 차례로 이벤트로 발생시킵니다.  이로 인해서 Start 이벤트가 발생합니다.



Start 이벤트가 발생하면, DesktopCapture(화면 캡쳐 객체)와 FrameCompare(화면 간 비교 객체)의 Clear 메소드를 실행하여, 이전에 실행 된 압축 중간 데이터를 삭제합니다.


Stop 이벤트에서는 아무것도 표시되지 않았으나, 내부적으로 플래그에 Stoped 처리가 되어 이후 외부에서 화면데이터를 읽으려고 하면 nil을 반환하도록 합니다.  전체적인 시나리오에 크게 영향을 주지 않는다고 판단하여 생략하였습니다.  실제 개발에서도 너무 시시콜콜한 내용들은 생략하고 있습니다.


Start와 Stop의 경우에는 굳이 비동기를 사용하지 않아도 되겠으나, 압축이 일어나는 스레드와 분리 되는 것보다 같은 스레드에서 작업하는 것이 편리한 지라, 모든 작업을 TaskQueue 를 통해서 하고 있습니다.


이번에는 DesktopEncoder.Get() 메소드에 대해서 고민을 해보도록 하겠습니다.  압축을 하면 데이터가 조각으로 나오며, 이것이 하나씩 읽혀지게 됩니다.  만약 네트워크 속도의 한계가 오면 외부 객체가 더 이상 Get을 하지 않게 되고, 압축은 잠시 멈추게 됩니다.  이 조건을 살펴보면 압축 된 데이터가 잠시 어딘가에 보관되어야 한다는 것을 알 수가 있습니다.  아직까지 설계에서 나타나지 않은 버퍼 객체가 필요한 것 입니다.  [그림 5]는 버퍼 객체를 추가한 Job Flow 입니다.


[그림 5] Jow Flow


화면 압축 데이터를 저장할 PacketBuffer라는 새로운 객체가 늘어 났습니다.  Start와 Stop 이벤트가 발생하면 저장 된 데이터를 모두 소거하도록 되어 있습니다.


DesktopEncoder.Get() 메소드가 실행되면 PacketBuffer.Get()을 실행하여, 저장 된 데이터를 가져와서 돌려줍니다.  데이터를 돌려준다는 것은 해당 메소드가 void 형태가 아니고 리턴 값이 존재한다는 의미입니다.  그리고, 만약 PacketBuffer가 비어 있다면, 다음 화면을 압축하라는 의미로 TaskQueue.Add(NextScreen)을 통해서 TaskQueue에 메시지를 추가합니다.


조건에 대해서는 아래처럼 마름모 기호를 사용 할 수도 있지만, 간략하게 화살표 위에 표시하였습니다.



[그림 6]은 모든 조건이 충족 된 최종 Job Flow 입니다.


[그림 6] Jow Flow

  • NextScreen 이벤트가 발생하면, DesktopCapture에서 캡쳐 된 화면의 조각 묶음을 가져옵니다.  ScreenSlice라는 데이터 객체 입니다.  
  • 가져온 ScreenSlice는 FrameCompare에 의해서 이전 화면과 비교됩니다.  변경 된 조각마다 DeskFrame 이벤트를 발생시킵니다. 
  • DeskFrame 이벤트가 발생하면 FrameEncoder.Execute()를 통해서 해당 화면을 압축하도록 합니다.  DeskFrame 이벤트는 어떤 방식으로 압축해야 하는 지에 대한 기초 데이터가 함께 제공됩니다.  압축된 결과인 FrameData는 PacketBuffer.Add() 메소드에 의해서 저장됩니다.
  • FrameCompare 가 화면의 비교를 마치면 EndOfFrame 이벤트가 발생합니다.  이벤트가 발생하면, PacketBuffer.Add() 메소드를 통해서 EndOfFrame  마킹 데이터를 저장합니다.


이렇듯 각 객체 간의 약속이 정해지면, 구현 알고리즘을 캡슐화한 각 객체들에 대한 개발은 분업해서 작업하기가 쉬워집니다.  또한 약속이 변경되지 않는한 내부 구현의 변화에 의해서 다른 객체들이 영향을 받지 않게 됩니다.  그리고, 전체적인 프로세스 흐름을 쉽게 파악하고 이해하기 쉬워지기 때문에 개발 효율면에서도 도움이 됩니다.


실제 개발 했던 과정을 소설처럼 모두 설명해드리고 싶었지만, 오늘은 여기까지로 참겠습니다 ^^;


실제 개발 과정에서는 Job Flow가 바로 바로 나오지 않고, 여러 시나리오를 상상해 가면서 수정을 반복합니다.  명확한 해답이 필요한 경우에는 중간에 작은 테스트 프로그램을 통해서 시나리오들을 검증하기도 합니다.


아래는 위의 Job Flow로 표현했던 아이디어를 코드로 나타낸 일부입니다.  코딩을 하면서 추가 및 변경 된 부분들이 있습니다.


unit DesktopEncoder;

interface

uses
  DesktopEncoder.FrameCompare,
  DesktopEncoder.FrameEncoder,
  DeskCamUtils, DeskCamInterface,
  TaskQueue,
  PacketBuffer, DesktopCapture, ScreenSlice,
  Windows, SysUtils, Classes, SyncObjs;

type
  TDesktopEncoder = class (TComponent, IDesktopEncoder)
  private  // implementation of IDesktopEncoder
    function GetIsPaused:boolean;
    procedure SetIsPaused(AValue:boolean);
  private
    FStarted : boolean;
    FIsPaused : boolean;
    FPacketBuffer : TPacketBuffer;
    FDesktopCapture : TDesktopCapture;
    procedure add_DeskCamInfo;
  private
    FTaskQueue : TTaskQueue;

    procedure on_Task(Sender:TObject; AData:pointer; ASize:integer; ATag:pointer);
    procedure on_Terminate(Sender:TObject);

    procedure do_Start;
    procedure do_Stop;
    procedure do_Clear;
    procedure do_GetNextScreen;
  private
    FFrameCompare : TFrameCompare;
    procedure on_ChangedFrame(Sender:TObject; AThreadNo,AIndex:word; AData:Pointer; ASize:integer; ...);
    procedure on_FrameCompare_Completed(Sender:TObject);
  private
    FFrameEncoder : TFrameEncoder;
    procedure on_DeskFrame(Sender:TObject; AData:pointer; ASize:integer);
  public
    constructor Create(AOwner: TComponent); override;
    destructor Destroy; override;

    procedure Start;
    procedure Stop;

    procedure Clear;

    function Get(var AData:Pointer; var ASize:integer):boolean;
  published
    property DesktopCapture : TDesktopCapture read FDesktopCapture;
  end;

implementation

type
  TTaskType = (ttStart, ttStop, ttClear, ttGetNextScreen);

{ TDesktopEncoder }

procedure TDesktopEncoder.add_DeskCamInfo;
var
  Packet : TDeskCamInfoPacket;
begin
  Packet.Header.FrameType := ftDeskCamInfo;
  Packet.Header.Index := 0;
  Packet.DeskCamInfo.ScreenWidth  := (FDesktopCapture as IDesktopCapture).GetScreenWidth;
  Packet.DeskCamInfo.ScreenHeight := (FDesktopCapture as IDesktopCapture).GetScreenHeight;

  FPacketBuffer.Add( @Packet, SizeOf(Packet) );
end;

procedure TDesktopEncoder.Clear;
begin
  FTaskQueue.Add( Pointer(Integer(ttClear)) );
end;

constructor TDesktopEncoder.Create(AOwner: TComponent);
begin
  inherited;

  FStarted := false;
  FIsPaused := false;

  FPacketBuffer := TPacketBuffer.Create;
  FDesktopCapture := TDesktopCapture.Create(Self);

  FFrameCompare := TFrameCompare.Create;
  FFrameCompare.OnChangedFrame := on_ChangedFrame;
  FFrameCompare.OnCompleted := on_FrameCompare_Completed;

  FFrameEncoder := TFrameEncoder.Create;
  FFrameEncoder.OnDeskFrame := on_DeskFrame;

  FTaskQueue := TTaskQueue.Create;
  FTaskQueue.OnTask := on_Task;
  FTaskQueue.OnTerminate := on_Terminate;
end;

destructor TDesktopEncoder.Destroy;
begin
  FTaskQueue.Clear;

  FreeAndNil(FTaskQueue);

  inherited;
end;

procedure TDesktopEncoder.do_Clear;
begin
  FPacketBuffer.Clear;
  FFrameCompare.Clear;
end;

procedure TDesktopEncoder.do_GetNextScreen;
var
  ScreenSlice : TScreenSlice;
begin
  if FStarted = false then Exit;
  if FIsPaused then Exit;

  try
    ScreenSlice := FDesktopCapture.GetScreenSlice;
    if ScreenSlice <> nil then FFrameCompare.Execute(ScreenSlice);
  except
    // TODO: 에러 처리
  end;
end;

procedure TDesktopEncoder.do_Start;
begin
  if FStarted then Exit;

  FStarted := true;

  FPacketBuffer.Clear;
  FDesktopCapture.Clear;
  FFrameCompare.Clear;

  add_DeskCamInfo;

  FDesktopCapture.Start;
end;

procedure TDesktopEncoder.do_Stop;
begin
  if not FStarted then Exit;

  FStarted := false;

  FDesktopCapture.Stop;

  FPacketBuffer.Clear;
  FFrameCompare.Clear;
end;

function TDesktopEncoder.Get(var AData: Pointer; var ASize: integer): boolean;
begin
  AData := nil;
  ASize := 0;

  Result := FPacketBuffer.GetPacket( AData, ASize );

  if FPacketBuffer.IsEmpty then FTaskQueue.Add( Pointer(Integer(ttGetNextScreen)) );
end;

function TDesktopEncoder.GetIsPaused: boolean;
begin
  Result := FIsPaused;
end;

procedure TDesktopEncoder.on_ChangedFrame(Sender: TObject; AThreadNo,AIndex:word;
  AData: Pointer; ASize, ...);
begin
  FFrameEncoder.Execute( AIndex, AData, ASize, ... );
end;

procedure TDesktopEncoder.on_DeskFrame(Sender: TObject; AData: pointer; ASize: integer);
begin
  FPacketBuffer.Add( AData, ASize );
end;

procedure TDesktopEncoder.on_FrameCompare_Completed(Sender: TObject);
var
  Packet : TEndOfFramePacket;
begin
  Packet.Header.FrameType := ftEndOfFrame;
  Packet.Header.Index  := 0;
  Packet.CountOfFrames := 0;
  Packet.SizeOfFrames  := 0;

  FPacketBuffer.Add( @Packet, SizeOf(Packet) );
end;

procedure TDesktopEncoder.on_Task(Sender: TObject; AData: pointer;
  ASize: integer; ATag: pointer);
begin
  case TTaskType(Integer(ATag)) of
    ttStart: do_Start;
    ttStop: do_Stop;
    ttClear: do_Clear;
    ttGetNextScreen: do_GetNextScreen;
  end;
end;

procedure TDesktopEncoder.on_Terminate(Sender: TObject);
begin
  FreeAndNil(FPacketBuffer);
  FreeAndNil(FDesktopCapture);
  FreeAndNil(FFrameCompare);
  FreeAndNil(FFrameEncoder);
end;

procedure TDesktopEncoder.SetIsPaused(AValue: boolean);
begin
  FIsPaused := AValue;
  FDesktopCapture.IsPaused := AValue;
end;

procedure TDesktopEncoder.Start;
begin
  FTaskQueue.Add( Pointer(Integer(ttStart)) );
end;

procedure TDesktopEncoder.Stop;
begin
  FTaskQueue.Add( Pointer(Integer(ttStop)) );
end;

end.



TaskQueue 에 대한 소스는 아래 링크를 참고하시기 바랍니다.


관련글 더보기