I'm FanJae.

Unity 게임 개발 캠프 개인 프로젝트 6일차. Non-Blocking Socket과 Polling의 한계 (개인 공부 내용) 본문

Projects/MyToyMapleServer

Unity 게임 개발 캠프 개인 프로젝트 6일차. Non-Blocking Socket과 Polling의 한계 (개인 공부 내용)

FanJae 2026. 9. 19. 21:58

1. 시작에 앞서

- 이전에는 Blocking 방식으로 여러 클라이언트를 처리할 때 발생할 수 있는 문제에 대해 정리했다.

- Blocking Socket에서는 recv()와 같은 I/O 함수를 호출했을 때 작업을 바로 완료할 수 없다면 해당 Thread가 대기하게 된다.

Thread

recv()
  │
  │ 데이터 대기
  │
  ▼
return

- 따라서 여러 클라이언트를 동시에 처리하기 위해 Client마다 Thread를 하나씩 할당하는 방법을 생각할 수 있었다.

Client A ─ Thread A
Client B ─ Thread B
Client C ─ Thread C

- 하지만 연결 수가 증가하면 Thread 수도 같이 증가하고, 많은 Thread가 실제 연산이 아닌 네트워크 I/O를 기다리기 위해 존재하게 된다.

- 그렇다면 이런 의문이 생긴다. 데이터를 기다리지 않고, recv()가 바로 반환하도록 만들 수 있지 않을까?

- 여기서 등장하는 방식이 Non-Blocking Socket이다.


2. Non-Blocking Socket

- Blocking Socket에서는 읽을 데이터가 없으면 recv()가 데이터를 기다릴 수 있다.

- 반면 Non-Blocking Socket에서는 현재 작업을 바로 완료할 수 없다면 기다리지 않고 즉시 반환한다.

- Windows에서는 이 경우 일반적으로 WSAEWOULDBLOCK 오류를 반환한다. 

- 예를 들어 다음과 같이 사용할 수 있다.

int result = recv(socket,buffer,sizeof(buffer),0);

if (result == SOCKET_ERROR)
{
    int error = WSAGetLastError();

    if (error == WSAEWOULDBLOCK)
    {
        // 현재 읽을 수 있는 데이터가 없음
    }
}

- 흐름으로 보면 다음과 같다.

recv()
  │
  ▼
현재 데이터가 있는가?
  │
  ├─ YES → 데이터 반환
  │
  └─ NO  → WSAEWOULDBLOCK

- 따라서 Thread는 데이터가 도착할 때까지 recv() 안에서 멈춰 있지 않는다.


3. Blocking 방식과 비교

- Blocking 방식에서는 다음과 같은 상황이 발생할 수 있다.

Client A

recv()
  │
  │
  │ 데이터 대기
  │
  ▼
return

- 그동안 해당 Thread는 다른 작업을 할 수 없다.

- 반면 Non-Blocking 방식에서는 데이터가 없으면 아래와 같이 즉시 반환되어 Thread가 다른 작업 수행이 가능하다.

recv()
  │
  ▼
데이터 없음
  │
  ▼
즉시 반환

- 겉으로 보면 Blocking 서버에서 발생했던 문제가 해결된 것처럼 보인다.

 

- 하지만 새로운 문제가 생긴다.


4. 그렇다면 언제 다시 recv()를 호출해야 할까?

- 현재 데이터가 없어서 WSAEWOULDBLOCK이 반환되었다고 가정해보자.

recv(...);

↓

// WSAEWOULDBLOCK

- recv()는 바로 반환되었지만 결국 서버는 언젠가 다시 recv()를 호출해야 한다.

- 그렇다면 언제 다시 확인해야 할까?

- 가장 단순한 방법은 반복해서 호출하는 것이다.

while (true)
{
    int result = recv(socket,buffer,sizeof(buffer),0);

    if (result > 0)
    {
        ProcessPacket(buffer);
        break;
    }

    if (WSAGetLastError() == WSAEWOULDBLOCK)
    {
        continue;
    }
}

- 동작 흐름은 다음과 같다.

recv()
  ↓
데이터 없음

recv()
  ↓
데이터 없음

recv()
  ↓
