[Java] 금액 계산 타입: BigDecimal

bien·2025년 3월 12일
0

java

목록 보기
11/11

1. BigDecimal?

  • BigDecimal은 Java 언어에서 숫자를 정밀하게 저장하고 표현할 수 있는 유일한 방법이다.
  • 소수점을 저장할 수 있는 가장 크기가 큰 타입인 double은 소수점의 정밀도에 있어 한계가 있어 값이 유실될 수 있다.
  • Java 언어에서 돈과 소수점을 다룬다면 BigDecimal은 선택이 아니라 필수다.
  • BigDecimal의 유일한 단점은 느린 속도와 기본 타입보다 조금 불편한 사용법 뿐이다.

2. 기본 숫자 타입의 한계

정수타입(int, long)

  • 장점: 처리 속도가 빠르고 메모리 사용이 적다
  • 단점: 소수점 표현이 불가능하여 금액의 센트/원 단위 처리를 위해 별도 로직이 필요하다.
// 10,000원 50전을 표현하기 위해 정수를 사용할 경우
int amount = 1000050; // 10,000원 50전 (원 단위)

부동 소수점 타입(float, double)

  • 장점: 소수점 표현이 가능하고 넓은 범위의 값을 표현할 수 있다.
  • 단점: 이진 부동 소수점 방식으로 인한 정확도 문제가 있다.
double price = 10.25;
double quantity = 3;
double total = price * quantity; // 기대값: 30.75
System.out.println(total); // 출력: 30.750000000000004 (오차 발생)

double은 뭐가 문제인걸까?

double은 내부적으로 수를 저장할 때 이진수의 근사치를 저장한다.
저장된 수를 다시 십진수로 표현하면서 정확도 문제가 발생한다.
BigDecimal 타입은 내부적으로 수를 십진수로 저장하여 작은 수와 큰 수의 연산에 대해 거의 무한한 정밀도를 보장한다.

3. BigDecimal 분석

기본 용어

  • precision
    • 숫자를 구성하는 전체 자리수. 정확히는 왼쪽부터 0이 아닌 수가 시작하는 위치부터 오른쪽부터 0이 아닌 수로 끝나는 위치까지의 총 자리수
    • unscale의 동의어
    • ex) 012345.67890의 precision은 11이 아닌 9이다.
  • scale
    • 전체 소수점 자리 수. 정확히는 소수점 첫째 자리부터 오른쪽부터 0아닌 수로 끝나는 위치까지의 총 소수점 자리수
    • fraction과 동의어
    • ex) 012345.67890의 scale은 4. 하지만 0.00, 0.0의 scale은 모두 1이다.
    • BigDecimal은 32bit의 소수점 크기를 가진다.
  • DECIMAL128
    • IEEE 754-2008에 의해 표준화된, 부호와 소수점을 수용하며, 최대 34자리까지 표현 가능한 10진수를 저장할 수 있는 형식.
    • 2018년 미국 정부의 총 부채액이 15조 7천 500억 달러로 총 14자리 임을 감안하면, 금융권에서 처리되는 대부분의 금액을 수용할 수 있는 크기.
    • Java에서는 BigDecimal 타입을 통해 공식적으로 지원한다.

기본 상수

flot, double과 달리 초기화가 어려워 자주 쓰는 0, 1, 10은 쓰기 편하게 미리 상수로 정의되어 있다.

// 0
BigDecimal.ZERO

// 1
BigDecimal.ONE

// 10
BigDecimal.TEN

초기화

double 타입으로 부터 BigDecimal 타입을 초기화하는 가장 안전한 방법은 문자열의 형태로 생성자에 전달하여 초기화하는 것이다.
double 타입의 값을 그대로 전달할 경우 앞서 사칙연산 결과에서 본 것과 같이 이진수의 근사치를 가지게 되어 예상과 다른 값을 얻을 수 있다.

// double 타입을 그대로 초기화하면 기대값과 ㅏ른 값을 가진다.
// 0.01000000000000000020816681711721685132943093776702880859375
new BigDecimal(0.01);

// 문자열로 초기화하면 정상 인식
0.01
new BigDecimal("0.01");

// 위와 동일한 결과, double#toString을 이용하여 문자열로 초기화
// 0.01
BigDecimal.valueOf(0.01);

비교 연산

BigDecimal은 기본 타입이 아닌 오브젝트이기 때문에 특히, 동등 비교 연산을 유의해야 한다. double 타입을 사용하던 습관대로 무의식적으로 == 기호를 사용하면 예기치 않은 연산 결과를 초래할 수 있다.

