Rust 오류를 통한 학습 가이드 feat: 탈중앙금융
제가 만들고 있는 탈중앙 금융 시스템 개발 중 발생한 Rust 에러와 해결 방법을 정리한 실전 가이드
목차
1. 소유권과 참조 (Ownership & References)
1.1 HTTP Response 소유권 문제
문제: HTTP 응답 객체를 여러 번 사용하려고 할 때 발생하는 소유권 에러
❌ 잘못된 코드:
async fn get_swap_quote(&self, params: SwapParams) -> Result<SwapQuote> {
let response = self.client.get(&url).send().await?;
if !response.status().is_success() {
return Err(anyhow!("API request failed"));
}
// response를 여러 번 소비하려고 시도
let error_text = response.text().await?; // response가 여기서 consumed
let data: OxApiResponse = response.json().await?; // ❌ 이미 consumed된 response 사용
}
✅ 올바른 해결 방법:
async fn get_swap_quote(&self, params: SwapParams) -> Result<SwapQuote> {
let response = self.client.get(&url).send().await?;
// status()는 borrow이므로 문제없음
if !response.status().is_success() {
// 에러 시에만 response를 소비
let error_text = response.text().await?;
return Err(anyhow!("API request failed: {}", error_text));
}
// 정상적인 경우에만 response 소비
let data: OxApiResponse = response.json().await?;
Ok(SwapQuote {
aggregator: "0x".to_string(),
amount_in: data.buy_amount.parse().unwrap_or_default(),
amount_out: data.sell_amount.parse().unwrap_or_default(),
})
}
. reqwest::Response의 바디는 스트리밍 소비형(one-shot) 이라 text().await?나 json().await? 중 하나만 쓸 수 있고, 하나를 호출하면 바디가 소모되어 다른 걸 다시 부를 수 없어요. status() 같은 건 본문을 건드리지 않으니 여러 번 써도 됩니다.send() 는 뭐냐?
self.client.get(&url) 는 RequestBuilder 를 줍니다.send().await 가 실제 HTTP 요청을 네트워크로 전송해서 Response 를 돌려줘요. (채널의 send가 아니라 “HTTP 요청 전송”)
1.2 String vs &str 생명주기 문제
문제: 함수에서 임시 String의 참조를 반환하려고 할 때 발생
❌ 잘못된 코드:
fn extract_token_symbol(address: &Address) -> &str {
let temp_string = address.to_string(); // 임시 String 생성
let symbol = match temp_string.as_str() {
"0xA0b86a33E6441000..." => "WETH",
"0x6B175474E89094C4..." => "DAI",
_ => temp_string.as_str() // ❌ 여기서 실제 에러 발생!
};
symbol // ❌ returns a value referencing data owned by the current function
}
컴파일 에러:
error[E0515]: cannot return value referencing local variable `temp_string`
--> src/strategies/sandwich_onchain.rs:8:5
|
6 | _ => temp_string.as_str() // ❌ 여기서 실제 에러 발생!
| ----------- `temp_string` is borrowed here
8 | symbol
| ^^^^^^ returns a value referencing data owned by the current function
✅ 올바른 해결 방법:
// 방법 1: String 반환 (소유권 이전)
fn extract_token_symbol(address: &Address) -> String {
match address.to_string().as_str() {
"0xA0b86a33E6441000..." => "WETH".to_string(),
"0x6B175474E89094C4..." => "DAI".to_string(),
_ => "UNKNOWN".to_string()
}
}
// 방법 2: 정적 문자열 참조 반환
fn extract_token_symbol(address: &Address) -> &'static str {
match address.to_string().as_str() {
"0xA0b86a33E6441000..." => "WETH",
"0x6B175474E89094C4..." => "DAI",
_ => "UNKNOWN"
}
}
// 방법 3: HashMap으로 최적화
use std::collections::HashMap;
use once_cell::sync::Lazy;
static TOKEN_SYMBOLS: Lazy<HashMap<&str, &str>> = Lazy::new(|| {
let mut m = HashMap::new();
m.insert("0xA0b86a33E6441000...", "WETH");
m.insert("0x6B175474E89094C4...", "DAI");
m.insert("0xdAC17F958D2ee523...", "USDT");
m
});
fn extract_token_symbol(address: &Address) -> &'static str {
let addr_str = address.to_string();
TOKEN_SYMBOLS.get(addr_str.as_str()).copied().unwrap_or("UNKNOWN")
}1.3 가변 참조와 불변 참조 동시 사용
문제: 같은 데이터에 대해 가변 참조와 불변 참조를 동시에 사용하려고 할 때
기본 규칙 (황금률!)
불변 참조 여러 개 OR 가변 참조 하나만 가능
둘 다 동시에는 안됨!
let mut data = vec![1, 2, 3]; // 가변 VEC 구조
let read1 = &data; // ✅ 가변 VEC를 불변으로 참조함
let read2 = &data; // ✅ 불변 참조 여러 개 OK
// let write = &mut data; // ❌ 불변이 살아있는데 가변? NO!문제 상황 1: 읽으면서 동시에 수정하기
// ❌ 이렇게 하면 에러날 수 있음
fn bad_example() {
let mut vec = vec![1, 2, 3];
let items = &vec; // 불변 차용
for item in items {
if *item < 2 {
vec.push(999); // 가변 차용 시도 - 에러 가능성!
}
}
}해결법 1: 먼저 정보 수집, 나중에 수정
// ✅ 이렇게 하면 안전함
fn good_example() {
let mut vec = vec![1, 2, 3];
// 1단계: 어떤 걸 추가할지만 결정
let mut to_add = Vec::new();
for item in &vec {
if *item < 2 {
to_add.push(999);
}
} // 여기서 불변 차용 끝!
// 2단계: 실제 수정
vec.extend(to_add); // ✅ 이제 가변 차용 가능
}해결법 2: 인덱스 사용하기
fn index_solution() {
let mut vec = vec![1, 2, 3];
// 수정할 인덱스들 찾기
let indices: Vec<_> = vec.iter()
.enumerate()
.filter(|(_, &item)| item < 2)
.map(|(i, _)| i)
.collect();
// 인덱스로 접근해서 수정
for i in indices {
vec.push(vec[i] * 100); // ✅ 문제없음
}
}비동기에서 주의할 점
// ❌ 차용이 await를 넘어가면 문제
async fn async_problem() {
let mut data = vec![1, 2, 3];
let items = &data; // 차용 시작
for item in items {
let result = async_call().await; // 차용이 await 넘어감!
data.push(result); // ❌ 에러!
}
}
// ✅ 차용 범위를 await 전에 끝내기
async fn async_solution() {
let mut data = vec![1, 2, 3];
// 필요한 정보만 복사
let items_to_process: Vec<_> = data.iter().cloned().collect();
// 여기서 차용 끝!
for item in items_to_process {
let result = async_call(item).await; // ✅ 문제없음
data.push(result); // ✅ 가능
}
}실전 팁
에러가 나면: "먼저 정보 수집 → 나중에 수정" 패턴 사용
비동기에서: 차용 범위를 await 전에 끝내기
복잡하면: 그냥
.clone()해버리기 (성능보다 정확성이 먼저!)
핵심 기억할 것
Rust는 메모리 안전을 위해 엄격함
"동시에 읽고 쓰기"를 막는 게 목적
해결책은 "시간차 공격": 먼저 읽고, 나중에 쓰기!
2. 스마트 포인터와 동시성 (Smart Pointers & Concurrency)
2.1 Arc<Mutex> 패턴
문제: 여러 스레드에서 공유 데이터에 가변 접근이 필요할 때
❌ 잘못된 코드:
use std::sync::Arc;
struct LiquidationStrategyManager {
scanner: Arc<MultiProtocolScanner>, // 불변 참조만 가능
}
impl LiquidationStrategyManager {
async fn update_scanner_config(&self, config: ScannerConfig) -> Result<()> {
// Arc 내부 값 수정 시도
self.scanner.update_config(config).await?; // ❌ cannot borrow as mutable
Ok(())
}
}
✅ 올바른 해결 방법:
use std::sync::Arc;
use tokio::sync::Mutex as AsyncMutex;
struct LiquidationStrategyManager {
scanner: Arc<AsyncMutex<MultiProtocolScanner>>, // 스레드 안전한 가변 공유
}
impl LiquidationStrategyManager {
async fn update_scanner_config(&self, config: ScannerConfig) -> Result<()> {
// 비동기 락 획득 후 수정
let mut scanner = self.scanner.lock().await; // ✅ 정상 동작
scanner.update_config(config).await?;
Ok(())
// 여기서 락 자동 해제
}
async fn scan_liquidation_opportunities(&self) -> Result<Vec<LiquidationOpportunity>> {
let scanner = self.scanner.lock().await;
let opportunities = scanner.scan_all_protocols().await?;
Ok(opportunities)
}
}
위처럼 lock().await로 락을 잡은 뒤 그 상태로 또 .await 하는(= update_config().await 호출) 패턴은 교착이나 불필요한 경합을 일으킬 수 있어요. 가능하면:
락 보유 범위를 최소화하고,
락을 잡은 구간에서는 await를 피하거나,
내부 구조를
RwLock/Mutex로 세분화해서 필요한 부분만 잠금하는 게 좋아요.
예:
// 1) 락 구간에서 바뀔 “값”만 계산하고
let new_state = self.compute_new_state(config.clone()).await?;
// 2) 짧게 락 잡고 적용 (await 없음)
{
let mut scanner = self.scanner.lock().await;
scanner.apply_state(new_state);
}
// 3) 락 밖에서 네트워크 작업 등 await 수행
self.kick_off_side_effects().await?;
대안들
Arc<RwLock<T>>: 읽기가 훨씬 많고 쓰기가 드문 경우 유리 (
read().await/write().await).인터리어 가변성:
MultiProtocolScanner내부 필드를Mutex/RwLock/Atomic으로 감싸서update_config(&self, …)로 설계.단일 스레드 런타임(LocalSet): !Send 타입을 써야 하거나 지연이 매우 민감하면 싱글 스레드 + 내부
RefCell등.
2.2 Arc<RwLock> 패턴 (읽기 최적화)
문제: 읽기가 많고 쓰기가 적은 경우 성능 최적화
use std::sync::Arc;
use tokio::sync::RwLock as AsyncRwLock;
struct PriceOracle {
cache: Arc<AsyncRwLock<HashMap<String, PriceData>>>,
client: HttpClient,
}
impl PriceOracle {
async fn get_price(&self, symbol: &str) -> Result<f64> {
// 읽기 락으로 캐시 확인 (여러 스레드가 동시 읽기 가능)
{
let cache = self.cache.read().await;
if let Some(cached) = cache.get(symbol) {
if !cached.is_expired() {
return Ok(cached.price);
}
}
} // 여기서 읽기 락 해제
// API 호출
let price_data = self.fetch_price_from_api(symbol).await?;
// 쓰기 락으로 캐시 업데이트 (독점적 접근)
self.cache.write().await.insert(symbol.to_string(), price_data.clone());
Ok(price_data.price)
}
}
읽기 많은 캐시에 Arc<tokio::sync::RwLock<HashMap<..>>> 쓰는 전형적인 방식이고, 다음 점이 특히 좋습니다.
읽기 경로에서 read 락만 잡고,
await없이 금방 풀어줌API 호출은 락 밖에서 수행 → 긴 네트워크 대기 동안 락 점유 X
갱신 시에만 write 락을 짧게 잡고
insert
다만, 실전에서 몇 가지 보완을 추천합니다.
1) 더블 체크(TOCTOU 회피)
여러 태스크가 동시에 캐시 미스 → 모두 API 호출하는 “쑤셔넣기(herd)”가 생길 수 있어요. API 호출 후 write 락 잡은 시점에 한 번 더 검증하세요.
async fn get_price(&self, symbol: &str) -> Result<f64> {
// 1) 1차: 읽기 락
{
let cache = self.cache.read().await;
if let Some(cached) = cache.get(symbol) {
if !cached.is_expired() {
return Ok(cached.price);
}
}
}
// 2) 락 바깥에서 API 호출
let price_data = self.fetch_price_from_api(symbol).await?;
// 3) 2차: 쓰기 락에서 재확인(다른 태스크가 먼저 채웠는지)
let mut cache = self.cache.write().await;
if let Some(cached) = cache.get(symbol) {
if !cached.is_expired() {
return Ok(cached.price);
}
}
cache.insert(symbol.to_string(), price_data.clone());
Ok(price_data.price)
}
2) “동일 키 동시 요청” 중복 제거
같은 심볼을 동시에 여러 태스크가 요청하면 API가 N번 호출됩니다. 아래 중 하나를 고려하세요.
moka::future::Cache (추천): TTL/용량/동시성/중복제거까지 제공
per-key OnceCell:
DashMap<String, Arc<tokio::sync::OnceCell<PriceData>>>로 키별 in-flight Future를 공유
// OnceCell 스케치
let cell = self.inflight
.entry(symbol.to_string())
.or_insert_with(|| Arc::new(OnceCell::new()))
.clone();
let data = cell
.get_or_try_init(async { self.fetch_price_from_api(symbol).await })
.await?;
3) 에러·신선도 정책
API 실패 시 마지막 캐시값을 grace 기간 동안 허용(stale-while-revalidate)할지 결정
캐시 항목에
fetched_at,ttl필드 두고 만료/갱신 로직 명확히실패 연속 시 백오프(exponential backoff) 적용
4) 자료구조 선택 팁
읽기/쓰기가 모두 짧은 동기 작업이면
DashMap도 대안 (단, 그 안에서await금지)구조 전체를 자주 바꾸지 않고 스냅샷 교체가 가능하다면
ArcSwap<HashMap<..>>로 잠금 없이 읽기(고급)
5) 락 사용 주의
지금 코드처럼 락 안에서
await하지 않는 것이 핵심 (잘 하셨습니다)tokio::RwLock은 writer 기아를 방지하므로 일반적으로 안전하지만, read 폭주 상황에서는 쓰기 지연 가능 → 캐시 TTL/재검증 빈도 설계로 완화
2.3 성능 비교 가이드
패턴 | 읽기 성능 | 쓰기 성능 | 메모리 사용량 | 사용 사례 |
|---|---|---|---|---|
| 보통 | 보통 | 낮음 | 읽기/쓰기 균등 |
| 높음 | 낮음 | 낮음 | 읽기 중심 |
| 매우 높음 | 매우 높음 | 매우 낮음 | 단순 원자적 연산 |
사용 권장사항:
읽기 위주:
Arc<RwLock<T>>사용쓰기 위주:
Arc<Mutex<T>>사용단순 카운터:
Arc<AtomicU64>사용비동기 환경:
tokio::sync버전 사용
3. 비동기 프로그래밍 (Async Programming)
3.1 async 트레이트 구현
문제: 트레이트에서 async 메서드를 정의할 때의 복잡성
❌ 잘못된 코드:
pub trait ProtocolScanner: Send + Sync {
fn scan_all_users<'a>(&'a self)
-> Pin<Box<dyn Future<Output = Result<Vec<LiquidatableUser>>> + Send + 'a>>;
}
✅ 올바른 해결 방법:
use async_trait::async_trait;
#[async_trait]
pub trait ProtocolScanner: Send + Sync {
// async_trait이 라이프타임을 자동 처리
async fn scan_all_users(&self) -> Result<Vec<LiquidatableUser>>;
}
#[async_trait]
impl ProtocolScanner for AaveScanner {
async fn scan_all_users(&self) -> Result<Vec<LiquidatableUser>> {
// 간단하고 읽기 쉬운 async 구현
let users = self.fetch_users_from_contract().await?;
Ok(users)
}
}
선택지 비교 (간단 요약)
A.
async_trait(권장 기본값)장점: 코드가 깔끔, 구현이 쉬움, trait object 사용 용이(객체 안전성 유지).
단점: 호출당 heap 할당 + 동적 디스패치 오버헤드.
B.
Pin<Box<dyn Future>>수동 반환장점: 매크로 없이 안정적으로 동작.
단점: 서명 장황, 여전히 힙 할당 + 동적 디스패치.
C. GAT + TAIT +
impl Future(가능하면 최고 성능)장점: 제로 비용(대개 힙 할당 없음, 정적 디스패치로 인라이닝 가능), 핫패스에 유리.
단점: 트레이트가 object-safe가 아니게 되는 경우가 많음(=
dyn Trait로 쓰기 어려움), 약간 문법이 어렵고 컴파일러 버전 제약을 확인해야 함.
3.2 비동기 태스크 관리
문제: 여러 비동기 태스크를 효율적으로 관리할 때
❌ 비효율적 방법:
async fn process_liquidations(users: Vec<User>) -> Result<()> {
let mut handles = Vec::new();
for user in users {
let user_clone = user.clone(); // 각 태스크마다 전체 데이터 복제 - 메모리 낭비
let handle = tokio::spawn(async move {
process_single_user(user_clone).await
});
handles.push(handle);
}
// 모든 태스크 완료 대기
futures::future::try_join_all(handles).await?;
Ok(())
}
✅ 효율적 방법:
use futures::future::try_join_all;
async fn process_liquidations(users: Vec<User>) -> Result<()> {
let users = Arc::new(users); // 단일 할당으로 모든 태스크가 공유
let mut handles = Vec::new();
for i in 0..users.len() {
let users_ref = Arc::clone(&users); // 포인터만 복제, 실제 데이터는 공유
let handle = tokio::spawn(async move {
process_single_user(&users_ref[i]).await
});
handles.push(handle);
}
// 모든 태스크 완료 대기
try_join_all(handles).await?;
Ok(())
}
// ✅ 더 나은 방법: 인덱스 대신 직접 참조
async fn process_liquidations(users: Vec<User>) -> Result<()> {
let mut handles = Vec::new();
for user in users.into_iter() { // 소유권 이전
let handle = tokio::spawn(async move {
process_single_user(user).await // 각 태스크가 개별 소유권 가짐
});
handles.push(handle);
}
try_join_all(handles).await?;
Ok(())
}첫 번째(Arc<Vec<User>> + &users[i]) 방식은 권장하지 않습니다.
tokio::spawn은'staticfuture를 요구하는데, “스폰된 future 안에서 캡처한Arc<Vec<_>>의 내부 원소를&users[i]로 또 참조”하면 자기참조 future가 되어서 컴파일이 깨지기 쉽습니다(수명 추론이 매우 까다로움).세 번째( into_iter 로 각 유저 소유권을 태스크에 넘기는 방식 )가 가장 깔끔하고, 성능/안전성 측면에서 일반적으로 베스트입니다(추가 공유구조 필요 없음).
아래처럼 정리해 쓰면 안정적이고 효율적입니다.
✅ 권장 패턴들
1) 소유권 이전(가장 단순/안전)
use futures::stream::{self, StreamExt, TryStreamExt};
async fn process_liquidations(users: Vec<User>) -> Result<()> {
// 동시성 제한(예: 32개) + 에러 전파
stream::iter(users)
.map(|user| async move { process_single_user(user).await })
.buffer_unordered(32) // 동시 실행 개수 제한
.try_collect::<()>() // Result들을 모아 첫 에러 전파
.await
}
장점: 각 태스크가
User를 단독 소유 → 수명 문제 없음.buffer_unordered(N)로 동시성 제한까지 한 번에 처리.
2) JoinSet로 스폰/수확
use tokio::task::JoinSet;
async fn process_liquidations(mut users: Vec<User>) -> Result<()> {
let mut set = JoinSet::new();
for user in users.drain(..) {
set.spawn(async move { process_single_user(user).await });
}
while let Some(res) = set.join_next().await {
res??; // JoinError, 내부 Result 둘 다 전파
}
Ok(())
}
장점: 점진 수확(끝나는 대로 처리) + 에러 핸들링이 명확.
3) 요소 단위 Arc (공유가 꼭 필요할 때)
여러 태스크가 같은 유저를 공유해야 하거나 복제 비용이 큰 구조체일 때만 사용하세요.
use std::sync::Arc;
use futures::future::try_join_all;
async fn process_liquidations(users: Vec<User>) -> Result<()> {
let users: Vec<Arc<User>> = users.into_iter().map(Arc::new).collect();
let handles = users.into_iter().map(|u| {
let u = Arc::clone(&u);
tokio::spawn(async move {
// process_single_user가 &User만 필요하면 &*u 넘기기
process_single_user(&u).await
})
});
try_join_all(handles).await?; // JoinError/내부 에러 전파
Ok(())
}
포인트: **
Arc<Vec<User>>+&users[i]**가 아니라, **Vec<Arc<User>>**로 각 원소를 안전하게 공유하세요. (자기참조 future 회피)
⛔ 피해야 할 패턴
Arc<Vec<User>> + &users_ref[i] 캡처
let users = Arc::new(users);
tokio::spawn(async move {
// ❌ self-referential: Future 내부에서 Arc를 소유하면서
// 그 Arc가 가리키는 Vec의 요소 참조를 동시에 들고 있음
process_single_user(&users[i]).await
});
이 패턴은
'staticfuture 요구와 자기참조 문제로 컴파일이 깨지거나, 컴파일되더라도 유지보수/수명 문제가 잦습니다.
추가 팁
동시성 제한 꼭 넣기: 네트워크/노드/DB에 과부하 방지 (
buffer_unordered(N)/for_each_concurrent(N, ..)/세마포어 등).타임아웃: 개별 작업에
tokio::time::timeout적용해 매달림 방지.취소/중단:
AbortHandle또는JoinSet::abort_all로 롤백 경로 준비.에러 전파:
JoinError와 내부Result를 구분 처리(res??패턴).CPU 바운드 로직은
spawn_blocking으로 옮겨 런타임 워커를 막지 않기.
4. 타입 시스템과 라이브러리 (Type System & Libraries)
4.1 ethers vs alloy U256 타입 처리
문제: 서로 다른 라이브러리의 U256 타입 간 변환
❌ 잘못된 코드:
use ethers::types::U256 as EthersU256;
use alloy::primitives::U256 as AlloyU256;
fn calculate_profit(amount: AlloyU256) -> f64 {
// Error: no method named `as_u128` found for AlloyU256
let value = amount.as_u128() as f64;
value / 1e18
}
✅ 올바른 해결 방법:
use ethers::types::U256 as EthersU256;
use alloy::primitives::U256 as AlloyU256;
fn calculate_profit_alloy(amount: AlloyU256) -> f64 {
// Alloy U256는 to::<T>() 메서드 사용
let value = amount.to::<u128>() as f64;
value / 1e18
}
fn calculate_profit_ethers(amount: EthersU256) -> f64 {
// Ethers U256는 as_u128() 메서드 사용
let value = amount.as_u128() as f64;
value / 1e18
}
// 타입 간 변환
fn convert_ethers_to_alloy(ethers_u256: EthersU256) -> AlloyU256 {
AlloyU256::from(ethers_u256.as_u128())
}
fn convert_alloy_to_ethers(alloy_u256: AlloyU256) -> EthersU256 {
EthersU256::from(alloy_u256.to::<u128>())
}
4.2 String vs &str 타입 처리
문제: HashMap에서 String과 &str 타입 불일치
❌ 잘못된 코드:
let mut params = HashMap::new();
// Error: expected `String`, found `&str`
params.insert("sellToken", "0x...");
✅ 올바른 해결 방법:
// 방법 1: .to_string()으로 &str을 String으로 변환
let mut params = HashMap::new();
params.insert("sellToken", "0x...".to_string());
// 방법 2: HashMap 타입을 명시
let mut params: HashMap<&str, &str> = HashMap::new();
params.insert("sellToken", "0x...");
// 방법 3: 타입 별칭 사용
type ParamMap = HashMap<String, String>;
let mut params: ParamMap = HashMap::new();
params.insert("sellToken".to_string(), "0x...".to_string());
4.3 NameOrAddress 열거형 처리
문제: ethers의 NameOrAddress 타입 처리
use ethers::types::{Transaction, NameOrAddress};
fn create_transaction(tx_request: &TransactionRequest) -> Transaction {
Transaction {
to: tx_request.to.and_then(|addr| {
match addr {
NameOrAddress::Name(_) => None, // ENS names 미지원
NameOrAddress::Address(addr) => Some(addr),
}
}),
value: tx_request.value.unwrap_or_default(),
gas: tx_request.gas.unwrap_or_default(),
// ...
}
}
5. 성능 최적화 패턴 (Performance Optimization)
5.1 MEV 시스템을 위한 캐시 패턴
use std::sync::atomic::{AtomicU64, Ordering};
use std::collections::HashMap;
pub struct MevOpportunityCache {
// 메모리 효율적인 캐시 구조
opportunities: Arc<RwLock<HashMap<Address, Vec<Opportunity>>>>,
// 원자적 카운터로 성능 모니터링
cache_hits: AtomicU64,
cache_misses: AtomicU64,
}
impl MevOpportunityCache {
pub async fn get_opportunities(&self, token: Address) -> Vec<Opportunity> {
// 읽기 락으로 빠른 접근
if let Some(opps) = self.opportunities.read().await.get(&token) {
self.cache_hits.fetch_add(1, Ordering::Relaxed);
opps.clone()
} else {
self.cache_misses.fetch_add(1, Ordering::Relaxed);
Vec::new()
}
}
pub async fn update_opportunities(&self, token: Address, opportunities: Vec<Opportunity>) {
self.opportunities.write().await.insert(token, opportunities);
}
pub fn get_cache_stats(&self) -> (u64, u64) {
(
self.cache_hits.load(Ordering::Relaxed),
self.cache_misses.load(Ordering::Relaxed)
)
}
}
무엇이 좋은가
Arc<RwLock<HashMap<..>>>+AtomicU64카운터: 간단하고 안전함.카운터에
Relaxed사용: 단순 통계엔 충분함(순서 보장 불필요).
개선 포인트
복사 비용(클론)
get_opportunities에서Vec<Opportunity>를 그대로clone()→ 트래픽이 크면 비용 큼.대안: 값을
Arc<[Opportunity]>로 저장해 얕은 복사(참조 증가)만 하세요.저장:
Arc::<[Opportunity]>::from(opportunities)반환:
Arc<[Opportunity]>(clone은 레퍼런스 카운트 +1)
락 경합
tokio::sync::RwLock는 읽기 편향이라 write가 잦으면 지연될 수 있어요.대안:
DashMap(샤딩된 락)으로 키별 병행성 ↑
write가 단일 스레드/배치라면 ArcSwap + 스냅샷 교체(락-프리 읽기) 패턴
키 스키마
키가
token: Address하나뿐이면 충돌 큼. 보통 체인ID/풀/쌍까지 포함하세요.예:
(chain_id, base, quote)혹은(pair_address, fee_tier).
만료/용량 관리
TTL/LRU가 없으면 메모리 불어나요.
moka(async LRU/TTL) 추천, 또는 자체 TTL 필드와 주기적 GC.
통계 정확도
히트/미스는
RelaxedOK. 다만 오버플로 방지(saturating add)나 주기적 집계 리셋을 고려.
API 디자인
읽기에서 잠깐 락 잡고 참조를 바깥으로 내보낼 수 없음(Lock Guard 수명).
그래서 소유(Arc) 반환이 정석.
간단 개선 예시(클론 제거 + 키 확장)
use std::{collections::HashMap, sync::Arc};
use tokio::sync::RwLock;
use std::sync::atomic::{AtomicU64, Ordering};
use alloy::primitives::Address; // 예시
// 체인/토큰쌍 키
#[derive(Hash, Eq, PartialEq, Clone)]
pub struct Key {
pub chain_id: u64,
pub base: Address,
pub quote: Address,
}
pub struct MevOpportunityCache {
// 값은 Arc<[Opportunity]>로 저장 → 얕은 복사
opportunities: Arc<RwLock<HashMap<Key, Arc<[Opportunity]>>>>,
cache_hits: AtomicU64,
cache_misses: AtomicU64,
}
impl MevOpportunityCache {
pub fn new() -> Self {
Self {
opportunities: Arc::new(RwLock::new(HashMap::new())),
cache_hits: AtomicU64::new(0),
cache_misses: AtomicU64::new(0),
}
}
pub async fn get_opportunities(&self, key: &Key) -> Arc<[Opportunity]> {
let map = self.opportunities.read().await;
if let Some(opps) = map.get(key) {
self.cache_hits.fetch_add(1, Ordering::Relaxed);
Arc::clone(opps)
} else {
self.cache_misses.fetch_add(1, Ordering::Relaxed);
Arc::from([]) // 빈 슬라이스
}
}
pub async fn update_opportunities(&self, key: Key, opportunities: Vec<Opportunity>) {
let arc_slice: Arc<[Opportunity]> = Arc::from(opportunities);
self.opportunities.write().await.insert(key, arc_slice);
}
pub fn stats(&self) -> (u64, u64) {
(
self.cache_hits.load(Ordering::Relaxed),
self.cache_misses.load(Ordering::Relaxed),
)
}
}
더 높은 스루풋이 필요하면
DashMap<Key, Arc<[Opportunity]>>: 다중 쓰레드에서 동시 읽기/쓰기 경합 감소.
ArcSwap<Arc<HashMap<..>>>: 배치로 새 맵을 만들어 한 번에 스왑 → 읽기는 완전 락-프리(O(1)).
TTL/LRU가 필요하면 (예시: moka)
use moka::future::Cache;
// value = Arc<[Opportunity]> 권장
let cache = Cache::builder()
.max_capacity(50_000)
.time_to_live(std::time::Duration::from_secs(10))
.build();
// get, insert는 async 메서드 제공
요약: 지금 코드도 “작게 시작”하기엔 충분하지만, 클론 제거(Arc<[T]>), 락 경합 완화(DashMap/ArcSwap), 키 설계 보강, TTL/LRU까지 넣으면 MEV 워크로드에서 한 단계 더 견고해집니다.
5.2 메모리 효율적인 데이터 처리
// ✅ 효율적: Cow 패턴으로 조건부 복제
use std::borrow::Cow;
impl AaveProtocol {
fn get_reserve_info(&self, asset: Address) -> Cow<ReserveData> {
match self.reserves.get(&asset) {
Some(reserve) => Cow::Borrowed(reserve), // 참조 사용
None => Cow::Owned(ReserveData::default()), // 소유권 생성
}
}
}
// ✅ 효율적: Zero-copy 직렬화
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct OptimizedTransaction {
// 큰 데이터는 참조로 처리
#[serde(borrow)]
data: &'a [u8],
// 작은 데이터는 직접 포함
nonce: u64,
gas_price: u64,
}
1) Cow로 조건부 복제
아이디어 자체는 👍
캐시에 있으면 참조(
Borrowed), 없으면 기본값을 생성해Owned로 반환 → 불필요한 복제를 줄일 수 있어요.
하지만…
None일 때 기본값을 돌려주면 “없음”과 “기본값”이 구분되지 않음. 보통 **Option<Cow<_>>**로 리턴하는 게 안전합니다.
use std::borrow::Cow;
impl AaveProtocol {
fn get_reserve_info(&self, asset: Address) -> Option<Cow<ReserveData>> {
self.reserves.get(&asset)
.map(|r| Cow::Borrowed(r)) // 캐시에 있음: 빌림
// .or_else(|| Some(Cow::Owned(ReserveData::default()))) // 기본값을 쓰고 싶다면 이렇게 별도 정책으로
}
}
정말 “항상 무언가”를 주고 싶다면 현재 코드처럼
Cow::Owned(ReserveData::default())도 가능은 합니다. 다만 호출자가 “실제 값인지 기본값인지”를 알아야 한다면Option이 낫습니다.
2) Zero-copy 직렬화(역직렬화)
핵심은 수명 파라미터를 명시하고, 입력 버퍼가 결과보다 오래 살아야 한다는 점이에요.
올바른 형태
use serde::{Serialize, Deserialize};
use std::borrow::Cow;
#[derive(Serialize, Deserialize, Debug)]
struct OptimizedTransaction<'a> {
// 문자열/바이트를 입력 버퍼에서 빌려 씀
#[serde(borrow)]
data: Cow<'a, [u8]>, // 혹은 &'a [u8]
nonce: u64,
gas_price: u64,
}
Cow<'a, [u8]>를 쓰면 포맷에 따라 빌림(무복사) 혹은 **소유(복사)**가 자동 선택돼 편리합니다.그냥
&'a [u8]를 써도 되지만, 포맷에 따라 복사가 필요해질 때는 실패할 수 있어Cow가 더 유연합니다.
꼭 알아둘 점
JSON은 바이트 배열을 base64 문자열로 담으므로 진짜 zero-copy가 어렵습니다.
진짜 무복사를 원하면 bincode / postcard / rmp( MessagePack ) / CBOR 같은 이진 포맷을 쓰세요.
바이트 필드엔serde_bytes를 쓰면 효율이 좋아집니다.역직렬화 시 입력 버퍼가 결과보다 오래 살아야 합니다. 파일/네트워크 버퍼를 소유(예:
bytes::Bytes,Arc<[u8]>)한 다음 그 위에서 빌리세요. self-referential 구조는 금지.
// 권장: 버퍼를 소유하고, 그 위에서 빌린 구조를 만든다
use bytes::Bytes;
fn parse<'a>(buf: Bytes) -> anyhow::Result<(Bytes, OptimizedTransaction<'a>)> {
// 예시: bincode 사용 시
// Borrowed 데이터가 buf를 참조하므로 buf를 함께 반환해 수명 보장
let tx: OptimizedTransaction<'a> = bincode::deserialize(&buf)?;
Ok((buf, tx))
}
3) 언제 유리한가
Cow: 대부분 “읽기만” 하고, 드물게만 복사가 필요한 경우.&'a [u8]/&'a str: 이진 포맷에서 입력 버퍼를 오래 유지할 수 있을 때.JSON처럼 무복사가 어려운 포맷에선 그냥
Vec<u8>소유가 현실적일 때도 많습니다.
4) 보너스 팁
캐시/대량 전달엔
Arc<[T]>가 메모리 효율적입니다(슬라이스 단일 할당 + 얕은 복사).작은 벡터는
smallvec::SmallVec<[T; N]>로 힙 할당을 줄일 수 있어요.
요약: 두 패턴 모두 “의도는 정확”합니다. 다만 (1) 없음 vs 기본값 구분 필요 여부, (2) 포맷별 zero-copy 가능성, (3) 입력 버퍼 수명 보장만 챙기면 실전에서도 매끄럽게 굴러갑니다.
5.3 비동기 성능 최적화
// ✅ 효율적: 스트림 처리
use futures::stream::{StreamExt, FuturesUnordered};
async fn process_opportunities_stream(opportunities: Vec<Opportunity>) -> Result<()> {
let mut futures = FuturesUnordered::new();
// 동시 처리할 수 있는 만큼만 스트림에 추가
for opportunity in opportunities {
futures.push(process_opportunity(opportunity));
// 메모리 사용량 제한
if futures.len() >= 100 {
futures.next().await;
}
}
// 남은 모든 태스크 완료 대기
while let Some(result) = futures.next().await {
result?;
}
Ok(())
}아주 괜찮은 패턴이에요. 요지는 **FuturesUnordered + 윈도우 제한(100개)**로 “완료되는 순서대로” 처리하면서 동시성도 제한하는 것.
이 코드가 하는 일
futures.push(process_opportunity(opportunity));로 작업을 스트림에 넣고길이가 100에 도달하면
futures.next().await로 하나 끝날 때까지 대기 → 최대 동시 100 유지마지막엔 남은 것 전부
while let Some(result) = futures.next().await로 회수
주의 & 개선 포인트
에러 처리/취소
result?로 즉시 반환하면, 함수가 에러로 끝나는 순간 남은 작업은 drop되며 취소됩니다(미완료 future는 폴링 중단). 의도한 바면 OK.모든 에러를 모아 리포트하고 싶다면
result를 수집했다가 마지막에 합쳐서 반환하세요.
타임아웃/행동 보장
use tokio::time::{timeout, Duration};
futures.push(async move {
timeout(Duration::from_secs(5), process_opportunity(opportunity)).await??
// ^^^^^^^^^^^^^^^^^ 타임아웃 시 Err 반환
});
더 간결한 대안 (권장)
buffer_unordered:
use futures::{stream, StreamExt, TryStreamExt};
stream::iter(opportunities)
.map(process_opportunity) // -> Future<Result<()>>
.buffer_unordered(100) // 동시 100
.try_for_each(|_| async { Ok(()) })
.await?;
try_for_each_concurrent:
use futures::stream::{self, TryStreamExt};
stream::iter(opportunities)
.map(Ok) // Result로 승격
.try_for_each_concurrent(100, |opp| async move {
process_opportunity(opp).await
})
.await?;
!Send future를 써야 할 때
멀티스레드 런타임에서
FuturesUnordered는 보통Send가 필요합니다.!Send라면LocalSet안에서LocalBoxFuture+FuturesUnordered<LocalBoxFuture<_>>를 쓰세요.
레이트 리미트/쿼터
외부 API(거래소) 호출이라면 세마포어로 per-exchange 제한을 거세요.
let sem = Arc::new(tokio::sync::Semaphore::new(20)); // 동시 20
futures.push({
let sem = sem.clone();
async move {
let _permit = sem.acquire_owned().await?;
process_opportunity(opportunity).await
}
});
결론
현재 코드도 실전에서 잘 먹히는 바운디드 동시성 패턴입니다. 다만 타임아웃/레이트 리미트/에러 집계를 얹고 싶다면 위 개선안으로 정리하면 더 견고해져요.
실전 팁과 베스트 프랙티스
에러 패턴 인식 가이드
에러 메시지 | 문제 유형 | 해결 방향 |
|---|---|---|
| 소유권 이동 후 재사용 | Clone, 참조 사용, 소유권 구조 재설계 |
| 가변/불변 차용 충돌 | 차용 스코프 분리, 내부 가변성 패턴 |
| 생명주기 문제 | 소유권 반환, 정적 생명주기, 구조 재설계 |
| 생명주기 명시 필요 | 소유권 기반 설계, 명시적 생명주기 |
| 공유 포인터 가변성 | Arc<Mutex>, Arc<RwLock> |
결론
이 가이드는 실제 시스템 개발 중 발생한 124개의 컴파일 에러를 최종적으로 0개까지 줄이는 과정에서 얻은 경험을 정리한 것입니다.
Rust의 엄격한 타입 시스템과 소유권 모델은 처음에는 어렵게 느껴질 수 있지만, 런타임 에러를 컴파일 타임에 잡아주어 안전한 시스템 구축을 가능하게 합니다.
핵심 교훈
컴파일러는 친구다: 에러 메시지를 자세히 읽고 이해하자
패턴을 인식하라: 비슷한 에러는 비슷한 해결책을 가진다
도구를 활용하라: sed, grep, jq 등으로 대량 수정 자동화
타입을 명확히: 애매한 타입보다 명시적 타입이 낫다
성능을 고려하라: 탈중앙 금융 시스템에서는 마이크로초 단위의 최적화가 중요하다