블로그로 돌아가기

국경 간 급여 통화 변환: 글로벌 급여를 위한 실시간 FX 개발자 가이드(2026)

V
Vlado Grigirov
May 14, 2026
Currency API Exchange Rates Payroll Cross-Border Payments Developer Guide Fintech Finexly

2026년에 급여 소프트웨어를 개발한다면, 당신은 더 이상 단일 국가용 도구를 만들고 있지 않다. Deel, Remote, Rippling 그리고 수직 HRIS 플랫폼의 긴 꼬리는 매달 90개국 이상의 계약자와 직원에게 급여를 송금하고 있고, 모든 지급에는 똑같은 지루하지만 비싼 질문이 숨어 있다 — 어떤 환율을 쓸지, 언제 락할지, 그리고 직원(그리고 감사인)에게 우리가 옳게 했음을 어떻게 증명할지. 국경 간 급여 통화 변환은 백오피스 디테일처럼 들리지만, 월급 $4,000에서 50 베이시스포인트 빗나가면 그 직원은 매달 $20씩 — 매달 — 잃고, 금요일이면 지원함이 터진다는 것을 깨달을 때까진 그렇다.

이 가이드는 여러 통화로 정산하는 급여, 계약자 지급, EOR 또는 HRIS 시스템을 만드는 엔지니어를 위한 것이다. 중요한 아키텍처 결정들 — 환율 출처, 락 날짜, 미드마켓 대 스프레드, 멱등성, 감사 추적, 반올림 — 을 차례로 살펴보고 각각에 대해 Finexly API를 써서 Node.js, Python, PHP로 프로덕션 코드를 짠다. 끝낼 때쯤에는 SOC 2 리뷰와 USD/JPY가 하룻밤 사이 1.5% 움직인 뒤 새벽 2시의 Slack 핑 모두를 견뎌내는 급여 FX 레이어를 갖게 된다.

급여 FX가 보이는 것보다 어려운 이유

급여 통화 변환에 대한 순진한 접근은 한 줄짜리 코드다: amount_local = amount_usd * rate. 마케팅 페이지의 환율 변환기에는 충분하다. 급여에는 부족하다, 한꺼번에 물어뜯는 여섯 가지 이유 때문에:

  1. 환율은 재현 가능해야 한다. 직원이나 감사인이 "왜 3월 급여가 ¥609,118가 아니라 ¥608,243인가?"라고 물을 때, 특정한 환율, 타임스탬프, 출처를 가리킬 수 있어야 한다. "지급 시점에 Stripe가 호가한 것"은 감사를 통과하는 답이 아니다.
  2. 락 날짜는 정책 결정이지 버그가 아니다. 실행일? 기간 말? 월별 급여라면 25일? 각 선택은 FX 리스크, 직원 예측 가능성, 세무 보고에 다른 영향을 준다. 코드는 CFO가 고른 정책을 인코딩하고 배포 없이 바꿀 수 있게 해야 한다.
  3. 미드마켓과 "지급 환율"은 다른 것이다. 미드마켓 환율은 bid와 ask 사이의 중간점 — Google이나 Bloomberg가 보여주는 것. SWIFT, 로컬 레일, 스테이블코인 브리지를 통해 실제로 돈을 옮기는 환율에는 항상 스프레드가 붙는다. 미드마켓 참조 환율을 명확히 보여주고 결제 제공자가 실제로 쓴 환율을 별도로 추적해야 정산이 맞는다.
  4. 멱등성은 중요하다. 급여 실행은 재시도된다 — 잡 타임아웃, 큐 재전달, 운영자가 두 번 클릭. FX 조회가 (직원, 기간)에 대해 멱등하지 않으면, 재시도마다 다른 환율이 호가되고 grossto-net이 달라진다.
  5. 주말, 공휴일, 관할 규칙. FX 시장은 뉴욕 금요일 늦은 시각부터 시드니 일요일까지 휴장한다; 급여 실행은 그렇지 않다. 순진한 코드는 조용히 오래된 캐시를 쓴다. 그리고 일부 관할(브라질 PTAX, 아르헨티나 BCRA, 인도 RBI 비거주자용)은 미드마켓을 덮어쓰는 중앙은행 참조 환율을 요구한다 — 환율 레이어는 관할별 오버라이드를 지원해야 한다.