BigDecimal a = new BigDecimal("2.01");
BigDecimal b = new BigDecimal("2.010");

// 객체의 레퍼런스 주소에 대한 비교 연산자로 무의식적으로 값의 비교를 위해 사용하면 오동작

따라서, BigDecimal값을 비교하려면 아래와 같은 방법을 사용해야 한다.

1. equals() 메서드 사용

BigDecimal 객체의 값과 스케일이 정확히 같은지 확인한다.
스케일이 다르면 값이 같더라도 false를 반환한다.

BigDeciaml a = new BigDecimal("2.01");
BigDecimal b = new BigDecimal("2.010");
boolean isEqual = a.equals(b); // false (스케일이 다름)

2. compareTo() 메서드 사용

숫자 값만 비교하며 스케일은 무시한다.

  • 반환값
    • 0 : 값이 같음
    • 1 : 첫 번째 값이 더 큼
    • -1 : 두 번째 값이 더 큼
BigDecimal a = new BigDecimal("2.01");
BigDecimal b = new BigDecimal("2.010");
int result = a.compareTo(b); // 0 (숫자 값은 같음)

⚠️ 주의사항

  • 숫자 값만 비교하고 싶다면 compareTo()를 사용하는 것이 일반적이다.
  • equlas()는 스케일까지 엄격히 비교하므로 주의가 필요하다.
  • == 연산자는 절대 사용하지 말아야 한다.

사칙 연산

BigDecimal a = new BigDecimal("10");
BigDecimal b = new BigDecimal("3");

// 더하기
// 13
a.add(b);

// 빼기
// 7
a.sbtract(b);

// 곱하기
// 30
a.mutlply(b);

// 나누기
// 3.333333...
// java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
a.divide(b);

// 나누기
// 3.333
a.divide(b, 3, RoundingMode.HALF_EVEN);

// 나누기 후 나머지
// 전체 자리수를 34개로 제한
// 1
a.remainder(b, MathContext.DECIMAL128);

// 절대값
// 3
new BigDecimal("-3").abs();

// 두 수 중 최소값
// 3
a.min(b);

// 두 수 중 최대값
// 10
a.max(b);

BigDecimal과 Java Stream

// POJO 목록에서 BigDecimal 타입을 가진 특정 필드의 합을 반환
BigDecimal sumOfFoo = fooList.stream()
    .map(FooEntity::getFooBigDecimal)
    .filter(foo -> Objects.nonNull(foo))
    .reduce(BigDecimal.ZERO, BigDecimal::add);

// 특정 BigDecimal 필드를 기준으로 오름차순 정렬된 리스트를 반환
foolist.stream()
    .sorted(Comparator.comparing(it -> it.getAmount()))
    .collect(Collectors.toList());

// 위와 동일한 기능, 정렬된 새로운 리스트를 반환하지 않고 원본 리스트를 바로 정렬
foolist.sort(Comparator.comparing(it -> it.getAmount()));

4. BigDecimal의 외부 연동

MySQL과 BigDecimal

  • MySQL 역시 Java와 같이 Float, double 타입에 소수를 가진 수를 저장할 경우 앞서와 동일한 연산의 정확도 문제가 발생한다.
    이를 해결하기 위해 MySQL은 BigDecimal 타입에 대응하는 Decimal 타입을 제공한다.
foo DECIMAL(5,2) DEFAULT 0.00 NOT NULL

JPA에서의 BigDecimal 처리

  • JDBC에서 MySQL/MaridDB의 DECIMAL 타입은 ResultSet 인터페이스의 getBigDecimal(), getString() 2개 메서드로 획득이 가능하다. JPA 또한 별도의 작업 없이 엔티티 필드에 BigDecimal 타입을 사용하여 처리하면 된다.
  • 만약, 데이터베이스 저장 시 소수점 이하 자리수와 반올림 방법을 자동으로 처리되게 하고 싶다면 JPA가 제공하는 커스텀 컨버터를 제작하면 된다.

JSON 문자열 변환

  • 외부 서비스 간의 API 요청-응답 처리에서도 BigDecimal을 고려해야 한다. JSON 스펙에서는 BigDecimal 타입의 표현 방법에 대해 명확히 규정하고 있지 않다. 따라서 API 응답을 표현할 때 혹시 모를 소수점 이하에서의 데이터 유실을 확실하게 예방하려면 BigDecimal을 숫자가 아닌 문자열로 응답해야 한다.

Reference

profile
Good Luck!

0개의 댓글