데이터 없음

recv()
  ↓
데이터 도착

- 이러한 방식은 프로그램이 직접 상태를 계속 확인한다는 점에서 Polling이라고 볼 수 있다.

- Non-Blocking 작업이 WSAEWOULDBLOCK을 반환한 경우, 이후 언제 다시 작업을 시도해야 하는지를 판단하기 위한 별도의 메커니즘이 필요하다고 설명한다.


5. 단순 Polling의 문제

- Non-Blocking 방식의 장점은 Thread가 I/O에서 멈추지 않는다는 것이다.

- 하지만 다음과 같이 계속 recv()를 호출한다면 문제가 생긴다.

while (true)
{
    recv(SocketA);
    recv(SocketB);
    recv(SocketC);
}

- 실제로 데이터가 없어도 계속 Socket을 확인한다.

- 예를 들어 세 개의 Socket이 있다고 해보자.

Socket A → 데이터 없음
Socket B → 데이터 없음
Socket C → 데이터 없음

- 서버는 계속 반복한다.

A 확인 → 없음
B 확인 → 없음
C 확인 → 없음

A 확인 → 없음
B 확인 → 없음
C 확인 → 없음

A 확인 → 없음
...

- Blocking 방식에서는 Thread가 아무 작업도 하지 않고 기다렸다면, 단순한 Non-Blocking Polling에서는 반대로 할 일이 없어도 CPU를 사용하면서 계속 상태를 확인할 수 있다.

- 즉, Thread가 너무 자주 확인하게 된다는 문제가 생긴다.


6. 여러 Socket을 직접 순회한다면?

- 클라이언트가 여러 명이라면 다음과 같은 방식도 생각할 수 있다.

for (Socket& socket : sockets)
{
    int result = recv(socket,buffer,sizeof(buffer),0);

    if (result > 0)
    {
        ProcessPacket(buffer);
    }
}

- 이 구조에서는 하나의 Thread가 여러 Socket을 관리할 수 있다.

Thread
  │
  ├─ Socket A 확인
  ├─ Socket B 확인
  ├─ Socket C 확인
  ├─ Socket D 확인
  └─ ...

- Client마다 Thread를 하나씩 만들 필요는 없어졌다.

- 하지만 연결 수가 많아지면 매번 모든 Socket을 순회해서 확인해야 한다.

100개의 Socket
→ 100개 확인

1,000개의 Socket
→ 1,000개 확인

10,000개의 Socket
→ 10,000개 확인

- 실제로 데이터가 들어온 Socket이 극히 일부라도 애플리케이션이 전체 Socket을 반복적으로 검사한다면 불필요한 확인 작업이 증가한다.

- 이에 대해서 이와 다음과 같은 고려 사항이 생긴다. '데이터가 들어온 Socket만 알려줄 수는 없을까?'


7. Socket의 상태를 한 번에 확인하기

- 이 문제를 해결하기 위한 방법 중 하나가 select()이다.

- Windows의 select() 함수는 하나 이상의 Socket에 대해 읽기 가능, 쓰기 가능, 예외 상태 등을 확인할 수 있다. 호출이 반환되면 조건을 만족한 Socket 집합을 확인할 수 있다.

- 개념적으로 보면 다음과 같다.

Socket A ─┐
Socket B ─┤
Socket C ─┼─ select()
Socket D ─┤
Socket E ─┘
          │
          ▼
현재 준비된 Socket 확인

- 예를 들어 소켓에 대해서 각각 아래와 같다고 가정한다.

Socket A → 읽을 데이터 없음
Socket B → 읽을 데이터 있음
Socket C → 읽을 데이터 없음
Socket D → 읽을 데이터 있음

 

select()

↓

// B, D가 Read 가능

- 그러면, select()에서는 이와 같이 확인 가능하다.

- 따라서 애플리케이션에서 무작정 모든 Socket에 recv()를 반복 호출하는 것보다는 효율적인 방식으로 상태를 확인할 수 있다.


8. select()를 이용한 구조

대략적인 구조는 다음과 같다.

fd_set readSet;

FD_ZERO(&readSet);

for (SOCKET socket : sockets)
{
    FD_SET(socket, &readSet);
}

