블로그로 돌아가기

FastAPI로 환율 API 만들기 (비동기 httpx, 캐싱, Pydantic)

V
Vlado Grigirov
August 11, 2026
FastAPI Python Currency API Exchange Rates Tutorial Fintech

Python으로 핀테크 백엔드, 다중 통화 결제, 또는 내부 가격 책정 서비스를 구축하다 보면 조만간 자체 환율 엔드포인트가 필요해집니다. FastAPI로 환율 API를 구축하면 실시간 환율 제공자를 감싸고, 무료 등급 안에 머물도록 캐싱을 추가하며, 다른 서비스가 호출할 수 있는 깔끔한 /convert 엔드포인트를 노출하는 빠르고 비동기적이며 완전히 타입이 지정된 마이크로서비스를 얻게 됩니다. FastAPI는 여기에 자연스럽게 맞습니다. 진정한 비동기 I/O를 위해 ASGI 위에 구축되었고, Pydantic으로 요청과 응답을 검증하며, 대화형 OpenAPI 문서를 무료로 생성합니다. 이 튜토리얼은 제공자에 대한 첫 요청부터, 공유 HTTP 클라이언트, 캐시된 읽기, Decimal을 통한 안전한 금액 처리, 타입이 지정된 오류 처리를 갖춘 프로덕션 준비 완료 변환기까지 전체 여정을 따라갑니다.

끝에 이르면 Finexly API 문서와 170개 이상 통화 커버리지에 뒷받침되는 작고 독립적인 FastAPI 서비스를 갖게 됩니다. 이미 Python 통화 API 튜토리얼을 읽었다면, 이 글은 각 부품이 실제 ASGI 백엔드에서 어떻게 맞물리는지 보여주는 서비스 수준의 자매편입니다. Django 튜토리얼이 ORM과 템플릿 계층에 기대는 반면, FastAPI는 모든 것을 가볍고 비동기 우선으로 유지합니다.

FastAPI가 통화 마이크로서비스에 훌륭한 이유

통화 변환은 사소해 보입니다——금액에 환율을 곱하기——그러나 서비스로서 그것을 해내는 일은 FastAPI가 설계 대상으로 삼은 관심사들을 건드립니다.

  • 진정한 비동기 I/O. 환율 조회는 네트워크에 종속됩니다. async def 엔드포인트와 비동기 HTTP 클라이언트를 사용하면, 단일 워커가 호출마다 스레드를 막는 대신 요청이 진행되는 동안 많은 동시 변환을 처리할 수 있습니다.
  • 타입이 지정된 계약. Pydantic 모델은 들어오는 쿼리 파라미터를 검증하고 나가는 JSON을 형성하므로, 잘못된 형식의 amount나 알 수 없는 통화 코드는 로직에 도달하기 전에 명확한 422로 거부됩니다.
  • 무료 대화형 문서. 작성하는 모든 엔드포인트는 /docs의 Swagger UI에 나타나, 서비스를 팀의 나머지 구성원에게 자기 문서화된 것으로 만듭니다.
  • lifespan 관리. FastAPI는 시작 시 단일 HTTP 연결 풀을 열고 종료 시 이를 비우는 깔끔한 장소를 제공합니다——아래에서 보게 되듯 성능에 결정적입니다.

우리가 구축하는 아키텍처는 얇고 캐시를 의식하는 프록시입니다. 서비스는 환율 API를 호출하고, 짧은 구간 동안 응답을 캐시하며, 변환 결과를 자체 프런트엔드나 다른 마이크로서비스에 제공합니다. 이 패턴은 API 키를 서버 측에 두고, 제공자의 속도 제한으로부터 여러분을 격리하며, 비즈니스 규칙(반올림, 마진, 허용 목록)을 한 곳에서 추가할 수 있게 합니다.

사전 요구 사항과 프로젝트 설정

Python 3.11 이상이 필요합니다. 가상 환경을 만들고 의존성을 설치하세요.

python -m venv .venv
source .venv/bin/activate  # on Windows: .venv\Scripts\activate

pip install "fastapi[standard]" httpx pydantic-settings

fastapi[standard]는 Uvicorn(ASGI 서버)과 CLI를 함께 가져옵니다. httpx는 우리의 비동기 HTTP 클라이언트이고, pydantic-settings는 환경 변수로부터 구성을 처리합니다.

Finexly 대시보드에서 무료 API 키를 받고, 버전 관리에 절대 들어가지 않도록 내보내세요.

export FINEXLY_API_KEY="your_api_key_here"

프로젝트 구조를 만드세요.

fx-service/
├── app/
│   ├── __init__.py
│   ├── config.py
│   ├── client.py
│   ├── models.py
│   └── main.py
└── .env

1단계: pydantic-settings로 구성하기

