I'm FanJae.

Unity 게임 개발 캠프 개인 프로젝트 4일차. Blocking 서버의 한계 (개인 공부 내용) 본문

Projects/MyToyMapleServer

Unity 게임 개발 캠프 개인 프로젝트 4일차. Blocking 서버의 한계 (개인 공부 내용)

FanJae 2026. 9. 17. 22:08

1. 시작에 앞서

- 이전에는 동기와 비동기, Blocking과 Non-Blocking의 차이에 대해 정리했다.

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

Thread

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

- 구현 자체는 단순하다. 실행 흐름도 직관적이기 때문에 연결 수가 많지 않은 프로그램이라면 Blocking 방식만으로도 충분할 수 있다.

- 하지만 서버에서 동시에 처리해야 하는 클라이언트가 증가하기 시작하면 이야기가 달라진다.

- 이번에는 Blocking 방식으로 여러 클라이언트를 처리하려고 할 때 어떤 문제가 발생하는지 정리해보려고 한다.


2. 하나의 Thread에서 처리한다면?

- 먼저 가장 단순한 서버를 생각해보자.

while (true)
{
    SOCKET clientSocket = accept(listenSocket, nullptr, nullptr);

    char buffer[1024];

    recv(clientSocket, buffer, sizeof(buffer), 0);

    ProcessPacket(buffer);
}

- 구조만 보면 상당히 간단하다.

Client 연결
     ↓
   accept
     ↓
    recv
     ↓
Packet 처리

- 문제는 recv()에서 받을 데이터가 없는 경우이다.

- Blocking Socket에서는 데이터를 받을 수 있을 때까지 recv()가 반환하지 않을 수 있다.

- Windows의 Winsock 문서에서도 Blocking Socket에서 recv()와 같은 작업은 즉시 완료할 수 없다면 데이터가 도착할 때까지 호출을 차단할 수 있다고 설명한다.

- 그렇다면 Client A가 서버에 연결한 뒤 아무런 데이터를 보내지 않는 상황을 생각해볼 수 있다.

Server Thread

Client A
   │
   ▼
 recv()
   │
   │ 데이터가 오지 않음
   │
   │ 대기...
   │

- 이때 서버 Thread가 recv()에 묶여 있기 때문에 다른 클라이언트를 처리할 수 없다.

Client A ───── Server Thread
                │
                └─ recv() 대기

Client B ───── 처리 불가능

Client C ───── 처리 불가능

- 따라서 여러 클라이언트를 동시에 처리하려면 다른 방법이 필요하다.


3. Client마다 Thread를 만든다면?

- 가장 직관적으로 생각할 수 있는 방법은 클라이언트마다 Thread를 하나씩 생성하는 것이다.

Client A ───── Thread A

Client B ───── Thread B

Client C ───── Thread C

- 이를 흔히 Thread-per-Connection 형태라고 볼 수 있다.

- 대략 다음과 같은 방식이다.

while (true)
{
    SOCKET clientSocket = accept(listenSocket, nullptr, nullptr);

    std::thread clientThread([clientSocket]()
    {
        while (true)
        {
            char buffer[1024];

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

            if (result <= 0)
                break;

            ProcessPacket(buffer);
        }
    });

    clientThread.detach();
}

- 이제 Client A의 recv()가 Blocking되어 있더라도 Client B는 별도의 Thread에서 처리할 수 있다.

Client A ─ Thread A ─ recv() 대기
Client B ─ Thread B ─ recv() 대기
Client C ─ Thread C ─ Packet 처리

- 처음 문제는 해결된 것처럼 보인다.

- 실제로 연결 수가 많지 않은 서버라면 이러한 구조는 구현하기 쉽고 이해하기도 편하다.

- 하지만 클라이언트 수가 계속 증가하면 새로운 문제가 발생한다.


4. 연결 수와 함께 Thread 수도 증가한다

- Client 하나당 Thread 하나를 사용한다면 연결 수가 곧 Thread 수 증가로 이어진다.

10 Clients
   ↓
약 10 Threads

100 Clients
   ↓
약 100 Threads

1,000 Clients
   ↓
약 1,000 Threads

- 여기서 중요한 것은 Thread 자체도 비용이 없는 객체가 아니라는 점이다.

- 각 Thread에는 독립적인 실행 상태가 존재하고, Stack 영역도 필요하다.

- Windows에서 Thread를 생성하면 각 Thread마다 Stack 공간이 할당된다.

- Windows 실행 파일에서 사용하는 기본 Thread Stack 예약 크기는 일반적으로 1MB이다.

- 다만 여기서 주의할 점이 있다.

Thread 1개 생성 → 무조건 실제 RAM 1MB 사용은 아니다.

- Windows는 Stack을 위해 주소 공간을 Reserve하고 실제로 필요한 페이지를 점진적으로 Commit할 수 있기 때문이다.

