블로그로 돌아가기

Go 환율 API 연동 가이드: 프로덕션에 바로 쓰는 Golang 튜토리얼 (2026)

V
Vlado Grigirov
November 10, 2026
Currency API Golang Tutorial Exchange Rates Finexly Backend

Go에서 환율 API를 쓰려 한다면 표준 라이브러리만으로도 거의 충분합니다. net/http, encoding/json, context 패키지만 있으면 빠르고 의존성 없는 클라이언트를 만들 수 있습니다. 어려운 부분은 HTTP 호출 자체가 아니라 그 주변입니다. 타임아웃, 오류 처리, 캐싱, 금액 연산, 그리고 테스트입니다. 대부분의 튜토리얼은 http.Get과 fmt.Println에서 끝납니다. 데모로는 괜찮지만 결제 흐름에서는 위험합니다.

이 가이드에서는 Finexly API 문서의 엔드포인트를 위한 작지만 프로덕션을 염두에 둔 Golang 클라이언트를 만들고, 제공자 장애에도 버티는 동시성 안전 캐시로 감싸고, 정수 최소 화폐 단위로 동작하는 환산 함수를 추가한 뒤, 테스트가 포함된 HTTP 서비스로 완성합니다. 모든 코드는 Go 1.22 이상에서 컴파일되며 표준 라이브러리만 사용합니다.

환율 데이터에 Go가 잘 맞는 이유

환율은 읽기가 매우 많고, 크기가 작으며, 웹 요청 단위의 시간 척도에서는 천천히 변합니다. 이런 특성은 Go와 잘 맞습니다. 고루틴 덕분에 하나의 메모리 내 스냅샷으로 수천 개의 동시 요청을 저렴하게 처리할 수 있고, 단일 정적 바이너리는 배포가 쉬우며, context.Context는 모든 외부 호출에 대해 취소와 마감 시간을 일급으로 지원합니다.

설계 목표는 단순합니다. 요청 핸들러가 환율 API를 직접 호출하지 않게 하는 것입니다. 핸들러는 캐시에서 읽고, 하나뿐인 안전한 갱신 경로만 API와 통신합니다. 그러면 지연 시간이 일정하게 유지되고, 할당량이 보호되며, 제공자의 일시적 문제가 서비스 장애로 번지지 않습니다. 이론적 배경은 통화 API 캐싱 및 오류 처리 가이드에서 다루었고, 여기서는 이를 직접 구현합니다.

1단계: 먼저 cURL로 확인하기

Go 코드를 작성하기 전에 키가 동작하는지 확인하세요. 무료로 가입하고 키를 복사한 다음 실행합니다.

curl "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY&apikey=$FINEXLY_API_KEY"
{
  "base": "USD",
  "timestamp": 1762761600,
  "rates": { "EUR": 0.92, "GBP": 0.80, "JPY": 151.2 }
}

The numbers above are illustrative.

응답에는 세 개의 필드가 있습니다. base(모든 환율의 기준 통화), timestamp(Unix 초), rates(ISO 4217 코드에서 환율로의 맵)입니다. rates의 각 값은 기준 통화 1단위가 해당 통화 몇 단위에 해당하는지를 나타냅니다. 키는 환경 변수에 저장하고 소스 관리에는 절대 올리지 마세요.

export FINEXLY_API_KEY="your_key_here"

2단계: 타임아웃과 안전한 오류 처리를 갖춘 타입 클라이언트

go mod init fxdemo로 모듈을 만들고 아래 코드를 main.go에 넣으세요. 응답 구조체, 사용자 정의 APIError, 그리고 Latest 메서드가 context.Context를 받는 Client를 정의합니다.

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"sync"
	"time"
)

type RatesResponse struct {
	Base      string             `json:"base"`
	Timestamp int64              `json:"timestamp"`
	Rates     map[string]float64 `json:"rates"`
}

type APIError struct {
	Status int
	Body   string
}

func (e *APIError) Error() string {
	return fmt.Sprintf("finexly: HTTP %d: %s", e.Status, e.Body)
}

type Client struct {
	apiKey  string
	baseURL string
	http    *http.Client
}

func NewClient(apiKey string) *Client {
	return &Client{
		apiKey:  apiKey,
		baseURL: "https://api.finexly.com/v1",
		http:    &http.Client{Timeout: 5 * time.Second},
	}
}

