
1. 논블록 소켓 방식의 단점
2025.06.15 - [Game Development/Server] - 온라인 게임 프로그래밍에서 소켓 핸들 방식 1
온라인 게임 프로그래밍에서 소켓 핸들 방식 1
온라인 게임 프로그래밍에서 소켓은 파일 핸들 방식과는 약간 다르다.게임 서버에서는 다루어야 하는 소켓 개수가 많다. TCP를 이용해 통신하는 경우 클라이언트 개수만큼 소켓이 있어야 한다.
9ky0.tistory.com
앞서 블로킹 소켓과 논블록 소켓을 다루는 방법을 살펴보았다. 논블록 소켓의 장점은 다음과 같다.
- 스레드가 블로킹되지 않아 중간 취소 등의 제어가 가능하다.
- 소켓 개수가 많아도 적은 수의 스레드(심지어 하나의 스레드)로도 다룰 수 있다.
- 호출 스택 및 컨텍스트 스위칭 비용이 줄어들어 메모리 및 CPU 자원이 절약된다.
하지만 다음과 같은 단점도 존재한다.
- would block 리턴값으로 인해 재시도 호출이 필요하며, 이는 CPU 자원 낭비로 이어질 수 있다.
- 송수신 함수 호출 시, 데이터 블록 복사로 인한 부하가 발생한다.
- 함수마다 재시도 정책이 일관적이지 않다(connect()는 즉시 실패 리턴, send()/recv()는 반복 호출 필요).
1. 재시도 호출의 낭비
1. TCP
send() 함수는 함수는 송신 버퍼에 1바이트라도 여유 공간이 있으면 I/O 가능 상태로 간주된다. 이 상태에서 send()를 호출하면 전송하려는 데이터가 버퍼 크기보다 커도 가능한 만큼만 전송되고 나머지는 이후 처리 대상이 된다. 이 경우 send()는 성공으로 간주되므로 별다른 문제가 없다.
receive()도 마찬가지로, 수신 버퍼에 1바이트라도 있으면 recv() 호출 시 블로킹 없이 데이터를 받아올 수 있다. 따라서 would block이 발생하지 않는다. UDP에서도 수신의 경우 동일하다.
2. UDP
그러나 UDP의 send()는 다르다. 부분 전송이 불가능하기 때문에, 버퍼 여유 공간이 전송할 데이터보다 작으면 would block이 발생한다.
List<Socket> sockets;
void NonBlockSocketOperation()
{
while(true)
{
// 100ms까지 대기
// 1개라도 I/O 처리할 수 있으면 그 전에 리턴
select(sockets, 100ms);
foreach(s in sockets)
{
result, length = s.sendto(dest, data);
if(length >= 0)
{
// 송신 성공
}
else if(result != EWOULDBLOCK)
{
// 소켓 오류 처리
}
else
{
// 아직 would block
}
}
}
}
이 경우 계속해서 select()가 가능 상태를 알려주지만, 실제로는 send()가 실패(would block)하게 되며, 이를 반복하면 CPU 자원 낭비가 심각해진다.
2. 데이터 복사 비용
소켓 함수(send, recv) 호출 시, 인자로 넘긴 데이터 블록은 운영체제가 내부적으로 복사한다. 이 과정에서 CPU 캐시에 없던 데이터를 메인 메모리(RAM)에서 읽어오게 되면 속도 저하가 발생한다.