- 따라서 단순히 Thread 1,000개 사용이 실제 RAM 1GB 사용이라고 계산하는 것은 정확하지 않다.

- 하지만 Thread 수가 늘어날수록 Stack을 포함하여 Thread를 유지하기 위한 자원이 계속 필요해진다는 사실 자체는 변하지 않는다.


5. 그런데 대부분의 Thread는 무엇을 하고 있을까?

- 게임 서버의 네트워크 연결을 생각하면 모든 클라이언트가 항상 데이터를 보내고 있는 것은 아니다.

- 예를 들어 1,000명의 사용자가 접속해 있다고 해보자.

Client 1   ───── recv 대기
Client 2   ───── recv 대기
Client 3   ───── recv 대기
Client 4   ───── Packet 처리
Client 5   ───── recv 대기
...
Client 1000───── recv 대기

- 특정 순간 실제 CPU 연산이 필요한 연결은 일부일 수 있다.

- 하지만 Client마다 Blocking recv()를 담당하는 Thread를 만들었다면 데이터가 없는 연결도 각각 Thread를 하나씩 가지고 있게 된다.

- 즉, 서버 입장에서는 많은 Thread가 CPU 연산 수행을 하기 위해 존재하는 것이 아니라 데이터가 도착하기를 기다리기 위해 존재하게 된다.

- Blocking 서버의 확장성 문제를 이해할 때 이 부분이 중요하다. 핵심은 Blocking 함수 자체가 느리다는 것이 아니다. I/O를 기다리기 위해 Thread라는 실행 자원을 연결별로 계속 유지해야 할 가능성이 생긴다는 것이 문제다.


6. Thread가 많아지면서 발생하는 문제

6.1 메모리 및 Thread 관리 비용

- Thread에는 Stack과 각종 Thread 상태 정보가 필요하다.

- 따라서 Thread 수가 증가하면 이를 유지하기 위한 자원도 증가한다.

Client 증가
     ↓
Thread 증가
     ↓
Thread Stack 및 관리 자원 증가

- Windows에서는 Thread Stack을 위한 메모리 영역이 필요하며, 예약된 Stack 영역 역시 무한정 사용할 수 있는 것은 아니다.

- 따라서 동시 연결 수가 매우 커지는 구조에서 Client마다 별도의 Thread를 유지하는 방식은 부담이 될 수 있다.


6.2 스케줄링 비용

- 실제로 CPU Core에서 실행할 수 있는 Thread 수에는 한계가 있다.

- 예를 들어 CPU에서 동시에 실행할 수 있는 Thread보다 프로그램에 존재하는 Runnable Thread가 훨씬 많다면 운영체제의 Scheduler가 실행할 Thread를 계속 선택해야 한다.

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

Thread A ─ 실행
             ↓
Thread B ─ 실행
             ↓
Thread C ─ 실행
             ↓
Thread A ─ 다시 실행

- Thread 실행이 전환되면서 현재 실행 상태를 저장하고 다른 Thread의 상태를 복원해야 할 수 있다.

- 이를 일반적으로 Context Switching이라고 한다.

- Thread가 많다는 이유만으로 모든 Thread가 계속 Context Switching을 발생시키는 것은 아니다.

- 특히 Blocking 상태에 있는 Thread는 Runnable 상태가 아니기 때문이다.

- 하지만 많은 연결을 Thread로 직접 표현하는 구조에서는 I/O 완료나 여러 이벤트에 의해 동시에 다수의 Thread가 실행 가능한 상태가 될 수 있고, Thread 수가 필요 이상으로 많아질수록 Scheduler가 관리해야 하는 대상도 많아진다.

- 따라서 Thread가 많다고 Context Switching이 많아진다라기 보단 필요 이상으로 많은 Thread를 사용하는 구조가 되면 메모리와 스케줄링 측면에서 추가적인 관리 비용을 발생 시킬 수 있다는 점이다.


7. 핵심 문제는 CPU가 아니라 I/O 대기

- Blocking 서버 구조에서 고려해야 하는 문제점이 있다.

Client A Thread
     │
     └─ recv 대기

Client B Thread
     │
     └─ recv 대기

Client C Thread
     │
     └─ recv 대기

- 많은 Thread를 만들어 두었지만 실제로 대부분의 Thread가 CPU 작업을 하고 있지 않다.

- 단순히 네트워크에서 데이터가 도착하기를 기다리고 있다.

- 여기서 고려해야 하는 문제가 생긴다. I/O가 완료될 때까지 Thread를 하나씩 붙잡아 둘 필요가 있는가?

- 이를 운영체제에게 이 Socket에서 데이터가 들어오면 알려달라고 처리할 수 있다면 어떨까?

Socket A ─┐
Socket B ─┤
Socket C ─┤
Socket D ─┤
Socket E ─┘
          │
          ▼
      I/O 대기
          │
     데이터 도착
          │
          ▼
      Thread가 처리

