Laravel 환율 서비스: 다중 통화 환불 분쟁을 해결한 방법

발행: (2026년 6월 10일 PM 06:09 GMT+9)
12 분 소요
원문: Dev.to

출처: Dev.to

“102.30을 지불했는데 99.85만 돌려주셨나요? 환불이 아니라 벌금이네요.”

Alex는 우리 크로스보더 플랫폼에서 스니커즈를 주문했습니다 — ¥16,500, 결제 시점에 €102.30에 해당했습니다. 며칠 뒤 사이즈가 맞지 않아 환불을 요청했죠. 운영 측면에서는 모든 것이 순조롭게 진행됐습니다… 하지만 돈이 카드에 입금되면서 문제가 발생했습니다.

우리는 ¥16,500을 다시 돌려줬지만, 구매 시점 이후 엔‑유로 환율이 변동했습니다. 그는 €99.85만 받았고, 이는 지불한 금액보다 €2.45 적은 금액이었습니다. 근본 원인은 계산 오류가 아니라 우리의 순진한 환율 처리 방식이었습니다: 환불 시점에 현재 환율을 사용했을 뿐, 원래 결제 시점의 환율을 사용하지 않았던 것이었습니다.

실제 문제: 변동하는 환율

우리 플랫폼은 유럽 구매자와 일본 스니커즈 판매자를 연결하는 마켓플레이스를 구동하는 Laravel 모놀리쓰입니다. 주문 금액은 판매자의 현지 통화인 엔으로 기록됩니다. 하지만 고객에게는 선호하는 통화(유로, 달러 등)로 청구하고, 모든 재무 보고는 또 다른 기준 통화로 이루어집니다. 즉, 모든 금전 이동 작업이 환율 경계를 넘게 되는 것이었습니다.

구매 시에는 외부 제공자에게서 실시간 환율을 받아 총액을 변환하고 고객에게 청구했습니다. 주문 레코드에는 최종 변환 금액과 통화만 저장되었고, 환율 자체는 저장되지 않았습니다. 환불이 발생하면 시스템은 다시 현재 환율을 가져와 고객 통화로 환불 금액을 계산했습니다. 구매와 환불 사이에 엔이 유로 대비 약세를 보이면 고객이 손해를 보게 되고, 강세를 보이면 우리가 손해를 보게 됩니다. 어느 쪽이든 불만이 생기는 것이었습니다.

돌이켜 보면 해결책은 명확했습니다: 주문 생성 시점에 환율을 고정하고, 이후 모든 재무 이벤트(환불, 부분 환불, 청구 취소 등)에서 정확히 같은 환율을 재사용해야 했습니다.

실제로 동작하는 Laravel 환율 서비스 구축

우리는 OrderExchangeRate 모델과 전용 서비스 클래스를 도입해 환율 캡처와 조회를 담당하도록 했습니다. 서비스의 역할은 간단합니다:

  • 주문이 생성될 때 실시간 환율을 가져와 주문과 함께 저장한다.
  • 환불이 시작될 때 저장된 환율을 조회한다 — API를 다시 호출하지 않는다.
  • 환율이 없을 경우(발생해서는 안 됨) 우아하게 대체 로직을 수행한다.

서비스 핵심 코드는 다음과 같습니다:

namespace App\Services;

use App\Models\OrderExchangeRate;
use App\Services\ExchangeRateProvider;
use Illuminate\Support\Facades\Log;

class ExchangeRateService
{

public function captureRateForOrder(int $orderId, string $from, string $to): void

{

try {

$rate = app(ExchangeRateProvider::class)->getRate($from, $to);

} catch (\Throwable $e) {

Log::error('Failed to fetch rate for order', [

'order_id' => $orderId,

'exception' => $e->getMessage(),

]);

throw $e;

}

OrderExchangeRate::create([

'order_id' => $orderId,

'from_currency' => $from,

'to_currency' => $to,

'rate' => $rate,

'captured_at' => now(),

]);

}
}

ExchangeRateProvider는 실제 환율 API(Fixer를 사용했으며, 비용 절감을 위해 이후 ECB 피드로 교체)와의 얇은 래퍼일 뿐입니다. 중요한 점은 환율을 한 번만 정확히 캡처해 불변하게 저장한다는 것입니다. 이렇게 하면 환율이 떠돌지 않습니다.

환불이 시작될 때는 저장된 환율을 조회합니다:

public function getRateForOrder(int $orderId): float
{
    $rate = OrderExchangeRate::where('order_id', $orderId)
        ->latest('captured_at')
        ->value('rate');

    if (!$rate) {
        Log::warning('No stored rate found for order, falling back', [
            'order_id' => $orderId,
        ]);

        // 비상 대체 – 실시간 환율을 가져오지만 환불 계산에는 사용하지 않음
        // 이 경로는 극히 드물어야 하며 알림을 트리거해야 함
        return app(ExchangeRateProvider::class)->getRate('JPY', 'EUR');
    }

    return $rate;
}

