신뢰할 수 있는 계산기는 화면을 그릴 때마다 숫자 하나를 다시 요청하지 않는다. 환율을 source, market_session, data_updated_at과 함께 저장하고 잠시 재사용한 뒤, 갱신에 성공했을 때만 교체한다. 응답 시각은 API가 답한 때이고 관측 시각은 환율 자체가 바뀐 때다.
rates, sources, market_session, data_updated_at을 포함한 전체 관측값을 캐시한다.data_updated_at으로 판단한다. timestamp는 응답 생성 시각이다.응답 전체를 하나의 관측값으로 다루기
메타데이터가 없는 환율은 의미가 불분명하다. 장중 현물, 일일 기준값, 다른 통화를 거쳐 계산한 교차환율일 수 있다. rates.EUR만 꺼내지 말고 객체 전체를 저장한다. API 레퍼런스는 응답 필드를 정의하고, 방법론은 출처와 시장 상태 라벨이 정해지는 방식을 설명한다.
{
"base": "USD",
"source": "live",
"market_session": "open",
"timestamp": "2026-08-23T09:42:06Z",
"data_updated_at": "2026-08-23T09:41:00Z",
"rates": { "EUR": 0.86124, "GBP": 0.74281 },
"sources": { "EUR": "live", "GBP": "live" },
"derived_symbols": []
}source는 여러 통화 응답에서 가장 덜 최신인 데이터 등급을 요약한다. 통화별 상태는 sources를 읽고, derived_symbols로 중간 통화를 거쳐 계산된 환율을 확인한다.
timestamp는 API가 응답을 만든 시각이고 data_updated_at은 원본 관측값이 기록된 시각이다. 최신 여부와 화면 표시는 후자를 기준으로 한다.타이머보다 먼저 갱신 정책 정하기
모든 제품에 맞는 간격은 없다. 쇼핑몰의 대략적 표시값은 몇 분간 공유 캐시할 수 있고, 운영 대시보드는 보이는 동안 짧게 갱신할 수 있으며, 일일 보고서는 필요한 고시 이후 한 번 가져올 수 있다. 렌더링마다 요청해도 같은 관측값이 돌아올 수 있다.
market_session은 open, weekend, interbank_closed 중 하나다. 활발한 통화는 거래 주간에 움직이고 일일 기준 시계열은 발표 일정에 따라 갱신된다.
서버에 작은 캐시 만들기
통화 코드를 정렬해 안정적인 키를 만들고 성공한 응답만 저장한다. 동시에 들어온 갱신은 하나로 합치며 인증 요청과 API 키는 서버에 둔다.
type RateSource = "live" | "ecb_daily" | "fred_daily";
type LatestRates = {
base: string;
source: RateSource;
sources: Record<string, RateSource>;
market_session: "open" | "weekend" | "interbank_closed";
timestamp: string;
data_updated_at: string;
rates: Record<string, number>;
derived_symbols: string[];
};
type CacheEntry = {
value: LatestRates;
fetchedAt: number;
};
const cache = new Map<string, CacheEntry>();
const pending = new Map<string, Promise<LatestRates>>();
function cacheKey(base: string, symbols: string[]): string {
return `${base}:${[...symbols].sort().join(",")}`;
}
async function fetchLatest(base: string, symbols: string[]): Promise<LatestRates> {
const url = new URL(`https://api.exchangerate.dev/v1/latest/${base}`);
url.searchParams.set("symbols", [...symbols].sort().join(","));
const response = await fetch(url, {
headers: process.env.EXCHANGERATE_API_KEY
? { Authorization: `Bearer ${process.env.EXCHANGERATE_API_KEY}` }
: {},
signal: AbortSignal.timeout(8000),
});
if (!response.ok) {
throw new Error(`FX refresh failed: ${response.status}`);
}
return response.json() as Promise<LatestRates>;
}
export async function getLatestRates(
base: string,
symbols: string[],
maxAgeMs = 60_000,
): Promise<LatestRates> {
const key = cacheKey(base, symbols);
const existing = cache.get(key);
if (existing && Date.now() - existing.fetchedAt < maxAgeMs) {
return existing.value;
}
const inFlight = pending.get(key);
if (inFlight) return inFlight;
const refresh = fetchLatest(base, symbols)
.then((value) => {
cache.set(key, { value, fetchedAt: Date.now() });
return value;
})
.finally(() => pending.delete(key));
pending.set(key, refresh);
return refresh;
}인스턴스가 여러 개라면 공유 저장소나 프레임워크 캐시를 사용한다. 값과 관측 메타데이터를 함께 저장하고 성공 시에만 교체한다는 원칙은 같다.
갱신 실패는 새 환율이 아니다
타임아웃, 429, 상류 오류는 시장 관측값이 아니다. 마지막 정상값을 허용하는 기능이라면 원래 data_updated_at과 대체 상태를 표시한다. 정상값이 없으면 오류를 반환한다. 429에서는 Retry-After를 따르고 일시 오류에는 무작위 지연을 포함한 지수 백오프를 쓴다. 401은 설정을 고쳐야 한다.
type ConverterResult =
| { status: "fresh"; data: LatestRates }
| { status: "last_known_good"; data: LatestRates; reason: string };
async function getConverterRates(
base: string,
symbols: string[],
): Promise<ConverterResult> {
const key = cacheKey(base, symbols);
try {
return { status: "fresh", data: await getLatestRates(base, symbols) };
} catch (error) {
const previous = cache.get(key);
if (!previous) throw error;
return {
status: "last_known_good",
data: previous.value,
reason: error instanceof Error ? error.message : "Refresh failed",
};
}
}429 응답에서는 Retry-After가 있으면 따르고 모든 인스턴스가 동시에 재시도하지 않게 한다. 일시 장애에는 무작위 지연을 포함한 지수 백오프가 적절하다. 인증 오류는 재시도가 아니라 설정 수정이 필요하다.
시장 휴장은 정상 상태다
market_session: weekend 응답이 거래 주간 마지막 관측값을 담는 것은 정상이다. 새 HTTP 응답 시각으로 관측 시각을 덮어쓰거나 주말 환율을 만들어 내지 않는다.
시장 휴장 중에는 앱 캐시 시간을 늘릴 수 있지만 거래 주간이 재개되면 다시 갱신해야 한다. 모든 통화가 한 일정에 따른다고 가정하지 말고 반환된 시장 상태와 제품 요구사항으로 결정한다.
환산 후 한 번만 반올림하기
기준 통화가 USD라면 25 USD를 EUR로 바꾸는 계산은 25 × rates.EUR이다. 환율 정밀도를 유지하고 최종 금액만 대상 통화 규칙에 따라 반올림한다.
출시 전 확인 항목
- 화면에 필요한 통화만 요청한다.
- 기준 통화와 정렬한 심볼로 캐시 키를 만든다.
- 성공한 전체 응답만 저장하고 HTTP 오류는 저장하지 않는다.
- 동시 갱신 요청을 중복 실행하지 않는다.
- 데이터 나이가 중요하면
data_updated_at을 표시한다. sources,market_session,derived_symbols를 보존한다.- API 키는 서버에 둔다.
- 마지막 정상값에는 원래 시각과 명확한 상태를 표시한다.