이 여섯을 잘하면 급여 FX 레이어가 된다. 하나만 실패해도 사고 대기 중인 상태다.

참조 아키텍처

아래 예제는 경계가 엄격한 세 개 레이어를 가정한다:

레이어 1 — 환율 레이어. 실시간 제공자(Finexly)에서 미드마켓 환율을 가져와 캐시하고, 감사 추적용으로 하루 한 번 스냅샷한다. 플랫폼의 다른 어떤 것도 FX 제공자와 직접 대화하지 않는다.

레이어 2 — FX 정책. (직원, 기간, 원본 금액, 원본 통화, 대상 통화, 정책)을 받아 (변환된 금액, 환율, 타임스탬프, 출처)를 반환하는 순수 함수. "25일에 락" 또는 "BRL은 중앙은행 참조 사용"을 인코딩한다. 레이어 1을 호출하지, 제공자는 호출하지 않는다.

레이어 3 — 결제 실행. 돈을 옮기는 무엇이든(Stripe Connect, Wise Platform, 은행 레일, 스테이블코인 브리지). 제공자가 실제로 정산한 환율을 보고하고, 레이어 2의 참조 환율 옆 감사 테이블에 기록한다.

이 세 레이어 분리는 국가를 늘려도 코드베이스가 유지보수 가능하게 하는 가장 큰 결정이다 — 그리고 레이어 1을 고정 환율로 스텁할 수 있으므로 테스트도 다루기 쉬워진다.

환율 출처 선택: 감사 추적이 있는 미드마켓

레이어 1에는 미드마켓이 필요하다 — bid와 ask 사이의 중간점, 최소 분당 새로고침, 날짜로 과거 환율을 조회 가능. 이것이 그 외 모든 것에 깨끗한 참조가 된다.

Finexly는 주요 유동성 제공자에서 집계된 미드마켓 환율을 라이브와 과거 엔드포인트로 반환한다. 모든 게 연결됐는지 확인하는 첫 호출:

curl "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY,INR,BRL,PHP,MXN" \
  -H "Authorization: Bearer YOUR_API_KEY"

rates, base, timestamp가 들어 있는 JSON 페이로드를 받게 된다. 급여에서 중요한 두 필드는 timestamp(스냅샷이 찍힌 UTC 순간)와 개별 통화 값이다. 항상 둘 다 로깅 — 환율만이 아니라.

환율 제공자 선택에 대한 더 많은 맥락은 무료 vs 유료 currency API 비교Finexly vs Open Exchange Rates vs Fixer 비교 참조.

환율 락하기: 구현할 가치가 있는 세 가지 정책

"언제 락하는가"의 결정은 급여 FX의 핵심이다. 세 가지 정책이 거의 모든 실제 고객을 커버한다:

정책 A — 급여 실행일에 락. 간단하고 방어 가능하고 급여 명세서에서 설명하기 쉽다. 같은 날 환율; Run을 누르는 순간 시장이 보여주는 것이 직원이 보는 것. 계약자형 지급에 가장 좋은 기본값.

정책 B — 월의 고정일에 락. 월별 급여 고객은 25일에 락하고 싶을 수 있다 — 그러면 명세서는 27일까지 미리 생성 가능, 결제는 1일에 이루어진다. 직원 경험에서 실행일 변동성을 제거한다.

정책 C — 기간 평균. 긴 기간(반월, 월) 일부 고객은 기간 동안의 미드마켓 환율 평균을 선호한다. 변동성을 매끄럽게 하지만 윈도우 내 모든 영업일에 대해 과거 환율을 조회해야 한다.

세 가지 모두에 대한 TypeScript 구현. 정책 로직이 분명히 보이도록 레이어 1 호출은 rateService.getRate(...)로 스텁했다:

// Layer 2: payroll FX policy
type Policy = "run_date" | "fixed_day" | "period_avg";

