1. 들어가기에 앞서
지난 게시글에서 Overlapped 모델의 2가지 사용 방식에 대해 알아봤었다. 그중 콜백 기반 방식의 동작을 요약하면 아래와 같다.
- 비동기 입출력 함수가 완료되면, 스레드마다 들고 있는 APC Queue에 작업(콜백 함수)이 Push
- 입출력 함수를 호출한 스레드가 Alertable Wait 상태가 되면, APC Queue에 있는 콜백 함수를 Pop 하며 수행
Overlapped 모델의 2가지 방식(이벤트 기반, 콜백 기반)은 각각 단점이 존재했다.
- 이벤트 기반은 소켓과 이벤트를 1:1로 일일이 대응시켜야 한다.
- 콜백 기반은 각 스레드마다 APC Queue가 존재해 입출력 작업 분배가 불편하며, 잦은 Alertable Wait 상태 전환으로 인해 성능 저하가 발생한다.
이번에 알아볼 IOCP 모델은 아래와 같은 방식으로 동작한다.
- 스레드마다 갖고 있던 APC Queue 대신, 중앙에 Completion Port라고 하는 단일 입출력 큐를 둔다.
- Alertable Wait 상태에 진입하면 APC Queue로부터 일감을 처리했던 것처럼, GetQueuedCompletionStatus라는 API를 사용해 중앙 Completion Port에 들어있는 일감을 처리한다.
이러한 방식은 기존 Overlapped 모델의 단점을 개선하며, 멀티스레드 환경에 적합하여 높은 성능을 이끌어낼 수 있는 장점이 있다.
2. 코드
1. Main Thread
Completion Port를 생성하는 작업과 Completion Port에 소켓을 등록하는 작업을 수행하는 API가 존재한다: CreateIoCompletionPort
HANDLE CompletionPort = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, 0, 0);
최초에 Completion Port를 생성할 때는 API 호출 시 인자를 위와 같이 넘겨준다.
// Main Thread: Accept 담당
while (true)
{
SOCKADDR_IN ClientAddr;
int AddrLen = sizeof(ClientAddr);
SOCKET ClientSocket = ::accept(ListenSocket, reinterpret_cast<SOCKADDR*>(&ClientAddr), &AddrLen);
if (ClientSocket == INVALID_SOCKET)
{
return 1;
}
cout << "Client Connected!\n";
...
}
이전 시간에도 얘기했지만 비동기 버전 accept 함수 AcceptEx가 있다. 하지만 여기서는 간단한 테스트를 위해 동기 방식으로 진행하며, 추후 Completion Port를 통해 진행하도록 수정해보고자 한다.
- 또한 ListenSocket도 블로킹 방식으로 전환하고, accept 함수 호출 부분을 간소화하였다.
- 즉 서버의 메인 스레드는 Accept을 전담해서 처리하고, Recv와 Send는 다른 스레드에서 처리해보고자 한다.
vector<FSession*> Sessions;
while (true)
{
...
FSession* Session = new FSession(ClientSocket);
Sessions.push_back(Session);
}
서버는 여러 클라이언트가 접속할 수 있으니, 여러 개의 세션을 관리할 수 있어야 한다. 이번에는 이를 흉내 내고자 Session을 동적 생성하고, 이를 벡터에서 관리하도록 한다.
while (true)
{
...
ULONG_PTR CompletionKey = reinterpret_cast<ULONG_PTR>(Session);
::CreateIoCompletionPort(reinterpret_cast<HANDLE>(ClientSocket), CompletionPort, CompletionKey, 0);
}
이제 앞서 생성한 Completion Port에 들어온 클라이언트를 등록해야 한다. 등록할 때도 동일하게 CreateIoCompletionPort API가 사용되는데, 넘겨주는 인자가 달라진다.
- 기존에 INVALID_HANDLE_VALUE를 넘겨줬던 FileHandle은 등록할 소켓을 넘겨준다.
- 기존에 nullptr을 넘겨줬던 ExistingCompletionPort는 아까 생성한 IoCompletionPort를 넘겨준다.
- 기존에 0을 넘겨줬던 CompletionKey는 추후 Queue에 있는 작업을 꺼낼 때 사용하게 되는, Completion을 구분하는 Key 역할을 한다. 여기서는 Session의 주소를 넘겨주도록 한다.
- NumberOfConcurreuntThreads는 Completion Port가 사용할 수 있는 최대 스레드 수를 의미하는데, 0을 넘겨서 알아서 사용할 수 있는 최대 개수를 설정하도록 한다.
enum class EIoType : unsigned char
{
Read,
Write,
Accept,
Connect
};
struct FIoInfo : public OVERLAPPED
{
explicit FIoInfo(EIoType InType)
: Type(InType)
{}
EIoType Type;
};
while (true)
{
...
FIoInfo* Info = new FIoInfo(EIoType::Read);
WSABUF Buffer{FSession::BufferSize, Session->Buffer};
DWORD BytesRecvd = 0;
DWORD Flags = 0;
::WSARecv(ClientSocket, &Buffer, 1, &BytesRecvd, &Flags, Info, nullptr);
}
클라이언트 소켓을 CompletionPort의 관찰 대상으로 등록했지만, 최초 1회는 Recv를 호출해줘야 한다. 따라서 WSARecv를 호출하는데, 맨 뒤에 2개의 인자가 이전과 달라진다.
- 마지막 인자인 CompletionRoutine은 콜백 함수를 사용하지 않을 것이기 때문에 nullptr을 넘겨준다.
- 뒤에서 두 번째 인자는 LPWSAOVERLAPPED 타입 변수를 넘기는데, 여기서는 FIoInfo라는 새로운 구조체를 정의해 해당 인스턴스를 넘긴다.
앞서 소켓을 Completion Port에 등록하기 위해 CompletionKey로 Session의 주소를 넘겨줬었다. 따라서 동일한 정보를 2번 넘기지 않도록, Overlapped를 상속받은 별개의 구조체를 정의하고 해당 구조체의 인스턴스를 생성해 그 인스턴스를 넘겨주도록 한다.
- 이 구조체는 Io 종류를 들고 있도록 설정하여, 이후 Io가 어떤 종류인지 식별할 때 사용할 수 있도록 설정했다.
- 지금은 간단하게 Read만 테스트하므로 Read로 세팅하지만, 추후 여러 Io가 존재할 수 있다.
2. Worker Thread
초기에 서버에 접속한 Client Socket을 Completion Port에 등록하였다. 그렇기 때문에 소켓에 해당하는 Recv나 Send 같은 입출력이 완료되었다는 통지가 Completion Port에 오게 된다. 즉 여기서 호출한 WSARecv API의 결과가 바로 나타날 수도 있고 Pending 상태에 걸려 나중에 완료될 수도 있지만, 결국 Completion Port를 관찰해서 그 결과를 알 수 있다는 것이다. 따라서 메인 스레드는 여기까지만 작업을 수행한다.
- 정리하자면, 메인 스레드는 새로운 클라이언트(소켓)를 입장시킨 뒤 최초 1회 Recv를 호출해 주는 역할만 수행한다.
- 이후 완료 여부 등의 과정은 별도의 스레드에게 위임한다.
void WorkerMain(HANDLE CompletionPort)
{
while (true)
{
... 입출력 완료에 따른 추가 작업 처리
}
}
int main()
{
...
HANDLE CompletionPort = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, 0, 0);
for (int i = 0; i < 5; ++i)
{
GThreadManager->Launch([=]() { WorkerMain(CompletionPort); });
}
...
}
즉 위 코드처럼 WorkerMain이라는 Worker Thread들의 Main 함수를 하나 정의해, 해당 함수를 수행하도록 설정한다.
void WorkerMain(HANDLE CompletionPort)
{
while (true)
{
DWORD BytesTransferred = 0;
FSession* Session = nullptr;
FIoInfo* Info = nullptr;
bool bGetResult = ::GetQueuedCompletionStatus(
CompletionPort,
&BytesTransferred,
reinterpret_cast<ULONG_PTR*>(Session),
reinterpret_cast<LPOVERLAPPED*>(Info),
INFINITE
);
if (!bGetResult || BytesTransferred == 0)
{
continue;
}
assert(Info->Type == EIoType::Read);
cout << "[IOCP]Recvd Data: " << BytesTransferred << "\n";
...
}
}
CompletionPort에 입출력 결과가 있는지 확인하는 방법은 GetQueuedCompletionStatus API를 사용하는 것이다.
- 첫 번째 인자는 현재 사용하고 있는 CompletionPort를 넘겨준다.
- 두 번째 인자로부터 송신/수신한 데이터 크기를 획득한다.
- 세 번째 인자는 아까 넘겨준 CompletionKey이다. Session의 주소를 넘겼기 때문에, 다시 Session으로 캐스팅하여 해당 정보를 활용한다.
- 네 번째 인자는 아까 넘겨준 Overlapped를 상속받은 FIoInfo 구조체이다. 이 또한 캐스팅하여 정보를 사용한다.
- 마지막 인자는 입출력 결과가 해당 CP에 등장할 때까지 기다리는 시간을 의미한다. INFINITE로 세팅해 끝까지 기다리도록 한다.
해당 API의 반환값으로 성공하면 true, 아니라면 false를 반환한다. 이 정보와 BytesTransferred 값을 통해 정상적으로 입출력 결과를 획득했는지 알 수 있다. 만약 정상적으로 결과를 획득했다면, IoType이 Read인지 검증한 뒤 수신받은 데이터 크기를 한 번 출력해 본다.
void WorkerMain(HANDLE CompletionPort)
{
while (true)
{
...
WSABUF Buffer{ FSession::BufferSize, Session->Buffer };
DWORD BytesRecvd = 0;
DWORD Flags = 0;
::WSARecv(Session->Socket, &Buffer, 1, &BytesRecvd, &Flags, Info, nullptr);
}
}
그리고 다시 WSARecv를 호출해 준다.
- 최초에는 Main 스레드에서 WSARecv를 호출해 각 워커스레드들이 WorkerMain 함수에 진입해 GetQueuedCompletionStatus를 호출할 수 있었다.
- 그런데 이 이후에 다시 Worker 스레드들이 CompletionPort에 있는 일감을 확인해서 처리하기 위해서는 다시 입출력 함수들을 호출해야 한다. 따라서 여기서 다시 WSARecv를 호출한다.
입출력 함수를 다시 호출하는 행위는 낚시에 비유할 수 있을 것 같다. 최초에 물고기를 잡기 위해 낚싯대를 던졌는데, 입질이 와서 건졌으면 이를 다시 던져야 물고기를 계속해서 잡을 수 있을 것이다. 즉 1. 입출력 함수를 호출해서(낚싯대 던지기) 2. 완료가 되면(입질 오면) 3. 관련 작업을 수행하고(낚싯대 건지기) 4. 다시 입출력 함수를 호출하는(낚싯대 다시 던지기) 루틴이 반복되는 것이다.
현재는 다시 입출력 함수를 호출할 때, CompletionStatus를 Get 할 때 사용했던 Overlapped(FIoInfo)를 다시 그대로 넘겨주도록 했다. 만약 Recv가 아니라 Send와 같은 다른 입출력을 처리하고 싶다면 새로운 객체를 동적할당하여 타입을 설정한 뒤 넘겨주고 기존 객체는 해제해 주면 된다.