func (c *Client) Latest(ctx context.Context, base string, symbols []string) (*RatesResponse, error) {
	q := url.Values{}
	q.Set("base", base)
	if len(symbols) > 0 {
		q.Set("symbols", strings.Join(symbols, ","))
	}
	q.Set("apikey", c.apiKey)

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/latest?"+q.Encode(), nil)
	if err != nil {
		return nil, errors.New("finexly: build request failed")
	}
	resp, err := c.http.Do(req)
	if err != nil {
		var ue *url.Error
		if errors.As(err, &ue) {
			err = ue.Err // drop the URL, it contains the API key
		}
		return nil, fmt.Errorf("finexly: request failed: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		return nil, &APIError{Status: resp.StatusCode, Body: strings.TrimSpace(string(body))}
	}
	var out RatesResponse
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, fmt.Errorf("finexly: decode response: %w", err)
	}
	return &out, nil
}

데모와 프로덕션 코드를 가르는 몇 가지 세부 사항이 있습니다.

  • 확실한 타임아웃. 기본값 그대로의 http.Client에는 타임아웃이 없어서, 멈춘 연결 하나가 고루틴을 영원히 붙잡을 수 있습니다. 5 * time.Second를 설정하고 호출자의 컨텍스트도 전달하므로, 사용자가 취소한 요청은 업스트림 호출도 취소합니다.
  • 로그로 키가 새지 않게 하기. 키가 apikey 쿼리 파라미터로 전달되기 때문에 Go의 *url.Error는 메시지에 키가 포함된 전체 URL을 담습니다. errors.As로 풀어서 내부 오류만 남기므로 이 오류를 로깅해도 안전합니다.
  • 타입이 있는 APIError. 호출자는 errors.As로 Status에 따라 분기할 수 있습니다. 예를 들어 429에서는 대기하고 403에서는 알림을 보냅니다. 본문은 io.LimitReader로 읽기 때문에 오작동하는 업스트림이 수 MB를 보내도 버퍼링하지 않습니다.
  • 설정 가능한 baseURL. 비용이 들지 않으면서 6단계에서 보듯 클라이언트를 아주 쉽게 테스트할 수 있게 해줍니다.

3단계: 만료 데이터 폴백이 있는 동시성 안전 캐시

요청마다 API를 호출하면 할당량이 빠르게 소진되고 응답 지연도 늘어납니다. 대부분의 애플리케이션에서 환율은 몇 분 사이에 의미 있게 변하지 않으므로 캐시하세요. 아래 캐시에는 단순한 map과 Mutex 예제가 놓치는 세 가지 특성이 있습니다.

  1. 갱신 때문에 읽기가 막히지 않습니다. 네트워크 호출은 읽기/쓰기 락 바깥에서 이루어집니다.
  2. 한 번에 하나의 고루틴만 갱신합니다. 두 번째 뮤텍스와 락 획득 후 재확인 덕분에, 부하 중 TTL이 만료될 때 요청이 한꺼번에 몰리는 현상을 막습니다.
  3. 오래된 데이터가 없는 것보다 낫습니다. 갱신이 실패했는데 이전 스냅샷이 있으면 stale=true와 함께 반환해 호출자가 판단하게 합니다.
type Cache struct {
	client *Client
	base   string
	ttl    time.Duration

	mu        sync.RWMutex
	data      *RatesResponse
	fetchedAt time.Time

	refreshMu sync.Mutex
}

func NewCache(c *Client, base string, ttl time.Duration) *Cache {
	return &Cache{client: c, base: base, ttl: ttl}
}

func (c *Cache) fresh() (*RatesResponse, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	if c.data != nil && time.Since(c.fetchedAt) < c.ttl {
		return c.data, true
	}
	return nil, false
}

// Get returns rates, and stale=true if it had to fall back to old data.
func (c *Cache) Get(ctx context.Context) (rates *RatesResponse, stale bool, err error) {
	if d, ok := c.fresh(); ok {
		return d, false, nil
	}
	c.refreshMu.Lock() // only one goroutine hits the API
	defer c.refreshMu.Unlock()
	if d, ok := c.fresh(); ok { // refreshed while we waited
		return d, false, nil
	}
	d, err := c.client.Latest(ctx, c.base, nil)
	if err != nil {
		c.mu.RLock()
		old := c.data
		c.mu.RUnlock()
		if old != nil {
			return old, true, nil
		}
		return nil, false, err
	}
	c.mu.Lock()
	c.data, c.fetchedAt = d, time.Now()
	c.mu.Unlock()
	return d, false, nil
}

기준 통화의 모든 환율을 한 번의 요청으로 가져온다는 점에 주목하세요(symbols 생략). 그러면 시간당 한 번의 호출로 서비스에 필요한 모든 통화쌍을 처리할 수 있고, 상시 실행되는 인스턴스 하나 기준으로 월 약 720회입니다. 월 1,000건의 무료 플랜 안에 들어옵니다. TTL을 10분으로 하면 월 약 4,300회가 되므로 더 짧은 갱신 주기는 유료 플랜이 필요합니다. 요금제를 확인하세요.

4단계: 부동소수점 문제 없이 금액 환산하기

