본문 바로가기
@9ky02025. 8. 25. 16:01

1. 들어가며

https://9ky0.tistory.com/15

 

논블로킹 소켓

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 상황이나 논블로킹 모드의 반복 체크 상황을 미리 방지할 수 있다. 다음과 같이 동작한다.

  1. 읽기/쓰기/예외 Socket Set을 생성하고, 각각의 Set이 관찰할 Socket을 등록한다.
  2. select 함수를 호출해 관찰을 시작한다.
  3. 관찰 대상으로 등록한 Set에서, 적어도 하나의 Socket이 준비되면 리턴한다. 이때 준비되지 않은 소켓은 Set에서 모두 제거된다.
  4. 나머지 소켓을 확인해 반복한다.

이때 예외 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
9ky0
@9ky0 :: Hello, 9ky0!

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차