IT_Programming/Network Programming

[펌] TCP 서버 네트워크 엔진 구현

JJun ™ 2013. 7. 3. 08:59

 


 출처: http://blog.naver.com/neon978/110016194331


 

 

서버 프로그래밍에서의 핵심은 역시 잘 다듬어진 네트워크 엔진(모듈)이라고 할 수 있다.

이것이 잘 받쳐주지 않으면 훌륭한 기획에 어느 수준의 개발까지 별 탈 없이 진행된다 하더라도

결국 서버의 안정성이 확보되지 않아 개발이 실패하는 일이 벌어지기도 한다.

 

이처럼 네트워크 엔진은 곧 서버의 안정성 문제와 직결되기 때문에, 수많은 시행착오를 겪어가며

다양한 방법으로의 검증이 필요한 부분이기도 하다.

모든 프로그램이 마찬가지겠지만, 특히 서버의 네트워크 엔진은 매우 유연하고 견고하게 설계되어야

한다. 또한 네트워크 엔진은 보통 서버 프로세스의 공통 모듈로서 존재하며 사용 빈도가 매우 높기

때문에 접근하기 쉬운 인터페이스와 일정 수준 이상의 성능이 보장돼야 한다.

 

이번 시간에는 이러한 네트워크 엔진을 직접 구현해 볼텐데, 먼저 엔진의 인터페이스를 구성해 보고,

이어서 실전 코드들이 어떻게 엔진에 적용될 수 있는지 살펴보도록 하겠다.

 


어떤 형태의 서버를 만들 것인가

 

설계에 앞서 어떤 형태의 서버에 초점을 맞춰 설계할 것인지 그 범위를 한정해 둘 필요가 있다.

서비스 차원에서 분류해 보면 <표 1>처럼 크게 두 갈래의 서버로 나눠진다.
여기서는 채팅이나 메신저 서버와 같은 일반적인 형태의 서비스, 즉 대량의 접속을 처리할 수 있는 서버에 초점을 맞추어 설계할 것이다. 물론 이렇게 범위를 한정해 둔다고 해서 다른 형태의 서버에 쓸 수 없는 것은 아니다. 충분히 활용할 수 있으며, 다만 엔진의 내부 구현상 미미하게나마 성능 차이가 발생하는 것뿐이다.

 


인터페이스


엔진 자체의 성능도 중요하지만, 접근하기 쉬운 인터페이스를 제공하는 것 역시 매우 중요하다.

접근하기 쉽다는 것은 그만큼 오류를 줄일 수 있다는 것을 뜻한다. 따라서 엔진 제작자는 직관적이고

사용하기 편리한 인터페이스를 설계하는 데 충분한 시간을 투자해야 한다.


이번 시간에 구현해 볼 샘플 엔진의 객체 구조를 <표 2>에 나타냈다. 혹시 ‘남이 만들어 놓은 괴이한

구조의 엔진을 이해해야 하는 것 아닌가’ 하는 독자들의 우려 섞인 목소리가 벌써부터 들리는 듯 한데,

그건 걱정하지 않아도 좋다. 일반적인 TCP 서버 프로그래밍에서 설명되고 있는 개념들을 네트워크 객체들로 정리한 것뿐이다.


필자를 포함해 대다수의 프로그래머들이 그러하듯 소스를 당장 뿌리지 않으면 대단히 불편한 심기를

드러내곤 한다. 필자도 이 점 충분히 공감하고 있다. 그러나 지금은 갑갑하더라도 잠시만 참아주기

바란다. 엔진의 전체 구조와 인터페이스의 정확한 의도를 파악하는 것이 전체를 분석하는 데

더욱 중요하기 때문이다.


<표 2>에서 설명한 것처럼 TcpServer가 이 엔진의 중심 객체로서 나머지 객체들을 총 관리하는 역할을 한다. 또한 내부적으로 IOCP의 워커 쓰레드를 포함하고 있기 때문에 패킷의 송/수신 등의 통보가 최초로 알려지는 곳이기도 하다. TcpServer_XXX로 시작하는 객체들은 TcpServer의 기능을 분산시키기 위해

떼어 놓은 쓰레드이며, 엔진 사용자는 이들 객체를 직접 생성하거나 접근할 수 없다.

 

결과적으로 엔진 사용자가 사용할 수 있는 객체는 TcpListener, TcpPeer, TcpServer 뿐이며,

