Async Rust에서 동적 디스패치를 사용하는 캐시 백엔드 API를 설계할 때 발생하는 dyn 호환성 문제와 이를 우아하게 해결하는 방법을 다룬다.
Async Rust는 다루기 까다로울 수 있고, 라이브러리 유지관리자 입장에서는 사용자가 비동기 코드의 골칫거리를 떠안지 않도록 가능한 한 많은 무거운 일을 대신 처리해주는 것이 중요합니다. 우리는 최근 cot에 동적 캐시 백엔드 지원을 추가했는데, 그 과정에서 작은 도전 과제 하나가 API 설계와 사용성(ergonomics)을 제대로 맞추는 일이었습니다. 여기서 사용하는 사용 사례와 코드는 코드베이스에서 그대로 가져온 것입니다. Cot은 Django에서 영감을 받은 Rust 웹 프레임워크입니다.
우리는 cot 웹 프레임워크에 캐싱 메커니즘을 추가해야 했습니다. 사용자는 단순한 캐시 인터페이스를 사용하되, 객체가 캐시에 어떻게 저장되고 조회되는지는 추상화되어야 합니다. 또한 여러 캐시 백엔드를 지원하고, 원한다면 사용자가 자신만의 커스텀 캐시 백엔드를 구현할 수 있게 하고 싶었습니다. 특히 async 코드를 다루는 상황에서, Rust로 이런 API를 어떻게 설계할까요?
먼저 단순한 동기 구현부터 시작해봅시다. 아래 코드를 보겠습니다:
rustuse std::collections::HashMap; use serde_json::Value; use serde::{Serialize, de::DeserializeOwned}; use thiserror::Error; #[derive(Error, Debug)] pub enum CacheError { #[error("Cache operation failed: {0}")] Backend(String), #[error("Serde error: {0}")] SerdeJson(#[from] serde_json::Error), } pub type CacheResult<T> = std::result::Result<T, CacheError>; pub trait CacheStore: Send + Sync + 'static { fn get(&self, key: &str) -> CacheResult<Option<Value>>; fn insert(&mut self, key: &str, value: Value) -> CacheResult<()>; fn remove(&mut self, key: &str) -> CacheResult<()>; } #[derive(Debug, Clone)] pub struct Memory { map: HashMap<String, Value>, } impl Memory { pub fn new() -> Self { Self { map: HashMap::new(), } } } impl CacheStore for Memory { fn get(&self, key: &str) -> CacheResult<Option<Value>> { Ok(self.map.get(key).cloned()) } fn insert(&mut self, key: &str, value: Value) -> CacheResult<()> { self.map.insert(key.to_string(), value); Ok(()) } fn remove(&mut self, key: &str) -> CacheResult<()> { self.map.remove(key); Ok(()) } } pub struct Cache { store: Box<dyn CacheStore>, } impl Cache { pub fn new(store: impl CacheStore) -> Self { let store = Box::new(store); Self { store } } pub fn get<K, V>(&self, key: K) -> CacheResult<Option<V>> where K: AsRef<str>, V: DeserializeOwned, { self.store .get(key.as_ref())? .map(serde_json::from_value) .transpose() .map_err(CacheError::from) } pub fn insert<K, V>(&mut self, key: K, value: V) -> CacheResult<()> where K: AsRef<str>, V: Serialize, { self.store.insert(key.as_ref(), serde_json::to_value(value)?) } pub fn remove<K>(&mut self, key: K) -> CacheResult<()> where K: AsRef<str>, { self.store.remove(key.as_ref()) } }
이 코드에서는 여러 캐시 저장소를 지원하려고 공통 인터페이스로 CacheStore 트레이트를 정의합니다. 캐시 데이터를 관리하기 위한 몇 가지 필수 연관 메서드(associative methods)만 요구하도록 최소한으로 유지해봅시다.
또한 CacheStore가 제공하는 메서드를 구현한 간단한 인메모리 저장소를 제공합니다. file이나 redis 같은 다른 저장소를 추가하는 것은 CacheStore의 메서드들을 구현하기만 하면 됩니다. 그리고 사용자가 직접 상호작용할 수 있는 공개 API인 Cache 구조체를 제공합니다. 이는 저장소와 캐시 관련 설정들을 함께 감싸고, 대부분의 작업을 저장소로 전달하는 캐시 관리 메서드를 제공합니다.
아래 코드로 테스트할 수 있습니다:
rustfn main() { let store = Memory::new(); let cache = Cache::new(store); cache.insert("foo", "bar"); let expected: Option<String> = cache.get("foo"); assert_eq!(expected, Some("foo".to_string())); }
이는 잘 동작합니다. 하지만 코드는 동기 방식이며, 특히 redis나 I/O를 수행하는 file 같은 다른 저장소를 추가하면 확장성이 떨어집니다. 따라서 설계를 비동기 방식으로 바꿔야 합니다.
Rust는 async 트레이트를 지원하므로, (생략된 라이프타임을 보여주지 않는다면) 업데이트된 코드는 대략 아래처럼 보일 것입니다:
rustpub trait CacheStore: Send + Sync + 'static{ async fn get(&self, key: &str) -> CacheResult<Option<Value>>; async fn insert(&self, key: &str, value: Value) -> CacheResult<()> ; async fn remove(&self, key: &str) -> CacheResult<()>; } #[derive(Debug, Clone)] pub struct Memory { map: Arc<Mutex<HashMap<String, Value>>>, } impl CacheStore for Memory { async fn get(&self, key: &str) -> CacheResult<Option<Value>> { // implementation } ... } ... impl CacheStore { ... pub async fn get<K, V>(&self, key: K) -> CacheResult<Option<V>> where K: AsRef<str>, V: DeserializeOwned, { self.store.get(key.as_ref()).await?.map(serde_json::from_value).transpose().map_err(CacheError::from) } pub async fn insert<K, V>(&self, key: K, value: V) -> CacheResult<Option<V>> where K: AsRef<str>, V: Serialize, { self.store.insert(key.as_ref(), serde_json::to_value(serde_json::to_value(value)?)).await } ... }
위 코드에서는 인메모리 저장소를 Mutex 뒤에 두고, 트레이트 메서드에서 mut 요구사항을 제거합니다. 또한 메인 함수를 tokio의 async 런타임을 사용하도록 바꿉니다:
rust#[tokio::main] fn main() -> Result<(), String> { let store = Memory::new(); let cache = Cache::new(store); cache.insert("foo", "bar"); let expected: Option<String> = cache.get("foo").await?; assert_eq!(expected, Some("foo".to_string())); }
하지만 불행히도, 이는 기대대로 동작하지 않습니다. 보통 아래와 같은 에러를 만나게 됩니다:
texterror[E0038]: the trait `CacheStore` is not dyn compatible --> src/bin/asyn_t.rs:52:16 | 52 | Self { store } | ^^^^^ `CacheStore` is not dyn compatible | note: for a trait to be dyn compatible it needs to allow building a vtable for more information, visit <https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility> --> src/bin/asyn_t.rs:11:14
게다가 트레이트 객체와 함께 공개 트레이트 메서드에서 async fn을 쓰려고 시도하면 컴파일러가 여러분에게 고함을 지를 것입니다.
왜 이런 일이 발생하는지 이해하려면 Rust의 dyn 호환성(dyn compatibility) 개념을 알아야 합니다. Rust에서 트레이트는 dyn Trait 형태의 트레이트 객체로 사용할 수 있는데, 오직 트레이트가 dyn compatible일 때만 가능합니다. 트레이트가 dyn compatible이 되려면 두 가지 주요 조건을 만족해야 합니다:
&self, &mut self, 또는 Box<Self>여야 한다.트레이트의 async 함수 문제는, 이것들이 impl Future를 반환하는 함수로 디슈가(desugar)된다는 점입니다. 즉, 우리의 예시는 내부적으로 대략 이런 형태가 됩니다:
rustpub trait CacheStore: Send + Sync + 'static{ fn get(&self, key: &str) -> impl Future<Output = Result<Value, String>>; ... }
이는 메서드가 이제 제네릭 반환 타입을 갖게 된다는 뜻이며, dyn 호환성의 첫 번째 조건을 위반합니다.
async-trait 크레이트이 제한을 우회하는 한 가지 방법은 async-trait 크레이트를 사용하는 것입니다. 이 크레이트는 dyn 호환성을 유지하면서 트레이트에 async 함수를 정의할 수 있게 해줍니다. 코드를 async-trait를 사용하도록 바꾸면 다음과 같습니다:
rustuse async_trait::async_trait; #[async_trait] pub trait CacheStore: Send + Sync + 'static{ async fn get(&self, key: &str) -> Result<Option<Value>, String>; ... } ... #[async_trait] impl CacheStore for Memory { async fn get(&self, key: &str) -> CacheResult<Option<Value>> { // implementation } ... }
트레이트와 구현에 #[async_trait]를 붙이면, 이 크레이트가 async 메서드를 dyn 호환적으로 만들기 위한 필요한 변환을 처리해줍니다. 덕분에 dyn 호환성 문제 없이 dyn CacheStore를 사용할 수 있습니다.
하지만 우리는 async-trait 사용을 제한하기로 했습니다. 때때로 디버깅하기 어려운 에러 메시지를 생성하기 때문입니다. 또한 async-trait를 사용하면 트레이트 메서드 문서에 불필요한 디테일이 잔뜩 포함되어 가독성이 떨어집니다. 예를 들어 cargo doc --open을 실행하면 CacheStore 트레이트가 다음과 같이 표시됩니다:
rustpub trait CacheStore: Send + Sync + 'static { // Required methods fn get<'life0, 'life1, 'async_trait>( &'life0 self, key: &'life1 str, ) -> Pin<Box<dyn Future<Output = CacheResult<Option<Value>>> + Send + 'async_trait>> where Self: 'async_trait, 'life0: 'async_trait, 'life1: 'async_trait; fn insert<'life0, 'life1, 'async_trait>( &'life0 self, key: &'life1 str, value: Value, ) -> Pin<Box<dyn Future<Output = CacheResult<()>> + Send + 'async_trait>> where Self: 'async_trait, 'life0: 'async_trait, 'life1: 'async_trait; fn remove<'life0, 'life1, 'async_trait>( &'life0 self, key: &'life1 str, ) -> Pin<Box<dyn Future<Output = CacheResult<()>> + Send + 'async_trait>> where Self: 'async_trait, 'life0: 'async_trait, 'life1: 'async_trait; }
그러면 이런 질문이 생깁니다: async-trait 없이도 사용성, 성능, 문서 품질을 어떻게 균형 있게 맞출 수 있을까요? 답을 찾으려면 async-trait가 내부에서 하는 일을, 수동으로 비슷하게 해줘야 합니다.
앞서 언급했듯이, 트레이트의 async 함수는 impl Future를 반환하는 함수로 디슈가되며 이는 dyn 호환이 아닙니다. 하지만 반환된 future를 박싱(Box<dyn Future + Send>)하면 구체 타입(concrete type)이 되므로 dyn 호환이 가능합니다.
다만 이것만으로는 충분하지 않습니다. future는 자기참조(self-referential)일 수 있기 때문입니다(자기 내부에 자기 데이터에 대한 참조를 포함). 따라서 Pin 래퍼 타입으로 메모리에 고정(pin)해야 합니다. 이는 future가 메모리 내에서 이동하는 것을 막아 내부 참조가 무효화되는 것을 방지합니다.
그 결과 트레이트 메서드 시그니처는 다음과 같이 됩니다:
rustuse std::pin::Pin; use std::future::Future; pub trait CacheStore: Send + Sync + 'static{ fn get(&self, key: &str) -> Pin<Box<dyn Future<Output = CacheResult<Option<Value>>> + Send>>; ... // Other trait methods (insert and remove) should have similar signature. }
이제 Memory에 대한 CacheStore 구현을 아래처럼 업데이트할 수 있습니다:
rustuse std::pin::Pin; use std::future::Future; use std::boxed::Box; use std::collections::HashMap; use serde_json::Value; use std::sync::{Arc, Mutex}; use futures::future; #[derive(Debug, Clone)] pub struct Memory { map: Arc<Mutex<HashMap<String, Value>>>, } ... impl CacheStore for Memory { fn get(&self, key: &str) -> Pin<Box<dyn Future<Output = CacheResult<Option<Value>>> + Send>> { let map = self.map.clone(); let key = key.to_string(); Box::pin(async move { let map = map.lock().unwrap(); Ok(map.get(&key).cloned()) }) } ... }
공개 API인 Cache는 이전과 동일하게 유지할 수 있고, 이를 실행하면 기대대로 동작합니다. 좋은 점은 Cache API를 사용하는 사용자들은 async 트레이트 복잡성을 전혀 다룰 필요가 없고, 그대로 사용하면 된다는 것입니다.
하지만 트레이트 메서드 구현의 시그니처는 매우 보기 흉하고 장황합니다:
rustfn get(&self, key: &str) -> Pin<Box<dyn Future<Output = CacheResult<Option<Value>>> + Send>>
예를 들어 다운스트림 사용자나 서드파티 라이브러리 작성자가 자신만의 캐시 저장소를 구현하려면 이런 장황함을 감당해야 하는데, 사용성 측면에서 이상적이지 않습니다. 이들을 위해서도 더 편하게 만들어줄 수는 없을까요?
CacheStore 트레이트가 고정된 박싱 future를 직접 반환하는 대신, 모든 더러운 작업을 수행하는 헬퍼 트레이트에 이를 위임하고, CacheStore 트레이트는 평소처럼 async 메서드를 정의하도록 만들 수 있습니다. 실제로 어떻게 되는지 봅시다:
rustuse std::pin::Pin; use std::future::Future; use serde_json::Value; pub(crate) trait BoxedCacheStore: Send + Sync + 'static { fn get<'a>(&'a self, key: &'a str) -> Pin<Box<dyn Future<Output = CacheResult<Option<Value>>> + Send + 'a>>; ... } pub trait CacheStore: Send + Sync + 'static { fn get(&self, key: &str) -> impl Future<Output = CacheResult<Option<Value>>> + Send; ... } impl<T: CacheStore> BoxedCacheStore for T { fn get<'a>(&'a self, key: &'a str) -> Pin<Box<dyn Future<Output = CacheResult<Option<Value>>> + Send + 'a>> { Box::pin(async move { T::get(self, key).await }) } ... }
이제 무슨 일이 일어나는지 풀어봅시다. 우리는 내부 헬퍼 트레이트 BoxedCacheStore를 제공하는데, 이는 이전 CacheStore와 유사한 시그니처의 메서드들을 정의합니다. 그리고 CacheStore 트레이트를 구현하는 어떤 타입 T에 대해서도 이 트레이트를 블랭킷 구현합니다. 이 구현은 CacheStore의 실제(구체) 구현을 호출하고, 반환된 future를 박싱하는 역할만 합니다.
또한 BoxedCacheStore 메서드에 명시적 라이프타임을 제공한 점을 볼 수 있습니다. 이는 Rust의 트레이트 객체에 적용되는 라이프타임 생략 규칙(elision rules) 때문에 매우 중요합니다. 명시적 라이프타임이 없다면 컴파일러는 트레이트에 대해 'static을 가정합니다:
rustfn get(&self, key: &str) -> Pin<Box<dyn Future<Output = ...> + Send>>; // is equivalent to fn get(&self, key: &str) -> Pin<Box<dyn Future<Output = ...> + Send + 'static>>;
하지만 우리의 future는 self와 key 파라미터로부터 빌림(borrow)을 하고 있기 때문에 'static일 수 없습니다. 따라서 명시적 라이프타임을 생략하는 것은 불법이 됩니다. 명시적으로 라이프타임을 제공함으로써, 우리는 박싱된 future의 라이프타임이 캡처한 참조들의 라이프타임에 묶여 있음을 컴파일러에게 알려줍니다.
이제 Memory 구현을 아래처럼 업데이트합니다:
rustuse std::collections::HashMap; use std::sync::{Arc, Mutex}; use serde_json::Value; #[derive(Debug, Clone)] pub struct Memory { map: Arc<Mutex<HashMap<String, Value>>>, } impl Memory { pub fn new() -> Self { Self { map: Arc::new(Mutex::new(HashMap::new())), } } } impl CacheStore for Memory { async fn get(&self, key: &str) -> CacheResult<Option<Value>> { let map = self.map.lock().unwrap(); Ok(map.get(key).cloned()) } ... }
훨씬 깔끔합니다! 고정된 박스도 없고, future를 수동으로 박싱할 필요도 없고, 그저 평범한 async 함수입니다.
이제 Cache 구조체를 업데이트하여 공개 API에서는 CacheStore를 노출하되 내부적으로는 BoxedCacheStore를 사용하도록 할 수 있습니다:
rustuse std::sync::Arc; use serde::{Serialize, de::DeserializeOwned}; #[derive(Clone)] pub struct Cache { store: Arc<dyn BoxedCacheStore>, } impl Cache { pub fn new(store: impl CacheStore) -> Self { let store = Arc::new(store) as Arc<dyn BoxedCacheStore>; Self { store } } pub async fn get<K, V>(&self, key: K) -> CacheResult<Option<V>> where K: AsRef<str>, V: DeserializeOwned, { self.store .get(key.as_ref()) .await? .map(serde_json::from_value) .transpose() .map_err(CacheError::from) } ... }
보시다시피 이제 store 필드는 트레이트 객체로 BoxedCacheStore를 사용하지만, 공개 API는 CacheStore를 구현하는 어떤 타입이든 받아들입니다. 그리고 Cache 인스턴스를 만들 때 제공된 저장소를 BoxedCacheStore로 캐스팅합니다. 개인적으로 훨씬 보기 좋다고 생각합니다.
이 접근의 단점은 보일러플레이트가 늘어나고 유지보수할 코드가 더 많아진다는 점입니다. 하지만 보일러플레이트는 최소한이고 대부분 기계적인 코드입니다. 헬퍼 트레이트는 단지 실제 구현으로 위임(delegation)하기만 합니다. 더 나은 사용성과 문서 품질을 위한 작은 대가라고 할 수 있습니다.