고성능 서버에서는 이러한 복사 오버헤드도 무시할 수 없는 성능 저하 요인이다.
2. Overlapped I/O
이러한 단점을 모두 해결하는 기법이 Overlapped 또는 Asynchronous I/O이다. 앞서 살펴본 논블록 소켓은 다음 데이터를 다룬다.
- 소켓이 I/O 가능인 것이 있을 때까지 기다린다.
- 소켓에 대해 논블록 액세스를 한다.
- would block이 발생했으면 그대로 두고, 그렇지 않으면 실행 결과 리턴 값을 처리한다.
반면 Overlapped I/O에서는 다르게 한다.
- 소켓에 대해 Overlapped 액세스를 건다.
- Overlapped 액세스가 성공했는지 확인한 후, 성공했으면 결괏값을 얻어 와서 나머지를 처리한다.
코드를 보면서 Overlapped I/O를 다루는 방법을 알아보자.
void OverlappedSocketOperation()
{
var overlappedSendStatus; // 1.
result, length = s.OverlappedSend(data, overlappedSendStatus); // 2.
if(length > 0)
{
// 3. 보내기 성공
}
else if(result == WSA_IO_PENDING)
{
// 4. Overlapped I/O 진행 중
while(true)
{
result, length = GetOverlappedResult(s, overlappedSendStatus); // 5.
if(length > 0)
{
// 3. 보내기 성공
}
else
{
// 아직 I/O Pending 중
}
}
}
}
- Overlapped I/O를 걸 때 진행 중인 상태 현황을 보관하는 구조체를 먼저 준비한다(1).
- 블로킹 소켓을 그대로 사용한다.
- 소켓에 대한 Overlapped I/O 전용 함수를 호출한다(2). 전용 함수는 항상 즉시 리턴한다.
- 즉시 성공했다면 OK가 리턴되고(3), 그렇지 않으면 I/O Pending(완료를 기다리는 중)이라는 값이 즉시 반환된다(4).
- Overlapped I/O 완료 여부는 (5)처럼 확인하는 함수를 호출해 보면 알 수 있다.
- (5)에서 '완료'라고 결과가 나오면 나머지를 처리한다.
- Overlapped I/O를 수신했다면 data 객체에 수신된 데이터가 자동으로 채워져 있다. 이것에 접근한다.
1. 주의 사항
Overlapped I/O는 OS가 인자로 받은 메모리(데이터 블록, 상태 구조체)를 백그라운드에서 직접 접근한다. 따라서 해당 I/O 작업이 완료되기 전까지 이 메모리를 수정하거나 해제하면 안 된다. Overlapped I/O 전용 함수의 인자로 들어가는 Overlapped status 구조체를 통해 완료 여부를 알 수 있다.

또한, Overlapped I/O를 동시에 여러 개 처리하려면 서로 다른 데이터 블록 및 상태 구조체를 사용해야 한다.
소켓은 내부에 송수신 버퍼를 가지고 있는데, 버퍼 크기는 유저가 원하는 대로 설정할 수 있다. 크기를 0으로도 설정할 수 있는데, Overlapped I/O 방식에서 버퍼 크기를 0으로 설정하면 다르게 동작한다.
Overlapped I/O 전용 송수신 함수를 호출하면 운영체제는 송신할 데이터가 저장되어 있는 메모리 블록 자체를 송신 버퍼로 사용한다. 수신을 할 때도 마찬가지다. 수신할 데이터가 있으면 수신 버퍼에 일단 온 후에는 수신 데이터 블록에 복사되지 않는다. 즉시 수신 데이터 블록에 수신 데이터가 쌓이게 된다.
참고로 Overlapped I/O는 Windows에서만 제공되며, 다음과 같은 함수들을 통해 사용할 수 있다.
| 일반 버전 함수 | Overlapped I/O 전용 함수 |
| send | WSASend |
| sendto | WSASendTo |
| recv | WSARecv |
| recvfrom | WSARecvFrom |
| connect | ConnectEx |
| accept | AcceptEx |
MSDN에서 함수 이름으로 검색하면 자세한 설명을 볼 수 있다.
2. 정리
지금까지 살펴본 Overlapped I/O의 장단점을 정리하면 아래와 같다.
| 장점 | 단점 |
| 소켓 I/O 함수 호출 후 would block 값인 경우 재시도 호출 낭비가 없다. | 완료되기 전까지 Overlapped status 객체가 데이터 블록을 중간에 훼손하지 말아야 한다. |
| 소켓 I/O 함수를 호출할 때 입력하는 데이터 블록에 대한 복사 연산을 없앨 수 있다. | 윈도 플랫폼에서만 제공하는 기능이다. |
| send, receive, connect, acppet 함수를 한 번 호출하면 이에 대한 완료 신호는 딱 한 번만 오기 때문에 프로그래밍 결과물이 깔끔하다. | accept, connect 함수 계열의 초기화가 복잡하다. |
| 뒤에서 설명할 I/O Completion port와 조합하면 최고 성능의 서버를 개발할 수 있다. | - |
논블록 소켓에서 I/O 실행 상태는 다음과 같다.
- 송신 버퍼에 1바이트라도 여유 공간이 있으면 송신 가능, 즉 send available
- 수신 버퍼에 1바이트라도 여유 공간이 있으면 수신 가능, 즉 receive available
- 통칭 I/O 가능
Overlapped I/O에서 I/O 실행 상태는 다음과 같다.
- Overlapped 송신이 진행 중이고 완료가 아직 안 되었으면 Overlapped pending(송신 완료 대기 중)
- Overlapped 수신이 진행 중이고 완료가 아직 안 되었으면 Overlapped 수신 완료 대기 중
- 통칭 I/O 완료 대기 중 혹은 I/O 실행 중
논블록 소켓에서는 상태 확인 후 뭔가를 한다. Overlapped I/O에서는 일단 저지른 후 결과를 확인한다. 그래서 논블록 소켓을 reactor pattern이라 하며, Overlapped I/O는 proactor pattern이라 한다.
| 리액터 | 프로액터 |
| 논블록 소켓 | Overlapped I/O |
| 1. I/O 시도(성공할 수도, 실패할 수도 있음) 2. 실패할 때는 I/O 가능을 기다린 후 재시도 3. 성공할 때는 상황 종료 |
1. I/O 시행(무조건 성공) 2. I/O 완료 대기 3. 상황 종료 |
| 리눅스, FreeBSD, iOS, 안드로이드 등에서 주로 사용. 윈도에서도 사용 가능하나 대세는 아님 |
윈도에서만 사용 가능 |
소켓이 1만 개인 상황에서 리액터에서는 이렇게 해야 한다.
- 소켓 1만 개에 대해 select 호출
- 각 소켓에서 반복문을 돌며 I/O 시도(성능 문제 야기)
프로액터에서는 이렇게 한다.
- 개수가 1만 개 이하인 각 Overlapped status 객체에 대해 GetOverlappedStatus 실행, I/O 완료 상태인 지 확인
- I/O 완료 상태면 나머지 처리(받은 데이터에 대한 로직 혹은 다음에 보낼 데이터에 대한 송신 함수 재호출) 수행
소켓 개수에 비례해 반복문을 돌기 때문에 성능 문제가 발생하는 것을 알 수 있다. 예를 들어 각 소켓이 초당 100번씩 I/O 가능 이벤트 혹은 I/O 완료 이벤트가 발생하면 초당 100만 번 처리를 해야 한다. 그런데 1회에 소켓 1만 개에 대한 반복문을 돈다면 생각하면 끔찍하다. 소켓 개수가 많을 때 이러한 루프 없이 한 번에 끝내는 방법이 없을까?
그래서 등장한 것이 IOCP(Input/Output Completion Port)와 epoll이다. 우선 epoll부터 알아보자.
3. epoll
epoll은 다수의 소켓 중 I/O 가능 상태가 된 소켓만을 효율적으로 감지해 이벤트로 알려주는 메커니즘이다.