이 세 가지의 객체로 거의 모든 TCP 서버 처리를 할 수 있게 되는 것이다.

 

 <1> 서로 다른 형태의 서버
형태종류
대량의 패킷을 처리해야 하는 서버FTP, POP3, SMTP, ...
대량의 클라이언트 접속을 처리해야 하는 서버HTTP, 채팅, 메신저,...


 

이 엔진 구조를 토대로 TCP 서버에서 일어날 수 있는 가상 시나리오를 몇 가지 세워 보았다.

각 시나리오의 흐름을 따라가면서 천천히 살펴보도록 하자.


첫 번째, 간단한 형태의 에코 서버를 만든다고 하자. 에코 서버(Echo Server)란 클라이언트가 보낸

데이터를 그대로 되돌려 주는 서버(말 그대로 메아리쳐 주는 서버)를 말하며, 이를 구현할 때의

코드 흐름은 다음과 같다(굵게 표시된 부분은 엔진이 엔진 사용자에게 알려 주는 비동기 통보를 뜻한다).

① TcpServer 객체를 생성한다.
② TcpListener 객체를 이용해 에코 서버를 위한 TCP 포트를 바인딩한다.
③ 앞서 만든 TcpListener를 TcpServer에 등록한다.
④ TcpServer_Acceptor가 클라이언트의 접속을 감지한다.
⑤ 접속받은 소켓을 이용해 TcpPeer 객체를 생성한다.
⑥ 앞서 생성된 TcpPeer 객체로 통신(패킷 입출력)을 시작한다.

개념적으로 간단히 나열해 보았는데, 실제 구현을 위한 코드도 크게 다르지는 않다.

당연한 것이지만, 엔진 사용자가 윈속 및 시스템의 API를 직접 호출하는 일은 절대 없으며,

이러한 모든 코드는 엔진이 은닉(encapsulation)하고 있다.
두 번째, 앞의 에코 서버가 특수한 처리를 위해 원격 호스트에 직접 접속할 일이 생겼다고 하자.

이 때의 흐름은 다음과 같다.

① TcpPeer를 생성한다.
② TcpServer에 원격 호스트의 주소와 함께 TcpPeer를 등록한다.
③ TcpServer_Connector가 접속 성공 여부를 감지한다.
④ 접속 성공 후 TcpPeer 객체로 해당 서버와의 통신(패킷 입출력)을 시작한다.

앞의 두 시나리오의 마지막에 TcpPeer의 통신을 시작한다고 했는데,

구체적으로 어떤 흐름을 거쳐 패킷을 주고받는지 살펴보자.

① TcpServer가 패킷의 수신 완료 사실을 알아낸다.
② TcpServer_Dispatcher가 패킷 처리를 시작한다.
③ TcpPeer에 패킷이 수신되었음을 알린다.
④ 에코 서버이므로 받은 그대로를 돌려준다.
⑤ TcpServer가 패킷의 송신 완료 사실을 알아낸다.
⑥ TcpPeer에 패킷이 송신되었음을 알린다.

여기까지 이 엔진이 가지는 TCP 서버로서의 기능을 몇 가지 시나리오를 통해 간략히 살펴봤다.

 

이번엔 앞의 시나리오에서 언급됐던 ‘~를 감지한다, ~를 알린다’ 등의 통보가 실제로 어떻게 처리되는지, 또 어떤 종류의 통보들이 있는지 알아보자.


이 엔진에서는 이벤트 통보를 위해 C++ 가상 함수를 이용한다.

패킷의 입출력 완료 통보는 TcpPeer 객체의 가상 함수에, 그 외의 모든 통보는 TcpServer 객체의

가상 함수를 통해 알려지게 된다. 쉽게 말해 TcpServer와 TcpPeer는 네트워크 객체로서의 역할도

하지만, 이벤트 통보를 위한 인터페이스의 역할도 함께 하는 것이다. 따라서 앞의 에코 서버를 실제로

구현한다고 하면, TcpServer를 상속받은 EchoServer, TcpPeer를 상속받은 EchoPeer 등의 객체들을

만들어 각각의 가상 함수를 구현해야 할 것이다. <표 3>은 TcpServer가 가진 가상 함수의 종류를,

<표 4>는 TcpPeer가 가진 가상 함수의 종류를 나열한 것이다.


지금까지 엔진의 전체 구조와 인터페이스, 접근 방법에 대해 간략하게 살펴봤다.

이제 구조에 대한 설명은 이만 마치기로 하고, 지난 호에 다루었던 윈속 및 오버랩드 I/O, IOCP 등의

