프로그래밍/소켓 프로그래밍 입문

IOCP 예제로 살펴보는 서버 설계

데일리 백수 2025. 2. 3. 22:38

Windows 환경에서 IOCP(I/O Completion Port)를 사용해 서버를 만들면, 대규모 동시 연결을 효율적으로 처리할 수 있습니다. 아래 예제는 파일이 아닌 소켓(네트워크)에 IOCP를 적용한 코드 구조입니다. 각 단계별로 “어떻게 IOCP와 스레드 풀이 연동해 클라이언트 요청을 처리”하는지 살펴보겠습니다.


1. 핵심 자료구조와 전역 변수

typedef struct _USERSESSION
{
    SOCKET	hSocket;
    char	buffer[8192];	//8KB 버퍼
} USERSESSION;

// 스레드 개수(워크 스레드 수)
#define MAX_THREAD_CNT 4

CRITICAL_SECTION  g_cs;				// 스레드 동기화
std::list<SOCKET> g_listClient;     // 연결된 클라이언트 소켓 리스트
SOCKET g_hSocket;                   // 서버 리슨 소켓
HANDLE g_hIocp;                     // IOCP 핸들
  1. USERSESSION:
    • 각 클라이언트(소켓) 별로 세션 객체를 만든다.
    • 소켓 핸들과 임시 입출력 버퍼(8KB)를 포함.
  2. g_hIocp:
    • CreateIoCompletionPort로 생성되는 IOCP 객체.
    • IOCP 큐를 사용해 소켓 I/O 완료를 통지.
  3. 스레드 4개:
    • 주로 GetQueuedCompletionStatus를 통해 I/O 완료 패킷을 끄집어낸 뒤 처리.

2. IOCP와 작업자(Worker) 스레드 생성

// 1) IOCP 객체 생성
g_hIocp = ::CreateIoCompletionPort(
    INVALID_HANDLE_VALUE,  // 아직 연결된 핸들 없음
    NULL,
    0,
    0 // 스레드 수 OS가 결정(또는 지정 가능)
);

if (g_hIocp == NULL) {
    puts("ERORR: IOCP 생성 실패");
    return 0;
}

// 2) 작업자 스레드 N개 생성
HANDLE hThread;
DWORD dwThreadID;
for (int i = 0; i < MAX_THREAD_CNT; ++i)
{
    hThread = ::CreateThread(
        NULL,        // 보안 속성
        0,           // 기본 스택 크기
        ThreadComplete,  // 워커 스레드 함수
        (LPVOID)NULL,
        0,
        &dwThreadID
    );
    ::CloseHandle(hThread);
}

 

  1. CreateIoCompletionPort(1단계):
    • IOCP를 생성해 “I/O 완료를 모아두는 큐”를 마련.
  2. 워커 스레드(2단계):
    • 4개 스레드가 ThreadComplete 함수를 실행.
    • 이 스레드들은 GetQueuedCompletionStatus를 호출해 I/O 완료 통지를 대기.

3. 서버 소켓 생성과 Accept 스레드

g_hSocket = ::WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP,
                       NULL, 0, WSA_FLAG_OVERLAPPED);

// bind(), listen() ...
// ...
// accept전용 스레드 생성
hThread = ::CreateThread(NULL, 0, ThreadAcceptLoop,
                        (LPVOID)NULL, 0, &dwThreadID);
::CloseHandle(hThread);
  • WSASocket:
    • Overlapped 플래그(WSA_FLAG_OVERLAPPED)로 소켓 생성.
  • ThreadAcceptLoop:
    • Accept를 반복 수행하며 새 클라이언트가 접속할 때마다 IOCP에 등록하고, 첫 번째 WSARecv를 비동기로 걸어둠.

4. ThreadAcceptLoop: 새 클라이언트 연결 처리

DWORD WINAPI ThreadAcceptLoop(LPVOID pParam)
{
    int nAddrSize = sizeof(SOCKADDR);
    SOCKADDR  ClientAddr;
    SOCKET hClient;

    while ((hClient = ::accept(g_hSocket, &ClientAddr, &nAddrSize)) != INVALID_SOCKET)
    {
        puts("새 클라이언트 연결");

        // 1) 세션 객체 생성
        USERSESSION *pNewUser = new USERSESSION;
        memset(pNewUser, 0, sizeof(USERSESSION));
        pNewUser->hSocket = hClient;

        // 2) 소켓을 IOCP에 연결(Attach)
        CreateIoCompletionPort(
            (HANDLE)hClient,
            g_hIocp,
            (ULONG_PTR)pNewUser, // KEY: USERSESSION 포인터
            0
        );

        // 3) 첫 번째 비동기 recv를 걸어둠
        WSAOVERLAPPED *pWol = new WSAOVERLAPPED;
        memset(pWol, 0, sizeof(WSAOVERLAPPED));
        WSABUF wsaBuf = { sizeof(pNewUser->buffer), pNewUser->buffer };

        DWORD dwReceiveSize = 0, dwFlag = 0;
        int nRecvResult = ::WSARecv(hClient, &wsaBuf, 1,
                                    &dwReceiveSize,
                                    &dwFlag,
                                    pWol, // Overlapped
                                    NULL
        );
        if (::WSAGetLastError() != WSA_IO_PENDING)
            puts("ERROR: WSARecv() != WSA_IO_PENDING");
    }
    return 0;
}
  1. Accept 반복:
    • 클라이언트 소켓(hClient) 생성.
  2. USERSESSION 생성:
    • 소켓 + 8KB 버퍼를 갖는 세션 객체.
  3. IOCP에 소켓 등록:
    • CreateIoCompletionPort((HANDLE)hClient, g_hIocp, (ULONG_PTR)pNewUser, 0)
    • 이로써 “이 소켓의 I/O가 끝나면 IOCP 큐에 (pNewUser, Overlapped, …)” 알림이 들어감.
  4. WSARecv(비동기) 호출:
    • 이로써 OS가 이 소켓에 데이터 들어오면 Overlapped 구조체에 기록, 완료 시점에 IOCP 알림.