위 그림의 경우 소켓 2가 I/O 가능이 되는 순간 epoll은 이 상황을 epoll 안에 내장된 큐에 push 한다. 유저는 epoll에서 이러한 이벤트 정보를 pop 할 수 있다. 이렇게 해서 어떤 소켓이 I/O 가능인지 알 수 있다.
따라서 소켓 1만 개를 감시하더라도, 이 중 I/O 가능한 소켓에 대해서만 이벤트가 발생하므로 전체를 반복 탐색하지 않고도 필요한 처리를 할 수 있다.
epoll = new epoll(); // 1. epoll 객체 생성
foreach(s in sockets)
{
epoll.add(s, GetUserPtr(s)); // 2. 감시할 소켓 등록, 소유자 객체 등 원하는 아무 값을 같이 넣을 수 있음
}
events = epoll.wait(100ms); // 3. 이벤트 대기(최대 100ms 블로킹)
foreach(event in events) // 4. 발생한 이벤트 순회
{
s = event.socket;
userPtr = event.userPtr;
type = event.type; // 송수신 구분
if(type == ReceiveEvent)
{
result, data = s.recv();
if(data.length > 0)
{
Process(userPtr, s, data); // 수신된 데이터 처리
}
}
}
1. 트리거 모드
이상적으로는 위처럼 동작하겠지만, 현실에서는 소켓의 송신 버퍼가 빈 공간이 없는 순간을 유지하는 시간이 상대적으로 짧다. 그래서 거의 대부분은 송신 가능 상태이다. 그래서 이 방식으로 만들면 필요 이상의 반복문을 돌아야 해서 불필요한 CPU 연산 낭비가 일어난다.
epoll은 이를 해결하기 위해 2가지 트리거 모드를 제공한다.
- level trigger: 소켓이 I/O 가능한 상태인 한, 해당 이벤트가 계속 epoll에서 반환
- edge trigger: 소켓 상태가 불가능 -> 가능으로 변화하는 순간에만 이벤트 발생
전자공학에서 유래한 용어로, level은 "신호 상태 유지", edge는 "신호 변화 감지"에 해당한다.

