자바의 자료형을 공부하다가 의문이 생겨 추가 학습 해보았습니다.
📌구글 제미나이 AI에게 질문을 바탕으로 정리한 저의 기록입니다.
Go는 소수점 연산에서 버그가 생기는 것을 막기 위해 금액을 다룰 때 소수점을 없앤 int64 자료형을 보통 많이 쓴다.
한국 원화는 int64로 10,000 정수 그대로를 표현한다.
미국 달러라면 1.25달러 대신 센트 단위인 125로 저장된다.
기본적으로 "Go에서는 float을 안 쓸 뿐이지 정수 연산에 오차는 없다."
다만 결제/핀테크에서 금리/환율/수수료는 부동소수점 (float) 타입은 거의 하지 않는다. 이는 부동소수점 오차 때문에 원 단위 불일치나 정산 차이가 나기 때문이다.
이때 Go언어에서는 복잡하게 객체 만드는 것보다 깔끔하게 정수(int64)로 100배 계산한 다음, 마지막에 화면에 보여줄 때만 소수점을 찍자는 것이다.
Java도 마찬가지이다.
// ❌ 자바에서도 이렇게 짜면 금융 사고 납니다!
float a = 0.1f;
float b = 0.2f;
System.out.println(a + b); // 결과: 0.30000001192... (오차 발생)
대신 정수로 바꾸지 말고, 소수점 그대로 BigDecimal 객체에 담아서 .add(), .multiply() 메서드로 계산한다. Java 언어 자체(JDK)에 BigDecimal이라는 강력한 무기가 기본 탑재되어 있어 전 금융권이 이를 표준으로 쓴다.
BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
System.out.println(a.add(b)); // 결과: 정확하게 0.3
다만 이자율과 환율은 일반 금액 계산과 달리 소수점 아래 자릿수가 끝도 없이 늘어나거나 예측하기 힘들 수 있다.
이때는 int64만 쓰기에는 한계가 있을거라고 생각이 들었다.
그렇다면, Java는 BigDecimal을 사용하면 되지만, Golang은 어떻게 처리하는 지 궁금하여 이 부분을 질문했습니다.
Go언어로 결제/핀테크 기업에서는 이자율이나 환율 계산과 같은 복잡한 연산시 int64 변환 후 하는 방법 외에 어떤 방법을 이용하는 지 알려줘
Go 기본은 int64 정수 연산으로 속도와 안전성을 챙기되, 소수점 연산이 필수적인 금융 로직에서는 shopspring/decimal 같은 외부 표준 라이브러리를 가져와서 해결한다.
왜냐하면, 원화나 달러 금액 자체는 정수로 바꿀 수 있지만, 3.25%, 환율 1345.335처럼 어쩔 수 없이 소수점 계산을 해야 할 때가 있다. 이때 Go는 2가지 방법을 쓴다.
방법A. shopspring/decimal 표준 라이브러리 활용
특징
- 10진수를 coefficient(계수) + exponent(지수) 로 보관
- 예: 123.45 → 12345 × 10^-2
- + - * / 를 10진 규칙으로 수행 → 0.1 + 0.2 = 0.3 같은 문제 없음
- JSON/DB 문자열("123.45")과 맞추기 쉬움
장점
- API가 직관적 (Add, Mul, Round, StringFixed)
- 팀 온보딩·코드 리뷰가 쉬움
- 환율·이자율·수수료 곱셈·반올림 정책을 코드에 명시하기 좋음
단점
- int64보다 메모리·속도 부담
- 반올림 모드(RoundHalfUp 등)를 회사 규칙과 맞춰 문서화해야 함
package main
import (
"fmt"
"github.com/shopspring/decimal"
)
func main() {
// 환율: 1 USD = 1,352.75 KRW
usdAmount := decimal.NewFromString("100.00") // must check err in production
fxRate := decimal.NewFromString("1352.75")
krw := usdAmount.Mul(fxRate) // 135275.00
fmt.Println("KRW:", krw.StringFixed(0)) // 원화면 소수 0자리 반올림 정책 별도
// 이자: 원금 1,000,000원, 연이율 3.25%, 30일 단순 이자
principal := decimal.NewFromInt(1_000_000)
annualRate := decimal.NewFromString("0.0325")
days := decimal.NewFromInt(30)
yearDays := decimal.NewFromInt(365)
interest := principal.
Mul(annualRate).
Mul(days).
Div(yearDays).
Round(0) // 원 단위 절사/반올림은 사내 규칙
fmt.Println("interest:", interest) // 예: 2671 (정책에 따라 다름)
// 수수료 2.35% + VAT 10% (예시)
feeRate := decimal.NewFromString("0.0235")
fee := principal.Mul(feeRate).Round(0)
vat := fee.Mul(decimal.NewFromString("0.10")).Round(0)
fmt.Println("fee:", fee, "vat:", vat)
}
📌실무 팁
- 외부 API·DB에서 오는 금액은 NewFromString (float 문자열 금지)
- 저장: DB NUMERIC ↔ Go decimal.Decimal
- 최종 원화 정산만 int64로 내리는 팀도 많음: interest.IntPart() 전에 Round 정책 고정
방법B. Go 표준 패키지 math/big 의 big.Rat 활용
특징
- 값을 분자/분모 로 보관: 3/10, 365/360
- 나눗셈이 정확히 “분수”로 남음 → 중간에 부동소수점 오차 없음
- 최종 금액으로 바꿀 때 Float64() 쓰면 다시 오차 위험 → 보통 정수 스케일로 변환하거나 decimal로 넘김
외부 라이브러리 설치가 불가능하거나 Go 표준 패키지만으로 순수하게 해결해야 할 때는 math/big 패키지의 big.Rat (유리수 타입)을 사용한다.
장점
- 일수/연일수/실제/360 같은 금융 관례를 분수로 표현하기 좋음
- 이자 비율 단계만 정확히 유지할 때 유리
단점
- API가 낮고 (Add, Mul, SetFrac)
- 반올림·통화 소수 자릿수는 직접 구현
- JSON·DB 매핑이 decimal보다 번거로움
package main
import (
"fmt"
"math/big"
)
// Rat을 원 단위 int64로 내림 (정책: floor)
func ratToKRWFloor(r *big.Rat) int64 {
// 원 단위 = rat * 1 → 정수부만 (실무는 Round 모드 통일)
f := new(big.Float).SetRat(r)
i, _ := f.Int(nil)
return i.Int64()
}
func main() {
principal := big.NewRat(1_000_000, 1) // 1,000,000원
// 연이율 3.25% = 13/400
annualRate := big.NewRat(13, 400)
// 30/365 (단순 일할)
dayFraction := big.NewRat(30, 365)
interest := new(big.Rat).Mul(principal, annualRate)
interest.Mul(interest, dayFraction)
fmt.Println("interest (exact rational):", interest.FloatString(10))
fmt.Println("interest KRW floor:", ratToKRWFloor(interest))
// 365/360 convention (은행 관례 예시)
actualDays := big.NewRat(30, 1)
basis := big.NewRat(360, 1)
frac := new(big.Rat).Quo(actualDays, basis)
_ = frac
}
📌실무 팁
- 파이프라인: big.Rat으로 비율·기간 계산 → 마지막에 decimal 또는 int64로 한 번만 반올림
- Rat.FloatString은 디버그용, 정산 금액 확정은 정수화 함수 하나로 통일
방법C. 고정 소수점 커스텀 구조체 정의
대규모 트래픽을 처리해야 하는 결제 게이트웨이(PG)나 트레이딩 시스템에서는 shopspring/decimal마저도 객체 생성 오버헤드 때문에 느리다고 판단할 수 있다. 이때는 기업 내부에 전용 고정 소수점 구조체를 직접 만들어 쓴다.
특징
- 내부는 항상 정수 (int64), 소수 자릿수 scale 고정연산은 정수 연산 + 스케일 맞추기(shift) + 반올림
- 예: scale=4 → 1234567 = 123.4567
장점
- int64와 비슷하게 빠르고 예측 가능
- PG·카드처럼 자릿수가 고정된 도메인에 적합
- 외부 의존성 없음
단점
- 곱셈 시 overflow (int64 한계) → int64/big.Int 확장 필요
- 환율처럼 scale이 다른 값끼리 연산 규칙을 직접 짜야 함
- 팀마다 라이브러리가 달라질 수 있음
package main
import (
"errors"
"fmt"
)
// scale=4 → 1.2345 는 12345
type Fixed struct {
raw int64
scale int32 // 소수 자릿수
}
func NewFixedFromString(s string, scale int32) (Fixed, error) {
d, err := parseDecimalToRaw(s, scale)
if err != nil {
return Fixed{}, err
}
return Fixed{raw: d, scale: scale}, nil
}
func (f Fixed) Add(g Fixed) (Fixed, error) {
if f.scale != g.scale {
return Fixed{}, errors.New("scale mismatch")
}
return Fixed{raw: f.raw + g.raw, scale: f.scale}, nil
}
// 곱: (a * b) / 10^scale, 반올림 half-up
func (f Fixed) Mul(g Fixed) (Fixed, error) {
if f.scale != g.scale {
return Fixed{}, errors.New("scale mismatch")
}
prod := f.raw * g.raw
div := pow10(f.scale)
// half-up
if prod >= 0 {
prod = (prod + div/2) / div
} else {
prod = (prod - div/2) / div
}
return Fixed{raw: prod, scale: f.scale}, nil
}
func (f Fixed) String() string {
return formatRaw(f.raw, f.scale)
}
func pow10(n int32) int64 {
p := int64(1)
for i := int32(0); i < n; i++ {
p *= 10
}
return p
}
// 간단 파서 (실무는 decimal 라이브러리로 파싱 후 raw 변환 권장)
func parseDecimalToRaw(s string, scale int32) (int64, error) {
// ... 구현 생략 시 shopspring/decimal로 raw 변환하는 편이 안전
_ = s
return 12345, nil
}
func formatRaw(raw int64, scale int32) string {
_ = raw
_ = scale
return "123.4500"
}
func main() {
amount, _ := NewFixedFromString("1000.0000", 4)
rate, _ := NewFixedFromString("0.0325", 4) // 3.25%
fee, _ := amount.Mul(rate)
fmt.Println("fee:", fee.String())
}
📌실무 팁
- 원화 결제 금액은 scale=0 int64, 환율·수수료율만 scale=4~8 Fixed
- overflow 대비: 중간 연산은 big.Int로 올리는 Fixed 변형을 많이 씀
방법D. 데이터베이스(DB) 연산 위임 (Database-Driven Calculation)
애플리케이션(Go) 단에서 계산하지 않고, 금융 연산에 특화된 데이터베이스의 기능을 100% 활용하는 방식이다.
특징
- 애플리케이션은 문자열/NUMERIC으로 넘기고,
- 이자·환전·집계·정산은 SQL NUMERIC, DECIMAL, DB 함수로 계산
- PostgreSQL NUMERIC, MySQL DECIMAL, Oracle NUMBER 등
장점
- 정산·리포트·배치가 SQL 중심인 조직과 맞음
- 대량 집계 시 DB 엔진이 처리 (앱 메모리 절약)
- 레거시 ERP/정산 DB와 단일 진실 공급원(SSOT) 유지
단점
- 반올림 규칙이 SQL·DB마다 다를 수 있음 (버전/함수 확인 필요)
- 앱·DB 이중 구현 시 불일치 위험
- 단위 테스트·재현이 SQL fixture에 의존
-- 테이블: NUMERIC으로 저장
CREATE TABLE fx_trade (
id BIGSERIAL PRIMARY KEY,
usd_amount NUMERIC(18, 2) NOT NULL,
fx_rate NUMERIC(18, 6) NOT NULL, -- 1352.750000
krw_amount NUMERIC(18, 0) GENERATED ALWAYS AS (
ROUND(usd_amount * fx_rate, 0) -- 원화 반올림 정책
) STORED
);
INSERT INTO fx_trade (usd_amount, fx_rate)
VALUES (100.00, 1352.75);
-- 이자 배치 (단순 예시)
SELECT
loan_id,
principal,
ROUND(
principal * annual_rate * (days::NUMERIC / 365),
0
) AS interest_krw
FROM loan_accrual
WHERE accrual_date = CURRENT_DATE;
Go에서는 계산을 최소화하고, DB 결과만 읽도록 한다.
type AccrualRow struct {
LoanID int64
InterestKRW string // NUMERIC은 string/decimal로 받는 경우 많음
}
// database/sql + pgx: NUMERIC → string → decimal.NewFromString
📌실무 팁
- 실시간 결제 승인 경로는 latency 때문에 앱(decimal/int64)에서 처리
- 일마감·월마감·이자 계상은 DB/배치 SQL
- DB와 앱이 같은 규칙을 쓰려면 반올림 함수를 SQL로 한 번 정의하고 앱은 테스트로 검증
📌4가지 정리
[실시간 결제 API] → int64(원) 또는 decimal → 최종 int64
[환율·이자·수수료율] → decimal (A) 또는 Rat→decimal (B)
[초고속·자릿수 고정] → Fixed (C)
[정산/리포트/배치] → DB NUMERIC (D) + 앱은 검증만
| 방식 | 아이디어 | 대표 사용처 |
|
int64 스케일
|
amount = 12345 → 123.45원 (scale=2)
|
PG 정산, 원화 결제
|
|
A decimal
|
10진수 스케일을 라이브러리가 관리
|
환율·이자·수수료율
|
|
B big.Rat
|
유리수(분수) 정확 연산
|
이자율 = 365/360 같은 비율
|
|
C 고정소수점
|
내부는 정수, 소수 자릿수 고정
|
고성능·단순 통화
|
|
D DB 연산
|
NUMERIC/DECIMAL로 DB가 계산
|
레거시·정산 SQL·리포트
|
'Domain > 핀테크, 결제' 카테고리의 다른 글
| 결제 도메인을 공부하고자 하는 이유는 말입니다. (0) | 2026.05.18 |
|---|