다중 통화 비용 정산의 여섯 가지 금액 버그
2026-08-21 감사는 테스트가 통과시켜 온 여섯 가지 통화 결함을 찾아냈고, 그 원인은 조용히 사실이 아니게 된 문서 속 한 문장이었습니다.
문서에 남아 있던 낡은 문장 하나가 여섯 개의 금액 버그를 낳았습니다. 2026-08-21에 진행한 Dimesum 다중 통화 감사는 우리 테스트 스위트가 통과시켜 온 코드에서 이 버그들을 찾아냈고, 그중에는 ¥6,000 채무를 -₹60.00로 표시한 청구 페이지도 있었습니다. 그 문장은 "그룹은 INR로 고정된다"였습니다. W7까지는 참이었지만 배포되는 순간 거짓이 되었고, 그로부터 일주일 뒤에도 여전히 FOLLOWUPS.md에 남아 있었습니다.
낡은 문장은 문서가 아예 없는 것보다 더 위험합니다. 나중의 감사는 그 문장을 믿고 그것이 다루는 경로를 건너뛰므로, 잘못된 한 줄은 침묵이 결코 낼 수 없는 손해를 냅니다. 빈 문서는 여러분을 소스 코드로 보냅니다. 잘못된 문서는 여러분을 전혀 엉뚱한 곳으로 보냅니다.
현실이 된 후속 항목이 열린 항목보다 나쁜 이유
Dimesum은 미뤄 둔 작업을 FOLLOWUPS.md에 보관하는데, 결정 하나당 한 행에 보류된 이유를 적습니다. 항목 A17은 그룹이 INR로 고정되어 있어 해외여행을 발생한 통화로 기록할 수 없으며, 그 수정은 창업자의 결정을 기다린다고 적혀 있었습니다. 그런데 W7은 그대로 다중 통화를 배포했습니다. 비용은 어떤 통화로도 기록될 수 있고, 잔액은 마이그레이션 ledger/00005에 의해 (group_id, member_id, currency)로 키가 매겨집니다. 아무도 그 행을 지우지 않았습니다.
다음 감사는 A17을 읽고 그 영역이 아직 만들어지지 않았다고 결론지었으며, 정산, 청구, 랜딩 코드를 한 번도 열어 보지 않았습니다. 통화를 구분하지 못하는 세 개의 금액 경로가 문장 하나에 기대어 계속 배포되었습니다. 앞을 막는 어려운 문제는 없었습니다. app/amount.py의 독스트링도 같은 주장을 담고 있어서, 읽는 사람은 같은 말을 두 번 듣게 되었습니다.
후속 항목은 그것을 마무리하는 바로 그 커밋에서 지우십시오. 더 이상 그렇게 동작하지 않는 코드를 설명하는 행은 여러분이 읽어야 할 파일에서 멀어지게 하는 안내이며, 아무 말도 하지 않는 것보다 더 큰 비용을 치릅니다.
세 파일에서 똑같이 나타난 하드코딩된 100
여기서 금액은 int64 최소 단위에 ISO 4217 코드를 더한 것이며, 부동소수점은 여기에 절대 닿지 않습니다. 정수는 구조상 정확하므로 남은 위험한 산술은 주 단위와 최소 단위 사이의 변환뿐입니다. 최소 단위가 항상 100분의 1인 것은 아닙니다. JPY에는 최소 단위가 아예 없고, KWD는 소수점 세 자리를 가지며, 하드코딩된 100 하나하나는 사용자가 집에만 있었다는 데 거는 내기입니다.
파이썬 사이드카의 app/amount.py가 첫 발견지였고, 실제로 발생한 결함이라기보다 지뢰로서 감사 이전에 제거되었습니다. 이 코드는 문장에서 읽어 낸 수치가 무엇이든 100을 곱했기에, ¥1,200 영수증이 120,000 최소 단위가 되었습니다. 그것은 ¥120,000으로 읽힙니다. 최소 단위가 없는 통화에서 이 상수는 누군가의 청구액을 백 배로 부풀리며, 이제 이 함수는 대신 요청 통화의 지수를 조회합니다.
감사는 사람이 잔액을 받아들일지 결정하는 그 페이지에서도 같은 상수를 찾아냈습니다. /m/{token}의 청구 랜딩 뒤에 있는 formatMinor는 100으로 나눈 뒤 앞에 루피 기호를 붙였습니다. ¥6,000 채무가 -₹60.00로 출력되었으니, 기호도 틀렸고 금액도 100분의 1이었습니다. 그 JSON 형제인 /v1/claims도 함께 실패해, 한 구성원의 통화별 행을 하나로 뭉개고 맵이 마지막으로 쓴 통화만 남겼습니다.
세 번째 파일은 internal/ingestion/store.go이며, 감사가 아니라 W8이 중복 감지를 확장하던 중에 찾아냈습니다. 그 "루피 플러스마이너스 1" 허용 오차는 상수 100, 즉 paise로 센 1루피였는데, 최소 단위가 전혀 없는 JPY에서는 플러스마이너스 ¥100의 범위가 되었습니다. 이제 셋 다 지수를 읽습니다.
맨 정수 비교가 엔화를 루피처럼 보이게 한 문제
Dimesum의 중복 제거 계층은 새로 들어온 항목을 최근 비용과 대조해, 가져온 영수증이 누군가 손으로 입력한 주문을 이중 청구하지 못하게 합니다. 그 허용 오차 범위 옆의 질의에는 통화 필터가 전혀 없었습니다. 그래서 ¥1,000이 ₹1,000과 일치했고, 가져오기가 아무 관련도 없는 비용을 대체하겠다고 제안했습니다. 그룹은 ledger/00005 이후로 다중 통화였으므로, 이 경우는 이론적이라기보다 실제로 도달 가능한 것이었습니다.
맨 최소 단위는 통화 간에 비교할 수 없으며, 허용 오차도 마찬가지입니다. 이제 프로젝션은 통화로 필터링하고, 비교하는 통화의 주 단위 하나에서 그 범위를 도출합니다.
"누가 누구에게 갚는가"에 답한 두 엔진, 그리고 통화를 몰랐던 두 번째 엔진
비싼 값을 치른 결함은 산술이 아니라 구조에 있었습니다. 정산 쓰기 경로는 internal/platform/simplify 위에서 계획을 세웠는데, 이는 Balance 구조체에 통화 필드가 없는 두 번째 최소 현금 흐름 엔진이었습니다. 통화별로 합이 0이면 통합 합계도 0이 되므로, ¥300,000 채무와 ₹500 채무가 서로 상쇄되어 아무것도 남지 않았고 어떤 가드도 그 계획을 거부하지 않았습니다. 그 결과 계획은 엔화 채권자를 루피 채무자와 짝지었습니다.
쓰기는 상황을 더 악화시켰습니다. 서비스는 모든 정산에 Currency: g.DefaultCurrency를 찍었기에, ¥300,000 채무가 INR 원장 기록을 승인했고 엔화 채무는 아예 청산될 수 없었습니다.
simplify는 삭제되었고 /settle-plan은 폐기되었습니다. 이제 유일한 엔진은 internal/platform/settle이며, 통화를 변환 가능한 것과 불가능한 것으로 나누고, 순잔액을 한 번 변환한 뒤, 변환 불가능한 각 통화를 자기 단위로 라우팅합니다. 정산은 자신이 청산하는 통화를 명시하며, 그룹이 둘 이상의 통화를 가지면 그 필드는 필수가 됩니다.
0이라는 상한이 상한 없음으로 읽힌 문제
초과 지불 가드는 정산하려는 채무보다 큰 지불을 거부합니다. 이 가드는 outstanding > 0 && amount > outstanding으로 읽었기에, 0이라는 상한은 비교를 통째로 건너뛰었습니다. 존재하지 않는 채무에 대해 지불을 기록하는 누군가야말로 이 규칙이 존재하는 이유인데, 바로 그 경우가 무사통과했습니다.
수정은 조건이 아니라 타입입니다. 이제 OutstandingMinor는 *int64이므로, "아무도 이것을 계산하지 않았다"를 "답은 0이다"와 같은 방식으로 표기할 수 없습니다. nil은 검사를 건너뛰며 진짜로 알 수 없음을 뜻하고, 원장 접근 권한이 있는 호출자는 실제 숫자를 넘깁니다.
초록빛 테스트 스위트가 아무것도 증명하지 못한 이유
이 경로들의 모든 픽스처는 INR을 사용했습니다. 통화를 구분하지 못하는 비교는 단일 통화 테스트에서는 드러나지 않는데, 통화가 하나뿐이면 혼동할 것이 없기 때문입니다. 스위트는 약한 것이 아니라 좁았고, 그것을 넓혔을 감사는 A17에 의해 돌려보내졌습니다.
엔드투엔드 원장 검증기도 같은 맹점을 공유했는데, 이 대목이 기억할 가치가 있습니다. 검증기는 통화별로 묶지 않고 구성원별로 기록을 합산했기에, 건강한 두 통화 그룹에는 헛경보를 울렸고 두 번 망가진 그룹에서는 합계가 0으로 나왔습니다. 위험한 방향은 거짓 음성입니다. 코드와 같은 가정 위에 세운 검사기는 언제나 코드와 의견이 같습니다.
| 결함 | 위치 | JPY 그룹이 겪은 결과 | 수정 |
|---|---|---|---|
통화가 없는 Balance 구조체 | platform/simplify | 엔화 채권자가 루피 채무자와 짝지어짐 | simplify 삭제, settle가 통화별로 분할 |
| 모든 정산에 찍힌 그룹 기본값 | 정산 서비스 | ¥300,000 채무가 INR 원장 기록을 승인 | 정산이 청산하는 통화를 명시 |
초과 지불 가드의 outstanding > 0 | 정산 서비스 | 0이라는 상한이 상한 없음이 됨 | *int64: nil은 알 수 없음, 0은 0 |
| 루피 기호와 함께 100으로 나눔 | 게이트웨이 formatMinor | ¥6,000 채무가 -₹60.00로 출력됨 | 기호와 소수 자릿수를 통화에서 가져옴 |
| 통화별 잔액이 한 행으로 뭉개짐 | /v1/claims | 맵이 마지막으로 쓴 통화 | 통화별로 한 항목씩, GET /balances와 일치 |
| 구성원별로 합산되고 통화가 빠진 기록 | 엔드투엔드 원장 검증기 | 두 번 망가진 그룹이 균형 잡힌 것으로 보고됨 | 구성원과 통화로 묶음 |
금액 코드에서 리터럴 100을 grep 하십시오
그 상수를, 그리고 통화를 옆에 두지 않은 채 두 금액을 나란히 놓는 모든 비교를 검색하십시오. 그런 다음 더 값싼 일, 다음 여섯 개를 막아 주는 일을 고치십시오. 후속 항목을 그것을 마무리하는 바로 그 커밋에서 지우는 것입니다. 그리고 중복된 엔진은 고치기보다 삭제하십시오. "누가 누구에게 갚는가"에 대한 두 개의 답은 그중 하나가 계속 틀린 채로 남는 방식입니다.
자주 묻는 질문
다중 통화 비용 정산이란 무엇인가요?
다중 통화 비용 정산은 각 비용을 발생한 통화로 기록하고, 모든 것을 하나로 변환하는 대신 통화별로 별도의 잔액을 유지합니다. Dimesum은 잔액을 그룹, 구성원, 통화로 키를 매기므로 엔화 채무와 루피 채무가 결코 하나의 숫자로 합쳐지지 않습니다. 변환은 정산 계획에 쓰이는 뷰일 뿐, 저장되는 금액이 아닙니다.
금액 코드에서 하드코딩된 100은 왜 위험한가요?
하드코딩된 100은 모든 통화가 소수점 두 자리를 가진다고 가정하지만, 그렇지 않은 통화가 여럿 있습니다. JPY에는 최소 단위가 없어서 100을 곱하면 ¥1,200 영수증이 120,000 최소 단위가 되는데, 이는 반올림 오차가 아니라 백 배 부풀림입니다. KWD는 소수점 세 자리를 가지므로 같은 상수가 열 배 어긋납니다. 대신 ISO 4217 지수를 읽으십시오.
두 정산 엔진이 서로 다른 답을 내지 않게 하려면 어떻게 하나요?
두 엔진을 맞추려 하기보다 그중 하나를 삭제하십시오. 누가 누구에게 갚는가에 대한 두 번째 답은 첫 번째 답이 계속 틀린 채로 남는 방식이기 때문입니다. Dimesum은 simplify와 settle를 나란히 돌렸는데, 감사가 첫 번째 엔진의 잔액 타입에 통화가 없음을 찾아냈고 이 때문에 계획이 엔화 채권자를 루피 채무자와 짝지을 수 있었습니다. simplify는 제거되었고 /settle-plan은 패치되기보다 폐기되었습니다.
테스트 스위트는 왜 여섯 개의 금액 버그를 통과시켰나요?
테스트 스위트가 초록빛을 유지한 이유는 해당 경로의 모든 픽스처가 단일 통화를 썼고, 통화가 하나뿐이면 통화를 구분하지 못하는 비교가 실패할 수 없기 때문입니다. 엔드투엔드 원장 검증기도 같은 가정을 가지고 있었습니다. 통화별로 묶지 않고 구성원별로 기록을 합산했기에, 두 번 망가진 그룹을 균형 잡힌 것으로 보고했습니다. 코드 자신의 가정 위에 세운 검사기는 코드와 의견이 같습니다.
기능을 배포한 뒤 후속 항목은 어떻게 처리해야 하나요?
후속 항목은 그것이 설명하는 작업을 마무리하는 바로 그 커밋에서 지우십시오. 조용히 현실이 되어 버린 후속 항목은 열려 있는 항목보다 나쁜데, 나중에 읽는 사람을 그것이 설명하던 코드에서 멀어지게 하기 때문입니다. Dimesum은 다중 통화가 배포된 뒤에도 일주일 동안 그룹이 INR로 고정되어 있다는 항목을 남겨 두었고, 다음 감사는 그 말을 믿고 세 개의 금액 경로를 건너뛰었습니다.