엣지 트리거의 주의점
UDP 소켓 S1에 데이터그램이 2개 도착한 상황에서 epoll은 Edge Trigger로 설정되어 있다고 하자. 이때 수신 이벤트를 받아 한 번만 recv()를 호출하면 1개만 꺼내게 되고, 나머지 1개는 남아 있지만 이벤트가 더 이상 발생하지 않게 된다. 왜냐하면 I/O 가능 상태에서 다시 I/O 가능 상태로 유지되므로 변화가 없기 때문이다.
따라서 엣지 트리거를 쓸 때는 다음 사항을 주의해야 한다.
- 소켓은 반드시 논블로킹으로 설정해야 한다.
- recv()는 EWOULDBLOCK이 반환될 때까지 반복해야 한다.
...
foreach(event in events)
{
s = event.socket;
userPtr = event.userPtr;
type = event.type;
if(type == ReceiveEvent)
{
while(true)
{
result, data = s.recv();
if(data.length > 0)
{
Process(userPtr, s, data);
}
if(result == EWOULDBLOCK)
{
break; // 더 이상 읽을 데이터 없음
}
}
}
}
2. 송수신 처리
epoll은 connect()와 accept()에서도 I/O 가능 이벤트를 받을 수 있다.
- connect() -> send 이벤트로 간주
- accept() -> receive 이벤트로 간주
즉 리스닝 소켓에서 receive 이벤트가 발생하면 이는 새로운 클라이언트 연결이 대기 중임을 의미하며, accept()를 호출해 새 TCP 연결을 얻을 수 있다.
...
foreach(event in events)
{
s = event.socket;
userPtr = event.userPtr;
type = event.type;
if(type == ReceiveEvent)
{
if(IsListeningSocket(s))
{
s2 = s.accept(); // 새 클라이언트 연결 수락
}
else
{
s.recv(); // 일반 수신 처리
}
}
}
4. IOCP(Input Output Completion Port)
IOCP는 Windows에서 제공하는 고성능 I/O 모델로, 소켓의 Overlapped I/O가 완료되었을 때 이를 감지해 이벤트로 알려주는 역할을 한다. 아래 그림을 보면 소켓 2가 완료되는 순간 IOCP는 이 상황을 내장된 큐에 푸시한다.

소켓이 1만 개 있어도 IOCP는 이 중 실제로 I/O가 완료된 소켓에 대해서만 이벤트를 반환하므로, 모든 소켓에 대해 반복문을 돌지 않아도 된다.
iocp = new iocp(); // 1. IOCP 객체 생성
foreach(s in sockets)
{
iocp.add(s, GetUserPtr(s)); // 2. 감시할 소켓 등록, 유저 포인터 지정
s.OverlappedReceive(data[s], receiveOverlapped[s]); // 6. 수신 I/O 등록
}
events = iocp.wait(100ms); // 3. 최대 100ms까지 대기 후 이벤트 반환
foreach(event in events) // 4. 완료된 이벤트 순회
{
userPtr = event.userPtr;
ov = event.overlappedPtr;
s = GetSocketFromUserPtr(userPtr);
if(ov == receiveOverlapped[s])
{
Process(s, userPtr, data[s]); // 5. 데이터 처리
s.OverlappedReceive(data[s], receiveOverlapped[s]); // 6. 다음 수신 예약
}
}
epoll과 구조는 유사하지만, epoll은 I/O 가능성(event readiness)을 알려주는 반면, IOCP는 I/O 작업 완료(completion)를 알려준다는 점이 가장 큰 차이점이다.
1. Accept 처리
IOCP는 epoll보다 훨씬 오래전에 나온 API이다. 그래서 epoll보다 기능적으로 더 복잡한 초기화 절차가 필요한 경우가 있다. Accept이 대표적인 예시인데,
- listen 소켓을 IOCP에 등록하면, 클라이언트 연결 시 완료 이벤트가 IOCP 내부 큐에 추가된다.
- 단, 사전에 AcceptEx을 통해 Overlapped I/O 요청을 걸어두어야 한다.
- 이벤트가 발생한 후에는 SO_UPDATE_ACCEPT_CONTEXT 설정을 통해 새로 생성된 소켓을 사용 가능하게 만들어야 한다.
2. 스레드 모델 장점
IOCP는 epoll과 달리 스레드 풀을 쉽게 구현할 수 있어 스레드들이 여러 일을 효율적으로 분담해서 처리할 수 있다.
epoll은 I/O 여부와 상관없이 I/O 가능 이벤트가 오기 때문에, 동일한 소켓 이벤트를 여러 스레드가 동시에 받을 수 있다. 예를 들어 어떤 소켓이 UDP 데이터를 2개 보유했다고 가정하자. epoll 이벤트를 스레드 1, 2가 동시에 감지해 각 스레드가 recv()를 호출한다. 이러면 데이터 수신이 중복되며, 순서를 알기 어려운 문제가 있다. 이러한 경쟁 제어를 위해 락 또는 상태 제어 로직이 필요하다.