이론들이 이 엔진을 통해 어떻게 적용되어 있는지 살펴보자.

 

 <2> TCP 서버 객체목록
객체역할
TcpListener
(
서버소켓 객체)
서비스할 TCP 포트를 바인딩한 후 클라이언트의 접속을 받기 위해 사용한다.
TcpPeer
(
클라이언트 소켓 객체)
개념적으로 하나의 TCP 연결을 나타낸다. ① TcpListener로부터 반환받은 소켓, 또는 ② 원격 호스트로의 접속에 성공한 소켓을 이용해 패킷의 입출력을 처리하는 데 사용한다.
TcpServer
(TCP
서버 중심 객체)
원속 및 IOCP 초기화, TcpListener, TcpPeer 객체들의 총 관리를 포함하는 엔진의 중심 객체이다. 응용 프로그램은 이 객체를 가장 먼저 생성해야 한다.
TcpSever_Acceptor
(
라이언트의 접속을 받기 위한 객체)
하나의 응용 프로그램 안에는 다수의 TcpListener들의 네트워크 이벤트를 처리하고 다음 동작으로 신속히 전환할 수 있도록 도와준다.
TcpSever_Connector
(
원격 호스트로의 접속을 위한 객체)
서버라고 해서 클라이언트의 접속만 받아 처리하는 것은 아니다. 부하 분산을 위해 때로는 다른 서버에 접속하기도 하는데, 이 쓰레드 객체는 이러한 접속 문제를 해결하기 위한 것이다.
TcpSever_Dispatcher
(
패킷을 처리하기 위한 객체)
여러 쓰레드에서 무작위로 수신되고 있는 패킷들을 즉시 처리해 버리면 작은 조각들을 하나하나씩 처리하는 것이 되어 효울이 떨어질 뿐 아니라, 자칫 잘못하면 상호 참조로 인한 데드락을 초래할 수 도 있다. 이 객체는 이러한 문제를 해결하기 위한 것이다.


 

 


구현 상태 보기


TcpListener 객체부터 살펴보자. 부모 클래스인 Monitor 객체는 멀티 쓰레드의 접근을 동기화하기 위해

크리티컬 섹션(critical section)을 랩핑해 놓은 클래스다. TcpPeer도 마찬가지지만, 여러 쓰레드가

언제든지 이들 객체에 접근할 수 있기 때문에 동기화 작업은 반드시 필요하다.


TcpListener 객체는 서버 소켓을 감싸고 있다. Init 메쏘드에서 쓰이는 SockAddr 객체는 윈속의 SOCKADDR 구조체를 랩핑해 놓은 클래스다. 비록 윈속이 인터넷 프로토콜 주소를 위해

SOCKAD DR_IN 구조체를 제공한다고 해도, 하나의 주소를 설정하기 위한 작업이 여간 불편한 것이 아니다.

따라서 앞으로 소켓의 주소를 지정하는 데 SockAddr 객체를 사용할 것이다.

 <표 3> TcpSever의 가상 함수 목록
가상 함수역할
OnAccept 클라이언트의 접속이 감지됐음
TcpListener 객체가 반환한 소켓으로 TcpPeer 객체를 만  든다.
OnAcceptError 주어진 TcpListener 객체로 접속을 받아들일 수 없음
소켓이 닫혔거나 시스템 리소스가 부족한 경우 등 여러 가지 경우에 발생할 수 있다.
OnConnect 원격 호스트로의 접속이 성공했음
TcpPeer가 요청한 원격 호스트로의 접속이 성공했음을 알린다.
OnConnectError 원격 호스트로의 접속이 실패했음
네트워크에 이상이 있거나 다른 시스템적인 문제가 발생할 경우 호출될 수 있다.
OnClosePeer TcpPeer의 접속이 끊겼음
말 그대로 어떤 이유로 인해 접속이 끊겼을 때 알려진다.
OnBeginDispatch TcpPeer의 패킷 처리를 시작함
TcpSever_Dispatcher는 일정 주기로 TcpPeer가 수신한 패킷을 처리하는데, 처리하기 직전 TcpSever에 이 사실을 알린다.
OnEndDispatch TcpPeer의 패킷 처리가 끝났음
TcpPeer의 패킷 처리가 끝났음을 TcpSever에 알린다.


 

 <표 4> TcpPeer의 가상 함수 목록
가상 함수역할
  onInitComplete 클라이언트의 연결이 초기화됐음
