대용량 소켓 서버 프로그래밍 두 번째 주제로 "Connection Object"에 대한 이야기를 해볼 까 합니다.  


새로 맡은 프로젝트를 진행 중에 기존 소켓 라이브러리에서 잦은 문제가 발생하자, 저는 IOCP를 이용해서 새로운 소켓을 작성하여 사용 중 입니다.  그리고, 그 동안 가장 맘에 안들었던 컨넥션 관리 알고리즘을 변경하였습니다.  테스트 결과는 상당히 만족스럽습니다.  



컨넥션 객체의 재사용

객체나 메모리를 할당 받고 삭제하는 것을 반복하는 것보다는 재사용하는 쪽이 성능에 당연히 유리합니다.  그리고, 이것이 빈번 할 경우에는 상당한 차이가 벌어질 수도 있습니다.  따라서, 생성했던 객체를 삭제하지 않고 객체 풀에 보관하다가, 필요 할 때 다시 재사용하면서 다소 성능에 이득을 볼 수가 있습니다. 


또한, 이미 언급했었던 패킷 풀과 같은 원리로, 컨넥션이 끊어지고 나서 해당 컨넥션 객체에 접근하더라도 Access Violation 에러를 내지 않고 유연하게 대체 할 수 있게 됩니다.  이러한 점을 이용해서 아래 "특정 컨넥션 객체 찾기"에서는, ID를 통해서 임계 영역없이 그리고 반복문도 없이 바로 컨넥션 객체에 찾아내고 접근하는 방법을 설명하고 있습니다.   


재사용이 너무 빠르면 이전 참조와 충돌하는 문제로 인해서 풀에서 바로 꺼내지 않고 한 참 후에 꺼내도록 하고 있습니다.  패킷 풀에서와 마찬가지로 풀에서 잠자는 시간보다 컨넥션 객체 참조의 라이프 사이클이 더 길면 문제가 됩니다.



컨넥션 객체의 컨테이너

제가 사용했던 대부분의 소켓 라이브러리는 ArrayList 형태를 취하고 있습니다.  델파이에서는 TList가 대부분입니다. 이것이 저는 항상 의아했습니다.  그리고, 대충 [소스 1]과 같이 컨넥션 객체를 접근하고 있습니다.  


문제는 새로운 접속이 들어오고, 또는 접속이 해제 됐을 때, 해당 객체를 제거하는 데에 CPU를 많이 사용하게 된다는 점 입니다.   따라서, TList를 제거하고 Linked-List로 데이터 구조를 변경하여 테스트하였더니, 상당히 만족스러운 결과를 얻을 수가 있었습니다.  


다만, 접속자 수가 많지 않거나 Connected와 Disconnected가 빈번하지 않은 경우에는 큰 차이가 없었습니다.  (당연히, 그리고 오히려 그러한 경우에는 TList가 유리합니다)  100, 500, 1000, 2000의 접속자 수에 대해서 각각 테스트 하였습니다. 


[소스 1]

procedure TfmMain.btSendToAllClick(Sender: TObject);
var
  List : TList;
  Loop: Integer;
begin
  List := IdTCPServer.Contexts.LockList;
  try
    for Loop := 0 to List.Count-1 do
      TIdContext(List[Loop]).Connection.IOHandler.WriteLn('');
  finally
    IdTCPServer.Contexts.UnlockList;
  end;
end;

[그림 1]은 TList와 Linked-List의 성능 비교 입니다.  데이터가 앞에서 부터 삭제되는 경우에는 아주 큰 차이를 보이고 있으며, 뒤에서부터 차례로 삭제되는 것은 TList가 두 배 정도 앞서고 있습니다.  이것은 실제 사용에서 좀 더 큰 차이가 나타날 수가 있습니다.  (메모리가 한 곳에 몰려 있지 않고 분산 될 경우 특히)


[그림 1]



특정 컨넥션 객체 찾기

귓속말을 보내는 과정을 생각해보도록 하겠습니다.  필자는 [소스 2]와 같이 컨넥션 객체마다 일련번호를 부여하고, 클라이언트에서 해당 일련 번호를 이용해서 서버의 객체를 반복문 없이 바로 사용하는 방법을 사용합니다.  





