C++ Winsock2, Windows

윤형·2025년 12월 27일

C++ Game Server

목록 보기
9/10
post-thumbnail

서론

본격적인 IOCP 구현을 하기 전에 window에서 소켓과 관련된 여러 라이브러리가 존재한다. 이러한 헤더파일의 타입 객체가 무엇을 뜻하고 어떻게 활용되는지 알아야 수준 높은 서버를 구축할 수 있다.

Widows.h

Windows.h 라이브러리는 윈도우의 많은 기능을 수행할 수 있다. 파일을 만들고 추가하는 작업등이 대표적인데 이러한 기능 말고 우리는 Windows의 HANDLE에 대해서 알아야 한다.

HANDLE

핸들은 커널 오브젝트를 사용하기 위해 필요한 장치라고 생각하면 된다. 우리가 자동차 핸들을 돌리면 실제 바퀴를 우리가 돌리는 것이 아닌 것 처럼. 우리가 커널 공간의 메모리를 직접 참조하지 않고 핸들을 이용해 접근해 처리하는 개념이다.

  • 우리의 프로세스가 직접 커널에 접근하면 위험하기 때문에 HANDEL을 이용해 접근을 하게 되는데 핸들 테이블을 각 프로세스마다 독립적으로 가지게 된다.
  • 그림은 응용프로그램에서 '리소스'에 대한 HANDLE값을 얻는 흐름이다.
  • 응용 프로그램은 커널 부분의 운영체제의 리소스에 직접 접근하지 않고 API 함수를 이용해 핸들값을 받아오게 된다.
HANDLE hIOCP = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
  • IOCP를 생성할때 핸들 값을 받게 되는데 이때 CreateIoCompletionPort함수를 사용하게 된다.
  • HANDLE을 이용해 운영체제 내부의 '리소스'를 사용하는 흐름이다.
  • 핸들값 + 매개변수를 보내서 핸들 테이블에서 그 값을 확인한 후 실제 리소스에 접근해 값을 얻으면 되는 흐름이다.
  • 여기서 핸들 테이블은 각 프로세스마다 독립적으로 가지게 된다.

참고로 HANDLE을 의미하는 자료형은 모두 앞에 H를 붙여서 표현하게 된다.

HCURSOR h_my_cursor = LoadCursor(NULL, IDC_ARROW);
HICON h_my_icon = LoadIcon(NULL, IDI_APPLICATION);
  • 이런식으로 핸들값을 사용하게 된다.

CreateIoCompletionPort()

이 함수는 새 IOCP 객체를 생성하거나 기존 IOCP에 핸들을 연결하는 함수다.

HANDLE WINAPI CreateIoCompletionPort(
  _In_     HANDLE    FileHandle, // [in] 1번: 파일/소켓 
  _In_opt_ HANDLE    ExistingCompletionPort, // [in] 2번: 기존 IOCP 
  _In_     ULONG_PTR CompletionKey, // [in] 3번: 완료키 (사용자 데이터)
  _In_     DWORD     NumberOfConcurrentThreads // [in] 4번: 동시 스레드 수
);
  • 먼저 첫번째 인자로 INVALID_HANDLE_VALUE를 받게 되면 내부에서 이걸 확인한 다음 새로운 IOCP 커널 오브젝트를 생성하게 된다.
  • 두번째 인자로는 기존 IOCP의 핸들을 받게 되는데, 여기서는 IOCP 오브젝트를 새로 만드는 것이기 때문에 NULL로 넣었다.
  • 만약 새로 만드는게 아니라면 두번째 인자에 HANDEL값을 넣게 된다. 그렇게 되면 첫번째 인자는 어떤 소켓을 넣을지 그 소켓의 핸들 즉, (HANDLE)socket이런 형태가 될 것이다.
  • 세번째 인자에는 "이 소켓이 누구인지"를 식별할 수 있는 값이면 된다. * ULONG_PTR은 운영체제의 따라 크기가 가변적으로 바뀌는 포인터이다. 클래스, 구조체, 등등을 다 가르킬 수 있다.
  • DWORD는 32비트로 고정된 Unsigned int이다.
  • 4번째 인자는 0으로 두면 CPU코어 수로 자동으로 설정되는데 그냥 0으로 일단 하는걸로 하고 넘어가겠다.

