C++ 게임 서버를 개발하기 위해서는 소켓 프로그래밍(Socket Programming)에 대한 이해가 필수적입니다. 그리고 소켓 프로그래밍을 제대로 다루기 위해서는, 그 기반이 되는 네트워크(Network)와 운영체제(OS)에 대한 기본적인 지식이 선행되어야 합니다.
두번 째 글의 목표는 Socket Model 중 IOCP에 대한 정확한 이해입니다. IOCP 또한 이번에 배울 Select Model에 기반이 되기 때문입니다. 꼭 짚기 넘어가야 합니다.
논외로 글을 원활하게 이해하기 위해서는 TCP / UDP의 기본 개념과 주요 Socket Option에 대한 사전 지식이 필요합니다. 해당 내용이 아직 익숙하지 않다면, 먼저 제가 작성한 Network 관련 글을 참고하신 후 읽어주시기 바랍니다.
(이 글은 여러 기술 문서와 교재 등 다양한 자료를 참고하였습니다)
Select Model이란? select() 함수가 핵심 역할을 한다는 뜻에 붙인 이름이며, Select Model을 사용하면 Socket Mode와 관계없이 여러 Socket을 한 Thread로 처리할 수 있습니다.
그러면 select() 함수는 무슨 역할을 할까요? select() 함수는 “지금 처리해도 안전한 소켓을 필터링해서 알려주는 함수”입니다.
즉
Blocking Socket의 경우 소켓 함수 호출 시 조건을 만족하지 않아 Blocking되는 상황을 막을 수 있습니다.
Non-Blocking Socket의 경우 소켓 함수 호출 시 조건을 만족하지 않아 나중에 다시 호출해야 하는 상황을 막을 수 있습니다.
Select Model을 사용하려면 세 개의 Socket Set을 준비해야 합니다. Socket Set은 소켓의 집합으로, 호출할 함수의 종류에 따라 Socket을 담아두는 역할을 합니다.
예를 들면, 어떤 소켓에 대해
recv()함수를 호출하고 싶다면 read set에 넣고,send()함수를 호출하고 싶다면 wrtie set에 넣으면 됩니다.
Socket Set은 각각 Read Set, Write Set, Exception Set으로 나눱니다. 각각 Set의 역할은 아래와 같습니다.
Read Set은 읽기 작업을 수행할 수 있는 상태인지를 감시하기 위한 Socket Set입니다. select() 함수 호출 이후 Read Set에 남아 있는 소켓은 recv()를 호출해도 블로킹되지 않는 상태임을 의미합니다.
다음과 같은 경우에 Read Set이 준비 상태로 판단됩니다.
Write Set은 쓰기 작업을 수행할 수 있는 상태인지를 감시하기 위한 Socket Set입니다.
select() 호출 이후 Write Set에 남아 있는 소켓은 send()를 호출해도 블로킹되지 않는 상태임을 의미합니다.
이는 보통 다음과 같은 상황을 의미합니다.
다만, TCP 소켓은 연결 직후 대부분 쓰기 가능 상태이기 때문에 Write Set을 항상 감시할 경우 select()가 불필요하게 자주 반환될 수 있습니다.
Exception Set은 소켓에 발생한 비정상적이거나 특수한 상황을 감시하기 위한 Socket Set입니다.
주로 다음과 같은 경우에 사용됩니다.
하지만 일반적인 TCP 기반 서버, 특히 게임 서버 환경에서는 거의 사용되지 않습니다. 대부분의 오류 상황과 연결 종료는 Read Set에서 recv()의 반환값을 통해 처리하는 것이 더 명확하고 안정적입니다.
OOB(Out of Band) Data는
send()함수의 마지막 인수에MSG_OOB옵션을 사용하여 보내는 특별한 데이터입니다. OOB Data가 도착하면recv()함수의 마지막 인수에MSG_OOB옵션을 사용해야만 읽을 수 있습니다.

