블로그로 돌아가기

Rust로 통화 환율 API 다루기 — 완전한 통합 튜토리얼

V
Vlado Grigirov
August 04, 2026
Rust Currency API Exchange Rates Tutorial reqwest Tokio Fintech

핀테크 인프라, 가격 산정 엔진, CLI 도구, 또는 고처리량 백엔드를 구축하고 있다면, 머지않아 실시간 외환 데이터가 필요해집니다. Rust로 통화 환율 API를 연결하는 것은 이 언어가 제공하는 가장 깔끔한 네트워킹 작업 중 하나입니다. reqwest, tokio, serde를 사용하면 비동기 HTTP 클라이언트, 자동 JSON 역직렬화, 그리고 환율 처리 코드가 실행되기도 전에 그 정확성을 보장하는 컴파일 타임 보증을 얻게 됩니다. 이 튜토리얼은 전체 여정을 따라갑니다 — 첫 번째 GET 요청에서부터, 재사용 가능한 클라이언트, 타입이 지정된 모델, 커스텀 오류 처리, 소수점까지 안전한 금액 계산, 그리고 무료 등급 안에 머물게 해 주는 인메모리 캐시를 갖춘 프로덕션 형태의 통화 변환기까지 말입니다.

끝날 무렵이면 Finexly API 문서 위에 구축된, 작고 재사용 가능한 Rust 모듈을 갖게 되며, 170개 이상의 통화 사이를 실시간 환율로 변환할 수 있습니다. 이미 저희의 Go 통화 API 튜토리얼을 읽으셨다면, 이것은 동일한 아키텍처 — 타입이 지정된 클라이언트, 캐시, 그리고 깔끔한 오류 전파 — 를 가진 Rust 대응판입니다.

왜 Rust가 통화 데이터에 잘 맞는가

통화 데이터는 I/O 바운드이고, 지연 시간에 민감하며, 그리고 — 돈을 다루는 어떤 시스템에서든 — 정확성이 결정적으로 중요합니다. Rust는 이 세 가지 모두에 유난히 잘 맞습니다.

  • 기본적으로 비동기. reqwesthypertokio 위에 구축되어 있어, 동시적인 환율 조회가 저렴하고 논블로킹입니다. OS 스레드를 생성하지 않고도 수십 개의 통화 쌍을 병렬로 요청할 수 있습니다.
  • 타입 시스템이 실수를 일찍 잡아낸다. serde로 파생된 struct는, 형식이 잘못된 응답이나 누락된 필드가 프로덕션에서 세 겹 깊숙이 묻힌 수수께끼 같은 null이 아니라, 컴파일 타임 또는 역직렬화 시점의 오류가 됨을 의미합니다.
  • 가비지 컬렉터가 없어 지연 시간이 예측 가능하다. 부하 속에서 외환 환율을 제시하는 서비스에서는, GC 멈춤이 없다는 점이 중요합니다.
  • 소수점까지 안전한 금액. rust_decimal crate를 사용하면 고정 소수점 연산을 얻게 되어, €100.00을 €99.999999로 바꿔 버리는 반올림 버그를 결코 출시하지 않습니다.

절충점은 스크립트 언어보다 초기 설정이 다소 가파르다는 것이지만, 그 결과로 서비스에 그대로 끼워 넣고 신뢰할 수 있는 통화 클라이언트를 얻게 됩니다.

무엇을 구축하게 되는가

다음을 수행하는 재사용 가능한 FinexlyClient입니다.

  1. 통화 환율 API에서 특정 기준 통화의 최신 환율을 가져옵니다.
  2. serde를 사용해 JSON을 타입이 지정된 Rust struct로 역직렬화합니다.
  3. 양쪽 모두 기준 통화가 아닐 때에도(교차 환율), 임의의 두 통화 사이를 변환합니다.
  4. 커스텀 오류 타입으로 오류 — 네트워크 장애, 200이 아닌 응답, 속도 제한 — 를 처리합니다.
  5. TTL과 함께 응답을 메모리에 캐시하여, 무료 요금제 안에 넉넉히 머물게 합니다.

