블로그로 돌아가기

Django로 통화 변환기 만들기: 환율 API 완전 튜토리얼 (2026)

V
Vlado Grigirov
May 12, 2026
Django Python Currency API Exchange Rates Tutorial Finexly

돈을 다루는 대부분의 Django 앱은 결국 환율이 필요해집니다 — 세 가지 통화로 청구서를 발행하든, Stripe 정산을 기축 통화로 되돌리든, 결제 페이지에서 현지화된 가격을 보여주든. 정답은 Django용 환율 API 를 꽂아 FX를 "해결된 문제"로 다루는 것입니다. 오답은 settings.py에 환율을 하드코딩하거나, 요청마다 업스트림을 호출하는 것입니다.

이 가이드는 프로덕션 수준의 통합을 처음부터 끝까지 살펴봅니다. Django 5.x 에서 Finexly API, 핫 리드용 Django 캐시 프레임워크, 영속성을 위한 모델, 예약된 새로고침용 management command, 부동소수점 오차로 잔돈을 흘리지 않게 해주는 Decimal 정밀도, Django Ninja를 통한 REST 엔드포인트, htmx 기반 UI, 그리고 네트워크에 닿지 않는 pytest + responses 테스트를 사용해 작은 통화 변환기를 만듭니다. 마치고 나면 SaaS 청구, 전자상거래, 회계 등 돈이 국경을 넘는 어떤 Django 프로젝트에도 그대로 꽂을 수 있는 서비스 클래스 아키텍처가 손에 들어옵니다.

전용 통화 API가 하드코딩보다 나은 이유

settings.py에 환율을 하드코딩하는 것은 첫 번째 오답입니다. 두 번째 오답은 모든 뷰에서 업스트림을 호출하는 것입니다. 제대로 쓰인 전용 통화 API는 하드코딩된 값으로는 줄 수 없는 네 가지를 줍니다:

  • 필요할 때의 신선도. 환율은 시장 시간 중 끊임없이 변합니다. USD/JPY나 EUR/TRY 같은 변동성 큰 페어에서는 24시간 묵은 환율도 1–2% 움직일 수 있습니다 — SaaS 플랜 마진을 지우기에 충분합니다.
  • 넓은 커버리지. Finexly는 신흥국 통화와 CBDC 참조 환율을 포함해 170개 이상의 통화를 커버합니다. 많은 오픈소스 Django 패키지에 딸려오는 ECB 피드는 약 32개 메이저만 커버합니다. 사용자 한 명이라도 아르헨티나 페소나 터키 리라로 결제한다면, 이 갭은 중요합니다.
  • 단일 계약. latest, historical, convert 엔드포인트가 같은 JSON 형식 — 세 가지 업스트림 피드를 테이프로 붙여놓을 필요가 없습니다.
  • 예측 가능한 쿼터. "폴링을 너무 했다고 ECB가 IP를 차단했다"가 아니라 추론할 수 있는 명문화된 한도.

아직 선택지를 비교 중이라면, 2026 무료 vs 유료 통화 API 비교ExchangeRate-API vs CurrencyLayer vs Finexly 글이 대안을 자세히 다룹니다.

만들 것

다섯 가지를 잘 하는, 의견 있는 작은 Django 모듈 fx:

  1. 타입이 지정된 서비스 클래스를 통해 임의의 기축 통화에 대한 최신 환율을 Finexly에서 가져옵니다.
  2. 핫 리드를 Django 캐시(프로덕션은 Redis, 개발은 locmem)에 캐싱합니다.
  3. 각 fetch를 ExchangeRate 모델에 영속화해, 업스트림이 깜빡여도 결제가 죽지 않게 합니다.
  4. management command + cron(또는 Celery beat)으로 환율을 예약 새로고침합니다.
  5. JSON 변환 엔드포인트와 htmx 기반 HTML 폼을 노출합니다.

기존 Django 프로젝트에 재사용 가능한 앱으로 꽂아 넣으면 됩니다.

사전 요구사항

