[Network] TCP IOCP Model(C st) Concept & Programming

녹차·2026년 1월 9일

Network

목록 보기
10/15
post-thumbnail

Introduction

이번 글에서는 Unity와의 연동이 아닌, Visual Studio 환경에서 동작하는 클라이언트 단독 코드를 통해 TCP IOCP Model을 다룹니다. 구현 언어는 C++이지만, 개념 이해에 집중할 수 있도록 C 스타일의 코드 형식으로 작성하여 관련 기능들을 단계적으로 학습해보겠습니다.

다만 이전 글에서 Non-Blocking & Blocking, Sync & Async I/O의 개념을 배웠다 하더라도 IOCP Model의 동작 원리와 Code를 보면 충분히 어려울 수 있습니다. 저 또한 누군가에게 설명할 수 있도록 여러 번 문서와 책을 봤으며 세세히 설명해보겠습니다.

(이 글은 여러 기술 문서와 교재 등 다양한 자료를 참고하였습니다)

Body

IOCP(I/O Completion Port)는 사전적으로 비동기 I/O 작업의 ‘완료(Completion)’를 하나의 논리적인 ‘포트(Port)’로 모아, 완료된 I/O 결과만을 커널을 통해 효율적으로 전달·처리하기 위한 메커니즘입니다.

예를 들어 하나의 소켓이 완료되는 순간 IOCP는 이 상황을 내장된 Queue에 푸시(push)합니다. 그리고 사용자는 IOCP에서 I/O가 완료되었음을 알려주는 완료 신호(completion event)를 꺼낼 수(pop) 있습니다. 소캣 개수가 1만 개라고 하더라도 이 중에서 I/O가 완료된 것들만 IOCP을 이용해서 바로 얻을 수 있기 때문에 모든 소켓에서 루프를 돌지 않아도 됩니다.

IOCP의 배경

대규모 네트워크 게임 서버 구축을 위해 1990년대부터 많은 시도들이 있었습니다. 하지만 기존 동기식 I/O 방식은 클라이언트 수만큼 스레드가 필요해 자원 낭비가 심했고, Non-Blocking 또한 CPU Busy Waiting 등 한계를 느끼며 이에 Select, WSAAsyncSelet Model이 있었지만 완벽한 해결책이라고 할 수 없었습니다.

이에 Windosw는 이 문제를 Kernel level에서 해결하고 싶었습니다. 그렇기에 1993년쯤 IOCP라는 개념이 등장하고, 2003년 Network Programming for Microsoft Windows 같은 여러 서적에서 소개되면서 본격적으로 널리 알려졌습니다.

IOCP 모델의 동작 원리

IOCP을 제대로 이해할려면 단순히 Code만 보고 이해할 수 없습니다. 그렇기에 위 사진을 토대로 IOCP 동작 원리에 대해 알아보겠습니다. 먼저 아래 세 가지 핵심 요소를 알아야 합니다.

  • Overlapped I/O
  • Completion Port
  • Worker Thread

Overlapped I/O

Overlapped I/O란? I/O 요청을 수행한 스레드가 해당 I/O의 완료를 기다리지 않고 즉시 반환하며, 여러 개의 I/O 요청을 동시에 “진행 중 상태”로 유지할 수 있도록 하는 Windows의 비동기 I/O 처리 방식을 의미합니다.

[MS]
There are two types of input/output (I/O) synchronization: synchronous I/O and asynchronous I/O. Asynchronous I/O is also referred to as overlapped I/O.

[위키백과]
Overlapped I/O is a name used for asynchronous I/O in the Windows API.[1][2][3] It was introduced as an extension to the API in Windows NT.

Q1. 저번 글의 Asyn I/O 개념과 일맥상통하지 않은 느낌이 들어요

좋은 지적입니다. Microsoft 공식 문서와 위키백과의 정의에 따르면 Overlapped I/O와 Asynchronous I/O는 동일 선상에 둡니다. 그러나 이는 Windows 환경에서 Async I/O의 개념을 Overlapped I/O라는 방식으로 구현했기 때문에 발생한 오해입니다.

실제로 Asyn I/O는 OS와 Kernel 차원에서 정의되는 I/O 처리 개념이며, 특정 구현 방식에 종속되지 않습니다. 반면 Overlapped I/O는 Windows에서 Asyn I/O를 구현하기 위해 제공하는 일종의 API입니다.