GetQueuedCompletionStatus()

IOCP 큐에서 완료된 IO작업을 꺼내오는 함수다.

BOOL GetQueuedCompletionStatus(
  [in]  HANDLE       CompletionPort,
        LPDWORD      lpNumberOfBytesTransferred,
  [out] PULONG_PTR   lpCompletionKey,
  [out] LPOVERLAPPED *lpOverlapped,
  [in]  DWORD        dwMilliseconds
);
  • CompletionPort: CreateioCompletionPort로 만들어진 핸들을 연결한다.
  • lpNumberOfBytesTransferrd: 는 이번 I/O 작업에서 실제 전송된 바이트 수를 저장하는 출력 매개변수이다.
  • ipCompletionKey: CreateioCompetionPort에서 등록한 키를 연결한다.
  • lpOverlapped: Overlapped작업이 시작될때 지정한 구조체의 주소의 포인터다.
  • dwMilliseconds: 대기시간. 이 대기시간동안 완료 패킷이 완료 포트에 나타나지 않으면 시간초과로 FALSE처리한다. 보통 INFINITY를 넣는다.

GetQueuedCompletionStatusEx

기존의 함수에서 확장이 된 Ex버전의 함수도 존재한다. 주로 고성능 서버에서 사용된다.

BOOL WINAPI GetQueuedCompletionStatusEx(
  _In_  HANDLE             CompletionPort,
  _Out_ LPOVERLAPPED_ENTRY lpCompletionPortEntries,
  _In_  ULONG              ulCount,
  _Out_ PULONG             ulNumEntriesRemoved,
  _In_  DWORD              dwMilliseconds,
  _In_  BOOL               fAlertable
);
  • 엄청 고성능 서버에서만 사용하니까 대충 배치로 처리한다는 함수가 있다는 사실만 알고 넘어가도록 하자.

winsock2.h

winsock2.h는 window에서 제공하는 소켓 관련 라이브러리 인데 사실상 IOCP의 핵심이라고 해도 과언이 아니다. 이 라이브러리를 좀 알고있어야 게임서버를 구현할 수 있다.

SOCKET

typedef UINT_PTR SOCKET;
SOCKET WSAAPI socket(
  [in] int af,
  [in] int type,
  [in] int protocol
);
  • 여기서 WSAAPI를 표준 규약으로 넣게되면 컴파일러에게 "windows 표준 규약 써"라고 지시하는 것이다.
  • #pragma comment(lib, "ws2_32.lib")로 자동 해결할 수 있다.
  • 먼저 [in] af같은 경우는 주소 패밀리의 사양이다. 이는 어떤 주소 체계를 사용할건지 명시하는 것이다. (ex ipv4, ipv6)
  • 종류는 공식 문서를 확인해보면 더 많이 알 수 있다.
AF의미
AF_INET (2)IPv4 주소 패밀리.
AF_INET6 (23)IPv6 주소 패밀리.
AF_BTH (32)블루투스 주소 패밀리
  • [in] type같은 경우는 소켓의 형식 타입을 의미한다.
type의미
SOCK_STREAM (1)양방향 연결 기반 바이트 스트림, IPv4혹은 IPv6에서 TCP를 사용한다.
SOCK_DGRAM (2)고정된 길이, 데이터그램 지원, IPv4혹은 IPv6에서 UDP를 사용한다.
  • [in] protocol은 사용할 프로토콜을 정의한다.
protocol의미
BTHPROTO_RFCOMM (3)블루투스 프로토콜
IPPROTO_TCP (6)TCP
IPPROTO_UDP (17)UDP

이렇게 socket함수를 이용해 원하는 통신 방법에 대해 소켓을 만들면 된다.

WSA

앞으로 WSA라는 이름이 붙어있는 함수나 변수를 많이 보게 될텐데 여기서 WSA는 Window Socket Api라는 뜻이다.

LP

