TCP 게임 서버 - TCP geim seobeo

�ȳ��ϼ���!

����Ͽ� ��Ʈ��ũ ������ ������մϴ�

���� �������� �������̶� ���� ��ġ�� �ϸ鼭 TCP�� UDP�� ���� �� �˾ƺý��ϴ�.

���� �Ϻ��� ���� ������ �����Ҷ��� TCP�� ������󱸿�, ���� ���� �Ѿ�� �����̴ٺ��� �Ϻ��� ����ȭ���ٴ�

�ϸ��� ������ ���� �ൿ�� �ߴ��� ijġ�� ��Ȯ�� �ϱ� �����ΰ� �����󱸿�(�³���?..)

�׷��� ���� �������ϴ� ������ ������ ��ġ���ؼ� ���� �ξ�����,

�ǽð����� �ٴٸ��� ����ϰ� ������ �ϴ°̴ϴ�.

������ �ƴ϶�, �ǽð����� �������ϴ°��� �ٷιٷ� �ݿ��Ǿ��ϱ� ������ UDP�� ����ϴ� ���� �³���?

TCP�� �̿��� ��� ��Ŷ�� ������ �ȵǸ� ��� ������Ű�� ������ �ǽð����� ���� ��Ȳ�� �ݿ��� �ȵǴ� �ɰ���

������ ����˴ϴٸ�..

�׷����� �� UDP�� ��Ŷ �սǷ��� �ִٰ��ؼ�...  ���࿡ �÷��̾ � Ű�� �Է������� �׿� �´�

�ִϸ��̼��̳� ���� ��Ȳ�� �ﰢ �ݿ��Ǿ���ϴµ�, UDP�� ������ ����� Ű�� �Է��� ��Ŷ�� �սǵ� ���

������ ����� ������ �ȵ� Ȯ���� �ִ� ���ݾƿ�?..

�׷��� �߿��� �κ�(�ൿ�Է�, ������ȹ�� ��)�� TCP�� �����ϰ�,  �Ϲ� ��ǰ��� �κ��� UDP�� ��� 

�����ϴ°� �³���?

��Ŷ �ս��� �Ǹ� �ʵǴ� �߿��� �������� ���ӿ��� �ʼ������� �ֱ� �����ε�, UDP�� ���� ������ ���ӵ� �ֳ���?

�׸���.. ���������� ���� ������� ������ p2p������� �����ص��dz���? �������� ��Ī�Ǹ� �Ѹ���

������ �̿��ϴ¹���̿�, ����Ͽ��� p2p�� �ſ� ��ȿ�����̶�����ϴµ� �� ���ذ� �Ȱ�������  

���� ���� ���� ������ ��Ƽ� ��Ȯ�� �� �𸣁ٳ׿� �Ф�

�����̳� �������ֽø� �����ϰڽ��ϴ�.

시작하기 전에

  • 본 글은 배현직 저자님의 게임 서버 프로그래밍 교과서를 읽고 썼습니다.

  • 본 글은 저자님의 요청으로 언제든지 지워질 수 있습니다.

목차

  1. 게임 서버에서의 소켓 프로그래밍

  2. 블로킹 소켓

  3. 논블로킹 소켓

  4. Overlapped I/O 혹은 비동기 I/O

  5. epoll

  6. IOCP

  7. 실습

1. 게임 서버에서의 소켓 프로그래밍

 게임 서버는 다뤄야 하는 소켓 개수가 많다. TCP를 이용하는 경우 클라이언트 개수만큼 소켓이 있어야 한다. 그러면서도 파일 핸들을 하는 동안 스레드가 대기하는 일이 없어야 하기 때문에 보통 비동기 입출력 상태로 다루게 된다. 여기엔 논블로킹 소켓(non-blocking socket), Overlapped I/O, epoll, IOCP(I/O Completion Port)가 있다. 간단한 것부터 살펴본다.