사전 준비와 프로젝트 설정

비교적 최신의 안정 버전 Rust 툴체인(rustup을 통해 설치)과 무료 Finexly API key가 필요합니다. 1분도 채 걸리지 않아 무료로 가입할 수 있습니다 — 신용카드가 필요 없고, 무료 등급은 월 1,000회 요청입니다.

새 프로젝트를 만들고 의존성을 추가합니다.

cargo new finexly-rates
cd finexly-rates
cargo add tokio --features full
cargo add reqwest --features json
cargo add serde --features derive
cargo add thiserror
cargo add rust_decimal rust_decimal_macros

이제 Cargo.toml의 의존성은 다음과 같은 모습이어야 합니다.

[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
thiserror = "2"
rust_decimal = "1"
rust_decimal_macros = "1"

각 crate에 대한 간단한 설명입니다. reqwest는 Rust의 사실상 표준 비동기 HTTP 클라이언트입니다. json 기능은 serde_json을 끌어오고 .json() 헬퍼를 활성화합니다. derive가 붙은 serde#[derive(Deserialize)]를 사용할 수 있게 해 줍니다. thiserror는 사용하기 편한 커스텀 오류 열거형을 만들게 해 줍니다. rust_decimal은 금액을 위한 고정 소수점을 제공합니다.

key가 소스 관리에 결코 들어가지 않도록, 환경 변수에 저장합니다.

export FINEXLY_API_KEY="your_api_key_here"

Finexly API 응답 구조

코드를 작성하기 전에, 이 엔드포인트가 무엇을 반환하는지 살펴봅시다. 최신 환율 엔드포인트에 대한 요청은,

curl "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY&apikey=YOUR_KEY"

작고 예측 가능한 JSON 객체를 반환합니다.

{
  "success": true,
  "base": "USD",
  "timestamp": 1753660800,
  "rates": {
    "EUR": 0.9213,
    "GBP": 0.7847,
    "JPY": 161.42
  }
}

아래 코드를 결정짓는 것은 두 가지입니다. 첫째, rates는 평평한 맵으로, ISO 4217 통화 코드에서 부동소수점 숫자로의 대응입니다 — 이는 Rust의 HashMap<String, f64>로 깔끔하게 대응됩니다. 둘째, base는 그 환율들이 무엇을 기준으로 한 것인지 알려 줍니다. USD → EUR을 변환하려면 rates["EUR"]를 곱합니다. 두 개의 비기준 통화 사이를 변환하려면 기준 통화를 거칩니다. 한쪽으로 나누고, 다른 한쪽을 곱하는 것입니다.

reqwest로 첫 요청 보내기

가능한 한 가장 단순한 비동기 요청부터 시작합시다. src/main.rs를 다음으로 교체합니다.

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let api_key = std::env::var("FINEXLY_API_KEY")
        .expect("Set FINEXLY_API_KEY in your environment");

    let url = format!(
        "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY&apikey={api_key}"
    );

    let body = reqwest::get(&url).await?.text().await?;
    println!("{body}");

    Ok(())
}

cargo run으로 실행하면 출력된 원시 JSON을 보게 됩니다. #[tokio::main] 매크로가 비동기 런타임을 설정하고, reqwest::get이 요청을 수행하며, 각 .await?는 어떤 오류든 전파하면서 네트워크가 응답할 때까지 태스크를 중단합니다. 이것은 동작하지만, reqwest::get은 호출할 때마다 완전히 새로운 클라이언트를 만듭니다 — 일회성으로는 괜찮지만 실제 애플리케이션에는 잘못된 방식입니다. 이는 곧 고치겠습니다.

Serde로 JSON 모델링하기