- 이 경우 연결 하나당 Thread를 유지할 필요가 없어진다.

- Thread는 I/O가 실제로 완료된 시점에 필요한 작업을 처리하는 역할에 더 집중할 수 있다.

- 이러한 방향이 이후 Non-Blocking I/O와 비동기 I/O를 이해하는 출발점이 된다.


8. Blocking 방식은 어떻게 사용해야 할까?

- Blocking 방식에도 분명한 장점이 있다. 가장 큰 장점은 구조가 단순하다는 점이다.

recv();
ProcessPacket();
send();

- 실행 흐름과 코드 흐름이 비교적 일치하기 때문에 구현과 디버깅이 쉽다.

- 아래와 같은 경우 Blocking 방식도 충분히 합리적인 선택일 수 있다.

- 동시 연결 수가 적은 프로그램
- 단순한 내부 도구
- 테스트 프로그램
- 확장성이 크게 중요하지 않은 서버

- IOCP와 같은 비동기 구조는 더 많은 연결을 효율적으로 처리할 수 있는 대신 상태 관리와 수명 관리 등 구현 복잡도가 증가한다.

- 결론은, 서버에서 요구되는 동시 연결 수와 처리 구조에 따라 적절한 I/O 모델을 선택해야 한다.


9. Blocking 서버의 한계

- 지금까지 내용을 정리하면 Client마다 Thread를 사용하는 Blocking 서버에서는 다음과 같은 구조가 만들어질 수 있다.

Client 증가
   ↓
Blocking I/O 증가
   ↓
대기 중인 Thread 증가
   ↓
Thread를 유지하기 위한 자원 증가
   ↓
많은 연결을 처리할수록 확장성에 부담

- 특히 네트워크 서버에서는 CPU 작업보다 I/O를 기다리는 시간이 존재한다.

- 따라서 많은 Thread가 단순히 I/O를 기다리는 형태보다 I/O 대기와 Thread 실행을 분리할 수 있다면 더 적은 수의 Thread로 여러 연결을 처리하는 구조를 생각해볼 수 있다.

- Windows에서는 많은 비동기 I/O 요청을 처리하기 위한 방법 중 하나로 **I/O Completion Port(IOCP)**를 제공한다.

- IOCP는 다수의 비동기 I/O 요청을 처리할 때 요청마다 새로운 Thread를 생성하는 대신, 미리 준비된 Thread들을 이용하여 완료된 I/O를 처리할 수 있도록 설계되어 있다.


10. 현재 프로젝트에서의 적용 예

- 현재 프로젝트에서는 Login Server와 이후 Game Server에서 여러 Session을 처리해야 한다.

- 만약 Session 하나마다 다음과 같이 Thread를 하나씩 가지고 있는 구조를 사용한다면 Session 수가 증가하면서 Thread 수도 같이 증가하는 구조가 된다.

Session A ─ Thread A
Session B ─ Thread B
Session C ─ Thread C
Session D ─ Thread D
...
Session A ─┐
Session B ─┤
Session C ─┼─ IOCP ─ Worker Threads
Session D ─┤
Session E ─┘

- 반면 앞으로 구현하려는 IOCP 기반에서는 여러 Session에서 발생한 비동기 I/O 완료를 제한된 수의 Worker Thread가 처리하는 방향으로 구성할 수 있다.

- 따라서 현재 구현하려는 IOCP는 많은 Connection을 처리하면서 I/O를 기다리기 위해 Connection마다 Thread를 하나 씩 유지해야 하는 구조를 피하고, 완료된 I/O 중심으로 Thread를 사용하기 위한 모델이다.


11. 정리

- Blocking I/O는 구현이 단순하고 프로그램의 흐름을 이해하기 쉽다.

- 하지만 많은 클라이언트를 동시에 처리하는 서버에서는 I/O를 기다리는 동안 Thread가 대기하게 된다는 특징 때문에 Client마다 Thread를 할당하는 구조로 이어지기 쉽다.

Connection
    │
    ▼
 Thread
    │
    ▼
Blocking I/O
    │
    ▼
I/O 완료까지 대기

- Connection 수가 증가하면 Thread 수 역시 증가할 수 있고, 각 Thread를 유지하기 위한 Stack 등의 자원과 Thread 관리 비용도 함께 고려해야 한다.

- 특히 많은 Thread가 실제 연산을 수행하는 대신 네트워크 I/O 완료를 기다리고 있다면 I/O 대기를 위해 Thread를 연결별로 유지하는 것이 필요한가?라는 문제가 생긴다.

- 다음에는 우선 Non-Blocking Socket을 사용하면 Blocking 서버에서 발생했던 문제를 어떻게 다르게 처리할 수 있는지, 그리고 Non-Blocking만 사용했을 때는 어떤 새로운 문제가 발생하는지 정리해보려고 한다.

Comments