본문 바로가기
Language/Java

[Java] BigDecimal 사용하기

by 며루치꽃 2023. 12. 21.

0. 서론

회사에서 결제와 관련된 도메인을 맡아서 작업하고 있습니다.
특히나 해외 결제에는 환율 적용, 화폐 단위(달러, 엔화 등)으로 인해 많은 소수점 처리를 하게 되는 것 같습니다.
본 포스팅을 통해 BigDecimal을 사용하는 이유와 개념, 사용법, 사용하다 겪은 주의사항을 간단하게 소개하고자 합니다.

1. double의 문제점

환율을 적용해서 가격을 계산하다보면 소수점이 굉장히 긴 연산을 하는 케이스가 많다. 아래 테스트 케이스처럼 굉장히 긴 소수점을 계산하는 케이스가 있는 경우도 생길 수 있다. 아래 테스트 코드는 통과를 할까?

 

@Test
@DisplayName("Double 계산")
void double_test(){
    double a = 100.0000000003;
    double b = 90.0000000002;
    
    assertEquals(10.0000000001, a - b);
}

신기하게도 통과되지 않는다.
그 이유는 Java에서의 double은 정확한 값이 아닌 최대한 근접한 근삿값을 담고 있어서 발생하는 부동소수점 타입을 표현하기 때문이다.

 

2. 소수점 표현 방식

컴퓨터는 2진수를 사용해 소수를 표현하기 위해 고정 소수점 방식과 부동 소수점 방식으로 나눌 수 있다.

 

고정소수점

고정소수점 표현 방식은 실수를 부호 비트, 정수부, 소수부로 나누고 자릿수를 고정

하여 실수를 표현하는 방식이다.

https://tcpschool.com/cpp/cpp_datatype_floatingPointNumber

 

예를 들어 5비트가 있다면 1비트는 부호, 2비트는 왼쪽에 있는 숫자들을 표현하는데 쓰고 나머지 2비트는 2진 소수점의 오른쪽에 있는 분수들을 표현하는데 쓸 수 있다.

 

정수 부분   분수 부분
0 0 . 0 0 0
0 0 . 0 1 1/4
0 0 . 1 0 2/4
0 0 . 1 1 3/4
0 1 . 0 0 1
0 1 . 0 1 1 + 1/4
0 1 . 1 0 1 + 2/4
0 1 . 1 1 1 + 3/4

 

소수점 왼쪽의 정수 부분은 정수를 표현하고, 오른쪽은 소수점은 분수를 표현하는 방식이다.

 

위와 같이, 예시와 같은 방식을 확장하여 계산하는 방식이 고정소수점 표현방식이다.

 

고정소수점 방식은 위와 같이 계산이 간단하지만 정수부와 소수부의 자릿수에 제한을 받아 표현할 수 있는 범위가 매우
적다는 단점이 있다. 자릿수 부족을 해결하기 위해 부동소수점 방식이 등장하게 되었다.

 

부동소수점

현재 사용되고 있는 부동 소수점 방식은 대부분 IEEE 754 표준을 따르고 있다.

부동소수점 표현방식은 실수를 부호부, 가수부, 지수부로 나누고, 정규화된 값을 각 비트에 나눠 담아 실수를 표현하는 방식이다.

 

https://tcpschool.com/cpp/cpp_datatype_floatingPointNumber

 

말만 들어서는 조금 이해하기 어려운데, 1.234 라는 소수를 다르게 표현하면 0.1234 X 10^3 으로 표현할 수도 있다. 이를 부동 소수점 방식으로 처리를 한다면 부호에는 양수이니 0, 가수부에는 0.1234를 담고 지수부에는 3이 들어간다. 이를 10진수가 아닌 2진수로 변환해서 표현하는 방식이 부동소수점 방식이다.

 

부동소수점 표현방식은 고정소수점 표현방식에 비해 자릿수가 더 넓지만, 결국 2진수를 사용하므로 여전히 표현할 때 오차가 발생하게 된다. 따라서 double을 실수로 표현하는 방법은 정확한 표현이 아닌, 근삿값을 표현하는 방식이기 때문에 오차가 발생하게 된다.

 

