블로그로 돌아가기

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

V
Vlado Grigirov
May 05, 2026
Laravel Currency API Exchange Rates PHP Tutorial Finexly

돈을 다루는 Laravel 애플리케이션은 결국 환율이 필요해집니다. 세 가지 통화로 청구서를 발행하든, Stripe 정산을 본국 통화로 환전하든, 결제 페이지에서 현지화된 가격을 보여주든 마찬가지입니다. 가장 간단한 방법은 Laravel 환율 API를 꽂아서 FX를 "해결된 문제"로 취급하는 것이고, 어려운 부분은 그걸 제대로 하는 것입니다. 쿼터를 태우지 않게 적극적으로 캐싱하고, 상위 5xx로 결제가 죽지 않게 환율을 영속화하고, 유럽중앙은행의 16:00 CET 픽싱에 맞춰 갱신을 스케줄링하고, 네트워크를 건드리지 않는 테스트를 작성해야 합니다.

이 가이드에서 그걸 모두 다룹니다. Laravel 11, Finexly API, 영속화용 Eloquent, 핫 읽기용 Cache 파사드, 갱신용 scheduler, ISO 4217용 커스텀 검증 규칙, Http::fake()를 이용한 Pest 테스트로 작은 통화 변환기를 만듭니다. 끝나면 SaaS 결제, 이커머스, 회계 — 돈이 국경을 넘는 어떤 Laravel 앱에도 그대로 옮길 수 있는 service class 아키텍처를 갖게 됩니다.

전용 환율 API가 하드코딩된 값을 이기는 이유

config 파일에 환율을 박아두는 게 첫 번째 오답입니다. 매 요청마다 상위 API를 호출하는 게 두 번째 오답입니다. 잘 쓴 전용 통화 API는 고정값이 줄 수 없는 네 가지를 줍니다:

  • 즉시 신선도. 평일 시장 시간에 환율은 끊임없이 움직입니다. 고객에게 청구하는데 USD/JPY나 EUR/TRY 같은 변동성 큰 페어에서 24시간 묵은 환율은 1-2% 차이를 낼 수 있고, 그 정도면 SaaS 플랜의 마진이 사라집니다.
  • 넓은 커버리지. Finexly는 신흥국 통화와 CBDC 참조 환율을 포함해 170+ 통화를 다룹니다. Laravel Swap이 기본으로 묶고 오는 ECB 피드는 메이저 32종 정도. 아르헨티나 페소나 터키 리라로 결제하는 고객이 한 명만 있어도 차이가 납니다.
  • 하나의 계약. latest, historical, convert 모두 같은 JSON 형태. 세 개의 상위를 테이프로 붙일 필요가 없습니다.
  • 예측 가능한 레이트 리밋. 추론 가능한 문서화된 쿼터. "ECB가 폴링 과다로 우리 IP를 차단했다"가 아니라.

옵션 비교는 2026 무료 vs 유료 통화 API 비교ExchangeRate-API vs CurrencyLayer vs Finexly 에서 자세히 다뤘습니다.

프로젝트 셋업: Laravel 11 + Composer

Laravel 11 신규 설치를 가정합니다 (Laravel 10도 동일하게 동작합니다. 5단계에서 만지는 bootstrap/app.php만 다릅니다):

laravel new fx-converter
cd fx-converter
php artisan migrate

.env에 Finexly API 키를 추가:

FINEXLY_API_KEY=your_key_here
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USD

15분(900초) 캐시 TTL은 B2B 결제에 합리적인 기본값입니다. 소비자용 결제는 60-300초가 적절하고, 트레저리 대시보드는 3,600까지 늘릴 수 있습니다. 받아들일 수 있는 환율 변동 리스크에 맞춰 고르세요.

config/services.php에 블록 추가:

'finexly' => [
    'key' => env('FINEXLY_API_KEY'),
    'base' => env('FINEXLY_BASE_URL', 'https://finexly.com/api/v1'),
    'cache_ttl' => (int) env('FX_CACHE_TTL', 900),
    'default_base' => env('FX_DEFAULT_BASE', 'USD'),
],

키가 아직 없다면, Finexly 가입 — 무료 플랜으로 월 1,000 요청, 사이드 프로젝트나 작은 SaaS에 충분합니다.

ExchangeRateService 클래스 만들기