2. 블로킹 소켓(Blocking Socket)

 블로킹은 디바이스에 처리 요청을 걸어 놓고 응답을 대기하는 함수를 호출할 때 스레드에서 발생하는 대기 현상을 말한다. 이 현상이 발생하는 이유는 소켓 각각이 송신 버퍼(send buffer)와 수신 버퍼(receive buffer)를 가지기 때문이다. 이 버퍼는 FIFO 형태로 작동하는 바이트 배열인데, 이 버퍼가 가득차서 송신이나 수신할 수 없을 때, 블로킹이 발생하는 것이다.  TCP로 연결하는 방법은 다음과 같다.

  1. socket()으로 TCP 소켓 핸들을 생성한다.

  2. bind()로 포트 바인딩을 한다.

  3. connect()로 서버를 연결한다.

  4. send()로 데이터를 전송한다.

  5. close()로 소켓을 닫고 연결을 해제한다.

 연결을 받는 방법은 다음과 같다.

  1. socket()으로 TCP 소켓 핸들을 생성한다.

  2. bind()로 포트 바인딩을 한다.

  3. listen()으로 연결을 대기한다.

  4. accept()로 연결된 클라이언트와 통신하기 위한 소켓 핸들을 가져온다.

  5. recv()로 데이터를 수신한다. 수신할 수 없으면 블로킹한다. 이 함수는 오류가 발생하면 음수를, 연결 해제면 0을 반환한다.

  6. close()로 소켓을 닫고 연결을 해제한다.

 블로킹 모드의 문제점은 네트워킹의 대상이 여럿이라면, 각각의 소켓들에 대해 블로킹이 걸려 자원이 낭비되는 점이다. 이 때문에 논블로킹 모드를 사용한다.

3. 논블로킹 소켓(Non-blocking Socket)

 소켓을 논블로킹 모드로 전환할 수 있다. 논블로킹 모드가 되면 소켓 함수들이 즉시 반환을 하는데, 반환값은 would block 혹은 성공 중 하나이다. would block의 의미는 블로킹이 걸렸어야 하는 상태를 말한다. 논블로킹 모드일 때는 connect()를 주의하여 사용하여야 한다.

 논블로킹 모드에서 connect()를 호출했을 때, would block이 반환되었다면, 연결 과정이 진행중인 상태라는 것을 의미한다. 그래서 제대로 연결이 되었는지 확인해야 한다. 이 때는 0바이트 송신을 이용할 수 있다. 0바이트 송신을 했을 때, 성공이 반환되면 연결됐다는 것을, ENOTCONN이 반환되면 연결 진행 중임을, 기타 오류 코드가 나오면 연결 시도가 실패했다는 것을 의미한다.

 논블로킹 모드가 되어 한 스레드에서 여러 소켓을 다룰 수 있게 되었지만, 그렇다고 자원이 낭비되지 않는 것은 아니다. 각 소켓을 돌면서 해당 소켓이 성공인지, would block인지, 실패인지에 따라 쉴새없이 처리해야 하므로 CPU가 바쁜(busy) 상태로 들어서게 된다. 서버는 다른 일을 할 수 있는 여유를 두기 위해 자원을 확보해두고 있어야 한다. 따라서, 이는 피해야할 상황이다. 

 이를 해결하기 위해 select()poll()이 있다. 이 두 함수는 최대 대기 시간 혹은 소켓 리스트 중 하나라도 I/O 처리를 할 수 있는 것이 생기는 순간까지 블로킹하여 CPU 폭주 상태를 방지하고, 블로킹이 끝나면 어떤 소켓이 I/O 처리를 할 수 있는지 알려준다. 이러한 경우를 I/O 가능 이벤트(I/O available event)가 왔다고 하거나 I/O 가능이라고 한다.

 논블로킹 소켓에서는 accept() 처리에 유의해야 한다. 리스닝 소켓이 논블록인 경우 TCP 연결이 들어오지 않았다면 블로킹 대신 would block을 반환한다. 그래서 이 경우 select()로 I/O 가능한 소켓에 대해 accept()를 호출하면 들어온 TCP 연결에 대한 소켓 핸들을 얻을 수 있다.

 정리하자면, 논블로킹 소켓은 상태를 확인하고 나서 무언가를 하게 된다. 이를 가리켜 리액터 패턴(reactor pattern)이라고 한다. 논블로킹 소켓은 리눅스, FreeBSD, iOS, 안드로이드 등에서 주로 사용한다. 윈도우즈에서도 사용할 수 있지만 대세는 아니다.

 논블로킹 소켓은 다음과 같은 장, 단점이 있다.

  • 장점

    • 스레드 블로킹이 없으므로 중도 취소 같은 통제가 가능하다.

    • 스레드 개수가 1개이거나 적다고 해도 소켓을 여러 개 다룰 수 있다.

    • 스레드 개수가 적거나 1개이므로 CPU 연산량과 호출 스택 메모리가 낭비되지 않는다.

  • 단점

    • 소켓 I/O 함수가 리턴한 코드가 would block인 경우 재시도 호출 낭비가 발생한다.

      • sendTo() 호출시, 보내려는 데이터가 송신 버퍼에 비해 많으면, UDP는 데이터를 일부만 보낼 수 없으므로 would block이 반환된다. 이는 CPU 낭비로 이어진다.

    • 소켓 I/O 함수를 호출할 때 입력하는 데이터 블록에 대한 복사 연산이 발생한다.

      • 송신시, 사용자 프로세스 →  송신 버퍼로

      • 수신시, 수신 버퍼 →  사용자 프로세스로

    • connect()는 재시도 호출을 하지 않지만, send(), receive()는 재시도 호출하는 것처럼, API가 일관되지 않는다.

 이런 단점을 해결할 수 있는 기법이 Overlapped I/O 혹은 비동기 I/O이다.