① 클라이언트가 접속했거나 ② 원격 호스트에 접속이 성공한 경우, 즉  T CP 연결이 성립됐을 때 (connection established) 호출된다.
OnSendComplete 패킷 송신이 완료됐음
요청한 패킷의 송신이 완료됐음을 알린다.
OnExtractPacket 패킷 수신이 완료됐음
1바이트라도 패킷이 수신됐으면 이 함수가 먼저 호출된다. 이름이 OnExtracPacket인 이유는, 엔진이 엔진 사용자의 편의를 위해 ① 패킷의 완료 여부를 판단하는 분석부(parsing part)와 ② 완료 패킷을 처리하는 부 (processing part)로 명확히 구분하여 처리하기 때문이다. 이 함수는 패킷의 완료 여부를 판단하기 위해 사용된다.
OnRecvComplete 완료 패킷이 수신됐음
완료 패킷이 수신됐을 때 호출된다.
OnError 패킷 송/수신을 실패했음
소켓이 닫혔거나 시스템 리소스가 부족한 경우 등 여러 가지 경우에 발생할 수 있다.


 

 class TcpListener : public Monitor
 {
    SOCKET m_sock;

    bool Init( SockAddr *addr );
    void Uninit();
 };



다음은 TcpListener 객체를 초기화하는 메쏘드를 보여준다. 간단한 함수이므로 그냥 넘어가기로 하고,

잠시 후 TcpServer_Acceptor 객체를 설명할 때 TcpListener를 이용해 어떻게 접속을 받아들이는지

살펴보도록 하겠다.


이제 TcpPeer 객체를 살펴보자(<리스트 1>).

<리스트 1> TcpPeer 클래스

 

 Class TcpPeer : public Monitor
 {
        struct OVERLAPPEDEX : public OVERLAPPED
        {
                OPCODE opcode;
                WSABUF wsaBuf; 
                bool inProgress;
        };

        SOCKET m_sock;
        SockAddr m_remoteAddr;
        char m_refCount; 
        ...

        TcpPeer( int recvBufMax = 8192 );
        virtual ~TcpPeer();

        bool Send( char *p, int len ); 
        bool Recv();
        bool CloseConnection( bool graceful = true ); 
        ...
 };



TcpPeer는 패킷의 송수신을 위해 각각의 OVERLAPPED 구조체를 이용하여 WSASend, WSARecv 호출을

하게 된다. 여기서 OVERLAPPED 구조체를 확장한 OVERLAPPEDEX 구조체를 사용하는 이유는 ?

 

IOCP로부터 완료 통보를 받을 때 어떤 호출의 완료인지 식별할 수 있어야 하고(opcode),

? 오버랩드 호출에서는 시스템이 인식할 버퍼의 포인터가 있어야 하며(wsaBuf),

? 동일한 오퍼레이션(WSA Send, WSARecv)의 중복 호출을 막기 위해서(inProgress)다.

TcpPeer 객체는 연결된 호스트의 소켓과 주소 정보를 감싸며 비동기 호출에 대한 참조 카운트를 갖는다.

잠시 후에 알아보겠지만, 참조 카운트는 TcpPeer 객체를 안전하게 종료시키는 데 필요하기 때문에 모든 비동기 호출에 대해 반드시 정확하게 유지되어야 한다.

TcpPeer의 생성자를 보면 수신 버퍼의 최대 크기를 명시하도록 되어 있다. 응용 프로그램에 따라 다르겠지만, 보통 일정 크기의 버퍼를 할당해 서버의 성능이나 보안 문제를 일차적으로 처리해 둔다. 생성자의 기본 값이 8K로 되어 있는 것은, 대부분의 응용 프로토콜 크기에 적합하기 때문이다.


 