처음부터 끝까지 지킬 규칙: 컨트롤러, job, Blade 뷰는 절대 Finexly와 직접 통신하지 않는다. 모든 호출은 단일 service class를 거칩니다. 테스트에서 mock이 사소해지고, 프로바이더 교체나 fallback 로직 추가도 한 군데서 처리됩니다.

app/Services/ExchangeRateService.php를 만듭니다:

<?php

namespace App\Services;

use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;

class ExchangeRateService
{
    public function __construct(
        protected string $baseUrl,
        protected string $apiKey,
        protected int $cacheTtl,
        protected string $defaultBase,
    ) {}

    public function rate(string $from, string $to): float
    {
        $from = strtoupper($from);
        $to   = strtoupper($to);

        if ($from === $to) {
            return 1.0;
        }

        $rates = $this->latest($from);
        if (!isset($rates[$to])) {
            throw new RuntimeException("Unsupported currency pair: {$from}->{$to}");
        }

        return (float) $rates[$to];
    }

    public function convert(float $amount, string $from, string $to): float
    {
        return round($amount * $this->rate($from, $to), 4);
    }

    public function latest(string $base): array
    {
        $base = strtoupper($base ?: $this->defaultBase);
        $key  = "fx:latest:{$base}";

        return Cache::remember($key, $this->cacheTtl, function () use ($base) {
            $response = Http::withToken($this->apiKey)
                ->acceptJson()
                ->retry(2, 250, throw: false)
                ->timeout(8)
                ->get("{$this->baseUrl}/latest", ['base' => $base]);

            return $this->extractRates($response, $base);
        });
    }

    protected function extractRates(Response $response, string $base): array
    {
        if ($response->failed()) {
            Log::warning('Finexly fetch failed', [
                'base'   => $base,
                'status' => $response->status(),
                'body'   => $response->body(),
            ]);
            throw new RuntimeException("Exchange rate fetch failed: HTTP {$response->status()}");
        }

        $payload = $response->json();
        if (!is_array($payload['rates'] ?? null)) {
            throw new RuntimeException('Malformed exchange rate response');
        }

        return $payload['rates'];
    }
}

짚고 넘어갈 점 몇 가지. retry(2, 250)은 250ms 백오프로 자동 재시도 두 번 — 직접 재시도 코드 안 짜고 대다수 일시적 502/503을 흡수합니다. timeout(8)은 보수적입니다 — 5초 미만이면 대륙 간 호출에서 빡빡하고, 10초 초과면 불안정한 상위가 요청 지연 예산을 박살냅니다. withToken()은 Finexly가 기대하는 Authorization: Bearer ... 헤더를 붙입니다.

서비스 프로바이더 등록

app/Providers/AppServiceProvider.php에 바인딩해서 DI가 그냥 동작하도록:

use App\Services\ExchangeRateService;

public function register(): void
{
    $this->app->singleton(ExchangeRateService::class, function ($app) {
        $cfg = $app['config']['services.finexly'];
        return new ExchangeRateService(
            baseUrl: $cfg['base'],
            apiKey: $cfg['key'],
            cacheTtl: $cfg['cache_ttl'],
            defaultBase: $cfg['default_base'],
        );
    });
}

여기서 singleton이 맞습니다 — 클래스 안에 요청 단위 상태가 없고, singleton이면 같은 요청 내 호출에서 하위 HTTP 클라이언트가 연결을 재사용할 수 있습니다.

Laravel Cache로 캐싱 (Redis 권장)

latest() 안의 Cache::remember는 보이는 것보다 더 많은 일을 합니다. file 캐시 드라이버라도 동작은 정확하지만 만료 시 캐시 스탬피드가 Finexly에 연속 타격이 됩니다. Redis라면 두 플래그가 도움이 됩니다:

// .env
CACHE_STORE=redis
REDIS_CLIENT=phpredis

스탬피드를 절대 허용 안 되는 엔드포인트(예: 결제)에서는 Cache::lock으로 감쌉니다:

public function rateWithLock(string $from, string $to): float
{
    $lock = Cache::lock("fx:lock:{$from}:{$to}", 5);
    return $lock->block(3, fn () => $this->rate($from, $to));
}

상위 재호출은 한 프로세스만; 나머지는 캐시가 다시 채워질 때까지 최대 3초 대기. 스탬피드 + 재시도 패턴은 통화 API 캐싱과 에러 처리 가이드 에서 깊이 다뤘습니다.