문자열을 출력하는 것은 쓸모가 없습니다. 원하는 것은 타입이 지정된 데이터입니다. 응답을 그대로 본뜬 struct를 정의하고, 파싱은 serde에게 맡깁시다.

use serde::Deserialize;
use std::collections::HashMap;

#[derive(Debug, Deserialize)]
pub struct RatesResponse {
    pub success: bool,
    pub base: String,
    pub timestamp: i64,
    pub rates: HashMap<String, f64>,
}

이제 .text().json()으로 바꿉니다. 이는 본문을 곧바로 여러분의 struct로 역직렬화합니다.

let data: RatesResponse = reqwest::get(&url).await?.json().await?;
println!("1 USD = {} EUR", data.rates["EUR"]);

json() 메서드는 응답 본문을 읽고 내부적으로 serde_json을 실행합니다. 필드가 누락되었거나 타입이 잘못되면, 소리 없는 null이 아니라 명확한 역직렬화 오류를 얻게 됩니다. ratesHashMap이므로, 반환된 어떤 통화든 그 ISO 4217 코드로 조회할 수 있습니다.

재사용 가능한 클라이언트 구축하기

가장 흔한 reqwest 실수는 요청마다 새로운 Client를 만드는 것입니다. 각 클라이언트는 자체 커넥션 풀을 소유하므로, 그것을 다시 만드는 것은 keep-alive 연결과 TLS 핸드셰이크를 내다 버리는 셈입니다. Client를 하나 만들고, 그것을 저렴하게 클론하여(내부적으로 Arc입니다), 어디서나 재사용하세요. 그것을 URL과 key를 숨기는 작은 struct로 감쌉니다.

use reqwest::Client;

#[derive(Clone)]
pub struct FinexlyClient {
    http: Client,
    api_key: String,
    base_url: String,
}

impl FinexlyClient {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self {
            http: Client::builder()
                .timeout(std::time::Duration::from_secs(10))
                .build()
                .expect("failed to build HTTP client"),
            api_key: api_key.into(),
            base_url: "https://api.finexly.com/v1".to_string(),
        }
    }

    pub async fn latest(
        &self,
        base: &str,
        symbols: &[&str],
    ) -> Result<RatesResponse, FinexlyError> {
        let url = format!("{}/latest", self.base_url);
        let symbols_csv = symbols.join(",");

        let resp = self
            .http
            .get(&url)
            .query(&[
                ("base", base),
                ("symbols", symbols_csv.as_str()),
                ("apikey", self.api_key.as_str()),
            ])
            .send()
            .await?;

        let resp = resp.error_for_status()?;
        let data = resp.json::<RatesResponse>().await?;
        Ok(data)
    }
}

몇 가지 짚어 둘 만한 점이 있습니다. .query(&[...]) 빌더가 URL 인코딩을 대신 처리해 주므로, 쿼리 문자열을 손으로 이어 붙일 일이 전혀 없습니다. 빌더의 .timeout(...)은 멈춰 버린 연결이 서비스를 정체시키는 것을 막아 줍니다. 그리고 error_for_status()는 2xx가 아닌 모든 HTTP 응답을 Err로 바꿉니다 — 이것이 오류 처리로 이어집니다.

견고한 오류 처리

프로덕션 코드는 다음 질문에 답해야 합니다. 네트워크가 끊어졌을 때, key가 유효하지 않을 때(403), 또는 속도 제한에 걸렸을 때(429) 무슨 일이 일어나는가? thiserror를 사용한 커스텀 오류 열거형으로 그러한 경우들을 모델링합니다.

use thiserror::Error;

