3억 2,700만 달러짜리 타입 에러

2022년, 한 대형 핀테크 플랫폼이 32,700,000.00의 대량 이체를 처리했습니다. 금액은 평범한 number로 저장되었습니다. 하위 서비스는 센트 단위라고 가정했습니다. 그렇지 않았습니다.

버그는 코드 리뷰, 단위 테스트, 통합 테스트를 모두 통과했습니다. 타입 시스템은 numbernumber를 보고 하루를 마감했습니다. 두 개의 동일한 타입, 완벽하게 호환되고, 재앙적으로 잘못되었습니다.

이것이 곧 화폐 문제의 핵심입니다. 타입 시스템은 문자열에 정수를 더하는 것을 막습니다. 그러나 일본 엔에 미국 달러를 더하거나, 주요 단위 금액을 부수 단위 금액으로 취급하는 것은 막지 않습니다. 이것들은 의미적으로 다른 양이지만, TypeScript, Rust, Go 및 대부분의 주류 언어에서는 단일 타입을 공유합니다.

네이티브 숫자 타입이 돈을 다루는 데 실패하는 이유

number, f64, int, BigDecimal. 모두 크기를 인코딩하지만, 의미는 인코딩하지 않습니다.

number는 100달러, 100센트, 100유로를 표현할 수 있습니다. 타입 시스템은 이 세 가지를 동일하게 취급합니다. 더할 수 있고, 비교할 수 있으며, number를 기대하는 어떤 함수에도 불만 없이 전달할 수 있습니다.

function processPayment(amount: number) {
  // Is this dollars? Cents? Euros?
  // The type system does not know, so it cannot help you.
}

const usd = 100        // 100 USD
const cents = 10000    // 10000 cents, also 100 USD
const eur = 100        // 100 EUR

processPayment(usd + eur)     // Compiles. Wrong.
processPayment(usd + cents)   // Compiles. Also wrong.

표준 방어책은 명명 규칙입니다. 변수를 amountInCents 또는 amountUsd라고 부르는 것입니다. 누군가 리팩토링하거나, 경계를 넘어 값을 복사하거나, 단순히 이름을 잘못 읽을 때까지는 효과가 있습니다. 주석과 명명 규칙은 강제할 수 없습니다.

필요한 것은 숫자 값과 통화 단위를 모두 인코딩하는 타입입니다. 컴파일러는 USD + EURstring + number를 거부하는 것과 같은 방식으로 거부해야 합니다.

팬텀 타입: 컴파일러에게 통화를 가르치기

효과가 있는 기법을 팬텀 타입이라고 합니다. 숫자를 둘러싼 제네릭 래퍼를 정의하는데, 타입 매개변수가 통화 레이블을 전달합니다. 타입 매개변수는 런타임에 절대 나타나지 않지만, 컴파일러는 제약을 강제하는 데 사용합니다.

다음은 TypeScript에서 완전히 작동하는 구현입니다:

// money.ts

declare const brand: unique symbol;

type Currency = "USD" | "EUR" | "JPY" | "GBP";

type Money<C extends Currency> = number & {
  readonly [brand]: C;
};

function money<C extends Currency>(amount: number, _currency: C): Money<C> {
  return amount as Money<C>;
}

function add<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
  return (a + b) as Money<C>;
}

function subtract<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
  return (a - b) as Money<C>;
}

brand 심볼은 명목적 타입 구분을 만듭니다. 서로 다른 통화 브랜드를 가진 두 Money 값은 내부적으로 number라 할지라도 호환되지 않습니다.

사용 예시는 다음과 같습니다:

const price = money(100, "USD");
const tax = money(8.5, "USD");
const total = add(price, tax); // Money<"USD">, OK

const invoice = money(500, "EUR");
const wrong = add(price, invoice);
// Error: Argument of type 'Money<"EUR">' is not assignable
// to parameter of type 'Money<"USD">'.

이제 컴파일러는 USD와 EUR이 다른 것임을 이해합니다. 실수로 더할 수 없습니다. USD를 기대하는 함수에 EUR을 전달할 수 없습니다. 오류는 프로덕션 원장이 아닌 호출 지점에서 표면화됩니다.

변환을 명시적으로 처리하기

모든 혼합을 막는 타입 시스템은 사용할 수 없을 것입니다. 실제 시스템은 항상 통화를 변환합니다. 요령은 변환을 명시적이고, 추적 가능하며, 감사 가능하게 만드는 것입니다.

type ExchangeRate<From extends Currency, To extends Currency> = {
  readonly from: From;
  readonly to: To;
  readonly rate: number;
};

function convert<From extends Currency, To extends Currency>(
  amount: Money<From>,
  rate: ExchangeRate<From, To>
): Money<To> {
  return (amount * rate.rate) as Money<To>;
}

이제 통화 변환은 타입 수준의 서류 추적이 있는 일급 연산입니다. ExchangeRate 없이는 변환할 수 없으며, 환율 자체도 출발 통화와 도착 통화로 타입이 지정됩니다.

const usdToEur: ExchangeRate<"USD", "EUR"> = {
  from: "USD",
  to: "EUR",
  rate: 0.92,
};

const euros = convert(price, usdToEur); // Money<"EUR">