반면 IOCP에서는 이러한 문제가 없는데, Overlapped I/O을 요청한 경우에만 이벤트가 발생하기 때문에 소켓 하나에 대한 완료 이벤트는 스레드 하나가 전담하여 처리하는 것이 보장된다. 그러므로 여러 스레드 간 이벤트 분배가 용이하다. 이는 대규모 동시 접속이 필요한 게임 서버에 이상적인 구조이다.

3. epoll에서 스레드 풀 구성 방법
그럼 epoll에서 스레드 풀링을 구현하려면 어떻게 해야 할까? 여러 방법이 있지만, 저자가 프라우드넷 개발에 적용한 방식은 아래와 같다.
- 스레드 개수만큼 epoll 객체를 생성
- 각 스레드는 자신의 epoll만 담당
- 소켓은 임의의 epoll 중 하나에만 등록

이 경우, 소켓의 이벤트는 지정된 하나의 스레드에서만 발생한다. 완전한 부하 균형은 어렵지만, 단일 스레드 구조보다는 개선된 방식이다. 물론 IOCP만큼 균형 있게 신호를 분담해 주는 효율적인 처리 성능을 내지는 못한다.
| 구분 | IOCP | epoll |
| 블로킹을 없애는 수단 | Overlapped I/O | 논블록 소켓 |
| 블로킹 없는 처리 순서 | 1. Overlapped I/O를 건다. 2. 완료 신호를 꺼낸다. 3. 완료 신호에 대한 나머지 처리를 한다. 4. 끝나고 나서 다시 Overlapped I/O를 건다. |
1. I/O 이벤트를 꺼낸다. 2. 꺼낸 이벤트에 대응하는 소켓에 대한 논블록 I/O를 실행한다. |
| 지원 플랫폼 | Windows | Linux, Android |
4. 성능적 우위
IOCP가 epoll에 비해 성능상 유리한 기능이 더 있다.
epoll을 쓰는 리눅스에서는 TCP 소켓으로 수신을 한 후에 데이터 수신을 하려면 소켓 수신 함수(recv)를 이어서 호출해줘야 한다. 그러나 IOCP를 쓰는 윈도우 서버에서는 수신과 연결 수락을 한 번의 API 호출로 처리 가능하다. 수신 함수인 AcceptEx()이 수신과 클라이언트 endpoint 정보 제공을 함께 해줘서 최적화에 유리하다.
| 윈도우 서버 | 리눅스 서버 |
| AcceptEx GetAcceptExSockaddrs |
accept recv getsockname getpeername |
위 표를 보면 윈도우 서버의 커널 함수 호출이 더 적다는 것을 알 수 있다. 게임 서버는 한 번 TCP 연결을 맺으면 게임 클라이언트가 나갈 때까지 거의 유지되지만, 웹 프로토콜(HTTP)처럼 메시징을 할 때마다 TCP 연결을 맺는 상황에서는 윈도우 서버가 유리하다.
'Game Development > Server' 카테고리의 다른 글
| 논블로킹 소켓 (6) | 2025.08.14 |
|---|---|
| TCP 서버 (2) | 2025.07.28 |
| 소켓 프로그래밍 기초 (5) | 2025.07.22 |
| 게임 서버와 클라이언트 (3) | 2025.06.18 |
| 온라인 게임 프로그래밍에서 소켓 핸들 방식 1 (1) | 2025.06.15 |