실제 코드에서는 아래와 같이 SOCKETINFO 구조체에 변수로 정의되어 있으며

struct SOCKETINFO
{
	OVERLAPPED overlapped;
    ...(생략)
}

Completion Port

Completion Port란? Kernel이 완료된 비동기 I/O 작업을 사용자 모드에 통지하기 위해 사용하는 Kernel 내부의 동기화된 완료 이벤트 큐(Completion Event Queue) 객체입니다. 여기서 Port는 Network Port을 가르키는 것이 아닌 Kernel과 Use Mode 사이에서 Event가 오가는 통로입니다.

쉅게 비유하자면 우편이 쌓이는 '우편함'으로 비유할 수 있습니다.

Completion Port는 Overlapped I/O로 요청된 I/O 작업이 완료되는 순간 해당 I/O에 대한 completion packet을 생성하여 이를 Queue에 넣습니다.

completion packet에는 아래와 같은 정보가 들어있습니다.

  • 실제 전송된 Byte 수
  • 해당 I/O가 속한 객체를 식별하기 위한 Completion Key
  • 어떤 I/O 요청이 완료되었는지를 나타내는 Overlapped Pointer

이 세 가지 정보는 비동기I/O 하나의 완성 결과를 식별하기 위한 최소 단위의 결과물입니다.

만일 Queue가 비어있으면 WorkThread는 Sleep 상태로 대기하고, 이후 Queue에 항목이 들어오면 필요한 수의 Thread만 Wake 상태로 바꿉니다.

따라서 맨 위 사진처럼 비동기 I/O 작업은 완료되는 순간 입출력 완료 포트(Completion Port)에 내장된 큐로 전달되며, WorkThread는 이 큐로부터 완료된 I/O만을 효율적으로 받아 처리할 수 있습니다.


위에 설명들은 커널 내부 일어나기에 우리는 Completion Port가 Kernel 내에서 실행될 수 있도록 WSARecv(), WSASend() 함수를 사용합니다.

retval = WSARecv(..., &ptr->overlapped, NULL);
...(생략)
retval = WSASend(..., &ptr->overlapped, NULL);

마찬가지로 Queue가 비어있는지, 차있는지 검사하기 위해 무한 루프를 통해 Worker Thread가 Sleep 혹은 Wake 상태가 될 수 있도록 GetQueuedCompletionStatus() 함수를 사용합니다.

while (true)
{
	...(생략)
	GetQueuedCompletionStatus(...)
}

Worker Thread

WorkThread란? I/O Completion Port에 연결되어, 완료된 비동기 I/O 결과(Completion Packet)를 처리하여 실제 로직을 처리하는 스레드입니다.

쉅게 비유하자면 우편함을 있는 여러 봉투들을 각각의 집에 배달해주는 "배달원"으로 비유할 수 있습니다.

따라서 맨 위 사진처럼 Worker Thread는 Kernel이 완료한 비동기 I/O의 결과가 Completion Port의 Queue에 도착했을 때 이를 GetQueuedCompletionStatus() 함수를 통해 수신하고 사용자 로직을 처리하는 역할을 담당합니다.


WorkThread 개수는 어떤 작업을 하냐따라 달라집니다. 정해진 개수는 없기에 관례적으로 CPU가 집중적인 작업이라면 CPU코어수 혹은 CPU코어수 + 1의 개수를 사용하고 I/O 집중적인 작업이라면 CPU * 2 혹은 CPU * 4 의 개수를 이용합니다. 이를 IOCP Worker Thread Pool이라고 합니다.

//CPU 개수 확인
SYSTEM_INFO si;
GetSystemInfo(&si);

//(CPU 개수 * 2)개의 작업자 스레드 생성
HANDLE hThread;

for (int i = 0; i < (int)si.dwNumberOfProcessors * 2; i++)
{
	hThread = CreateThread(NULL, 0, WorkerThread, hcp, 0, NULL);
	if (hThread == NULL) return 1;
	CloseHandle(hThread);
}

Worker Thread는 GetQueuedCompletionStatus() 함수를 통해 완료된 비동기 I/O 하나의 결과 정보를 흭득합니다. WorkThread는 이렇게 전달받은 I/O 결과를 바탕으로 로직을 수행합니다. 예제 코드에서는 Echo 서버이므로 수신한 데이터를 그대로 출력합니다

