이제 본격적인 네트워크 라이브러리 작성에 들어간다.
공부한 내용을 까먹기 전에 최대한 메모해 보자.
1. 클래스의 필요성
지난 시간까지 소켓에 관해 작성했던 코드들 중 일부 살펴보자.
WSAData wsaData;
if (::WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
return 0;
SOCKET clientSocket = ::socket(AF_INET, SOCK_STREAM, 0);
if (clientSocket == INVALID_SOCKET)
return 0;
u_long on = 1;
if (::ioctlsocket(clientSocket, FIONBIO, &on) == INVALID_SOCKET)
return 0;
SOCKADDR_IN serverAddr;
::memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
::inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr);
serverAddr.sin_port = ::htons(7777);
// Connect
while (true)
{
if (::connect(clientSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
{
// 원래 블록했어야 했는데... 너가 논블로킹으로 하라며?
if (::WSAGetLastError() == WSAEWOULDBLOCK)
continue;
// 이미 연결된 상태라면 break
if (::WSAGetLastError() == WSAEISCONN)
break;
// Error
break;
}
}
이렇게 함수를 쓰고 인자를 일일이 채우고 하는 작업을 계속 직접 타이핑하는 것은 너무 비효율적이다.
이런 번거로움을 클래스로 담아서 간편하게 꺼내쓸 수 있다면 좋지 않을까?
실수를 하게 될 일도 현저히 적어질 것이다.
2. 클래스 작성
2-1. NetAddress
이번엔 2개의 클래스를 작성할 것인데, 소켓에 관련한 것들을 담당할 SocketUtils와,
주소와 관련한 것들을 담당할 NetAddress 클래스이다.
먼저 NetAddress부터 작성해 보자.
#pragma once
// 세션의 주소를 이 클래스로 졍형화해서 관리
class NetAddress
{
public:
NetAddress() = default;
// 2가지 형태로 받을 수 있게 세팅
NetAddress(SOCKADDR_IN sockAddr);
NetAddress(wstring ip, uint16 port);
SOCKADDR_IN& GetSockAddr() { return _sockAddr; }
wstring GetIpAddress();
uint16 GetPort() { return ::ntohs(_sockAddr.sin_port); }
public:
static IN_ADDR Ip2Address(const WCHAR* ip);
private:
SOCKADDR_IN _sockAddr = {};
};
생성자는 2개를 만들었는데, 이는 2가지 형태의 주소 입력에 대응하기 위함이다.
클라이언트의 주소를 확인할 일이 있을 것이므로,
IP와 포트 둘 다, 또는 한쪽씩 가져올 수 있는 함수도 작성한다.
이제 함수 정의를 작성한다.
#include "pch.h"
#include "NetAddress.h"
NetAddress::NetAddress(SOCKADDR_IN sockAddr) : _sockAddr(sockAddr)
{
}
NetAddress::NetAddress(wstring ip, uint16 port)
{
::memset(&_sockAddr, 0, sizeof(_sockAddr));
_sockAddr.sin_family = AF_INET;
_sockAddr.sin_addr = Ip2Address(ip.c_str());
_sockAddr.sin_port = ::htons(port);
}
wstring NetAddress::GetIpAddress()
{
WCHAR buffer[100];
// WCHAR는 2바이트기 때문에 sizeof()로 값을 구하면 200이 돼버린다.
// sizeof(buf) / sizeof(WCHAR)형태로 계속 사용하는 것은 번거롭고 오타가 나올 수도 있기 때문에
// lenN이라는 매크로를 작성해 사용한다.
::InetNtopW(AF_INET, &_sockAddr.sin_addr, buffer, len32(buffer));
return wstring(buffer);
}
IN_ADDR NetAddress::Ip2Address(const WCHAR* ip)
{
IN_ADDR address;
::InetPtonW(AF_INET, ip, &address);
return address;
}
SOCKADDR_IN을 그대로 받을 경우엔 별다른 처리가 필요하지 않다. 바로 넘겨주면 되기 때문.
다른 생성자에선 기존의 방식대로 처리가 필요하다.
GetIpAddress()에선 wstring을 사용하므로 whcar를 사용할 필요가 생겼다.
여기서 wchar는 2바이트임을 조심하여야 한다. sizeof(buffer)의 값은 100이 아니가 200이 나올 것이기 때문에,
이걸 그대로 사이즈로 넘겨주면 문제가 생긴다.
sizeof(buffer) / sizeof(WCHAR)로 문제를 해결할 수 있겠지만, 과정이 번거로워지고 잘못 작성하게 될 수도 있다.
이런 것들은 따로 매크로를 정의하여 사용할 수 있다.
아래와 같이 매크로를 정의했다.
#define size16(val) static_cast<int16>(sizeof(val))
#define size32(val) static_cast<int32>(sizeof(val))
#define len16(arr) static_cast<int16>(sizeof(arr)/sizeof(arr[0]))
#define len32(arr) static_cast<int32>(sizeof(arr)/sizeof(arr[0]))
이 매크로들로 보다 편해지자.
당장 필요한 것들은 다 된거 같으니, 다음 클래스로 넘어가 보자.
2-2. SocketUtils
먼저 우리가 자주 사용하던 socket, connect, bind, accept 등의 작업에 대해 클래스를 작성해 보자.
#pragma once
#include "NetAddress.h"
class SocketUtils
{
public:
static LPFN_CONNECTEX ConnectEx;
static LPFN_DISCONNECTEX DisconnectEx;
static LPFN_ACCEPTEX AcceptEx;
public:
static void Init();
static void Clear();
static bool BindWindowsFunction(SOCKET socket, GUID guid, LPVOID* fn);
static SOCKET CreateSocket();
static bool Bind(SOCKET socket, NetAddress netAddr);
static bool BindAnyAddress(SOCKET socket, uint16 port);
static bool Listen(SOCKET socket, int32 backlog = SOMAXCONN);
static void Close(SOCKET& socket);
};
당장 필요한 건 다 있는 것 같다.
ConnectEx 등을 별도 포인터로 둔 이유는 BindWindowsFunction() 함수를 정의할 때 밝혀진다.
Clear()는 WSACleanup()로 끝난다.
Bind도 NetAddress를 작성할 때 처럼 2가지로 두었다.
bool SocketUtils::Bind(SOCKET socket, NetAddress netAddr)
{
return SOCKET_ERROR != ::bind(socket, reinterpret_cast<const SOCKADDR*>(&netAddr.GetSockAddr()), sizeof(SOCKADDR_IN));
}
bool SocketUtils::BindAnyAddress(SOCKET socket, uint16 port)
{
SOCKADDR_IN myAddress;
myAddress.sin_family = AF_INET;
myAddress.sin_addr.s_addr = ::htonl(INADDR_ANY);
myAddress.sin_port = ::htons(port);
return SOCKET_ERROR != ::bind(socket, reinterpret_cast<const SOCKADDR*>(&myAddress), sizeof(myAddress));
}
과정은 이전과 다를 게 없다.
나머지 Listen과 Close도 아래와 같이 작성했다.
bool SocketUtils::Listen(SOCKET socket, int32 backlog)
{
return SOCKET_ERROR != ::listen(socket, backlog);
}
void SocketUtils::Close(SOCKET& socket)
{
if (socket != INVALID_SOCKET)
::closesocket(socket);
socket = INVALID_SOCKET;
}
2-2-1. BindWindowsFunction
여기가 문제다.
우린 아까 AcceptEx 등의 함수 포인터를 선언했고, 이제 이 포인터에 함수의 주소를 넘겨주어야 한다.
그런데 왜 함수를 직업 실행하지 않고 포인터를 받아서 불러오느냐에 대해 의문스러움이 있었다.
왜 포인터를 미리 얻어와야 하는지에 대해선 아래의 링크를 참고하라.
요약하자면 포인터를 미리 얻어오지 않고 AcceptEx를 직접 호출하는 것은 비용이 많이 들기 때문이다.
효율적인 작업 처리를 위해 포인터를 먼저 받아오는 것이다.
여하튼 포인터를 받아오기 위해서 WSAIoctl()라는 함수를 사용한다.
인자에 대해 100% 이해하지 못해도 시키는대로 하는 것은 간단하다.
bool SocketUtils::BindWindowsFunction(SOCKET socket, GUID guid, LPVOID* fn)
{
DWORD bytes = 0;
// 런타임에서 ConnectEx 등의 함수의 주소를 얻어옴
// 바로 AcceptEx를 호출하는게 아니라 WSAIoctl()를 이용해 포인터를 받아오는 이유는
// 포인터를 미리 얻지 않고 AcceptEx를 직접 호출하는 경우는 비용이 많이 들기 때문이다
// https://stackoverflow.com/questions/4470645/acceptex-without-wsaioctl
return SOCKET_ERROR != ::WSAIoctl(socket, SIO_GET_EXTENSION_FUNCTION_POINTER, &guid, sizeof(guid), fn, sizeof(*fn), OUT & bytes, NULL, NULL);
}
이럴게 함수 포인터들을 받아올 수 있다.
이제 이 함수를 Init()에서 사용할 것이다.
2-2-2. Init
이제 함수 포인터들을 받아와 보자.
void SocketUtils::Init()
{
WSADATA wsaData;
ASSERT_CRASH(::WSAStartup(MAKEWORD(2, 2), OUT &wsaData) == 0);
// 런타임에 주소 얻어오는 API
// reinterpret_cast는 항상 조심해서 사용!
SOCKET dummySocket = CreateSocket();
ASSERT_CRASH(BindWindowsFunction(dummySocket, WSAID_CONNECTEX, reinterpret_cast<LPVOID*>(&ConnectEx)));
ASSERT_CRASH(BindWindowsFunction(dummySocket, WSAID_DISCONNECTEX, reinterpret_cast<LPVOID*>(&DisconnectEx)));
ASSERT_CRASH(BindWindowsFunction(dummySocket, WSAID_ACCEPTEX, reinterpret_cast<LPVOID*>(&AcceptEx)));
Close(dummySocket);
}
WSAStartup은 만약을 위해 ASSERT를 걸었다.
소켓은 더미 소켓을 만들어줘서 넘겨줬다.
GUID로 넘겨주는 매크로를 살펴보면 각 함수를 나타내는 특별한 16진수 값이 아래와 같이 들어있다.
#define WSAID_CONNECTEX \
{0x25a207b9,0xddf3,0x4660,{0x8e,0xe9,0x76,0xe5,0x8c,0x74,0x06,0x3e}}
여하튼 얘네들로 함수를 찾고 포인터를 넘겨주게 된다.
2-2-3. Options
소켓 옵션을 기억하는가?
이 친구들도 계속 사용될 것이기 때문에 클래스에 넣어주어야 한다.
static bool SetLinger(SOCKET socket, uint16 onoff, uint16 linger);
static bool SetReuseAddress(SOCKET socket, bool flag);
static bool SetRecvBufferSize(SOCKET socket, int32 size);
static bool SetSendBufferSize(SOCKET socket, int32 size);
static bool SetTcpNoDelay(SOCKET socket, bool flag); // Nagle Algorithm
static bool SetUpdateAcceptSocket(SOCKET socket, SOCKET listenSocket);
먼저 위와 같이 선언해 준다.
기존의 보던 것들 외에도 SetUpdateAcceptSocket()이라는 옵션도 추가했다.
이 옵션은 Listensocket의 옵션을 Clientsocket에 그대로 적용하기 위해 필요하다.
bool SocketUtils::SetLinger(SOCKET socket, uint16 onoff, uint16 linger)
{
LINGER option;
option.l_onoff = onoff;
option.l_linger = linger;
return SetSockOpt(socket, SOL_SOCKET, SO_LINGER, option);
}
/*
* 옵션 정의...
*/
// ListenSocket의 특성을 ClientSocket에 그대로 적용
bool SocketUtils::SetUpdateAcceptSocket(SOCKET socket, SOCKET listenSocket)
{
return SetSockOpt(socket, SOL_SOCKET, SO_UPDATE_ACCEPT_CONTEXT, listenSocket);
}
위와 같이 내용을 채웠다.
setsockopt()를 일일이 다 입력하기엔 좀 번거로울 수 있으므로 필요한 정보만 받아서 옵션을 적용할 수 있게,
템플릿을 사용해 아래와 같이 SetSockOpt()를 만들었다.
// 자주 사용할 것이기 때문에 템플릿으로 만들어 둠
template<typename T>
static inline bool SetSockOpt(SOCKET socket, int32 level, int32 optName, T optVal)
{
return SOCKET_ERROR != ::setsockopt(socket, level, optName, reinterpret_cast<char*>(&optVal), sizeof(T));
}
3. 서버 작성
일단 클라이언트로부터의 연결이 잘 받아지는지 확인해 보자.
작성한 클래스를 활용해 순식간에 소켓을 만들고 accept까지만 테스트한다.
int main()
{
SOCKET socket = SocketUtils::CreateSocket();
SocketUtils::BindAnyAddress(socket, 7777);
SocketUtils::Listen(socket);
SOCKET clientSocket = ::accept(socket, nullptr, nullptr);
cout << "Client Connected!" << endl;
while (true)
{
}
}
이걸 빌드하고 실행하면...
일단은 연결이 잘 되는 것을 확인할 수 있다.
4. 문제점
우린 아까 ConnectEx 등의 포인터를 받아왔다.
하지만 위의 코드로는 그 코드들이 동작하지 않아서 포인터를 받아오지 못한다.
생각할 수 있는 원인 중 가장 유력한 것은 컴파일러의 판단에 의한 것이다.
컴파일러는 꽤 똑똑해서 사용하지 않을 부분이라면 그 부분에 관한 건 실행시키지 않는다.
일단 포인터를 받아오기 위해 조치를 취하자.
먼저 아래처럼 GThreadManager가 Join()을 호출하도록 한다.
while (true)
{
}
GThreadManager->Join();
CoreGlobal 구조체로 돌아가서 Init()과 Clear()를 호출하도록 한다.
class CoreGlobal
{
public:
CoreGlobal()
{
GThreadManager = new ThreadManager();
GMemory = new Memory();
GDeadLockProfiler = new DeadLockProfiler();
SocketUtils::Init();
}
~CoreGlobal()
{
delete GThreadManager;
delete GMemory;
delete GDeadLockProfiler;
SocketUtils::Clear();
}
} GCoreGlobal;
그리고 다시 빌드한 후, 더미소켓을 Close 하는 부분에 브레이크 포인트를 걸어보자.
제대로 브레이크 포인트가 걸리고 ConnectEx는 정상적으로 주소를 받아서 해당 함수를 가리키고 있다.
이제 우리는 정상적으로 Ex함수들을 호출할 수 있게 되었다.
5. 음...
아직까진 강의 내용에 최대한 충실하여 그를 머리에 넣으려고 하는데,
새로운 게 계속 튀어나오다 보니까 조금 당황하게 되는 것 같다.
그래도 열심히 머리에 넣어보자.
'Study > C++ & C#' 카테고리의 다른 글
[C++] Service (0) | 2023.06.23 |
---|---|
[C++] IOCP Core (0) | 2023.06.21 |
[C++] Completion Port Model (0) | 2023.06.17 |
[C++] Overlapped Model (0) | 2023.06.16 |
[C++] WSAEventSelect Model (0) | 2023.06.14 |