앞으로 LP라고 붙은 타입들도 보게 될것이다. LP는 Long Pointer의 약자로 이게 붙어있으면 주소를 가르키는 타입이라고 생각하면 된다.

ex) LPWSADATA: 라고 되어있으면 WSA의 정보를 가지고 있는 상자의 주소를 가르키는 구나 라고 생각할 수 있다.

WSAOVERLAPPED (비동기 작업 ID)

typedef struct _WSAOVERLAPPED {
  DWORD    Internal;
  DWORD    InternalHigh;
  DWORD    Offset;
  DWORD    OffsetHigh;
  WSAEVENT hEvent;
} WSAOVERLAPPED, *LPWSAOVERLAPPED;
  • WSAOVERLAPPED는 비동기 IO작업의 식별자로, WSARecv 호출 시 완료 알림을 받기 위한 구조체이다.
  • 사실상 안건드림. 그냥 이걸 WSARecv에 넣어서 결과 확인하면 됨.

WSAStartup() / WSACleanup()

int WSAAPI WSAStartup(
  [in]  WORD      wVersionRequested,
  [out] LPWSADATA lpWSAData
);
  • 이게 WSAStartup의 기본 형태이다.
  • 여기서 WORD는 typedef unsigned short WORD;를 뜻한다.
  • Winsock의 버전 번호를 받는다고 생각하면 된다.
  • LPWSADATA는 다음을 의미한다.
typedef struct _WSADATA {
    WORD            wVersion;
    WORD            wHighVersion;
    char            szDescription[WSADESCRIPTION_LEN+1];
    char            szSystemStatus[WSASYS_STATUS_LEN+1];
    unsigned short  iMaxSockets;
    unsigned short  iMaxUdpDg;
    char FAR*       lpVendorInfo;
} WSADATA, *LPWSADATA;  // LPWSADATA = WSADATA*
  • 이런 구조체를 의미하는데 쉽게 winsock 초기화 정보를 받아주는 빈 상자로 생각하면 된다.
  • mVersionRequested는 호출자가 사용할 수 있는 가장 높은 버전의 Windows 소켓 사양이다. 현재는 2.2버전이 가장 최신이기 때문에 맞춰서
WSADATA wsaData; // 빈 데이터 상자
WORD ver = MAKEWORD(2,2);
int ref = WSAStartup(ver, &wsaData);
  • 이런식으로 버전을 맞춰주면 된다.
  • lpWSAData는 소켓구현의 세부 정보를 받는 포인터이다.
  • WSAStartup()함수는 성공하면 0을 반환하고 아니면 여러 오류중 하나를 반환한다. (공식문서 확인)
int WSAAPI WSACleanup();
  • WSACleanup의 경우 성공하면 0을 반환한다.
  • 그냥 마지막에 작성하면 되고, WSA를 종료하겠다는 의미로 생각하면 된다.

WSASocket()

WSASocket은 socket()함수의 업그레이드 버전으로 IOCP나 고성능 서버를 이용할때 사용한다.

  • WSASocket은 매크로로 WSASocketA/ WSASocketW의 별칭이다. 이렇게 2개로 나눠져있는 이유는 문자 인코딩 방식에있는데 그냥 WSASocket으로 사용하면 될 듯 하다.
SOCKET WSAAPI WSASocket(
  [in] int                 af,
  [in] int                 type,
  [in] int                 protocol,
  [in] LPWSAPROTOCOL_INFOA lpProtocolInfo,
  [in] GROUP               g,
  [in] DWORD               dwFlags
);
  • af는 앞서 설명한거처럼 주소 패밀리 사양이다.
  • type도 앞서 설명한 소켓의 형식 타입이다.
  • protocol도 앞서 socket에서 설명한것과 같다.
  • [in] lpProtocolInfo: 이거는 그냥 Null로 하면 된다. 찾아보니까 null로 설정하면 앞의 3개 인자로 자동 결정이 된다. 대부분의 상황에서는 Null이니까 그냥 넘어가도록 하자.
  • Group g: 이는 같은 그룹의 소켓들끼리 "동일한 설정을 강제"하는 것인데 0으로 하면 개별 소켓으로 처리하는 것이다. 그리고 이것도 대부분의 상황에서는 0이니까 그냥 0이라고 생각하고 넘어가자.
  • dwFlags: 추가 소켓 특성을 지정하는데 사용되는 플레그이다. 이것도 여러가지가 있는데 WSA_FLAG_OVERLAPPED를 사용하면 IOCP를 구현할 수 있고 이것이 IOCP의 핵심이라고 볼 수 있다.