4. Overlapped I/O 혹은 비동기 I/O

 Overlapped I/O는 소켓에 대해 Overlapped 액세스를 건 후, Overlapped 액세스가 성공했는지 확인한다. 그리고, 성공했으면 결과값을 얻어 와서 나머지를 처리하는 과정을 겪는다.

 Overlapped I/O는 재시도용 호출이 없고, 데이터 블록에 대한 복사 연산이 없어 성능상 유리하지만, OS가 해당 I/O 실행을 동시에 백그라운드에서 별도로 진행한다. 그래서 사용하려면, 백그라운드에서의 액세스를 간과해서는 안된다. Overlapped I/O 전용 함수가 비동기로 하는 일이 완료될 때까지는 소켓 API 인자로 넘긴 데이터 블록을 제거하거나 내용을 변경해서는 안된다. 완료 여부는 전용 구조체로 알 수 있다. 구조체 또한 백그라운드에서 액세스 된다.

 Overlapped I/O의 장, 단점을 정리하면 다음과 같다.

  • 장점

    • 소켓 I/O 함수 호출의 반환값이 would block인 경우 재시도용 호출 낭비가 없다.

    • 소켓 I/O 함수를 호출할 때, 입력된 데이터 블록에 대한 복사 연산이 필요 없다.

    • send, receive, connect, accept 함수를 한 번 호출하면 이에 대한 완료 신호는 딱 한 번만 오기 때문에 프로그래밍 결과물이 깔끔하다.

    • IOCP와 조합하면 최고 성능의 서버를 개발할 수 있다.

  • 단점

    • 완료되기 전까지 status 객체가 데이터 블록을 중간에 훼손하지 말아야 한다.

    • 윈도우즈에서만 제공한다

    • accept, connect 함수 계열의 초기화가 복잡하다.

 덧붙여, 논블로킹 소켓과 다르게 Overlapped I/O는 행동을 한 후에 결과를 확인함을 볼 수 있다. 이를 프로액터 패턴(proactor pattern)이라고 한다. 또, 논블로킹 소켓에서 I/O 가능이라고 했던 것처럼 I/O 실행(operation) 중 혹은 I/O 완료 대기 중이라고 표현한다.