while (true)
{
	...(생략)
	GetQueuedCompletionStatus(...)
}

IOCP 주요 함수

IOCP 모델의 동작 원리를 이해했다면, 이제 실제 코드에서 핵심적으로 사용되는 함수들을 살펴볼 차례입니다. GetQueuedCompletionStatus() 외에도 IOCP 모델을 구성하는 중요한 함수들이 있습니다.

입출력 완료 포트 생성

CreateIoCompletionPort() 함수는 두 가지 역할을 합니다. 입출력 완료 포트를 새로 생성하는 일이고, 또 하나는 소켓과 입출력 완료 포트를 연결하는 일입니다. 소켓과 입출력 완료 포트를 연결해두면 이 소켓에 대한 비동기 입출력 결과가 입출력 완표 포트에 저장됩니다.

HANDLE CreateIoCompletionPort(
  HANDLE    FileHandle,
  HANDLE    ExistingCompletionPort,
  ULONG_PTR CompletionKey,
  DWORD     NumberOfConcurrentThreads
);
  • FileHandle : 입출력 완료 포트와 연결할 파일 핸들입니다. 파일 입출력에서는 파일 핸들을 소켓 프로그래밍에서는 소켓을 넣어주면 됩니다. 새로운 입출력 완료 포트를 생성할 때는 유효한 핸들 대신 INVALID_HANDLE_VALUE 값을 사용해도 됩니다.

  • ExistingCompletionPort : 파일 또는 소켓과 연결할 입출력 완료 포트 핸들입니다. 이 값이 NULL이면 새로운 입출력 완료 포트를 생성합니다.

  • CompletionKey : 입출력 완료 패킷에 들어갈 부가 정보입니다. 여기서 입출력 완료 패킷이란?
    비동기 입출력 작업이 완료할 때마다 생성되어 입출력 완료 포트에 저장되는 정보입니다.

  • NumberOfConcurrentThreads : 동시에 실행될 수 있는 작업자 스레드의 개수입니다. 0을 사용하면 자동으로 CPU 개수와 같은 수로 설정됩니다. OS는 실행 중인 작업자 스레드 개수가 여기서 설정한 값을 넘지 않도록 관리해줍니다.

실제 코드에서 전자의 역할은 첫번째 예제, 후자의 역할을 두번째 예제입니다.

HANDLE hcp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
//소켓과 입출력 완료 포트 연결
CreateIoCompletionPort((HANDLE)client_sock, hcp, client_sock, 0);

비동기 입출력(데이터 송/수신)

표준 소켓 함수의 send() 함수와 recv() 함수 대신 WSASend() 함수와 WSARecv() 함수를 사용해야 비동기 입출력을 할 수 있습니다. 아래는 함수에 대한 원형입니다.