Socket Set을 세 개 준비해 select() 함수를 호출하면, select() 함수는 해당 Socket Set에 포함된 소켓 중 입출력을 수행할 준비가 된 소켓이 발생할 때까지 대기합니다.
적어도 하나 이상의 소켓이 준비 상태가 되면 select() 함수는 리턴하며, 리턴 시 전달되는 Socket Set에는 현재 시점에서 입출력이 가능한 소켓만 남게 됩니다.
이때 준비되지 않은 소켓들은 이번 select() 호출 결과에서만 제외될 뿐, 소켓이 닫히거나 감시 대상에서 영구적으로 제거되는 것은 아닙니다. 아래는 select() 함수의 원형입니다.
#include<winsock2>
int select
(
int nfds,
fd_set* readfds,
fd_set* writefds,
fd_set* exceptfds,
timeval* timeout
);
nfds : 유닉스/리눅스와의 호환성을 위해 존재하며 Window에서는 무시해도 됩니다.readfds, writefds, exceptfds : 각각 읽기, 쓰기, 예외 셋입니다. 사용하지 않는다면 NULL 값이 될 수 있습니다.timeout : Seconds와 Microseconds 단위로 timeout을 지정합니다. 이 시간이 지나면 selec() 함수는 무조건 리턴합니다 만일 NULL일 경우 무한 대기합니다.그 외로 Socket Set을 조작하는 주요 메크로 함수를 살펴보겠습니다.
FD_ZERO(fd_set *set) : Set을 초기화합니다.FD_SET(SOCKET s, fd_set *set) : Socket s를 Set에 넣습니다.FD_CLR(SOCKET s, fd_set *set) : Socket s를 Set에서 제거합니다.FD_ISSET(SOCKET s, fd_set *set) : Socket s가 Set에 들어 있으면 0이 아닌 값을 return합니다. 그렇지 않으면 0을 return합니다.아래 예제 코드는 Select Model 기반의 대표적인 Echo 서버 구조를 보여줍니다. 내용이 길고 처음 보면 다소 복잡해 보이기에 부분적으로 보며 설명해보겠습니다.
#include<winsock2.h>
#include<ws2tcpip.h>
#include<iostream>
#pragma comment(lib, "ws2_32.lib");
#define SERVERPORT 9000
#define BUFSIZE 512
using std::cout;
using std::endl;
//소켓 정보 저장을 위한 구조체와 변수
struct SOCKETINFO
{
SOCKET sock;
char buf[BUFSIZE + 1];
int recvbytes;
int sendbytes;
};
int nTotalSockets = 0;
SOCKETINFO* SocketInfoArray[FD_SETSIZE];
// 소켓 정보 관리 함수
bool AddSocketInfo(SOCKET sock);
void RemoveSocketInfo(int nIndex);
bool AddSocketInfo(SOCKET sock)
{
if (nTotalSockets >= FD_SETSIZE)
{
cout << "[오류] 소켓 정보를 추가할 수 없습니다." << endl;
return false;
}
SOCKETINFO* ptr = new SOCKETINFO;
if (ptr == nullptr)
{
cout << "[오류] 메모리가 부족합니다!" << endl;
return false;
}
ptr->sock = sock;
ptr->recvbytes = 0;
ptr->sendbytes = 0;
SocketInfoArray[nTotalSockets++] = ptr;
return true;
}
void RemoveSocketInfo(int nIndex)
{
SOCKETINFO* ptr = SocketInfoArray[nIndex];
// 클라이언트 정보 얻기
struct sockaddr_in clientaddr;
int addrlen = sizeof(clientaddr);
getpeername(ptr->sock, (struct sockaddr*)&clientaddr, &addrlen);
// 클라이언트 정보 출력
char addr[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &clientaddr.sin_addr, addr, sizeof(addr));
cout << "[TCP Server] Client connected : IP주소 = " << addr << ", 포트 번호 = " << ntohs(clientaddr.sin_port) << endl;
// 소켓 닫기
closesocket(ptr->sock);
delete ptr;
if (nIndex != (nTotalSockets - 1))
SocketInfoArray[nIndex] = SocketInfoArray[nTotalSockets - 1];
--nTotalSockets;
}
int main()
{
int retval;
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
return 1;
SOCKET listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock == INVALID_SOCKET)
{
//TODD : 애러구현
}
#pragma region Socket Option
DWORD optval = 1;
retval = setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, (const char*)&optval, sizeof(optval));
if (retval == SOCKET_ERROR)
{
//TODD : 애러구현
}
retval = setsockopt(listen_sock, SOL_SOCKET, SO_KEEPALIVE, (const char*)&optval, sizeof(optval));
if (retval == SOCKET_ERROR)
{
//TODD : 애러구현
}
#pragma endregion
#pragma region bind()
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
serveraddr.sin_port = htons(SERVERPORT);
retval = bind(listen_sock, (struct sockaddr*)&serveraddr, sizeof(serveraddr));
if (retval == SOCKET_ERROR)
{
//TODD : 애러구현
}
#pragma endregion
retval = listen(listen_sock, SOMAXCONN);
if (retval == SOCKET_ERROR)
{
//TODD : 애러 구현
}
u_long on = 1;
retval = ioctlsocket(listen_sock, FIONBIO, &on);
if (retval == SOCKET_ERROR)
{
//TODD : 애러구현
}
fd_set rset, wset; // 각각 읽기 쓰기 set
int nready;
SOCKET client_sock;
struct sockaddr_in clientaddr;
int addrlen;
while (true)
{
FD_ZERO(&rset);
FD_ZERO(&wset);
FD_SET(listen_sock, &rset);
for (int i = 0; i < nTotalSockets; i++)
{
if (SocketInfoArray[i]->recvbytes > SocketInfoArray[i]->sendbytes)
FD_SET(SocketInfoArray[i]->sock, &wset);
else
FD_SET(SocketInfoArray[i]->sock, &rset);
}
nready = select(0, &rset, &wset, NULL, NULL);
#pragma region Socket Set Prosecutor(1): 클라이언트 접속 수용
if (FD_ISSET(listen_sock, &rset))
{
addrlen = sizeof(clientaddr);
client_sock = accept(listen_sock, (struct sockaddr*)&client_sock, &addrlen);
if (client_sock == INVALID_SOCKET)
{
//TODD : 애러 구현
break;
}
else
{
char addr[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &clientaddr.sin_addr, addr, sizeof(addr));
cout << "[TCP Server] Client connected : IP주소 = " << addr << ", 포트 번호 = " << ntohs(clientaddr.sin_port) << endl;
if (!AddSocketInfo(client_sock))
closesocket(client_sock);
}
if (--nready <= 0)
continue;
}
#pragma endregion
#pragma region Socket Set Prosecutor(2): 데이터 통신
for (int i = 0; i < nTotalSockets; i++)
{
SOCKETINFO* ptr = SocketInfoArray[i];
if (FD_ISSET(ptr->sock, &rset))
{
//데이터 받기
retval = recv(ptr->sock, ptr->buf, BUFSIZE, 0);
if (retval == SOCKET_ERROR)
{
RemoveSocketInfo(i);
//TODD : 애러 구현
}
else if (retval == 0)
RemoveSocketInfo(i);
else
{
ptr->recvbytes = retval;
//Client 정보 얻기
addrlen = sizeof(clientaddr);
getpeername(ptr->sock, (struct sockaddr*)&clientaddr, &addrlen);
//받은 데이터 출력
ptr->buf[ptr->recvbytes] = '\0';
char addr[INET6_ADDRSTRLEN];
inet_ntop(AF_INET, &clientaddr.sin_addr, addr, sizeof(addr));
cout << "[TCP/" << addr << ":" << ntohs(clientaddr.sin_port) << "] " << ptr->buf;
}
}
else if (FD_ISSET(ptr->sock, &wset))
{
//데이터 보내기
retval = send(ptr->sock, ptr->buf + ptr->sendbytes, ptr->recvbytes - ptr->sendbytes, 0);
if (retval == SOCKET_ERROR)
{
//TODD : 애러 구현
RemoveSocketInfo(i);
}
else
{
ptr->sendbytes += retval;
if (ptr->recvbytes == ptr->sendbytes)
{
ptr->recvbytes = ptr->sendbytes = 0;
}
}
}
}
#pragma endregion
}
closesocket(listen_sock);
WSACleanup();
}
struct SOCKETINFO
{
SOCKET sock;
char buf[BUFSIZE + 1];
int recvbytes;
int sendbytes;
};
int nTotalSockets = 0; //소켓을 생성할 때마다 +1, 닫을 때마다 -1
SOCKETINFO* SocketInfoArray[FD_SETSIZE];
FD_SETSIZE : Select 모델에서 처리할 수 있는 소켓의 최대 개수를 정의합니다.기존의 TCP/UDP Server 예제에서는 하나의 client_sock이 데이터 송수신을 담당했습니다. Single Client와 통신하는 구조에서는 소켓 하나의 상태만 관리하면 되므로 큰 문제가 되지 않습니다.
하지만 이후에 다루게 될 Multi Client Server 환경에서는 여러 개의 소켓을 동시에 관리해야 하며, 각 소켓은 송수신 진행 상황, 버퍼 상태 등 서로 다른 상태를 가질 수 있습니다.
따라서 소켓마다 필요한 상태 정보를 함께 관리하기 위해 SOCKETINFO 구조체를 정의하여 소켓 단위로 정보를 관리할 수 있도록 합니다
Select 모델은 Blocking과 Non-Blocking Socket 모두 사용할 수 있지만, Non-Blocking Socket을 사용하는 것이 더 효율적입니다. 단, Non-Blocking Socket을 사용하면 send() 함수 호출 시 지정한 값보다 작은 크기가 전송될 수 있으므로 보낸 byte 수를 관리해야 합니다.
u_long on = 1;
retval = ioctlsocket(listen_sock, FIONBIO, &on);
if (retval == SOCKET_ERROR)
{
//TODD : 애러구현
}
fd_set rset, wset; // 각각 읽기 쓰기 set
int nready;
각각의 Socket Set은 fd_set 이라는 구조체 안에 있기에 데이터 통신에 필수인 rset, wset을 선언합니다.
이때 예외 셋은 사용을 안하기에
select()함수 호출시 NULL로 설정합니다.
이후 무한 루프를 돌아 통신을 주고받게 되며 크게 세 부분으로 나눌 수 있습니다.
FD_ZERO(&rset);
FD_ZERO(&wset);
FD_SET(listen_sock, &rset);
for (int i = 0; i < nTotalSockets; i++)
{
if (SocketInfoArray[i]->recvbytes > SocketInfoArray[i]->sendbytes)
FD_SET(SocketInfoArray[i]->sock, &wset);
else
FD_SET(SocketInfoArray[i]->sock, &rset);
}
nready = select(0, &rset, &wset, NULL, NULL);
먼저 read set(rset)과 write set(wset)을 초기화한 뒤, 새로운 클라이언트의 접속 요청을 감지하기 위해 listen_sock을 rset에 등록합니다.
이후 소켓 정보 구조체(SOCKETINFO)를 순회하며 각 소켓의 현재 상태에 따라 읽기 또는 쓰기 셋에 등록합니다. recvbytes가 sendbytes보다 큰 경우는 아직 보내지 못한 데이터가 남아 있음을 의미하므로 해당 소켓을 write set에 등록하고, 그 외의 경우에는 새로운 데이터를 수신하기 위해 read set에 등록합니다.
이는 에코 서버의 특성상 수신한 데이터를 그대로 다시 전송해야 하기 때문에, 소켓의 송수신 상태에 따라 감시할 이벤트를 동적으로 변경하는 것입니다.
마지막으로 select() 함수를 호출하여 등록된 소켓들 중 입출력 이벤트가 발생할 때까지 대기합니다.
if (FD_ISSET(listen_sock, &rset))
{
addrlen = sizeof(clientaddr);
client_sock = accept(listen_sock, (struct sockaddr*)&client_sock, &addrlen);
if (client_sock == INVALID_SOCKET)
{
//TODD : 애러 구현
break;
}
else
{
char addr[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &clientaddr.sin_addr, addr, sizeof(addr));
cout << "[TCP Server] Client connected : IP주소 = " << addr << ", 포트 번호 = " << ntohs(clientaddr.sin_port) << endl;
if (!AddSocketInfo(client_sock))
closesocket(client_sock);
}
if (--nready <= 0)
continue;
}
select() 함수 호출 이후, listen_sock이 read set에 포함되어 있다면 새로운 클라이언트의 접속 요청이 도착했음을 의미합니다.
이 경우 accept() 함수를 호출하여 클라이언트와 통신할 새로운 소켓(client_sock)을 생성합니다. accept()가 실패하면 에러 처리가 필요하며, 성공 시 접속한 클라이언트의 IP 주소와 포트 번호를 출력합니다.
이후 생성된 client_sock을 SOCKETINFO 구조체에 등록하여 멀티 클라이언트 환경에서 관리할 수 있도록 합니다. 만약 소켓 정보 등록에 실패할 경우, 해당 소켓은 즉시 종료합니다.
하나의 이벤트를 처리한 뒤 nready 값을 감소시키며, 더 이상 처리할 이벤트가 없다면 다음 select 루프로 넘어갑니다.
for (int i = 0; i < nTotalSockets; i++)
{
SOCKETINFO* ptr = SocketInfoArray[i];
if (FD_ISSET(ptr->sock, &rset))
{
//데이터 받기
retval = recv(ptr->sock, ptr->buf, BUFSIZE, 0);
if (retval == SOCKET_ERROR)
{
RemoveSocketInfo(i);
//TODD : 애러 구현
}
else if (retval == 0)
RemoveSocketInfo(i);
else
{
ptr->recvbytes = retval;
//Client 정보 얻기
addrlen = sizeof(clientaddr);
getpeername(ptr->sock, (struct sockaddr*)&clientaddr, &addrlen);
//받은 데이터 출력
ptr->buf[ptr->recvbytes] = '\0';
char addr[INET6_ADDRSTRLEN];
inet_ntop(AF_INET, &clientaddr.sin_addr, addr, sizeof(addr));
cout << "[TCP/" << addr << ":" << ntohs(clientaddr.sin_port) << "] " << ptr->buf;
}
}
else if (FD_ISSET(ptr->sock, &wset))
{
//데이터 보내기
retval = send(ptr->sock, ptr->buf + ptr->sendbytes, ptr->recvbytes - ptr->sendbytes, 0);
if (retval == SOCKET_ERROR)
{
//TODD : 애러 구현
RemoveSocketInfo(i);
}
else
ptr->sendbytes += retval;
if (ptr->recvbytes == ptr->sendbytes)
ptr->recvbytes = ptr->sendbytes = 0;
}
}
select() 이후, 이벤트가 발생한 클라이언트 소켓들을 순회하며 각 소켓의 상태에 따라 읽기 또는 쓰기 작업을 수행합니다.
먼저 read set(rset)에 포함된 소켓은 수신 가능한 데이터가 존재함을 의미하므로 recv() 함수를 호출하여 데이터를 수신합니다. recv() 호출 결과가 오류이거나 0인 경우는 클라이언트와의 연결이 종료되었음을 의미하므로 해당 소켓을 제거합니다.
정상적으로 데이터를 수신한 경우, 수신한 바이트 수를 recvbytes에 저장하고 getpeername()을 통해 클라이언트의 주소 정보를 얻어 수신된 데이터를 출력합니다.
반면 write set(wset)에 포함된 소켓은 이전에 수신했으나 아직 전송되지 않은 데이터가 존재함을 의미합니다. 이 경우 send() 함수를 호출하여 남은 데이터를 전송하며, 전송한 바이트 수만큼 sendbytes를 증가시킵니다.
모든 데이터 전송이 완료되면 recvbytes와 sendbytes를 초기화하여 다음 수신을 준비합니다.
select 모델은 하나의 스레드에서 여러 소켓을 감시하는 구조로, 멀티 클라이언트 서버의 기본 개념을 이해하기에 매우 적합합니다.
멀티 스레드를 사용하지 않기 때문에 스레드 동기화, 락(lock), 데드락과 같은 복잡한 문제를 고려할 필요가 없습니다. 이는 서버 로직을 단순하게 유지하는 데 큰 장점이다.
select() 함수는 대부분의 OS에서 지원되는 표준 API로, Windows, Linux, macOS 등 다양한 환경에서 동일한 개념으로 사용할 수 있습니다.
select는 매번 모든 소켓을 순회하며 이벤트를 검사합니다.
즉, 감시 중인 소켓 수가 증가할수록 성능이 선형적으로 저하됩니다. 대규모 동시 접속 환경에서는 병목 현상이 발생하기 쉽습니다.
select는 감시할 수 있는 소켓 개수가 제한되어 있습니다. (Windows 기준 기본 64, Linux 기준 1024). 하지만 Window OS에서는 FD_SETSIZE를 재정의함으로써 스레드당 처리할 수 있는 소켓의 개수를 늘릴 수 있습니다.
select 모델은 수천~수만 개의 동시 연결을 처리해야 하는 게임 서버나 대규모 서비스 서버에는 적합하지 않습니다.
그렇기에 이러한 환경에서는 IOCP(Windows)와 같은 고성능 I/O 모델이 필수적입니다.
이번 글에서는 Non-Blocking Socket 환경에서 발생하는 Busy Waiting 문제를 해결하기 위한 방법으로 Select Model을 살펴보았습니다.
다만 Select Model은 구조가 직관적이고 학습용으로는 매우 적합하지만, O(n) 성능 한계와 FD_SETSIZE 제한으로 인해 대규모 동시 접속을 처리해야 하는 게임 서버나 상용 서버 환경에는 적합하지 않다는 명확한 한계도 가지고 있습니다.
따라서 다음 글에서는 IOCP 모델을 본격적으로 다루기에 앞서, 동기 & 비동기 I/O의 개념과 커널의 개념 정리하고, 이 개념들이 IOCP와 어떻게 연결되는지를 단계적으로 다루겠습니다.
[교보문구]
https://product.kyobobook.co.kr/detail/S000061352062
https://product.kyobobook.co.kr/detail/S000001792817