<리스트 2> TcpPeer의 중요 메쏘드

 

 bool TcpPeer::Send( char *p, int len )
 {
        ... 
        memset( &m_olSend, 0, sizeof( OVERLAPPED ) );
        m_olSend.wsaBuf.buf = m_sendBuf;
        m_olSend.wsaBuf.len = m_sendBufPos;

        DWORD bytesSent;

        if ( WSASend( m_sock, &m_olSend.wsaBuf, 1, &bytesSent, 0, (OVERLAPPED *) &m_olSend, NULL )

                == SOCKET_ERROR )
        {
                if ( WSAGetLastError() != WSA_IO_PENDING )
                        return onError( WSAGetLastError() );
        }

        // WSASend OVERLAPPED 사용 중 상태로 바꾼다.
        m_olSend.inProgress = true;
        // 비동기 요청을 했기 때문에 참조 카운트를 증가시킨다.
        m_refCount++;

        return true;
 }

 bool TcpPeer::Recv()
 {
        ...
        // 수신 버퍼의 위치를 조정한다.
        // TCP의 특성상 쪼개지거나 뭉쳐서 올 수 있기 때문이다.

        memset( &m_olRecv, 0, sizeof( OVERLAPPED ) );

        m_olRecv.wsaBuf.buf = m_recvBuf + m_recvBufPos;
        m_olRecv.wsaBuf.len = m_recvBufMax - m_recvBufPos;

        DWORD bytesReceived;
        DWORD flag = 0;

        if ( WSARecv( m_sock, &m_olRecv.wsaBuf, 1, &bytesReceived, &flag, (OVERLAPPED *) &m_olRecv, NULL )

                == SOCKET_ERROR )
        {
                if ( WSAGetLastError() != WSA_IO_PENDING )
                        return onError( WSAGetLastError() );
        }

        // WSARecv OVERLAPPED 사용 중 상태로 바꾼다.
        m_olRecv.inProgress = true; // 비동기 요청을 했기 때문에 참조 카운트를 증가시킨다.
        m_refCount++;

        return true;
 }

 bool TcpPeer::CloseConnection( bool graceful )
 {
        ...
        // 송신 버퍼를 비운 후 접속을 끊게 한다. 
        m_usrClosing = true;

        // 현재 송신 버퍼가 비어 있는 상태라면 바로 끊는다.
        if ( graceful )
        {
                if ( !m_olSend.inProgress )
                        shutdown( m_sock, SD_SEND );
        }
        else
                shutdown( m_sock, SD_SEND );

        return true;
 }



<리스트 2>는 TcpPeer의 핵심 메쏘드인 Send, Recv, CloseCon nection의 구현부를 보여준다.

Send나 Recv 메쏘드에서 주의깊게 살펴 볼 부분은, OVERLAPPEDEX를 초기화하고 버퍼 포인터를

지정하는 부분과 참조 카운트를 증가시키는 부분이다. 특히 TCP의 특성상 네트워크를 경유하면서

패킷이 쪼개지거나 합쳐질 수 있기 때문에 이러한 처리를 해두는 것은 필수적이다.

Send 메쏘드와 달리 Recv 메쏘드는 엔진 사용자가 직접 호출하지 않는다.

Recv 메쏘드는 엔진 내부에서 직접 호출해 주는 것으로, 엔진 사용자는 자신의 TcpPeer가 언제나 패킷을

수신할 수 있다고 가정하면 된다.

CloseConnection 메쏘드는 이름 그대로 호스트와의 접속을 끊는 데 사용한다. graceful 파라미터를 눈여겨 볼 필요가 있는데, 이 파라미터는 접속을 끊기 전에 송신 버퍼에 있는 패킷들을 모두 전송할 것인지 결정해 준다. 가령 인사말과 함께 클라이언트의 접속을 끊고 싶은 경우에 사용할 수 있다.


다음으로 이 엔진의 중심 객체인 TcpServer를 살펴보자. 윈속 및 IOCP 초기화 등의 작업을 포함해 Dispatcher, Accepter, Connector 객체를 생성, 관리한다. 또한 앞서 가상 함수를 설명하면서 언급했듯

대부분의 중요한 이벤트 통보들이 이 객체를 통해 알려진다.

지난 시간에 IOCP 프로그래밍의 흐름을 간략하게 살펴봤는데, 이번엔 구체적인 코드를 보며 하나하나

따라가 보도록 하자. 먼저 <리스트 3>은 IOCP를 초기화하고 워커 쓰레드를 생성하는 코드를 보여준다.

여기에 있는 Init 메쏘드의 dispatchCycle 파라미터는 Disp atcher가 TcpPeer의 패킷을 처리할 주기를

말한다.

 

엔진의 기본 값은 100인데, 이것은 TcpPeer가 수신한 패킷을 초당 최대 10회까지 처리할 수 있다는 것을

뜻한다. 이 값이 크면 클수록 이어지는 WSARecv 호출의 주기가 늦어지기 때문에, 서버의 성능이 올라가는 대신 응답 속도는 떨어지게 된다. 나머지 파라미터는 지난 시간에 설명한 대로 IOCP를 감시하기 위한

쓰레드의 수를 조절하는 역할을 한다.