구성을 하나의 타입이 지정된 장소에 두세요. pydantic-settings는 환경 변수와 .env 파일을 읽고, 필수 값이 없으면 요란하게 실패합니다.

# app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_prefix="FINEXLY_")

    api_key: str
    base_url: str = "https://api.finexly.com/v1"
    cache_ttl_seconds: int = 300
    request_timeout_seconds: float = 10.0


settings = Settings()  # raises at startup if FINEXLY_API_KEY is unset

구성 객체가 임포트 시점에 생성되므로, 키가 없으면 첫 요청에서 실패하는 대신 서비스가 시작되지 못하게 합니다——배포 파이프라인에서 바로 원하는 바입니다.

2단계: lifespan을 통한 공유 비동기 HTTP 클라이언트

이것은 전체 서비스에서 가장 중요한 성능 결정입니다. 요청마다 새로운 httpx.AsyncClient를 생성하지 마세요. 각 클라이언트는 연결 풀을 소유합니다. 호출마다 하나씩 만들면 keep-alive 연결을 버리고 매번 새로운 TLS 핸드셰이크를 강제합니다. 대신 앱이 시작될 때 하나의 클라이언트를 열고 프로세스의 수명 동안 재사용하세요.

이를 하는 현대적인 방법은 FastAPI의 lifespan 컨텍스트 매니저입니다. (구식 @app.on_event("startup")@app.on_event("shutdown") 데코레이터는 FastAPI 0.93+에서 lifespan을 위해 폐기되었습니다.) async with를 사용하면 시작이 중단되더라도 종료 시 연결 풀이 비워지고 소켓이 닫히는 것이 보장됩니다.

# app/client.py
import httpx
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request

from .config import settings


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Runs once on startup: open a single pooled client
    async with httpx.AsyncClient(
        base_url=settings.base_url,
        headers={"Authorization": f"Bearer {settings.api_key}"},
        timeout=settings.request_timeout_seconds,
    ) as client:
        app.state.http = client
        yield
    # On shutdown, the async-with block closes the pool automatically


def get_client(request: Request) -> httpx.AsyncClient:
    # FastAPI dependency: hand endpoints the shared client
    return request.app.state.http

이제 각 엔드포인트는 의존성을 통해 동일한 풀링된 클라이언트를 빌리며, 누구도 연결을 열거나 닫는 것을 생각할 필요가 없습니다.

3단계: 타입이 지정된 요청과 응답 모델

Pydantic 모델은 검증과 자기 문서화된 응답을 제공합니다. 금전 금액에 Decimal을 사용하는 점에 주목하세요. 이진 부동소수점은 십진 소수를 정확히 표현할 수 없으며, 금액의 반올림 오류는 흥미로운 일화가 아니라 진짜 버그입니다.

# app/models.py
from decimal import Decimal
from pydantic import BaseModel, Field


class ConversionResult(BaseModel):
    base: str = Field(..., examples=["USD"])
    quote: str = Field(..., examples=["EUR"])
    amount: Decimal = Field(..., examples=["100.00"])
    rate: Decimal = Field(..., examples=["0.92"])
    converted: Decimal = Field(..., examples=["92.00"])
    as_of: str = Field(..., description="Timestamp of the upstream rate")


class RatesResponse(BaseModel):
    base: str
    rates: dict[str, Decimal]
    as_of: str

FastAPI는 이 모델들을 OpenAPI 스키마에 렌더링하고 값을 자동으로 강제 변환/검증하므로, 말이 안 되는 금액의 요청은 핸들러가 실행되기 전에 거부됩니다.

4단계: 작은 TTL 캐시로 환율 가져오기

환율은 밀리초마다 바뀌지 않으며, 매 요청마다 제공자 API를 두들기는 것은 느릴 뿐 아니라 할당량을 빠르게 소진하는 방법입니다. 수명(TTL)을 가진 작은 인메모리 캐시가 이를 완화합니다. 단일 프로세스 서비스라면 이 딕셔너리 기반 캐시로 충분합니다. 여러 워커라면 호출 지점을 바꾸지 않고 나중에 Redis로 교체하세요.

# app/main.py
import time
from decimal import Decimal, ROUND_HALF_UP

import httpx
from fastapi import Depends, FastAPI, HTTPException, Query

from .client import lifespan, get_client
from .config import settings
from .models import ConversionResult

app = FastAPI(title="FX Service", lifespan=lifespan)

# Simple in-memory cache: {base_currency: (expiry_timestamp, rates_dict)}
_cache: dict[str, tuple[float, dict]] = {}