5. epoll

 다른 운영체제에서도 Overlapped I/O처럼 비슷한 기능을 하는 객체가 있다. 리눅스, 안드로이드는 epoll, iOS, MacOS, FreeBSD에서는 kqueue가 있다. 둘 다, 어떤 소켓이 I/O 가능 상태가 되면 이를 감지해 사용자에게 알려준다.

 그러면, ‘epoll도 Overlapped I/O랑 비슷하게 사용하면 되겠구나’ 싶겠지만, 현실은 그렇지 않다. 현실에서는 I/O 가능 상태일 때가 많기 때문에 큰 성능 향상을 기대할 수 없다. 그래서, 이를 해결하기 위해 레벨 트리거(level trigger) 대신 엣지 트리거(edge trigger)를 사용한다.

 레벨 트리거와 엣지 트리거는 epoll의 작동 방식이다. 레벨 트리거는 epoll의 기본 작동 방식으로 특정 준위(상태)가 유지되는 동안 감지하는 것이고, 엣지 트리거는 특정 준위가 변화하는 시점을 감지하는 것이다. 예를 들어 디지털 신호 0000111000111000111에서 1에 대한 트리거라면 레벨 트리거는 1이 유지되는 시간동안 횟수에 상관없이 발생하고, 엣지 트리거는 0에서 1로 변하는 시점에서만 발생한다. 다시 말해 이 경우 엣지 트리거는 3회 발생한다.

 엣지 트리거를 사용하면 루프 횟수를 줄일 수 있으나, 2개 이상의 데이터가 있을 시 엣지의 변화가 없어 꺼낸 데이터를 제외한 나머지 데이터를 영원히 꺼내지 못할 수도 있다. 따라서 이를 예방하려면 I/O 호출을 한번만 하지말고 would block이 발생할 때까지 반복해야 한다.

 epoll은 connect()와 accept()에 대해서도  I/O 가능 이벤트를 받을 수 있다. connet()는 send와 accept()는 receive와 동일한 이벤트로 취급된다.

6. IOCP

 epoll과 비슷하게 윈도우즈에서도 대량의 논블로킹 소켓을 효율적으로 처리할 수 있는 기능이 있다. 그것이 바로 I/O Complete Port 혹은 IOCP이다. 이는 소켓의 Overlapped I/O가 완료되면 이를 감지해서 사용자에게 알려주는 역할을 한다. 사용자는 완료 신호(completion event)를 받을 수 있어, 수천개의 소켓이 있더라도 I/O가 완료된 것들만 IOCP를 사용해 바로 얻을 수 있기 때문에 모든 소켓에서 루프를 돌지 않아도 된다.

 IOCP의 사용법은 epoll과 비슷하나 조금 복잡하다. 특히 accept()를 할 때 그렇다. 살펴보자면, (1) IOCP에 리스닝 소켓을 추가할 때, AcceptEx()로 Overlapped I/O를 건다. (2) 그 소켓이 TCP 연결을 받을 경우 이에 대한 완료 신호가 IOCP에 추가된다. (3) Overlapped accept()처럼 SO_UPDATE_ACCEPT_CONTEXT와 관련된 처리를 한 후 새 TCP 소켓 핸들을 얻는다.

 IOCP와 epoll를 비교하자면 다음과 같다.

구분

IOCP

epoll

블로킹을 없애는 수단

Overlapped I/O

논블록 소켓

블로킹 없는 처리 순서

1. Overlapped I/O를 건다.

2. 완료 신호를 꺼낸다.

3. 완료 신호에 대한 나머지 처리를 한다.