Python 3.11+, Django 5.0+, Finexly API 키가 필요합니다. Finexly 가입 페이지에서 무료로 받으세요 — 무료 티어가 월 1,000건의 요청을 주는데, 캐싱만 제대로 하면 MVP에는 차고 넘칩니다.

1단계: Django 프로젝트 세팅

python -m venv .venv
source .venv/bin/activate
pip install "django>=5.0,<6.0" httpx python-decouple django-ninja
django-admin startproject site .
python manage.py startapp fx

site/settings.pyINSTALLED_APPS에 새 앱을 추가:

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "fx",
]

requests 대신 httpx를 쓰는 이유는 타임아웃, httpx.HTTPTransport(retries=...)를 통한 재시도, 그리고 나중에 갈아끼울 수 있는 async 클라이언트를 하나의 클라이언트로 다 주기 때문입니다. 환경 변수에는 python-decouple, REST 엔드포인트에는 django-ninja를 — 둘 다 가볍고 비켜서 있습니다.

2단계: 환경 변수 설정

프로젝트 루트에 .env를 만듭니다:

DEBUG=True
SECRET_KEY=change-me-in-production
FINEXLY_API_KEY=your_finexly_api_key_here
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USD

site/settings.py에 연결:

from decouple import config

SECRET_KEY = config("SECRET_KEY")
DEBUG = config("DEBUG", default=False, cast=bool)

FINEXLY_API_KEY = config("FINEXLY_API_KEY")
FINEXLY_BASE_URL = config("FINEXLY_BASE_URL", default="https://finexly.com/api/v1")
FX_CACHE_TTL = config("FX_CACHE_TTL", default=900, cast=int)
FX_DEFAULT_BASE = config("FX_DEFAULT_BASE", default="USD")

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": config("REDIS_URL", default="redis://127.0.0.1:6379/1"),
    }
}

개발 환경에 Redis가 없다면 RedisCacheLocMemCache로 바꾸세요 — 나머지 코드는 신경 쓰지 않습니다.

15분(900초) TTL은 대부분의 소비자 대상 앱에 합리적인 기본값입니다. 고빈도 트레이딩은 sub-second가 필요하고, 회계 보고서는 6시간 TTL도 괜찮습니다. 캐싱과 에러 처리 베스트 프랙티스 글에서 자신의 사례에 맞는 숫자를 고르는 방법을 다룹니다.

3단계: ExchangeRate 모델

영속성은 중요합니다 — 업스트림이 닿지 않을 때 폴백이 되고, 재무팀이 "4월 3일 그 청구서에는 무슨 환율을 썼지?"라고 물을 때 감사 추적이 됩니다. fx/models.py를 만드세요:

from decimal import Decimal
from django.db import models


class ExchangeRate(models.Model):
    base = models.CharField(max_length=3, db_index=True)
    quote = models.CharField(max_length=3, db_index=True)
    rate = models.DecimalField(max_digits=20, decimal_places=10)
    fetched_at = models.DateTimeField(auto_now_add=True, db_index=True)

    class Meta:
        indexes = [
            models.Index(fields=["base", "quote", "-fetched_at"]),
        ]
        constraints = [
            models.CheckConstraint(
                check=models.Q(rate__gt=Decimal("0")),
                name="rate_positive",
            ),
        ]

    def __str__(self) -> str:
        return f"{self.base}/{self.quote} = {self.rate} @ {self.fetched_at:%Y-%m-%d %H:%M}"

설계상 두 가지를 짚어둡시다. 첫째, FloatField가 아니라 DecimalField — 부동소수점 오차는 수천 건의 변환에서 누적되고, 송장당 $0.03씩 어긋난다고 재무팀에 설명하는 엔지니어가 되고 싶진 않을 겁니다. 소수점 10자리는 표시용으로는 과하지만 FX 저장에는 표준입니다. 둘째, (base, quote, -fetched_at) 복합 인덱스는 "가장 최근 USD/EUR 환율을 줘"를 정렬 대신 단일 B-tree seek으로 만듭니다.

마이그레이션 실행:

python manage.py makemigrations fx
python manage.py migrate

4단계: 서비스 레이어

