배경 및 목표
지금까지 만든 것과 한계
- IOCP 기반 송수신(
WSARecv, WSASend) 자체는 이미 동작합니다.
- 하지만 샘플 코드 수준에서는 네트워크 처리, 게임 로직, 수명 관리가 한곳에 섞이기 쉽습니다.
- 이 상태로는 다른 프로젝트에서 재사용하거나 팀 단위로 유지보수하기 어렵습니다.
엔진화의 의미
| 항목 | 목표 |
|---|
| Core(엔진) | IOCP, 세션, 버퍼, 이벤트 디스패치 같은 공통 인프라 담당 |
| Game(컨텐츠) | 패킷 해석 이후 게임 규칙/월드 상태 처리 담당 |
| 경계 | Core는 게임 규칙을 모름, Game은 Winsock 저수준 API를 모름 |
이 파트의 핵심 질문
"무엇을 라이브러리로 빼야 오래 버티는가?"
- 답은 IOCP 3단계 흐름 + Register/Dispatch/Process 분리 + 수명/재등록 규칙 고정입니다.
IOCP 핵심 3단계
단계별 의미
| 단계 | API | 시점 | 의미 |
|---|
| 1 | CreateIoCompletionPort(INVALID_HANDLE_VALUE, ...) | 서버 시작 | Completion Port(완료 큐) 생성 |
| 2 | CreateIoCompletionPort((HANDLE)socket, iocp, key, ...) | 소켓 준비 후 | 소켓을 IOCP에 연결(Association) |
| 3 | GetQueuedCompletionStatus | 워커 루프 | 완료 이벤트 dequeue + 처리 |
최소 코드 패턴
HANDLE iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, 0, 0);
if (iocp == nullptr) {
}
if (CreateIoCompletionPort(reinterpret_cast<HANDLE>(sock), iocp,
reinterpret_cast<ULONG_PTR>(object), 0) == nullptr) {
}
DWORD bytes = 0;
ULONG_PTR key = 0;
OVERLAPPED* ov = nullptr;
BOOL ok = GetQueuedCompletionStatus(iocp, &bytes, &key, &ov, INFINITE);
GQCS 결과 해석 기본
| 조건 | 해석 | 대응 |
|---|
ok == TRUE | 정상 완료 | ioType별 처리 |
ok == FALSE && ov != nullptr | 완료는 왔지만 작업 실패 | 오류 코드 기록 후 정리 |
ok == FALSE && ov == nullptr | 종료/제어 경로 가능성 | 워커 종료 조건 확인 |
Register / Dispatch / Process 분리
왜 분리하는가
- 등록(Register), 전달(Dispatch), 처리(Process)를 섞으면 버그가 생겼을 때 원인 추적이 어렵습니다.
- 각 단계의 책임을 분리하면 테스트/로깅/확장이 쉬워집니다.
역할 정의
| 단계 | 책임 | 대표 함수 |
|---|
| RegisterX | 비동기 요청 등록(낚싯대 던짐) | RegisterRecv, RegisterSend, RegisterAccept |
| Dispatch | 완료 이벤트를 객체/타입으로 라우팅 | object->Dispatch(ctx, bytes) |
| ProcessX | 완료 결과 실제 처리 + 다음 요청 재등록 | ProcessRecv, ProcessSend, ProcessAccept |
흐름 다이어그램
flowchart TD
A[RegisterX<br/>WSARecv, WSASend, AcceptEx] --> B[커널에서 비동기 I/O 진행]
B --> C[IOCP 큐 완료 도착]
C --> D[GQCS로 key/ov 수신]
D --> E[object->Dispatch ctx]
E --> F[ProcessX]
F --> G[필요 시 재등록 RegisterX]
G --> A
재등록 규칙
ProcessRecv 끝에서 다음 RegisterRecv를 누락하면 수신이 멈춥니다.
ProcessAccept 끝에서도 다음 RegisterAccept를 넣어 accept 파이프라인을 유지해야 합니다.
확장 함수 (AcceptEx, ConnectEx, DisconnectEx)
왜 동적 로드가 필요한가
- 이 함수들은 일반 Winsock import 함수처럼 정적으로 바로 호출되지 않습니다.
WSAIoctl(SIO_GET_EXTENSION_FUNCTION_POINTER)로 런타임에 함수 주소를 얻어야 합니다.
로드 예시
LPFN_ACCEPTEX acceptEx = nullptr;
LPFN_CONNECTEX connectEx = nullptr;
LPFN_DISCONNECTEX disconnectEx = nullptr;
DWORD bytes = 0;
GUID guidAcceptEx = WSAID_ACCEPTEX;
WSAIoctl(dummySock, SIO_GET_EXTENSION_FUNCTION_POINTER,
&guidAcceptEx, sizeof(guidAcceptEx),
&acceptEx, sizeof(acceptEx),
&bytes, nullptr, nullptr);
실무 주의사항
WSAStartup 이후에 로드해야 합니다.
- 같은 provider 계열 소켓에서 얻은 함수 포인터를 사용해야 안전합니다.
ConnectEx는 호출 전에 소켓 bind가 필요합니다.
엔진 부트스트랩 전체 흐름
초기화 시퀀스
[Server Start]
-> WSAStartup
-> 확장 함수 포인터 로드(AcceptEx/ConnectEx/DisconnectEx)
-> IOCP 생성
-> Listener 생성 + IOCP 등록
-> RegisterAccept N개 선등록
-> Worker Thread 시작(Dispatch Loop)
런타임 시퀀스
- Accept 완료 -> Session 할당 -> IOCP 등록
- Session에
RegisterRecv 선등록
- Worker가 완료를 받아
Dispatch -> Process
- 처리 후 필요한 I/O를 재등록
설계 핵심 문장
엔진은 "등록과 완료 전달"을 책임지고, 컨텐츠는 "패킷 의미와 게임 규칙"을 책임진다.
강의 시 유의사항
강조 포인트
- 이 파트의 본질은 함수 암기가 아니라 역할 분리입니다.
- "등록 -> 완료 통지 -> 처리 -> 재등록" 루프를 몸에 익히면 Part 2 이후가 쉬워집니다.
- 특히 재등록 누락과 수명 관리 실패가 실전 버그의 대부분입니다.
자주 하는 오해
| 오해 | 바로잡기 |
|---|
| IOCP는 API 몇 개만 알면 끝난다 | 객체 경계/수명/종료 절차가 핵심이다 |
GQCS에서 FALSE면 모두 치명 오류 | ov 유무로 실패 완료/제어 경로를 구분해야 한다 |
| Accept 완료만 처리하면 된다 | 다음 RegisterAccept를 넣어 파이프라인을 유지해야 한다 |
체크 질문 (스스로 답해보기)
- Core와 Game 사이 경계를 코드 레벨로 어디에 둘 것인가?
RegisterRecv를 언제, 어떤 조건에서 다시 호출해야 하는가?
- 서버 종료 시 워커를 안전하게 종료하려면 어떤 제어 이벤트가 필요한가?