Eloquent로 영속화

캐시는 핫 읽기용. 데이터베이스는 내구성 담당 — 캐시가 식었을 때(Redis 재시작, 스토어를 비우는 배포) 상위까지 왕복 없이 최근 환율을 쓸 수 있어야 합니다. 마이그레이션 생성:

php artisan make:model ExchangeRate -m

내용:

Schema::create('exchange_rates', function (Blueprint $table) {
    $table->id();
    $table->string('base', 3)->index();
    $table->string('quote', 3)->index();
    $table->decimal('rate', 18, 8);
    $table->date('quoted_on')->index();
    $table->timestamps();

    $table->unique(['base', 'quote', 'quoted_on']);
});

decimal(18,8) 정밀도가 중요합니다. 환율을 float으로 저장하면 JPY 페어에서 소수 pip이 잘려나가 규모가 커지면 눈에 보이는 정합 오차가 생깁니다. 8자리는 공시되는 어떤 크로스 환율도 담아냅니다.

모델:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class ExchangeRate extends Model
{
    protected $fillable = ['base', 'quote', 'rate', 'quoted_on'];
    protected $casts = [
        'rate' => 'decimal:8',
        'quoted_on' => 'date',
    ];

    public function scopeLatestFor($query, string $base, string $quote)
    {
        return $query->where('base', $base)
                     ->where('quote', $quote)
                     ->orderByDesc('quoted_on')
                     ->limit(1);
    }
}

응답을 테이블에 스냅샷하는 메서드를 서비스에 추가합니다 — 감사와 이력 분석에 유용:

public function snapshot(string $base): int
{
    $rates = $this->latest($base);
    $today = now()->toDateString();
    $rows = collect($rates)->map(fn ($v, $k) => [
        'base'       => $base,
        'quote'      => $k,
        'rate'       => $v,
        'quoted_on'  => $today,
        'created_at' => now(),
        'updated_at' => now(),
    ])->values()->all();

    \App\Models\ExchangeRate::upsert(
        $rows,
        ['base', 'quote', 'quoted_on'],
        ['rate', 'updated_at']
    );

    return count($rows);
}

upsert는 "넣거나 업데이트" 이중 왕복을 피합니다 — 단일 statement, 유니크 키에 대해 멱등, 아래 일일 갱신에 딱 맞습니다.

매일 갱신을 스케줄링

Laravel scheduler가 스냅샷을 둘 적절한 자리. routes/console.php (Laravel 11) 또는 app/Console/Kernel.php (Laravel 10):

use App\Services\ExchangeRateService;
use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    app(ExchangeRateService::class)->snapshot('USD');
    app(ExchangeRateService::class)->snapshot('EUR');
})
->dailyAt('16:30')
->timezone('Europe/Berlin')
->name('fx:snapshot')
->withoutOverlapping()
->onOneServer();

16:30 CET 윈도우는 16:00 CET의 ECB 참조 픽싱을 잡습니다 — 15분 여유로 어떤 프로바이더 지연이라도 흡수합니다. 워커가 둘 이상이면 onOneServer()가 필수입니다. 이게 없으면 수평 배포에서 스냅샷이 N번 돌면서 API 쿼터가 두 배로 빠집니다. 서버 cron도 잊지 말고:

* * * * * cd /var/www/fx-converter && php artisan schedule:run >> /dev/null 2>&1

통화 변환기 엔드포인트

라우트와 컨트롤러를 연결합니다. routes/web.php:

use App\Http\Controllers\ConverterController;

Route::get('/convert', [ConverterController::class, 'show'])->name('convert.show');
Route::post('/convert', [ConverterController::class, 'convert'])->name('convert.do');

컨트롤러:

namespace App\Http\Controllers;

use App\Rules\IsoCurrency;
use App\Services\ExchangeRateService;
use Illuminate\Http\Request;

class ConverterController extends Controller
{
    public function show()
    {
        return view('convert');
    }