4. 끝나고 나서 다시 Overlapped I/O를 건다.

1. I/O 이벤트를 꺼낸다.

2. 꺼낸 이벤트에 대응하는 소켓에 대한 논블록 I/O를 실행한다.

지원 플랫폼

윈도우즈

리눅스, 안드로이드

 epoll이 I/O 가능 이벤트를 알려주는 것에 비해, IOCP는 I/O 완료 이벤트를 알려주는 점을 눈여겨 보자. IOCP는 epoll보다 사용법이 복잡하긴 해도 커널 함수 호출이 적고, 연결된 소켓의 끝점 정보를 얻는 것도 같이 끝낼 수 있어 최적화에 유리하기 때문에 성능상 우위에 있다.

 또, IOCP는 스레드 풀링을 쉽게 구현할 수 있다. epoll은 한 epoll에 대해 여러 스레드가 동시에 이벤트 발생을 기다리면, 데이터 순서를 알기 어렵고, 순서를 알아도 여러 스레드가 동시에 같은 일을 한다면 처리 순서 로직까지 작성하여 주어야 한다. 하지만 IOCP에서는 어떤 소켓에 대해 Overlapped I/O를 하지 않는 이상 그 소켓에 대한 완료 신호가 전혀 발생하지 않기 때문에 소켓 하나에 대한 완료 신호를 스레드 하나만 처리할 수 있게 보장할 수 있다. 그래서 이 성질을 이용하면 많은 소켓에 대한 I/O 처리를 동시다발적으로 수행할 때, 여러 스레드가 완료 신호 처리를 골고루 나눠서 처리할 수 있다. 이는 대규모 동시접속자 게임 서버를 만들고자 멀티코어를 모두 활용해야 할 때 유리하다.

7. 실습

 간단하게 TCP 통신을 하는 서버와 클라이언트를 프로그래밍 해보았다. 해당 코드는 윈도우즈만을 고려하였으며, 추후 리눅스 지원과 다른 기법은 //github.com/ChoiSeonMun/SocketPractice 에 업로드할 예정이다.

TCP 서버

#defineWIN32_LEAN_AND_MEAN

#include<winsock2.h>

#include<windows.h>

#include<Ws2tcpip.h>

#include<iostream>

#include <string>

#pragmacomment(lib, "Ws2_32.lib")

voidPrintError(const char* msg);

voidCleanup(SOCKET sock);

template <typenameT1, typenameT2>

constexpr void PrintErrorAndCleanup(T1 msg, T2 sock);

intmain()

{

    // Init

    WSADATA w;

    WSAStartup(MAKEWORD(2, 2), &w);

    // Create a tcp socket

    SOCKET listenSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

    // Make a endpoint

    sockaddr_in myEndpoint = { 0 };

    myEndpoint.sin_family = AF_INET;

    inet_pton(AF_INET, "127.0.0.1", &myEndpoint.sin_addr);

    myEndpoint.sin_port = htons(27015);

    // Port binding

    if (bind(listenSocket, (sockaddr*)&myEndpoint, sizeof(myEndpoint)) == SOCKET_ERROR)

    {

        PrintErrorAndCleanup("Bind Error : ", listenSocket);

        return 1;

    }

    // Listen

    listen(listenSocket, 5000);

    // Accept

    SOCKET clientSocket;

    clientSocket = accept(listenSocket, NULL, 0);

    if (clientSocket == SOCKET_ERROR)

    {

        PrintErrorAndCleanup("Accept Error : ", listenSocket);

        return 2;

    }

    // Get the endpoint of the client

    sockaddr_in clientEndpoint = { 0 };

    clientEndpoint.sin_family = AF_INET;

    socklen_t sockLength = sizeof(clientEndpoint);

    if (getpeername(clientSocket, (sockaddr*)&clientEndpoint, &sockLength) == SOCKET_ERROR)

    {

        PrintErrorAndCleanup("GetPeerName Error : ", listenSocket);

        closesocket(clientSocket);

        return 3;

    }

    if (sockLength > sizeof(clientEndpoint))

    {

        PrintErrorAndCleanup("GetPeerName Buffer Overrun : ", listenSocket);

        closesocket(clientSocket);

        return 4;

    }

    char addrString[1000] = { 0 };

    inet_ntop(AF_INET, &clientEndpoint.sin_addr, addrString, sizeof(addrString) - 1);

    char finalString[1000] = { 0 };

    sprintf_s(finalString, "%s:%d", addrString, htons(clientEndpoint.sin_port));

    std::cout << "Socket From " << finalString << " is accepted\n";

    // Receive the data

    char receivedBuffer[1000] = { 0 };

    while (true)

    {

        std::string receivedData;

        std::cout << "Receiving data...\n";

        int recvResult = recv(clientSocket, receivedBuffer, sizeof(receivedBuffer), 0);

        if (recvResult == 0)

        {

            std::cout << "Connection closed.\n";

            break;

        }

        else if (recvResult == SOCKET_ERROR)

        {

            PrintError("Connect lost : ");

        }

        std::cout << "Received: " << receivedBuffer << std::endl;

    }

    closesocket(clientSocket);

    Cleanup(listenSocket);

}

