1. 들어가며
논블로킹 소켓
0. 들어가며https://9ky0.tistory.com/12 TCP 서버https://9ky0.tistory.com/11 소켓 프로그래밍 기초게임 서버를 본격적으로 공부해 보기 전, 소켓 프로그래밍에 대한 큰 흐름을 우선 알아보고자 한다. 쉽게 이해
9ky0.tistory.com
이전 포스팅에서 블로킹 모드 기반 소켓 통신 코드를 논블로킹 모드 기반으로 변경하였다. 하지만 해당 방식에는 여전히 단점이 아래와 같은 단점이 존재했다.
- EWouldBlock 상태를 검사하기 위한 코드가 군데군데 추가되어 흐름이 난잡해졌다.
- EWouldBlock 상태가 아닐 때까지 무한 while-loop을 돌아야 해 CPU 사이클을 낭비한다.
따라서 논블로킹 모드를 사용하되, 해당 소켓을 사용할 준비가 된 시점을 미리 파악할 수 있어야 유의미한 개선이 있을 것이다. 그리고 이것이 이번에 알아볼 Select 모델의 컨셉이다.
2. Select 모델
여러 소켓 입출력 모델 중 가장 기초가 되는 모델이며, select 함수가 핵심이 되는 모델이라 Select 모델이라고 부른다. 게임 서버는 몇 천 단위의 클라이언트와 동시에 통신해야 하기 때문에 IOCP와 같은 모델이 사용되지만, 클라이언트는 서버와 1:1 통신만 하면 되기 때문에 Select 모델도 많이 사용된다.
앞서 얘기했듯, 이 모델의 컨셉은 '소켓 함수 호출이 성공할 시점을 미리 알 수 있다'다. 지금까지는 OS의 수신 버퍼에 데이터가 없는데 Recv를 시도한다던가, 자신의 송신 버퍼나 상대방의 수신 버퍼에 빈 공간이 없는데 Send를 시도하면 아래와 같이 동작했었다.
- 블로킹 모드에서는 Blocking
- 논블로킹 모드에서는 EWouldBlock이 반환되어 대기
Select 모델을 사용하면 send, recv와 같은 API를 호출할 때 해당 함수를 성공시킬 수 있을지 미리 확인하기 때문에, 블로킹 모드의 Blocking 상황이나 논블로킹 모드의 반복 체크 상황을 미리 방지할 수 있다. 다음과 같이 동작한다.
- 읽기/쓰기/예외 Socket Set을 생성하고, 각각의 Set이 관찰할 Socket을 등록한다.
- select 함수를 호출해 관찰을 시작한다.
- 관찰 대상으로 등록한 Set에서, 적어도 하나의 Socket이 준비되면 리턴한다. 이때 준비되지 않은 소켓은 Set에서 모두 제거된다.
- 나머지 소켓을 확인해 반복한다.
이때 예외 Set은 선택 사항으로, Out Of Band를 체크하는 용도로 사용한다. send 함수의 마지막 인자로 MSG_OOB를 넘기면 동작하며, 수신 측에서도 recv 함수에 OOB 세팅을 해야만 해당 소켓을 식별할 수 있다. 자세한 건 코드를 통해 알아보자.
struct FSession
{
static constexpr int32 BufferSize = 1000;
SOCKET Socket;
char RecvBuffer[BufferSize];
int32 BytesSent;
int32 BytesRecvd;
explicit FSession(SOCKET InSocket = INVALID_SOCKET)
: Socket(InSocket)
, RecvBuffer{}
, BytesSent(0)
, BytesRecvd(0)
{
}
void Clear()
{
BytesRecvd = BytesSent = 0;
}
};
우선 진행하기에 앞서 Session이라는 구조체를 정의한다. 추후 클라이언트가 서버에 접속하게 되면, 이 구조체를 이용해 정보를 관리하게 된다. 즉 이 구조체는 동시 접속한 클라이언트 수, 예를 들어 5000명이 접속했다면 5000개의 세션이 생성된다. 각각의 세션이 클라이언트 소켓과 수신 버퍼 등등을 들고 있는 구조가 되는 것이다.
int main()
{
...
vector<FSession> Sessions;
Sessions.reserve(100);
}
초기화가 완료된 상황에서, 우선 게임 서버가 100명 정도 접속을 받을 수 있다고 가정하자.
https://learn.microsoft.com/ko-kr/windows/win32/api/winsock2/ns-winsock2-fd_set
fd_set(winsock2.h) - Win32 apps
Fd_set 구조체(winsock2.h)는 Windows 소켓(Winsock) 함수 및 서비스 공급자가 소켓을 세트에 배치하는 데 사용됩니다.
learn.microsoft.com
fd_set ReadSet, WriteSet;
우선 우리가 관찰하고 싶은 소켓은 1. 읽기 2. 쓰기 두 종류가 있을 것이다. 그러므로 각각에 대응되는 Set을 생성한다.
while(true)
{
FD_ZERO(ReadSet);
FD_ZERO(WriteSet);
}
소켓 Set을 사용하려면 매 루프마다 Set을 초기화해줘야 한다. 이는 FD_ZERO라는 매크로를 통해 수행한다.
while(true)
{
...
FD_SET(ListenSocket, &ReadSet);
}
서버가 클라이언트를 받기 위해선 ListenSocket을 초기화해야 한다. 따라서 FD_SET 매크로를 이용해 ReadSet에 ListenSocket을 등록한다.
- ListenSocket은 말 그대로 듣는 소켓으로, Accept 할 대상(클라이언트)이 있는지 대기하는 역할을 한다.
- 따라서 ReadSet에 등록한다.
while(true)
{
...
for(FSession& Session : Sessions)
{
if(Session.BytesRecvd <= Session.BytesSent)
{
FD_SET(Session.Socket, &ReadSet);
}
else
{
FD_SET(Session.Socket, &WriteSet);
}
}
}
ListeneSocket을 등록했다면 다음으로 세션의 소켓들을 차례대로 Read 또는 Write Set에 등록한다,
- 클라이언트로부터 받은 데이터 크기가 BytesRecvd, 클라이언트에게 전송한 데이터 크기가 BytesSent를 의미한다.
- 우리는 Echo 서버를 제작하기 때문에 클라이언트로부터 데이터를 다 받았으면, 이를 다시 서버에서 전송해줘야 한다.
- 즉 데이터를 받았음을 의미하는 게 BytesRecvd > BytesSent이므로, 이 부등호 방향에 따라 ReadSet과 WriteSet에 적절히 등록시킨다.
while(true)
{
...
int32 NumReadySocketHandles = ::select(0, ReadSet, WriteSet, nullptr, nullptr);
if (NumReadySocketHandles == SOCKET_ERROR)
{
break;
}
else if (NumReadySocketHandles == 0)
{
// time limit expired
break;
}
...
}
모든 소켓에 대해 SocketSet 등록이 완료되었으므로, select 함수를 호출한다. 인자들을 확인해 보자.
- nfds: 0을 넣어준다. Linux 호환을 위해 존재하는 매개변수이다. Windows에서는 사용하지 않는다.
- *readfds: 읽기 소켓 집합을 넘겨준다.
- *writefds: 쓰기 소켓 집합을 넘겨준다.
- *exceptfds: 에러 소켓 집합을 넘겨준다. 우리는 사용하지 않으니 nullptr을 넘겨준다.
- *timeout: 준비된 소켓이 등장할 때까지 대기하는 최대 시간을 제한하는 옵션이다. nullptr을 넣어주면 하나라도 있을 때까지 무한 대기한다.
만약 관찰 중인 소켓 집합에서 하나라도 준비된 소켓이 있다면 그 수를 반환한다. 만약 반환된 값이 SOCKET_ERROR 거나 0이라면 에러 또는 시간 초과인 것이므로 break 한다.
while(true)
{
...
if(FD_ISSET(ListenSocket, &ReadSet))
{
SOCKADDR_IN ClientAddr;
int AddrLen = sizeof(ClientAddr);
SOCKET Client = ::accept(ListenSocket, reinterpret_cast<sockaddr*>(&ClientAddr), &AddrLen);
printf("Client Connected\n");
Sessions.emplace_back(Client);
}
}
어떤 소켓이 준비된 것은 알 수 있지만, 정확히 어떤 소켓이 준비되었는지는 FD_ISSET 매크로를 통해 확인해야 한다. 따라서 여기서는 ListenSocket이 준비되었는지 확인한 후, 준비되었다면 Client가 connect 요청을 한 것이므로 accept 함수를 호출해서 Client 소켓을 받아들인다.
- 이전에 Select 모델을 사용하기 전에는 Client가 정말로 접속이 되었는지 한 번 더 확인했어야 했다.
- 하지만 Select 모델을 사용함으로써 ISSET을 통해 준비된 것을 보장받는다. 따라서 Client 소켓이 INVALID_SOCKET인지 검사하지 않는다.
while(true)
{
...
for (FSession& Session : Sessions)
{
if (FD_ISSET(Session.Socket, &ReadSet))
{
int32 BytesRecvd = ::recv(Session.Socket, Session.RecvBuffer, FSession::BufferSize, 0);
if (BytesRecvd <= 0)
{
// TODO: 세션에서 제거
continue;
}
Session.BytesRecvd = BytesRecvd;
}
if (FD_ISSET(Session.Socket, &WriteSet))
{
int32 BytesSent = ::send(Session.Socket, &Session.RecvBuffer[Session.BytesSent], Session.BytesRecvd - Session.BytesSent, 0);
if (BytesSent == SOCKET_ERROR)
{
// TODO: 세션에서 제거
continue;
}
Session.BytesSent += BytesSent;
if (Session.BytesRecvd == Session.BytesSent)
{
Session.Clear();
}
}
}
}
이제 나머지 소켓(세션)들에 대해서도 확인한다.
- ReadSet에 포함돼 있다면 recv 함수를 호출한다. 세션의 RecvBuffer 크기만큼 데이터를 수신 시도하고, 실제로 수신받은 데이터 크기를 확인해 Session의 BytesRecvd 크기를 갱신한다.
- WriteSet에 포함돼 있다면 send 함수를 호출한다. 세션의 RecvBuffer에서 전송한 데이터 크기만큼 Offset을 주고, [이전에 수신받은 데이터 전체 크기 - 지금까지 전송한 데이터 크기]만큼 다시 전송을 시도한다. 그 후 실제로 전송한 데이터 크기를 확인해 세션의 BytesSent를 갱신하고, 만약 이 크기가 BytesRecvd와 동일해졌다면 모든 데이터를 전송한 것이므로 Clear 한다.

