프로그래밍 칠거지악

  1. go to
    • 아직도 살아 있었냐?  멸종 된 이후 화석만이 가끔 발견된다고 하더니!
  2. 전역 변수
    • 전역 변수는 여러 객체(요소)들을 하나로 연결하여 "스파게티 코드"를 유도하게 함
    • 어쩔 수 없는 경우가 아니라면 무조건 피해야 함
  3. 중복 코드
    • 완전히 같은 코드의 반복
    • 유사한 코드의 반복
  4. 쁜 이름
    • 이름 만으로 무엇을 하는 지 짐작할 수 없는 함수
    • 어디서 소스를 복사 해 왔는 지, 하는 일과 전혀 다른 이름을 가진 변수, 함수, 객체 참조 변수 등..
  5. 너무 깊은 블럭
    • 3중, 4중 반복(루프)?  오노!!  (3차원 그래프 문제 등이 아닌 이상 3중 반복문부터는 고민을 해야 합니다)
    • 조건문의 반복도 문제
  6. 너무 긴 블럭
    • if () { 이 시작 한 뒤 한 참을 스크롤해도 나타나지 않는 }
    • 함수 하나가 A4 용지를 넘어가는 장편 소설!
  7. 매직 넘버
    • 의미를 가진 숫자는 상수로 처리해서 상수 이름만 보고도 의미를 파악 할 수 있도록


사족

여기서 하나만 덧붙이자면, 외형적으로 이쁜 코드를 만들려고 노력해야 합니다.  줄 맞춰 쓰기 단락 구성 등 일관성 있는 코드 형식이면 좋겠습니다.  보편적인 코딩 규칙을 따른다면 더할 나위 없고요.


C 언어 등에서의 goto

글을 쓸 때 설명 할 것인가 고민을 하다가 큰 상관없다고 생각하여 말았는데, 결국 언급 된 사례가 있어 해명을 합니다.  저도 C 언어를 사용 할 때에는 함수 탈출 전 뒤처리가 필요 할 때 goto를 사용합니다.  이런 경우에도 return 전에 뒤처리 코드를 적을 수도 있겠지만, return 이 발생하는 곳이 여러 곳인 경우에는 goto 를 사용하기 도 합니다.  사실 화석보다 찾아보기 힘듭니다 ㅡ.ㅡ;




중첩 된 반복 문을 빠져나갈 때의 goto


"중첩 된 반복문을 빠져 나갈 때의 goto 문은 괜찮지 않겠냐?"는 의견을 받았습니다.  


듣고보니 당연히 goto 가 편할 것으로 보입니다.  그런데, "왜 그런 코딩을 한 기억이 없는 가?" 라는 생각이 들어서 제 경험을 되돌아 봤습니다.  제 방식이 무조건 옳다는 것은 아니지만, 저의 경우에는 중첩 된 반복문의 탈출은 return을 사용하고 있었습니다.  


중첩 된 반복문은 함수로 따로 빼서 사용하는 습관이 있다는 것을 저도 이번에 깨달았습니다.  


중첩문을 빠져나오고 반복문의 처리 된 내용을 마무리 처리를 거쳐야 하는 경우라면, 단순히 return 으로 끝낼 수가 없으니, 따로 함수로 빼고 return 값을 이용해서 마무리 처리를 진행하는 형식입니다.  


중첩 된 반복문은 단순 반복문에 비해서 특정한 의미를 가지는 경우가 대부분이었기 때문에 함수로 빼내서 사용하는 것이 훨씬 자연스럽게 느껴졌었던 것 같습니다.  


"try finally"를 사용 할 수 있는 경우라면 리턴 값을 받아서 처리하지 않고, 하나의 함수 내에서 마무리를 함께 처리 할 수도 있습니다.




기타


다른 의견이 있는 분들도 알려주시면 감사하겠습니다.







Posted by 류종택

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

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


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

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


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


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


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

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


