안녕하세요! NewCodes입니다!

저번 글에서 실시간 퀴즈 게임 프로젝트에 대해
부하테스트와 성능최적화 글을 다루었습니다.
Artillery를 통한 Socket.io 게임 서버 부하테스트 경험기
실시간 게임 서버 성능 최적화 - 한 게임방에서 200명이 플레이?!
이번 글에서는 Network I/O가 어떻게 일어나는지
고수준에서 저수준으로 깊게 파보려 합니다.
이번에는 병목의 원인으로 잡혔던 Network I/O에서
저수준에서 어떤 부분이 원인이었을지를 탐구해보겠습니다.
이를 통해 다음에 트래픽이 상승하여 서버에 부하가 생겼을 때,
개선 방향은 무엇이 있을지 고민해보려 합니다.
이 글에서 다루고자 하는 내용
- Network I/O가 Node.js에서부터 시작해서 OS까지 이루어지는 과정 분석하기
- I/O는 왜 비싼 비용이 드는 작업인지 규명하기
- I/O로 인한 오버헤드를 개선하는 방법들 살펴보기
💣 1초에 80,000번 Network I/O를 최적화하기
400 RPS의 부하테스트
진행했던 부하테스트는 한 게임방에 200명이 접속해 플레이하는 상황이었습니다. 이때 각각의 플레이어는 1초에 2번 꼴로 채팅이나 캐릭터 이동과 같은 요청을 보냅니다. 그렇기에 서버는 1초에 400개의 요청을 받게 됩니다.
'서버는 400RPS 정도만 처리하면 되니 거뜬하게 처리할 수 있겠구나!'라고 생각할 수도 있는데요! 하지만 부하테스트를 했을 때 심각한 성능 저하가 있었습니다.

캐릭터 위치 변경에 대한 서버 처리 시간은 5.8초가 걸렸고요. 채팅은 평균 8.3초가 걸렸습니다. 이러한 부하의 원인은 수많은 Network I/O에 있었습니다.
400RPS임에도 부하가 걸렸던 이유
게임의 특성상 한 명의 캐릭터가 이동하면, 이 캐릭터의 위치를 게임방에 있는 모든 인원에게 브로드캐스팅을 해야 합니다. 예를 들어, 200명이 있는 게임방에서 1명이 요청을 보내면 서버 입장에서는 200개의 응답을 보내줘야 합니다.
그리고 부하테스트했던 상황에서는 400RPS였습니다. 400개의 요청에 대해서 200명에게 브로드캐스팅하려면 총 80,000개의 응답을 주어야 합니다. 그리고 80,000개의 Network Output으로 인한 부하가 심각한 성능 저하에 영향을 주었습니다.
배치 처리를 통해 성공적인 최적화
이렇게 빈번한 브로드캐스팅으로 인한 병목을 인지하여 배치 처리로 최적화를 했습니다. 데이터를 쌓아두고 있다가 0.1초에 한번씩 I/O를 수행합니다. 이를 통해 Network I/O 횟수를 대폭 줄일 수 있었어요. 기존에는 1초에 80,000개의 Network Output이 이루어졌지만, 개선 이후에는 1초에 2000개이하의 Network Output이 이루어집니다.
이를 통해 부하테스트 결과도 준수한 수치로 나왔습니다. 캐릭터 위치 이동은 p95 기준으로 0.11초 안에 응답을 줍니다. 또, 채팅은 p95 0.02초 안에 응답을 주게 됩니다. 자세한 최적화 스토리는 이 글을 참고해주세요!
실시간 게임 서버 성능 최적화 - 한 게임방에서 200명이 플레이?!
안녕하세요! NewCodes입니다! 실시간 퀴즈 게임 플랫폼 프로젝트에서성능 최적화한 경험을 공유하고자 합니다! 부하테스트를 통해 메트릭을 수집했고, 병목의 원인을 분석했습니다. 그리고
newcodes.tistory.com
그래서 결론은 성공적인 최적화로 마무리되었습니다. 하지만, 궁금했습니다.
도대체 I/O는 왜 비용이 많이 드는 작업일까?
I/O를 저수준에서 처리하는 과정 중 어디서 오버헤드가 제일 높을까?
또, 다음에도 부하가 생긴다면 I/O와 관련한 부하일 수도 있을 겁니다.
배치 처리로 잘 되긴 했지만, 제대로 분석을 하고 넘어가야 할 문제입니다!
이제부터 함께 알아보시죠!
🫥 80,000번은 통상적으로 가능한 수치는 아니다!
우선 80,000번이라는 I/O 횟수는 저희 아키텍처에서는 현실적으로 가능한 수치는 아니라는 걸 알았습니다. 참고로 아래 저희 서비스 아키텍처를 소개합니다. 게임에서 실시간 통신을 위해 NestJS와 Socket.io를 주로 사용하고 있었습니다.