3. BigDecimal 활용

BigDecimal을 불변의 성질을 띠며, 임의 정밀도와 부호를 지니는 10진수라고 표현한다. 임의 정밀도란 쉽게 정리하면 아무리 큰 숫자라도 표현할 수 있는 것을 의미한다

 

BigDecimal 클래스를 살짝 뜯어보면 다음과 같다.

 

BigDecimal decimalNumber = new BigDecimal("1.234");
int intVal = decimalNumber.unscaledValue().intValue(); // intVal: 1234
int scale = decimalNumber.scale(); // scale: 3
int precision = decimalNumber.precision(); // precision: 4
  • intVal: 정수를 저장한다. unscaledValued이고 BigInteger를 사용하는 것을 알 수 있다. 이 unscaledValued은 뒤에 비교연산을 사용할 때도 사용되는 개념이다. 
    • 1.234 → intVal: 1234
  • scale: 위에서 설명한 지수이다. 소수점 첫째 자리 ~ 0이 아닌 수로 끝나는 수 까지의 총 소수점 자리이다.
    • 1.234 → 지수: 3
    • 1.2340 → 지수: 3 (소수점 뒤의 0은 지수에 영향을 주지 않는다)
  • precision: 전체 자릿수 개수
    • 1.234 → 자릿수 개수: 4

3-1. BigDecimal 생성

BigDecimal bigDecimalA = new BigDecimal("1.234");
BigDecimal bigDecimalB = BigDecimal.valueOf(1.234);
BigDecimal bigDecimalC = new BigDecimal(1.234); // X

BigDecimal 생성은 생성자에 문자열 또는 double 값을 전달하여 BigDecimal을 생성할 수 있다.

 

그러나 3번째 줄처럼, 이때 주의할 점은 double을 통해서 BigDecimal을 생성하면 안 된다.
double은 근삿값을 담고 있기 때문에 double 값을 직접 전달하면 부동 소수점 정확성 문제가 발생할 수 있다.
위와 같이, IDE에서도 예측 가능하지 않은 동작이라고 alert를 띄어주는 것을 확인할 수 있다. (친절한 IDE..)

 

3-2. 사칙 연산

더하기(add)

BigDecimal bigDecimalA = new BigDecimal("1.234");
BigDecimal addResult = bigDecimalA.add(BigDecimal.TEN);
assertEquals(new BigDecimal("11.234"), addResult);
  • add() 를 이용한다

빼기(subtract)

BigDecimal bigDecimalB = new BigDecimal("11.234");
BigDecimal subtractResult = bigDecimalB.subtract(BigDecimal.TEN);
assertEquals(new BigDecimal("1.234"), subtractResult);

  • subtract() 를 이용한다

곱하기(multiply)

BigDecimal bigDecimalC = new BigDecimal("2");
BigDecimal multiplyResult = bigDecimalC.multiply(new BigDecimal("7"));
assertEquals(new BigDecimal("14"), multiplyResult);
  • multiply() 를 이용한다

나누기(divide)

BigDecimal valueA = new BigDecimal("5");
BigDecimal valueB = new BigDecimal("3");

assertThrows(ArithmeticException.class, () -> {
    BigDecimal result = valueA.divide(valueB);
});
  • divide() 를 이용한다.
  • divide() 에는 주의사항이 있는데, 정확하게 나누어지지 않는 몫이 있을 경우 ArithmeticException 예외가 발생한다.
  • IDE에서도 divide() 메서드에 소수점 처리 모드를 호출하고 있다고 알려주고 있다.

 

소수점 처리 모드