#[derive(Debug, Error)]
pub enum FinexlyError {
    #[error("HTTP request failed: {0}")]
    Http(#[from] reqwest::Error),

    #[error("API returned an unsuccessful response")]
    Unsuccessful,

    #[error("currency '{0}' was not present in the response")]
    MissingCurrency(String),
}

#[from] reqwest::Error 줄은, reqwest의 어떤 실패든 — DNS, TLS, 타임아웃, 또는 error_for_status()가 잡아낸 2xx가 아닌 상태 — ? 연산자를 통해 자동으로 FinexlyError::Http로 변환됨을 의미합니다. 이것이 latest() 안의 모든 .await?가 그대로 동작하는 이유입니다.

맞춤 동작을 원할 때는 상태 코드를 명시적으로 검사할 수도 있습니다 — 예를 들어 429에서 백오프하는 식으로 말입니다.

if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
    // sleep and retry, or fall back to a cached rate
}

재시도, 백오프, 캐싱 전략에 대한 더 깊은 다룸은 저희의 통화 API 캐싱 및 오류 처리 모범 사례 가이드를 참고하세요.

임의의 두 통화 사이 변환하기

단일 환율을 곱하는 방식은 출발 통화가 기준 통화일 때만 통합니다. 실제 애플리케이션에서는 양쪽 모두 기준 통화가 아닌 임의의 쌍 — EUR → JPY, GBP → CAD — 이 필요합니다. 요령은 기준 통화를 거쳐 경로를 잡는 것입니다. 금액을 출발 환율로 나눠 기준 통화 환산을 구한 다음, 목표 환율을 곱합니다.

impl FinexlyClient {
    /// Convert `amount` from `from` to `to`, using `from` as the request base.
    pub async fn convert(
        &self,
        amount: f64,
        from: &str,
        to: &str,
    ) -> Result<f64, FinexlyError> {
        let data = self.latest(from, &[to]).await?;

        if !data.success {
            return Err(FinexlyError::Unsuccessful);
        }

        let rate = data
            .rates
            .get(to)
            .ok_or_else(|| FinexlyError::MissingCurrency(to.to_string()))?;

        Ok(amount * rate)
    }
}

여기서 우리는 단지 from을 기준 통화로 하여 환율을 요청하므로, 반환된 환율은 이미 직접적인 from → to 변환입니다. 하나의 기준 통화(가령 USD)를 캐시하면서 교차 환율이 필요하다면, 대신 클라이언트 쪽에서 계산하세요: amount / rates[from] * rates[to]. 처음부터 끝까지 시도해 봅시다.

#[tokio::main]
async fn main() -> Result<(), FinexlyError> {
    let client = FinexlyClient::new(
        std::env::var("FINEXLY_API_KEY").expect("set FINEXLY_API_KEY"),
    );

    let usd = client.convert(250.0, "EUR", "USD").await?;
    println!("250 EUR = {usd:.2} USD");

    Ok(())
}

금액에는 rust_decimal 사용하기

f64는 표시용 변환에는 괜찮지만, 잔액 저장, 청구, 또는 센트 단위까지 대사(對査)해야 하는 어떤 것에도 잘못된 타입입니다. 이진 부동소수점은 0.1을 정확히 표현할 수 없고, 그 오차들은 쌓여 갑니다. 금액에는 고정 소수점을 사용하세요.

use rust_decimal::Decimal;
use rust_decimal::prelude::FromPrimitive;
use rust_decimal_macros::dec;

pub fn convert_decimal(amount: Decimal, rate: f64) -> Decimal {
    let rate = Decimal::from_f64(rate).unwrap_or_default();
    (amount * rate).round_dp(2)
}

// usage
let total = convert_decimal(dec!(1999.99), 0.9213);
println!("{total}"); // rounded to 2 decimal places

한 가지 중요한 주의점이 있습니다. 모든 통화가 두 개의 보조 단위를 갖는 것은 아닙니다. JPY와 KRW는 소수점 자릿수가 0입니다. BHD와 KWD 같은 일부 통화는 3자리입니다. 2를 하드코딩하는 대신, (ISO 4217의 보조 단위 표에 따라) 통화마다 올바른 소수 자릿수로 반올림하세요. 저희의 통화 변환기API 문서는 모두 이러한 통화별 규칙을 반영하고 있습니다.