int result = select(
    0,
    &readSet,
    nullptr,
    nullptr,
    nullptr);

- select()가 반환되면 각 Socket이 읽기 가능한지 확인한다.

for (SOCKET socket : sockets)
{
    if (FD_ISSET(socket, &readSet))
    {
        recv(socket,buffer,sizeof(buffer),0);
    }
}

- 흐름은 다음과 같다.

Socket 집합 등록
      ↓
select()
      ↓
준비된 Socket 확인
      ↓
해당 Socket만 처리

- select()는 fd_set에 등록된 여러 Socket을 대상으로 읽기 가능성, 쓰기 가능성, 오류 상태 등을 확인하며, 반환 후에는 조건을 만족하는 Socket만 집합에 남는다.


9. 그렇다면 select()로 충분한가?

- 단순 Polling보다는 개선되었지만 select() 역시 생각해볼 부분이 있다.

- 우선 select()는 애플리케이션이 관심 있는 Socket 집합을 전달하고, 호출이 반환된 뒤 다시 어떤 Socket이 준비되었는지를 확인하는 방식이다.

- 즉 기본적인 구조는 여전히 준비된 Socket에 대해서 애플리케이션이 확인 후 처리하는 형태다.

- 따라서 많은 연결을 처리하는 서버에서는 애플리케이션이 계속 Socket의 상태를 확인하는 구조가 아니라, 'I/O 작업 자체를 미리 요청하고 작업이 끝났을 때 완료 사실을 전달받을 수는 없을까?' 라는 방향으로 생각할 수 있다.


10. 상태 확인에서 완료 통지로

- 지금까지의 형태를 보면, 각각 다음과 같다.

Blocking

recv()
  ↓
데이터가 올 때까지 기다림
  ↓
처리

Non-Blocking

recv()
  ↓
데이터 없음
  ↓
바로 반환
  ↓
나중에 다시 확인

select()

여러 Socket 감시
  ↓
준비된 Socket 확인
  ↓
recv()

- 여기에서 한 단계 더 생각해볼 수 있다.

recv 작업을 미리 요청
       ↓
운영체제가 I/O 처리
       ↓
I/O 완료
       ↓
완료 사실을 전달
       ↓
완료된 작업 처리

- 즉, 준비가 되었는지 계속 확인하는 것이 아니라 작업이 끝나면 알려달라고 요청하는 방식이다.

- 이것이 Windows에서 사용하는 Overlapped I/O와 이후 IOCP를 이해하는 중요한 출발점이 된다.


11. Overlapped I/O로 넘어가기

- Windows에서는 Winsock에 Overlapped I/O를 사용할 수 있다.

- Overlapped I/O 작업이 Socket의 Blocking / Non-Blocking 모드와 별개의 개념이라고 명시한다.

- 즉 Overlapped 속성을 사용한다고 해서 Socket 자체가 Non-Blocking 모드로 변경되는 것은 아니며, Overlapped I/O 작업은 별도의 비동기 I/O 모델로 동작한다.

- Overlapped I/O에서는 대략 다음과 같은 형태가 된다.

WSARecv 요청
    ↓
I/O Pending
    ↓
Thread는 다른 작업 수행
    ↓
수신 완료
    ↓
완료 처리

- 이 과정에서 WSAOVERLAPPED 구조체는 I/O 작업을 시작한 시점과 이후 작업 완료 시점을 연결하는 정보를 제공한다.

- 즉 이전 방식과의 가장 큰 차이는 다음과 같이 생각할 수 있다.

Non-Blocking

"지금 데이터 있어?"
"아직 없어."
"지금은?"
"아직 없어."
"지금은?"


Overlapped I/O

"데이터 수신 작업을 요청할게."
          ↓
"완료되면 알려줄게."

 


12. 현재 프로젝트와 연결

- 현재 서버 프로젝트에서는 이후 IOCP 기반으로 여러 Session의 네트워크 I/O를 처리하려고 한다.

- Blocking 방식이라면 다음과 같은 구조를 생각할 수 있었다.

Session A ─ Thread A ─ recv()
Session B ─ Thread B ─ recv()
Session C ─ Thread C ─ recv()

