KoreanFoodie's Study

[C++ 게임 서버] 3-8. Select 모델 본문

Game Dev/Game Server

[C++ 게임 서버] 3-8. Select 모델

GoldGiver 2023. 9. 25. 16:35

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

[C++ 게임 서버] 3-8. Select 모델

핵심 :

1. Select 모델을 사용하면 소켓의 유효성을 데이터를 주고 받기 전에 미리 체크할 수 있다.

2. 관찰하고자 하는 소켓을 fd_set 에 넣고, select 하면 관찰이 시작되며, 적어도 하나의 소켓이 준비되면 리턴된다(낙오자는 알아서 제거됨)

3. 세션을 이용해서 소켓을 관리할 수 있다. 세션은 두 기기간의 통신이 연결되었을 때 구축되고, 끊겼을 때 파괴되는 양방향 링크라고 생각하면 된다.

이번 시간에는 select 함수가 핵심이 되는 Selet 모델에 대해 알아보자. Select 모델을 사용하면 소켓 함수 호출이 성공할 시점을 미리 알 수 있다. 이것은 아래와 같은 상황에서 큰 이점으로 작용한다 :

  1. 블로킹 소켓 : 조건이 만족되지 않아서 블로킹되는 상황 예방
  2. 논블로킹 소켓 : 조건이 만족되지 않아서 불필요하게 반복 체크(spin)하는 상황 예방

 

사실 원리는 간단하다. 우리는 Select 모델은 Socket Set 가 존재하고, 우리는 '관찰할' 소켓들을 이 Socket Set 에 넣는다.

관찰을 시작한 후, 적어도 하나의 소켓이 준비되면 리턴을 한다. 이때, 준비가 되지 않은(버퍼가 비어있다거나, 가득 차 있다거나) 소켓들은 알아서 제거된다. 이제 남은 소켓을 체크해서 진행하면 된다. 정리하면 다음과 같을 것이다 :

  1. 읽기[ ] 쓰기[ ] 예외(OOB)[ ] 관찰 대상 등록 -> OutOfBand는 send() 마지막 인자 MSG_OOB로 보내는 특별한 데이터. 받는 쪽에서도 recv OOB 세팅을 해야 읽을 수 있음
  2. select(readSet, writeSet, exceptSet); -> 관찰 시작
  3. 적어도 하나의 소켓이 준비되면 리턴 -> 낙오자는 알아서 제거됨
  4. 남은 소켓 체크해서 진행

즉, 읽기 소켓 1, 2, 3 이 있다고 하고, 이를 관찰 대상에 넣었다. 그런데 1, 3 소켓이 지금 사용불가능한 상태라면, 관찰 시 2번 소켓만 목록에 남을 것이다. 😉

 

이제 간략한 API 를 보자.

// 관찰 set 만들기
fd_set set;
SOCKET s;

// FD_ZERO : 비운다
ex) FD_ZERO(set);

// FD_SET : 소켓 s를 넣는다
ex) FD_SET(s, &set);

// FD_CLR : 소켓 s를 제거
ex) FD_CLR(s, &set);

// FD_ISSET : 소켓 s가 set에 들어있으면 0이 아닌 값을 리턴한다
ex) FD_ISSET(s, &set);

위의 API 를 이용하여 서버에서 select 모델을 구현할 것이다.

 

참고로, fd_set 은 다음과 같이 생긴 녀석이다 :

/*
 * Select uses arrays of SOCKETs.  These macros manipulate such
 * arrays.  FD_SETSIZE may be defined by the user before including
 * this file, but the default here should be >= 64.
 *
 * CAVEAT IMPLEMENTOR and USER: THESE MACROS AND TYPES MUST BE
 * INCLUDED IN WINSOCK.H EXACTLY AS SHOWN HERE.
 */
#ifndef FD_SETSIZE
#define FD_SETSIZE      64
#endif /* FD_SETSIZE */