    public function convert(Request $request, ExchangeRateService $fx)
    {
        $data = $request->validate([
            'amount' => ['required', 'numeric', 'min:0', 'max:1000000000'],
            'from'   => ['required', new IsoCurrency()],
            'to'     => ['required', new IsoCurrency()],
        ]);

        $converted = $fx->convert(
            (float) $data['amount'],
            $data['from'],
            $data['to'],
        );

        return view('convert', [
            'result' => [
                'from'      => strtoupper($data['from']),
                'to'        => strtoupper($data['to']),
                'amount'    => $data['amount'],
                'converted' => $converted,
                'rate'      => $fx->rate($data['from'], $data['to']),
                'as_of'     => now()->toIso8601String(),
            ],
        ]);
    }
}

최소 Blade 뷰 resources/views/convert.blade.php:

<form method="post" action="{{ route('convert.do') }}">
    @csrf
    <input name="amount" type="number" step="0.01" required>
    <input name="from" maxlength="3" placeholder="USD" required>
    <input name="to" maxlength="3" placeholder="EUR" required>
    <button type="submit">변환</button>
</form>

@isset($result)
    <p>{{ $result['amount'] }} {{ $result['from'] }} =
       {{ $result['converted'] }} {{ $result['to'] }}
       (환율 {{ $result['rate'] }}, 기준 {{ $result['as_of'] }})</p>
@endisset

40줄 Blade와 컨트롤러 하나 — 사용자 측 변환기 끝. 잘 마감된 호스팅 버전이 필요하다면 finexly.com의 통화 변환기가 같은 엔드포인트 위에 만들어져 있습니다.

ISO 4217 커스텀 검증 규칙

'currencies' => 'in:USD,EUR,GBP,...' 식 수동 검증은 코드 열 개까지는 견디고 170개에서 무너집니다. 제대로 된 규칙을 생성:

php artisan make:rule IsoCurrency
namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class IsoCurrency implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (!is_string($value) || !preg_match('/^[A-Z]{3}$/', strtoupper($value))) {
            $fail("The :attribute must be a valid 3-letter ISO 4217 currency code.");
        }
    }
}

더 엄격하게 하고 싶다면, 라이브 심볼 목록을 캐시:

$symbols = Cache::remember('fx:symbols', 86400, function () use ($fx) {
    return array_keys($fx->latest('USD'));
});

규칙 안에서 in_array(strtoupper($value), $symbols, true). 표준의 완전 레퍼런스는 ISO 4217 통화 코드 가이드 에 있습니다.

API 오류, 재시도, 레이트 리밋 처리

서비스의 retry/timeout이 일시적 흔들림을 막습니다. 지속적인 장애에는 fallback 체인: cache → DB → upstream. latest()를 다시 정렬:

public function latest(string $base): array
{
    $key = "fx:latest:{$base}";

    if ($cached = Cache::get($key)) {
        return $cached;
    }

    try {
        $rates = $this->fetchUpstream($base);
        Cache::put($key, $rates, $this->cacheTtl);
        return $rates;
    } catch (\Throwable $e) {
        Log::warning('Upstream FX failed, falling back to DB', ['error' => $e->getMessage()]);
        $rates = \App\Models\ExchangeRate::query()
            ->where('base', $base)
            ->where('quoted_on', '>=', now()->subDays(7))
            ->orderByDesc('quoted_on')
            ->get()
            ->groupBy('quote')
            ->map->first()
            ->mapWithKeys(fn ($r, $k) => [$k => (float) $r->rate])
            ->all();

        if (empty($rates)) {
            throw $e;
        }
        return $rates;
    }
}

Finexly가 한 시간 동안 503을 줘도 결제는 DB의 가장 최근 스냅샷으로 계속 동작합니다. 레이트 리밋에 여유를 주는 요금제와 짝을 맞추세요 — 95% 쿼터에 머무는 건 트래픽 스파이크에 throttle이 429로 변하라고 부르는 것과 같습니다.

밀리초가 중요한 앱 — 트레이딩 대시보드 같은 — 에 스냅샷 모델은 느립니다. 스트리밍으로 가세요. 트레이드오프는 통화 변환을 위한 REST vs WebSocket 에서 비교했습니다.

Pest와 Http::fake()로 테스트

테스트는 라이브 API를 절대 건드리지 말아야 합니다. Http::fake()가 그걸 사소하게 만듭니다. tests/Feature/ExchangeRateServiceTest.php:

use App\Services\ExchangeRateService;
use Illuminate\Support\Facades\Http;