서비스 클래스는 업스트림과 대화하는 로직이 모두 사는 곳입니다. 뷰는 절대 httpx를 직접 호출하지 않습니다. fx/services.py를 만드세요:

import logging
from decimal import Decimal
from typing import Mapping

import httpx
from django.conf import settings
from django.core.cache import cache

logger = logging.getLogger(__name__)


class ExchangeRateError(Exception):
    """Upstream fetch failed and no usable fallback exists."""


class ExchangeRateService:
    def __init__(
        self,
        api_key: str | None = None,
        base_url: str | None = None,
        cache_ttl: int | None = None,
        default_base: str | None = None,
    ) -> None:
        self.api_key = api_key or settings.FINEXLY_API_KEY
        self.base_url = base_url or settings.FINEXLY_BASE_URL
        self.cache_ttl = cache_ttl or settings.FX_CACHE_TTL
        self.default_base = default_base or settings.FX_DEFAULT_BASE

    def latest(self, base: str | None = None) -> Mapping[str, Decimal]:
        base = (base or self.default_base).upper()
        cache_key = f"fx:latest:{base}"

        cached = cache.get(cache_key)
        if cached is not None:
            return cached

        rates = self._fetch_latest(base)
        cache.set(cache_key, rates, self.cache_ttl)
        self._persist(base, rates)
        return rates

    def convert(self, amount: Decimal, frm: str, to: str) -> Decimal:
        frm, to = frm.upper(), to.upper()
        if frm == to:
            return amount
        rates = self.latest(frm)
        if to not in rates:
            raise ExchangeRateError(f"Currency {to} not found in {frm} rates")
        return (amount * rates[to]).quantize(Decimal("0.01"))

    def _fetch_latest(self, base: str) -> dict[str, Decimal]:
        url = f"{self.base_url}/latest"
        headers = {"Authorization": f"Bearer {self.api_key}"}
        transport = httpx.HTTPTransport(retries=2)
        try:
            with httpx.Client(transport=transport, timeout=8.0) as client:
                response = client.get(url, params={"base": base}, headers=headers)
                response.raise_for_status()
        except httpx.HTTPError as exc:
            logger.warning("Finexly fetch failed for %s: %s", base, exc)
            fallback = self._fallback_from_db(base)
            if fallback is not None:
                return fallback
            raise ExchangeRateError(str(exc)) from exc

        payload = response.json()
        rates = payload.get("rates")
        if not isinstance(rates, dict):
            raise ExchangeRateError("Malformed response: missing 'rates'")
        return {quote: Decimal(str(rate)) for quote, rate in rates.items()}

    def _fallback_from_db(self, base: str) -> dict[str, Decimal] | None:
        from .models import ExchangeRate

        recent = (
            ExchangeRate.objects.filter(base=base)
            .order_by("quote", "-fetched_at")
            .distinct("quote")
        )
        rates = {row.quote: row.rate for row in recent}
        return rates or None

    def _persist(self, base: str, rates: Mapping[str, Decimal]) -> None:
        from .models import ExchangeRate

        ExchangeRate.objects.bulk_create(
            [ExchangeRate(base=base, quote=q, rate=r) for q, r in rates.items()],
            batch_size=200,
        )

많은 일이 일어나니 풀어 봅시다. latest()는 유일한 공개 읽기 경로입니다: 캐시 시도 → 업스트림 폴백 → 반환 시 영속화. convert()는 같은 통화 엣지 케이스(1을 곱해서 반올림을 일으키지 마세요)를 처리하고 결과를 소수점 둘째 자리로 양자화합니다 — 표시용 선택이고 저장은 더 높은 정밀도를 유지하세요. _fallback_from_db는 프로덕션 안전망입니다: Finexly가 503을 반환하면 자체 DB에서 quote별 최신 환율을 서빙합니다. 서드파티의 잠깐 끊김으로 결제가 죽지 않습니다.