이제 간단한 예제를 통해서 지금까지 설명한 내용을 자세하게 살펴보도록 하겠습니다.  하마티(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 에 대한 소스는 아래 링크를 참고하시기 바랍니다.



Posted by 류종택

소프트웨어를 개발하다보면 언제나 변화라는 문제에 봉착하게 됩니다.  그리고, 요구사항의 변화는 소프트웨어가 제공하는 기능에 대한 변화, 추가 및 삭제로 이어지게 됩니다.  이때, 기능 중심으로 개발 된 시스템은, 부분의 변화가 전체를 위협하기 쉬운 상태가 됩니다.  마치 젠가 게임을 하다가 보면 어느 순간 한 조각의 변화가 전체를 쓰러트리는 것과 같은 현상이 일어납니다.


작은 시스템의 경우에는 "기능 중심"의 개발이 그다지 문제가 되지 않습니다.
높이가 낮은 젠가는 쓰러트리기가 오히려 어려운 것처럼...


하나의 시스템을 구성하는 기능이 서로 전혀 연관이 없다면 문제가 없겠지만, 실무에서 그러한 시스템이 존재할 가능성은 거의 없습니다.


[그림 1]


OOP 중심의 개발에서는 이러한 기능을 박스로 분리합니다.  기능들을 묶어서 서로 다른 박스에 넣어 둡니다.  묶는 기준은 개발자(설계자) 마다 다를 수가 있으며, 무엇이 반드시 옳고 무엇이 반드시 틀렸다고 하기는 어렵습니다.  따라서, 우리는 "보편적인 아름다움"을 추구해야 합니다.  일반적으로 좀 더 타당한 해결책을 찾아야 한다는 의미입니다.


다만, 잘못 묶은 경우라면 [그림 2]와 같은 "나쁜 냄새"가 납니다.  왼쪽 박스에 있는 기능 하나가, 자신의 박스에 있는 기능들 보다 오른쪽 박스에 있는 기능들과 친합니다.  이런 경우라면, 해당 기능이 오른쪽 박스에 분류되어야 한다는 의미가 됩니다.  (기능은 동그라미로 표현했습니다)


[그림2]


이렇게 박스에 기능들이 담겨진 경우라면, 각각의 기능이 변화를 겪는다고 해도 전체를 위협하는 경우는 잘 일어나지 않습니다.  (젠가 조각을 박스에 담고, 그 박스를 쌓아 올린 경우입니다)  결국, 부분적인 변화가 전체를 위협하지 않는 상태가 되는 것 입니다.


  • 기능이 "서로 친하다"라는 것은 의존성이라고 표현합니다.  어떤 기능이 실행되는데 영향을 받는 경우를 뜻 합니다.  기능이 서로 영향을 주는 경우는, 다음과 같이 나눌 수 있습니다.
    • 특정 기능이 실행되는데 함께 실행된다.
    • 특정 기능이 실행 된 결과가 다른 기능에게 영향을 준다.
    • 특정 기능이 다른 기능의 실행 결과에 영향을 받는다.


물론 기능 변화가 심하거나 해서 박스가 변경되어야 하는 상황이 되면, 부분적인 변화가 다시 전체를 위협하게 됩니다.  OOP는 박스의 변화에 대해서도 좀 더 유연한 대응을 할 수 있는 것이 장점이며, 이에 대해서는 다음에 다루도록 하겠습니다.


Posted by 류종택

프로그래밍 입문자들에게 강의를 할 때, 항상 장벽처럼 느껴지는 주제들이 있습니다.  포인터, OOP, 비동기 처리 등이 그렇습니다.  그리고, 오늘 이야기하려고 하는 재귀 호출 또한 큰 장벽이 됩니다.


"어떻게 하면 재귀 호출을 쉽게 설명 할 수 있을까?"


처음에는 어떻게 하면 쉽게 설명 할 수 있을 까 고민을 많이 해봤습니다.  하지만, 번번히 실패하고 말았습니다.  그래서, 질문을 바꿔 보았습니다.


"재귀 호출을 이해하는 것이 왜 어려울 까?"


그리고, 찾아낸 것이 바로, 입문자분들께서 함수 호출 과정 자체를 제대로 이해하지 못하고 있다는 것을 깨달았습니다.  따라서 오늘은 먼저, 재귀 호출을 설명하기 전에 우선 함수 호출 과정을 살펴보도록 하겠습니다.  (소스 내에 문법에 어긋난 곳이 있습니다)




함수의 호출 과정


[그림 1]에서 보면 CPU에 의해서 실행되고 있는 코드가 빨간색 화살표로 표시되고 있습니다.  age 변수에 숫자 1을 대입하고 있습니다.  


[그림 1]


[그림 2]는 코드가 좀 더 진행되어서 2번 째 라인에 있는 incAge() 함수를 호출합니다.


[그림 2]


[그림 3]에서는 함수를 실행하는 중간입니다.  아직 함수가 있는 B 코드 영역으로 이동하지는 못하였습니다.  다만, 오른쪽에 있는 Stack에 보시면, "A.3의 주소"가 저장되었습니다.  이는 [그림 2]에서 incAge() 함수를 호출하기 전에 있었던 라인의 바로 다음 라인이 됩니다.


[그림 3]


[그림 4]에서는 이제 코드 진행이 B 코드 영역의 3 번 라인을 실행하고 있습니다.  age 변수에 있는 값이 숫자 1 만큼 증가하여 대입됩니다.  결과적으로 2가 될 것 입니다.


[그림 4]


[그림 5]에서는 incAge() 함수의 실행이 끝나고 종료되는 순간입니다.   Stack에 있는 가장 최근 데이터를 가져옵니다.  "A.3의  주소"가 저장되어 있습니다.  따라서, 코드 진행은 A 코드 영역의 3번 라인으로 이동합니다.



[그림 5]


[그림 6] 에서는 다시 함수에서 되돌아 와서 원래 코드의 진행이 계속 됩니다. 


[그림 6]


함수에서 return의 의미는 이처럼 "스택에 저장 된 최근 주소, 즉, 나를 실행시킨 라인 바로 다음 라인의 주소로 돌아간다" 라는 의미입니다.  (우리가 작성하는 코드와 기계가 실제 인식하는 코드의 라인은 서로 다릅니다)


왜 이렇게 하고 있을까요?  외부 함수는 현재 진행 코드와는 일반적으로 동 떨어진 곳에 있기 마련입니다.  그리고, 해당 함수를 호출하는 곳이 한 곳이 아니기 때문에, 함수는 자신이 실행되고 난 뒤에 되돌아 갈 곳이 항상 바뀔 수 밖에 없습니다.  결국 되돌아 갈 곳이 어딘지를 저장해둬야 합니다.


그렇다면, 왜 Queue가 아니고 Stack을 사용하고 있을까요?  A() 함수가 B() 함수를 호출하고, 이제 B() 함수가 C() 함수를 실행 시켰다고 가정하겠습니다.

  • A() --> B() 호출 할 때 되돌아 갈 A() 주소를 저장합니다.
  • B() --> C() 호출 할 때도 되돌아 갈 B() 주소를 저장합니다.
  • C() 실행이 종료되면 B()로 돌아가야 하나요?  A() 로 돌아가야 하나요?
당연히 B()로 돌아가야 합니다.  B() 주소는 A()보다 나중에(최근에) 저장되었습니다.  최근에 저장 된 것이 먼저 출력되는 데이터 구조가 필요합니다.  즉, Stack을 사용 하게 되는 것 입니다.

잠시!!!!
일단 이 부분이 확실하게 이해가지 않는다면, 함수 호출 과정 자체를 심도 깊게 공부하셔야 합니다.  위의 설명은 파라메터 관리 등에 대한 세밀한 언급을 피하고 최대한 간략하게 설명한 것입니다.  하지만, 지금까지의 이해가 부족하다면 재귀 호출 뿐 아니라, 다른 공부를 진행하는데에도 언젠가는 방해를 받게 될 것 입니다.



재귀란?

[그림 7] 부문의 패턴의 반복으로 전체의 패턴인 피라미트 구조체 (출처: http://commons.wikimedia.org)


"재귀란 반복 현상입니다.  
그리고, 
그 반복 속에서 일정한 형태를 유지하고 있는 경우를 의미합니다."

재귀는 특정 패턴이 반복되면서 거대한 구조를 만들었을 때, 그 구조물 전체가 다시 특정 패턴이 되는 것을 의미합니다.



1차원적 재귀

좀더 이해를 돕기 위해서, 1부터 시작해서 1씩 증가하는 순차 순열의 합을 통해서 1차원적 재귀 문제를 다뤄보도록 하겠습니다.

Sum = 1 + 2 + 3 ..... n

자, 이것을 순서대로 합을 나열해 보겠습니다.  Sum(n)은 n까지의 합을 의미합니다.

Sum(1) = 1
Sum(2) = Sum(1) + 2
Sum(3) = Sum(2) + 3
.....

"Sum(n) = Sum(n-1) + n" 형태가 n 값만 변경 된 상태에서 계속 반복되는 것을 알 수가 있습니다.  
  • 일단 Sum(n)의 결과값이 무엇인지 모르겠습니다.
  • 하지만, 만약 Sum(n-1)까지 구한 값이 있다면, Sum(n)은 거기에 n만 더하면 됩니다.
  • Sum(n)에게 결과값을 물어 봤더니, Sum(n)은 자기 자신인 Sum()에게 Sum(n-1) 값을 요구합니다.
  • Sum()은 항상 일관적인 행동을 하는 심지가 굳은 놈입니다.  그래서 Sum(n-1)은 Sum(n-2)에게 값을 요구합니다.
  • Sum(1)은 Sum(0)에게 물어보고 싶은 마음은 굴뚝 같지만, 문제의 시작에서 "1부터 시작해서" 라는 전제에 의해서 억울하지만, 1이라는 값을 Sum(2)에게 알려줍니다.  
  • Sum(2)는 Sum(1)이 알려준 1의 값에 2를 더해서 Sum(3)에게 알려줍니다.  그렇게 Sum(n-1)에게 까지 전달되면 Sum(n-1)은 결과에 n을 더해서 Sum(n)에게 알려주고 이제 Sum(n)은 최종 답을 알게 되는 것 입니다.
이러한 것을 점화식이라고도 부릅니다.  Sum(1)부터 화르륵 타올라서 Sum(n)까지 도달해서 끝이 나는 모양을 표현 한 것 입니다.

다음의 소스는 이것을 코드로 표현한 것 입니다.  막내가 바로 "탈출구" 또는 "탈출조건"이 됩니다.  만약 저러한 탈출구가 없다면, 계속 함수가 반복되어 자기 자신을 실행 할 것 입니다.  그러면 스택에 데이터가 점점 쌓이게 되고 언젠가는 스택의 메모리 부족(Stack Overflow)으로 에러가 나게 됩니다.  (함수 호출은 스택에 실행 된 곳 이후 라인이 항상 저장 되는 것을 상기하시기 바랍니다)

int Sum(int n)
{
	// 막내의 설음 ㅠ.ㅠ
	// 서럽지만 시킬 사람이 없다.
	// 그냥 내가 계산해 줄께, 답은 1이다 ㅠ.ㅠ
	if (1 == n) {
		return 1;
	}

	// 내가 계산하기 구찮다 아우야 니가 계산하고
	// 나는 거기에 n만 더할께
	else {
		return Sum(n-1) + n;
	}
}

코드를 완전하게 이해하기 위해서 Sum(3)의 과정을 컴퓨터의 입장에서 따라가 보도록 하겠습니다.  빨간 라인이 실제 실행되는 경로입니다.


[그림 8]


[그림 8]을 이해하려면 Sum(3), Sum(2), Sum(1)을 일종의 분신처럼 생각해야 합니다.  1부터 3까지 더하는 것을 세 명에게 나눠서 분업 시키는 결과와 같기 때문입니다.  다만, 멀티 스레드를 사용한 것은 아니기 때문에 한 번에 하나씩만 실행됩니다.  따라서, 위의 과정을 아래처럼 해석 할 수 있습니다.

  • Sum(3)이 13: 번 라인에서 분신 Sum(2)를 만들고 자신을 잠시 얼려 둔다.
  • 이제 Sum(3)은 굳어버린 상태이고, Sum(2)가 활동한다.  6: 라인에서 조건이 맞지 않기 때문에 Sum(3)과 마찬가지로, 13: 번 라인에서 새로운 분신 Sum(1)을 만들고 자신을 잠시 얼려 둔다.
  • Sum(1)은 6: 라인의 조건을 만족하기 때문에 "return 1"을 외치면서 사라진다.
  • 분신이 사라지자, 얼었던 Sum(2)가 다시 살아난다.  그 동안 무슨 일이 있었는 지는 모르지만, "return 1" 메시지가 전달 된다.
  • Sum(2)는 "return 1" 메시지에 2 를 더하여 "return 3"을 외치면서 사라진다.
  • 분신이 사라지자, 얼었던 Sum(3)이 다시 살아난다.
  • Sum(3)은 "return 3" 메시지에 3 를 더하여 "return 6"을 외치면서 사라진다.
  • Sum(3)의 메시지는 Sum(3)을 호출한 곳으로 전달된다.


그리고, 위의 과정은 "함수 호출 과정"에서 다뤘던 스택의 상태까지 함께 머리에 그릴 수 있어야지만 정확한 이해가 완성되는 것 입니다.




다차원적 재귀


다차원적 재귀에서 가장 흔하게 보이는 문제는 "미로 찾기", "폐곡선 내부에 색 채우기" 그리고 "하위 폴더 전체 검색" 등이 있습니다.  


그런데!  왜?

1차원적 재귀 문제인 팩토리얼 구하기는 쉽게(?) 이해가 가는데, 2차원만 되기 시작해도 어려워 질까요?


1차원적 재귀 문제를 이해했다면, 다차원적 재귀는 따로 이해해야 하는 것이 아닙니다.  두 문제가 본질적으로 같기 때문입니다.  




다차원적 재귀는 이해가 아닌 익숙해져야 하는 대상입니다.


김 노인과 박 노인이 장기를 둡니다.  김 노인이 날카로운 수를 두자, 박 노인이 시간을 끌며 생각에 잠깁니다.


"이 놈 봐라!  내가 여기에 두면 차로 덤비겠지, 그럼 나는 마로 방어하고 음.. 그리고.. 그리고...

그런데, 차로 안 덤비고 포로 덤비면 어떻게 하지?"


그렇게 수 없는 재귀적 조합을 통해서 경우의 수를 생각하다가,


"아, 장기 두는 넘 어디 갔나?"


김 노인이 갑자기 끼어드는 바람에 생각 해 둔 수를 모두 잊고 맙니다.


"아까 어디까지 생각 했더라?"


다시 처음부터 생각을 시작합니다.


다차원적 사고라는 것은 인간에게 있어서 자연스럽지 못한 행동입니다.  따라서, 상당한 연습을 통해서 익숙해져야 하는 것 입니다.  


마치 장기나 오목과 같은 보드 게임을 잘하는 사람들이 특별한 요령을 아는 것이 아닌 것처럼, 오랜 기간 반복 훈련을 통해서 익숙해져 가는 것 입니다.




하위 폴더 찾기에 대한 두 가지 해법


우선 다음의 코드는 재귀 호출을 이용한 방법입니다.  

// 재귀 호출을 이용한 방법

void searchFolder(String name)
{
  findFirst();
  while (찾은 것이 있다면) do {
    if (찾은것 == 폴더) SearchFolder(name + '\' + 찾은것);
    else 찾은_파일_처리();    
    findNext();
  }
}

이어지는 코드는 Stack을 이용한 방법입니다.  폴더를 검색하다가 하위 폴더가 나타나면, 스택에 쌓아 두고 원래 검색하던 것을 그대로 진행합니다.  원래 진행하던 폴더 검색이 끝나면 스택에서 가장 최근 것을 찾아서 다시 검색을 진행합니다.  큐를 사용하여도 상관없습니다.


재귀 호출을 사용한 것보다 코드가 복잡해졌지만, 성능면에서는 효율적입니다.  폴더를 검색하다 말고 다른 폴더로 이동했다가 다시 돌아오지 않고 현재 프로세스를 계속 이어 갈 수 있기 때문입니다.  요즘은 하드 디스크 입출력 및 기타 하드웨어 속도가 빠르기 때문에 크게 차이는 없습니다.  


일단 Stack 0verflow 문제에서는 쉽게 벗어 날 수 있습니다.  (Stack 클래스가 Heap 메모리를 사용하도록 구현 한다면)

// Stack을 이용한 방법

void workWithFolder(String name)
{
  findFirst();
  while (찾은 것이 있다면) do {
    if (찾은것 == 폴더) Stack.push(name + '\' + 찾은것);
    else 찾은_파일_처리();    
    findNext();
  }
}

void searchFolderUsingStack(String name)
{
  workWithFolder(name);
  while (false == Stack.isEmpty) {
    workWithFolder(Stack.pop());
  }
}




재귀적 문제가 중요한 이유


실무에서는 비교적 재귀적 문제가 자주 나타나지 않습니다.  더구나 재귀적 문제는 일반적인 해법으로도 해결이 가능합니다.  심지어 메모리 및 프로세스 효율면에서도 재귀 호출보다는 일반적인 해법이 더욱 유리합니다.  특히, 1차원적 재귀 문제들은 재귀 호출은 조금 과하다는 생각마저 듭니다.


그렇다고 재귀 호출이 무조건 나쁘다는 것은 아닙니다.  

  • 재귀 호출
    • 간결하고 직관적인 코드를 제공 (한 편의 시)
  • 일반적인 해법
    • 소설처럼 장황하지만 이해하기 쉬운 코드를 제공 (가독성은 떨어질 수 있음)
    • 메모리 및 프로세스 효율적인 코드

하지만, 재귀적 문제를 열심히 훈련해야 하는 이유는 설계와 개발 그리고 디버깅 과정 모두가 재귀적인 사고력을 요구한다는 것 입니다.


이미 장기 두는 노인분들의 예를 들었듯이, 복잡한 문제들은 다양한 경우 수를 두고 각각의 연관 관계를 살펴봐야 하는 경우가 많습니다.  디버깅의 경우에도 여러 가지 시나리오를 서로 비교하고 연구해야 하는 경우가 생깁니다.  그리고, 재귀 호출 형식이 아니더라도 재귀적인 개념이 필요한 문제 영역도 상당히 자주 만나게 됩니다.




좀 더 재귀적인 문제를 연구하고 싶은 분들은

  • Composite pattern
  • Back tracking
  • 파서와 컴파일러








Posted by 류종택

근래 제 블로그에 "클래스 상속과 인터페이스 구현의 차이"를 검색해서 들어오는 사례가 많아서 해당 글을 다시 한 번 정리합니다.  (원글은 http://ryulib.tistory.com/76 에 있습니다.)



기본적인 개념 설명


결론부터 말한다면,

  • 클래스 상속은 is-a 관계를 나타냅니다.
  • 인터페이스 구현은 can-do 관계를 나타냅니다.

그리고, is-a  관계는 태생부터 무엇인가 기질을 타고 난 경우를 말합니다.  부모가 황인종이기 때문에 저도 황인종입니다.  이것은 상속 받은 유전적이며 선천적인 형질입니다.

저는 태어나면서부터 프로그래밍을 할 줄 알았던 것은 아니지만, 지금은 할 줄 알고 있습니다.  이런 후천적인 형질은 can-do 관계라고 할 수 있습니다.


[그림 1]


[그림 1]을 보면 "자식"은 부모에게 유전적 형질을 물려 받게 됩니다.  이 관계는 is-a 입니다.  그러나, 자식은 프로그래밍으로부터 유전되지 않습니다.  단지 배워서 프로그래밍을 할 수 있을 뿐 입니다.  이러한 관계는 can-do 입니다.


일반적인 프로그래밍 언어에서는 유전적 상속은 단 하나의 클래스로부터만 가능합니다.  하지만, 인터페이스의 경우에는 다중 상속이 가능합니다.  프로그래밍을 한다고 해서 피아노를 배우지 못하란 법은 없습니다.  인간의 자식이면서 뱀의 자식이기는 어렵지만...


프로그래밍을 하는데 있어서도 이렇게 관계를 분리해야 할 경우가 있습니다.  


그러나, 칼로 자르듯이 "이런 경우에는 반드시 상속(is-a)를 통해서 해결해야 하고, 저런 경우에는 인터페이스(can-do)를 이용해서 해결해야 한다"라고 말하기는 대부분 어렵습니다.


설계자가 어느 정도 상황을 인지하여 결정을 내리는 수 밖에 없습니다.  다만, 최대한 자신의 개성이 아닌 보편적으로 이해 될 수 있는 구조를 갖출 수록 값진 설계가 될 것 입니다.



실제 프로그래밍 환경에서 다시 살펴보기


애완동물 샵 관리 프로그램을 작성하려는데, 현재 샵에서 판매하고 있는 동물들은 [그림 2]와 같습니다.

[그림 2]


[그림 2]의 동물들에 대한 클래스를 보니, 사료먹기가 중복되고 있습니다.  중복은 프로그래밍 7거 지악 중에 하나입니다.  제거합니다.  제거하는 방법으로는 위임과 상속이 있습니다.  오늘은 주제가 상속과 인터페이스 구현이므로 상속을 통해서 처리하겠습니다.  그리고, 그 결과가 [그림 3] 입니다.


[그림 3]


[그림 3]에서는 사료먹기를 애완동물 클래스에 정의하고 다른 클래스들은 애완동물 클래스를 상속 받아 사용하고 있습니다.  고질적인 문제인 "중복"은 확실히 제거 되었습니다.


그런데, 요구사항이 갑자기 늘었습니다.  강아지와 고양이는 "기어다니기" 기능이 있어야 하고, 금붕어의 경우에는 "헤엄치기"가 필요해 졌습니다.


[그림 4]


[그림 4]처럼 요구사항을 처리했으나, 이번에는 "기어다니기"가 중복되고 있습니다.  이번에도 상속을 통해서 처리하도록 하겠습니다.


[그림 5]


[그림 5]와 같이 이번에도 요구사항을 처리했으나 또 다시 새로운 요구사항이 들어 옵니다.  (이 바닥이 원래 그렇잖아요 ㅡ.ㅡ)  취급 동물이 하나 더 늘었습니다.  이번에 취급해야 하는 것은 오리 입니다.


[그림 6]


[그림 6]을 보시면 아시겠지만, 곤란한 문제가 생겼습니다.  오리는 육상동물 행동인 "기어다니기"도 하고, 수중동물인 금붕어와 같이 "헤엄치기"도 합니다.  양쪽의 형질을 모두 이어 받으려면 다중상속이 필요한 순간입니다.  다중상속은 대부분의 언어가 지원하지도 않고, 지원한다고 해도 무분별하게 사용하면 추후 감당하기 어려운 문제에 봉착하게 됩니다.


[그림 6]의 경우에는 상속이 두 단계에 거쳐서 일어나면서 "최대한 상속으로 피해라"라는 원칙에도 위배 됩니다.  (상속은 장점도 있지만, 그에 따른 비용이 큽니다)


[그림 7]


[그림 7]은 이러한 단점들을 인터페이스를 통해서 처리한 내용입니다.  즉, "기어다니기"와 "헤엄치기"를 유전적 형질로 보지 않고, 후천적 형질로 보고 처리한 것 입니다.  상속의 단계도 줄어들었고, 다중상속 문제도 해소되었습니다.  앞으로 추가적인 형질이 늘어난다고 해도, 상속 단계를 늘리지 않고 해결 할 수 있는 구조입니다.


오리가 "나는 기어 다닐수도 있고, 헤엄칠 수도 있다" 라고 말하고 있습니다.


그러나, 여기에도 문제는 있습니다.  인터페이스는 코드를 공유하지 않고 단지 인터페이스만 공유하는 것 입니다.  결국 "기어다니기"와 "헤엄치기" 코드는 모든 클래스에 중복되어 구현되고 있습니다.


[그림 8]


[그림 8]에서는 중복되는 "기어다니기"와 "헤엄치기" 코를 각각 "기는알고리즘" 클래스와 "헤엄치기알고리즘" 클래스에 옮겨두고, 각각의 애완동물 클래스들은 이 클래스에게 해당 기능을 위임하여 처리하고 있습니다.



정리


상속과 인터페이스 구현이 왜 필요한 지를 생각해 보겠습니다.

  • 코드의 공유: 상속은 중복된 코드를 제거하여 이를 상속 받는 클래스들이 코드를 공유 할 수 있도록 합니다.
  • 인터페이스의 공유: OOP로 프로그래밍을 하다보면 수 많은 객체들을 다루게 됩니다.  만약 특정 객체들이 동일한 호출방식(인터페이스)를 갖고 있다면, 객체의 타입을 무시하고 이를 호출 할 수 있게 됩니다.  이것을 다형성이라고 부르며, 조건문을 제거하고도 객체의 종류의 따라 같은 제어(흐름)에서 다른 결과를 얻을 수 있게 됩니다.
하지만, 코드의 공유는 위임을 통해서도 처리 할 수 있기 때문에, 상속보다는 위임을 우선으로 생각해보는 것이 좋습니다.  

인터페이스의 공유는 클래스 상속과 인터페이스 구현 모두 가능합니다.  다만, 상속의 경우에는 다중 상속의 벽에 부디칠 수도 있고, 남발하면 코드의 유지 보수가 어려워 질 수 있습니다.  결국, 클래스 상속과 인터페이스 구현 중 하나를 선택해야 하며, 그 기준은 해당 관계가 "is-a" 인가 또는 "can-do" 인가를 보고 결정하시면 됩니다.










Posted by 류종택

1997년 실시간 온라인 바둑 강의 시스템을 만들고 난 뒤부터 줄 곳 PC 화면을 실시간으로 송출하는 알고리즘을 만들어 왔습니다.  물론 업무에 시달리면서 틈틈이 해왔기 때문에 대부분의 시간은 집중하지 못하였지만, 근래 4년 동안은 상당히 많은 시간을 이 분야에만 매달려 왔습니다.


일단 PC 화면을 압축하는 방법 중에는 H264와 같이 이미 검증 된 솔루션을 사용해도 무방합니다.  하지만, 이는 압축하는 데 CPU 성능을 상당히 요구하며, 해상도가 큰 화면에서는 버겁습니다.  (제 고객 중에는 1680x1050 해상도로 강의하시는 분도 계심)


또한 영화와 같이 변화와 컬러의 분포가 고른 동영상의 경우라면 몰라도, PC 화면처럼 변화가 대부분 극소적이 영역에서 그치고 컬러의 분포가 고르지 않는 경우에는 일반적인 방법보다는 제가 만든 압축 방법이 휠씬 압축률이 높습니다.  (몇 개월 전, 1024x768 크기를 화질의 열화 없이 1시간 동안 녹화한 온라인 강의 자료가 40MB 도 안되었습니다)


압축률보다 더 문제가 되는 것이 있습니다.  많은 사람에게 실시간으로 전송해야 하는 문제입니다.  이 때문에 더 많은 시간을 보내게 되었는데, 이 와중에 제 최신 압축 라이브러리를 구매하신 어떤 고객은 경쟁사의 제품보다 내 솔루션의 압축률이 휠씬 못하다고 불평한 적도 있었습니다.  알고보니 그 분의 경쟁사 제품이 제 예전 솔루션이더군요.  (압축률은 높지만 실시간으로 다수의 사용자에게 전송하는데 단점이 있는 알고리즘)


현재도 압축률을 좀 더 끌어 올릴 수 있는 방법들이 있습니다.  이미 테스트도 완료한 것들입니다.  하지만, 실제 서비스에는 적용 못하고 있습니다.


가장 문제가 되는 부분이 "바로 실시간으로 다수의 사용자에게 동시에 전달"이라는 부분입니다.


지금 제가 사용 서비스하는 하마티(http://www.himytv.com/)에서는 강사가 보낸 데이터를 서버에서 모든 수강자의 상태에 따라 화면을 각각 재구성하는 방법을 사용합니다.  이부분에서 서버의 CPU 사용을 낮추고, 최대한 화면 전송을 부드럽게 진행하기 위해서 그 동안 수 많은 시행착오를 거치게 되었습니다.


오늘은 그 중에 까다로운 기술 하나를 적용하고 밤새 테스트 장비에서 테스트를 마무리하였습니다.  이제 반드시 적용하고 싶은 기술은 하나가 남았네요.  (이 부분이 아까 언급한 고객의 경쟁사가 사용하는 예전 압축 알고리즘, 이제 최근 알고리즘과 병행하여 사용 중)


되돌아보면 그 동안 정성을 들였던 시간에 비한다면, 참으로 간단하고 초라한 기술을 얻어낸 것 같습니다.  


그리고, 

깨달은 것이 있다면,

"얼마나 열심히 하는 가"보다 "얼마나 현명하게 일했는 지"가 더욱 중요하다는 것 입니다.




Posted by 류종택


[그림 1]


이번에는 방송 서버의 다른 기능을 좀 부각해서 설명을 이어 갑니다.  바로 데이터베이스를 비동기로 처리하는 내용입니다.  지난 포스트의 클래스 다이어그램에서는 표시되지 않았지만, 룸서버는 데이터베이스를 처리하는 TDatabase 클래스와 상호 협조를 하고 있습니다.  


TDatabase는 일반적인 데이터베이스 시스템일 수도 있고, 현재 서비스에서처럼 http 프로토콜을 이용해서 사용자 정보 등을 쿼리해 올 수도 있습니다.


일단 예제로 로그인 과정을 한 번 살펴보도록 하겠습니다.


[소스 1] 로그인 과정

procedure TRoomServer.rp_Login(AConnection: TConnection;
  ACustomData: DWord; AData: pointer; ASize: integer);
var
  ValueList : TValueList;
  pPacket : PRoomPacket absolute AData;
begin
  if AConnection.IsLogined then Exit;

  AConnection.UserName := 'Loging...';

  ValueList := TValueList.Create;
  try
    ValueList.Text := pPacket^.ToString(ASize);

    if ValueList.Integers['Version'] <> ROOM_SERVER_VERSION then begin
      sp_ErVersion(AConnection);
      Exit;
    end;

    AConnection.LocalIP := ValueList.Values['LocalIP'];

    FDatabase.Execute(TDatabaseRequestLogin.Create(Self, AConnection, ValueList.Values['LoginString']));
  finally
    ValueList.Free;
  end;
end;

22: 라인에 보면 FDatabase(TDatabase의 객체 레퍼런스)의 Execute() 메소드를 이용해서 로그인 요청을 하고는 결과를 처리하지 않고 패킷 처리를 완료합니다.  그리고 파라메터로 TDatabaseRequestLogin의 객체를 넘겨주고 있습니다.  그리고 TDatabaseRequestLogin 클래스의 생성자의 맨 앞에 있는 파라메터는 자기 자신을 가르키도록 되어 있습니다.


TDatabaseRequestLogin는 로그인 결과를 Self 객체에게 피드백을 해주는 방식을 취합니다.  콜백 함수와 같은 것 입니다.




[그림 1] Job Flow - DB에 대한 비동기 처리


[그림 1]의 Job Flow를 설명하면 다음과 같습니다.

  • rp_Login(이벤트): 클라이언트로부터 로그인 요청을 받았습니다.  
  • Database.Execute: Database 객체에게  DB 처리를 요청합니다.
  • Add: 작업 요청을 큐에 저장합니다.
  • WakeUp: 워크 스레드를 깨 웁니다.
  • WakeUp(이벤트): 워크 스레드들이 일어납니다.  
  • Get: 작업 요청을 하나씩 꺼내옵니다.  꺼내 온 결과 nil 이면 무시합니다.  
  • Worker. Execute: 요청 작업이 있으면 실제 작업을 처리하고, TDatabaseRequestLogin의 첫 번 째 파라메터에 해당하는 객체에게 Call Back을 실행하여 결과를 알려 줍니다.

[소스 2] 실행 결과를 Call Back 받아 처리 하는 부분
procedure TRoomServer.DatabaseRequestLogin(AConnection: TConnection;
  AID: integer; AResult: TValueList);
var
  RoomUnit : TRoomUnit;
  ErrorMsg : string;
  UserLimit : integer;
  IsLogined : boolean;
begin
  if AConnection.IsLogined then Exit;

  IsLogined := AResult.Booleans['result'];

  AConnection.RoomID := AResult.Values['room_id'];
  AConnection.UserID := AResult.Values['user_id'];
  AConnection.UserName := AResult.Values['user_name'];
  AConnection.UserLevel := AResult.Integers['user_level'];

  // TODO: 임시 코드 예전 웹 서비스에 맞춰서 추가 함
  if AResult.Booleans['owner'] then AConnection.UserLevel := TUserLevel.ADMIN;

  ErrorMsg := AResult.Values['error_msg'];
  UserLimit := AResult.Integers['user_limit'];

  AConnection.UserID := TIdURI.URLDecode(AConnection.UserID);
  AConnection.UserID := StringReplace(AConnection.UserID, '%20', ' ', [rfReplaceAll]);

  ErrorMsg := TIdURI.URLDecode(ErrorMsg);
  ErrorMsg := StringReplace(ErrorMsg, '%20', ' ', [rfReplaceAll]);

  if not IsLogined then begin
    sp_ErLogin(AConnection, ErrorMsg);
    Exit;
  end;

  RoomUnit := FRoomList.FindRoomByRoomID(AConnection.RoomID);
  if RoomUnit = nil then begin
    sp_ErLogin(AConnection, '강의실에 접속하는 중 에러가 발생하였습니다.'#13#10'잠시 후 다시 시도하시기 바랍니다.');
    Exit;
  end;

  if (UserLimit > 0) and (RoomUnit.ConnectionList.Count >= UserLimit) then begin
    sp_ErLogin(AConnection, '강의실 정원을 초과하였습니다.');
    Exit;
  end;

  if (AConnection.UserLevel < TUserLevel.ADMIN) and RoomUnit.CheckBlackList(AConnection) then begin
    sp_ErLogin(AConnection, '강퇴 된 사용자는 당분간 강의실을 이용하실 수가 없습니다.');
    Exit;
  end;

  AConnection.IsLogined := IsLogined;

  RoomUnit.UserIn(AConnection);
end;


[소스 2]에 구현된 DatabaseRequestLogin 메소드는 IDatabaseRequestLogin 인터페이스의 메소드입니다.  즉, TRoomServer는 DB 처리에 대한 Call back을 받기 위해서, IDatabaseRequestLogin 상속 받아서 구현해야 합니다.


[소스 3]은 IDatabaseRequestLogin와 TDatabaseRequestLogin의 선언부 입니다.


[소스 3]

type
  IDatabaseRequestLogin = interface
    ['{9B615467-9024-41C8-8E92-CD40B64AF68E}']

    {*
      로그인 요청에 대한 데이터 처리 결과 통보
      @param AConnection 처리를 요청한 (연결) 객체
      @param AID 처리를 요청한 객체의 아이디
      @param AResult 처리 결과 데이터
    }
    procedure DatabaseRequestLogin(AConnection:TConnection; AID:integer; AResult:TValueList);
  end;

  TDatabaseRequestLogin = class (TDatabaseRequest)
  protected
    procedure Execute; override;
  public
    Receiver: IDatabaseRequestLogin;
    Connection : TConnection;
    ConnectionID : integer;
    LoginString : string;  /// JSon 포멧의 RoomID, UserID, UserPass, etc

    {*
      @param AReceiver 처리를 요청한 그리고 결과를 메시지를 받을 객체
      @param AConnection 처리를 요청한 (연결) 객체
      @param ALoginString 로그인을 위한 정보
    }
    constructor Create(AReceiver:IDatabaseRequestLogin; AConnection:TConnection; ALoginString:string); reintroduce;
  end;


데이터베이스 처리는 로그인 뿐 아니라, 게임 등에서는 승패 정보 등을 저장하고 순위 정보를 쿼리해오는 등의 다양한 일을 하게 됩니다.  저는 모든 요청 사항에 대하여 따로 클래스와 인터페이스를 작성하여 처리하고 있습니다.



Posted by 류종택

Core에서 View를 호출 하기


Core는 단순한 클래스이기 때문에 외부에서 메소드를 호출하여 실행하시면 됩니다.  따라서 Core가 어떤 기능을 제공하는 지만 알면 Core를 사용하는데 문제가 없습니다.


Payer의 코어가 제공하는 기능은 다음과 같습니다.


그런데, 저는 반대로 Core에서 View 오브트들을 호출하기 위해서는 직접적인 호출을 허용하고 있지 않습니다.  Core에 View 오브젝트들에 대한 정보가 담겨지면 나중에 View 오브젝트들이 변경했을 때 재사용성에 문제가 되기 때문입니다.


Core는 View 오브젝트들의 대표자인 TCore.Obj.View 객체만을 사용합니다.  그리고, TCore.Obj.View.Add(Object) 를 이용해서 IView에 등록 된 모든 객체에게 메시지를 전달하여 원하는 동작을 하도록 합니다.  "Observer Pattern" 과 RTTI 를 이용합니다.


[그림 1] Core가 View 오브젝트를 호출하는 장면


[그림 1]에서처럼 Core는 View 객체들에게 메시지를 전달하기 위해서 멤버인 View에 선언되어 있는 메시지 전송 메소드를 실행합니다.  View는 자신에게 메시지 구독 신청을 위해 등록 된 모든 객체에게 해당 메시지를 전달합니다.  그리고 객체들은 자신이 처리해야 할 메시지인 경우 해당 메시지를 처리하면 되고, 아니면 무시하면 됩니다.


이를 통해서 Core는 실제 View 관련 오브젝트들에 대한 정보 없이 필요한 메시지를 통보하는 형식을 취하게 됩니다.


이것은 제가 APP(Abstract Presentation Pattern) 라는 이름으로 만든 패턴이며, http://ryulib.tistory.com/245 포스트를 참고하시기 바랍니다.




코드를 통해서 이해하기

기존 소스에서는 타이머를 통해서 새로운 이미지가 있는 지를 계속 점검하여 화면에 표시하는 형태를 취했습니다.  하지만, 새로운 소스는 Core 내부에서 보내지고 있는 rp_BitmapOfDeskCamDecoderIsReady 를 캐치해서 57-60: 라인과 같이 처리하고 있습니다.


현재 소스의 20: 라인에보면 메시지 수신용 메소드가 선언되어 있습니다.  RTTI를 이용해서 호출하고 있어서 published 에 선언되어야 합니다.


해당 메소드를 호출하는 곳은 어디에도 없지만, 메시지의 이름과 동일하기만 하면 자동으로 호출됩니다.  따라서, 자신이 해당 메시지를 받아서 처리해야 하는 경우에는 단지 published 영역에 메소드를 선언만 하면 됩니다.


TValueList는 TStringList를 상속 받은 클래스이며, RyuLib에 포함되어 있습니다.  6: 라인에서처럼 ValueList 유닛을 추가 해줘야 합니다.  메시지의 파라메터의 개수가 일정 할 수 없기 때문에 TStringList를 이용해서 파라메터를 전달 받고 있습니다.


54: 라인에 보면 폼의 OnCreate 이벤트에서 폼 자신을 View에 등록하고 있습니다.  등록이 되지 않은 객체는 Core로 부터 메시지를 받을 수가 없습니다.


35: 라인은 폼이 OnClose 될 때 View 등록을 해지하고 있습니다.  View는 더 이상 이 객체를 찾아서 메시지를 보내지 않습니다.


[그림 2]


[그림 2]는 코드의 동작을 다이어그램으로 표현한 것입니다.  View에 있는 sp_BitmapOfDeskCamDecoderIsReady 메소드는 밖에서는 호출 할 수 없으며, Core 내부에서만 동작합니다.  


메시지를 전송하는 메소드는 "sp_"로 시작하며, 메시지를 수신하는 메소드는 "rp_"로 시작합니다.  그 뒤에는 똑같은 이름이 있는 경우에 자동으로 해당 메소드를 실행하게 됩니다.




Player의 코어에서 View 객체에게 전달하는 메시지 종류


아래 링크는 TView에 대한 설명입니다.  sp_로 시작하는 메소드들은 해당 이름과 같은 rp_ 메시지를 발생합니다.  그리고 각각의 메시지의 의미는 Description 에 설명되어 있습니다.


TView 는 TViewBase 를 상속 받았습니다.  따라서, TViewBase 에서 전송하는 메시지들도 수신됩니다.  TViewBase 클래스에 대한 설명은 아래 링크에 있습니다.






Posted by 류종택

이글을 작성한 이후 소스를 직접 빌드 할 수 있도록 변경하였습니다.  코어 모듈의 민감한 부분들을 모두 dll로 제공하는 방식으로 전환하였습니다.


제공되는 소스 및 dll 파일은 공부를 위해서 오픈 된 것 일 뿐, 상업적인 사용은 물론 복제 및 무단 배포를 허용하지 않습니다.


오픈 소스에서 가져온 dll은 마음대로 사용하셔도 됩니다.


여전히 RyuLib가 필요합니다.  (http://ryulib.tistory.com/279)



우선 http://ryulib.tistory.com/280 포스트를 보시면, HiMyTV 재생기에 대한 코어 모듈 정보가 있습니다.  해당 포스트는 일단 참고로만 알고 계시고 첨부터 복잡하게 이해하거나 정성 들여서 읽으실 필요는 없습니다.




빈 프로젝트 열기

위 링크를 클릭하시면 빈 폼만이 제공되는 프로젝트 파일이 있습니다.  이미 언급한 것처럼 핵심 모듈은 제공 할 수가 없기 때문에 빌드가 되지는 않습니다.


빈 폼만이라고 했지만, 프로젝트를 열어보시면 무엇인가 잔뜩 포함되어 있습니다.  그것들은 바로 코어 모듈입니다.  저는 응용 어플리케이션을 작성 할 때에도 UI와 코어를 분리해서 개발 할 수 있는 MVC와 유사한 라이브러를 이용합니다.  


결국 위의 링크에는 "빈 메인 폼" + "코어 모듈" 두 개가 있는 것 입니다.




코어 모듈 초기화 및 종료 처리

위의 링크에는 코어 모듈의 초기화 및 종료 처리가 되어 있는 리비젼이 표시됩니다.


무엇이 변했는 지는 아래의 링크를 클릭하시면 알 수가 있습니다.  일단 클릭하신 이후 나타나는 브로우져 창을 보면서 설명을 읽어 나가시기 바랍니다.

오른쪽에 나타나는 소스와 같이, 메인 폼에 OnCreate 이벤트에서 "TCore.Obj.Initialize" 를 실행해주면 초기화가 진행됩니다.  OnClose 이벤트에서는 "TCore.Obj.Finalize" 로 종료 처리를 하고 있습니다.


코어 모듈의 초기화와 종료 처리는 개발자가 스스로 알아서 할 수 있도록 저는 언제나 코어 모듈에는 위와 같은 두 메소드를 지원하고 있습니다.


22-23: 라인에 보시면 코어 모듈이 있는 Core 유닛을 참조하고 있습니다.


TCore 클래스에는 "class function obj" 가 정의 되어 있어 싱글톤 패턴을 이용하여 객체를 참조 할 수 있도록 되어 있습니다.  해당 구현은 아래 링크를 참고하시면 됩니다.

메인 폼처럼 전역 변수를 사용해서 객체에 접근해도 되겠지만, 단 하나의 객체만이 존재하면 프로그램 생명 주기 내내 필요한 것이기 때문에 싱글톤이 더 편하다고 판단하였습니다.




간단한 녹화 재생 실습


위의 링크에는 간단한 녹화 재생이 처리 된 상태입니다.


변한 것은 메인 폼에 콤포넌트 몇 개 내려 놓은 것과 함께 코딩이 추가 된 것입니다.  일단 추가 된 코드를 살펴보도록 하겠습니다.

41-49: 라인을 보시면 실행 파라메터가 있을 경우에는 해당 파라메터의 파일을 오픈하고, 파라메터 없이 실행 되었을 경우에는 OpenDialog 를 실행해서 재생 할 파일을 묻도록 되어 있습니다.  이때 파일은 http://www.himytv.com/ 프로그램으로 녹화된 것이어야 합니다.

  • TCore.Obj.Open(FileName): FileName의 파일을 재생합니다.  http://가 앞에 붙으면 웹에 있는 파일을 다운받으면서 재생하게 됩니다.

아래는 위의 코드를 빌드한 재생기 프로그램입니다.  지난 번 FFMPEG for Delphi를 강의 했던 파일을 

http://goo.gl/j3IzM 에서 다운 받아서 재생해보시면 됩니다.  음성과 함께 PC 화면 녹화 내용이 출력 될 것 입니다.

Player.zip


54-59: 라인에서는 타이머를 이용해서 주기적으로 TCore.Obj.GetBitmap(Bitmap) 메소드를 실행하고 있습니다.  교재 화면에 새로운 이미지가 있다면 GetBitmap은 true를 리턴하면서 파라메터에 넘겨준 Bitmap 객체에 해당 이미지를 덮어 쓰게 됩니다.  즉, 녹화 파일을 디코딩하면서 새로운 화면이 나타 날 때마다 화면에 표시하게 됩니다.


이번 포스트에서는 코어 모듈을 UI와 분리하여 개발하는 장점을 제대로 보여 주지는 못했습니다.  이것은 앞으로 풀어갈 숙제이며, 처음부터 너무 많은 것을 설명하기보다는 작은 코드를 통해서 쉽게 기본 배경을 이해하는데 집중하였습니다.







Posted by 류종택

프로그램 체험: http://www.himytv.com/  (실시간 강의 솔루션)


[그림 1]


[그림 1]은 HiMyTV의 재생기에 대한 코어 모듈들의 클래스 다이어그램입니다.  다소 복잡해 보이지만 사실 아래와 같이 몇 개 안되는 클래스로 시스템이 운영됩니다.  강사용 프로그램과 수강생용 프로그램이 재생기와 중복되는 기능들이 많기 때문에 중복되는 기능들을 부모 클래스로 옮기다보니 클래스가 다소 늘어났습니다.

  • TCore: 프로그램의 핵심 기능만을 모아 둔 클래스 입니다.
  • TView: 사용자 인터페이스의 유연성을 보장하기 위해서 코어 등에서는 View 관련 객체를 직접 참조하지 않습니다. 서로 전혀 무관한 것처럼 인식하도록 하기 위해서 입니다.

위의 두 클래스가 핵심입니다.  그리고 추가로 보이는 아래 클래스는 보조 역할을 합니다.

  • TOption: 각종 옵션 설정
  • TLayout: 재생기는 4 가지의 레이아웃을 제공하여 사용자가 맘에 드는 화면을 선택하는 기능이 있습니다.  이를 지원하는 클래스입니다.

각 유닛과 클래스들의 자세한 설명은 아래 링크와 같습니다.  Description이 없는 유닛이나 클래스는 다른 것들에 비해 덜 중요하기 때문에 우선 순위에서 밀린 것들입니다.



Posted by 류종택


티스토리 툴바