잘못된 환율을 사용하려고 하면 컴파일러가 막습니다. 환율 정의에서 fromto를 바꾸면 해당 환율을 사용하는 모든 호출 지점이 타입 에러가 됩니다. 버그는 커밋하기 전에 잡힙니다.

주요 단위 대 부수 단위의 함정

통화가 유일하게 모델링할 수 있는 차원은 아닙니다. 2022년 핀테크 버그는 통화 불일치가 아니었습니다. 단위 불일치였습니다: 달러 대 센트.

팬텀 타입은 이것도 처리합니다. 단위에 두 번째 타입 매개변수를 추가하세요.

type Unit = "major" | "minor";

type Money<C extends Currency, U extends Unit> = number & {
  readonly [brand]: { currency: C; unit: U };
};

function money<C extends Currency, U extends Unit>(
  amount: number,
  _currency: C,
  _unit: U
): Money<C, U> {
  return amount as Money<C, U>;
}

function toMinor<C extends Currency>(
  amount: Money<C, "major">
): Money<C, "minor"> {
  return (amount * 100) as Money<C, "minor">;
}

function toMajor<C extends Currency>(
  amount: Money<C, "minor">
): Money<C, "major"> {
  return (amount / 100) as Money<C, "major">;
}

이제 컴파일러는 통화와 단위를 모두 추적합니다:

const dollars = money(100, "USD", "major");
const cents = money(10000, "USD", "minor");

const bad = add(dollars, cents);
// Error: 'Money<"USD", "minor">' not assignable to 'Money<"USD", "major">'

const fixed = add(dollars, toMajor(cents)); // Money<"USD", "major">, OK

변환 함수는 단위 경계를 넘는 유일한 방법입니다. 모든 경계 넘김은 명시적이고, grep으로 검색 가능하며, 리뷰할 수 있습니다.

이 패턴이 실제로 아픈 곳

팬텀 타입은 공짜가 아닙니다. 일상적인 연산에 마찰을 추가하고 모든 돈 문제를 해결하지는 않습니다.

직렬화가 첫 번째 고통 포인트입니다. JSON에는 브랜디드 타입 개념이 없습니다. JSON.stringifyMoney<"USD">를 문자열화하면 평범한 숫자가 나옵니다. 다시 파싱하면 평범한 숫자가 됩니다. 모든 경계에서 검증하고 재브랜딩해야 합니다.

function parseMoney<C extends Currency>(
  raw: number,
  currency: C
): Money<C> {
  if (typeof raw !== "number" || !isFinite(raw)) {
    throw new Error("Invalid money value");
  }
  return money(raw, currency);
}

서드파티 라이브러리가 두 번째 고통 포인트입니다. 대부분의 수학, 포맷팅, 데이터베이스 라이브러리는 평범한 numberDecimal을 기대합니다. 간극을 메우기 위해 어댑터 함수나 래퍼 타입을 작성하는 데 시간을 쓸 것입니다.

부동 소수점이 세 번째입니다. 위의 예시는 number를 사용하는데, 이는 0.1 + 0.2 !== 0.3을 의미합니다. 금융 소프트웨어에서 이는 용납할 수 없습니다. 돈을 부수 단위의 정수로 저장하거나, 적절한 십진 라이브러리를 사용하고, number 대신 그것을 브랜딩해야 합니다.

import { Decimal } from "decimal.js";

type Money<C extends Currency> = Decimal & {
  readonly [brand]: C;
};

더 간단한 대안: 그냥 객체를 사용하세요

팬텀 타입이 과하다고 느껴진다면, 런타임 검증이 있는 평범한 객체로 대부분의 안전성을 더 적은 타입 장치로 얻을 수 있습니다.

type Money = {
  readonly amount: number;
  readonly currency: Currency;
  readonly unit: Unit;
};

function add(a: Money, b: Money): Money {
  if (a.currency !== b.currency || a.unit !== b.unit) {
    throw new Error(
      `Cannot add ${a.currency} ${a.unit} to ${b.currency} ${b.unit}`
    );
  }
  return { ...a, amount: a.amount + b.amount };
}

이것은 컴파일 타임이 아닌 런타임에 오류를 잡습니다. 트레이드오프는 단순성입니다. 내부 도구, 프로토타입, 또는 TypeScript에 익숙하지 않은 팀에게 객체 접근법은 종종 올바른 시작점입니다. 나중에 언제든지 팬텀 타입을 추가할 수 있습니다.

하나의 불변성부터 시작하세요

첫날부터 모든 통화와 모든 단위를 모델링할 필요는 없습니다. 이전에 당신을 화나게 했던 하나의 불변성을 골라 브랜딩하고 강제하세요.

팀이 달러 대 센트 버그를 배포한 적이 있다면, 단위 추적부터 시작하세요. 보고서에서 통화를 혼합한 적이 있다면, 통화 추적부터 시작하세요. 하나의 브랜디드 타입, 하나의 변환 함수, 하나의 컴파일 타임 보장이 종종 다음 100만 달러짜리 실수를 막기에 충분합니다.

타입 시스템은 환율 로직을 작성하거나, 올바른 반올림을 처리하거나, 잘못된 환율로 나누는 것을 막지 않을 것입니다. 그것이 할 일은 실수를 불가능하게 만드는 것입니다. 절대 만나서는 안 될 두 값이 만나면 컴파일을 거부할 것입니다. 그것이 추가 타입의 가치입니다.