Rust 웹 서비스에서 제로 카피 데이터 파싱을 통한 성능 향상 달성
Lukas Schneider
DevOps Engineer · Leapcell

소개
고성능 웹 서비스의 세계에서는 모든 밀리초가 중요합니다. 애플리케이션이 확장되고 데이터 볼륨이 증가함에 따라, 특히 데이터 복사 및 할당과 관련된 기존 데이터 처리 기법의 오버헤드는 상당한 병목 현상이 될 수 있습니다. 이는 JSON API, 파일 업로드 또는 스트리밍 데이터와 같이 대량의 요청 또는 응답 본문을 처리하는 서비스의 경우 특히 그렇습니다. 성능, 메모리 안전성 및 세밀한 제어에 중점을 둔 Rust는 이러한 문제를 해결하는 데 이상적인 환경을 제공합니다. Rust의 철학에 완벽하게 부합하는 강력한 최적화 기법 중 하나는 제로 카피 데이터 파싱입니다. 제로 카피 파싱은 데이터 복사를 최소화하거나 완전히 제거함으로써 웹 서비스의 처리량과 지연 시간을 극적으로 개선할 수 있습니다. 이 문서에서는 Rust 웹 서비스 컨텍스트 내에서 제로 카피 데이터 파싱의 개념을 살펴보고, 그 이점을 설명하며, 효과적인 구현 방법을 보여줍니다.
제로 카피 및 영향 이해
구현 세부 사항에 들어가기 전에 관련된 핵심 개념을 명확하게 이해해 보겠습니다.
핵심 용어
- 제로 카피(Zero-Copy): 본질적으로 제로 카피는 CPU가 메모리 위치 간에 데이터를 복사하지 않는 작업을 의미합니다. 데이터를 복제하는 대신 제로 카피 기법은 일반적으로 기존 데이터를 가리키거나 메모리를 다시 매핑하는 방식을 사용합니다. 이는 데이터가 네트워크 버퍼에서 애플리케이션 버퍼로 복사된 다음, 파싱 또는 처리를 위해 다시 복사될 수 있는 기존 접근 방식과는 대조적입니다.
- 메모리 할당(Memory Allocation): 프로그램에서 사용하기 위해 메모리 블록을 예약하는 프로세스입니다. 빈번하거나 큰 할당은 CPU 주기 측면에서 비용이 많이 들 수 있으며 메모리 단편화를 초래할 수도 있습니다.
- 역직렬화(Deserialization): 구조화된 형식(JSON, XML 또는 이진 프로토콜 등)을 메모리 내 데이터 구조(Rust struct 등)로 변환하는 프로세스입니다.
&[u8]
(바이트 슬라이스): 부호 없는 8비트 정수(바이트)의 연속 시퀀스에 대한 참조를 나타내는 기본 Rust 유형입니다. 이것은 기존 메모리에 대한 보기이며 제로 카피 작업에 이상적입니다.Cow<'a, T>
(Clone on Write): Rust의 스마트 포인터로, 소유된(T
) 값 또는 빌린(&'a T
) 값을 허용합니다. 대부분의 경우 데이터를 빌려야 하지만 때로는 소유하고 수정해야 하는 제로 카피 시나리오에서 특히 유용합니다.
기존 파싱의 문제점
웹 서비스의 일반적인 시나리오를 생각해 보겠습니다. 들어오는 HTTP 요청 본문에는 JSON 페이로드가 포함되어 있습니다. 일반적인 파싱 흐름은 다음과 같을 수 있습니다.
- 네트워크 버퍼에서 애플리케이션 버퍼로: 웹 서버는 네트워크에서 바이트를 수신하고 이를 내부 버퍼로 복사합니다.
- 애플리케이션 버퍼에서 문자열/벡터로: 버퍼의 바이트는 잠재적으로
String
(UTF-8 유효성 검사를 위해) 또는Vec<u8>
으로 변환됩니다. 이는 추가 복사 및 할당을 수반합니다. - 파싱/역직렬화: 역직렬화기는 이
String
또는Vec<u8>
에서 읽어 애플리케이션별 데이터 구조를 생성합니다. 역직렬화기와 데이터에 따라, 부분적인 복사가 다시 발생할 수 있습니다(예: 문자열 필드에 대한 새String
인스턴스를 만들 때).
이러한 각 복사 작업은 CPU 오버헤드를 발생시키고 잠재적으로 메모리 할당 압력을 증가시켜, 가비지 컬렉션(GC가 있는 언어를 사용하는 경우)의 빈도를 높이거나 Rust에서 할당기 오버헤드로 인해 단순히 실행 속도를 늦출 수 있습니다.
제로 카피 솔루션
제로 카피 파싱은 이러한 불필요한 복사를 우회하는 것을 목표로 합니다. 데이터를 복사하는 대신 가능한 한 원본 데이터 버퍼에 대한 참조를 사용하여 작업합니다. 예를 들어, JSON 문자열을 받는 경우 제로 카피 파서는 문자열 필드 값을 입력 바이트 버퍼로 다시 참조(예: &str
)로 파싱하며, 각 필드에 대한 새 String
할당을 생성하는 대신에 그렇습니다.
주요 이점은 다음과 같습니다.
- CPU 주기 감소:
memcpy
작업이 적다는 것은 데이터 이동에 소요되는 CPU 시간이 더 적다는 것을 의미합니다. - 메모리 할당 감소: 새 객체가 적을수록 메모리 할당자에 대한 압력이 줄어들어 캐시 지역성이 향상되고 메모리 단편화가 줄어들 수 있습니다.
- 처리량 향상: 필수적이지 않은 데이터 조작에 소요되는 시간이 줄어들기 때문에 서비스는 초당 더 많은 요청을 처리할 수 있습니다.
- 지연 시간 감소: 개별 요청 처리 시간이 단축됩니다.
Rust 웹 서비스에서 제로 카피 구현
Rust의 소유권 및 빌림 시스템과 강력한 직렬화/역직렬화 크레이트를 결합하면 제로 카피 파싱을 놀라울 정도로 달성할 수 있습니다. 핵심은 가능한 경우 빌린 유형으로 역직렬화하는 것입니다.
가장 인기 있는 serde
및 serde_json
크레이트를 사용하여 이를 설명해 보겠습니다. 이들은 종종 Axum
또는 Actix-web
과 같은 웹 프레임워크와 함께 사용됩니다.
간단한 JSON 페이로드를 생각해 보겠습니다.
{ "name": "Jane Doe", "age": 30, "hobbies": ["reading", "hiking"] }
기존(소유) 역직렬화
이를 위한 일반적인 Rust 구조체는 다음과 같습니다.
use serde::Deserialize; #[derive(Debug, Deserialize)] struct UserOwned { name: String, age: u8, hobbies: Vec<String>, }
UserOwned
로 역직렬화할 때 각 String
및 Vec<String>
은 새 메모리를 할당하고 입력 버퍼에서 데이터를 복사해야 합니다.
제로 카피 (빌린) 역직렬화
문자열 및 바이트 슬라이스 필드의 경우 제로 카피를 달성하려면 수명(lifetime)을 가진 빌린 유형을 사용할 수 있습니다.
use serde::Deserialize; #[derive(Debug, Deserialize)] struct UserBorrowed<'a> { name: &'a str, // 입력 바이트 버퍼의 슬라이스를 빌립니다. age: u8, hobbies: Vec<&'a str>, // 각 취미 문자열에 대한 슬라이스를 빌립니다. }
여기서 name
은 &'a str
이고 hobbies
는 Vec<&'a str>
입니다. 이는 serde_json
이 이러한 필드를 JSON을 포함하는 원본 바이트 배열을 직접 가리키는 문자열 슬라이스(&str
)를 생성하여 파싱함을 의미합니다. 이러한 필드에 대한 새 String
할당은 이루어지지 않습니다.
Axum을 사용한 실제 예제
이것을 Axum 웹 서비스에 통합해 보겠습니다. 요청 본문이 JSON 페이로드인 시나리오를 시뮬레이션합니다.
먼저 필요한 종속성을 추가합니다.
# Cargo.toml [dependencies] tokio = { version = "1", features = ["full"] } axum = "0.7" serde = { version = "1", features = ["derive"] } serde_json = "1"
이제 Axum 핸들러입니다.
use axum::{ body::Bytes, extract::State, http::StatusCode, response::IntoResponse, routing::post, Router, }; use serde::Deserialize; use std::sync::Arc; // 제로 카피에 친화적인 구조체. // 참고: 입력 바이트가 'static이 아닐 때 빌린 유형으로 역직렬화하는 데 중요한 `#[serde(borrow)]` 속성. // `Vec<&'a str>`의 경우 `serde(borrow)`는 `Vec` 자체에 대해 반드시 필요하지는 않지만, // Vec 내부의 요소들은 이득을 얻고, 일반적으로 빌린 역직렬화 전략을 나타냅니다. #[derive(Debug, Deserialize)] #[serde(borrow)] // `serde_json`이 빌린 유형을 올바르게 역직렬화하도록 하는 것이 중요합니다. struct User<'a> { name: &'a str, age: u8, hobbies: Vec<&'a str>, } #[tokio::main] async fn main() { let app = Router::new() .route("/users", post(create_user)); let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await .unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap(); } async fn create_user( // Axum의 `Bytes` 추출기는 요청 본문 바이트에 대한 불변 참조를 제공합니다. // 제로 카피 파싱에 이상적인 입력입니다. body: Bytes, ) -> impl IntoResponse { // 바이트를 빌린 User 구조체로 역직렬화 시도. // `&body` 슬라이스는 `serde_json::from_slice`에 전달됩니다. match serde_json::from_slice::<User<'_>>(&body) { Ok(user) => { println!("Received user: {:?}", user); // 실제 애플리케이션에서는 여기서 `user`를 처리합니다. // 참고: `body`의 범위를 넘어 `user`를 저장해야 하는 경우, // 빌린 필드를 소유한 `String`으로 변환하거나 `Cow`를 사용하여 복사를 지연해야 할 수 있습니다. (StatusCode::CREATED, "User created successfully (zero-copy parsed)".into_response()) } Err(e) => { eprintln!("Failed to parse user: {:?}", e); (StatusCode::BAD_REQUEST, format!("Invalid request body: {}", e).into_response()) } } }
이 예제에서는 다음과 같습니다.
axum::body::Bytes
는 효율적으로 공유되는 불변 바이트 버퍼인bytes::Bytes
유형입니다. Axum이 요청 본문을 수신하면 즉시String
또는Vec<u8>
으로 복사하지 않습니다. 대신, 원시 네트워크 데이터에 대한 저렴한 보기 또는 참조를 제공합니다.&body
(즉,&[u8]
)를serde_json::from_slice
에 직접 전달합니다.User
의#[serde(borrow)]
속성은 중요합니다. 이는serde
에게 역직렬화 시 새로운 소유 유형을 할당하는 대신 입력 슬라이스에서 문자열 유사 및 바이트 유사 데이터를 빌리려고 시도하도록 지시합니다.- 결과적으로
user.name
과user.hobbies
내의 요소는body
Bytes
버퍼를 직접 가리키는&str
이 됩니다.body
가 살아 있는 한 이러한 참조는 유효합니다.
요청보다 오래 지속되어야 하는 데이터 처리
User
데이터를 데이터베이스나 body
Bytes
버퍼(따라서 빌린 데이터)가 더 이상 유효하지 않은 오래 지속되는 캐시에 저장해야 하는 경우 어떻게 해야 할까요? 이때 Cow<'a, str>
이 유용합니다.
use serde::Deserialize; use std::borrow::Cow; #[derive(Debug, Deserialize)] #[serde(borrow)] struct UserCow<'a> { name: Cow<'a, str>, // 빌릴 수도 있고 소유할 수도 있습니다. age: u8, hobbies: Vec<Cow<'a, str>>, // 각 요소는 빌릴 수도 있고 소유할 수도 있습니다. }
Cow
를 사용하면 serde_json
이 먼저 가능한 경우 데이터를 빌리려고 시도합니다. 만약 어떤 이유로 인해 불가능하다면(예: 디코딩 또는 복사를 강제하는 유효성 검사가 필요한 경우), 데이터를 할당하고 소유합니다. 이렇게 하면 합리적으로 제로 카피 동작과 필요한 경우 소유 데이터로의 우아한 대체 동작이라는 두 가지 장점을 모두 얻을 수 있습니다.
그런 다음 저장 시 명시적으로 소유 데이터로 변환할 수 있습니다.
fn process_and_store_user(user: UserCow<'_>) { let owned_user = UserOwned { name: user.name.into_owned(), // 소유한 String 생성 age: user.age, hobbies: user.hobbies.into_iter().map(|s| s.into_owned()).collect(), // 소유한 String 생성 }; // owned_user 저장 }
애플리케이션 시나리오
제로 카피 파싱은 특히 다음과 같은 경우에 유익합니다.
- 고처리량 API: 대량의 JSON, XML 또는 Protobuf 페이로드를 처리하는 서비스.
- 프록시 서비스: 데이터를 최소한으로 처리한 후 전달하는 경우.
- 로그 처리: 불필요한 문자열 할당 없이 구조화된 로그 라인을 파싱하는 경우.
- 미디어 스트리밍: 이진 데이터 청크를 효율적으로 처리하는 경우.
- 데이터 수집: 성능이 중요한 대규모 데이터 피드.
중요 고려 사항:
- 수명(Lifetimes): 수명 (
'a
)의 사용은 Rust에서 제로 카피의 기본입니다. 입력 바이트 버퍼가 빌린 파싱된 데이터보다 오래 지속되도록 보장해야 합니다. 웹 프레임워크는 일반적으로 요청 핸들러의 기간 동안 이를 올바르게 처리합니다. #[serde(borrow)]
:serde
가 빌리려고 시도하도록 구조체에 이 속성을 추가하는 것을 잊지 마십시오.- 데이터 불변성: 빌린 데이터는 불변입니다. 파싱된 문자열 필드를 수정해야 하는 경우 결국 소유한
String
으로 변환해야 합니다. Cow
를 사용한 유연성: 때로는 소유하거나 수정해야 할 수도 있는 필드에Cow
를 사용하여 제로 카피와 변경 가능성 간의 유연한 균형을 제공합니다.
결론
제로 카피 데이터 파싱은 메모리 할당 및 데이터 복사를 최소화하여 Rust 웹 서비스의 성능을 크게 향상시킬 수 있는 강력한 최적화 기법입니다. Rust의 강력한 타입 시스템, 수명 및 serde
및 serde_json
과 같은 크레이트의 기능을 활용함으로써 개발자는 데이터를 끊임없이 복제하는 대신 빌린 참조로 작업하여 방대한 양의 데이터를 효율적으로 처리할 수 있습니다. 제로 카피 파싱을 구현하는 것은 더 빠른 요청 처리, 더 낮은 메모리 사용량, 궁극적으로 더 확장 가능하고 응답성이 뛰어난 웹 애플리케이션으로 이어집니다. 제로 카피를 수용하여 Rust 웹 서비스의 전체 성능 잠재력을 발휘하십시오.