interface LockedRate {
  rate: number;
  source: "finexly_mid";
  policy: Policy;
  policyInputs: Record<string, string | number>;
  lockedAt: string;        // ISO8601 UTC
  rateTimestamp: string;   // ISO8601 UTC, from provider
}

async function lockPayrollRate(
  base: string,
  quote: string,
  payPeriodStart: Date,
  payPeriodEnd: Date,
  payrollRunAt: Date,
  policy: Policy,
  fixedDay: number = 25
): Promise<LockedRate> {
  switch (policy) {
    case "run_date": {
      const r = await rateService.getRate(base, quote, payrollRunAt);
      return {
        rate: r.rate,
        source: "finexly_mid",
        policy,
        policyInputs: { runAt: payrollRunAt.toISOString() },
        lockedAt: new Date().toISOString(),
        rateTimestamp: r.timestamp,
      };
    }
    case "fixed_day": {
      const lockDate = new Date(payPeriodEnd);
      lockDate.setUTCDate(fixedDay);
      // If the fixed day is a weekend, snap back to Friday
      const snapped = snapToBusinessDay(lockDate);
      const r = await rateService.getRate(base, quote, snapped);
      return {
        rate: r.rate,
        source: "finexly_mid",
        policy,
        policyInputs: { fixedDay, snappedTo: snapped.toISOString() },
        lockedAt: new Date().toISOString(),
        rateTimestamp: r.timestamp,
      };
    }
    case "period_avg": {
      const days = businessDaysBetween(payPeriodStart, payPeriodEnd);
      const rates = await Promise.all(
        days.map(d => rateService.getRate(base, quote, d))
      );
      const avg = rates.reduce((s, r) => s + r.rate, 0) / rates.length;
      return {
        rate: avg,
        source: "finexly_mid",
        policy,
        policyInputs: {
          start: payPeriodStart.toISOString(),
          end: payPeriodEnd.toISOString(),
          dayCount: rates.length,
        },
        lockedAt: new Date().toISOString(),
        rateTimestamp: rates[rates.length - 1].timestamp,
      };
    }
  }
}

반환된 LockedRate 객체의 형태는 급여 엔진의 나머지 부분과의 계약이다. 다운스트림의 모든 계산 — gross-to-net, 원천징수, 표시되는 명세서 금액, 내보낸 결제 파일 — 은 그 단일 락된 환율을 참조한다. 절대 다시 호가하지 말 것.

Finexly에서 과거 환율 조회(Python)

정책 B와 C는 과거 환율이 필요하다 — Finexly의 /historical 엔드포인트는 ISO 날짜를 받는다. 재시도, 백오프, 멱등 캐싱이 있는 정책 C 파이썬 구현:

import os
import time
import json
import hashlib
import requests
from datetime import date, timedelta
from typing import List
import redis

API_KEY = os.environ["FINEXLY_API_KEY"]
BASE_URL = "https://api.finexly.com/v1"
r = redis.from_url(os.environ["REDIS_URL"])

def _cache_key(base: str, quote: str, on: date) -> str:
    return f"fx:{base}:{quote}:{on.isoformat()}"

def get_historical_rate(base: str, quote: str, on: date) -> dict:
    """Return mid-market rate for a base/quote pair on a given UTC date."""
    key = _cache_key(base, quote, on)
    cached = r.get(key)
    if cached:
        return json.loads(cached)

    url = f"{BASE_URL}/historical"
    params = {"base": base, "symbols": quote, "date": on.isoformat()}
    headers = {"Authorization": f"Bearer {API_KEY}"}

    for attempt in range(4):
        try:
            resp = requests.get(url, params=params, headers=headers, timeout=10)
            resp.raise_for_status()
            data = resp.json()
            payload = {
                "base": data["base"],
                "quote": quote,
                "rate": data["rates"][quote],
                "timestamp": data["timestamp"],
                "date": on.isoformat(),
            }
            # Historical rates are immutable — cache them for 30 days
            r.setex(key, 30 * 24 * 3600, json.dumps(payload))
            return payload
        except (requests.HTTPError, requests.ConnectionError, requests.Timeout):
            if attempt == 3:
                raise
            time.sleep(2 ** attempt)

