서버의 본질은 결국 요청을 받고, 처리하고, 응답하는 것이다.
Client -> Connection -> Receive -> Packet -> Game Logic -> Send -> Client
여기에 여러 클라이언트가 동시에 접속하면서 I/O를 효율적으로 처리해야 하는 문제가 생긴다.
Client -> TCP -> Socket -> I/O Model -> Session -> Buffer -> Packet -> Game Logic -> Send
TCP 서버 기본 흐름
Socket -> Bind -> Listen -> Accept -> Client Socket -> Recv / Send
Listen Socket은 연결을 받는 용도이고, 실제 통신은 Client Socket을 통해 이루어진다.
Server -> Listen Socket -> Client Connection -> Client Socket
TCP와 Packet 처리
TCP는 Packet 단위가 아니라 Byte Stream으로 데이터를 전달한다.
Client Packet -> TCP Stream -> Receive Buffer -> Packet Framing -> Packet -> Handler
하나의 Send가 하나의 Receive로 대응된다는 보장은 없다.
Send Packet A -> TCP Stream -> Receive A 일부 -> Receive A 나머지
여러 Packet이 하나의 Receive에 합쳐질 수도 있다.
Send A -> Send B -> TCP Stream -> Receive A + B
따라서 일반적인 Packet 처리 흐름은 다음과 같다.
Receive -> Buffer 추가 -> Header 확인 -> Packet Length 확인 -> 데이터 충분 여부 확인 -> Packet 추출 -> Handler
Blocking Socket
Blocking Socket에서는 I/O 작업이 완료될 때까지 현재 Thread가 기다린다.
recv() -> 데이터 대기 -> 데이터 도착 -> 처리
연결마다 Thread를 사용하는 구조라면 다음처럼 된다.
Client A -> Thread A
Client B -> Thread B
Client C -> Thread C
Client N -> Thread N
연결 수가 증가하면 Thread 관리 비용과 Context Switching 비용이 증가할 수 있다.
IOCP
Windows에서는 IOCP를 사용해 비동기 I/O를 처리할 수 있다.
핵심 흐름은 다음이다.
WSARecv() -> I/O 요청 -> Windows -> I/O 처리 -> I/O 완료 -> IOCP -> GetQueuedCompletionStatus() -> Worker Thread -> Packet 처리
IOCP의 핵심은 Completion이다.
I/O 요청 -> I/O 완료 -> Completion Queue -> Completion 처리
IOCP 자체가 Thread Pool은 아니다.
IOCP -> 완료된 I/O 전달 -> Worker Thread -> 실제 작업 처리
IOCP Overlapped I/O
비동기 I/O 작업의 상태를 OVERLAPPED를 통해 관리한다.
WSARecv() -> OVERLAPPED -> Windows I/O -> I/O 완료 -> IOCP -> OVERLAPPED 확인 -> Session 확인 -> IO Type 확인 -> 처리
실제 서버에서는 I/O 작업 정보를 확장해서 관리하는 경우가 많다.
IO Context -> OVERLAPPED + Session + Buffer + IO Type
I/O 종류는 다음처럼 나눌 수 있다.
Accept -> Recv -> Send
IOCP Worker 구조
Worker Thread 여러 개가 하나의 IOCP에서 완료된 작업을 가져갈 수 있다.
I/O Request -> Windows -> IOCP -> Worker Thread -> Packet Processing
여러 Worker를 사용한다면:
IOCP -> Worker 1
IOCP -> Worker 2
IOCP -> Worker 3
핵심 관계는 다음이다.
IOCP -> Completion 전달
Worker Thread -> Completion 처리
Linux epoll
Linux에서는 epoll을 사용할 수 있다.
핵심 흐름은 IOCP와 다르다.
Socket -> epoll_ctl() -> Socket 감시 -> I/O 가능 상태 -> epoll_wait() -> Socket 확인 -> recv() / send()
epoll의 핵심은 Readiness다.
Socket 등록 -> I/O 가능 상태 감지 -> Application 통지 -> Application이 I/O 수행
IOCP와 달리 epoll이 데이터를 대신 읽어주는 것은 아니다.
epoll_wait() -> Read 가능 확인 -> recv() -> 데이터 처리
epoll Event Loop
일반적인 epoll 서버는 Event Loop 구조와 함께 사용된다.
Socket 등록 -> epoll_wait() -> Event 확인 -> Socket 처리 -> recv() / send() -> Event Loop 반복
여러 Socket을 하나의 Event Loop에서 감시할 수 있다.
Socket A -> epoll
Socket B -> epoll
Socket C -> epoll
Socket D -> epoll
실제 실행 흐름은 다음과 같다.
epoll -> 여러 Socket 감시 -> I/O 가능 Socket 확인 -> 해당 Socket 처리 -> 다음 이벤트 대기
Non-blocking Socket
epoll에서는 일반적으로 Non-blocking Socket을 사용한다.
epoll_wait() -> READ 가능 -> recv() -> recv() -> recv() -> EAGAIN -> 현재 처리 종료
특히 Edge Trigger 방식에서는 가능한 데이터를 모두 읽는 구조가 중요하다.
epoll_wait() -> recv() 반복 -> EAGAIN -> Event 처리 종료
Level Trigger
Level Trigger에서는 조건이 계속 유지되면 이벤트가 다시 발생할 수 있다.
데이터 도착 -> EPOLLIN -> 일부 데이터 읽음 -> 데이터 잔여 -> EPOLLIN -> 남은 데이터 처리
Edge Trigger
Edge Trigger에서는 상태 변화가 발생한 시점을 기준으로 이벤트를 받는다.
데이터 없음 -> 데이터 도착 -> EPOLLIN -> recv() 반복 -> EAGAIN
따라서 ET에서는 일반적으로 다음 흐름이 된다.
epoll_wait() -> EPOLLIN -> recv() 반복 -> EAGAIN -> 다음 Event
IOCP와 epoll
두 기술의 핵심 차이는 다음 하나로 잡으면 된다.
IOCP -> Completion
epoll -> Readiness
좀 더 풀면 다음과 같다.
IOCP -> I/O 요청 -> OS 처리 -> I/O 완료 -> Completion 전달 -> Application 처리
epoll -> Socket 감시 -> I/O 가능 -> Application 통지 -> Application이 I/O 수행
따라서 전체 흐름을 비교하면:
IOCP -> Async I/O Request -> OS -> Completion -> Worker -> Processing
epoll -> Socket Monitoring -> Readiness -> epoll_wait() -> recv / send -> Processing
I/O 모델과 실행 구조
여기서 중요한 구분이 있다.
IOCP와 epoll은 OS의 I/O 모델이고, Event Loop와 Worker Pool은 Application의 실행 구조다.
IOCP / epoll -> OS I/O Model
Event Loop / Worker Pool / Game Thread -> Application Execution Model
따라서 다음처럼 고정해서 생각하면 안 된다.
IOCP -> Worker Pool
epoll -> Event Loop
실제로는 다음처럼 조합할 수 있다.
IOCP -> Worker Thread
IOCP -> Worker Pool
epoll -> Event Loop
epoll -> Event Dispatch -> Worker Pool
Session 구조
Socket을 그대로 사용하는 것보다 Session이라는 객체로 연결을 추상화하는 경우가 많다.
Socket -> Session
Session 안에는 연결에 필요한 상태를 관리한다.
Session -> Socket + Receive Buffer + Send Buffer + Connection State + User State
전체 흐름은 다음과 같다.
Socket -> Session -> Receive Buffer -> Packet -> Handler -> Game Logic
Receive 처리
게임 서버의 실제 Receive 흐름은 다음과 같이 볼 수 있다.
Client -> TCP -> Socket -> I/O Model -> Session -> Receive Buffer -> Packet Framing -> Packet -> Packet Handler -> Game Logic
Packet Framing 과정은 다음과 같다.
Receive -> Buffer 추가 -> Header 확인 -> Length 확인 -> Packet 완성 -> Packet 추출 -> Handler
Send 처리
Send 역시 비동기로 관리하는 경우가 많다.
Game Logic -> Packet 생성 -> Send Queue -> Send Buffer -> Async Send -> I/O 완료 -> 다음 Send
여러 곳에서 하나의 Session에 Send를 요청할 수도 있다.
Game Logic A -> Send Queue
Game Logic B -> Send Queue
Game Logic C -> Send Queue
이후 하나의 흐름으로 처리한다.
Send Queue -> Send Buffer -> Async Send -> Completion / Writable Event -> 다음 Send
멀티스레딩
Network Thread와 Game Thread를 분리하면 다음과 같은 구조를 만들 수 있다.
Network I/O -> Packet -> Packet Queue -> Game Thread -> Game State
여러 Network Worker가 존재할 수도 있다.
IOCP / epoll -> Network Worker -> Packet Queue -> Game Thread -> Game State
이 구조에서는 Network Layer가 게임 상태를 직접 변경하지 않고 Packet을 Game Thread에 전달하도록 만들 수 있다.
동시성
여러 Thread가 같은 데이터를 접근하면 동시성 문제가 발생한다.
Worker A -> Shared Data
Worker B -> Shared Data
이 과정에서 Race Condition이 발생할 수 있다.
Thread A -> Read / Write
Thread B -> Read / Write
-> Race Condition
이를 해결하기 위해 상황에 따라 동기화 구조를 사용한다.
Shared Data -> Mutex / Atomic / Lock-free Queue / Thread Ownership
하지만 더 중요한 방법은 공유 자체를 줄이는 것이다.
Network Thread -> Packet Queue -> Game Thread -> Game State
libuv
플랫폼별 I/O API를 직접 사용하는 대신 libuv 같은 추상화 계층을 사용할 수 있다.
Application -> libuv -> Platform I/O
운영체제에 따라 내부적으로 다른 I/O 기술을 사용한다.
Application -> libuv -> Windows -> IOCP
Application -> libuv -> Linux -> epoll
따라서 개발자가 플랫폼별 I/O 구현을 직접 분리해야 하는 부담이 줄어든다.
libuv Event Loop
libuv에서는 Event Loop가 핵심 실행 구조다.
Application -> libuv -> Event Loop -> Network Event -> Callback -> Packet 처리
TCP 서버를 기준으로 보면:
Client -> Network -> libuv -> Event Loop -> Read Callback -> Packet 처리
전체 서버 구조
지금까지의 내용을 하나로 묶으면 다음과 같다.
Client -> TCP -> Socket -> IOCP / epoll -> Session -> Receive Buffer -> Packet Framing -> Packet -> Packet Handler -> Game Logic -> Game State
Send까지 포함하면:
Client -> TCP -> Socket -> IOCP / epoll -> Session -> Receive Buffer -> Packet Framing -> Packet -> Handler -> Game Logic -> Send Queue -> Send Buffer -> IOCP / epoll -> TCP -> Client
멀티스레딩까지 포함하면:
Client -> TCP -> Socket -> IOCP / epoll -> Network Worker -> Packet Queue -> Game Thread -> Game Logic -> Game State
C++ 서버 전체 계층
서버를 계층으로 나누면 다음처럼 볼 수 있다.
Client -> Network -> Socket -> I/O Model -> Session -> Buffer -> Packet -> Handler -> Game Logic -> Game State -> Database
각 계층의 역할은 분리된다.
Socket -> Network Connection
I/O Model -> Efficient I/O
Session -> Connection State
Buffer -> Stream Data Management
Packet -> Protocol Data
Handler -> Request Routing
Game Logic -> Business Rules
Game State -> Runtime State
Database -> Persistent Data
IOCP
WSARecv() -> Windows -> Async I/O -> I/O Completion -> IOCP -> GetQueuedCompletionStatus() -> Worker -> Session -> Packet
epoll
Socket -> epoll_ctl() -> epoll -> epoll_wait() -> Readiness Event -> recv() / send() -> Session -> Packet
libuv
Application -> libuv -> Event Loop -> Platform I/O -> Callback -> Packet
핵심 흐름
결국 이 글에서 가장 중요한 흐름은 이것이다.
Client -> TCP -> Socket -> I/O Model -> Session -> Buffer -> Packet -> Handler -> Game Logic -> Game State
그리고 운영체제별 I/O 처리만 분리하면:
Windows -> IOCP -> Completion
Linux -> epoll -> Readiness
Cross Platform -> libuv -> Event Loop -> Platform I/O
서버의 실행 구조까지 포함하면:
Network I/O -> Packet -> Queue -> Game Thread -> Game State
IOCP와 epoll은 서버 그 자체가 아니다.
IOCP / epoll -> I/O 처리
Session -> 연결 관리
Buffer -> Stream 관리
Packet -> Protocol 처리
Queue -> Thread 간 작업 전달
Game Logic -> 실제 게임 처리
Game State -> 게임 상태 관리
결국 서버 개발에서 봐야 할 핵심 흐름은 다음 하나로 압축된다.
Client -> Network I/O -> Session -> Buffer -> Packet -> Queue -> Game Logic -> Game State -> Response -> Client
이 구조를 잡아두면 IOCP, epoll, libuv가 서로 다른 기술임에도 서버 전체에서 정확히 어느 위치에 존재하는지가 명확해진다.