그 다음은 NestJS의 RPS를 실험한 통계 자료를 외부에서 가져왔습니다.


보시다시피 NestJS의 RPS는 1만 개 정도에 속합니다.
위 두 가지 자료를 종합해보면 NestJS 서버로 80,000개를 응답한다는 건 불가능한 수치입니다. 더군다나 저희 서비스는 간단한 문자열을 응답하는 API도 아닌 점을 고려해보면 80,000개는 한 서버가 감당하기에 비현실적인 수치였습니다. 또한, 저희 서버는 Ncloud에서 CPU 코어 2개, 메모리 8GB를 쓰고 있었습니다.
(참고로 왼쪽 NestJS 해당 실험에서는 맥북 m1 (메모리 16GB)를 사용했으며, 간단히 "hello world"를 응답하는 HTTP API로 테스트를 진행했다고 합니다. 총 백 만개의 요청을 보냈고, 동시 연결이 10개, 50개, 100개, 200개일 때 테스트를 했습니다.)
왜 80,000번이 불가능할까?
결론부터 말씀드리자면, 80,000번 네트워크 응답이 불가능한 이유는 CPU의 한계입니다.
1) 테스트 결과, 높이 뛴 CPU


위에서 언급한 NestJS RPS 실험 중 추가 자료를 보면 평균 CPU 사용량이 100%를 넘는 모습을 보이며, CPU에 부하가 생겼음을 알 수 있습니다. 반면 메모리는 어떨까요? 메모리는 평균 약 120MB 정도 사용되었으며, 이는 메모리 16GB인 서버에 부하를 줄 수 있을 만큼의 수치는 아닙니다.
부하테스트를 진행했던 저희 프로젝트 서버에서도 위와 비슷한 양상을 보였습니다.


CPU는 100% 가까이 도달하는 모습을 보이며, 메모리는 그에 반해 약 800MB 사용되었습니다.
2) Network 대역폭의 문제는 아니었을까?
Network 대역폭이 제한되어 병목이 생기지 않았을까요? 결론부터 말씀드리자면 이는 문제의 원인이 아닙니다. 대역폭은 충분합니다. Ncloud에서 기본적으로 제공하는 대역폭은 1Gbps입니다. 그리고 1초에 80,000개의 메시지를 응답하기 위해 필요한 대역폭을 계산해본 결과, 122.24Mbps 정도입니다. 아직은 충분한 대역폭임을 알 수 있습니다.
또, Disk를 언급할 수 있는데요. 하지만 저희 프로젝트에서 1초에 80,000번 응답을 하는 애플리케이션 로직상, 읽어야 하거나 써야 하는 Disk는 없었습니다.
그래서 결론은 80,000번의 Network I/O는 CPU에 심각한 부하를 줍니다. 지금부터는 그 이유에 대해서 저수준으로 차근차근 파고들며 알아보겠습니다.
🔍 Node.js에서 Network I/O는 누가 담당할까?
우리 프로젝트는 NestJS를 사용했기에 애플리케이션 레벨에서부터 내려가볼 필요가 있습니다.

