채팅 서버에 만 명이 접속해 있다고 해 보겠습니다. 모든 사람이 계속 메시지를 보내지는 않습니다. 대부분의 연결은 조용하고, 일부 연결에서만 데이터가 들어옵니다. 서버는 아직 데이터가 없는 연결을 어떻게 기다릴 것인가를 결정해야 합니다. IOCP는 이 문제를 다루는 Windows의 방법 중 하나입니다.
이 글은 C++ 함수 호출과 포인터를 안다는 정도를 전제로 합니다. OVERLAPPED나 완료 포트를 이미 안다고 가정하지 않습니다. 먼저 하나의 연결을 따라간 뒤 여러 연결로 넓혀 보겠습니다.
IOCP를 이해하는 최소 단위는 “소켓이 준비됐는가”가 아니라 “앱이 맡긴 어느 I/O가 끝났고 그 버퍼의 어느 바이트가 유효한가”입니다. 따라서 제출 반환, 완료 패킷, 스트림 조립, 업무 처리 완료를 하나의 성공으로 합치지 않습니다. 이 구분이 있으면 pending을 중복 제출하는 문제와 부분 메시지를 완성된 요청으로 처리하는 문제를 각각 추적할 수 있습니다.
1. 소켓 읽기 결과와 앱 버퍼 유효 범위
소켓은 프로그램이 운영체제의 네트워크 기능을 사용하기 위한 끝점입니다. 프로그램은 SOCKET 값을 통해 연결을 지정합니다. 이 숫자 자체에 상대가 보낸 메시지가 들어 있는 것은 아닙니다.
일반적인 TCP 수신을 단순화하면, 네트워크로 들어온 바이트를 TCP 스택이 순서에 맞게 처리하고 수신할 데이터로 보관합니다. 앱은 자신이 마련한 메모리의 주소와 크기를 읽기 API에 전달합니다. 운영체제는 받을 수 있는 바이트를 그 버퍼에 채우고 실제 바이트 수를 알려 줍니다. 구체적인 복사와 버퍼링 구현은 경로에 따라 다르지만, 앱 버퍼의 어느 부분이 유효한지는 API 결과로 알아야 한다는 점은 같습니다.
char buffer[4096];
int received = recv(socket, buffer, sizeof(buffer), 0);
이 코드는 블로킹 TCP 수신을 설명하는 작은 조각입니다. received > 0이면 그만큼의 바이트만 읽어야 합니다. 양수 길이로 요청한 정상 수신에서 0이면 상대가 송신 방향을 정상 종료한 것이고, SOCKET_ERROR라면 WSAGetLastError()로 원인을 확인합니다.
4096바이트 버퍼를 줬다고 4096바이트를 채워서 돌아오는 것은 아닙니다. 상대의 send 한 번이 우리의 recv 한 번과 일치하지도 않습니다. TCP는 바이트 스트림이므로, 메시지의 길이 헤더와 본문을 조립하는 일은 앱에 남습니다. 이 부분은 TCP 메시지 경계 복원에서 이어집니다.
2. 데이터 부재 시 호출 스레드의 대기
블로킹 소켓에서 아직 받을 데이터가 없다면 recv는 바로 결과를 돌려주지 않습니다. 호출한 스레드는 대기하고, 데이터나 종료·오류 같은 조건이 생긴 뒤 돌아옵니다. 기다리는 동안 CPU를 계속 계산에 쓰는 것은 아니지만, 그 스레드는 다음 앱 코드를 실행하지 못합니다.
연결마다 스레드 하나를 만들면 설명은 쉽습니다. 각 스레드가 자기 연결의 recv를 기다리면 됩니다. 하지만 연결이 많아지면 스레드 스택과 관리 비용이 늘고, 많은 연결이 동시에 깨어날 때 스케줄링 부담도 생깁니다. 유휴 연결이 대부분인 서버에서는 연결 수와 스레드 수를 꼭 같게 만들 이유가 없습니다.
여기서 필요한 것은 ‘더 빨리 기다리는 함수’가 아니라, 한 스레드가 한 연결의 대기에 붙잡히지 않는 실행 구조입니다.
3. 논블로킹 읽기의 재확인 비용
논블로킹 소켓에서는 데이터가 없을 때 읽기 호출이 WSAEWOULDBLOCK 같은 결과로 돌아올 수 있습니다. 스레드는 다른 일을 할 수 있지만, 앱은 나중에 다시 읽어야 합니다. 만 개의 연결을 계속 순회하며 읽기를 시도하면 빈 연결을 확인하는 데도 작업이 듭니다.
그래서 준비 알림 방식은 ‘지금 읽기를 시도할 만한 연결’을 알려 줍니다. 앱은 알림 뒤에 실제 읽기를 수행하며, 그 사이 다른 소비자가 데이터를 가져가는 등의 이유로 다시 대기 불가 결과를 만날 수도 있습니다. 읽을 준비가 됐다는 알림과 앱 버퍼에 읽기가 끝났다는 알림은 다릅니다.
| 방식 | 앱이 하는 요청 | 나중에 알아내는 것 |
|---|---|---|
| 블로킹 읽기 | 이 버퍼로 지금 읽기 | 함수가 돌아올 때 읽기 결과 |
| 준비 알림 | 읽을 수 있는 연결을 알려 달라 | 실제 읽기를 시도할 기회 |
| 완료 알림 | 이 버퍼로 읽는 작업을 맡긴다 | 맡긴 작업의 바이트 수와 성공·실패 |
준비 알림과 완료 알림 중 하나가 모든 상황에서 우월한 것은 아닙니다. 중요한 것은 알림을 받았을 때 아직 읽어야 하는지, 이미 읽힌 버퍼를 처리해야 하는지입니다.
4. Overlapped I/O의 버퍼·작업 수명
Windows의 overlapped 수신에서는 앱이 버퍼와 OVERLAPPED 구조체를 준비해 WSARecv에 전달합니다. 함수가 돌아와도 요청한 I/O는 진행 중일 수 있습니다. 그동안 제출 스레드는 다른 연결의 작업을 할 수 있습니다.
그렇다면 나중에 돌아온 결과가 어느 수신 요청인지 어떻게 알까요? 제출할 때 사용한 OVERLAPPED의 주소가 작업을 식별하는 연결점이 됩니다. 보통 앱은 이 구조체와 버퍼, 작업 종류, 연결 참조를 하나의 작업 객체에 묶습니다. OVERLAPPED는 메시지 본문을 담는 버퍼도, 스레드 하나를 나타내는 객체도 아닙니다.
이제 버퍼를 지역 배열로 만들고 제출 함수가 끝나자마자 없애면 왜 위험한지 알 수 있습니다. 함수는 돌아왔지만 Windows가 아직 그 주소에 데이터를 쓸 수 있기 때문입니다. 진행 중인 다른 요청에 같은 OVERLAPPED를 덮어 쓰는 것도 같은 이유로 안 됩니다. 작업이 끝나고 결과를 사용하는 코드까지 끝났을 때 재사용합니다.
5. IOCP 완료 패킷과 워커 처리
각 소켓에 수신을 맡기는 것만으로는 서버의 전체 실행 구조가 끝나지 않습니다. 만 개의 소켓에서 완료가 생길 때마다 어디에서 결과를 받을지도 정해야 합니다. I/O 완료 포트(IOCP)는 여러 핸들의 완료 패킷을 모아 워커들이 꺼내 처리하게 하는 통로입니다.
소켓을 CreateIoCompletionPort로 포트에 연결하고, 워커는 GetQueuedCompletionStatus에서 기다립니다. 수신이 완료되면 워커는 연결을 구별할 key, 작업의 OVERLAPPED 주소, 처리된 바이트 수와 오류 정보를 얻습니다. 어떤 연결인지 찾고, 어떤 작업이 끝났는지 확인한 뒤 그 버퍼를 처리합니다.
그림을 글로 읽기
- 소켓 A · 수신 작업·버퍼
- 소켓 B · 수신 작업·버퍼
- IOCP · 완료 패킷 큐
- 워커 · GQCS로 결과 수집
- 유효 바이트 파싱·다음 작업
- 소켓 A · 수신 작업·버퍼 → IOCP · 완료 패킷 큐: A 수신 완료
- 소켓 B · 수신 작업·버퍼 → IOCP · 완료 패킷 큐: B 수신 완료
- IOCP · 완료 패킷 큐 → 워커 · GQCS로 결과 수집: 작업 식별·바이트·오류
- 워커 · GQCS로 결과 수집 → 유효 바이트 파싱·다음 작업: 결과 처리
IOCP가 워커 스레드를 자동으로 만들어 주지는 않습니다. 앱이 스레드를 만들고 같은 포트에서 기다리게 합니다. 포트의 concurrency 값은 완료 처리 스레드의 실행을 조절하는 설정이지 연결 수나 업무 요청 수의 상한이 아닙니다.
또 같은 연결의 수신과 송신 완료를 서로 다른 워커가 처리할 수 있습니다. 따라서 IOCP를 사용한다는 이유로 연결 객체의 상태 변경이 자동으로 직렬화되지는 않습니다.
재현 로그에는 연결 식별자, OVERLAPPED 작업 주소, 제출 반환, 완료 바이트 수, 파서 누적 길이, 다음 제출 여부를 함께 넣습니다. 완료 바이트가 0인 정상 종료와 길이가 짧은 부분 메시지를 같은 입력 부족으로 처리하지 않아야 하며, 작업 주소가 재사용된 흔적은 수명 오류의 강한 단서입니다.
6. 메시지 처리 단계별 순서
처음에는 연결마다 수신 하나만 진행시키는 구조로 생각하면 쉽습니다.
- 연결 수락:
AcceptEx가 접속을 받아 새 소켓을 준비합니다. 이 소켓을 연결 객체와 함께 포트에 연결합니다. - 수신 제출: 작업 객체의 버퍼로
WSARecv를 호출합니다. pending이면 다시 제출하지 않고 결과를 기다립니다. 기본 통지 모드에서는 즉시 성공도 완료 경로에서 처리합니다. - 완료 수집: 워커가 GQCS로 작업을 꺼냅니다. 성공 여부를 먼저 보고, 성공이면 반환한 바이트 수만 파서에 전달합니다.
- 메시지 조립: 길이 헤더나 본문이 덜 왔다면 파서에 남깁니다. 완성된 메시지만 업무 코드에 넘깁니다.
- 응답 송신: 완성한 응답을 연결별 송신 큐에 넣습니다.
WSASend완료는 상대 업무 처리 완료와 다르므로 필요한 프로토콜에는 별도 응답 확인이 있어야 합니다. - 다음 수신 또는 종료: 연결이 열려 있으면 다음 수신을 제출합니다. 닫는 중이면 새 제출을 막고 남은 완료와 버퍼 소비를 회수합니다.
이 흐름에서 ‘수신 요청을 받았다’, ‘바이트를 읽었다’, ‘메시지가 완성됐다’, ‘주문이 저장됐다’는 모두 다른 사건입니다. 버그를 찾을 때도 어느 사건까지 끝났는지 나누어 보면 원인이 훨씬 선명해집니다.
7. IOCP 구현 학습 경로
수신과 완료 처리에서는 실제 초기화 코드, WSARecv의 세 가지 반환 경로와 GQCS의 오류 분기를 읽습니다. 이후 접속 수락, 워커 구성, 송신, 종료 순서로 서버를 구성할 수 있습니다.
직접 확인할 때는 조용한 연결 하나에 수신을 제출하고, client가 몇 바이트를 보낸 뒤 정상 종료하는 작은 실험부터 시작하는 편이 좋습니다. 제출 반환값, 완료 작업 주소, 바이트 수, 작업 해제 시점을 기록하면 위의 각 단계를 실제 사건과 연결할 수 있습니다. Windows 버전별 차이와 선택적인 통지 생략은 기본 흐름이 확인된 뒤 적용합니다.
이 글의 코드는 원리를 설명하는 발췌입니다. 이 작업에서 Windows 서버 실행을 검증한 것은 아닙니다. API 세부 근거는 WSARecv와 I/O Completion Ports의 공식 문서 및 연결된 구현 노트에서 확인할 수 있습니다.