httpx.HTTPTransportretries=2가 일시적인 502/503 대다수를 자동으로 처리합니다. 8초 타임아웃은 보수적입니다 — 대륙을 넘는다면 5초 미만은 너무 빡빡하고, 10초를 넘으면 흔들리는 업스트림이 레이턴시 예산을 망칩니다.

5단계: 변환 뷰

이제 뷰. fx/views.py를 만드세요:

from decimal import Decimal, InvalidOperation

from django.http import HttpRequest, JsonResponse
from django.shortcuts import render
from django.views.decorators.http import require_http_methods

from .services import ExchangeRateError, ExchangeRateService


@require_http_methods(["GET", "POST"])
def convert_view(request: HttpRequest):
    context: dict = {"result": None}
    if request.method == "POST":
        service = ExchangeRateService()
        try:
            amount = Decimal(request.POST.get("amount", "0"))
            frm = request.POST.get("from", "USD")
            to = request.POST.get("to", "EUR")
            converted = service.convert(amount, frm, to)
            context.update({"result": converted, "from": frm, "to": to, "amount": amount})
        except (InvalidOperation, ExchangeRateError) as exc:
            context["error"] = str(exc)

    template = "fx/_result.html" if request.headers.get("HX-Request") else "fx/convert.html"
    return render(request, template, context)

HX-Request 헤더 검사가 htmx 패턴입니다: 같은 뷰, 두 개의 템플릿. 일반 POST는 전체 페이지를, htmx 발 POST는 결과 조각만 반환하고 htmx가 그것을 DOM에 끼워 넣습니다. 사용자는 변환된 금액이 제자리에 나타나는 것을 보고, 당신이 작성한 JavaScript는 0줄입니다.

6단계: htmx 템플릿

fx/templates/fx/convert.html을 만드세요:

{% load static %}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Currency Converter</title>
  <script src="https://unpkg.com/htmx.org@1.9.10"></script>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 480px; margin: 4rem auto; }
    input, select, button { padding: 0.5rem; font-size: 1rem; }
    .result { margin-top: 1rem; font-size: 1.4rem; }
  </style>
</head>
<body>
  <h1>Currency Converter</h1>
  <form hx-post="{% url 'fx:convert' %}" hx-target="#result" hx-swap="innerHTML">
    {% csrf_token %}
    <input type="number" name="amount" step="0.01" required placeholder="Amount">
    <input type="text" name="from" maxlength="3" required placeholder="USD" value="USD">
    <input type="text" name="to" maxlength="3" required placeholder="EUR" value="EUR">
    <button type="submit">Convert</button>
  </form>
  <div id="result">{% include "fx/_result.html" %}</div>
</body>
</html>

부분 템플릿 fx/templates/fx/_result.html:

{% if error %}
  <p class="error">{{ error }}</p>
{% elif result %}
  <p class="result">{{ amount }} {{ from }} = <strong>{{ result }} {{ to }}</strong></p>
{% endif %}

fx/urls.py에 연결:

from django.urls import path
from . import views

app_name = "fx"
urlpatterns = [
    path("convert/", views.convert_view, name="convert"),
]

site/urls.py에도:

from django.urls import include, path

urlpatterns = [
    path("", include("fx.urls")),
]

7단계: Django Ninja로 REST 엔드포인트

JSON 소비자 — 모바일 앱, 백그라운드 워커, 프론트엔드 SPA — 를 위해 타입이 지정된 엔드포인트를 노출하세요. fx/api.py를 만드세요:

from decimal import Decimal

from ninja import NinjaAPI, Schema
from ninja.errors import HttpError

from .services import ExchangeRateError, ExchangeRateService

api = NinjaAPI(title="FX API")


class ConvertOut(Schema):
    amount: Decimal
    from_: str
    to: str
    rate: Decimal
    result: Decimal


@api.get("/convert", response=ConvertOut)
def convert(request, amount: Decimal, from_: str, to: str):
    service = ExchangeRateService()
    try:
        rates = service.latest(from_)
        rate = rates[to.upper()]
        result = service.convert(amount, from_, to)
    except (ExchangeRateError, KeyError) as exc:
        raise HttpError(502, f"FX unavailable: {exc}") from exc
    return ConvertOut(amount=amount, from_=from_.upper(), to=to.upper(), rate=rate, result=result)