무료 등급 안에 머물기 위한 캐싱

환율은 초 단위로 의미 있게 움직이지 않습니다. 대부분의 표시, 결제, 리포트 사용 사례에서는 한 시간에 한 번 갱신하는 것으로 충분합니다 — 그리고 캐싱은 트래픽이 아무리 많이 와도 여러분을 넉넉하게 무료 요금제 안에 머물게 해 줍니다. Mutex로 보호되는 간단한 인메모리 TTL 캐시를 추가합니다.

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

struct CacheEntry {
    data: RatesResponse,
    fetched_at: Instant,
}

pub struct CachedClient {
    inner: FinexlyClient,
    ttl: Duration,
    cache: Mutex<HashMap<String, CacheEntry>>,
}

impl CachedClient {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self {
            inner: FinexlyClient::new(api_key),
            ttl: Duration::from_secs(3600), // 1 hour
            cache: Mutex::new(HashMap::new()),
        }
    }

    pub async fn latest(&self, base: &str) -> Result<RatesResponse, FinexlyError> {
        {
            let cache = self.cache.lock().unwrap();
            if let Some(entry) = cache.get(base) {
                if entry.fetched_at.elapsed() < self.ttl {
                    return Ok(entry.data.clone());
                }
            }
        }

        let fresh = self.inner.latest(base, &[]).await?;
        let mut cache = self.cache.lock().unwrap();
        cache.insert(
            base.to_string(),
            CacheEntry { data: fresh.clone(), fetched_at: Instant::now() },
        );
        Ok(fresh)
    }
}

이것이 컴파일되게 하려면 RatesResponse#[derive(Clone)]을 붙이는 것이 좋습니다. 한 시간의 TTL이 있으면, 사용자가 아무리 많은 변환을 수행하더라도 하나의 기준 통화가 하루에 드는 비용은 많아야 24회 요청입니다 — 1,000회 요청의 무료 등급에 비하면 반올림 오차 수준입니다. 그것을 넘어서게 되면, 요금제가 선형적으로 확장됩니다. stale-while-revalidate나 인스턴스 간 공유 캐시 같은 패턴에 대해서는 저희의 캐싱 및 오류 처리 가이드를 참고하세요.

과거 환율 가져오기

날짜를 소급한 청구서, 리포트, 차트에는 과거 데이터가 필요합니다. Finexly는 과거 데이터 엔드포인트를 제공하며, /v1/historical을 호출하는 메서드를 추가하면 동일한 클라이언트에서 호출할 수 있습니다.

impl FinexlyClient {
    pub async fn historical(
        &self,
        date: &str, // "YYYY-MM-DD"
        base: &str,
        symbols: &[&str],
    ) -> Result<RatesResponse, FinexlyError> {
        let url = format!("{}/historical", self.base_url);
        let symbols_csv = symbols.join(",");

        let data = self
            .http
            .get(&url)
            .query(&[
                ("date", date),
                ("base", base),
                ("symbols", symbols_csv.as_str()),
                ("apikey", self.api_key.as_str()),
            ])
            .send()
            .await?
            .error_for_status()?
            .json::<RatesResponse>()
            .await?;

        Ok(data)
    }
}

응답 구조는 최신 환율 엔드포인트와 동일하므로, 기존의 모든 파싱 및 변환 코드가 그대로 동작합니다. 전체 파라미터 목록과 날짜 범위를 위한 /v1/timeseries 엔드포인트는 API 문서를 참고하세요.

