나만의 작은 도서관

[C++] 게임서버 탐구 일지 #6. 소켓 모델 - Select 모델 본문

C++/Windows 게임서버

[C++] 게임서버 탐구 일지 #6. 소켓 모델 - Select 모델

pledge24 2025. 1. 4. 01:11

목차

  • 이전 블로킹/논블로킹 방식의 문제점
  • Select 모델의 특징
  • Select 모델 동작 과정
  • Select 모델을 이용한 echo 서버 만들기
  • Select 모델의 문제점
  • 깃헙 코드

이전 블로킹/논블로킹 방식의 문제점

이전 블로킹/논블로킹 방식에는 서로 다른 이유로 서버의 성능을 저하시키는 구조적 문제가 있었다. (블로킹 방식에 대한 정리 글은 여기, 논블로킹 방식에 대한 정리 글은 여기를 참고하면 된다.)

 

각 방식의 구조적 문제는 다음과 같다.

  • 블로킹 방식: 소켓 함수(recv(), send() 등)를 호출하면, 해당 작업이 완료될 때까지 호출한 스레드는 무한정 기다려야한다.
  • 논블로킹 방식:  소켓 함수(recv(), send() 등)를 호출하면, 해당 작업이 완료되었는지 반복적으로 확인해야한다.

결국 블로킹 방식을 사용하든, 논블로킹 방식을 사용하든 방식의 차이일 뿐, 소켓 작업이 진행될 수 없는 상황에서도 진행하려는 시도로 인해 비효율적으로 CPU를 사용하고 있다.

 

이러한 문제를 해결하기 위해 소켓 모델이 필요했고, 그 중 가장 간단한 모델이 바로 "Select 모델"이다.


Select 모델의 특징

select() 함수가 핵심

Select 모델은 select() 함수가 핵심이 되는 모델이다. select() 함수는 동기 방식(Synchronous)이며, 핵심인 만큼 리턴값으로 인해 소켓 함수의 실행 여부가 결정된다.

 

소켓 함수 호출이 성공할 시점을 미리 알 수 있다.

이 말을 처음 들으면 무슨 의미인지 이해하기 어려울 수 있지만(본인은 이해하기 어려워했다), 알고보면 어려운 개념은 아니다.

 

일단 성공할 시점은 실패하는 시점을 알면 이해하기 쉬운데, recv()와 send()가 실패하는 시점을 예로 들면 다음과 같다.

  • recv(): 수신 버퍼에 데이터가 없는데 read()를 호출하여 read하려는 경우
  • send(): 송신 버퍼가 꽉 찼는데 send()를 호출하여 write하려는 경우

즉, 실패할 시점은 소켓 함수 호출 시 작업을 수행하지 못한 경우를 말하며, 성공할 시점은 반대로 작업을 수행할 수 있는 경우를 말한다.

 

Select 모델에서 select() 함수는 "성공할 시점인 소켓 함수"가 하나라도 존재할 때 리턴하므로(timeout 옵션을 건드리지 않는 이상), select() 함수가 리턴한 시점은 반드시 성공할 시점이 존재하기 때문에 "소켓 함수 호출이 성공할 시점을 미리 알 수 있다"고 하는 것이다.

 

이러한 Select 모델의 구조 덕분에 실패할 시점에 소켓 함수를 호출하지 않을 수 있게되면서 이전의 블로킹/논블로킹 방식의 구조적 문제가 해결되었다!

 

기타 특징들

  • Linux 환경에서 사용가능: 앞으로 다룰 소켓 모델들 중에 유일하게 윈도우 운영체제가 아닌 Linux 운영체제에서도 사용가능한 소켓 모델이다. 디테일한 부분들은 다를 수 있겠지만, 윈도우에서 Select 모델을 구현해봤다면, Linux에서도 어렵지 않게 구현할 수 있다. 

  • 모델의 단순함: Select 모델은 상당히 단순한 구조를 가지고 있다. 그만큼 제약도 많고, 성능도 좋지 못하지만 이런 것들이 문제되지 않는 상황이라면 보다 복잡하고 어려운 소켓 모델을 사용하지 않고 Select 모델을 사용하는 것도 나쁘지 않을 수 있다.

  • 멀티플렉싱 방식: 하나의 프로세스로 다중 접속서버를 구현하는 방식을 멀티플렉싱 방식이라고 한다. Select 모델은 멀티플렉싱 방식의 구조를 가지고 있다.

  • 블로킹/논블로킹 소켓 둘 다 사용가능: Select 모델은 소켓 모드(블로킹/논블로킹)에 관계없이 여러 소켓을 하나의 쓰레드에서 처리할 수 있다.

