Java

BigDecimal

Jay-JI 2024. 5. 20. 18:38

 

BigDecimal 이란

 

BigDecimal은 Java에서 고정 소수점 연산을 정확하게 수행하기 위해 사용되는 클래스를 의미한다. double이나 float과 같은 부동 소수점 타입은 매우 큰 숫자나 매우 작은 숫자를 처리할 수 있지만 정확도가 떨어질 수 있다. 이러한 이유로 금융 계산과 같이 정확도가 중요한 경우 부동 소수점 타입을 사용해 연산을 처리하는 것은 적합하지 않다. 이때 BigDecimal을 사용하면 소수점 이하의 숫자도 정확하게 표현할 수 있다.

 


 

 

float과 double의 문제점

 

아래 코드를 보자

일반적으로 우리는 연산의 결과가 0.3이 나오길 기대하지만 실제로는 0.30000000000000004 라는 이상한 값이 도출된다.

double a = 0.1;
double b = 0.2;

System.out.println(a + b); 
// output : 0.30000000000000004

 

또 다른 코드를 보자

여기에서는 float 타입의 0.1을 3번 더하고 3번 뺐는데 이상한 결과가 나온다. double 타입으로 진행해도 마찬가지로 이상한 값이 도출된다.

System.out.println ( 0.1f + 0.1f + 0.1f - 0.1f - 0.1f - 0.1f ); 
//output : 1.4901161E-8

System.out.println ( 0.1d + 0.1d + 0.1d - 0.1d - 0.1d - 0.1d ); 
//output : 2.7755575615628914E-17

 

이러한 문제가 발생하는 이유는 float와 double이 정확한 값(precise value)이 아닌 근삿값(approximate value)을 담고 있어서 발생하는, 부동 소수점 타입의 한계 때문이다.

 


 

고정 소수점과 부동 소수점

 

컴퓨터에서는 실수를 표현하는 방식이 크게 2가지가 있는데 고정 소수점 방식과 부동 소수점 방식이다.

 

 

1) 고정소수점 표현방식(fixed point number representation)

 

고정소수점 표현방식은 실수를 부호 비트(signed bit), 정수부(integer part)와 소수부(fractional part)로 나누고, 자릿수를 고정하여 실수를 표현하는 방식이다. 예를 들어 7.75라는 실수를 2진수로 변환하면 111.11이 되는데 이를 각각 지수부와 소수부에 담아 표현한다. (그림은 32비트 기준)

 

 

 

고정소수점 표현방식은 구현하는 방법이 간단하다는 장점이 있지만, 자릿수가 제한되어 있으므로 표현할 수 있는 수의 범위가 한정적이었다. 이에 따라 더 넓은 범위의 실수를 표현하기 위해 부동소수점이라는 개념이 등장했다.

 

  • 고정소수점 최대값, 최소값
    - 최대값 : (2정수 비트 수 - 1) + (1 - 2소수 비트 수)
    - 최소값 : - (2정수 비트 수)

  • 32bit 기준 고정소수점 최대값, 최소값
    - 최대값 : (215 - 1) + (1 - 216) = 32767 + 0.99998474 = 32767.99998474
    - 최소값 : -215 = -32768.0

 

2) 부동소수점 표현방식(floating point number representation)

 

 

흔히 부동소수점 표현 방식은 ‘움직이지 않는다’라는 부동(不動)으로 오해하기 쉽다. 그러나 여기서 부동은 움직이지 않는다는 뜻이 아니라 떠다닌다, 부유하다의 의미를 가지는 부동(浮動)이다. 단어에서 유추 할 수 있듯이 소수점이 옮겨다니는 방식의 실수 표현법이라고 이해하면 된다.

 

