| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | ||
| 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| 20 | 21 | 22 | 23 | 24 | 25 | 26 |
| 27 | 28 | 29 | 30 |
- team project
- Data Structure
- git
- Network Programming
- C++
- c#
- Online Judge
- Photon
- design pattern
- System Programming
- Unity
- docker
- multi-thread
- Toy Project
- BOJ
- 독서
- PS
- Today
- Total
I'm FanJae.
Unity 게임 개발 캠프 개인 프로젝트 4일차. Blocking 서버의 한계 (개인 공부 내용) 본문
Unity 게임 개발 캠프 개인 프로젝트 4일차. Blocking 서버의 한계 (개인 공부 내용)
FanJae 2026. 9. 17. 22:081. 시작에 앞서
- 이전에는 동기와 비동기, 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만 사용했을 때는 어떤 새로운 문제가 발생하는지 정리해보려고 한다.
'Projects > MyToyMapleServer' 카테고리의 다른 글
| Unity 게임 개발 캠프 개인 프로젝트 5일차. LoginServer · GameServer 인증 구조 구성 (0) | 2026.09.19 |
|---|---|
| Unity 게임 개발 캠프 개인 프로젝트 4일차. IOCP 기반 비동기 네트워크 처리 구조 구현 (0) | 2026.09.17 |
| Unity 게임 개발 캠프 개인 프로젝트 3일차. 동기와 비동기, Blocking과 Non-Blocking (개인 공부 내용) (0) | 2026.09.16 |
| Unity 게임 개발 캠프 개인 프로젝트 2일차. 인증 상태와 세션 전달 방식에 대한 고민 (0) | 2026.09.15 |
| Unity 게임 개발 캠프 개인 프로젝트 1일차. 서버 구조에 대한 고민 (0) | 2026.09.14 |