3. 마무리
위와 같이 간단하게 구현할 수 있는 점이 Select 모델의 장점이다. 다만 소켓 집합으로 표현되는 fd_set 구조체는 한 번에 64개의 클라이언트만 관찰할 수 있는 한계가 존재한다. 그래서 64개가 넘는 클라이언트를 등록해야 한다면, 그 수에 비례해 소켓 집합을 여러 개 만들어 등록 및 확인 코드를 반복해야 하는 단점이 존재한다. 또한 매 루프마다 소켓 집합을 FD_ZERO를 통해 초기화해줘야 하는 것 또한 단점이다.
이러한 단점을 개선해서, Windows에서 WSAEventSelect이란 모델을 제공한다. 기회가 된다면 해당 모델에 대한 내용도 한 번 다뤄보겠다.
https://learn.microsoft.com/ko-kr/windows/win32/api/winsock2/nf-winsock2-wsaeventselect
WSAEventSelect 함수(winsock2.h) - Win32 apps
WSAEventSelect 함수는 지정된 FD_XXX 네트워크 이벤트 집합과 연결할 이벤트 개체를 지정합니다.
learn.microsoft.com
'Game Development > Server' 카테고리의 다른 글
| IOCP(Input/Output Completion Port) 모델 (1) | 2025.09.23 |
|---|---|
| Overlapped 모델 (0) | 2025.09.22 |
| 논블로킹 소켓 (6) | 2025.08.14 |
| TCP 서버 (2) | 2025.07.28 |
| 소켓 프로그래밍 기초 (5) | 2025.07.22 |