KoreanFoodie's Study

[C++ 게임 서버] 3-2. 소켓 프로그래밍 기초 #2 본문

Game Dev/Game Server

[C++ 게임 서버] 3-2. 소켓 프로그래밍 기초 #2

GoldGiver 2023. 9. 20. 23:42

Rookiss 님의 '[C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 를 들으며 배운 내용을 정리하고 있습니다. 관심이 있으신 분은 꼭 한 번 들어보시기를 추천합니다!

[C++ 게임 서버] 3-2. 소켓 프로그래밍 기초 #2

핵심 :

1. 소켓 프로그래밍 셋업에는 여러 API 들을 통한 세팅이 필요하다. 하지만 너무 겁먹지 말고, 흐름만 이해하자.

2. 클라이언트 쪽에서는 핸드폰 구입(client소켓 세팅)과 가게의 위치와 번호(서버 IP 주소와 포트)를 알아낸 후, 연결(::connect)을 시도하기만 하면 된다.

3. 서버 쪽에서는 핸드폰 구입(listen 소켓 세팅) 후, 가게의 위치와 번호(서버 IP 주소와 포트)를 설정한 후, 안내원의 폰을 개통(소켓과 서버 주소 ::bind)하고 영업을 시작(::listen)하면 된다.

이제 저번에 이야기했던 개념을 바탕으로, 실제 코드를 보자.

아마 익숙치 않은 코드가 잔뜩 등장을 하게 될 텐데, 하나하나 전부 알아야 한다는 부담을 내려놓고 진행을 하면 된다!

그럼 소켓 프로그래밍 실습을 시작~~~하겠습니다!

 

아참, 프로젝트 구조는 다음과 같다 :

나중에 DummyClient 와 GameServer 를 동시에 시작하려면 아래와 같은 설정을 바꿔주면 된다(프로젝트 > 속성).

 

먼저, DummyClient 라는 클래스를 만들고, 클라이언트 쪽 코드부터 만들어 보자. 일단, 윈도우 소켓을 초기화 한다.

// 윈속 초기화 (ws2_32 라이브러리 초기화)
// 관련 정보가 wsaData에 채워짐
WSAData wsaData;
if (::WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
    return 0;

일단 윈도우 소켓을 다루려면, WSAData 를 초기화 해 주어야 하는데, WSA 는 Window Socket API 의 약자이다. 😀

 

그리고 이제 클라이언트의 핸드폰을, 아니, 소켓을 만들어 주어야 한다.

// ad : Address Family (AF_INET = IPv4, AF_INET6 = IPv6)
// type : TCP(SOCK_STREAM) vs UDP(SOCK_DGRAM)
// protocol : 0
// return : descriptor
SOCKET clientSocket = ::socket(AF_INET, SOCK_STREAM, 0);
if (clientSocket == INVALID_SOCKET)
{
    int32 errCode = ::WSAGetLastError();
    cout << "Socket ErrorCode : " << errCode << endl;
    return 0;
}

위를 보면, ::socket 을 사용하여 clientSocket 을 만들어 주는데... 각각의 인자는 무엇을 의미할까?

일단, 첫 인자는 Address Family 로, IPv4 를 쓸 것인지 IPv6 를 쓸 것인지를 결정한다. 두 번째인 type 은 TCP 를 사용할 것인지, UDP 를 사용할 것인지를 의미하며, 마지막인 protocol 의 경우, 0을 넣어주면 알아서 결정해 준다.

참고로 해당 함수에 대한 문서를 참고하면 더 자세한 정보를 얻을 수 있다.

만약 소켓이 잘못 만들어졌다면, errorCode 를 출력해 준다.

 

그리고.. 이제 목적지를 연결해 주자. 연결을 제대로 하려면 IP 주소와 Port 값이 필요한데, IP주소를 아파트로, Port 를 호수로 생각하면 쉽다 🤣

// 연결할 목적지는? (IP주소 + Port) -> XX 아파트 YY 호
SOCKADDR_IN serverAddr; // IPv4
::memset(&serverAddr, 0, sizeof(serverAddr));

serverAddr.sin_family = AF_INET;

//serverAddr.sin_addr.s_addr = ::inet_addr("127.0.0.1"); << deprecated
::inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr);

serverAddr.sin_port = ::htons(7777); // 80 : HTTP
// host to network short
// Little-Endian vs Big-Endian
// ex) 0x12345678 4바이트 정수
// low [0x78][0x56][0x34][0x12] high < little
// low [0x12][0x34][0x56][0x78] high < big = network

일단, 서버 주소인 serverAddr 를 선언하고, memset 으로 초기화를 해 준다.

그리고 나서, sin_family 를 설정하는데... 아까 IPv4 를 쓴다고 했으니, AF_INET 으로 넣어준다.

