본문 바로가기

Domain/핀테크, 결제

[핀테크/결제 도메인] Go 핀테크에서 금액을 다루는 4가지

자바의 자료형을 공부하다가 의문이 생겨 추가 학습 해보았습니다.

 

📌구글 제미나이 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·리포트