금액을 float64에 저장하지 마세요. 정수 최소 화폐 단위(센트, 펜스, 엔)로 저장하고, 환율은 마지막에 명시적인 반올림과 함께 한 번만 적용하세요.

// Convert works in minor units (cents). It assumes both currencies use the
// same number of decimal places; see the ISO 4217 note in the article.
func Convert(amountMinor int64, from, to string, r *RatesResponse) (int64, error) {
	rate := func(code string) (float64, error) {
		if code == r.Base {
			return 1, nil
		}
		v, ok := r.Rates[code]
		if !ok || v <= 0 {
			return 0, fmt.Errorf("no rate for %q", code)
		}
		return v, nil
	}
	f, err := rate(from)
	if err != nil {
		return 0, err
	}
	t, err := rate(to)
	if err != nil {
		return 0, err
	}
	return int64(math.Round(float64(amountMinor) * t / f)), nil
}

환율은 기준 통화를 중심으로 제시되므로 EUR에서 GBP로의 환산은 그 기준을 거칩니다. 원래 통화의 환율로 나누고 대상 통화의 환율을 곱합니다. 이 함수는 기준 통화 자체를 정확히 1인 환율로 취급합니다.

현실적인 함정이 하나 있습니다. 위 함수는 두 통화의 소수 자릿수가 같다고 가정합니다. JPY는 0자리, USD는 2자리, KWD는 3자리이므로 프로덕션 시스템은 통화별 지수에 맞춰 스케일링해야 합니다. ISO 4217 통화 코드 가이드에 모든 코드의 최소 단위 지수가 정리되어 있습니다. 숫자를 빠르게 대화형으로 확인하려면 통화 변환기를 사용하세요.

5단계: HTTP로 제공하기

이제 모든 것을 HTTP 핸들러에 연결합니다. 핸들러는 요청을 파싱하고, 요청 컨텍스트로 캐시에서 읽으며, 환율이 오래된 것이었는지 알려줍니다.

func convertHandler(cache *Cache) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		q := r.URL.Query()
		amount, err := strconv.ParseInt(q.Get("amount"), 10, 64)
		if err != nil {
			http.Error(w, "amount must be an integer in minor units", http.StatusBadRequest)
			return
		}
		from, to := strings.ToUpper(q.Get("from")), strings.ToUpper(q.Get("to"))

		rates, stale, err := cache.Get(r.Context())
		if err != nil {
			log.Printf("rates unavailable: %v", err)
			http.Error(w, "rates unavailable", http.StatusBadGateway)
			return
		}
		out, err := Convert(amount, from, to, rates)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]any{
			"from": from, "to": to,
			"amount_minor": amount, "converted_minor": out,
			"rates_timestamp": rates.Timestamp, "stale": stale,
		})
	}
}

func main() {
	apiKey := os.Getenv("FINEXLY_API_KEY")
	if apiKey == "" {
		log.Fatal("FINEXLY_API_KEY is not set")
	}
	cache := NewCache(NewClient(apiKey), "USD", time.Hour)
	http.Handle("/convert", convertHandler(cache))
	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

실행한 뒤 100.00 USD를 EUR로 환산(10,000센트)해 보세요.

go run . &
curl "http://localhost:8080/convert?amount=10000&from=USD&to=EUR"

응답에는 rates_timestamp와 stale 플래그가 들어 있어, 하위 시스템이 "환율 기준 시각..."을 표시하거나 데이터가 너무 오래되면 견적을 거부할 수 있습니다. 감사인과 재무팀이 가장 먼저 요구하는 것이 바로 이 필드인 경우가 많습니다.

6단계: 네트워크 없이 테스트하기

기준 URL을 주입할 수 있으므로 httptest.NewServer로 클라이언트와 캐시 로직 전체를 테스트할 수 있습니다. 이 테스트는 가장 중요한 동작, 즉 API가 실패하기 시작하면 캐시가 오류 대신 오래된 데이터를 제공한다는 점을 증명합니다.

func TestLatestAndStaleFallback(t *testing.T) {
	healthy := true
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !healthy {
			http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError)
			return
		}
		w.Write([]byte(`{"base":"USD","timestamp":1,"rates":{"EUR":0.92}}`))
	}))
	defer srv.Close()

	c := NewClient("test-key")
	c.baseURL = srv.URL
	cache := NewCache(c, "USD", time.Millisecond)

	if _, stale, err := cache.Get(context.Background()); err != nil || stale {
		t.Fatalf("first call: err=%v stale=%v", err, stale)
	}
	healthy = false
	time.Sleep(5 * time.Millisecond)
	r, stale, err := cache.Get(context.Background())
	if err != nil || !stale || r.Rates["EUR"] != 0.92 {
		t.Fatalf("expected stale fallback, got err=%v stale=%v", err, stale)
	}
}

