기술

잘 되는 환율 API 를 두고 중앙은행 원본을 직접 치기로 했습니다 — 감싼 것을 쓰면 과거가 바뀝니다

2026.09.268분 읽기

프랜차이즈 ERP 의 해외 매장 매출 화면은 그동안 외화 금액만 보여줬습니다. 통화가 두 종이라 규모 감이 안 잡히고, 국내 실적과 나란히 놓고 이야기할 수도 없었습니다. 그래서 모든 금액 칸에 원화를 항상 병기하기로 했습니다.

원화를 병기한다는 말은 곧 환율 원천을 하나 고른다는 말입니다. 그리고 이 환율은 실시간 시세가 아니라 2024년부터의 과거 매출을 소급 환산하는 데 쓰입니다. 어제 뽑은 표와 오늘 뽑은 표가 같아야 한다는 조건이 처음부터 붙어 있었습니다.


후보 넷을 문서만 보지 않고 실제로 쳤습니다

환율 API 는 흔하고 예전에 써본 것도 있었습니다. 기억으로 고르지 않고 후보 넷을 같은 날 전부 호출해 봤습니다. 결과는 기억과 꽤 달랐습니다.

후보

실제로 쳐본 결과

판정

상용형 무료 환율 API (exchangerate.host)

어느새 API 키가 필요해짐

제3자 정책에 매인다

최신값 위주 API (open.er-api)

최신 값은 오는데 과거 날짜 조회가 404

목적 불일치

중앙은행을 감싼 오픈 API (frankfurter)

잘 됨. 값이 ECB 참고환율과 소수점까지 동일

중간 계층

유럽중앙은행(ECB) 참고환율 원본

XML 세 종. EUR 기준이라 크로스 계산 필요

채택

첫 번째는 예전 기억으로 골랐으면 붙이는 날 막혔을 겁니다. 두 번째는 탈락이 아니라 애초에 대상이 아니었습니다. 소급 환산이 목적인데 과거를 못 주는 API 는 비교할 자리에 오지 않습니다. 갈림길은 세 번째와 네 번째 사이였습니다.


잘 되는 것을 쓰지 않은 이유

세 번째 후보는 잘 됐습니다. 통화 코드로 바로 부르면 되고 과거 날짜도 줬습니다. 그런데 받은 값이 유럽중앙은행 참고환율과 소수점 자리까지 같았습니다. 원본을 감싼 중간 계층이라는 뜻입니다.

그 서비스의 새 버전을 쓸 뻔했습니다. 새 버전은 기본 동작이 여러 기관의 값을 섞은 blended 였습니다. 제공 기관이 하나 늘면 섞인 값이 바뀌고, 과거 값도 같이 바뀝니다. 옵션으로 기관을 하나로 고정하면 재현되는데, 그 옵션을 안 걸면 에러 없이 어제 표와 오늘 표가 조용히 달라집니다.

문제는 동작이 아니라 값의 정의를 누가 들고 있는가였습니다. 오늘 'ECB 값'인 필드가 내일 '여러 기관 blended'가 될 수 있고, 그게 바뀌면 이미 뽑아둔 과거 표까지 달라집니다. 실시간 시세라면 별일이 아닙니다. 과거 값이 확정돼 있어야 하는 데이터에서는 치명적입니다.

네 번째 후보의 비용은 명확하고 유한했습니다. EUR 기준이라 크로스 계산 한 줄, XML 파싱, 휴일 채움입니다. 전부 우리가 통제하는 코드 안에 있습니다. 그래서 중앙은행을 직접 치기로 했습니다. 그 순간 blended 문제 자체가 사라졌습니다. 옵션을 안 거는 실수를 할 자리가 없어졌기 때문입니다.

환율 래퍼 API 를 거치는 경로와 중앙은행 원본을 직접 치는 경로의 비교 — 래퍼는 정책 변경이 과거 값에 소급된다


원본의 형태와 저장 설계

원본은 파일 세 종으로 열려 있습니다. 일별 파일 약 1.5KB, 최근 90일 파일 약 70KB, 1999년부터의 전체 이력 약 8MB 입니다. 백필은 전체 이력으로, 일일 수집은 일별 파일로 해결됩니다. 같은 원천의 다른 창구라 두 경로의 값이 갈릴 일이 없습니다.

원본은 EUR 기준이라 '통화 1단위당 원화'는 크로스로 계산합니다.

원화/통화X = (원화/EUR) ÷ (통화X/EUR)

저장은 (기준일, 통화) 복합키에 원화 환율, 실제로 쓴 환율의 날짜, 원천 표기를 둔 테이블 하나입니다. 여기서 세 가지를 정했습니다.

환율만 저장하고 환산액은 저장하지 않습니다. 원화 금액을 테이블에 넣으면 언젠가 국내 매출 집계로 새어 들어갈 경로가 생깁니다. 해외 매장 원칙은 원화와 외화를 합산하지 않는 것입니다.

주말·공휴일에도 행을 만들고 직전 영업일 값을 채웁니다. 조회할 때마다 폴백을 짜면 그 규칙이 화면마다 흩어지고, 하나가 빠뜨려도 티가 안 납니다. 저장 시점에 한 번 결정합니다.

