본문으로 건너뛰기
엔지니어링Mar 28, 2026

Deep EVM #23: 성능 디버깅 — 데이터베이스 읽기가 지연 시간을 죽일 때

OS
Open Soft Team

Engineering Team

성능 문제: 데이터베이스 읽기 증폭

프로덕션 환경에서 블록 처리 시간이 갑자기 3배로 증가했습니다. 코드 변경은 없었지만, 블록당 트랜잭션 수가 증가하면서 성능이 급격히 저하되었습니다. 원인을 추적하니 데이터베이스 읽기 증폭이었습니다 — 단일 상태 전환을 위해 수백 번의 데이터베이스 읽기가 발생하고 있었습니다.

데이터베이스 읽기 증폭은 고성능 시스템에서 가장 교활한 성능 킬러 중 하나입니다. 프로파일링에서 단일 핫스팟으로 나타나지 않기 때문입니다 — 수천 번의 작은 읽기로 분산됩니다.

문제 분석: O(N) 읽기 패턴

초기 구현은 각 트랜잭션을 순차적으로 실행하고, 각 실행에서 필요한 모든 상태를 데이터베이스에서 직접 읽었습니다:

struct NaiveExecutor {
    db: Arc<MDBX>,
}

impl NaiveExecutor {
    fn execute_block(&self, block: &Block) -> Result<Vec<Receipt>> {
        let mut receipts = Vec::new();
        for tx in &block.transactions {
            // 각 트랜잭션이 데이터베이스에서 직접 읽기
            let sender_balance = self.db.get_balance(tx.from)?;
            let sender_nonce = self.db.get_nonce(tx.from)?;
            let code = self.db.get_code(tx.to)?;

            // 컨트랙트 실행 중에도 더 많은 읽기
            let receipt = self.execute_tx(tx)?;
            receipts.push(receipt);

            // 상태 변경을 데이터베이스에 직접 쓰기
            self.db.put_balance(tx.from, new_balance)?;
            self.db.put_nonce(tx.from, new_nonce)?;
        }
        Ok(receipts)
    }
}

블록에 100개의 트랜잭션이 있고, 각 트랜잭션이 평균 20개의 상태를 읽으면, 블록당 2,000번의 데이터베이스 읽기가 발생합니다. MDBX에서 각 읽기가 1-5 마이크로초이므로, 읽기만으로 2-10ms가 소요됩니다.

하지만 진짜 문제는 더 심각합니다. 같은 주소가 블록 내에서 여러 번 참조되면, 같은 데이터를 반복적으로 읽습니다. 인기 있는 DEX 컨트랙트가 블록의 50개 트랜잭션에서 호출되면, 그 컨트랙트의 코드와 스토리지를 50번 읽습니다.

해결책: CacheDB 패턴

핵심 통찰은 블록 실행을 두 단계로 분리하는 것입니다: 먼저 필요한 모든 상태를 메모리로 가져온 다음, 메모리 내에서 실행합니다:

struct CacheDB {
    accounts: HashMap<Address, AccountInfo>,
    storage: HashMap<(Address, U256), U256>,
    code: HashMap<B256, Bytes>,
    db: Arc<MDBX>,
}

impl CacheDB {
    fn new(db: Arc<MDBX>) -> Self {
        Self {
            accounts: HashMap::new(),
            storage: HashMap::new(),
            code: HashMap::new(),
            db,
        }
    }

    fn get_account(&mut self, address: Address) -> Result<&AccountInfo> {
        if !self.accounts.contains_key(&address) {
            // 캐시 미스 — 데이터베이스에서 한 번만 로드
            let info = self.db.get_account_info(address)?;
            self.accounts.insert(address, info);
        }
        Ok(self.accounts.get(&address).unwrap())
    }

    fn get_storage(&mut self, address: Address, slot: U256) -> Result<U256> {
        let key = (address, slot);
        if !self.storage.contains_key(&key) {
            let value = self.db.get_storage(address, slot)?;
            self.storage.insert(key, value);
        }
        Ok(*self.storage.get(&key).unwrap())
    }
}