그리고 이제 실제 주소에 연결을 해 주어야 하는데, IP 주소의 값은 "127.0.0.1" 로 넣었다. 이 주소는 자기 자신에게 피드백 루프를 계속 주는 주소다. inet_pton 이라는 함수가 생소해 보이기는 한데... 너무 신경 쓰지는 말자 😅

그리고 나서, 서버의 포트를 지정한다. 이때 htons 라는 함수를 이용해, 7777 번 포트를 할당할 것이다.

아, 참고로 htons 는 host to network short 의 약자인데, 자매품으로 htonl 이라는 함수도 있다. 눈치 빠른 사람은 알겠지만... host to network long 의 약자이다 😉

그 아래는 Little-Endian 과 Bin-Endian 설명이 적혀 있는데, 보통 Little-Endian 을 많이 이용한다. 궁금하면 디스어셈블리를 켜 보자.

디버그 > 창 > 메모리를 킨 다음, 주소 검색에서 &i 를 쳐 보면, i 의 값이 위처럼 반대로 잡혀 있는 것을 알 수 있다. 역시.. ㅡMSVC 도 Little-Endian 이었나? 아니, 보통 OS 별로 나뉘니 윈도우가 Little-Endian 일지도.

 

어쨋든, 서버 주소까지 지정을 했으니, 이제 진짜로 연결을 해 보자.