대신 어느 날짜의 환율을 썼는지를 컬럼으로 남깁니다. 안 남기면 나중에 '이 날 매출인데 왜 이틀 전 환율이지'를 아무도 못 밝힙니다. 채워 넣은 값은 사실과 구분이 안 되는데, 출처 컬럼 하나가 그 둘을 가릅니다.

주말·공휴일에도 환율 행을 채우되 어느 날짜의 환율을 썼는지 출처 컬럼으로 남기는 테이블 구조

백필 결과는 1,738행이었습니다. 869일 × 2통화이고 한 번 호출에 4초 걸렸습니다. 해외 매출이 있는 날 중 환율이 비어 있는 날은 해외 POS 두 곳 모두 0건이었습니다. 주말 두 날이 직전 금요일 환율을 참조하는 것까지 확인했습니다.


환산은 접기 전에, 일 단위로

화면은 일·주·월로 접힙니다. 접은 뒤 환율 하나를 곱하면 기간 안의 변동이 통째로 뭉개집니다. 얼마나 큰지 실제 데이터로 재봤습니다.

백필 전 구간의 가중평균 환율과 최근 환율이 약 5% 차이났습니다

한 60일 구간에서 통화 B 가 6.2% 움직였습니다

같은 구간을 최종 환율로 일괄 환산하면 일별 환산 대비 +3.8% 였습니다. 단순평균 환율로 하면 또 다른 값이 나옵니다

3.8% 는 눈으로는 안 잡히고 숫자로만 잡히는 크기입니다. 그래서 일별 행에 그날 환율을 실어 접기 전에 곱하게 했습니다. 합계는 서버가 외화 × 그날 환율의 합으로 냅니다. 환율 조인은 LEFT JOIN 입니다. 환율이 아직 없는 날의 매출 행을 통째로 떨어뜨리지 않기 위해서입니다.

기간을 접은 뒤 환율 하나를 곱하는 방식과 일별로 그날 환율을 곱한 뒤 합치는 방식의 BEFORE/AFTER 비교


만들자마자 두 번 걸렸습니다

첫 번째는 collation 이었습니다. 새 테이블이 DB 기본값인 utf8mb4_0900_ai_ci 로 생겼는데 기존 해외 매장 테이블은 utf8mb4_unicode_ci 였습니다. 조인이 Illegal mix of collations 로 터졌고, 데이터를 보존한 채 변환해 해결했습니다. 이 프로젝트에서 같은 일이 두 번째입니다.

두 번째는 수집 잡의 등록 지점이었습니다. 수집 경로는 이중입니다. 해외 POS 수집이 끝날 때 그 구간 그대로 환율을 채우는 종료 훅과, 해외 POS 가 꺼져도 환율은 계속 오게 하는 독립 배치입니다. upsert 라 겹쳐도 무해합니다. 그런데 새 잡을 붙이는 등록 지점이 디스패처·모듈 메타데이터·일일 요약 카테고리·배치 카탈로그·로그 라벨 키까지 5곳이었습니다. 하나만 빠져도 에러 없이 안 돕니다. 같은 날 아침에 다른 잡이 정확히 이걸로 안 돌았습니다.


어디까지 성립하는가

이 선택은 원본이 로그인 없이 파일을 주는 조건에서만 성립합니다. 원본이 인증·쿼터·계약을 요구하면 래퍼 쪽이 오히려 의존을 줄입니다. 외부 시스템에 직접 붙는 길이 세 번 막혀 공식 CLI 에 위임한 건이 정확히 그 반대편 사례였습니다.

래퍼가 해주던 것도 우리가 떠안습니다. 통화 커버리지, 휴일 처리, 형식 변경 대응이 전부 우리 몫이 됐습니다. 통화가 2종이라 감당되는 규모이지, 20종이었으면 계산이 달라졌을 겁니다. 그리고 중앙은행 참고환율은 회계·세무 기준환율이 아닙니다. 추이를 보기 위한 참고 병기용이고, 법정 환산이 목적이면 원천 선택 기준 자체가 다릅니다.


체크리스트

과거가 확정돼 있어야 하는 데이터인가. 그렇다면 원천의 정책 변경이 내 과거에 소급되는지 먼저 봅니다

'잘 된다'와 '값의 정의를 우리가 통제한다'를 따로 셉니다

대체값을 채웠으면 무엇으로 대체했는지를 같은 행에 남깁니다

파생값은 저장하지 않습니다. 저장하는 순간 합산될 경로가 생깁니다

기간 집계는 접기 전에 환산합니다. 오차는 눈이 아니라 숫자로만 잡힙니다

후보 하나는 잘 됐고, 그래서 안 썼습니다. 재현되지 않는 과거는 데이터가 아니라 그때그때의 의견입니다. 원본이 공개돼 있고 키도 비용도 없다면, 감싼 것 말고 원본을 칩니다.

#운영의기술#환율API#데이터원천#재현성#외부의존#파생값저장#fill-forward#배치설계