bind, listen, accept

int WSAAPI bind(
  [in] SOCKET         s,
  [in] const sockaddr *name,
  [in] int            namelen
);
  • bind를 시키는 방법은 서버의 소켓과, 그 서버 소켓의 주소를 sockaddr로 캐스팅, 그리고 그 주소의 길이를 인자로 넣으면 된다.
  • 0을 반환하면 성공적으로 된것이다.
int WSAAPI listen(
  [in] SOCKET s,
  [in] int    backlog
);
  • listen은 bind를 하고난 후에 호출한다. (중요)
  • listen을 통해 소켓의 상태를 listening으로 바꾸고 대기 큐를 생성한다.
  • s는 bind할때 사용한 서버의 소켓을 넣는다.
  • backlog는 보류중인 연결 큐의 최대 길이를 의미한다. SOMAXCONN으로 하면 백로그를 적절한 최대값으로 설정한다.
  • 참고로 listen은 TCP통신에서만 사용된다. (UDP는 listen과정이 없음.)
  • listen은 오류 시 SOCKET_ERROR를 반환한다.
SOCKET WSAAPI accept(
  [in]      SOCKET   s,
  [out]     sockaddr *addr,
  [in, out] int      *addrlen
);
  • accept은 bind와 똑같다. 단지 클라이언트의 소켓과, 클라이언트의 주소, 그리고 길이를 입력받는다.
  • accept는 성공하게 되면 새 소켓에 대한 설명인 SOCKET을 반환하게 된다.

recv, recvfrom, WSARecv, WSARecvfrom

int WSAAPI recv(
  [in]  SOCKET s,
  [out] char   *buf,
  [in]  int    len,
  [in]  int    flags
);
  • recv는 연결 지향형(TCP, SOCK_STREAM) 소켓에서 이미 연결된 상대방으로부터 데이터를 받는 것이다.
  • s: 연결된 소켓을 식별한다. -> 여기서는 클라이언트 소켓을 사용한다.
  • buf: 들어오는 데이터를 수신할 버퍼에 대한 포인터이다. 즉 어디에 받을지.
  • len: 그 버퍼의 크기
  • flags: 함수의 동작에 영향을 준다. 보통 0으로 한다.
flags 값의미
MSG_PEEK버퍼에 데이터를 복사하지만 큐에서 제거하지는 않는다.(패킷 검사에 사용)
MSG_OOBOut Of Band의 뜻으로 긴급 메시지를 뜻함. recv큐에 안쌓이고 바로 처리
MSG_WAITALLrecv가 요청한 전체 바이트 수 만큼 데이터를 받을 때 까지 블로킹(UDP에서는 무시됨.)
int WSAAPI recvfrom(
  [in]                SOCKET   s,
  [out]               char     *buf,
  [in]                int      len,
  [in]                int      flags,
  [out]               sockaddr *from,
  [in, out, optional] int      *fromlen
);
  • recvfrom은 비연결 (UDP, SOCKET_DGRAM) 소켓에서 발신자 주소까지 같이 받는 것이다.
  • from: 반환 시 원본 소켓 주소를 의미함. (누구한테 왔는지를 반환)
  • fromlen: from의 크기.
