IOCP를 쓰는 이유
문제 정의
- 동접이 커질수록 Select/이벤트 방식은 "소켓/핸들 관리 비용"이 빠르게 증가합니다.
- IOCP는 "완료된 작업만 큐로 전달"하여 워커가 필요한 일만 처리하게 만듭니다.
- 즉, 대규모 서버에서 컨텍스트 스위칭과 불필요한 검사 비용을 줄이는 방향입니다.
핵심 구조
flowchart TB
A[Accept/Connect 처리] --> B[소켓을 IOCP에 연결]
B --> C[WSARecv/WSASend 등록]
C --> D[커널이 I/O 완료]
D --> E[Completion Port 큐 적재]
E --> F[워커: GQCS로 꺼내 처리]
F --> G[게임 로직/패킷 처리]
G --> C
구성요소 한눈에 보기
| 구성요소 | 역할 |
|---|
| Accept 스레드(또는 AcceptEx 루프) | 신규 연결 수락 후 세션 생성 |
| IOCP 핸들 | 완료 통지를 모으는 커널 큐 |
| 워커 스레드들 | GetQueuedCompletionStatus로 완료 처리 |
| I/O 컨텍스트 | OVERLAPPED + 버퍼 + 작업 종류 메타데이터 |
필수 API와 생성 순서
Completion Port 생성
HANDLE iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, 0, 0);
INVALID_HANDLE_VALUE를 넘기면 "포트 생성" 모드입니다.
- 마지막 인자(Concurrency)는
0이면 시스템 권장값(보통 논리 코어 수 기준)을 사용합니다.
소켓을 IOCP에 연결
CreateIoCompletionPort(
reinterpret_cast<HANDLE>(clientSocket),
iocp,
reinterpret_cast<ULONG_PTR>(session),
0);
- 세 번째 인자가 Completion Key입니다(보통
Session*).
- 이후 해당 소켓의 완료는 같은 IOCP 큐로 도착합니다.
비동기 요청 등록
| 함수 | 역할 |
|---|
WSARecv | 수신 요청 등록 |
WSASend | 송신 요청 등록 |
GetQueuedCompletionStatus | 완료 이벤트 꺼내기 |
PostQueuedCompletionStatus | 제어 이벤트(종료 신호 등) 푸시 |
Completion Key와 OVERLAPPED의 역할 분리
왜 둘을 분리해야 하는가
- Completion Key는 "어느 세션인가?"를 식별합니다.
lpOverlapped는 "무슨 작업인가?"를 식별합니다.
- 둘을 분리하면 세션/작업 타입 분기가 단순해지고 디버깅이 쉬워집니다.
권장 컨텍스트 구조
enum class IoType { Recv, Send, Accept };
struct OverlappedEx {
OVERLAPPED ov{};
IoType ioType = IoType::Recv;
WSABUF wsaBuf{};
char buffer[8192]{};
};
첫 번째 멤버 규칙
OVERLAPPED를 첫 멤버로 두면 LPOVERLAPPED -> OverlappedEx* 복원이 단순합니다.
- 팀 규칙으로 고정해두면 캐스팅 실수를 크게 줄일 수 있습니다.
워커 스레드 루프와 GQCS 해석
기본 루프
for (;;) {
DWORD bytes = 0;
ULONG_PTR key = 0;
OVERLAPPED* ov = nullptr;
BOOL ok = GetQueuedCompletionStatus(iocp, &bytes, &key, &ov, INFINITE);
if (!ok && ov == nullptr) {
break;
}
Session* session = reinterpret_cast<Session*>(key);
OverlappedEx* ctx = reinterpret_cast<OverlappedEx*>(ov);
if (!session || !ctx) { continue; }
}
반환 결과 해석 표
| 상태 | 의미 | 기본 대응 |
|---|
ok == TRUE | 완료 정상 수신 | ioType 기준 처리 |
ok == FALSE + ov != nullptr | I/O 완료는 왔지만 실패 코드 포함 | 에러 로그 후 세션 정리/복구 |
ok == FALSE + ov == nullptr | 제어/종료 경로 가능성 | 워커 종료 조건 확인 |
바이트 수 해석
- Recv 완료에서
bytes == 0은 상대의 graceful close로 해석하는 것이 일반적입니다.
bytes > 0이면 파싱/로직 처리 후 다음 수신 요청을 재등록합니다.
실무 처리 규칙 (Recv/Send)
Recv 규칙
- 세션당 최소 1개의
WSARecv는 항상 걸려 있어야 합니다.
- 완료 처리 후 재등록이 빠지면 그 세션 수신은 즉시 멈춥니다.
- 처리량이 높으면 세션당 다중 Recv outstanding 여부를 정책으로 명시해야 합니다.
Send 규칙
- 여러 스레드가 동시에
WSASend를 던지면 순서/동시성 관리가 필요합니다.
- 일반적으로 "세션별 송신 큐 + 전송 중 플래그" 패턴을 사용합니다.
- 한 번의 Send 완료로 모두 전송됐다고 가정하지 말고 잔여 바이트를 확인하세요.
Accept 연계
- 학습 단계에서는
accept 후 IOCP 등록으로 충분합니다.
- 실서비스에서는
AcceptEx + 초기 Recv 선등록으로 accept 병목을 줄이는 패턴을 자주 씁니다.
수명 관리와 종료 시나리오
왜 위험한가
- 세션을 먼저 해제했는데 해당 세션의 완료 이벤트가 늦게 오면 댕글링 포인터가 됩니다.
권장 정책
- 세션 객체에 참조 카운트(또는
shared_ptr 기반 수명 관리)를 둡니다.
- "I/O 등록 시 +1, 완료 처리 후 -1" 정책을 명시해 종료 시점을 통제합니다.
- 종료 시에는 새 I/O 등록을 막고, 남은 완료를 회수 처리한 뒤 자원을 해제합니다.
제어 이벤트
- 워커 종료는
PostQueuedCompletionStatus로 종료 토큰을 넣어 깨우는 방식이 안전합니다.
성능/운영 체크포인트
스레드 수
- 워커 수는 코어 수를 기준으로 시작하고, 측정으로 조정하세요.
- 무작정 스레드를 늘리면 락 경쟁/문맥 전환 비용이 먼저 증가합니다.
관측 지표
- 큐 처리 지연(완료 후 처리까지 시간)
- 초당 완료 건수(Recv/Send 분리)
- 세션당 outstanding I/O 수
- 오류 코드 분포(예: 연결 끊김, 타임아웃, 취소)
디버깅 힌트
- "완료는 오는데 로직이 안 돈다"면
ioType 분기와 재등록 누락부터 확인
- "간헐 크래시"면 수명 관리(세션/컨텍스트 해제 타이밍)부터 확인
강의 시 유의사항
강조 포인트
- IOCP의 본질은 "비동기 완료 큐 + 워커 분배"입니다.
- Completion Key(세션 식별)와 Overlapped(작업 식별)의 분리가 핵심입니다.
- 성능보다 먼저 정합성(수명, 재등록, 종료 정책)을 고정해야 합니다.
자주 하는 오해
| 오해 | 바로잡기 |
|---|
| IOCP 쓰면 자동으로 고성능 | 설계가 나쁘면 큐 지연/락 경쟁으로 느려진다 |
GetQueuedCompletionStatus 실패면 바로 버그 | ov != nullptr 실패 완료 경로를 구분해야 한다 |
| 세션 해제는 소켓 닫자마자 가능 | 늦게 도착한 완료까지 고려한 수명 정책이 필요 |
체크 질문 (스스로 답해보기)
- Completion Key와
lpOverlapped를 분리해 쓰는 이유는 무엇인가?
GetQueuedCompletionStatus의 3가지 결과를 어떻게 분기 처리할 것인가?
- 서버 종료 시 워커 스레드를 안전하게 깨우고 종료하는 절차는 무엇인가?