it('converts USD to EUR using cached rates', function () {
    Http::fake([
        '*finexly.com/api/v1/latest*' => Http::response([
            'base'  => 'USD',
            'rates' => ['EUR' => 0.92, 'GBP' => 0.79, 'JPY' => 154.30],
        ], 200),
    ]);

    $fx = app(ExchangeRateService::class);
    expect($fx->convert(100, 'USD', 'EUR'))->toBe(92.0);
    expect($fx->rate('USD', 'JPY'))->toBe(154.30);
});

it('throws on unsupported pair', function () {
    Http::fake([
        '*' => Http::response(['base' => 'USD', 'rates' => ['EUR' => 0.92]], 200),
    ]);

    $fx = app(ExchangeRateService::class);
    $fx->convert(100, 'USD', 'XXX');
})->throws(RuntimeException::class, 'Unsupported currency pair');

it('falls back to database when upstream fails', function () {
    \App\Models\ExchangeRate::factory()->create([
        'base' => 'USD', 'quote' => 'EUR', 'rate' => 0.91, 'quoted_on' => now(),
    ]);
    Http::fake(['*' => Http::response('boom', 503)]);

    $fx = app(ExchangeRateService::class);
    expect($fx->rate('USD', 'EUR'))->toBe(0.91);
});

php artisan test로 실행. 세 테스트가 happy path, 검증 경로, fallback을 모두 커버 — 네트워크 호출은 0건.

배포 전 프로덕션 체크리스트

첫 배포에서 자주 잊는 것들 짧게:

  • 스냅샷을 큐에. scheduler 호출을 dispatch(new RefreshRatesJob('USD'))->onQueue('fx')로 감싸 느린 상위가 scheduler를 막지 않게.
  • 4xx와 5xx를 따로 모니터링. 401은 키 회전, 429는 더 큰 플랜 필요, 503은 상위 다운. 알람 세 종류.
  • User-Agent 고정. 부하 시 익명 트래픽이 가장 먼저 우선순위 강등됩니다.
  • API 키를 로그에 남기지 마세요. config/logging.php의 마스킹 목록에 FINEXLY_API_KEY 추가.
  • 서킷 브레이커. 60초 안에 연속 5번 실패 → 상위 호출 중단, 캐시만 제공, 온콜 통보.
  • 주말도 스냅샷. 대부분 프로바이더가 주말에 환율을 동결하지만, DB에 토/일 행이 있으면 월말 보고가 쉬워집니다.

자주 묻는 질문

이 튜토리얼은 어떤 Laravel 버전을 대상으로? Laravel 11. Laravel 10에서도 코드 그대로 동작합니다. 차이는 예약 작업을 등록하는 위치(10은 app/Console/Kernel.php, 11은 routes/console.php). Laravel 12(2025년 3월 출시)도 완전 호환.

Lumen에서도 사용 가능? service class는 Laravel 의존이 없어 그대로 이식됩니다. Lumen은 프로바이더 자동 발견을 안 하므로 바인딩을 수동 등록해야 하고, scheduler API가 약간 달라 Lumen\Framework\Console\Scheduling\Schedule를 사용합니다.

환율을 얼마나 자주 갱신해야 하나? B2B 결제는 16:00 CET ECB 픽싱 후 하루 한 번이 업계 표준. 소비자 결제는 시장 시간 중 5-15분. 트레이딩이나 트레저리 대시보드는 실시간 WebSocket — REST 폴링은 항상 늦습니다. 트레이드오프는 무료 환율 API 가이드 에 있습니다.

상위 API가 다운되면? 구축한 fallback 체인으로, TTL 만료까지는 캐시가 마지막 환율을 제공하고, 이후 7일 이내라면 DB가 가장 최근 스냅샷을 제공하며, 그 다음에야 요청이 실패합니다. 실제로는 같은 날의 어떤 장애도 결제는 살아남습니다.

튜토리얼을 따라가려면 유료 플랜이 필요한가? 아니요. Finexly의 무료 티어 — 월 1,000 요청, 카드 없음 — 으로 모든 단계 진행 가능. 15분 TTL이면 쿼터에 닿기 전 일일 약 30,000 페이지뷰를 지원합니다.


Laravel 앱에 라이브 환율을 꽂을 준비가 됐나요? Finexly 무료 API 키 발급 — 월 1,000 요청, 카드 없음, 30초. 무료 티어를 넘으면 요금제는 월 9달러부터 시작해 엔터프라이즈까지 확장. 결정 전에 통화 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 →

이 기사 공유하기