int WSAAPI WSARecv(
  [in]      SOCKET                             s,
  [in, out] LPWSABUF                           lpBuffers,
  [in]      DWORD                              dwBufferCount,
  [out]     LPDWORD                            lpNumberOfBytesRecvd,
  [in, out] LPDWORD                            lpFlags,
  [in]      LPWSAOVERLAPPED                    lpOverlapped,
  [in]      LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
  • s: 마찬가지로 연결된 소켓 (클라이언트)
typedef struct _WSABUF {
  ULONG len;
  CHAR  *buf;
} WSABUF, *LPWSABUF;
  • LPWSABUF타입은 _WSABUF구조체이다.
  • [in, out] lpBuffers: 여러 버퍼들을 받는다. + 버퍼의 길이까지. 참고로 이건 배열이고 주소를 가지고 있어서 0번째 인덱스를 가르키고 있는 것임.
  • dwBufferCount: lpBuffers 배열에 들어있는 WSABUF 구조체의 개수.
  • lpNumberOfButesRecvd: 이번 WSARecv호출로 실제 받은 전체 바이트 수를 반환한다. (총 수신량)
  • 참고로 이 값을 비동기 상태에서 Null로 할 수 있는데 이러면 "바이트 수는 나중에 계산하겠다" 라는 의미이다.
  • lpFlags: recv와 마찬가지 플레그들이 있다. 보통 0으로 설정
  • lpOverlapped: WSAOVERLAPPED 구조체에 대한 포인터이다.
  • lpCompletionRpoutine: 수신작업이 완료되면 호출되는 완료 루틴에 대한 포인터. 중요한거는 IOCP와 같이 사용 불가능하고 Event 모델에서만 사용한다.
int WSAAPI WSARecvFrom(
  [in]      SOCKET                             s,
  [in, out] LPWSABUF                           lpBuffers,
  [in]      DWORD                              dwBufferCount,
  [out]     LPDWORD                            lpNumberOfBytesRecvd,
  [in, out] LPDWORD                            lpFlags,
  [out]     sockaddr                           *lpFrom,
  [in, out] LPINT                              lpFromlen,
  [in]      LPWSAOVERLAPPED                    lpOverlapped,
  [in]      LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
  • UDP는 발신자 정보가 필요하기 때문에 그러한 정보를 추가로 가지고 있다.
  • lpFrom: 발신자 주소
  • lpFromlen: 그 주소 구조체의 크기

send, sendto, WSASend, WSASendto

int WSAAPI send(
  [in] SOCKET     s,
  [in] const char *buf,
  [in] int        len,
  [in] int        flags
);
  • send함수는 연결된 소켓에 데이터를 보내는 역할을 한다. 즉, TCP, SOCK_STREAM 소켓에서 이미 연결된 상대방에게 데이터를 보내는 것이다.
  • s: 연결된 소켓을 식별한다. 즉, 서버는 accept()로 얻은 클라이언트 소켓을 의미하고, 클라이언트에서는 connect()로 서버에 연결된 자신의 소켓을 말함.
  • buf: 전송할 데이터를 포함한 버퍼에 대한 포인터
  • len: 그 버퍼의 사이즈.
  • flags: 보통 0으로 플레그 안씀. (귀찮아서 정리 안하는거 아님.)
int WSAAPI sendto(
  [in] SOCKET         s,
  [in] const char     *buf,
  [in] int            len,
  [in] int            flags,
  [in] const sockaddr *to,
  [in] int            tolen
);
  • sendTo함수는 비연결 (UDP, SOCK_DGRAM) 소켓에서 수신자 주소를 같이 넣어 특정 소켓에 데이터를 보내는 것이다.
  • to: 대상 소켓의 주소를 포함하는 sockaddr에 대한 포인터다. (대상 주소)
  • tolen: 그거의 길이.
int WSAAPI WSASend(
  [in]  SOCKET                             s,
  [in]  LPWSABUF                           lpBuffers,
  [in]  DWORD                              dwBufferCount,
  [out] LPDWORD                            lpNumberOfBytesSent,
  [in]  DWORD                              dwFlags,
  [in]  LPWSAOVERLAPPED                    lpOverlapped,
  [in]  LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
  • 기존 한개만 받던 버퍼에서 LPWSABUF로 여러 버퍼를 받는다. (WSABUF[])
  • lpOverlapped: 이게 추가가 되어서 IOCP 큐에 들어갈 수 있음. (백그라운드 비동기 처리를 함.)
  • lpCompletionRoutine: 콜백 함수 포인터로 작업 완료시 자동으로 호출되는 완료 루틴이다. IOCP와는 같이 사용할 수 없어서 Null로 둔다.
int WSAAPI WSASendTo(
  [in]  SOCKET                             s,
  [in]  LPWSABUF                           lpBuffers,
  [in]  DWORD                              dwBufferCount,
  [out] LPDWORD                            lpNumberOfBytesSent,
  [in]  DWORD                              dwFlags,
  [in]  const sockaddr                     *lpTo,
  [in]  int                                iTolen,
  [in]  LPWSAOVERLAPPED                    lpOverlapped,
  [in]  LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
  • lpTo: SOCKADDR 구조체의 대상 소켓 주소에 대한 선택적 포인터이다. (누구한테 보낼지)
  • iTolen: 그 포인터 의 크기

closesocket()

int WSAAPI closesocket(
  [in] SOCKET s
);
  • 소켓을 안전하게 닫는다.
  • 잘 닫으면 0을 반환하고 에러가 생기면 SOCKET_ERROR를 반환한다.

Mswsock.h

추가로 이런 라이브러리도 있다. 주로 Winsock2.h의 확장된 기능들을 사용하고 있다. 그래서 IOCP를 실질적으로 구현한다고 하면 winsock 으로만 하는게 아니라 Mswsock를 같이 사용해 확장된 기능을 이용한다.

AcceptEx

기존의 accept는 블로킹을 한다. 그래서 연결 수락을 대기하게 되는데 AcceptEx같은 경우는 비동기로 처리가 된다. 그래서 미리 accept소켓을 준비해두고, overlapped IO로 호출을 한다. IOCP에서 완료 호출을 받으면 그 때 나머지 작업을 처리하면 되는 것이다.

BOOL AcceptEx(
  [in]  SOCKET       sListenSocket,
  [in]  SOCKET       sAcceptSocket,
  [in]  PVOID        lpOutputBuffer,
  [in]  DWORD        dwReceiveDataLength,
  [in]  DWORD        dwLocalAddressLength,
  [in]  DWORD        dwRemoteAddressLength,
  [out] LPDWORD      lpdwBytesReceived,
  [in]  LPOVERLAPPED lpOverlapped
);
  • sListenSocket: 여기서는 리슨 소켓을 넣는다. 즉 서버의 소켓을 하면 된다.
  • sAcceptSocket: 미리 만들어진 소켓으로 클라이언트의 소켓과 연결될 소켓을 의미한다.
  • lpOutputBuffer: 기존 accept같은 경우는 SOCKADDR_IN 구조체를 만들어서 이거의 포인터를 통해 대입을 했지만 AcceptEx에서는 char[buffer_size]형태로 대입한다.
  • dwReceivedataLength: 이거는 이제 입력을 받을 데이터의 사이즈를 구하는 건데. 보통 [로컬 주소][원격 주소][데이터] 이런 형태로 만들어지게 된다. 그래서 데이터 부분의 길이는 전체에서 - 로컬 주소 - 원격 주소 가 되는 것이 일반적이다.
  • (중요) 그래서 보통
    sizeof(recvBuffer) - (2 * (sizeof(SOCKADDR_IN) + 16))이렇게 계산된 값이 실제 데이터의 영역이라 이걸 넣는다.
  • dwLocalAddressLength, dwRemoteAddressLength: 이거는 위에서 했던 sizeof(SOCKADDR_IN) + 16을 넣으면 된다.
  • lpdwBytesReceived: 이거는 초기값을 말하는데 그냥 0으로 하면 됨.
  • lpOverlapped: 요청을 처리하는 Overlapped 구조체를 의미한다. 필수로 지정해줘야 한다.(null안됨)

마무리

이렇게 하면 이제 C++ 로 IOCP 서버를 만들 준비가 끝났다. IOCP의 기본 개념도 알았고 C++의 windows.h와 winsock2.h의 주요 함수에 대해서도 알아봤다. 다음시간에는 유니티와 연동하는 서버를 실제로 만들어 보도록 하겠다.

참고자료

window 공식문서 - winsock2.h
window 공식문서 - windows.h
Handle에 대하여 - 김성엽

profile
제가 관심있고 공부하고 싶은걸 정리하는 저만의 노트입니다.

0개의 댓글