돈은 화폐 단위가 무엇이든 마치 부동소수점 숫자처럼 보입니다. 하지만 통화 데이터에 float 타입을 사용하는 것은 실수입니다.
Float 객체는 정의상 근사치(approximate)로 표현되는 실수이며, 시스템 아키텍처가 제공하는 배정밀도(double-precision) 부동소수점 방식에 의존합니다. 부정확한 숫자는 회계 담당자를 불행하게 만듭니다. 이 글에서는 Ruby와 Rails 환경에서 금액 데이터를 안전하게 다루기 위한 다양한 방법을 예제와 함께 살펴보겠습니다.
Float란 무엇인가?
앞서 언급했듯이 float 객체는 일반 정수와 다른 연산 방식을 사용합니다. 이것이 float가 부정확한 숫자가 되는 주요 원인입니다. 특히 Ruby를 비롯한 대부분의 프로그래밍 언어는 float를 표현하기 위해 고정된 개수의 이진수(binary digits)를 사용하며, 그 과정에서 십진수를 이진수로 변환했다가 다시 되돌리는 작업이 반복됩니다.
실수(십진수)를 이진수로 깊이 들여다보면, 일부 숫자는 고정된 자릿수 안에서 정확히 표현될 수 없습니다. 이때 남은 유일한 선택지는 시스템이 해당 값을 반올림(rounding)하는 것뿐입니다.
흔히 알고 있는 수학적 사실을 떠올려 보면 이해가 쉽습니다. 원주율(pi)처럼 무한히 이어지는 숫자는 고정된 자릿수 안에 온전히 담을 수 없습니다. Float는 보통 32비트 또는 64비트 수준의 정밀도까지만 유지할 수 있으며, 한계에 도달하면 숫자가 잘려나갑니다.
대표적인 예제를 살펴보겠습니다:
1200 * (14.0/100)
숫자의 백분율을 구하는 아주 단순한 계산입니다. 1200의 14%라면 당연히 168이 나와야겠죠. 그런데 Ruby에서 실행하면 결과는 다음과 같습니다:
1200 * (14.0/100)
=> 168.00000000000003
그런데 공식에 단순히 0.1%만 더해 보면 결과가 달라집니다:
1200 * (14.1/100)
=> 169.2
원하는 소수점 자릿수를 지정해 가장 가까운 값으로 round 처리할 수도 있습니다:
(my_calculation).round(2)
하지만 더 복잡한 계산에서는 결과가 어떻게 나올지 장담할 수 없으며, 특히 이런 값들을 서로 비교하는 로직에서는 문제가 심각해집니다.
부동소수점 연산의 과학적인 배경이 궁금하다면 오라클(Oracle)의 부록 문서 What Every Computer Scientist Should Know About Floating-Point Arithmetic를 읽어보길 강력히 추천합니다. float 숫자가 부정확할 수밖에 없는 이유를 상세히 설명해 줍니다.
믿음직한 선택, BigDecimal
다음 코드를 살펴보세요:
require "bigdecimal"
BigDecimal("45.99")
이 코드만으로도 전자상거래 장바구니 금액 같은 실제 비즈니스 로직을 정확하게 표현할 수 있습니다. 내부적으로 다뤄지는 값은 언제나 정확히 45.99이며, 45.9989나 45.99000009 같은 엉뚱한 값이 될 일이 없습니다.
이것이 바로 BigDecimal의 정밀함입니다. 일반적인 산술 연산에서는 float도 같은 결과를 내는 경우가 많지만, 어디서 오차가 발생할지 예측할 수 없다는 점이 가장 큰 위험 요소입니다.
앞서 살펴본 백분율 계산을 BigDecimal로 실행하면 다음과 같은 결과를 얻습니다:
require "bigdecimal"
(BigDecimal(1200) * (BigDecimal(14)/BigDecimal(100))).to_s("F")
=> 168.0
irb 콘솔에서 빠르게 실행해 볼 수 있도록 축약한 버전입니다. 원래 BigDecimal 객체를 그대로 출력하면 과학적 표기법(scientific notation)으로 나타나는데, 우리가 원하는 형태는 아니죠. to_s 메서드에 인자를 넘기면 포맷 설정에 따라 BigDecimal에 해당하는 부동소수점 값이 표시됩니다. 자세한 내용은 Ruby 공식 문서를 참고하세요.
소수점 자릿수에 제한을 두고 싶다면 truncate 메서드를 사용하면 됩니다:
(BigDecimal(1200) * (BigDecimal("14.12")/BigDecimal(100))).truncate(2).to_s("F")
=> 169.44
RubyMoney 프로젝트
RubyMoney는 바로 이런 문제들을 해결하기 위해 탄생했습니다. Ruby 생태계에서 금액 데이터를 손쉽게 다룰 수 있도록 훌륭한 라이브러리를 제공하는 것이 목표인 오픈소스 커뮤니티 프로젝트입니다.
프로젝트는 총 7개의 라이브러리로 구성되어 있으며, 그중에서도 특히 중요한 세 가지는 다음과 같습니다:
- Money: 금액과 환율 변환을 처리하기 위한 Ruby 라이브러리입니다. 웹 애플리케이션이든 아니든, 견고하고 모던한 애플리케이션에서 금액을 다룰 수 있는 다양한 객체 지향적 기능을 제공합니다.
- Money-rails: RubyMoney를 Ruby on Rails에 통합한 버전으로, money 라이브러리의 강력한 기능과 Rails의 유연성을 결합합니다.
- Monetize: 다양한 객체를
money객체로 변환해 주는 라이브러리입니다. 문자열 파싱이 많은 애플리케이션에서 보조 도구처럼 활용됩니다.
그 외에도 흥미로운 라이브러리가 네 가지 더 있습니다:
- EU_central_bank: 유럽 중앙은행(ECB)이 공개하는 환율 정보를 활용해 환율 계산을 도와주는 라이브러리입니다.
- Google_currency: Google Currency 환율을 참조하여 통화 변환을 수행하는 라이브러리입니다.
- Money-collection:
money객체들의 합계, 최솟값, 최댓값을 정확하게 계산해 주는 보조 라이브러리입니다. - Money-heuristics: money 젬을 위해 문자열 입력을 휴리스틱하게 분석하는 모듈입니다.
“Money” 젬
가장 유명한 money 젬부터 시작해 보겠습니다. 주요 기능은 다음과 같습니다:
- 금액, 통화, 소수점 구분자 등 핵심 금전 정보를 담는
money클래스 - 개발자가 사용하는 화폐 단위 정보를 감싸는
Money::Currency클래스 - 앞서 설명한 오류를 피하기 위해 기본적으로 부동소수점이 아닌 정수(integer) 기반으로 동작
- 한 통화에서 다른 통화로 변환하는 기능 — 정말 강력합니다
그뿐만 아니라, 프로젝트의 다른 모델처럼 일관된 객체 지향 구조를 통해 금액 데이터를 매우 유연하게 조작할 수 있습니다.
사용법도 간단합니다. 먼저 젬을 설치하세요:
gem install money
고정된 금액을 다루는 간단한 예제입니다:
my_money = Money.new(1200, "USD")
my_money.cents #=> 1200
my_money.currency #=> Currency.new("USD")
보시다시피 money는 센트(cents)를 기준으로 표현됩니다. 1200센트는 곧 12달러입니다.
BigDecimal에서 했던 것처럼 이 객체들로 기본적인 수학 연산도 자유롭게 할 수 있습니다. 예를 들면:
cart_amount = Money.new(10000, "USD") #=> 100 USD
discount = Money.new(1000, "USD") #=> 10 USD
cart_amount - discount == Money.new(9000, "USD") #=> 90 USD
흥미롭지 않나요? 앞서 말씀드린 객체의 본질이 바로 이것입니다. 코딩하는 동안 무의미한 일반 숫자가 아니라 실제 화폐 값을 다루고 있다는 느낌을 받게 됩니다.
환율 변환
자체 환율 체계를 갖추고 있다면 exchange bank 객체를 통해 통화 변환을 수행할 수 있습니다. 다음 예제를 보세요:
Money.add_rate("USD", "BRL", 5.23995)
Money.add_rate("BRL", "USD", 0.19111)
이후 두 통화 간에 값을 변환할 필요가 있을 때는 다음과 같이 실행하면 됩니다:
Money.us_dollar(100).exchange_to("BRL") #=> Money.new(523, "BRL")
산술 연산이나 값 비교 등에도 동일하게 적용됩니다.
그 외에도 다양한 통화 관련 속성들이 제공됩니다. 예를 들어 iso_code(해당 통화의 국제 3자리 코드 반환)와 decimal_mark(금액의 정수부와 소수부 사이에 들어가는 문자) 등이 있으니 자세한 내용은 공식 문서를 확인하세요.
아, 거의 잊을 뻔했습니다. money 젬을 설치하면 BigDecimal에 to_money 메서드가 추가되어 변환을 자동으로 수행해 줍니다.
“Monetize” 젬
RubyMoney 프로젝트에서 각 라이브러리가 맡은 역할을 이해하는 것이 중요합니다. 다른 Ruby 객체(예: String)를 Money 객체로 변환해야 한다면 바로 monetize가 답입니다.
먼저 젬 의존성을 설치하거나 Gemfile에 추가하세요:
gem install monetize
물론 money 젬도 함께 설치되어 있어야 합니다.
parse 메서드는 다른 형식으로 전달된 금액 데이터를 받을 때 특히 유용합니다. 예를 들면:
Money.parse("£100") == Money.new(100, "GBP") #=> true
이 파싱 메서드를 쓸 만한 상황은 제한적이지만, HTTP 요청으로 통화 코드가 붙은 형식화된 값을 받을 때는 매우 유용합니다. 웹에서는 모든 것이 텍스트이기 때문에 문자열을 money 객체로 변환하는 기능은 생각보다 자주 필요합니다.
다만 시스템이 값을 어떻게 조작하는지, 그리고 값이 조작(해킹)될 여지가 있는지 항상 주의해야 합니다. 금융 시스템에는 여러 겹의 보안 계층이 적용되어, 수신한 값이 실제 거래 금액과 일치하는지 보장해야 합니다.
“Money-rails” 젬
이 라이브러리는 동일한 금액 조작 기능을 Rails 애플리케이션 안에서 사용할 수 있게 해줍니다.
Rails와 함께 쓰려고 굳이 별도의 라이브러리가 필요할까요? 물론 일반적인 수학 연산 정도는 money 젬만으로도 Rails 프로젝트에서 사용할 수 있습니다. 하지만 Rails의 구조(모델, DB 등)가 money의 기능과 연동해야 하는 순간에는 제대로 동작하지 않습니다.
다음 예제를 살펴보세요:
class Cart < ActiveRecord::Base
monetize :amount_cents
end
실제로 동작하는 Rails 모델 객체입니다. 데이터베이스(모델 속성 이름을 다르게 쓰고 싶을 때 별칭(alias) 지정도 가능), Mongoid, REST 웹 서비스 등과 함께 사용할 수 있습니다.
지금까지 살펴본 모든 기능이 이 젬에도 동일하게 적용됩니다. 보통은 몇 가지 추가 설정만 필요하며, 해당 설정은 config/initializers/money.rb 파일에 작성하면 됩니다:
MoneyRails.configure do |config|
# 기본 통화 설정
config.default_currency = :usd
end
이렇게 하면 지정한 통화가 기본값으로 설정됩니다. 하지만 개발 과정에서 환율 변환을 수행하거나 여러 모델에서 둘 이상의 통화를 처리해야 할 때가 있습니다.
그런 경우 money-rails는 모델 수준(model-level)의 통화 정의를 지원합니다:
class Cart < ActiveRecord::Base
# 모델 수준 통화로 EUR 사용
register_currency :eur
monetize :amount_cents, as "amount"
monetize :discount_cents, with_currency: :eur
monetize :converted_value, with_currency: :usd
end
설정을 마치고 나면 프로젝트 어디에서든 money 타입을 아주 손쉽게 활용할 수 있습니다.
마무리
이번 글에서는 Ruby와 Rails 생태계에서 금액 값을 다루는 몇 가지 방법을 살펴보았습니다. 핵심 내용을 정리하면 다음과 같습니다:
- float 숫자, 특히 금액 데이터를 다루는 계산이라면
BigDecimal또는Money인스턴스를 사용하세요. - 개발 과정에서 불일치가 누적되지 않도록 하나의 시스템만 일관되게 사용하세요.
money라이브러리는 RubyMoney 시스템 전체의 핵심으로, 견고하고 직관적입니다.money-rails는 Rails 애플리케이션용 대응 버전이며, 어떤 값을Money객체로 파싱해야 할 때는monetize가 필요합니다.Float사용을 피하세요. 지금 앱이 계산 기능이 필요 없더라도, 언젠가 경고를 무시한 개발자가 float를 사용할 가능성이 있습니다. 그때 당신이 곁에 있어 막아줄 수 있으리란 보장은 없습니다.
공식 문서를 항상 확인하는 습관을 잊지 마세요. BigDecimal 문서에는 훌륭한 설명과 사용 예제가 가득하며, RubyMoney 젬 프로젝트들 역시 마찬가지입니다.