Rust의 Mio를 이용한 고성능 논블로킹 네트워크 서비스 구축
Ethan Miller
Product Engineer · Leapcell

소개
현대 소프트웨어 개발의 영역에서 고성능 및 확장 가능한 네트워크 애플리케이션을 구축하는 것은 매우 중요합니다. 웹 서버, 실시간 통신 플랫폼 또는 분산 시스템을 제작하든, 수많은 동시 연결을 효율적으로 처리하는 능력은 필수 불가결한 요구 사항입니다. 전통적인 블로킹 I/O 모델은 각 연결이 자체 스레드를 요구하여 과도한 리소스 소비와 컨텍스트 스위칭 오버헤드를 초래하기 때문에 성능 병목 현상을 일으키는 경우가 많습니다. 이것이 바로 논블로킹 I/O가 빛을 발하는 지점인데, 이는 단일 스레드가 I/O 준비 이벤트에 반응함으로써 여러 연결을 관리할 수 있도록 합니다. Rust는 안전성, 성능 및 동시성에 대한 강력한 강조를 통해 이러한 노력에 대한 훌륭한 기반을 제공합니다. Rust 생태계 내에서 mio
(Metal I/O)는 저수준 논블로킹 네트워크 프로그래밍을 위한 기본 빌딩 블록으로 등장하며, 운영 체제의 이벤트 알림 메커니즘에 대한 원시적이고 편견 없는 인터페이스를 제공합니다. 이 글은 mio
를 사용하여 Rust에서 논블로킹, 저수준 네트워크 애플리케이션을 구축하는 과정을 안내하여 고도로 효율적이고 확장 가능한 네트워크 서비스를 구축할 수 있도록 지원합니다.
Mio를 이용한 핵심 개념 및 구현
코드를 살펴보기 전에 논블로킹 I/O 및 mio
의 핵심 개념을 명확히 이해해 봅시다.
주요 용어
- Non-Blocking I/O (논블로킹 I/O): 데이터가 사용 가능하거나 작업이 완료될 때까지 기다리는 블로킹 I/O와 달리, 논블로킹 I/O 작업은 데이터가 사용 가능하지 않거나 작업이 완료되지 않았더라도 즉시 반환됩니다. 이를 위해서는 애플리케이션이 폴링하거나 I/O가 준비되었을 때 알림을 받아야 합니다.
- Event Loop (이벤트 루프): 논블로킹 애플리케이션의 중앙 구성 요소입니다. I/O 이벤트(예: 데이터 도착, 연결 설정, 소켓 쓰기 가능)를 지속적으로 모니터링하고 적절한 핸들러로 파견합니다.
- Event Notification System (이벤트 알림 시스템):
mio
가 추상화하는 기반 운영 체제 메커니즘(예: Linux의 epoll, macOS/FreeBSD의 kqueue, Windows의 IOCP)입니다. 이 시스템을 통해 프로그램은 여러 파일 디스크립터에 대한 다양한 I/O 이벤트에 대한 관심을 등록하고 해당 이벤트가 발생할 때 효율적으로 알림을 받을 수 있습니다. mio::Poll
:mio
의 핵심입니다.Evented
객체(TCP 소켓 등)를 등록하고 등록된 하나 이상의 이벤트가 발생할 때까지 차단할 수 있는 이벤트 루프입니다.mio::Token
: 등록된 각Evented
객체와 연결된 고유 식별자입니다. 이벤트가 발생하면mio
는 이 토큰을 반환하여 이벤트가 어떤 등록된 객체에 해당하는지 식별할 수 있게 합니다.mio::Events
:poll.poll(...)
이 블로킹에서 반환된 후 발생하는 이벤트를 채우는 버퍼입니다.mio::Evented
: 객체가mio::Poll
에 등록되어 이벤트 알림을 수신하는 방법을 정의하는 트레이트입니다.mio
는TcpStream
및TcpListener
와 같은 표준 네트워크 유형에 대한Evented
구현을 제공합니다.- Edge-Triggered (엣지 트리거) vs. Level-Triggered (레벨 트리거):
- Level-Triggered (레벨 트리거): 이벤트 시스템이 조건이
참
이면 알림을 보냅니다(예: 버퍼에 데이터가사용 가능한
상태). 버퍼를 비울 때까지 반복적으로 알림을 받게 됩니다. - Edge-Triggered (엣지 트리거): 이벤트 시스템이 조건이
변경
될 때만 알림을 보냅니다(예: 새 데이터가도착
). 모든 사용 가능한 데이터를 한 번에 처리해야 하며, 그렇지 않으면 새 데이터가 도착할 때까지 다시 알림을 받지 못합니다.mio
는 효율성을 위해 주로 엣지 트리거 의미론을 사용합니다.
- Level-Triggered (레벨 트리거): 이벤트 시스템이 조건이
작동 원리
mio
-기반 논블로킹 애플리케이션의 일반적인 흐름은 다음 단계를 포함합니다.
mio::Poll
초기화: 이벤트 루프를 관리할mio::Poll
인스턴스를 생성합니다.Evented
객체 등록: 네트워크 소켓(예: 연결을 수락하기 위한TcpListener
, 연결된 클라이언트용TcpStream
)을 고유한Token
과 함께mio::Poll
에 등록하고Interest
(읽기, 쓰기 또는 둘 다)를 지정합니다.- 이벤트 루프 진입:
poll.poll(...)
을 지속적으로 호출하여 I/O 이벤트를 기다립니다. 이 호출은 이벤트가 발생하거나 타임아웃이 만료될 때까지 차단됩니다. - 이벤트 처리:
poll.poll(...)
이 반환되면 수신된mio::Events
를 반복합니다. 각 이벤트에 대해Token
을 사용하여 소스를 식별하고 해당 I/O를 처리합니다.TcpListener
이벤트가 발생하면 새 연결을 수락하고 새TcpStream
을mio::Poll
에 등록합니다.TcpStream
읽기 이벤트가 발생하면 사용 가능한 데이터를 논블로킹 방식으로 읽습니다.TcpStream
쓰기 이벤트가 발생하면 보류 중인 데이터를 씁니다.
- 관심 등록/수정: 이벤트를 처리한 후 수정된
Interest
로Evented
객체를 다시 등록해야 할 수 있습니다(예: 쓰기를 마친 경우Interest::WRITABLE
제거).
실제 예제: 간단한 에코 서버
mio
를 사용하여 기본적인 논블로킹 에코 서버를 구축하여 이러한 개념을 설명해 보겠습니다. 이 서버는 들어오는 TCP 연결을 수신 대기하고 클라이언트로부터 데이터를 읽은 다음 클라이언트에게 다시 에코합니다.
use mio::net::{TcpListener, TcpStream}; use mio::{Events, Interest, Poll, Token}; use std::collections::HashMap; use std::io::{self, Read, Write}; // 우리 소켓이 어떤 이벤트인지 식별하는 데 도움이 되는 토큰입니다. const SERVER: Token = Token(0); fn main() -> io::Result<()> { // poll 인스턴스를 생성합니다. let mut poll = Poll::new()?; // 이벤트 저장소를 생성합니다. let mut events = Events::with_capacity(128); // TCP 리스너를 설정합니다. let addr = "127.0.0.1:9000".parse().unwrap(); let mut server = TcpListener::bind(addr)?; // 서버를 poll 인스턴스에 등록합니다. poll.registry() .register(&mut server, SERVER, Interest::READABLE)?; // 연결된 클라이언트에 대한 추적을 유지하기 위한 해시 맵입니다. let mut connections: HashMap<Token, TcpStream> = HashMap::new(); let mut next_token = Token(1); // 클라이언트 토큰을 1부터 시작합니다. println!("Listening on {}", addr); loop { // 이벤트를 기다립니다. poll.poll(&mut events, None)?; // `None`은 타임아웃이 없음을 의미하며 무한정 차단됩니다. for event in events.iter() { match event.token() { SERVER => loop { // 서버 소켓에 대한 이벤트를 수신했으므로 새 연결을 사용할 수 있음을 의미합니다. match server.accept() { Ok((mut stream, addr)) => { println!("Accepted connection from: {}", addr); let token = next_token; next_token.0 += 1; // 새 클라이언트 연결을 poll 인스턴스에 등록합니다. // 이 클라이언트로부터 읽고 쓰는데 관심을 갖고 있습니다. poll.registry().register(&mut stream, token, Interest::READABLE | Interest::WRITABLE)?; connections.insert(token, stream); } Err(e) if e.kind() == io::ErrorKind::WouldBlock => { // 현재 더 이상 들어오는 연결이 없습니다. break; } Err(e) => { // 다른 오류, 리스너에 대해 복구 불가능할 수 있습니다. eprintln!("Error accepting connection: {}", e); return Err(e); } } }, token => { // 클라이언트 연결에 대한 이벤트를 수신했 // 이 줄은 원래 코드에서 잘렸던 부분입니다. let mut done = false; if let Some(stream) = connections.get_mut(&token) { if event.is_readable() { let mut buffer = vec![0; 4096]; match stream.read(&mut buffer) { Ok(0) => { // 클라이언트 연결이 끊어졌습니다. println!("Client {:?} disconnected.", token); done = true; } Ok(n) => { // `n` 바이트를 성공적으로 읽었습니다. 다시 에코합니다. println!("Read {} bytes from client {:?}", n, token); if let Err(e) = stream.write_all(&buffer[..n]) { eprintln!("Error writing to client {:?}: {}", token, e); done = true; } } Err(e) if e.kind() == io::ErrorKind::WouldBlock => { // 읽을 준비가 되지 않았으므로 나중에 다시 시도합니다. // 엣지 트리거 이벤트에서 올바르게 처리하면 이것은 발생하지 않아야 합니다. // 버퍼를 완전히 비우지 않은 경우 발생할 수 있습니다. } Err(e) => { eprintln!("Error reading from client {:?}: {}", token, e); done = true; } } } // `is_writable()`이 true이면 소켓에 차단 없이 쓸 수 있음을 의미합니다. // 간단한 에코 서버의 경우, 읽은 것을 즉시 다시 씁니다. // 더 복잡한 애플리케이션에 보내기 큐가 있다면 거기서 쓸 것입니다. // 이 예제에서는 단순성을 위해 `is_readable` 블록 안에서 쓰기가 발생합니다. // 생성하기만 관심 있었다면 여기에 별도의 쓰기 루프가 있을 것입니다. // 참고: 에코의 경우 읽은 후 즉시 다시 씁니다. // 내부 send 버퍼가 있다면 `is_writable`이 해당 버퍼에서 보내는 것을 트리거합니다. } else { // 이상적으로는 `connections` 맵이 일관성 있다면 발생하지 않아야 합니다. eprintln!("Event for unknown token: {:?}", token); } if done { // 클라이언트를 연결 맵에서 제거하고 등록 취소합니다. if let Some(mut stream) = connections.remove(&token) { poll.registry().deregister(&mut stream)?; } } } } } } }
이 예제를 실행하려면:
- 코드를
src/main.rs
로 저장합니다. Cargo.toml
에mio = { version = "0.8", features = ["net"] }
를 추가합니다.cargo run
을 실행합니다.netcat
으로 연결합니다:nc 127.0.0.1 9000
및 일부 텍스트를 입력합니다.
에코 서버 설명
Poll::new()
: 중앙 이벤트 루프 구조를 생성합니다.TcpListener::bind()
:TcpListener
를 지정된 주소에 바인딩하여 들어오는 연결을 수락할 준비를 합니다.poll.registry().register()
: (server
)를poll
인스턴스에 등록하여 수신 소켓에 대한READABLE
이벤트를 관심 갖고 있음을 나타냅니다.SERVER
토큰이 이 등록을 식별합니다.poll.poll(&mut events, None)
: 이것이 차단 호출입니다. 프로그램은 등록된 하나 이상의 이벤트가 발생할 때까지 여기서 일시 중지됩니다.None
은 타임아웃이 없음을 의미하며, 이는 무한정 차단됨을 의미합니다.events.iter()
:poll.poll
이 반환된 후,mio::Events
버퍼를 반복하여 각 보류 중인 이벤트를 처리합니다.match event.token()
:Token
을 사용하여 서버 리스너(SERVER
) 이벤트와 클라이언트 연결 이벤트 간을 구별합니다.- 서버
SERVER
이벤트:server.accept()
: 새 들어오는 연결을 수락합니다. 이벤트 루프 안에 있기 때문에 차단되지 않습니다. 연결이 없으면io::ErrorKind::WouldBlock
을 반환합니다.- 새로 수락된
TcpStream
은 새 고유Token
및Interest::READABLE | Interest::WRITABLE
과 함께poll.registry().register()
로 등록됩니다.TcpStream
을Token
으로 식별되는connections
맵에 저장합니다.
- 클라이언트
token
이벤트:event.is_readable()
: 이벤트가 클라이언트 소켓에 읽을 데이터가 있음을 나타내는지 확인합니다.stream.read(&mut buffer)
: 클라이언트로부터 데이터를 읽습니다. 이 역시 논블로킹입니다. 0바이트를 읽으면 클라이언트 연결이 끊어졌음을 의미합니다.ErrorKind::WouldBlock
은 데이터가 아직 준비되지 않았음을 의미하지만, 엣지 트리거 이벤트의 경우is_readable
이 참이면 데이터가 있어야 합니다.stream.write_all(&buffer[..n])
: 읽은 데이터를 클라이언트에게 다시 에코합니다. 오류가 발생하면 클라이언트가 연결 끊김 대상으로 표시됩니다.done
이 참(클라이언트 연결 끊김 또는 오류)인 경우 클라이언트의TcpStream
이connections
에서 제거되고poll.registry()
에서 등록 취소됩니다.
애플리케이션 시나리오
mio
는 다음과 같은 구축에 이상적입니다.
- 고성능 네트워크 프록시 및 로드 밸런서: 수많은 연결에 대한 트래픽을 효율적으로 전달하고 관리합니다.
- 사용자 지정 애플리케이션 계층 프로토콜: 더 높은 수준의 프레임워크 오버헤드 없이 고도로 특수화된 네트워크 통신을 구현합니다.
- 실시간 게임 서버: 낮은 지연 시간으로 많은 동시 플레이어 연결을 관리합니다.
- IoT 통신 허브: 방대한 수의 장치 연결을 효율적으로 처리합니다.
- 임베디드 네트워킹 애플리케이션: 리소스 제약이 저수준 제어 및 최소 오버헤드를 필요로 하는 경우.
결론
mio
를 사용하여 Rust에서 논블로킹 저수준 네트워크 애플리케이션을 구축하는 것은 성능, 제어 및 안전성의 탁월한 조합을 제공합니다. mio
는 운영 체제의 이벤트 알림 메커니즘과 직접 상호 작용함으로써 개발자가 고도로 효율적이고 확장 가능한 네트워크 서비스를 만들 수 있도록 합니다. 네트워크 프로그래밍 패러다임에 대한 더 깊은 이해가 필요하지만, 리소스 활용 및 응답성 측면에서의 이점은 상당하여 mio
를 Rust에서 까다로운 네트워크 중심 프로젝트에 유용한 도구로 만듭니다. 궁극적으로 mio
는 개발자가 Rust의 강점을 활용하여 강력하고 성능이 뛰어난 기본 네트워크 인프라를 구축할 수 있도록 지원합니다.