Select 모델 동작 과정

기존에 발표하기 위해 정리해둔 자료가 있어 해당 자료를 추가하였다. 

 

012345678910

 

참고 영상: https://www.youtube.com/watch?v=e8w3Hx68OQE


Select 모델을 이용한 echo 서버 만들기

socket() -> bind() -> listen()

TCP 서버를 만들 때는 항상 listen 소켓을 만들고 시작한다. socket() -> bind() -> listen()을 통해 listen 소켓을 하나 만들어준다. 

 

이 때, listen소켓은 소켓 모드(블로킹/논블로킹) 상관없이 사용가능하다. 어떤걸 사용해도 되지만 아래 예제에서는 논블로킹 방식을 사용했다. (논블로킹 echo 서버 때와 코드가 동일)

 int main()
{
    WSAData wsaData;
    if (::WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
        return 0;

    // socket(): 소켓 하나를 생성해 리턴한다.
    SOCKET listenSocket = ::socket(AF_INET, SOCK_STREAM, 0);
    if (listenSocket == INVALID_SOCKET)
    {
        HandleError("Socket");
        return 0;
    }

    // 논블로킹 방식으로 소켓 모드 설정
    u_long on = 1;
    if (::ioctlsocket(listenSocket, FIONBIO, &on) == INVALID_SOCKET)
        return 0;

    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(7777);
    
    // bind()
    if (::bind(listenSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
        return 0;
    
    // listen()
    if(::listen(listenSocket, SOMAXCONN) == SOCKET_ERROR)
        return 0;

    cout << "Accept" << '\n';
...

 

 

세션 준비

Select 모델은 여러 소켓을 동시에 다루기 때문에 각 소켓 데이터를 관리하는 세션 자료구조가 필요하다. 따라서 구조체 방식으로 세션을 준비해둔다. 

const int32 BUFSIZE = 1000;

struct Session
{
    SOCKET socket;
    char recvBuffer[BUFSIZE] = {};
    int32 recvBytes = 0;	// 수신한 바이트 수
    int32 sendBytes = 0;	// 송신한 바이트 수
};

 

 

1) fd_set에 관찰할 소켓 등록

Select 모델에서 각 소켓의 소켓 이벤트(읽기, 쓰기 등)를 감지하게하려면 파일 디스크립터(file descriptor, 줄여서 fd)에 해당 소켓을 등록해야한다. 소켓 등록은 fd_set 타입의 변수에 메크로 함수를 사용하여 등록한다.

 

Select 모델을 사용하기 위해 알아둬야 하는 메크로 함수는 다음과 같다.

fd_set set;
SOCKET s;

FD_ZERO(&set);     // FD_zero : set을 비운다. 
FD_SET(s, &set);   // FD_SET: set에 소켓 s를 넣는다
FD_CLR(s, &set);   // FD_CLR : set에 소켓 s를 제거
FD_ISSET(s, &set); // FD_ISSET: 소켓 s가 set에 들어있으면 0이 아닌 값 리턴

 

아래 코드를 통해 예를 들자면, 특정 클라이언트 소켓에서 발생하는 읽기 이벤트를 감지하고 싶은 경우 fd_set타입의 reads 변수와 소켓을 FD_SET() 메크로 함수에 인자로 넣어 이벤트를 등록한다.

 

+) 참고로, fd_set은 그저 정수 타입 배열로, 정수 값을 통해 각 소켓들을 구분한다.  

vector<Session> sessions;
sessions.reserve(100);

fd_set reads;
fd_set writes;

while (true)
{
    // 소켓 셋 초기화
    FD_ZERO(&reads);
    FD_ZERO(&writes);

    // ListenSocket 등록
    FD_SET(listenSocket, &reads);
    
    // 나머지 소켓 등록
    for (Session& s : sessions)
    {
        // echo 서버는 recv -> send 순으로 실행되므로 항상 s.recvBytes >= s.sendBytes
        // s.recvBytes == s.sendBytes: 수신한 데이터를 전부 송신한 경우
        // s.recvBytes > s.sendBytes: 수신한 데이터를 echo하지 못한 경우
        if (s.recvBytes <= s.sendBytes)
            FD_SET(s.socket, &reads);
        else
            FD_SET(s.socket, &writes);
    }
    ...

 

 

2) select() 함수 실행

이벤트를 감지할 모든 소켓을 각 종류별 fd_set에 넣었다면 만든 fd_set을 select() 함수에 넣어준다. select 함수에 넣어줘야하는 인자들은 다음과 같다.

select(
    _In_ int nfds,					// Linux환경과 맞추기 위한 인자. 윈도우는 0으로 설정
    _Inout_opt_ fd_set FAR * readfds,			// 읽기 셋
    _Inout_opt_ fd_set FAR * writefds,			// 쓰기 셋
    _Inout_opt_ fd_set FAR * exceptfds,			// 예외 셋
    _In_opt_ const struct timeval FAR * timeout		// timeout 설정
    );

 

아래 예제에서는 예외 셋을 사용하지 않기 때문에 exceptfds 자리에 nullptr을 넣어주었다.

 

+) 참고로, select() 함수는 호출 시 준비되지 않은(실패하는 시점인) 소켓 데이터는 전부 지워버린다.

+) 예외 셋은 OOB 설정을 위해 사용한다. OutOfBand(OOB)라고해서 send()할 때 마지막 인자로 MSG_OOB를 보내는 특별한 데이터가 있는데, 이러한 OOB 세팅을 받는 쪽에서도 해줘야 데이터를 읽을 수 있게하는 기능이다. 긴급 상황을 알릴 때 사용한다.