<리스트 3> IOCP 초기화 및 워커 쓰레드 생성

 

 bool TcpServer::Init(int dispatchCycle, int numConcurrentThreads, int numWorkers )
 {
        m_iocpHandle = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, numConcurrentThreads );
        if ( !m_iocpHandle )
                return false;

        if ( !InitWorkerThread( numWorkers ) )
                return false;
        ...
 }

 bool TcpServer::InitWorkerThread( int numWorkers )
 {
        ...
        m_numWorkers = numWorkers;
        // 워커 쓰레드를 만든다.
        // Thread 객체는 쓰레드 루틴을 좀 더 다루기 쉽게 랩핑한 클래스다.

        m_workerThreads = new Thread[ numWorkers ];
        if ( !m_workerThreads )
                return false;

        for ( int i = 0; i < numWorkers; i++ )
        {
                if ( !m_workerThreads[i].Create( ThreadEntry, this ) )
                        return false;
        }

        return true;
 }

 


IOCP 초기화를 마치고 워커 쓰레드를 생성했으면 이제는 소켓을 IOCP와 연결시켜 주어야 한다. 이 엔진에서는 클라이언트 소켓 객체를 TcpPeer가 감싸고 있으므로, 인터페이스 측면에서 볼 때 TcpPeer 객체를 IOCP에 연결하면 된다. <리스트 4>는 이러한 코드를 보여주고 있다.

<리스트 4> TcpPeer와 IOCP의 연결

 

 void TcpServer::AssociatePeer( TcpPeer *obj )
 {
        // 10초에 한 번씩, 재전송 주기는 1초에 한 번씩으로 맞춘다.
        tcp_keepalive keepAlive = { TRUE, 10000, 1000 };

        obj->Lock();
        // 킵얼라이브 옵션을 켠다. 
        // 컴파일하려면 mstcpip.h 헤더 파일이 필요하다(platform sdk 참조).

        DWORD tmp;
        WSAIoctl( obj->m_sock, SIO_KEEPALIVE_VALS, &keepAlive, sizeof( keepAlive ), 0, 0, &tmp, NULL, NULL );

        // TcpPeer의 소켓을 IOCP와 연결시킨다.
        CreateIoCompletionPort((HANDLE) obj->m_sock, m_iocpHandle, (DWORD) obj, NULL );
        if ( !obj->Recv() || !obj->OnInitComplete() )
        {
                if ( ClosePeer( obj ) )
                        return;
        }

        obj->Unlock();
 }

 


<리스트 4>에서 한 가지 특이한 코드가 보이는데, 소켓과 IOCP를 연결하기 전에 킵얼라이브(keepalive)를 설정하고 있는 부분이다. 흔히 고스트 클라이언트(ghost client)라고 하여 무응답 상태의 클라이언트를

처리하기 위해 NOP(No OPeration) 패킷을 주기적으로 주고받거나 킵얼라이브 세그먼트를 주고받도록

설정한다. 이 코드를 추가 함으로써 원격 호스트의 파워가 갑자기 나가거나 LAN 선이 뽑히는 비정상적인

연결을 감지해 낼 수 있다. 소켓을 IOCP에 연결시킨 이후에 곧바로 TcpPeer::Recv 메쏘드를 호출하는데, 앞서 설명한 것처럼 Recv 메쏘드는 엔진 내부에서 직접 호출하기 때문에, 엔진 사용자는 언제나 패킷의

수신 가능 상태를 보장받을 수 있다.

<리스트 5> TcpServer의 IOCP 워커 쓰레드

 

 

  void TcpServer::WorkerThread()
 {
        ...
        while ( true )
        { // IOCP의 통보를 기다린다.
                ret = GetQueuedCompletionStatus(m_iocpHandle,&bytesTransferred,(DWORD *) &obj,

                              (OVERLAPPED **) &overlapped, INFINITE );

                if ( !obj || !overlapped )
                        break;

                obj->Lock(); // 비동기 통보가 완료됐으므로 참조 카운트를 감소시킨다.
                obj->m_refCount--;

                if ( !ret || !bytesTransferred || obj->m_sysClosing || !DispatchObject( obj, bytesTransferred,

                            overlapped ) )
                {

                        if ( ClosePeer( obj ) )
                                continue;
                }

                obj->Unlock();
        }
 }

 