[소스 2]

var
  ID, Count : integer;
  ExchangeResult : pointer;
  FConnections : array [0..$FFFF] of TConnection;
begin
  // Get serial number and skip when it is zero or has taken.
  Count := 0;
  while true do begin
    Count := Count + 1;
    if Count > $FFFF then 
      raise Exception.Create('TConnectionList.Add: Connection limited.');

    ID := InterlockedIncrement(FConnectionID);

    // "ID = 0" means that Connection is not assigned.
    if ID = 0 then Continue;

    // This will helps you to find TConnection with ID without CriticalSection.
    ExchangeResult :=
      InterlockedCompareExchangePointer(
        Pointer(FConnections[Word(ID)]), AConnection, nil
      );
    if ExchangeResult = nil then begin
      AConnection.FID := ID;
      Break;
    end;
  end;


[소스 2]는 실제 코드가 아닌 설명을 위한 코드입니다.  


일련 번호는 FConnectionID에 저장합니다.  새로운 접속이 있을 때마다 하나씩 증가시키며, 혹시 해당 번호가 이미 사용 중이면 다음 번호를 찾도록 합니다.  


4: 그리고, FConnections은 각 컨넥션 객체를 관리하는 컨테이너입니다.  


19-22: 일련 번호가 선택되면 해당 번호에서 하위 2바이트만을 실제 인댁스로 사용하게 됩니다.  FConnectionID는 Integer로  선언되어있지만, Word 만큼만 사용합니다.  FConnections에 선택된 일련 번호에 해당하는 위치에 현재 컨넥션 객체의 주소를 저장합니다.  겹치는 영역들은 무시하고 다시 일련 번호를 찾기 때문에 문제 없습니다.


24: 실제 일련 번호를 저장합니다.


[소스 3]

function TConnectionList.GetConnection(ID: integer): TConnection;
begin
  Result := FConnections[Word(ID)];
  if (Result <> nil) and (Result.FID <> ID) then Result := nil;
end;


[소스 3]은 ID를 이용해서 컨넥션 객체를 찾아내는 코드 입니다.  클라이언트에서 ID를 알고 있는 경우, 반복문을 사용하지 않고 한 번에 객체에 접근 할 수 있도록 하였습니다.  또한, 해당 접속이 끊어지지 않는 이상 ID는 불변이기 때문에 특정 사용자에게만 메시지를 전달하고자 했을 경우 유용하게 사용 할 수가 있습니다.


4: ID가 가리키는 곳이라고 해도 nil이거나 FID와 완전히 일치하지 않으면 해당 ID는 할당 받은 적이 없거나 삭제된 컨넥션 객체입니다.  따라서, 결과를 nil로 리턴합니다.




Posted by 류종택
가끔씩 대용량 네트워크 프로그래밍에 대한 문의가 들어올 때가 있다.  그렇다고 해서 내가 아주 특별한 방법을 알고 있는 것은 아니지만, 대개의 경우에는 요구사항을 만족시켜 줄 수 있었다.  사실 대부분의 경우, 가장 기본적인 것들이 지켜지지 않았기 때문에 사용자 수가 조금만 증가해도 서버가 죽게 되는 것이다.



서버를 괴롭히는 요인
  • 네트워크 병목
  • 쓰레드 간 자원공유에 의한 병목
  • CPU 병목


네트워크 병목

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

이러한 네트워크 블로킹은 쓰레드를 이용하면 쉽게 해결할 수가 있다.  이때에도 네트워크 전송속도가 상당히 낮은 클라이언트 접속의 경우에는 무한정 버퍼링을 할 수가 없기 때문에, 접속을 해제해야 한다.  하지만, 그러한 사용자로 인해서 다른 사용자가 피해를 입는 것을 방지할 수가 있다.