int WSASend(
    SOCKET s,
    LPWSABUF lpBuffers,
    DWORD dwBufferCount,
    LPDWORD lpNumberOfBytesSent,
    DWORD dwFlags,
    LPWSAOVERLAPPED lpOverlapped,
    LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
int WSARecv(
    SOCKET s,
    LPWSABUF lpBuffers,
    DWORD dwBufferCount,
    LPDWORD lpNumberOfBytesRecvd,
    LPDWORD lpFlags,
    LPWSAOVERLAPPED lpOverlapped,
    LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
  • s : 비동기 입출력을 시작할 대상 소켓입니다.
  • lpBuffers, dwBufferCount : 데이터 송/수신에 사용할 버퍼를 WSABUF 구조체 배열의 시작 주소와 배열의 요소 개수로 나타냅니다.
  • lpNumberOfBytesRecvd, lpNumberOfBytesSent: 함수의 호출이 성공하면 보내거나 받은 바이트 수가 저장됩니다.
  • lpFlags, dwFlags : 옵션으로 MSG_ 형태의 상수를 전달할 수 있는데 대부분 0을 사용하면 됩니다.
  • lpOverlapped :
  • lpCompletionRoutine : 입출력 작업을 완료하면 OS가 자동으로 호출할 완료 루틴(콜백 함수)의 주솟값입니다.
Q1, accept(), connect() 함수는 Blocking Socket인데 IOCP에 사용되나요?

좋은 지적입니다. 실제로 accept(), connect() 이 아닌 AcceptEx(), ConnectEx() 함수를 이용합니다. 하지만 예제에서는 IOCP에 대한 이해를 목적으로 작성했기에 TCP IOCP Model Plus에서 다루겠습니다.

비동기 입출력 결과 확인

BOOL GetQueuedCompletionStatus(
    HANDLE       CompletionPort,
    LPDWORD      lpNumberOfBytesTransferred,
    PULONG_PTR   lpCompletionKey,
    LPOVERLAPPED *lpOverlapped,
    DWORD        dwMilliseconds
);
  • CompletionPort : 입출력 완료 포트 핸들입니다.
  • lpNumberOfBytesTransferred : 비동기 입출력 작업으로 전송된 바이트 수가 여기에 저장됩니다.
  • lpCompletionKey : CreateIoCompletionPort() 함수 호출 시 전달한 세 번째 인수가 여기에 저장됩니다.
  • lpOverlapped : 비동기 입출력 함수 호출 시 전달한 OVERLAPPED 구조체의 주솟값이 여기에 저장됩니다.
  • dwMilliseconds : WorkerThread가 대기할 시간을 밀리초 단위로 저장합니다. INFINITE 값을 넣으면 입출려 완표 패킷이 생성되어 OS가 깨울 때까지 무한히 대기합니다.

참고로, 응용 프로그램이 WorkerThread에 특별한 사실을 알리기 위해 직접 입출력 완료 패킷을 생성할 수 있습니다. 이때 사용하는 함수는 PostQueueCompletionStatus() 함수입니다.

IOCP TCP Programming(C st)

아래 예제 코드는 IOCP Model 기반의 대표적인 Echo 서버 구조를 보여줍니다. 내용이 길고 처음 보면 다소 복잡해 보이기에 부분적으로 보며 설명해보겠습니다.

#include<winsock2.h>
#include<ws2tcpip.h>
#include<iostream>

#pragma comment(lib, "ws2_32.lib");

#define SERVERPORT 9000
#define BUFSIZE 512

using std::cout;
using std::endl;

struct SOCKETINFO
{
	OVERLAPPED overlapped;
	SOCKET sock;
	char buf[BUFSIZE + 1];
	int recvbytes;
	int sendbytes;
	WSABUF wsbuf;
};

//Worker Thread 함수
DWORD WINAPI WorkerThread(LPVOID arg);

DWORD WINAPI WorkerThread(LPVOID arg)
{
	int retval;
	HANDLE hcp = (HANDLE)arg;

	while (true)
	{
		//비동기 입출력 완료 기다리기
		DWORD cbTransferred;
		SOCKET client_sock;
		SOCKETINFO* ptr;
		retval = GetQueuedCompletionStatus(hcp, &cbTransferred, (PULONG_PTR)&client_sock, (LPOVERLAPPED*)&ptr, INFINITE);

		//Client 정보(IP 주소, 포트 번호) 얻기
		struct sockaddr_in clientaddr;
		int addrlen = sizeof(clientaddr);
		getpeername(ptr->sock, (struct sockaddr*)&clientaddr, &addrlen);
		char addr[INET_ADDRSTRLEN];
		inet_ntop(AF_INET, &clientaddr.sin_addr, addr, sizeof(addr));

		//비동기 입출력 결과 확인
		if (retval == 0 || cbTransferred == 0)
		{
			cout << "[TCP Server] Client 종료 : IP주소 = " << addr << ", 포트 번호 = " << ntohs(clientaddr.sin_port) << endl;
			closesocket(ptr->sock);
			delete ptr;
			continue;
		}

		//데이터 전송량 갱신
		if (ptr->recvbytes == 0)
		{
			ptr->recvbytes = cbTransferred;
			ptr->sendbytes = 0;

			//받은 데이터 출력
			ptr->buf[ptr->recvbytes] = '\0';
			cout << "[TCP/" << addr << " : " << ntohs(clientaddr.sin_port) << "] " << ptr->buf << endl;
		}
		else
		{
			ptr->sendbytes += cbTransferred;
		}

		if (ptr->recvbytes > ptr->sendbytes)
		{
			//데이터 보내기
			memset(&ptr->overlapped, 0, sizeof(ptr->overlapped));
			ptr->wsbuf.buf = ptr->buf + ptr->sendbytes;
			ptr->wsbuf.len = ptr->recvbytes - ptr->sendbytes;

			DWORD sendbytes;
			retval = WSASend(ptr->sock, &ptr->wsbuf, 1, &sendbytes, 0, &ptr->overlapped, NULL);
			if (retval == SOCKET_ERROR)
			{
				if (WSAGetLastError() != WSA_IO_PENDING)
				{
					//TODD : 애러 구현
				}
				continue;
			}
		}
		else
		{
			ptr->recvbytes = 0;

			//데이터 받기
			memset(&ptr->overlapped, 0, sizeof(ptr->overlapped));
			ptr->wsbuf.buf = ptr->buf;
			ptr->wsbuf.len = BUFSIZE;

			DWORD recvbytes;
			DWORD flags = 0;

			retval = WSARecv(ptr->sock, &ptr->wsbuf, 1, &recvbytes, &flags, &ptr->overlapped, NULL);
			if (retval == SOCKET_ERROR)
			{
				if (WSAGetLastError() != WSA_IO_PENDING)
				{
					//TODD : 애러 구현
				}
				continue;
			}
		}
	}
}

int main()
{
	int retval;
	WSADATA wsa;

	if (WSAStartup(MAKEWORD(2, 2, ), &wsa) != 0) return 1;

	HANDLE hcp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
	if (hcp == NULL) return 1;

	//CPU 개수 확인
	SYSTEM_INFO si;
	GetSystemInfo(&si);

	//(CPU 개수 * 2)개의 작업자 스레드 생성
	HANDLE hThread;

	for (int i = 0; i < (int)si.dwNumberOfProcessors * 2; i++)
	{
		hThread = CreateThread(NULL, 0, WorkerThread, hcp, 0, NULL);
		if (hThread == NULL) return 1;
		CloseHandle(hThread);
	}

	//Socket 생성
	SOCKET listen_sock = socket(AF_INET, SOCK_STREAM, 0);
	if (listen_sock == INVALID_SOCKET)
	{
		//TODD : 애러 구현
	}

	//bind()
	struct 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(SERVERPORT);
	retval = bind(listen_sock, (struct sockaddr*)&serveraddr, sizeof(serveraddr));
	if (retval == SOCKET_ERROR)
	{
		//TODD : 애러 구현
	}

	//listen()
	retval = listen(listen_sock, SOMAXCONN);
	if (retval == SOCKET_ERROR)
	{
		//TODD : 애러 구현
	}

	//데이터 통신에 사용할 변수
	SOCKET client_sock;
	struct sockaddr_in clientaddr;
	int addrlen;
	DWORD recvbytes, flags;

	while (true)
	{
		//accept()
		addrlen = sizeof(clientaddr);
		client_sock = accept(listen_sock, (struct  sockaddr*)client_sock, &addrlen);

		if (client_sock == INVALID_SOCKET)
		{
			//TODD : 애러 구현
			break;
		}

		//접속한 클라이언트 정보 출력
		char addr[INET_ADDRSTRLEN];
		inet_ntop(AF_INET, &clientaddr.sin_addr, addr, sizeof(addr));
		cout << "[TCP Server] Client connected : IP주소 = " << addr << ", 포트 번호 = " << ntohs(clientaddr.sin_port) << endl;

		//소켓과 입출력 완료 포트 연결
		CreateIoCompletionPort((HANDLE)client_sock, hcp, client_sock, 0);

		//소켓 정보 구조체 할당
		SOCKETINFO* ptr = new SOCKETINFO;
		if (ptr == NULL) break;

		memset(&ptr->overlapped, 0, sizeof(ptr->overlapped));
		ptr->sock = client_sock;
		ptr->recvbytes = ptr->sendbytes = 0;
		ptr->wsbuf.buf = ptr->buf;
		ptr->wsbuf.len = BUFSIZE;

		//비동기 입출력 시작
		flags = 0;
		retval = WSARecv(client_sock, &ptr->wsbuf, 1, &recvbytes, &flags, &ptr->overlapped, NULL);

		if (retval == SOCKET_ERROR)
		{
			if (WSAGetLastError() != ERROR_IO_PENDING)
			{
				//TODD : 애러 구현
			}
			continue;
		}
	}

	WSACleanup();
	return 0;
}

SOCKETINFO 구조체

struct SOCKETINFO
{
	OVERLAPPED overlapped;
	SOCKET sock;
	char buf[BUFSIZE + 1];
	int recvbytes;
	int sendbytes;
	WSABUF wsbuf;
};

기존 Select Model의 SOCKETINFO의 차이점은 구조체 OVERLAPPEDWSABUF입니다. 앞서 OVERLAPPED 대해 설명했기에 WSABUF에 대해 설명하겠습니다. 해당 구조체의 원형은 아래와 같습니다.

typedef struct _WSABUF {
    ULONG len;
    CHAR* buf;
} WSABUF;

WSABUF 구조체는 WinSock에서 송수신 버퍼를 표현하기 위해 사용되며, 특히 WSARecv(), WSASend() 함수에서 비동기 I/O의 핵심 데이터 단위로 활용됩니다.

또한 WinSock은 Scatter & Gather I/O라는 것을 지원하여, 여러 개의 버퍼를 하나의 I/O 요청으로 묶어 한 번에 송수신할 수 있도록 설계되어 있습니다.

Q1, 왜 굳이 따로따로 안보내고 모아서 보내요?

이는 여러 번 송수신 호출로 인한 System Call Cost와 Context Swiching을 줄이고, 불필요한 버퍼 복사를 최소화하여 전체 네트워크 성능을 향상시키기 위해서입니다.

int main()

기존 TCPServer.cpp와 똑같은 부분은 설명을 생략하고, IOCP Model에서 중요한 부분만 다루겠습니다.

입출력 완료 포트를 생성한 뒤, CPU 코어 수 * 2를 기준으로 Worker Thread Pool을 구성합니다. 이때 Worker Thread는 GetQueuedCompletionStatus() 함수를 호출하여 대기 상태가 됩니다.

HANDLE hcp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
if (hcp == NULL) return 1;

//CPU 개수 확인
SYSTEM_INFO si;
GetSystemInfo(&si);

//(CPU 개수 * 2)개의 작업자 스레드 생성
HANDLE hThread;

for (int i = 0; i < (int)si.dwNumberOfProcessors * 2; i++)
{
	hThread = CreateThread(NULL, 0, WorkerThread, hcp, 0, NULL);
	if (hThread == NULL) return 1;
	CloseHandle(hThread);
}

이후 클라이언트와의 연결이 완료되면, client_sock을 입출력 완료 포트에 연결하여 client_sock에서 발생하는 비동기 I/O 완료 이벤트가 IOCP(hcp)를 통해 전달되도록 설정합니다.

//소켓과 입출력 완료 포트 연결
CreateIoCompletionPort((HANDLE)client_sock, hcp, client_sock, 0);

client_sockIOCP(hcp)에 연결했다고 해서 자동을 데이터 수신이 시작되는 것은 아닙니다. IOCP는 I/O 완료 결과를 전달하는 메커니즘일 뿐, 실제 I/O 요청은 응용 프로그램이 직접 등록해야 합니다.

따라서 client_sock에 대해 WSARecv() 함수를 호출하여 비동기 수신 I/O를 명시적으로 등록해야 합니다. 이는 “앞으로 client_sock에 데이터가 도착하면 알려 달라”는 요청을 Kernel에 등록하는 단계입니다.

쉽게 비유하자면 "택배 오면 경비실에 맡겨주세요"와 같은 뜻입니다.

만일 클라이언트로부터 아직 입력이 없는 경우(택배가 도착하지 않는 경우), WSARecv()는 I/O를 곧바로 완료할 수 없으므로 SOCKET_ERROR를 반환하고 오류 코드로 WSA_IO_PENDING 을 설정합니다.

  • WSA_IO_PENDING : “아직 완료되지 않았지만, Kernel이 비동기 I/O 요청을 정상적으로 접수했고 완료 시 IOCP를 통해 통지하겠다”는 신호입니다.

이후 클라이언트로부터 입력 들어왔을 경우(택배가 도착한 경우), Kernel은 해당 비동기 I/O 작업을 완료하고, 그 결과를 입출력 완료 포트(IOCP)의 큐에 등록합니다. 이 순간 GetQueuedCompletionStatus()에서 대기 중이던 Worker Thread가 깨어나 완료된 I/O를 처리하게 됩니다.

완료된 I/O를 처리하기 위해 하나의 비동기 I/O 요청에 필요한 모든 정보를 보관할 구조체를 생성합니다. 이때 반드시 0으로 초기화해야 예측 불가능한 동작을 방지할 수 있습니다.

SOCKETINFO* ptr = new SOCKETINFO;
		if (ptr == NULL) break;
 
memset(&ptr->overlapped, 0, sizeof(ptr->overlapped));

Worker Thread가 추가적인 정보 탐색 없이도 즉시 처리를 수행할 수 있도록 해당 I/O 요청의 소유자 & 수신 버퍼 & 처리 기준 정보를 미리 구조체에 저장합니다.

ptr->sock = client_sock; 
ptr->recvbytes = ptr->sendbytes = 0;
ptr->wsbuf.buf = ptr->buf;
ptr->wsbuf.len = BUFSIZE;

정리하자면 구체적으로 main()은 아래와 같은 역할을 합니다.

  • 입출력 완료 포트(IOCP)를 생성
  • CPU 코어 수를 기준으로 Worker Thread Pool을 구성
  • 클라이언트 접속을 수락하고 소켓을 IOCP에 연결
  • 각 클라이언트에 대해 최초의 비동기 수신 I/O(WSARecv) 함수를 등록

이후 실제 데이터 수신, 송신, 연결 종료와 같은 모든 I/O 완료 처리는 GetQueuedCompletionStatus()로 깨어난 Worker Thread에서 수행되며, main()은 더 이상 I/O 처리 흐름에 직접 관여하지 않습니다.

Work Thread

이제 다음으로, 실제로 I/O 완료 통지를 받아 처리하는 WorkerThread() 함수의 동작 흐름을 살펴보겠습니다.

먼저, 클라이언트는 언제든지 데이터를 보낼 수 있으며, 비동기 I/O 완료 시점 또한 예측할 수 없습니다. 이러한 특성 때문에 WorkerThread는 무한 루프 구조를 가지지만, 실제로는GetQueuedCompletionStatus() 함수에서 대기 상태로 머물며 CPU를 소모하지 않습니다.

비동기 I/O가 완료되는 순간에만 Kernel에 의해 깨어나 완료된 I/O를 처리하고, 처리가 끝나면 다시 GetQueuedCompletionStatus() 함수를 호출하여 다음 I/O 완료 이벤트를 기다리게 됩니다.

while(true)
{
	...(생략)
	retval = GetQueuedCompletionStatus(hcp, &cbTransferred, (PULONG_PTR)&client_sock, (LPOVERLAPPED*)&ptr, INFINITE);
}

Work Thread가 I/O 완료 이벤트로 깨어나면 화면 출력을 위해 Client의 IP 주소와 포트 번호를 조회합니다. 만일 비동기 I/O 결과를 확인하여 I/O 자체가 실패하거나 또는 수신된 데이터 크기가 0인 경우에는 클라이언트 정상적으로 연결을 종료한 것으로 판단합니다.

struct sockaddr_in clientaddr;
int addrlen = sizeof(clientaddr);
getpeername(ptr->sock, (struct sockaddr*)&clientaddr, &addrlen);
char addr[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &clientaddr.sin_addr, addr, sizeof(addr));

if (retval == 0 || cbTransferred == 0)
{
	cout << "[TCP Server] Client 종료 : IP주소 = " << addr << ", 포트 번호 = " << ntohs(clientaddr.sin_port) << endl;
	closesocket(ptr->sock);
	delete ptr;
	continue;
}

WorkerThread는 GetQueuedCompletionStatus() 함수를 통해 어떤 비동기 I/O가 완료되었다는 사실만 전달받습니다. 하지만 이 완료 이벤트가 수신(WSARecv)인지 송신(WSASend)인지는 운영체제가 직접 구분해서 알려주지 않습니다.

따라서 이 코드에서는 recvbytes 값을 기준으로 완료된 I/O의 종류를 판단합니다. 수신 완료 시(recvbytes == 0)에는 수신된 데이터 크기를 기록하고, 송신 완료 시(recvbytes != 0)에는 실제 전송된 바이트룰 수를 누적하여 부분 송신(Partial Send)을 안전하게 처리합니다.

if (ptr->recvbytes == 0)
{
	ptr->recvbytes = cbTransferred;
	ptr->sendbytes = 0;

	//받은 데이터 출력
	ptr->buf[ptr->recvbytes] = '\0';
	cout << "[TCP/" << addr << " : " << ntohs(clientaddr.sin_port) << "] " << ptr->buf << endl;
}
else
{
		ptr->sendbytes += cbTransferred;
}

.
.
.

if (ptr->recvbytes > ptr->sendbytes)
{
	...(생략)
    retval = WSASend(ptr->sock, &ptr->wsbuf, 1, &sendbytes, 0, &ptr->overlapped, NULL);
}
else
{
	...(생략)
	retval = WSARecv(ptr->sock, &ptr->wsbuf, 1, &recvbytes, &flags, &ptr->overlapped, NULL);
}

Conclusion

이번 글에서는 Windows 환경에서 고성능 네트워크 서버를 구현하기 위한 핵심 메커니즘인 TCP IOCP(I/O Completion Port) 모델을 개념부터 실제 코드 흐름까지 단계적으로 살펴보았습니다.

특히 다음과 같은 점들을 이해하는 것이 중요합니다.

  • IOCP는 I/O를 수행하는 구조가 아니라 I/O 완료 결과를 전달하는 메커니즘입니다.
  • 소켓을 IOCP에 연결해도 자동으로 통신이 시작되지 않으며, 반드시 비동기 I/O를 명시적으로 등록해야 합니다.
  • Worker Thread는 무한 루프를 돌지만 Busy Waiting이 아닌, GetQueuedCompletionStatus()에서 대기 → 깨어남 → 처리 → 재대기 흐름을 반복합니다.
  • 하나의 완료 이벤트는 “수신인지 송신인지”를 직접 알려주지 않기 때문에, 서버가 상태를 직접 관리해야 합니다.

이러한 구조 덕분에 IOCP 모델은 수천~수만 개의 소켓을 다루더라도 불필요한 스레드 생성 없이, 완료된 작업만 효율적으로 처리할 수 있는 확장성 높은 서버 구조를 제공합니다.

다만 이번 글의 예제는 IOCP의 전체 그림을 이해하는 데 초점을 맞춘 학습용 Echo 서버이며, 더 나은 게임 서버에서는 다음과 같은 추가 요소들이 필요합니다.

  • AcceptEx, ConnectEx를 이용한 완전한 비동기 연결 처리
  • Session / Packet 구조 분리
  • 에러 처리 및 자원 정리 정책
  • TCP 스트림 기반 패킷 파싱 구조

다음 글에서는 이러한 내용을 확장하여, TCP IOCP Model Plus 형태로 보다 실전적인 서버 구조와 함께 AcceptEx 기반의 연결 처리, 세션 관리 개념을 다뤄볼 예정입니다.

IOCP는 처음 접하면 난이도가 높지만, 한 번 구조를 이해하고 나면 Windows 기반 게임 서버에서 사실상 표준에 가까운 모델임을 체감하게 될 것입니다. 이 글이 IOCP 모델을 이해하는 데 하나의 기준점이 되기를 바랍니다.

Q1 IOCP Model은 TCP가 아닌 UDP로 구현 가능한가요?

좋은 질문입니다. 저 또한 공부할 당시 여러 자료들을 보면서 생각했던 부분이고요 제 결론을 먼저 말하자면 "이론적으로는 가능하나 현실적으로 어렵다"입니다.

그 첫번 째 이유는 UDP의 연결 개념 부재입니다. UDP는 모든 클라이언트가 하나의 소켓으로 데이터를 송수신합니다. 그렇기에 IOCP가 전제로 하는 “소켓 단위의 작업 완료 관리” 구조와 맞지 않습니다.

두 번째 이유는 IOCP의 Completion 통지 구조가 UDP에서는 의미가 약해지기 때문입니다. IOCP는 일반적으로 Completion Key를 통해 “어떤 세션(소켓)의 I/O가 완료되었는지”를 빠르게 식별하도록 설계되었습니다. UDP에서는 하나의 소켓으로 여러 클라이언트의 패킷이 수신되므로, Completion 통지를 받아도 어떤 클라이언트의 데이터인지 추가적인 과정이 필요합니다.

그렇기에 IOCP Model은 주로 TCP에서 사용됩니다.

참고문헌

[교보문구]

[MS]

[위키백과]

profile
CK23 Game programmer

0개의 댓글