IOCP에 관련된 초기 작업을 모두 마쳤다면 이제는 IOCP를 감시할 워커 쓰레드를 살펴 볼 차례다. <리스트 5>에 워커 쓰레드의 루틴이 있다. 여러 쓰레드로부터의 보호를 위해 크리티컬 섹션에 진입한 뒤, 참조 카운트를 감소시키고 완료된 오버랩드 요청(WSASend, WSARecv)을 처리하고 있다.

<리스트 6> 오버랩드 완료 처리

 

 


 bool TcpServer::DispatchObject(TcpPeer *obj, int bytesTransferred, OVERLAPPED *ov )
 {
        TcpPeer::OVERLAPPEDEX *ovex = (TcpPeer::OVERLAPPEDEX *) ov;
        // OVERLAPPEDEX::opcode로 어느 호출인지(WSASend, WSARecv) 구분한다.
        switch ( ovex->opcode )
        {
                case TcpPeer::SEND:
                        DispatchSend( obj, bytesTransferred );
                        break;

                case TcpPeer::RECV:
                        DispatchRecv( obj, bytesTransferred );
                        break;
        }

        return true;
 }

 bool TcpServer::DispatchSend( TcpPeer *obj, int bytesTransferred )
 {
        obj->m_olSend.inProgress = false;
        // TcpPeer의 가상 함수를 호출한다.
        obj->OnSendComplete(obj->m_olSend.wsaBuf.buf, obj->m_olSend.wsaBuf.len );
        // 송신 버퍼의 크기를 조절한다.
        obj->m_sendBufPos -= bytesTransferred;
        memmove( obj->m_sendBuf, &obj->m_sendBuf[ bytesTransferred ], obj->m_sendBufPos ); 
        // 이어서 보낼 패킷이 있는가?
        if ( obj->m_sendBufPos || obj->m_usrClosing )
                obj->Send( NULL, 0 );
    

        return true;
 }


 bool TcpServer::DispatchRecv( TcpPeer *obj, int bytesTransferred )
 {
        obj->m_olRecv.inProgress = false;
        obj->m_recvBufPos += bytesTransferred;
        // 여기서 패킷을 처리하지 않고 TcpServer_Dispatcher에 등록해 처리한다.
        // Dispatcher에 등록하는 것도 일종의 비동기 요청이기 때문에,
        // AddPeer 함수 안에서 TcpPeer의 참조 카운트를 증가시킨다.

        m_dispatcher.AddPeer( obj );

        return true;
 }

 

 


오버랩드 완료 처리를 어떻게 처리하느냐가 중요한데, 먼저 어떤 요청에 대한 결과인지 앞서 준비해 둔 OVERLAPPEDEX의 opcode를 이용해 구분해야 한다.

 

WSASend에 대한 완료라면 TcpPeer에 패킷의 송신이 완료됐음을 알리고(TcpPeer::OnSendComplete), WSARecv에 대한 완료라면 TcpServer_Dispatcher에 등록해 나중에 처리할 수 있도록 한다.

<리스트 6>은 방금 설명한 완료 처리 과정을 보여준다.

TcpPeer의 접속이 끊기거나 다른 비정상적인 이유로 해당 TcpPeer의 완료 통보가 실패로 돌아오는

경우가 있다. 이 때는 TcpPeer를 종료시켜야 하는데 이 때 참조 카운트가 사용된다.

 

다음은 TcpPeer를 종료시키는 코드를 보여준다.

 
 bool TcpServer::ClosePeer( TcpPeer *obj )
 {

       ...
       // 참조 카운트가 있다는 것은 다른 비동기 통보가 남아 있다는 뜻이다.
       // 따라서 지금 삭제하면 안된다.
       if ( obj->m_refCount )
              return false;

       ...

       // TcpServer의 가상 함수를 호출한다.
       onClosePeer( obj );

       return true;
 }

 




지금까지 TcpListener, TcpPeer, TcpServer 객체들이 어떻게 구현되는지 살펴봤다.

이제 엔진 내부에서 사용되는 TcpServer_XXX 객체들에 대해 알아 볼 차례다.

먼저 TcpServer_Dispatcher 객체부터 살펴보자. Dispatcher는 TcpPeer가 수신한 패킷을 처리하기 위한

별도의 쓰레드다. <리스트 6>처럼 TcpPeer가 패킷을 수신하게 되면, 자신을 디스패처(dispatcher)에

등록해 일정 주기로 패킷을 파싱해 처리하도록 한다. <리스트 7>은 디스패처의 쓰레드 루틴을 보여준다.

