MCP 서버 성능 최적화: Rust로 구축하는 초고속 처리 파이프라인

MCP 서버 성능 최적화: Rust로 구축하는 초고속 처리 파이프라인

최근 LLM(Large Language Model)을 활용한 에이전트 시스템이 개발 생태계의 핫한 이슈가 되면서, 이를 지원하는 인프라인 MCP(Model Context Protocol) 서버의 중요성이 커지고 있습니다. 이전 포스트에서 ZeroClaw와 같은 고성능 런타임을 언급하며 멀티 에이전트 아키텍처를 다루었지만, 오늘은 MCP 서버 자체의 **처리량(Throughput)과 대기 시간(Latency)**을 극적으로 개선하는 Rust 기반의 최적화 기법을 집중적으로 다루고자 합니다.

단순히 “빠르다"는 말만으로는 부족합니다. 수천 개의 Tool Call이 동시에 들어오는 상황에서 서버가 얼마나 효율적으로 리소스를 관리하는지가 핵심입니다. 특히 최근 트렌드인 AI 에이전트들이 RSS 피드나 실시간 데이터를 수집할 때, I/O 바운드 작업이 병목이 되는 경우가 많습니다.

이 글에서는 Rust의 강력한 비동기 런타임인 Tokio를 활용하여 병목 지점을 제거하고, 안전하면서도 빠른 코드를 작성하는 구체적인 방법을 소개합니다.

1. 문제 정의: 단일 스레드 병목

기본적으로 Python이나 Node.js로 작성된 간단한 MCP 서버는 싱글 스레드 이벤트 루프에 의존하는 경우가 많습니다. 이는 I/O 작업이 많을 때 유리하지만, 데이터 가공이나 복잡한 로직이 포함된 MCP 툴을 처리할 때 CPU 코어를 하나만 사용하기 때문에 한계가 명확합니다.

예를 들어, 블로그 자동화 시스템을 구축할 때 대량의 이미지를 처리하거나 로그를 파싱하는 과정에서 CPU 점유율이 100%에 도달하면 다른 요청은 대기열에 쌓이게 됩니다. 우리는 이를 멀티 스레드 비동기 처리로 해결해야 합니다.

2. Rust와 Tokio를 활용한 비동기 처리

Rust는 ‘Zero-cost abstractions’를 통해 안전성과 성능을 모두 잡을 수 있습니다. MCP 서버의 핵심 로직인 툴 실행(Tool Execution) 부분을 tokio::spawn을 이용해 병렬화해 보겠습니다.

기본 설정 (Cargo.toml)

먼저 의존성 파일에 Tokio를 추가합니다.

[dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

비동기 핸들러 구현 예제

아래 코드는 들어오는 요청을 별도의 태스크(Task)로 분리하여 처리하는 간단한 MCP 서버의 핸들러 구조입니다.

use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

// MCP 요청 메시지 구조 정의
#[derive(Debug, Deserialize)]
struct McpRequest {
    id: String,
    method: String,
    params: serde_json::Value,
}

#[derive(Debug, Serialize)]
struct McpResponse {
    id: String,
    result: serde_json::Value,
}

// 무거운 작업을 시뮬레이션하는 함수
async fn process_heavy_tool(params: serde_json::Value) -> Result<String, String> {
    // 실제 환경에서는 여기서 DB 조회나 파일 I/O 발생
    // tokio::time::sleep을 통해 비동기 대기 시뮬레이션
    tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
    Ok(format!("Processed: {}", params))
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("MCP Server listening on {}", listener.local_addr()?);

    loop {
        let (mut socket, _) = listener.accept().await?;

        // 연결이 들어올 때마다 새로운 태스크 생성 (병렬 처리)
        tokio::spawn(async move {
            let mut buf = [0; 1024];

            loop {
                let n = match socket.read(&mut buf).await {
                    Ok(n) if n == 0 => return, // 연결 종료
                    Ok(n) => n,
                    Err(e) => {
                        eprintln!("Failed to read from socket; err = {:?}", e);
                        return;
                    }
                };

                let req_str = String::from_utf8_lossy(&buf[0..n]);
                
                // JSON 파싱 및 처리 로직 (에러 처리 생략)
                if let Ok(req) = serde_json::from_str::<McpRequest>(&req_str) {
                    let req_id = req.id.clone();
                    
                    // 핵심: 비동기 함수를 spawn하여 블로킹 없이 처리
                    let handle = tokio::spawn(async move {
                        process_heavy_tool(req.params).await
                    });

                    // 결과 대기 및 응답 (실제 구현에선 채널 사용 권장)
                    if let Ok(Ok(result)) = handle.await {
                        let resp = McpResponse {
                            id: req_id,
                            result: serde_json::json!(result),
                        };
                        
                        if let Ok(serialized) = serde_json::to_string(&resp) {
                            let _ = socket.write_all(serialized.as_bytes()).await;
                        }
                    }
                }
            }
        });
    }
}

이 코드의 핵심은 tokio::spawn을 사용하여 각 요청이 메인 루프를 차단하지 않고 독립적으로 실행된다는 점입니다.

3. 스트리밍(Streaming)을 통한 메모리 최적화

대용량 파일 처리나 로그 전송 시, 모든 데이터를 메모리(RAM)에 올리는 것은 치명적입니다. Rust의 Stream을 활용하면 데이터를 청크(Chunk) 단위로 처리하여 메모리 사용량을 일정하게 유지할 수 있습니다.

특히 앞서 언급된 [blog-api-server] 로깅 개선 사항이나 Cloud Monitor 관련 작업에서 유용하게 쓰일 수 있습니다.

use futures::stream::{Stream, StreamExt};
use std::pin::Pin;

// 가상의 로그 데이터 생성 스트림
fn log_stream() -> Pin<Box<dyn Stream<Item = String> + Send>> {
    Box::pin(async_stream::stream! {
        for i in 0..1000 {
            yield format!("Log entry #{}\n", i);
            tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
        }
    })
}

// 스트림 처리 예시
async fn process_logs() {
    let mut stream = log_stream();
    while let Some(log_entry) = stream.next().await {
        // 실시간으로 한 줄씩 처리 (파일 쓰기 or 전송)
        println!("Processing: {}", log_entry);
    }
}

4. 결론: ZeroClaw를 향하여

우리가 목표로 하는 ZeroClaw 런타임은 이러한 비동기 처리와 메모리 안전성을 기본으로 제공해야 합니다. 단순히 기존 파이썬 스크립트를 Rust로 포팅하는 것을 넘어, Tokio의 스케줄링Zero-copy 직렬화를 적극 활용해야만 수천 개의 에이전트가 동시에 통신하는 환경을 견딜 수 있습니다.

다음 포스트에서는 이러한 고성능 서버 위에서 동작하는 에이전트 간 통신 프로토콜을 설계하는 방법에 대해 다루겠습니다.

Hugo로 만듦
JimmyStack 테마 사용 중