func TestConvert(t *testing.T) {
	r := &RatesResponse{Base: "USD", Rates: map[string]float64{"EUR": 0.92, "GBP": 0.80}}
	got, err := Convert(10000, "EUR", "GBP", r)
	if err != nil || got != 8696 {
		t.Fatalf("got %d err %v", got, err)
	}
}

go test ./...로 실행하세요. 이런 테스트는 밀리초 안에 끝나고, API 키가 필요 없으며, CI에 넣을 수 있습니다. 같은 방식으로 429 응답, 잘못된 JSON, 느린 서버(타임아웃 검증) 사례도 추가할 수 있습니다.

프로덕션 체크리스트

배포 전에 다음을 점검하세요.

  • 적절한 TTL을 고르세요. 청구서와 쇼핑몰 표시 가격에는 1시간이 적당하며 인스턴스 하나를 무료 플랜 안에 둘 수 있습니다. 견적이나 트레이딩 기능에는 몇 분 또는 몇 초가 필요할 수 있습니다. 비즈니스 위험에 맞춰 TTL을 정한 다음 플랜을 선택하세요.
  • 환율 타임스탬프를 노출하세요. 고객이 환산 금액을 보는 모든 곳에 표시하고, 나중에 재현할 수 있도록 거래마다 사용한 환율을 저장하세요.
  • 만료 상태에 알림을 설정하세요. stale=true가 임계값을 넘어 지속되면 메트릭을 보내세요. 하루 지난 데이터를 조용히 제공하는 캐시는 오류보다 나쁩니다.
  • 시작 시 캐시를 미리 채우고, 첫 조회가 실패하면 준비 상태 검사를 실패시키세요. 잘못된 키를 배포 시점에 잡을 수 있습니다.
  • 429는 백오프로 처리하세요. 즉시 재시도하지 마세요. 자주 발생한다면 TTL이 너무 짧거나 여러 인스턴스가 각자 독립적으로 갱신하는 것입니다. 후자는 Redis 같은 공유 캐시로 해결됩니다.
  • 가끔 제공자를 비교하세요. 데이터 소스를 평가 중이라면 통화 API 비교 페이지와 무료 통화 API 개요가 살펴볼 점을 보여줍니다.

여러 언어를 사용한다면 같은 패턴을 Rust 튜토리얼과 Python 튜토리얼에서도 볼 수 있습니다.

자주 묻는 질문

Go용 무료 환율 API가 있나요?

네. Finexly에는 월 1,000건의 무료 플랜이 있고, 위 클라이언트는 서드파티 패키지가 필요 없습니다. 1시간 캐시를 쓰면 상시 실행 인스턴스 하나가 월 약 720건을 사용하므로 여유가 있습니다. 인스턴스가 늘어나면 각각 독립적으로 갱신한다는 점을 기억하세요. 최신 한도는 요금제에서 확인하세요.

Go에서 환율에 float64를 써도 되나요?

환율 자체에 float64를 쓰는 것은 대체로 허용됩니다. 환율은 유효 숫자가 몇 자리인 근사값이기 때문입니다. 그러나 금액에는 안 됩니다. 금액은 정수 최소 화폐 단위로 보관하거나 소수 라이브러리를 사용하고, 마지막에 한 번만 반올림하세요.

환율은 얼마나 자주 갱신해야 하나요?

위험 허용도에 따라 다릅니다. 표시 가격과 보고서에는 보통 15분에서 60분이면 충분합니다. 청구서는 문서마다 환율을 고정하는 경우가 많습니다. 실시간으로 돈이 오가는 모든 처리는 갱신 주기와 무관하게 사용한 정확한 환율과 타임스탬프를 저장해야 합니다.

Go 로그에 API 키가 노출되지 않게 하려면?

키를 쿼리 파라미터로 보내면 http.Client가 반환하는 오류에 전체 URL이 포함됩니다. 이 가이드의 클라이언트처럼 errors.As로 *url.Error를 풀어 Err 필드만 로깅하세요. 미들웨어에서 전체 요청 URL을 로깅하는 것도 피하세요.

여러 기준 통화를 동시에 가져올 수 있나요?

네. SetLimit이 있는 golang.org/x/sync/errgroup으로 병렬성을 제한하고, 동시 사용에 안전한 같은 Client를 공유하세요. 하지만 대부분의 경우 기준 통화 하나와 4단계의 교차 환율 계산이 더 간단하고 저렴합니다.

다음 단계

실시간 환율을 Go 서비스에 통합할 준비가 되셨나요? 무료 Finexly API 키를 받으세요. 신용카드는 필요 없습니다. 월 1,000건의 무료 요청으로 시작하고 성장에 맞춰 업그레이드하세요.

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 →

이 기사 공유하기