typedef struct fd_set {
        u_int   fd_count;               /* how many are SET? */
        SOCKET  fd_array[FD_SETSIZE];   /* an array of SOCKETs */
} fd_set;

보면 fd_array 에 관찰할 소켓을 넣는데, 최대 갯수가 64 개라는 것을 알 수 있다. 즉, 그 이상의 소켓 갯수를 관찰하고 싶으면,  fd_set 을 여러 개 만들어야 할 것이다! 😮

 

추가적으로, 이번부터 Session 이라는 개념을 활용하여 소켓을 관리할 것이다.

struct Session
{
	SOCKET socket = INVALID_SOCKET;
	char recvBuffer[BUFSIZE] = {};
	int32 recvBytes = 0;
	int32 sendBytes = 0;
};

 

이제 진짜 서버 쪽 구현을 보자. bind 및 listen 까지는 이전 단계와 동일하다.

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

fd_set reads;
fd_set writes;

일단 while 문 아래 들어갈.. Session 리스트와 read/write 소켓 셋을 만든다.

 

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

// ListenSocket 등록
FD_SET(listenSocket, &reads);

// 소켓 등록
for (Session& s : sessions)
{
    if (s.recvBytes <= s.sendBytes)
        FD_SET(s.socket, &reads);
    else
        FD_SET(s.socket, &writes);
}

그리고선 소켓 셋을 초기화하고, ListenSocket 을 등록하고, 세션별로 소켓을 등록한다.

참고로, 위의 소켓 셋 초기화는 실제로 while loop 안에서 loop 마다 진행한다. 그 이유는, select 를 할 때 우리가 낙오자를 날리는데 이때 유효 소켓이 남기 때문이다!

이때 recvBytes 와 sendBytes 의 크기에 따라 Read/Write 소켓을 구분해서 Set 에 넣어주는데, 이는 지금 우리가 만들어 주는 것은 일종의 에코 서버 이다. 추후 코드를 보면, sendBytes 가 더 큰 녀석이 Read 소켓이 되는 것을 알 수 있다 😅

일단 '미래예지'를 통해 얘기하자면... recvBytes 는 클라가 서버에게 준 데이터의 크기이고, sendBytes 는 서버가 다시 클라에게 주는 데이터의 크기이다. 만약 recv <= send 이면, 보내줄 것을 다 보냈다는 뜻이므로 read 소켓이 될 것이고, 만약 recv > send 이면 100 을 받았는데 아직 30밖에 못 보냈다는 뜻이므로, write 소켓이 될 것이다(서버 입장에서). 😀

 

// [옵션] 마지막 timeout 인자 설정 가능
int32 retVal = ::select(0, &reads, &writes, nullptr, nullptr);
if (retVal == SOCKET_ERROR)
    break;

추가적으로 timeout 인자를 설정한다. 참고로, select 의 인자는 다음과 같은데,

아까 예외 처리 소켓은 안 쓴다고 했으니 nullptr 로 넣어 주었다.

만약 마지막 인자를 timeval 타입 인자로 넣어주면, 정해진 시간동안 기다렸다가 timeout 을 뱉는다. 여기서는 nullptr 를 넣어서, 무한 대기를 하게 된다 🤣

 

// 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" << endl;
        sessions.push_back(Session{ clientSocket });
    }
}

그리고 나서, Listener 소켓을 체크한다. 클라이언트와 소켓 연결이 가능하면, session 에 해당 소켓을 넣어준다.

 

// 나머지 소켓 체크
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;
        }
    }
}

이제 세션별로 Read/Write 소켓을 체크한다. 먼저 Read 소켓을 체크한 후, Write 소켓을 체크한다.

::send 에서 데이터를 일부만 보낼 수 있음을 알 수 있으며, 데이터를 전부 보내면 세션별로 sendBytes 와 recvBytes 의 크기를 다시 초기화해 준다.

 

왼쪽은 클라, 오른쪽은 서버 로그로, 위와 같이 전송이 잘 되는 것을 확인할 수 있다!

 

 

 

Comments