async def fetch_rates(base: str, client: httpx.AsyncClient) -> dict:
    base = base.upper()
    now = time.monotonic()
    cached = _cache.get(base)
    if cached and cached[0] > now:
        return cached[1]  # cache hit — no network call

    try:
        resp = await client.get("/latest", params={"base": base})
        resp.raise_for_status()
    except httpx.HTTPStatusError as exc:
        code = exc.response.status_code
        if code == 429:
            raise HTTPException(503, "Upstream rate limit reached; try again shortly")
        if code in (401, 403):
            raise HTTPException(502, "Upstream authentication failed")
        raise HTTPException(502, "Upstream rates provider error")
    except httpx.RequestError:
        raise HTTPException(504, "Could not reach rates provider")

    rates = resp.json()["rates"]
    _cache[base] = (now + settings.cache_ttl_seconds, rates)
    return rates

제공자 실패가 여러분의 호출자에게 의미 있는 HTTP 상태 코드로 어떻게 번역되는지 주목하세요. 제공자의 429는 재시도 힌트가 담긴 503이 되고, 인증 실패는 502가 되어 자격 증명 세부 정보를 하류로 결코 흘리지 않습니다. 통화 API의 캐싱과 오류 처리에 관한 우리의 동반 가이드가 이 패턴들을 더 깊이 파고듭니다.

5단계: /convert와 /rates 엔드포인트

이제 엔드포인트 자체는 아주 작습니다. 모든 어려운 일은 fetch_rates 안에 살기 때문입니다. 우리는 쿼리 파라미터를 FastAPI의 Query로 검증하고, 십진으로 안전한 연산을 하며, 타입이 지정된 모델을 반환합니다.