부동소수점 표현방식은 실수를 부호부(sign), 가수부(mantissa), 지수부(exponent)로 나누고 정규화된(normalized) 값을 각 비트에 나눠 담아 실수를 표현하는 방식이다. 쉽게 생각해서 12.3456를 저장한다면 표현식을 0.123456 * 10^2로 변경한 다음 가수부에는 0.123456을 담고 지수부에는 2를 저장하는 방식이다. 이해를 돕기 위해 간단하게 설명했지만 실제로는 IEEE 754 표준에 따라서 지수부에 bias라는 값을 더해주는 과정을 거치게 된다.

 

  • 부동소수점 표현방식은 고정소수점 표현방식에 비해 표현범위가 더 넓지만, 근본적으로 2진수를 사용하므로 여전히 소수를 표현할 때 오차가 발생한다. 예컨대 0.3을 2진수로 변환하면 0.0100110011...처럼 특정 수가 무한적으로 반복되는 것을 볼 수 있다. float와 double이 이와 같은 부동소수점 표현방식으로 구현되었기 때문에 위와 같은 문제들이 발생했던 것이다.

 


 

BigDecimal의 구성

 

BigDecimal은 개념적으로 임의 정밀도 정수형인 unscaled value와 소수점 오른쪽의 자릿수를 나타내는 32비트 정수인 scale로 구성된다. 예를 들어 BigDecimal 3.14의 경우 unscaled value는 314이고 scale은 2가 된다.

 

BigDecimal 클래스를 내부적으로 살펴보면 intVal, scale, percision, intCompact라는 주요 구성요소가 있는데 각 구성요소의 역할은 다음과 같다.  

 

  • intVal : BigInteger 타입으로 unscaled value를 저장한다. 다시 말해 이 값은 소수점 위치와 관계없이 숫자의 정수 부분을 표현한다.
    ex) 123.45라는 BigDecimal이 있다면 12345가 intVal에 저장된다.

  • scale : 소수점 오른쪽의 자릿수, 즉 소수점 이하 자릿수를 나타낸다.
    ex) 123.45라는 BigDecimal이 있다면 scale는 2가 된다.

  • precision : 총 자릿수, 혹은 숫자의 유효 자릿수를 나타낸다.
    ex) 123.45는 precision이 5 이다 / 0.00123 은 precision이 3 이다.

  • intCompact : BigDecimal의 정수값을 효율적으로 저장하기 위한 필드이다. 만약 표현하려는 값의 크기가 작아서 유효숫자의 절댓값이 long 타입으로 표현될 수 있다면 BigDecimal은 unscaled value를 intVal(BigInteger 타입) 대신 intCompact(long 타입)에 저장함으로써 메모리를 최적화한다.
package java.math;

public class BigDecimal extends Number implements Comparable<BigDecimal> {
    
    private final BigInteger intVal; // = unscaled value
    private final int scale;
    private transient int precision;
    private final transient long intCompact;
    ...

 


 

BigDecimal 생성 방법

 

다음과 같은 방법을 통해 BigDecimal을 생성할 수 있다.

 

BigDecimal fromBigInteger = new BigDecimal(new BigInteger("1000"));
BigDecimal fromCharArray = new BigDecimal(new char[]{'1', '5', '4', '3'});
BigDecimal fromInt = new BigDecimal(1000);
BigDecimal fromLong = new BigDecimal(10000000L);
BigDecimal fromDouble = new BigDecimal(1.12); // 이렇게 사용하면 안 됨!

 

이때 주의할 점은 double을 통해서 BigDecimal을 생성하면 안 된다는 것이다. 앞서 언급했듯이 double은 근삿값을 담고 있기 때문에 BigDecimal에 이 근삿값이 고스란히 담기게 된다. 실제로 출력해보면 엉뚱한 값이 나오는 것을 확인할 수 있다.

 

BigDecimal fromDouble = new BigDecimal(1.12);
System.out.println(fromDouble); 
//output : 1.12000000000000010658141036401502788066864013671875

 

따라서 String 생성자나 valueOf() 메소드를 사용해서 BigDecimal을 생성해야 한다. valueOf() 메소드는 double을 String으로 변환한 후 BigDecimal을 생성한다.

 

new BigDecimal("1.12"); // 1.12
BigDecimal.valueOf(1.12); // 1.12

 


 

BigDecimal 연산

 

1) 사칙연산

 