Node.js에서는 비동기 작업을 libuv가 처리합니다. libuv란 여러 운영체제 커널을 래핑한 C언어 라이브러리입니다. Node.js 자체는 싱글 스레드이기에 멀티 스레드로 비동기 작업을 처리하거나 운영체제 커널을 사용하기 위해 libuv를 채택하여 사용하고 있습니다.
libuv에서 작업을 처리하는 방식은 크게 두 가지입니다.
- OS에서 지원하는 시스템콜을 그대로 사용하여 작업
- thread pool을 사용하여 직접 작업
그중 네트워크 I/O와 같은 작업은 OS에서 제공하는 시스템콜을 그대로 사용합니다. 그렇다면 OS에서는 Network I/O가 어떻게 이루어지는지 살펴보시죠.
🧐 OS에서 Network I/O는 어떻게 이루어질까?
우선 OS에서 Network I/O를 처리하는 과정을 요약해서 보여드리겠습니다. 이 중 볼드체한 부분에만 집중해주셔도 좋습니다. (네트워크도 함께 다룰 겸 더 딥하게 정리한 감이 있습니다.)
1) JavaScript 레벨
- 아래처럼 애플리케이션에서 socket.emit() 호출
// room1이라는 게임 방에 플레이어 위치를 브로드캐스팅
socket
.to("room1")
.emit("updatePosition", {
playerId: 1234,
newPosition: [0.1, 0.2]
});
- room과 이벤트 정보를 포함한 패킷 객체 생성
// Socket.io - packages/lib/socket.io/lib/broadcast-operator.ts
const packet = {
type: PacketType.EVENT,
data: ["updatePosition", { playerId: 1234, newPosition: [0.1, 0.2] }],
}
- Socket.io 프로토콜에 맞게 패킷을 인코딩
- 인코딩된 패킷을 WebSocket frame으로 변환
- WebSocket 라이브러리에서 Node.js net 모듈의 socket을 사용하여 데이터 전송 요청
// WebSocket - ws/lib/sender.js
/**
* Sends a frame.
*
* @param {(Buffer | String)[]} list The frame to send
* @param {Function} [cb] Callback
* @private
*/
sendFrame(list, cb) {
if (list.length === 2) {
this._socket.cork();
this._socket.write(list[0]); // 데이터 쓰기
this._socket.write(list[1], cb); // 데이터 쓰기
this._socket.uncork();
} else {
this._socket.write(list[0], cb);
}
}
- Node.js Bindings 계층을 통해 요청을 C++로 매핑해서 libuv로 전달
2) libuv 레벨
- WebSocket frame 데이터를 libuv 버퍼에 적재
- 해당 요청을 write_queue에 등록
- uv__write() 함수가 write_queue에서 요청을 꺼내서 전처리하고 uv__try_write() 호출
- uv__try_write() 함수를 통해 실제 시스템콜 호출 준비
- 쓰고자 하는 소켓을 epoll 이벤트에 등록
- epoll_wait()을 통해 소켓 쓰기/읽기 이벤트 발생할 때까지 대기
- 해당 소켓 데이터 전송이 가능하다는 POLLOUT이 발생하면, 시스템콜 호출
3) 시스템콜 레벨
- writev() 시스템콜을 호출하여 컨텍스트 스위칭해서 커널 모드로 전환
- 보내고자 하는 데이터를 유저 공간의 buffer에서 커널 공간의 Socket의 Send Buffer로 복사
- 소켓 버퍼 상태에 따른 흐름 제어
- 비동기 처리의 경우 EAGAIN 발생 시 POLLOUT 이벤트 대기
4) 이후 과정
- TCP/IP 프로토콜 스택 처리 과정
- 네트워크 디바이스 드라이버로 물리적 전송
- (ACK 처리와 재전송 메커니즘)
- 완료 통지(completion notification) 처리
- 원래 컨텍스트로 스위칭
Network Ouput을 하기 위해서는 대략 이러한 과정이 순차적으로 일어납니다. 그렇다면 이 과정 중 구체적으로 어디서 비용이 많이 드는지를 살펴봅시다!
🕵🏻♂️ I/O가 비싼 작업인 이유를 밝혀보자!
CS에서 I/O 자체는 비용이 큰 작업으로 여겨집니다. I/O는 물리 장치와 응용 프로그램 간의 하는 작업이며, 이 사이에는 많은 소프트웨어 계층이 존재합니다. 이러한 계층이 야기하는 오버헤드로 인해 입출력 시스템 콜은 CPU 비용이 많이 듭니다.
오버헤드의 주요 원인에는 아래 세 가지가 있습니다.
- kernel의 protection boundary를 넘을 때의 context switching
- kernel buffer와 응용 프로그램 공간 간의 데이터 복사
- 입출력 장치를 조작하기 위한 인터럽트와 신호
이외에도 I/O 하고자 하는 하드웨어가 느린 장치라면 전체적인 작업 속도가 더뎌질 수 있죠.
이 중 1번, 2번에 대해서 자세하게 알아보겠습니다.
1) 빈번한 context switching
서버가 1초에 80,000개의 응답을 보내려면 빈번한 context switching이 일어나게 됩니다. 네트워크 응답을 보내고 완료되는 동안 다른 컨텍스트로 전환되고, 또 사용자의 새로운 요청도 받아주어야 합니다. 그리고 이러한 context switching은 비용이 비싼 작업입니다.