site/urls.py에 추가:

from fx.api import api

urlpatterns = [
    path("", include("fx.urls")),
    path("api/", api.urls),
]

이제 GET /api/convert?amount=100&from_=USD&to=EUR이 강타입 JSON 페이로드를 반환합니다. Django Ninja는 /api/docs에 OpenAPI 스키마를 공짜로 생성해줍니다 — 모바일 팀이 질문하기 시작할 때 유용합니다.

8단계: management command를 통한 예약 새로고침

대부분의 앱에서 똑똑한 패턴은 주기적인 백그라운드 새로고침 으로 트래픽이 도착하기 전에 캐시를 데우는 것입니다. fx/management/commands/refresh_rates.py를 만드세요:

from django.core.management.base import BaseCommand

from fx.services import ExchangeRateError, ExchangeRateService


class Command(BaseCommand):
    help = "Refresh exchange rates from Finexly for the configured base currencies."

    def add_arguments(self, parser):
        parser.add_argument("--bases", nargs="+", default=["USD", "EUR", "GBP"])

    def handle(self, *args, **options):
        service = ExchangeRateService()
        for base in options["bases"]:
            try:
                rates = service.latest(base)
                self.stdout.write(self.style.SUCCESS(
                    f"{base}: refreshed {len(rates)} rates"
                ))
            except ExchangeRateError as exc:
                self.stderr.write(self.style.ERROR(f"{base}: {exc}"))

fx/management/__init__.pyfx/management/commands/__init__.py가 있어야 Django가 명령을 찾습니다.

cron(또는 Celery를 이미 쓰고 있다면 Celery beat)으로 스케줄링:

*/15 * * * * cd /app && /app/.venv/bin/python manage.py refresh_rates --bases USD EUR GBP JPY

15분마다 네 개의 기축이 갱신됩니다. 갱신 1회 = 업스트림 호출 1회 — 4 × 96(하루 간격 수) = 일 384 호출, 매시간으로 낮추면 Finexly 무료 월 1,000 한도 안에 여유 있게 들어갑니다. 15분 미만의 신선도가 필요하면 요금제 페이지의 상위 티어 쿼터를 확인하세요.

9단계: 네트워크에 닿지 않는 테스트

서비스 레이어 존재 이유의 핵심은 mock 가능하다는 것입니다. responsespytest-django를 설치:

pip install pytest pytest-django responses

pytest.ini를 만드세요:

[pytest]
DJANGO_SETTINGS_MODULE = site.settings
python_files = tests.py test_*.py *_tests.py

그리고 fx/tests/test_services.py:

from decimal import Decimal

import pytest
import responses
from django.core.cache import cache

from fx.services import ExchangeRateError, ExchangeRateService


@pytest.fixture(autouse=True)
def _clear_cache():
    cache.clear()
    yield
    cache.clear()


@responses.activate
def test_latest_returns_rates_from_upstream(settings):
    responses.add(
        responses.GET,
        f"{settings.FINEXLY_BASE_URL}/latest",
        json={"base": "USD", "rates": {"EUR": 0.92, "GBP": 0.79}},
        status=200,
    )
    service = ExchangeRateService()
    rates = service.latest("USD")
    assert rates["EUR"] == Decimal("0.92")
    assert rates["GBP"] == Decimal("0.79")


@responses.activate
def test_convert_quantizes_to_cents(settings):
    responses.add(
        responses.GET,
        f"{settings.FINEXLY_BASE_URL}/latest",
        json={"base": "USD", "rates": {"EUR": Decimal("0.9234")}},
        status=200,
    )
    service = ExchangeRateService()
    result = service.convert(Decimal("100"), "USD", "EUR")
    assert result == Decimal("92.34")


@responses.activate
def test_upstream_failure_raises_when_no_fallback(settings):
    responses.add(
        responses.GET,
        f"{settings.FINEXLY_BASE_URL}/latest",
        status=503,
    )
    service = ExchangeRateService()
    with pytest.raises(ExchangeRateError):
        service.latest("USD")