사칙연산에서 주목할 부분은 나눗셈이다. 기본적으로 BigDecimal은 소수점 처리(rounding)을 하지 않고 정확한 몫을 반환한다. 만약 1 나누기 3처럼 몫의 값이 무한소수인 경우(Non-terminating decimal expansion)엔 ArithmeticException이 발생한다. 따라서 나누기 연산을 수행할 땐 반드시 소수점 처리 전략을 지정해줘야 한다. 소수점 처리에 대해서는 아래에서 별도로 설명한다.

 

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

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

// 빼기
// 4
a.subtract(b);

// 곱하기
// 21
a.multiply(b);

// 나누기 - 기본적으로 정확한 몫을 반환함
// 2.33333...
// java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
a.divide(b);

// 나누기 - 소수점 아래 첫째 자리까지 반올림
// 2.3
a.divide(b, 1, RoundingMode.HALF_UP);

// 나누기 - 총 자릿수를 34개로 제한하고 반올림(HALF_EVEN)
// 2.333333333333333333333333333333333
a.divide(b, MathContext.DECIMAL128);

// 나누기 - 총 자릿수를 제한하지 않고 반올림
// 2.33333...
// java.lang.ArithmeticException: Non-terminating decimal expansion; no exact
a.divide(b, MathContext.UNLIMITED);

// 나머지(%)
// 1
a.remainder(b);

// 나머지(%) - 총 자릿수를 34개로 제한
// 1
a.remainder(b, MathContext.DECIMAL128);

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

// 최대값
// 7
a.max(b);

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

// 부호 변환
// -7
a.negate();

 

 

2) 비교연산

 

equals()는 unscaled value와 scale을 모두 비교하고, compareTo()는 unscaled value만을 비교한다. 따라서 소수점 맨 끝의 0을 무시하고 값만을 비교하고 싶다면 compareTo()를 사용해야 한다.

 

// 주솟값을 비교한다
// false    
a == b;

// unscaled value와 scale을 비교한다 (값과 소수점 자리까지 함께 비교)
// false
a.equals(b);

// unscaled value만 비교한다 (값만 비교)
// true
a.compareTo(b) == 0;

 

 

3) 소수점 처리

 

BigDecimal의 올림, 내림 등 소수점 처리를 위해서는 RoundingMode가 사용해야 한다. RoundingMode는 java.math 패키지에 정의된 Enum 클래스로 다양한 소수점 처리 전략을 제공하고 있다. Enum의 value에는 BigDecimal의 상수가 담기게 되는데, BigDecimal 상수를 직접 참조하는 방식은 Java 9부터 Deprecated 되었으므로 RoundingMode를 사용해야 한다.

 

package java.math;

public enum RoundingMode {
    
    // 0에서 멀어지는 방향으로 올림 
    // 양수인 경우엔 올림, 음수인 경우엔 내림
    UP(BigDecimal.ROUND_UP),
    
    // 0과 가까운 방향으로 내림
    // 양수인 경우엔 내림, 음수인 경우엔 올림
    DOWN(BigDecimal.ROUND_DOWN),
    
    // 양의 무한대를 향해서 올림 (올림)
    CEILING(BigDecimal.ROUND_CEILING),
    
    // 음의 무한대를 향해서 내림 (내림)
    FLOOR(BigDecimal.ROUND_FLOOR),
    
    // 반올림 (사사오입) 
    // 5 이상이면 올림, 5 미만이면 내림
    HALF_UP(BigDecimal.ROUND_HALF_UP),
    
    // 반올림 (오사육입) 
    // 6 이상이면 올림, 6 미만이면 내림
    HALF_DOWN(BigDecimal.ROUND_HALF_DOWN),

    // 반올림 (오사오입, Bankers Rounding)
    // 5 초과면 올리고 5 미만이면 내림, 5일 경우 앞자리 숫자가 짝수면 버리고 홀수면 올림하여 짝수로 만듦
    HALF_EVEN(BigDecimal.ROUND_HALF_EVEN),
    