위 자료에서 보이듯이, context switch는 CPU 연산 중에서도 비싼 편에 속합니다. context switch를 하기 위해서는 다음과 같은 과정을 거치며 많은 CPU 사이클을 소모하게 됩니다.
- 현재 상태 저장 작업
- 실행 중이던 프로세스의 레지스터 값들을 PCB(Process Control Block)에 저장
- 프로그램 카운터, 스택 포인터 등 CPU 상태 정보를 보관
- 캐시 오염 (Cache Pollution)
- 다음 프로세스 페이지 테이블을 위해 TLB(Translation Lookaside Buffer)를 flush
- 프로세스가 바뀌면서 캐시 미스가 발생하고, 캐시를 새로 채우는 비용이 발생
2) kernel buffer와 애플리케이션 buffer 간의 데이터 복사
Network Output을 위해서는 애플리케이션 buffer에서 커널 공간의 buffer로 데이터 복사가 이루어져야 합니다. 커널과 유저 공간은 격리되어 있기에 상호 간에 직접적인 접근이 불가능하기 때문입니다. 시스템의 안전성과 보안을 위해서 어쩔 수 지불해야 하는 비용인 것이죠.
그리고 커널 buffer로 데이터를 복사하면서 CPU 부하가 증가합니다. 그리고 우리 서버는 1초에 80,000개의 메시지를 보내야 하니 복사도 그에 비례하여 많이 일어났을 것입니다.
참고로 아래 자료를 덧붙입니다.

위 그림은 전통적인 운영체제에서의 데이터 복사 방식입니다. 파일에 있는 데이터를 읽고 네트워크로 보내기 위해서는 총 4번의 복사가 필요합니다. 이중 CPU는 2번 관여하게 되고요.
하지만, 우리 서버에서 데이터는 Application buffer에 있는 상태에서 시작하기에 1번, 2번 과정은 일어나지 않습니다. 위 그림을 가져온 건 한 번 응답을 하기 위해 데이터 복사가 비효율적으로 많이 일어나는 것에 대해 문제의식을 알려드리기 위함이었습니다.
🎯 저수준의 해결 방안
지금껏 어디서 부하가 생기는지를 알아봤습니다. 그래서 I/O 효율을 높이기 위해서는 어떻게 해야 할까요? 대표적으로 아래와 같은 방법이 있습니다.
- Context Switching의 빈도 줄이기
- 물리 장치와 응용 프로그램 사이에 데이터가 복사되는 횟수 줄이기
- 인터럽트 빈도 줄이기
- DMA나 채널 등 사용
- 원시 처리 연산을 하드웨어로 구현
- CPU, 메모리, 버스, 입출력 등에 대한 부하가 균일하게 되도록 함
그러면 위와 같은 걸 어떻게 구체적으로 실현할 수 있을까요? 주로 위 1, 2번에 집중해보려 합니다. 저수준에서 어떤 노력들이 이루어지고 있는지 함께 살펴보시죠.
1) TCP_CORK
TCP_CORK란 데이터를 모아서 전송할 수 있는 소켓 옵션입니다. 이름에서 보시다시피 코르크로 데이터가 흘러가지 않게 막아두고, 원하는 시점에 코르크를 열어 데이터를 보내는 방식입니다.
그리고 WebSocket은 TCP_CORK 방식을 채택하고 있습니다.
// WebSocket - ws/lib/sender.js
/**
* Sends a frame.
*
* @param {(Buffer | String)[]} list The frame to send
* @param {Function} [cb] Callback
* @private
*/
sendFrame(list, cb) {
if (list.length === 2) {
this._socket.cork(); // 소켓 스트림 잠금
this._socket.write(list[0]);
this._socket.write(list[1], cb);
this._socket.uncork(); // 소켓 스트림 해제 (즉, 버퍼에 쌓아둔 데이터 전송)
} else {
this._socket.write(list[0], cb);
}
}
WebSocket에서는 데이터를 보낼 때 프레임으로 쪼개서 보내는데요. 프레임을 보내는 함수를 열어보면 `this._socket.cork()`, `this._socket.uncork()`로 TCP_CORK 설정을 하는 모습을 보실 수 있습니다.
2) writev()
writev()는 버퍼를 하나로 묶어 한 번에 쓰기를 사용할 때 사용되는 시스템 콜입니다. 아래 그림을 보시면 단번에 이해하실 수 있습니다.