피해야 할 흔한 함정

  • 요청마다 Client를 만들기. 하나만 구축하고, 그것을 클론하여(저렴합니다) 재사용함으로써 커넥션 풀을 따뜻하게 유지하세요.
  • 저장하는 잔액에 f64를 쓰기. 표시에는 괜찮지만 장부에는 잘못된 방식입니다 — 대사해야 하는 것에는 rust_decimal을 사용하세요.
  • 소수점 두 자리를 하드코딩하기. JPY와 KRW는 0자리이고, 몇몇 통화는 3자리입니다. ISO 4217의 보조 단위를 사용해 통화마다 반올림하세요.
  • 상태 검사를 건너뛰기. 403이나 429 본문을 환율 데이터로 역직렬화하면 혼란스러운 버그가 생깁니다. error_for_status()가 이를 한 줄로 처리합니다.
  • 변환할 때마다 조회하기. TTL로 캐시하고 사용자 입력을 디바운스하여, 할당량을 태워 버리지 않도록 하세요.
  • API key를 커밋하기. 환경 변수나 시크릿 매니저에서 읽으세요. main.rs 안의 문자열 리터럴로는 결코 두지 마세요.

아직 공급자를 비교하고 있는 단계라면, 저희의 통화 API 비교가 정확성, 통화 커버리지, 무료 등급 제한을 자세히 분석하고 있으며, 무료 통화 API 가이드는 비용이 들지 않는 선택지를 더 깊이 다룹니다.

자주 묻는 질문

Rust에서 통화 API를 호출하기에 가장 좋은 HTTP 클라이언트는 무엇인가요?

reqwest가 비동기 Rust의 표준 선택지입니다. hypertokio 위에 구축되어 있고, 커넥션 풀링, TLS, JSON을 기본으로 처리하며, 타입이 지정된 응답을 위해 serde와 자연스럽게 어울립니다. json 기능을 활성화하고, 애플리케이션 전체에서 단일 Client 인스턴스를 재사용하세요.

Rust에서 JSON 환율 응답을 어떻게 파싱하나요?

응답을 그대로 본뜬 struct를 정의하고, 그것에 serde::Deserialize를 파생시킵니다. rates 필드는 통화 코드에서 환율로의 평평한 맵이므로 HashMap<String, f64>로 모델링한 다음, 응답에 대해 .json::<RatesResponse>()를 호출하면 reqwest가 대신 역직렬화해 줍니다.

Rust에서 통화 변환에 f64와 Decimal 중 무엇을 써야 하나요?

빠른 표시용 변환에는 f64를 쓰되, 저장하거나 대사하는 것 — 잔액, 청구서, 장부 — 에는 rust_decimal::Decimal을 사용하세요. 이진 부동소수점은 십진 소수를 정확히 표현할 수 없어 반올림 오차가 쌓입니다. rust_decimal은 고정 소수점 정밀도를 제공합니다.

Rust 서비스에서 환율을 얼마나 자주 가져와야 하나요?

대부분의 결제, 표시, 리포트 사용 사례에서는 한 시간에 한 번이면 충분합니다 — 환율은 한 시간 안에 문제가 될 만큼 움직이지 않습니다. 한 시간의 TTL로 응답을 캐시하면, 기준 통화당 하루 약 24회 호출로 유지되어 넉넉하게 무료 요금제 안에 머무릅니다.

Rust에서 과거 환율을 가져올 수 있나요?

네. 동일한 클라이언트에서 /v1/historical?date=YYYY-MM-DD 엔드포인트를 호출하는 메서드를 추가하세요. JSON 구조가 최신 환율 엔드포인트와 일치하므로, 기존의 struct와 변환 로직이 변경 없이 동작합니다. 이는 날짜를 소급한 청구서, 리포트, 차트에 유용합니다.

구축을 시작하세요

실시간 환율을 여러분의 Rust 프로젝트에 통합할 준비가 되셨나요? 무료 Finexly API key를 받으세요 — 신용카드가 필요 없습니다. 월 1,000회 무료 요청으로 시작해서 성장에 맞춰 업그레이드하세요. 전체 엔드포인트 레퍼런스는 API 문서를 확인하고, 다중 통화 기능을 확장할 준비가 되면 요금제를 살펴보세요.

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →

이 기사 공유하기