<리스트 7> TcpServer_Dispatcher

 

 

 void TcpServer_Dispatcher::Dispatcher()
 {
        ...

        while ( true )
        {
                // dispatchCycle 만큼 쉰다.
                if ( WaitForSingleObject(m_close, m_dispatchCycle ) == WAIT_OBJECT_0 )
                        break; 

                Lock();
                // m_listWait가 현재 추가된 TcpPeer의 목록이다.
                m_listWait.swap( m_listProcess );
                Unlock();
                // TcpServer::OnBeginDispatch 가상 함수를 호출한다.
                m_parent->OnBeginDispatch();

                for ( iter = m_listProcess.begin(); iter != m_listProcess.end(); iter++ )
                {
                        obj = *iter;
                        obj->Lock();
                        // Dispatcher의 처리도 오버랩드 호출과 같은 
                        // 비동기 호출이기 때문에 증가시켰던 참조 카운트를 감소시킨다.

                        obj->m_refCount--;
                        // TcpPeer의 수신 버퍼를 처리한 다음 다시 WSARecv 호출을 한다.
                        if ( !obj->ProcessRecvBuffer() || !obj->Recv() )
                        {
                                m_parent->ClosePeer( obj );
                                continue;
                        }

                        obj->Unlock();
                }

                // TcpServer::OnEndDispatch 가상 함수를 호출한다.
                m_parent->OnEndDispatch();

                m_listProcess.clear();
        }
 }

 


TcpServer_Acceptor 객체는 이 글에서 가장 먼저 언급했던 TcpListener를 관리하여 클라이언트의

접속 요청을 처리하기 위한 쓰레드다. 이 쓰레드는 소켓의 이벤트 감지를 위해 WSAEvent Select 계열의

함수를 사용한다. 이들 함수의 자세한 사용법은 각자 알아보기로 하고, 지금은 코드의 내용을 토대로

전체적인 개념만 잡아내도록 하자. <리스트 8>은 Acceptor의 쓰레드 루틴을 보여준다.

<리스트 8> TcpServer_Acceptor

 

 
 void TcpServer_Acceptor::Acceptor()
 {
        ... 
        for ( int i = 2; i < MAXIMUM_WAIT_OBJECTS; i++ )

                events[i] = CreateEvent( NULL, FALSE, FALSE, NULL );

        while ( true )
        {

                // TcpListener를 위한 윈속 이벤트를 대기한다.
                ret = WSAWaitForMultipleEvents(eventCount, events, FALSE, INFINITE, FALSE );

                // 감지된 TcpListener의 접속을 처리한다.
                ProcessWinsockEvent( events, eventCount );
        } 

        for ( i = 2; i < MAXIMUM_WAIT_OBJECTS; i++ )
                CloseHandle( events[i] );
 }

 void TcpServer_Acceptor::AddNewListener( HANDLE *events, int *eventCount )
 {
        ...
        for ( ; iter != m_listListener.end(); iter++ )
        {

                // 새 TcpListener를 등록한다.
                WSAResetEvent( events[ *eventCount ] );
                WSAEventSelect( (*iter)->m_sock, events[ *eventCount ], FD_ACCEPT );

                (*eventCount)++;
        }
 }

 void TcpServer_Acceptor::ProcessWinsockEvent( HANDLE *events, int eventCount )
 {
        ...
        for ( int i = 2; i < eventCount; i++, iter++ )
        {
                listener = *iter;
               

                WSAEnumNetworkEvents( listener->m_sock, events[i], &eventResult );
                if ( !eventResult.lNetworkEvents )
                        continue;

                // 클라이언트의 접속을 수락한다. 
                addrLen = sizeof( addr );
                sock = accept( listener->m_sock, &addr, &addrLen );

                ...

                // TcpServer::OnAccept 가상 함수를 호출한다.
                obj = m_parent->OnAccept( listener, sock );
                if ( !obj )
                {
                        closesocket( sock );
                        continue;
                }

                // 새 접속으로 생성된 TcpPeer를 IOCP와 연결시킨다.
                m_parent->AssociatePeer( obj );
        }
 }

 



마지막으로 TcpServer_Connector 객체는 TcpPeer를 이용해 원격 호스트에 접속을 하기 위한 쓰레드다.

이 쓰레드 역시 소켓의 이벤트 감지를 위해 WSAEventSelect 계열의 함수를 이용하며, 전체 코드 구성이 TcpServer_Acceptor와 유사하다. 지면 관계상 소스는 생략하도록 하겠다.