def business_days(start: date, end: date) -> List[date]:
    days, cur = [], start
    while cur <= end:
        if cur.weekday() < 5:  # Mon-Fri
            days.append(cur)
        cur += timedelta(days=1)
    return days

def period_average_rate(base: str, quote: str, start: date, end: date) -> dict:
    days = business_days(start, end)
    if not days:
        raise ValueError("No business days in period")
    rates = [get_historical_rate(base, quote, d)["rate"] for d in days]
    avg = sum(rates) / len(rates)
    return {
        "base": base,
        "quote": quote,
        "rate": avg,
        "policy": "period_avg",
        "day_count": len(days),
        "first_day": days[0].isoformat(),
        "last_day": days[-1].isoformat(),
    }

코드에서 명백하지 않지만 중요한 두 가지. 첫째, 과거 환율은 불변이다 — Finexly의 2026-04-03 USD/EUR 환율은 영원히 같다 — 그래서 30일 캐시는 안전하고 반복 계약자가 있는 어떤 급여 시스템에서도 호출량을 95% 이상 줄인다. 둘째, 재시도 루프는 지수 백오프를 쓰는데, 급여 배치는 보통 수천 고객에서 같은 일요일 밤 윈도우에 실행되고 FX 제공자는 공유 자원이기 때문이다.

캐싱과 오류 처리 패턴은 currency API 캐싱 및 오류 처리 가이드 참조.

멱등성과 감사 추적(PHP)

급여 FX 레이어가 할 수 있는 가장 과소평가된 일은 락된 환율을 멱등 키에 대해 저장해서 재시도된 급여 실행이 같은 환율을 재사용하도록 하는 것이다. Postgres가 뒷받침하는 멱등 서비스로 Finexly를 감싼 PHP 구현:

<?php
declare(strict_types=1);

class PayrollFx {
    public function __construct(
        private \PDO $db,
        private string $apiKey,
        private string $baseUrl = "https://api.finexly.com/v1"
    ) {}

    public function lockOnRunDate(
        string $idempotencyKey,
        string $base,
        string $quote,
        \DateTimeImmutable $runAt
    ): array {
        // 1. Have we already locked this key?
        $stmt = $this->db->prepare(
            "SELECT rate, rate_timestamp, source FROM payroll_fx_locks
             WHERE idempotency_key = :k"
        );
        $stmt->execute([":k" => $idempotencyKey]);
        $existing = $stmt->fetch(\PDO::FETCH_ASSOC);
        if ($existing) {
            return $existing + ["replay" => true];
        }

        // 2. Fetch fresh mid-market from Finexly
        $url = sprintf(
            "%s/latest?base=%s&symbols=%s",
            $this->baseUrl, urlencode($base), urlencode($quote)
        );
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => ["Authorization: Bearer " . $this->apiKey],
            CURLOPT_TIMEOUT => 10,
        ]);
        $body = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($code !== 200) {
            throw new \RuntimeException("Finexly returned $code");
        }
        $data = json_decode($body, true);
        $rate = $data["rates"][$quote] ?? null;
        if ($rate === null) {
            throw new \RuntimeException("Missing $quote in response");
        }

        // 3. Persist atomically (UNIQUE on idempotency_key)
        $ins = $this->db->prepare(
            "INSERT INTO payroll_fx_locks
                (idempotency_key, base_currency, quote_currency, rate,
                 rate_timestamp, source, locked_at, run_at)
             VALUES (:k, :b, :q, :r, :rt, 'finexly_mid', NOW(), :ra)
             ON CONFLICT (idempotency_key) DO NOTHING
             RETURNING rate, rate_timestamp, source"
        );
        $ins->execute([
            ":k" => $idempotencyKey,
            ":b" => $base,
            ":q" => $quote,
            ":r" => $rate,
            ":rt" => $data["timestamp"],
            ":ra" => $runAt->format("c"),
        ]);
        $row = $ins->fetch(\PDO::FETCH_ASSOC);
        if (!$row) {
            // Race: another worker won. Re-read.
            $stmt->execute([":k" => $idempotencyKey]);
            $row = $stmt->fetch(\PDO::FETCH_ASSOC);
        }
        return $row + ["replay" => false];
    }
}