그리고 writev()는 libuv에서 채택되어 사용되고 있습니다!
// Node.js - deps/uv/src/unix/stream.c
static ssize_t uv__writev(int fd, struct iovec* vec, size_t n) {
if (n == 1)
return write(fd, vec->iov_base, vec->iov_len); // 버퍼가 한 개일 때는 write 사용
else
return writev(fd, vec, n); // 버퍼가 여러 개일 때는 writev 사용
}
Node.js 내 libuv에서도 마찬가지로 저수준에서 데이터를 모아서 처리하는 노력이 이루어지고 있었네요!
3) io_uring
io_uring이란 비동기 I/O 작업을 위한 linux 커널 시스템 콜 인터페이스입니다. io_uring은 2019년에 Linux 커널 5.1 버전에서 처음 도입되었습니다. Meta의 Jens Axboe라는 엔지니어가 만들었다고 해요!
io_uring은 기존의 비동기 I/O 인터페이스보다 효율적으로 설계되었습니다. 그렇다면 어떻게 설계되었는지 살펴봅시다!

위 다이어그램은 user space와 Linux kernel 사이에서 비동기 인터페이스를 제공하는 방법을 잘 보여주고 있습니다. 이 다이어그램만 보더라도 어떤 비동기 I/O인지 감이 오실 거예요.
io_uring을 사용하는 흐름은 대략 아래와 같습니다.
- user space에서 애플리케이션은 SQ(Submission Queue)를 이용해서 비동기 I/O 요청을 커널에 보낸다.
- kernel에서 SQ에 쌓인 작업을 하나씩 처리한다.
- kernel에서 처리가 완료된 작업에 대해 CQ(Completion Queue)에 response를 넣는다.
- 애플리케이션은 CQ에서 완료된 작업의 response를 꺼내서 사용한다.
그래서 Network I/O에 어떤 장점이 있냐면요! system call 횟수를 줄일 수 있다는 점입니다. 이는 system call 오버헤드와 컨텍스트 스위칭 횟수 자체를 크게 줄일 수 있습니다.
쉽게 말하면 여러 번의 읽기, 쓰기 작업을 한 번의 시스템 콜로 묶어서 처리가 가능합니다. 또, Submission Queue Polling 기능을 활성화하면 시스템 콜 없이 자동으로 SQ를 감지하고 처리합니다. 하지만 CPU는 조금 더 소모하게 될 것입니다.
하지만 io_uring은 여전히 개발 중입니다. 아직 애플리케이션들과 많이 통합은 되지 않았지만, 점점 될 예정인 것 같습니다. Go 언어 내부적으로 io_uring을 적용할지 검토하고 있는 중이라고 합니다. libuv에서도 지원하면 좋겠지만, libuv는 크로스 플랫폼을 지원하는 라이브러리이기에 io_uring을 지원하기는 힘들어 보입니다.
이쯤 되면 슬슬 감이 오실텐데요.
I/O의 성능 최적화를 위한 핵심은 '모아서 처리'함으로써 I/O의 연산 자체를 줄이는 것입니다. I/O를 하기 위한 비용 자체가 비싸다보니 한 번할 때 최대한 많은 데이터를 보내면 효율적이기 때문이죠.
🎯 고수준의 해결 방안
이제는 애플리케이션 레벨에서 보편적으로 I/O 작업을 개선할 수 있는 방법을 소개하겠습니다.
1) Cache
아무래도 대표적인 건 캐시가 있겠죠! 프록시, 웹 서버, WAS, 웹 브라우저 스토리지, 인메모리 DB 등 다양한 곳에서 캐시를 활용할 수 있습니다. 상황에 따라 적절한 곳에서 캐시를 활용하는 건 I/O를 줄이는 아주 바람직한 방법입니다.
하지만, 저희 프로젝트 같은 경우는 캐시를 두기가 힘듭니다. 사용자가 이동하는 캐릭터 위치 혹은 사용자가 보내는 채팅 메시지는 실시간으로 매번 달라지기 때문이죠.
그래서 그다음 방법을 살펴보시죠.
2) Batch Processing
배치 처리는 저희 프로젝트에서 적용했던 최적화 방법입니다.
배치 처리(Batch Processing)이란 주기적으로 데이터를 모아서 한 번에 처리하는 것입니다. I/O해야 하는 데이터가 생기면 이를 매번 바로바로 처리하는 게 아니라, 일정 시간 동안 요청을 모아뒀다가 한번에 I/O 하는 것입니다.
일상생활에도 비유를 해볼 수 있는데요!
필요한 게 생길 때마다 곧바로 마트에 가서 물품을 구매하면 왔다갔다 하는 비용이 많이 들 겁니다. 그게 아니라 필요한 게 어느 정도 쌓이면 마트에 가는 게 왔다갔다 하는 비용을 줄일 수 있기에 효율적이겠죠. 특히, 마트의 거리가 멀면 멀수록 배치 처리로 하는 게 효율적일 겁니다.
저희 프로젝트에서 배치 처리를 어떻게 적용했는지 궁금하시다면 이 글을 살펴봐주세요!
⭐️ 마무리
자! 이제 마무리하는 시간을 가져보겠습니다.
요약
- Node.js에서 Network I/O는 libuv가 OS 시스템 콜을 활용해서 처리한다.
- I/O는 비싼 작업이며, 주요 원인은 '빈번한 컨텍스트 스위칭, 데이터 복사, 인터럽트'로 인한 CPU 부하이다.
- 성능 개선을 위해 데이터를 모아서 처리함으로써 I/O 자체의 횟수를 줄이는 방안을 고민해야 한다.
느낀 점
- I/O는 비싼 작업이다. 애플리케이션 레벨에서 코드 짤 때 I/O 코드를 조심히 짜자. I/O 남발하면 안 된다!
- 성능 최적화 병목을 깊게 파보며 OS, Network에 대해 더 잘 알게 되었다.
- 배치처리로 최적화하면서 모아서 한 번에 처리하는 게 핵심이라 생각했는데, 저수준에서도 이러한 노력들이 보여서 신기했다.
레퍼런스
- https://en.wikipedia.org/wiki/I/O_bound
- https://tech.kakao.com/posts/680
- https://manvscloud.com/?p=1400
- http://ithare.com/infographics-operation-costs-in-cpu-clock-cycles/
- https://medium.com/deno-the-complete-reference/nestjs-express-vs-fastify-comparison-for-hello-world-19875479e41d
- https://fastify.dev/benchmarks/
- https://developers.redhat.com/articles/2023/04/12/why-you-should-use-iouring-network-io
- https://en.wikipedia.org/wiki/Io_uring
- https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
🙋🏻♂️ 궁금한 점이 있다면 무엇이든 댓글 편하게 남겨주세요~~! 🙋🏻♂️
🙇🏻 읽어주셔서 감사합니다 🙇🏻
'Computer Science > Operating System' 카테고리의 다른 글
| 동기 비동기 블로킹 논블로킹, 이 글 하나로 끝내자! (1) | 2025.05.24 |
|---|