// select() 함수 실행! 호출시 성공하는 소켓 함수의 개수를 반환한다.
int32 retVal = ::select(0, &reads, &writes, nullptr, nullptr);
if (retVal == SOCKET_ERROR)
    break;

 

 

3) 성공하는 소켓 함수 찾기 및 실행

select() 함수는 개수를 반환할 뿐, 어떤 소켓에서 어느 이벤트가 성공하는지 알려주지 않는다. 따라서, 세션들을 선형 탐색하며 FD_ISSET() 메크로 함수를 이용해 읽기 셋, 쓰기 셋에서 성공 시점이라 세팅된 소켓을 찾아 소켓 함수를 실행한다.

// Listener 소켓 체크
if (FD_ISSET(listenSocket, &reads))
{
    SOCKADDR_IN clientAddr;
    int32 addrLen = sizeof(clientAddr);
    SOCKET clientSocket = ::accept(listenSocket, (SOCKADDR*)&clientAddr, &addrLen);
    if (clientSocket != INVALID_SOCKET)
    {
        cout << "Client Connected" << '\n';
        sessions.push_back({clientSocket});
    }
}

// 나머지 소켓 체크
for (Session& s : sessions)
{
    // Read
    if (FD_ISSET(s.socket, &reads))
    {
        int32 recvLen = ::recv(s.socket, s.recvBuffer, BUFSIZE, 0);
        if (recvLen <= 0)
        {
            // TODO: sessions 제거
            continue;
        }

        s.recvBytes = recvLen;
    }

    // Write
    if (FD_ISSET(s.socket, &writes))
    {
        // 블로킹 모드 -> 모든 데이터 다 보냄
        // 논블로킹 모드 -> 일부만 보낼 수가 있음 (상대방 수신 버퍼 상황에 따라)
        int32 sendLen = ::send(s.socket, &s.recvBuffer[s.sendBytes], s.recvBytes - s.sendBytes, 0);
        if (sendLen == SOCKET_ERROR)
        {
            // TODO: sessions 제거
            continue; 
        }

        s.sendBytes += sendLen;
        if (s.recvBytes == s.sendBytes)
        {
            s.recvBytes = 0;
            s.sendBytes = 0;
        }
    }
}

 


Select 모델의 문제점

재등록 반복

select() 함수는 호출 시 준비되지 않은(실패하는 시점인) 소켓 데이터는 전부 지워버리기 때문에 매 루프마다 소켓을 fd_set에 재등록해야 한다. 이는 매우 비효율적이다.

 

한 번에 등록할 수 있는 소켓 수가 작음

FD_SETSIZE() 매크로 함수를 사용하면 fd_set 하나에 저장할 수 있는 소켓 수가 반환된다. 이를 확인해보면 64라는 매우 작은 크기를 가지고 있다는 것을 알 수 있는데, 동접자가 매우 많은 경우 fd_set을 여러 개 만들어야한다는 불편함이 있다.

 

선형 탐색으로 실행

소켓을 등록하거나 소켓 함수를 실행하기 위해 각 세션을 선형 탐색한다. 이러한 선형 탐색 작업이 매 루프마다 반복되기 때문에 등록한 소켓의 개수가 많아질수록 성능이 떨어지게 된다.


깃헙 코드

https://github.com/pledge24/WinGameServerPractice/tree/a5350ddeaffb8930ce3404247e2c2594541e7a41

 

GitHub - pledge24/WinGameServerPractice

Contribute to pledge24/WinGameServerPractice development by creating an account on GitHub.

github.com