BigDecimal의 올림, 내림 등 소수점 처리를 위해서는 RoundingMode가 사용된다.

  • UP – 양수일 경우 올림, 음수일 경우 내림
  • DOWN – 양수일 경우 내림, 음수일 경우 올림
  • CEILING – 올림
  • FLOOR – 내림
  • HALF_UP – 반올림(5이상 올림, 5미만 버림)
    • ex) '3.5'는 '4'로 반올림되고, '3.4'는 '3’
  • HALF_DOWN – 반올림(6이상 올림, 6미만 버림)
    • '3.5'는 '3'으로 반올림되고, '3.6'은 '4’
  • HALF_EVEN – 반올림(반올림 자리의 값이 짝수면 HALF_DOWN, 홀수면 HALF_UP)
    • '4.5'는 '4'로 반올림되고, '5.5'는 '6’
  • UNNECESSARY – 나눗셈 결과가 딱 떨어지지 않으면, ArithmeticException 발생

3-3. 비교 연산

비교 연산은 주의해야할 점이 있다. 바로 소수점 자릿수에 따른 0을 어떻게 비교할 것인가를 잘 고려하여 비교를 해야한다.

처음에는 equals 를 사용해서 비교하였는데, 특정케이스에서 계속 에러가 나서 살펴보니 다음과 같은 케이스였다.

@Test
@DisplayName("BigDecimal 비교연산")
void bigDecimal_equality_test(){
    BigDecimal value1 = new BigDecimal("151.1");
    BigDecimal value2 = new BigDecimal("151.10");
    
    assertTrue(value1.equals(value2));
}
  • 테스트 결과 true를 기대했지만, 아래와 같이 false가 나왔다!

 

equals()는 unscaled value와 scale을 모두 비교하고, compareTo()는 unscaled value만을 비교하기 때문이다. 간단하게
말해서, equals()는 값과 소수점 자리까지 함께 비교한다는 뜻이다. 아래 compareTo()로 비교한 테스트를 보자.

@Test
@DisplayName("BigDecimal compareTo 비교 연산")
void bigDecimal_equality_test2(){
    BigDecimal value1 = new BigDecimal("151.1");
    BigDecimal value2 = new BigDecimal("151.10");

    int result = value1.compareTo(value2);

    assertEquals(0, result);
}
  • 테스트 결과가 통과!
  • compareTo()는 간단히 소개하자면, BigDecimal 같이 Wrapper 타입의 객체는 Primitive 타입 처럼 >< 연산자를 사용하여 크기를 비교할 수 있는 메서드이다.
  • 따라서 소수점 맨 끝의 0을 무시하고 값만을 비교하고 싶다면 compareTo()를 사용해야 한다.

저 같은 경우 JSON을 직렬화, 역직렬화를 하다보면 별도 설정이 없을 경우 0이 붙어서 나오는 케이스, 안 붙어서 나오는 케이스가 많았습니다. ( 애초에, 역직렬화 이전에도 0이 있거나 없거나 케이스는 다양.. )
보통은 해당 값이 정확한지 비교하는게 목적이지, 자릿수까지 맞는지 체크하는지는 드물거라고 생각합니다. 그래서 값만 비교하고 싶다면 compareTo() 를 사용하는게 좋을 것 같습니다.

4. MySQL BigDecimal 저장하기

MySQL에서도 Java에서처럼 똑같은 근삿값 문제를 가지고 있기에, BigDecimal을 저장하려면 Decimal 타입을 사용해야한다. MySQL에서는 실수의 값을 정확하게 표현하기 위해 Decimal이라는 타입을 제공한다.

 

CREATE TABLE 'tableA' (
  'amount' DECIMAL(10, 4) DEFAULT NULL
) # default DECIMAL(10, 0)

다음과 같이 지정할 수 있는데, 소수부를 포함한 전체 자릿수는 10이고, 소수부의 자릿수는 4자리를 뜻한다. defualt의 경우 전체 10자리, 소수부가 없는 0으로 지정된다.

BigDecimal을 사용하려면 Double형 대신 DECIMAL을 사용해야한다.

 

5. 정리

BigDecimal에 대해 정리를 해보았습니다. 

BigDecimal은 돈(화폐)을 다루는데 있어 가장 확실하고 안전한 타입이기에, 만약 돈을 다루는 특히 해외 결제를 다룬다면 적용을 검토해보면 좋을 것 같습니다.

감사합니다.

 

 

참고

댓글