voidPrintError(const char* msg)

{

    std::cout << msg << WSAGetLastError() << "\n";

}

voidCleanup(SOCKET sock)

{

    closesocket(sock);

    WSACleanup();

}

template<typenameT1, typenameT2>

constexpr void PrintErrorAndCleanup(T1 msg, T2  sock) { PrintError(msg); Cleanup(sock); }

TCP 클라이언트

#defineWIN32_LEAN_AND_MEAN

#include<WinSock2.h>

#include<Windows.h>

#include<WS2tcpip.h>

#include<iostream>

#include <cstring>

#pragmacomment(lib, "Ws2_32.lib")

voidPrintError(const char* msg);

voidCleanup(SOCKET sock);

template <typenameT1, typenameT2>

constexpr void PrintErrorAndCleanup(T1 msg, T2 sock);

intmain()

{

    // Init

    WSADATA w;

    WSAStartup(MAKEWORD(2, 2), &w);

    // Create a tcp socket

    SOCKET mySocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

    // Make a endpoint

    sockaddr_in myEndpoint = { 0 };

    myEndpoint.sin_family = AF_INET;

    // Port binding

    if (bind(mySocket, (sockaddr*)&myEndpoint, sizeof(myEndpoint)) == SOCKET_ERROR)

    {

        PrintErrorAndCleanup("Bind Error : ", mySocket);

        return 1;

    }

    // Connect the server

    std::cout << "Connecting to the server...\n";

    sockaddr_in serverEndpoint = { 0 };

    serverEndpoint.sin_family = AF_INET;

    inet_pton(AF_INET, "127.0.0.1", &serverEndpoint.sin_addr);

    serverEndpoint.sin_port = htons(27015);

    if (connect(mySocket, (sockaddr*)&serverEndpoint, sizeof(serverEndpoint)) == SOCKET_ERROR)

    {

        PrintErrorAndCleanup("Connect Error : ", mySocket);

        return 2;

    }

    // Send the data

    std::cout << "Sending data...\n";

    const char* text = "Hello";

    send(mySocket, text, strlen(text) + 1, 0);

    Sleep(1); // make this thread blocked so that the server will be completely received the data

    closesocket(mySocket);

}

voidPrintError(const char* msg)

{

    std::cout << msg << WSAGetLastError() << "\n";

}

voidCleanup(SOCKET sock)

{

    closesocket(sock);

    WSACleanup();

}

template<typenameT1,typenameT2>

constexpr void PrintErrorAndCleanup(T1 msg, T2  sock) { PrintError(msg); Cleanup(sock); }

실행결과

Toplist

최신 우편물

태그