이번 글에서는 Unity와의 연동이 아닌, Visual Studio 환경에서 동작하는 클라이언트 단독 코드를 통해 TCP IOCP Model을 다룹니다. 구현 언어는 C++이지만, 개념 이해에 집중할 수 있도록 C 스타일의 코드 형식으로 작성하여 관련 기능들을 단계적으로 학습해보겠습니다.
다만 이전 글에서 IOCP를 공부하고 구현했다 하더라도 이번 Session 구조 & AcceptEx() 함수를 보면 충분히 어려울 수 있습니다. 저 또한 누군가에게 설명할 수 있도록 여러 번 문서와 책을 봤으며 세세히 설명해보겠습니다.
(이 글은 여러 기술 문서와 교재 등 다양한 자료를 참고하였습니다)
Session이란? 특정 클라이언트와의 통신을 위해 필요한 소켓(SOCKET), 송 & 수신 버퍼, 그리고 비동기 I/O 제어 정보(OVERLAPPED)를 하나로 묶은 논리적 단위입니다.
IOCP 기반 서버에서 클라이언트 하나는 일반적으로 하나의 Session 객체로 관리되며, 이 Session은 해당 클라이언트의 연결 상태와 네트워크 I/O 흐름 전체를 대표합니다.
Q1, 기존 SOCKETINFO의 어떤 문제점 때문에 Session 구조를 쓰나요?
기존 Worker Thread는 GQCS 함수를 통해 전달받은 OVERLAPPED 포인터로 송신인지 수신인지 구분하여 데이터를 전달하였습니다. 만일 송신과 수신이 같이 이루어진다면 커널은 어느 작업이 완료된 것인지 구분하지 못하거나 오류를 발생시킵니다. 또한 모든 SOCKETINFO가 고정된 BUFSIZE을 가지고 있습니다. 이는 데이터 송수신 과정에서 불필요한 메모리를 낭비하여 서버 전체의 성능을 낮출 수 있습니다.
그렇기에 Session 객체를 만들어 각 클라이언트마다 관리하여 고성능 IOCP 모델을 만들 수 있습니다. Session 특징은 아래와 같습니다.
쉅게 비유하자면 아래와 같습니다.
- SOCKETINFO : 우체부가 들고 있는 봉투
- Session = “한 사람의 우편함 + 신분증 + 상태”
앞서 언급했듯 IOCP는 완료 통지만 할 뿐 어떤 I/O인지 구분을 하지 못한다고 했습니다. 그렇기에 개발자가 직접 Enum 타입으로 구분해야 합니다. 이는 이후에 WorkerThread에서 구현합니다.
enum IOOperation
{
ACCEPT,
RECV,
SEND
};
또한 여러 I/O를 동시에 처리하기 위해서 각 I/O 작업마다 독립된 OVERLAPPED 구조체가 필요합니다. 그렇기에 기존 OVERLAPPED 구조체에서 확장하여 아래와 같이 작성합니다.
struct OVERLAPPED_EX
{
OVERLAPPED overlapped;
IOOperation operation;
WSABUF wsabuf;
char buffer[BUFSIZE + 1];
SOCKET acceptSocket; //이후 AcceptEx() 함수를 다룰 때 위한 선언
};
구조체
WSAOVERLAPPED와OVERLAPPED는 사실상 같습니다.
하나의 클라이언트마다 하나의 Session이 필요하기에 기본적으로 class 혹은 struct로 작성됩니다. 이번 Code에서는 이해를 위해 struct로 작성했습니다.
struct Session
{
SOCKET sock;
OVERLAPPED_EX recvOverlapped;
OVERLAPPED_EX sendOverlapped;
Session()
{
sock = INVALID_SOCKET;
recvOverlapped.operation = RECV;
sendOverlapped.operation = SEND;
}
};
AcceptEx()는 기존 accept() 함수처럼 호출 시점에 연결을 처리하는 방식이 아니라, 연결 대기 자체를 비동기 I/O 요청으로 커널에 등록하고 연결이 완료되었을 때 IOCP를 통해 통지받는 방식입니다.
기존 accept() 함수는 non-blocking으로도 사용할 수 있지만, 많은 접속을 처리하는 서버에서는 accept 호출 자체가 네트워크 스레드의 흐름을 방해할 수 있습니다.
AcceptEx()는 이러한 문제를 해결하기 위해 미리 여러 개의 accept 요청을 커널에 등록(pre-post)할 수 있고, 이를 통해 접속 폭주 상황에서도 안정적인 처리가 가능합니다.
Q1, 기존 accept() 함수를 써도 잘 돌아가는거 같은데 왜 AcceptEx() 함수를 써요?
소규모 서버나 접속 빈도가 낮은 환경에서는 accept()만으로도 충분히 동작합니다. 그러나 대규모 동시 접속 환경에서는 연결 요청 자체도 하나의 I/O 작업으로 취급해야 하며, 이를 IOCP 기반의 비동기 모델로 통합하기 위해 AcceptEx()를 사용합니다.
AcceptEx() 함수는 Winsock 확장 함수이기에 기존 accept(), recv()와 달리 함수 포인터를 통해 런타임에 직접 흭득해야 합니다. 그래서 mswosock.h 정의된 함수 포인터 타입을 사용하여 아래와 같이 선언합니다.
#include<mswsock.h>
LPFN_ACCEPTEX lpfnAcceptEx = NULL;
LPFN_GETACCEPTEXSOCKADDRS lpfnGetAcceptExSockaddrs = NULL;
AcceptEx() : 연결 대기 자체를 IOCP 기반 비동기 I/O 요청으로 등록하는 함수 GetAcceptExSockaddrs() : AcceptEx가 채운 버퍼로부터 로컬 주소와 원격 주소 정보를 안전하게 추출하는 함수이는 main() 함수 내에서 아래와 같이 사용합니다. WSAIoctl 함수에 전달된 입력 버퍼에는 값이 AcceptEx 확장 함수를 식별하는 GUID(Globally Unique Identifier)인 WSAID_ACCEPTEX 포함되어야 합니다. 성공하면 WSAIoctl 함수에서 반환된 출력에 AcceptEx 함수에 대한 포인터가 포함됩니다. WSAID_GETACCEPTEXSOCKADDRS도 마찬가지입니다.
int main()
{
...(생략)
GUID guidAcceptEx = WSAID_ACCEPTEX; //mswsock.h 헤더파일에 정의되어 있습니다.
GUID guidGetAccpetExSockaddrs = WSAID_GETACCEPTEXSOCKADDRS; //mswsock.h 헤더파일에 정의되어 있습니다.
DWORD dwBytes;
retval = WSAIoctl(listen_sock, SIO_GET_EXTENSION_FUNCTION_POINTER,
&guidAcceptEx, sizeof(guidAcceptEx),
&lpfnAcceptEx, sizeof(lpfnAcceptEx),
&dwBytes, NULL, NULL);
if (retval == SOCKET_ERROR) {
//TODD : 애러 구현
}
retval = WSAIoctl(listen_sock, SIO_GET_EXTENSION_FUNCTION_POINTER,
&guidGetAccpetExSockaddrs, sizeof(guidGetAccpetExSockaddrs),
&lpfnGetAcceptExSockaddrs, sizeof(lpfnGetAcceptExSockaddrs),
&dwBytes, NULL, NULL);
if (retval == SOCKET_ERROR)
{
//TODD : 애러 구현
}
}
이후 앞서 말했듯이
Q1. 세번 째, 네 번째 함수에 +16을 한 이유가 있나요?
좋은 질문입니다. 아마 처음 코드를 본다면 왜 다른 숫자도 아니고 + 16인가? 의문을 들 것입니다. + 16을 쓴 이유는 MS문서에서 sockaddr 구조 크기보다 16byte 커야한다는 명시때문입니다.
[MS]
The buffer size for the local and remote address must be 16 bytes more than the size of the sockaddr structure for the transport protocol in use because the addresses are written in an internal format.
Q2. Socket Context Update를 해야하는 이유가 있나요?
위 질문과 마찬가지로 이 또한 MS문서에 SO_UPDATE_ACCEPT_CONTEXT 옵션 사용시 getpeername() , getsockname() 같은 수락된 소켓과 연결된 로컬 주소를 검색할 수 있다고 나와있습니다. 그렇기에 위와 같이 작성하는 것을 권장합니다.
//# Socket Context Update
setsockopt(pOvEx->acceptSocket, SOL_SOCKET, SO_UPDATE_ACCEPT_CONTEXT, (char*)&listen_sock, sizeof(listen_sock));
[MS]
The socket sAcceptSocket does not inherit the properties of the socket associated with sListenSocket parameter untilSO_UPDATE_ACCEPT_CONTEXTis set on the socket. Use the setsockopt function to set theSO_UPDATE_ACCEPT_CONTEXToption
SO_UPDATE_ACCEPT_CONTEXT : 수신 소켓의 Context를 사용하여 수락 소켓을 업데이트합니다.[MS]