대체 로직은 의도적으로 마지막 수단으로 남겨두었습니다. 실제 운영에서는 주문 생성과 같은 DB 트랜잭션 안에서 캡처가 이루어지기 때문에 이 경로에 도달하지 않았습니다. 하지만 한 번은 배포 스크립트 오류로 일부 환율 레코드가 사라지는 상황이 있었고, 로그와 알림 덕분에 영향을 받은 주문을 정확히 파악해 고객에게 문제가 발생하기 전에 수동으로 환불을 재계산할 수 있었습니다.

캐싱에 대한 메모

많은 사람들이 레디스에 환율을 캐시해 매 주문마다 API 호출을 피하려 합니다. 우리도 그렇게 했지만, 5분 캐시 윈도우가 있으면 몇 초 차이로 들어온 두 주문이 미묘하게 다른 환율을 사용하게 됩니다. 이는 화면 표시용으로는 괜찮지만, 금전 거래에서는 각 주문마다 정확한 시점의 스냅샷이 필요합니다. 그래서 우리는 주문당 환율 캡처를 유지하고, 주문 생성 페이지의 통화 변환 미리보기에만 짧은 수명의 캐시를 사용했습니다. 실제 결제 시점에는 환율을 새로 받아 저장합니다.

나중에 검토한 프로젝트인 Taocarts(크로스보더 전자상거래 SaaS)에서도 동일하게 처리합니다. 각 주문이 자체 환율을 가지고, 환불 시 항상 원본 환율을 참조합니다. 교훈은 어디서든 동일합니다: 환율은 전역 변수로 취급하면 안 됩니다.

영향: 화난 티켓에서 지루한 장부까지

환율 서비스를 도입한 뒤 환불 관련 분쟁 건수는 사실상 0에 가까워졌습니다. 도입 전에는 매달 3~5건의 환불 금액 오류 불만이 있었지만, 도입 후 8개월 동안 1건만 발생했으며, 그 역시 환율 문제가 아니라 결제 게이트웨이 콜백 재시도로 인한 이중 청구였습니다.

회계 측면에서도 큰 개선이 있었습니다. 모든 주문에 알려진 환율이 고정되면서, 재무팀은 월말에 통화 변환 차이를 맞추느라 몇 시간을 소비하던 일을 없앨 수 있었습니다. “장부가 맞지 않는다”는 회의는 10분 정도의 점검으로 줄어들었고, 며칠 걸리던 조사 작업은 사라졌습니다.

교훈 (힘들게 얻은)

  • 이미 이동한 금액에 실시간 환율을 사용하지 말라. 결제가 포착되는 순간 환율은 거래의 DNA가 됩니다. 주문 레코드에 저장하는 비용은 저렴하지만, 고객 신뢰는 그렇지 않습니다.
  • 환율을 저장하라, 변환된 금액만 저장하지 말라. 최종 금액만 저장하면 부분 환불 시 올바른 환율을 역산할 수 없으며, 고객에게 투명성을 제공할 수 없습니다.
  • 대체 로직은 위험하지만 필요하다. 불가능한 상황을 설계하라. 환율 레코드가 없다는 상황은 존재해서는 안 되지만, 발생한다면 명확한 로그와 알림이 자동 “수정”보다 훨씬 가치 있습니다.
  • 환율 서비스는 복잡할 필요가 없다. 모델 하나, 메서드 두 개, DB 컬럼 하나면 충분합니다. 복잡함은 코드에 있는 것이 아니라, 처음에 이 구조가 필요하다는 사실을 깨닫는 데 있습니다.

돌이켜 보면, 이 모든 작업을 촉발한 €2.45는 최고의 예상치 못한 투자였습니다. Alex는 시스템을 고치는 동안 수동으로 전액 환불을 받았고, 결국 우리 고객으로 남았습니다. 그때 깨달은 점은 €2.45의 반올림 오류가 고객을 잃을 뻔했으며, 이를 해결하는 비용이 어떤 광고 캠페인보다 저렴했다는 것입니다.

요즘 우리 환불 분쟁은 거의 제로에 가깝습니다 — 8개월 동안 한 건, 그리고 그것도 게이트웨이 재시도였죠. 모델 하나, 메서드 두 개, DB 컬럼 하나면 충분합니다.

핵심 요점: 환율은 한 번 캡처하고 그 스냅샷을 신뢰하라. 여러분도 비슷한 환불 문제를 겪어본 적 있나요?

설명: 주문 생성 시 환율을 저장하면 다중 통화 환불

0 조회
Back to Blog

관련 글

더 보기 »

Eidentic 소개

Today we're releasing Eidentic, an open-source TypeScript SDK for building AI agents with self-improving memory and the production fundamentals built in — not b...

Typescript의 타입

Introdução Tipos são uma forma de definir a “forma” ou o contrato dos dados que estamos usando no código. Pensando em Javascript puro, ele é dinâmico: você pode...