- Non-Blocking Socket에서는 하나의 Thread가 여러 Session을 순회하며 상태를 확인할 수도 있다.

Thread
 │
 ├─ Session A 확인
 ├─ Session B 확인
 ├─ Session C 확인
 └─ ...

- 하지만 IOCP에서는 방향이 달라진다.

Session A ─ I/O 요청 ─┐
Session B ─ I/O 요청 ─┤
Session C ─ I/O 요청 ─┤
Session D ─ I/O 요청 ─┘
                      │
                      ▼
                Windows Kernel
                      │
                  I/O 완료
                      │
                      ▼
                    IOCP
                      │
                      ▼
                Worker Thread

- 즉 Thread가 모든 Session을 계속 확인하는 것이 아니라 미리 등록한 I/O 중 완료된 작업을 중심으로 처리하는 구조를 만들 수 있다.

- Winsock 문서에서도 여러 연결을 처리하는 서버에서는 Overlapped I/O 또는 IOCP와 같은 비동기 I/O 모델을 사용할 수 있으며, 최신 서버 코드에서는 이러한 비동기 I/O 방식을 권장하고 있다.


13. 정리

- Blocking I/O에서는 작업을 완료할 수 없으면 Thread가 대기한다.

- 이를 해결하기 위해 Non-Blocking Socket을 사용하면 recv()와 같은 호출이 현재 처리할 수 없는 경우 WSAEWOULDBLOCK을 반환하고 즉시 제어권을 돌려준다.

- 하지만 Non-Blocking으로 변경했다고 모든 문제가 해결되는 것은 아니다.

Blocking
    ↓
Thread가 I/O를 기다림

Non-Blocking
    ↓
Thread가 직접 완료 가능 여부를 확인해야 함

Polling
    ↓
불필요한 반복 확인 가능

select()
    ↓
여러 Socket의 Ready 상태를 묶어서 확인

Overlapped I/O
    ↓
I/O 작업 요청과 완료 처리를 분리

- 결국 중요한 것은 Thread가 기다리지 않는 것 뿐만 아니라 I/O가 언제 처리 가능한지를 어떻게 효율적으로 알아낼 것인가에 있다.

- 그리고 Windows에서는 이 문제를 비동기 I/O인 Overlapped I/O를 통해 처리할 수 있다.

- 다음에는 OVERLAPPED와 WSARecv()를 기준으로 Windows의 Overlapped I/O가 실제로 어떤 방식으로 동작하는지 정리해보려고 한다.


14. 참고

- Microsoft Learn, Nonblocking Input/Output

- https://learn.microsoft.com/en-us/windows/win32/winsock/nonblocking-i-o-2

 

Nonblocking Input/Output - Win32 apps

If a socket is in nonblocking mode, any I/O operation must either complete immediately or return the error code WSAEWOULDBLOCK indicating that the operation cannot be finished right away.

learn.microsoft.com

- Microsoft Learn, select function (winsock2.h)

- https://learn.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-select

 

select function (winsock2.h) - Win32 apps

The select function determines the status of one or more sockets, waiting if necessary, to perform synchronous I/O.

learn.microsoft.com

- Microsoft Learn, Overlapped I/O and Event Objects

- https://learn.microsoft.com/en-us/windows/win32/winsock/overlapped-i-o-and-event-objects-2

 

Overlapped I/O and Event Objects - Win32 apps

Windows Sockets 2 supports overlapped I/O and all transport providers support this capability.

learn.microsoft.com

- Microsoft Learn, WSAOVERLAPPED structure

- https://learn.microsoft.com/en-us/windows/win32/api/winsock2/ns-winsock2-wsaoverlapped

 

WSAOVERLAPPED (winsock2.h) - Win32 apps

Provides a communication medium between the initiation of an overlapped I/O operation and its subsequent completion.

learn.microsoft.com

- Microsoft Learn, Getting Started With Winsock

- https://learn.microsoft.com/en-us/windows/win32/winsock/getting-started-with-winsock

 

Getting started with Winsock - Win32 apps

Use the links and resources in this topic as a step-by-step guide to getting started with Windows Sockets programming.

learn.microsoft.com

 

Comments