흐름 요약
- Main에서 일종의 중앙 관리자 같은 역할을 하는 Completion Port를 생성한다.
- Listen Socket이 Client Socket을 생성하자마자 Completion Port의 관찰 대상으로 등록한다.
- 최초 1회 비동기 수신 함수 WSARecv를 호출한다.
- Worker Thread들이 GetQueuedCompletionStatus API를 통해 일감(입출력 완료 통지)을 확인한다.
- 완료되었다면 기존에 넘겨준 Completion Key 값과 Overlapped 구조체를 통해 정보를 복원하여 작업을 처리한다.
- 다시 입출력 함수를 호출하여 4번부터 반복한다.

3. 문제점 확인 및 보완
while (true)
{
...
FSession* Session = new FSession(ClientSocket);
Sessions.push_back(Session);
ULONG_PTR CompletionKey = reinterpret_cast<ULONG_PTR>(Session);
::CreateIoCompletionPort(reinterpret_cast<HANDLE>(ClientSocket), CompletionPort, CompletionKey, 0);
FIoInfo* Info = new FIoInfo(EIoType::Read);
WSABUF Buffer{FSession::BufferSize, Session->Buffer};
DWORD BytesRecvd = 0;
DWORD Flags = 0;
::WSARecv(ClientSocket, &Buffer, 1, &BytesRecvd, &Flags, Info, nullptr);
}
Main에서 CompletionPort에 Socket을 등록할 때, 각 소켓을 구분하기 위해 CompletionKey로 세션의 주소를 넘겨주고 있었다. 또한 Overlapped를 상속받은 FIoInfo를 통해 Recv에 정보를 넘겨주고 있었다.
이러한 상황에서 만약 세션과의 연결이 끊긴다면 어떻게 될까? 예를 들어, 어떤 유저가 게임에 접속했다가 모종의 이유로 종료하여 해당 세션을 삭제해야 하는 상황이 된 것이다.
while (true)
{
...
::WSARecv(ClientSocket, &Buffer, 1, &BytesRecvd, &Flags, Info, nullptr);
FSession* DisconnectedSession = Sessions.back();
Sessions.pop_back();
delete DisconnectedSession;
}
이 경우, Main 스레드에서는 해당 세션이 delete 되었지만 Worker 스레드는 이 사실을 몰라 해제된 메모리에 다시 접근하는 dangling pointer 문제가 발생하는 것이다.
void WorkerMain(HANDLE CompletionPort)
{
while (true)
{
DWORD BytesTransferred = 0;
FSession* Session = nullptr;
FIoInfo* Info = nullptr;
bool bGetResult = ::GetQueuedCompletionStatus(
CompletionPort,
&BytesTransferred,
reinterpret_cast<ULONG_PTR*>(Session), // 여기서 나오는 세션이 이미 해제됨!
reinterpret_cast<LPOVERLAPPED*>(Info), // 동일한 문제를 가지고 있음!
INFINITE
);
...
}
}
결국 스마트 포인터 등을 사용해서 레퍼런스 카운팅을 도입해야 이 문제를 해결할 수 있다. 특정 세션이 입출력 함수에 하나라도 걸려있는 상황이라면 절대로 해제되지 못하도록 주의를 기울여야 한다.
3. 마치며
이번 포스팅을 통해 게임 서버에 사용할 수 있는 네트워크 소켓 통신 모델의 종류에 대해 알아보았다. 앞으로의 라이브러리 구축은 IOCP 모델을 기반으로 진행할 것이며, 이 과정에서 또 다룰 이야기가 많을 것으로 예상된다.
'Game Development > Server' 카테고리의 다른 글
| Overlapped 모델 (0) | 2025.09.22 |
|---|---|
| Select 모델 (4) | 2025.08.25 |
| 논블로킹 소켓 (6) | 2025.08.14 |
| TCP 서버 (2) | 2025.07.28 |
| 소켓 프로그래밍 기초 (5) | 2025.07.22 |