if (::connect(clientSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
{
    int32 errCode = ::WSAGetLastError();
    cout << "Connect ErrorCode : " << errCode << endl;
    return 0;
}

 

크게 어려운 부분은 없을 것이다. 인자는 말했다시피, 정의된 API 를 잘 따라가 주도록 하자.

 

자, 그럼 이제 연결이 되었을 것이다!

// ---------------------------
// 연결 성공! 이제부터 데이터 송수신 가능!

cout << "Connected To Server!" << endl;

while (true)
{
    // TODO

    this_thread::sleep_for(1s);
}

// ---------------------------

// 소켓 리소스 반환
::closesocket(clientSocket);

// 윈속 종료
::WSACleanup();

그럼 while 아래에 우리가 수행할 것들을 넣어주기만 하면 된다.

할 일을 다 했을 경우에는, 소켓 리소스를 반환하고 윈속을 종료해 주면 된다.

 

이제 우리가 배웠던 클라이언트 쪽 코드를 종합하면, 다음과 같은 흐름을 가짊을 알 수 있다 😊

더보기
#include "pch.h"
#include <iostream>

#include <winsock2.h>
#include <mswsock.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")

int main()
{
	int i = 0x12345678;


	// 윈속 초기화 (ws2_32 라이브러리 초기화)
	// 관련 정보가 wsaData에 채워짐
	WSAData wsaData;
	if (::WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
		return 0;

	// ad : Address Family (AF_INET = IPv4, AF_INET6 = IPv6)
	// type : TCP(SOCK_STREAM) vs UDP(SOCK_DGRAM)
	// protocol : 0
	// return : descriptor
	SOCKET clientSocket = ::socket(AF_INET, SOCK_STREAM, 0);
	if (clientSocket == INVALID_SOCKET)
	{
		int32 errCode = ::WSAGetLastError();
		cout << "Socket ErrorCode : " << errCode << endl;
		return 0;
	}

	// 연결할 목적지는? (IP주소 + Port) -> XX 아파트 YY 호
	SOCKADDR_IN serverAddr; // IPv4
	::memset(&serverAddr, 0, sizeof(serverAddr));
	serverAddr.sin_family = AF_INET;
	//serverAddr.sin_addr.s_addr = ::inet_addr("127.0.0.1"); << deprecated
	::inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr);
	serverAddr.sin_port = ::htons(7777); // 80 : HTTP
	// host to network short
	// Little-Endian vs Big-Endian
	// ex) 0x12345678 4바이트 정수
	// low [0x78][0x56][0x34][0x12] high < little
	// low [0x12][0x34][0x56][0x78] high < big = network

	if (::connect(clientSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
	{
		int32 errCode = ::WSAGetLastError();
		cout << "Connect ErrorCode : " << errCode << endl;
		return 0;
	}

	// ---------------------------
	// 연결 성공! 이제부터 데이터 송수신 가능!

	cout << "Connected To Server!" << endl;

	while (true)
	{
		// TODO

		this_thread::sleep_for(1s);
	}

	// ---------------------------

	// 소켓 리소스 반환
	::closesocket(clientSocket);

	// 윈속 종료
	::WSACleanup();
}

 

 

그럼 이제 서버 쪽을 보자. 서버쪽도 사실 클라이언트와 비슷하다.

핸드폰을(리슨 소켓) 먼저 만들고, 건물과 주소를 설정(IP 주소와 포트)해 할당하고, 안내원 핸드폰을 개통(소켓과 주소를 연동)하고, 영업 개시를 알리면(실제로 패킷을 listen) 된다.

우리는 이 흐름대로 코드를 살펴볼 것이다. 😆

 

먼저, WSA 를 초기화하고 소켓을 만든다. 프로토콜은 소켓을 만들 때 지정된다.

// 윈속 초기화 (ws2_32 라이브러리 초기화)
// 관련 정보가 wsaData에 채워짐
WSAData wsaData;
if (::WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
    return 0;

// ad : Address Family (AF_INET = IPv4, AF_INET6 = IPv6)
// type : TCP(SOCK_STREAM) vs UDP(SOCK_DGRAM)
// protocol : 0
// return : descriptor
SOCKET listenSocket = ::socket(AF_INET, SOCK_STREAM, 0);
if (listenSocket == INVALID_SOCKET)
{
    int32 errCode = ::WSAGetLastError();
    cout << "Socket ErrorCode : " << errCode << endl;
    return 0;
}

 

그리고, 주소를 할당한다.

// 나의 주소는? (IP주소 + Port)->XX 아파트 YY 호
SOCKADDR_IN serverAddr; // IPv4
::memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = ::htonl(INADDR_ANY); //< 니가 알아서 해줘
serverAddr.sin_port = ::htons(7777); // 80 : HTTP

이때, ::htonl(INADDR_ANY) 부분이 조금 다른데, 우리가 아까 클라이언트에서는 127.0.0.1 을 지정했으나, 서버에서 하드코딩된 IP 주소만 받는다고 하면 조금 문제가 생길 것이다. 왜냐하면 서버는 다양한 주소와 통신을 해야 할 테니까.

그래서 위 구문을 쓰면, 어떤 주소가 와도 서버가 알아서 통신을 연결해 주게 된다.

 

주소도 할당했겠다, 이제 핸드폰을 개통하고, 영업을 시작하자!

// 안내원 폰 개통! 식당의 대표 번호
if (::bind(listenSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
{
    int32 errCode = ::WSAGetLastError();
    cout << "Bind ErrorCode : " << errCode << endl;
    return 0;
}

// 영업 시작!
if (::listen(listenSocket, 10) == SOCKET_ERROR)
{
    int32 errCode = ::WSAGetLastError();
    cout << "Listen ErrorCode : " << errCode << endl;
    return 0;
}

참고로, listen 의 두 번째 인자는, 최대 몇 개의 고객을 한 번에 응대할지에 대한 갯수라고 보면 된다.

 

그럼 while 문에서 서버가 다음과 같은 일을 처리할 것이다.

while (true)
{
    SOCKADDR_IN clientAddr; // IPv4
    ::memset(&clientAddr, 0, sizeof(clientAddr));
    int32 addrLen = sizeof(clientAddr);

    SOCKET clientSocket = ::accept(listenSocket, (SOCKADDR*)&clientAddr, &addrLen);
    if (clientSocket == INVALID_SOCKET)
    {
        int32 errCode = ::WSAGetLastError();
        cout << "Accept ErrorCode : " << errCode << endl;
        return 0;
    }

    // 손님 입장!
    char ipAddress[16];
    ::inet_ntop(AF_INET, &clientAddr.sin_addr, ipAddress, sizeof(ipAddress));
    cout << "Client Connected! IP = " << ipAddress << endl;

    // TODO
}

// 윈속 종료
::WSACleanup();

음.. 간단하게 살펴보면, 클라이언트 소켓을 인식하고, 클라이언트의 주소를 ipAddress 에 넣고 있다.

사실 clientAddr.sin_addr 를 파 보면, 다음과 같이 정의되어 있다.

//
// IPv4 Internet address
// This is an 'on-wire' format structure.
//
typedef struct in_addr {
        union {
                struct { UCHAR s_b1,s_b2,s_b3,s_b4; } S_un_b;
                struct { USHORT s_w1,s_w2; } S_un_w;
                ULONG S_addr;
        } S_un;
#define s_addr  S_un.S_addr /* can be used for most tcp & ip code */
#define s_host  S_un.S_un_b.s_b2    // host on imp
#define s_net   S_un.S_un_b.s_b1    // network
#define s_imp   S_un.S_un_w.s_w2    // imp
#define s_impno S_un.S_un_b.s_b4    // imp #
#define s_lh    S_un.S_un_b.s_b3    // logical host
} IN_ADDR, *PIN_ADDR, FAR *LPIN_ADDR;

IPv4 주소라 그런지, 8바이트 크기의 union 으로 되어 있는데... IPv4 를 고려해서 ipAddress 를 16 바이트로 선언한 걸까 싶었지만, 사이즈를 char[8] 로 바꾸니까 출력이 깨지는 것으로 보아... 16바이트 Align 으로 메모리가 할당이 되어 뭔가 뻑이 간 것 같다. 주소값을 ipAddress 에 넣고 있으니까. 😄

 

이제 할 일이 다 마친 후, 윈속 종료만 해주면 끝이다. 😎

// 윈속 종료
::WSACleanup();

 

실제로 위 솔루션을 돌리게 되면, 다음과 같이 결과가 제대로 나오는 것을 확인할 수 있다! 😮

Comments