    // 소수점 처리를 하지 않음
    // 연산의 결과가 소수라면 ArithmeticException이 발생함
    UNNECESSARY(BigDecimal.ROUND_UNNECESSARY);
    
    ....
}

 

RoundingMode는 다음과 같이 사용할 수 있다. setScale()의 첫 번째 인자로 scale(소수점 오른쪽의 자릿수)을, 두 번째 인자로 RoundingMode를 넘겨준다. 만약 RoundingMode를 지정하지 않는다면 기본 전략으로 UNNECESSARY가 적용된다.

 

// java 9 부터 Deprecated 된 사용법
// 아래와 같이 BigDecimal 상수를 직접 참조하여 사용하면 안된다.
new BigDecimal("0.123123").setScale(1, BigDecimal.ROUND_UP);

// 0.2
new BigDecimal("0.12345").setScale(1, RoundingMode.UP);
// -0.2
new BigDecimal("-0.12345").setScale(1, RoundingMode.UP);

// 0.1
new BigDecimal("0.12345").setScale(1, RoundingMode.DOWN);
// -0.1
new BigDecimal("-0.12345").setScale(1, RoundingMode.DOWN);

// 0.2
new BigDecimal("0.12345").setScale(1, RoundingMode.CEILING);
// -0.1
new BigDecimal("-0.12345").setScale(1, RoundingMode.CEILING);

// 0.1
new BigDecimal("0.12345").setScale(1, RoundingMode.FLOOR);
// -0.2
new BigDecimal("-0.12345").setScale(1, RoundingMode.FLOOR);

// 1
new BigDecimal("0.5").setScale(0, RoundingMode.HALF_UP);

// 0
new BigDecimal("0.5").setScale(0, RoundingMode.HALF_DOWN);

// 0
new BigDecimal("0.5").setScale(0, RoundingMode.HALF_EVEN);
// 2
new BigDecimal("1.5").setScale(0, RoundingMode.HALF_EVEN);

// java.lang.ArithmeticException: Rounding necessary
new BigDecimal("0.12345").setScale(1, RoundingMode.UNNECESSARY);

// java.lang.ArithmeticException: Rounding necessary
// RoundingMode.UNNECESSARY가 적용됨
new BigDecimal("0.12345").setScale(1);

// 0.0
new BigDecimal("0.00000").setScale(1);

// 0.12345
// 맨끝의 0을 절사
new BigDecimal("0.12345000").stripTrailingZeros();

 

소수점 처리에는 MathContext를 사용할 수도 있다. MathContext은 정밀도(precision)와 RoundingMode를 하나로 묶은 클래스이다.

 

public final class MathContext implements Serializable {
    
    public static final MathContext UNLIMITED =
        new MathContext(0, RoundingMode.HALF_UP);

    public static final MathContext DECIMAL32 =
        new MathContext(7, RoundingMode.HALF_EVEN);
    
    public static final MathContext DECIMAL64 =
        new MathContext(16, RoundingMode.HALF_EVEN);

    public static final MathContext DECIMAL128 =
        new MathContext(34, RoundingMode.HALF_EVEN);
    
    ...
BigDecimal a = new BigDecimal("7");
BigDecimal b = new BigDecimal("3");

// 전체 자릿수를 제한하지 않음
// java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
a.divide(b, MathContext.UNLIMITED);
    
// 전체 자릿수를 7개로 제한하고 HALF_EVEN을 적용
// 2.333333
a.divide(b, MathContext.DECIMAL32);

// 전체 자릿수를 16개로 제한하고 HALF_EVEN을 적용
// 2.333333333333333
a.divide(b, MathContext.DECIMAL64);

// 전체 자릿수를 34개로 제한하고 HALF_EVEN을 적용
// 2.333333333333333333333333333333333
a.divide(b, MathContext.DECIMAL128);

 


출처 : 
https://dev.gmarket.com/75
https://gguguk.github.io/posts/fixed_point_and_floating_point/#%EA%B3%A0%EC%A0%95-%EC%86%8C%EC%88%98%EC%A0%90fixed-point