학교에서 처음 소켓 프로그래밍을 배웠던 기억이 난다.
리눅스 환경도 낯선데, 갑자기 처음 보는 함수가 엄청 나와서 많이 당황했었다.
어찌저찌 실행시키니 정상적으로 작동하는 것을 보고 신기해했었던 것 같다.
하지만 그런 블로킹 방식의 소켓 구현으로는 실용적인 서버를 개발할 수 없다.
기본적인 모델이면서도 다양하게 활용될 수 있는 Select 모델에 대해 알아봤다.
1. Select 모델의 개념
Select 모델은 select 라는 함수가 핵심이 되기 때문에 이런 이름이 붙었다.
서버단에선 이 모델을 활용해서 수백 또는 수천의 세션을 관리하기엔 무리가 있을 수 있다.
하지만 대규모 동시 접속이 필요없는 클라이언트단에선 여전히 사용되기도 한다.
네트워크 프로그래밍의 지식을 쌓는다는 목적으로도 좋다.
Select 모델은 소켓 함수 호출이 성공할 시점을 미리 알 수 있다는 특징이 있다.
함수를 호출할 수 있는지에 대한 여부를 미리 확인하기 때문에 가능하다.
수신/송신 버퍼에 데이터가 비었는데/꽉 찼는데 read/write 하는 상황이 기존 소켓 프로그래밍에 있었다.
while (true)
{
if (::send(clientSocket, sendBuffer, sizeof(sendBuffer), 0) == SOCKET_ERROR)
{
// 원래 블로킹이지만 논블로킹으로 하라고 명령
if (::WSAGetLastError() == WSAEWOULDBLOCK)
continue;
// ERROR
break;
}
cout << "Send Data ! Len = " << sizeof(sendBuffer) << endl;
...
}
while문 안에서 send()가 계속 돌면서 WSAEWOULDBLOCK으로 에러를 확인한다.
이 방식으론 소켓 함수 호출이 언제 성공할지 알 수 없기 때문에 계속 확인해야 하고 자원의 낭비로 이어진다.
Select 모델을 사용하면
- 블로킹의 경우
조건이 만족되지 않아서 블로킹 되는 상황을 예방 - 논 블로킹의 경우
조건이 만족되지 않아서 불필요한 반복 체크를 예방
위와 같은 이점을 얻을 수 있다.
2. Socket Set
Select 모델은 소켓을 지정하고 그 소켓을 관찰한다고 볼 수 있다.
읽기 / 쓰기 / 예외의 3가지 기준을 두고 소켓을 관찰한다.
읽기, 쓰기는 알겠는데 예외는 느낌이 오지 않는다.
이는 OutOfBand라고 하는데, 이는 긴급상황 등의 특이사항을 알리는 용도로 주로 사용된다.
send()의 플래그에 MSG_OOB 라는 플래그를 지정해 주어 보낼 수 있고,
수신 측에서도 recv()의 플래그에 동일한 플래그를 지정해 수신할 수 있다.
일단 당장 사용할 일이 없을 듯 하니 없다고 생각한다.
set을 통해 관찰 대상을 정했으면 select()를 호출해 관찰을 시작한다.
관찰 중에 최소 한개의 소켓이 준비가 되면 리턴하고,
나머지 준비가 안된 관찰 대상 소켓들을 관찰 대상에서 제외한다.
2.1 사전 작업
select 모델을 본격적으로 작성하기 전에 소켓과 세션 구조체를 작성한다.
const int32 BUFSIZE = 1000;
struct Session
{
SOCKET socket = INVALID_SOCKET;
char recvBuffer[BUFSIZE] = {};
int32 recvBytes = 0;
int32 sendBytes = 0;
};
int main()
{
// 윈속 초기화 (ws2_32 라이브러리 초기화)
// 관련 정보가 wsaData에 채워짐
WSAData wsaData;
if (::WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
return 0;
// 논 블로킹
SOCKET listenSocket = ::socket(AF_INET, SOCK_STREAM, 0);
if (listenSocket == INVALID_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);
if (::bind(listenSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
return 0;
if (::listen(listenSocket, SOMAXCONN) == SOCKET_ERROR)
return 0;
cout << "Accept" << endl
vector<Session> sessions;
sessions.reserve(100);
// 아래에 Select 모델 구현
}
서버는 여러 클라이언트와 데이터를 주고받을 수 있어야 하기 때문에 세션으로 관리한다.
서버와 연결할 간이 클라이언트도 작성한다.
int main()
{
// 소켓 코드 일부 생략
SOCKADDR_IN serverAddr;
::memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
::inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr);
serverAddr.sin_port = htons(7777);
// Connect
while (true)
{
if (::connect(clientSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
{
if (::WSAGetLastError() == WSAEWOULDBLOCK)
continue;
// 이미 연결된 상태라면 break
if (::WSAGetLastError() == WSAEISCONN)
break;
// ERROR
break;
}
}
cout << "Connected to Server!" << endl;
char sendBuffer[100] = "Hello World!";
// Send
while (true)
{
if (::send(clientSocket, sendBuffer, sizeof(sendBuffer), 0) == SOCKET_ERROR)
{
// 원래 블로킹이지만 논블로킹으로 하라고 명령
if (::WSAGetLastError() == WSAEWOULDBLOCK)
continue;
// ERROR
break;
}
cout << "Send Data ! Len = " << sizeof(sendBuffer) << endl;
// Recv
while (true)
{
char recvBuffer[1000];
int32 recvLen = ::recv(clientSocket, recvBuffer, sizeof(recvBuffer), 0);
if (recvLen == SOCKET_ERROR)
{
if (::WSAGetLastError() == WSAEWOULDBLOCK)
continue;
// ERROR
break;
}
else if (recvLen == 0)
{
// 연결 끊김
break;
}
cout << "Recv Data Len = " << recvLen << endl;
break;
}
this_thread::sleep_for(1s);
}
// 소켓 리소스 반환
::closesocket(clientSocket);
// 윈속 종료
::WSACleanup();
}
이렇게 옮겨놓고 보니 쓸데없이 길어 보인다.
2.2 fd_set
먼저 set을 만들어야 한다.
reads와 writes set은 아래와 같이 작성한다.
fd_set reads;
fd_set writes;
이 set들을 초기화 하고 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이 아닌 값을 리턴한다
while (true)
{
// 소켓 셋 초기화
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);
}
// continue...
}
위의 정보를 활용해서 이렇게 소켓을 set에 등록할 수 있게 작성한다.
에코서버를 만들기 때문에 reads와 writes를 구분했는데, reads와 writes 둘 다 등록할 수도 있다.
3. Select
소켓을 다 등록 했다면 select()를 호출해 관찰에 들어간다.
// [옵션] 마지막 timeout 인자 설정 가능
// timeval timeout;
// timeout.tv_sec;
// timeout.tv_usec;
int32 retVal = ::select(0, &reads, &writes, nullptr, nullptr);
if (retVal == SOCKET_ERROR)
break;
select()의 마지막 파라미터가 timeout인데,
이를 설정하여 무한히 소켓들이 준비가 될 때까지 대기할지, 일정 시간만 기다릴지 설정할 수 있다.
일단 여기서는 무한대기를 하게끔 nullptr로 설정했다.
retVal이 -1이 아니라고 하더라도 어떤 소켓이 준비가 되었는지는 당장 알 수 없다.
여기서 FD_ISSET 매크로를 사용해 어떤 소켓이 set에 있는지 확인한다.
select()가 호출되면 준비가 되지 않은 소켓들을 다 set에서 제거하기 때문에,
남아있다는 것은 준비되었다는 것을 의미한다.
// Listner 소켓 체크
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 Connectd" << endl;
sessions.push_back(Session{ 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 : session 제거
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 : session 제거
continue;
}
// 미처 보내지 못한 바이트를 추적하기 위함
s.sendBytes += sendLen;
if (s.recvBytes == s.sendBytes)
{
s.recvBytes = 0;
s.sendBytes = 0;
}
}
}
기본적으로 논 블로킹 형식으로 동작하기 때문에 일부 데이터를 보내지 못하는 상황이 생길 수도 있다.
하지만 일부만 보내야 하는 상황은 거의 없다고 볼 수 있기 때문에 필수적인 요소라고 할 수는 없다.
공식 문서에 따른 FM적인 작성이라고 할 수 있다.
왜 while() 안에서 루프를 도는건지 의아할 수도 있다.
불필요한 반복을 줄인다면서 결국 또 반복 루프를 돈다니...
다시 select()에 대해 생각해 보자.
준비되지 않는 소켓이 있다면 set에서 제거를 한다고 했다.
만약 반복문을 통해 다시 set에 소켓을 등록할 수 없다면 그 소켓에 대한 처리는 영구히 불가능하게 된다.
따라서 모든걸 초기화하고 새로 소켓이 준비되었는지 확인할 필요가 있다.
4. 실행
sendBuffer의 크기가 100이므로 100바이트씩 주고받아야 정상이다.
제대로 100바이트씩 보내고 받고 보내는 에코서버가 완성되었다.
5. 생각할 점
Select 모델은 비교적 구현이 간단하고 이전에 비해 불필요한 반복이 줄었다는 장점이 있다.
하지만 여전히 while()을 통해 반복해 주어야 한다는 단점이 있다.
그러나 이보다 더 큰 단점이 있는데, set에 한번에 등록할 수 있는 소켓의 개수가 매우 적다는 것이다.
FD_SETSIZE라는 값을 들여다 보면,
#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;
64라는 값을 가지고 있다는 것을 알 수 있다.
만약 64개를 초과하는 세션을 관리해야 한다면, 이를 위해 추가적인 set을 생성하고 관리해 주어야만 한다.
6. 마무리
이전엔 무서웠던 개념들이 나이가 들고 보니 친숙해 짐을 느낀다.
마치 어릴 땐 싫었던 음식이 이젠 맛있어진 느낌이라고나 할까.
다른 모델에 대해서도 공부하겠지만, 포스팅을 할지는 모르겠다.
IOCP 라이브러리 작성 등의 IOCP서버에 대한 공부를 본격적으로 시작한다면,
나름 정리되는 대로 포스팅하여 복습의 기회로 삼을 예정이다.
'Study > C++ & C#' 카테고리의 다른 글
[C++] Overlapped Model (0) | 2023.06.16 |
---|---|
[C++] WSAEventSelect Model (0) | 2023.06.14 |
[C++] STL : Vector (0) | 2023.04.18 |
[C++] 스마트 포인터 (0) | 2023.02.04 |
[C++] 다중 포인터 (0) | 2023.01.27 |