델파이 개발자 중에서는 기본으로 내장되어 있는 Indy Socket을 사용하는 경우가 많다.  이때, 조심해야할 것은, Indy Socket 라이브러리와 함께 배포된 채팅 예제에서처럼 전체 접속자 리스트를 락을 걸고 데이터를 전송하는 경우가 있어서는 안된다.  이렇게 되면, 쓰레드로 처리하는 이점을 전혀 살릴 수가 없다.  

이러한 경우에는
  • 각 컨넥션 정보마다 버퍼를 둔다.
  • 데이터를 전송하는 대신 버퍼에 쌓아둔다.
  • 클라이언트에서 버퍼를 확인하는 메시지를 보낸다.
  • 자신의 버퍼에 데이터가 있으면 클라이언트에게 보낸다.
Indy Socket의 경우에는 클라이언트에서 수신되는 메시지 마다 서로 다른 쓰레드가 동작하기 때문에, 네트워크 위와 같이 하면 네트워크 병목을 자연스럽게 피해갈 수 있다.  (TIdThreadMgrPool을 사용하는 경우에는 같은 쓰레드가 여러 개의 컨넥션을 관리하겠지만, 일반적으로는 큰 문제 없다)



쓰레드 간 자원공유에 의한 병목

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

위에서 제시한 "네트워크 병목"에 대한 해법에서도 메시지 전송자와 수신자들을 관리하는 쓰레들 사이에서 똑같은 현상이 일어난다.  (버퍼를 안두고 무작정 보내는 것보다는 병목이 상당히 작게 일어나지만)

결국 최대한 임계영역을 자제하는 수 밖에 없다.  본인의 경우에는 Lock-Free 알고리즘을 이용하여 해결한다.

우선 쉽게 "InterLocked Funtion"을 사용한다.
  • Subversion https://dev.naver.com/svn/delphisamples/trunk/InterLocked Funtions
  • 아이디 / 암호 : anonsvn / anonsvn
그리고, 패킷에 관련된 버퍼를 큐를 이용하여 작성하고, 해당 큐의 데이터 처리를 Lock-Free 알고리즘을 적용하여 처리한다.

결론적으로 병목이 심한 부분들을 임계영역 없이 처리하는 것으로 해결한다.  하지만, 임계영역 없이 처리하기 어려운 상황도 생기고, Lock-Free를 적용하고도 생각보다 효과가 미비할 수도 있다.



CPU 병목

서버의 CPU를 바쁘게 하면, 당연히 서버가 괴로워한다.  CPU를 괴롭히는 요인들은 아래와 같다.
  • 암호화 처리 (쓸모있는 수준의 암호화는 CPU를 충분히 괴롭힌다)
  • 서버에 집중된 비지니스 로직
    • 클라이언트로 로직을 옮겨간다.  이때, 보안 문제가 발생할 수가 있다.
  • 잘못된 패킷 처리
    • 패킷 = 데이터 + 패킷 구분자 : CPU를 생각보다 많이 괴롭힌다.
    • 패킷 = 헤더(데이터의 크기) + 데이터 : 패킷 구분자를 통해서 패킷을 구별하는 것보다 효율적이다.
  • 기타
암호화 처리의 경우에는 내용이 공개되는 것 자체가 중요하지 않은 패킷에 대해서는 패킷변조와 패킷에 대한 삽입/삭제를 막는 간단한 방법이 있다.

패킷 변조는 헤더에 데이터에 대한 체크섬을 포함시키는 것으로 1차 방어를 할 수가 있다.  

패킷에 대한 삽입/삭제를 막는 방법 중 하나는, 최초 접속 시 랜덤 숫자를 교환하도록 하고, 약속된 복잡한 함수를 통해서 방금 전 패킷과 현재 패킷 사이에서 해당 함수의 결과값이 제대로 이행되는 지를 확인하면 된다.

이때, 함수의 결과값을 기준으로 체크섬을 계산하면, 데이터가 같아도 일정한 결과를 내지 않도록 하여 효과적인 방어를 할 수가 있다.

CPU 병목의 경우에는 단편적인 해법이 있을 수는 없다.  효율적인 알고리즘을 발견할 수 있도록 최선을 다하는 수 밖에 없다.


다음 이야기들



Posted by 류종택