자연스러운 멱등 키는 payroll_run_id:employee_id:base:quote — 한 실행 내 충돌은 불가능하고, 재시도(큐 재전달이든 운영자가 두 번 클릭하든)는 두 번째 API 호출 없이 정확히 같은 환율을 받는다.

PHP 통합 패턴은 currency API PHP 통합 가이드에 더 있다(전자상거래에 적용된 같은 형태).

주말, 공휴일, 휴장 시장 다루기

FX 시장은 뉴욕 금요일 오후 5시부터 시드니 일요일 오후 5시까지 휴장. 그 윈도우 안의 세 가지 합리적 정책: 마지막 종가로 롤백(급여의 올바른 기본값 — 안정적이고 설명 가능), 다음 개장으로 롤 포워드(미리보기), 또는 호가 거부(일회성 대규모 송금).

Finexly 과거 엔드포인트는 자동으로 가장 최근 영업일로 스냅한다 — 토요일 날짜를 조회하면 금요일 종가를 받고 타임스탬프는 금요일을 가리킨다. 항상 반환된 timestamp를 믿으라, 요청한 날짜가 아니라. 로컬 은행 휴일(브라질 카니발, 인도 디왈리, 중국 설)은 자체 테이블이 필요하다 — FX 시장은 열려 있어도 도착 레일은 닫혀 있을 수 있으니, 자금 도착일은 별도로 플래그할 것.

반올림, 표시, 명세서 산술

환율을 얻은 뒤에도 산술은 옳아야 한다. 지원 티켓을 예방하는 세 규칙:

  1. 전체 정밀도로 곱하고 마지막에 한 번만 반올림. 최소 10자리 유효숫자의 decimal 타입으로 amount_local = amount_usd * rate를 계산하고, 대상 통화의 ISO 4217 부단위로 반올림. JPY는 0자리; USD/EUR는 2자리; KWD/BHD는 3자리; CLF는 4자리.
  2. Half-to-even(은행원 반올림) 사용. 한 사이클에 수천 장의 명세서를 처리할 때 누적 편향을 줄인다. JavaScript의 기본 Math.round는 half-away-from-zero — decimal 라이브러리(decimal.js, bignumber.js)를 쓰라.
  3. 환율을 충분한 정밀도로 표시. 명세서에 최소 6자리 유효숫자로 표시(0.911234이지 0.91이 아니다). 환율을 계산기에 복사하는 직원이 현지 금액을 센트 단위까지 재현할 수 있어야 한다.

엔드 투 엔드 예제

미국 핀테크가 매월 계약자에게 지급한다. 멕시코시티의 Maria는 월 USD 4,800에 계약되어 있다. 고객 정책: "25일 락, 기간 말 정렬, 미드마켓 참조, 1일 Stripe Connect로 정산."

2026-04-25에 급여 FX 레이어가 idempotency_key="run_2026_04:contractor_847:USD:MXN"로 호출된다. 해당 날짜의 USD→MXN에 대해 Finexly historical을 조회하고 17.8642를 받아 락한다. 명세서는 "USD 4,800.00 → MXN 85,748.16 at 17.8642 USD/MXN (mid-market, Finexly, 2026-04-25 21:00 UTC)"를 보여준다.

2026-05-01에 Stripe Connect가 정산한다. Stripe payout API는 자체 exchange_rate를 반환한다 — 예컨대 17.8201(미드마켓에서 25 bps 스프레드를 뺀 것). 두 환율 모두 감사 테이블에 들어간다. 1099 내보내기는 락된 환율을 쓰고, GL 정산은 정산 환율을 쓰며, 차이는 FX 비용 계정에 기록된다. 이것이 잘 굴러가는 모습이다.

흔히 보는 실수