@app.get("/convert", response_model=ConversionResult)
async def convert(
    base: str = Query(..., min_length=3, max_length=3),
    quote: str = Query(..., min_length=3, max_length=3),
    amount: Decimal = Query(..., gt=0),
    client: httpx.AsyncClient = Depends(get_client),
):
    base, quote = base.upper(), quote.upper()
    rates = await fetch_rates(base, client)

    if quote not in rates:
        raise HTTPException(400, f"Unsupported currency: {quote}")

    rate = Decimal(str(rates[quote]))
    converted = (amount * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

    return ConversionResult(
        base=base, quote=quote, amount=amount,
        rate=rate, converted=converted, as_of="latest",
    )


@app.get("/health")
async def health():
    return {"status": "ok"}

로컬에서 실행하세요.

fastapi dev app/main.py

그런 다음 대화형 Swagger UI를 위해 http://127.0.0.1:8000/docs를 열거나, 엔드포인트를 직접 호출하세요.

curl "http://127.0.0.1:8000/convert?base=USD&quote=EUR&amount=100"
{
  "base": "USD",
  "quote": "EUR",
  "amount": "100",
  "rate": "0.92",
  "converted": "92.00",
  "as_of": "latest"
}

이제 동작하는 통화 마이크로서비스가 생겼습니다. 자체 변환 연산을 아예 실행하고 싶지 않다면, Finexly는 호스팅된 통화 변환기와 직접 호출할 수 있는 변환 엔드포인트도 제공합니다.

6단계: 십진 정밀도와 소수가 없는 통화에 대한 메모

두 가지 금전의 함정이 거의 모든 통화 서비스를 붙잡습니다.

  1. 금액에 이진 부동소수점을 절대 사용하지 마세요. 부동소수점 연산에서 0.1 + 0.20.3이 아닙니다. 환율과 금액을 Decimal(str(value))로 파싱하고 위에서처럼 최종 결과를 양자화하세요.
  2. 모든 통화가 소수점 두 자리를 갖는 것은 아닙니다. ISO 4217은 통화별로 보조 단위를 정의합니다. JPY와 KRW는 소수점 0자리, BHD와 KWD는 3자리입니다. .quantize(Decimal("0.01"))를 하드코딩하면 엔과 디나르를 소리 없이 잘못 반올림합니다. 프로덕션 서비스는 통화별로 올바른 지수를 조회하고 그에 맞게 양자화해야 합니다.

이것을 제대로 하는 것이 데모와, 실제 거래 앞에 놓을 수 있는 것 사이의 차이입니다.

7단계: 서비스 테스트하기

HTTP 클라이언트가 의존성으로 주입되므로 테스트는 쉽습니다——의존성을 모의 객체로 재정의하면 네트워크를 결코 건드리지 않습니다.

# test_convert.py
from fastapi.testclient import TestClient
from app.main import app, fetch_rates

async def fake_rates(base, client):
    return {"EUR": "0.92", "GBP": "0.79"}

def test_convert(monkeypatch):
    monkeypatch.setattr("app.main.fetch_rates", fake_rates)
    with TestClient(app) as c:
        r = c.get("/convert?base=USD&quote=EUR&amount=100")
        assert r.status_code == 200
        assert r.json()["converted"] == "92.00"

TestClient는 lifespan 이벤트를 대신 실행하므로, 공유 클라이언트는 프로덕션과 정확히 동일하게 설정되고 해체됩니다.

프로덕션으로: 확장과 속도 제한

배포 전에 계획해야 할 몇 가지.

  • 여러 워커. 여러 워커가 있는 Uvicorn/Gunicorn 아래에서는 인메모리 캐시가 프로세스별입니다. 공유 캐시를 위해 Redis로 이동하면 한 워커가 가져온 환율이 모두에게 도움이 됩니다. fetch_rates라는 이음새가 이를 단일 함수 변경으로 만듭니다.
  • 제공자 할당량. 캐싱은 첫 번째 방어선입니다. 5분 TTL이면 얼마나 많은 변환을 제공하든 기준 통화 하나당 시간당 최대 12번의 제공자 호출이 듭니다. 가격 페이지에서 요금제 한도를 확인하고 TTL을 자신의 등급에 맞게 설정하세요.
  • 과거 환율. 인보이스, 리포트, 감사 추적을 위해서는 특정 날짜의 환율을 조회하는 /historical 엔드포인트를 원하게 됩니다. 동일한 클라이언트와 캐시 패턴이 적용됩니다. 캐시를 (base, date)로 인덱싱하기만 하면 됩니다.
  • 관측 가능성. 캐시 적중/실패 비율과 제공자 지연을 로깅하여 실제 데이터로 TTL을 조정하세요.

아직 제공자를 고르는 중이라면, 통화 API 비교 페이지가 커버리지, 한도, 가격을 나란히 보여줍니다.

자주 묻는 질문

FastAPI에서 requests 대신 httpx를 쓰는 이유는?

requests는 동기식이며 이벤트 루프를 막아 비동기 프레임워크의 목적을 무산시킵니다. httpx는 연결 풀, 타임아웃, 재시도 전송을 갖춘 완전 비동기 클라이언트(httpx.AsyncClient)를 제공하므로, 다른 요청을 막지 않고 FastAPI의 async def 엔드포인트에 곧바로 끼워집니다.

httpx 클라이언트를 요청마다 만들어야 하나요, 한 번만 만들어야 하나요?

한 번만. lifespan 컨텍스트 매니저에서 단일 httpx.AsyncClient를 만들고 모든 요청에서 재사용하세요. 요청마다 만드는 클라이언트는 연결 풀을 버리고 매 호출마다 새로운 TLS 핸드셰이크를 강제하는데, 이는 부하 상황에서 측정 가능할 만큼 느리며 소켓을 고갈시킬 수 있습니다.

통화 API의 속도 제한에 도달하는 것을 어떻게 피하나요?

제공자 응답을 짧은 TTL(예: 5분)로 캐시하세요. 환율은 천천히 움직이므로, 기준 통화별 캐싱은 수천 건의 사용자 변환을 시간당 소수의 제공자 호출로 줄입니다. 다중 워커 배포에서는 캐시가 프로세스 간에 공유되도록 Redis를 사용하세요.

소수 자릿수가 다른 통화는 어떻게 처리하나요?

Python의 Decimal 타입을 사용하고 절대 float를 쓰지 말며, ISO 4217에 따라 각 통화의 올바른 보조 단위 자릿수로 결과를 양자화하세요. JPY와 KRW는 0자리, 대부분은 2자리, BHD/KWD는 3자리를 씁니다. 두 자리를 하드코딩하는 대신 통화별로 지수를 조회하세요.

이 패턴을 Flask나 Django 같은 동기 프레임워크에 쓸 수 있나요?

캐싱과 Decimal 패턴은 곧바로 이전되지만, 공유 클라이언트와 비동기 엔드포인트 부분은 FastAPI 고유입니다. Django의 경우 프레임워크의 캐시 백엔드와 ORM을 사용하는 전용 Django 통화 API 튜토리얼을 참고하세요.

구축을 시작하세요

이제 FastAPI에 완전하고 비동기적이며 캐시를 의식하는 통화 마이크로서비스가 생겼습니다——Pydantic으로 타입이 지정되고, Decimal로 안전하며, 제공자 장애에 견고합니다. 한 자리에서 읽을 만큼 작고, 실제 프로덕션 서비스로 성장할 만큼 구조화되어 있습니다.

실시간 환율을 프로젝트에 통합할 준비가 되셨나요? 무료 Finexly API 키를 받으세요——신용카드 불필요. 170개 이상의 통화를 다루는 넉넉한 무료 등급으로 시작해 트래픽이 커짐에 따라 확장하세요. 먼저 둘러보고 싶으신가요? 무료 통화 API 개요를 살펴보거나 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 →

이 기사 공유하기