상세 컨텐츠

본문 제목

화상회의 프로젝트 에피소드 3 - 안정적인 서버 구축

프로젝트/Ah!FreeCa

by ryujt 2010. 11. 22. 15:01

본문

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

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

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

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

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

일단 서버의 안정성을 위협하는 가장 큰 두 가지 요소는 다음과 같습니다.
  • 코드상의 문제
  • 병목 현상
    • 네트워크 병목현상
    • 임계영역 병목현상
    • 프로세스 병목현상 (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 인코딩 과정을 설명하고 있습니다.



관련글 더보기