급여 시스템 코드 리뷰에서 반복해서 보는 패턴:

  • 재시도에서 다시 호가하기. 재시도에서의 다른 환율은 실행 내용이 그대로인데 다른 gross-to-net을 의미한다. 항상 멱등 키로 캐시할 것.
  • Date.now()를 환율 타임스탬프로. 그건 당신의 시계지 제공자의 시계가 아니다. 제공자의 timestamp를 로깅하라.
  • 오래된 캐시로의 조용한 폴백. 장애 중 캐시로 떨어진다면 명세서에 표시할 것 — 절대 오래된 데이터를 라이브처럼 보여주지 말 것.
  • 부동소수점 돈. 4800 * 17.8642는 모든 머신에서 같지 않다. 돈을 만지는 모든 것에 decimal 라이브러리를 쓸 것.
  • 실행당 글로벌 환율 하나. 직원마다 다른 정책이 필요할 수 있다(브라질 계약자는 PTAX, 인도 계약자는 RBI 참조). 직원별로 해결하라.

자주 묻는 질문

국경 간 급여에 가장 좋은 환율은? 미드마켓 — bid와 ask 사이의 중간점 — 이 표준 참조다. 직원이 Google이나 Bloomberg에서 검증할 수 있고, 대부분 계약이 "공정 시장 환율"이라 할 때 의미하는 것이다. 실제 결제 레일은 미드마켓에 스프레드를 붙인다; 둘 다 로깅하고 명세서엔 미드마켓을 표시.

환율을 실행일에 락할까 결제일에 락할까? 고객의 정책이 무엇이든 — 둘 다 방어 가능하다. 실행일 락은 결제 전 직원에게 고정 미리보기를 주고, 결제일 락은 기반 레일의 동작과 일치한다. 중요한 건 정책을 명시적으로 코드에 인코딩하는 것이다, 잡이 우연히 API를 호출하는 날짜에 암묵적으로 의존하는 게 아니라.

주말이나 공휴일 급여 실행은 어떻게 다루나? 마지막 종가를 쓴다. Finexly 과거 엔드포인트는 자동으로 가장 최근 영업일로 스냅한다; timestamp를 믿으라. 도착 통화의 은행 휴일(수취 은행이 닫힌 경우)에는 명세서의 자금 도착일을 플래그하지만 FX 환율은 보통처럼 쓴다.

특정 국가에 대해 중앙은행 참조 환율을 써야 하나? 일부는 그렇다 — 브라질(PTAX), 인도의 비거주자 보고(RBI 참조), 아르헨티나(BCRA)는 현지 세무 신고에 게시 참조 환율을 요구한다. 환율 레이어는 관할별 오버라이드를 받아들이고 나머지는 미드마켓으로 폴스루해야 한다.

명세서의 환율은 얼마나 정확해야 하나? 표시는 최소 6자리 유효숫자 — 17.8642이지 17.86이 아니다. 계산은 decimal 10자리 이상으로 하고 마지막에만 반올림한다. 직원은 정말로 환율을 계산기에 입력해 검증한다.

프로덕션 급여에 무료 currency API를 써도 되나? 프리 티어는 매우 낮은 볼륨에는 작동하지만, 대부분 제한(일별 환율만, 과거 없음, 월 1,000회)이 첫 국제 채용에 깨진다. 옵션은 무료 vs 유료 currency API 가이드에서 비교.

마치며

국경 간 급여 통화 변환은 한 번의 곱셈처럼 보이지만 여섯 개의 맞물린 결정 — 출처 정책, 락 날짜, 멱등성, 반올림, 주말 처리, 감사 추적 — 으로 드러난다. 세 레이어 아키텍처 — 환율 레이어, FX 정책, 결제 실행 — 를 제대로 갖추면 각 결정은 아무도 건드리고 싶지 않은 저장 프로시저가 아니라 작고 테스트 가능한 함수가 된다.

실시간 미드마켓 환율을 급여 엔진에 연결할 준비가 됐는가? 무료 Finexly API 키 받기 — 신용카드 불필요. 월 1,000회 요청이면 위의 모든 코드 예제를 테스트하기에 충분하고, 유료 플랜은 앞으로 온보딩할 모든 계약자를 감당하도록 확장된다. Finexly API 문서를 확인하거나 Finexly를 다른 제공자와 비교하라.

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 →

이 기사 공유하기