이제 같은 주소에 대한 반복적인 읽기는 HashMap 조회(~50나노초)로 해결되며, 데이터베이스 접근(~3마이크로초)보다 60배 빠릅니다.

성능 측정: 전과 후

벤치마크 결과:

지표순진한 구현CacheDB
블록당 DB 읽기2,847312
블록 실행 시간14.2ms3.8ms
P99 지연 시간28.5ms6.1ms
읽기 증폭 비율9.1x1.0x

읽기 횟수가 89% 감소했고, 실행 시간은 73% 단축되었습니다.

사전 로딩 최적화

CacheDB를 더 최적화하려면 블록의 트랜잭션 접근 목록을 분석하여 필요한 상태를 미리 배치로 로드합니다:

impl CacheDB {
    fn prefetch_block(&mut self, block: &Block) -> Result<()> {
        // 블록의 모든 트랜잭션에서 참조되는 주소 수집
        let mut addresses: HashSet<Address> = HashSet::new();
        let mut storage_keys: HashSet<(Address, U256)> = HashSet::new();

        for tx in &block.transactions {
            addresses.insert(tx.from);
            if let Some(to) = tx.to {
                addresses.insert(to);
            }
            // EIP-2930 접근 목록이 있으면 활용
            for entry in &tx.access_list {
                addresses.insert(entry.address);
                for key in &entry.storage_keys {
                    storage_keys.insert((entry.address, *key));
                }
            }
        }

        // 배치 읽기 — 단일 데이터베이스 트랜잭션으로
        let accounts = self.db.batch_get_accounts(&addresses)?;
        for (addr, info) in accounts {
            self.accounts.insert(addr, info);
        }

        let values = self.db.batch_get_storage(&storage_keys)?;
        for ((addr, slot), value) in values {
            self.storage.insert((addr, slot), value);
        }

        Ok(())
    }
}

이 접근법은 많은 작은 읽기를 소수의 배치 읽기로 통합하여 I/O 오버헤드를 최소화합니다.

프로파일링 방법론

읽기 증폭을 진단하기 위한 도구와 방법:

perf를 사용한 시스템 수준 프로파일링

perf record -g --call-graph dwarf ./target/release/node --block 18000000
perf report --hierarchy

tracing을 사용한 애플리케이션 수준 계측

use tracing::{instrument, info_span};
use std::sync::atomic::{AtomicU64, Ordering};

static DB_READS: AtomicU64 = AtomicU64::new(0);

#[instrument(skip(self))]
fn execute_block(&mut self, block: &Block) -> Result<Vec<Receipt>> {
    DB_READS.store(0, Ordering::Relaxed);
    let result = self.inner_execute(block)?;
    let reads = DB_READS.load(Ordering::Relaxed);
    tracing::info!(
        block = block.number,
        db_reads = reads,
        txs = block.transactions.len(),
        "Block executed"
    );
    Ok(result)
}

실제 환경에서의 교훈

이 최적화에서 배운 핵심 교훈:

  1. 프로파일링 먼저 — 추측하지 마세요. 측정하고 데이터에 기반하여 최적화합니다.
  2. 읽기 증폭 추적 — 논리적 작업당 물리적 읽기 횟수를 모니터링합니다.
  3. 배치 우선 — 가능하면 개별 읽기 대신 배치 읽기를 사용합니다.
  4. 캐싱은 지역성을 활용 — 같은 데이터가 반복 접근되면 캐시가 매우 효과적입니다.
  5. O(N)과 O(affected) 구분 — 블록의 전체 트랜잭션 수(N)가 아닌 영향을 받는 고유 상태(affected)에 비례해야 합니다.

결론

데이터베이스 읽기 증폭은 고성능 Rust 시스템에서 흔한 성능 문제입니다. CacheDB 패턴은 이 문제에 대한 우아한 해결책을 제공합니다: 메모리 내 캐시를 데이터베이스 앞에 배치하여 반복적인 읽기를 제거합니다. 사전 로딩과 배치 읽기는 추가적인 최적화를 제공합니다. 핵심은 항상 프로파일링에서 시작하고, 실제 데이터에 기반하여 최적화하는 것입니다.