5. ThreadComplete: IOCP에서 I/O 완료 처리

 

DWORD WINAPI ThreadComplete(LPVOID pParam)
{
    DWORD dwTransferredSize = 0;
    USERSESSION *pSession = NULL;
    WSAOVERLAPPED *pWol = NULL;
    BOOL bResult;

    puts("[IOCP 작업자 스레드 시작]");
    while (1)
    {
        // 1) 큐에서 I/O 완료 패킷을 꺼내옴
        bResult = GetQueuedCompletionStatus(
            g_hIocp,
            &dwTransferredSize,
            (PULONG_PTR)&pSession,
            (LPOVERLAPPED*)&pWol,
            INFINITE
        );

        if (!bResult)
        {
            // TODO: 에러 처리
            continue;
        }

        // 2) dwTransferredSize == 0이면 연결 종료
        if (dwTransferredSize == 0)
        {
            CloseClient(pSession->hSocket);
            delete pWol;
            delete pSession;
            puts("\tGQCS: 클라이언트 연결 종료");
        }
        else
        {
            // 3) 수신된 데이터 처리
            SendMessageAll(pSession->buffer, dwTransferredSize);
            memset(pSession->buffer, 0, sizeof(pSession->buffer));

            // 4) 다시 WSARecv()로 비동기 수신 준비
            WSABUF wsaBuf = { sizeof(pSession->buffer), pSession->buffer };
            DWORD dwFlag = 0;
            int nRes = WSARecv(
                pSession->hSocket,
                &wsaBuf,
                1,
                &dwTransferredSize,
                &dwFlag,
                pWol,
                NULL
            );
            if (::WSAGetLastError() != WSA_IO_PENDING)
                puts("\tGQCS: ERROR: WSARecv()");
        }
    }
    return 0;
}
  1. GetQueuedCompletionStatus:
    • IOCP 큐에서 I/O 완료 패킷을 기다림.
    • 반환 시, dwTransferredSize, pSession, pWol 등을 얻음.
  2. 데이터 크기 0:
    • 클라이언트 소켓이 연결을 끊었음 → 세션 객체 해제, Overlapped 구조체 해제.
  3. 그 외(데이터 수신):
    • pSession->buffer에 데이터가 들어있으니 Broadcast나 Echo 등.
    • 버퍼 초기화 후, 다시 WSARecv를 걸어 추가 메시지를 비동기로 기다림.

6. IOCP의 주요 포인트 정리

  1. IOCP 핸들 생성: CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
  2. N개의 작업자 스레드가 IOCP 큐에서 GetQueuedCompletionStatus로 대기.
  3. 소켓(핸들)을 IOCP에 “등록”: CreateIoCompletionPort((HANDLE)hClient, g_hIocp, (ULONG_PTR)pSession, 0);
    • 세션 포인터(pSession)를 Key로 등록하면, I/O가 끝날 때 “어떤 세션인지” 알 수 있음.
  4. 비동기 I/O 호출: WSARecv, WSASend, WriteFile, ReadFile 등 Overlapped로 실행.
    • 실제 완료가 되면 OS가 IOCP 큐에 패킷을 push.
  5. Work Thread: GetQueuedCompletionStatus → I/O 완료 처리 반복.

7. 결론: “IOCP 예제 한눈에 보기”

  • Accept 스레드: 새 클라이언트가 접속하면 accept 후 IOCP에 소켓을 등록(CreateIoCompletionPort).
  • WSARecv로 첫 recv 걸어두면, 데이터가 올 때마다 I/O 완료 패킷이 IOCP 큐에 쌓임.
  • 워커 스레드(4개): GetQueuedCompletionStatus로 큐에서 꺼내고, 수신 데이터 처리 → 다시 WSARecv.
  • 연결이 끊기면 dwTransferredSize == 0, 세션/Overlapped 해제.

이러한 구조로 작성하면, 동시에 수많은 클라이언트가 연결되어도 적은 스레드로 모든 I/O를 처리 가능하고, OS가 “I/O가 끝난 소켓에만” CPU 시간을 배분해주므로 컨텍스트 스위칭 오버헤드를 최소화할 수 있습니다. “확장성 높은 고성능 서버”를 짜려면, 이와 같은 IOCP 예제를 기반으로 더 복잡한 비즈니스 로직만 얹어주면 되는 것이죠.