def test_same_currency_returns_input():
    service = ExchangeRateService()
    assert service.convert(Decimal("100"), "USD", "USD") == Decimal("100")

실행:

pytest -q

테스트 4건, 네트워크 호출 0건, 총 실행 시간 sub-second. 그게 전부의 요점입니다.

프로덕션 메모

FX가 라이브로 가면 Django 앱을 무는 몇 가지:

  • DB 영속화는 폴백입니다. _persist를 건너뛰면 업스트림 장애가 곧 결제 장애입니다. 건너뛰지 마세요.
  • 캐시 스탬피드. 100개 요청이 차가운 캐시에 동시에 닿으면 100개의 업스트림 호출이 나갑니다. django-redisget_or_set에 락을 걸거나, 트래픽이 낮으면 트레이드오프를 수용하세요.
  • 주말 통화 드리프트. FX 시장은 닫힙니다. 대부분의 API(Finexly 포함)는 주말 내내 금요일 종가를 서빙합니다 — 환율은 월요일 오픈까지 움직이지 않습니다. 주말 거래를 결제한다면 정책을 문서화하세요.
  • 감사 추적. ExchangeRate 테이블은 빠르게 자랍니다. 최근 7일에 partial 인덱스를 걸거나, 컴플라이언스가 허용하면 오래된 행을 월간 요약으로 롤업하세요.

위 패턴들이 더 궁금하면 캐싱과 에러 처리 가이드가 스탬피드 보호, 서킷 브레이커, 점진적 저하를 상세히 다룹니다.

자주 묻는 질문

Django에 가장 잘 어울리는 환율 API는? 어떤 REST API든 동작하지만 사용성이 다릅니다. Finexly는 Python dict에 깔끔하게 맵핑되는 평평한 {rates: {...}} 형식을 반환하고, 170개 이상의 통화를 지원하며, 월 1,000 호출 무료 티어를 제공합니다 — MVP에 충분합니다. Fixer나 Open Exchange Rates 같은 대안은 쿼터 구조가 다르며, 비교 글에서 트레이드오프를 다룹니다.

Django에서 통화 환율은 Decimal과 Float 중 무엇을? 저장은 항상 DecimalField, 비즈니스 로직은 항상 Decimal. Float는 반올림 오차를 도입하고, 수천 건의 변환에서 누적되며, 금융 코드에서는 결국 사용자에게 보이는 버그가 됩니다. 성능 비용은 무시할 수 있습니다.

환율은 얼마나 자주 새로고침해야? 사례에 따라 다릅니다. 현지화 가격을 보여주는 소비자 앱이라면 15분이면 충분합니다. 회계 보고서는 매시간이면 됩니다. 트레이딩이나 실시간 헤징이라면 sub-second WebSocket 피드가 필요합니다 — 그 패턴은 REST vs WebSocket 글을 보세요.

Django Ninja 대신 Django REST Framework로도 가능? 네. 서비스 레이어는 프레임워크에 비종속적입니다. fx/api.pyExchangeRateService().convert(...)를 호출하고 결과를 직렬화하는 DRF APIView로 교체하면 됩니다. 모델, 서비스, management command, 테스트는 그대로 유지됩니다.

API 응답에 없는 통화는 어떻게 처리? 두 가지 패턴. 명시적으로 ExchangeRateError를 던져 사용자에게 노출하거나(더 깔끔한 UX), USD 경유 크로스 레이트로 폴백합니다(더 관대하지만 디버그하기 어려움). 대부분의 앱에서는 일찍 던지는 쪽이 옳습니다.

Finexly 무료로 시도하기

Django 프로젝트에 실시간 환율을 통합할 준비가 되셨나요? 무료 Finexly API 키 받기 — 신용카드 불필요. 월 1,000 무료 요청, 170개 이상의 통화에 대한 실시간 환율, Python에 깔끔하게 맵핑되는 JSON 계약으로 시작하세요. 필요 시 요금제에서 앱과 함께 스케일업하세요.

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 →

이 기사 공유하기