[Java] 소수점 계산과 BigDecimal

보라보라·2023년 12월 26일
post-thumbnail

개요

소수점 계산을 처리하고 싶어요!

floatdouble은 모두 소수점을 처리하기 위한 타입이다. 즉 실수(Real Number) 타입이다.
간단한 계산에서는 사용해도 무방하지만,
💵돈💵 계산과 같이 정확한 계산이 요구될 때에는 BigDecimal사용을 해야 한다고 한다.
어떠한 이유로 그래야 할까?


floatdouble의 문제점

✏️floatdouble의 정의

Oracle의 Java Tutorial 에서는 다음과 같이 정의한다.

  • 단일 정확도를 가지는 32/64 비트 IEEE 754 부동 소수점이다.
  • 통화와 같은 정확한 계산을 해야할 때는 이 두 타입을 사용하지 말고 java.math.BigDecimal 클래스를 대신 사용해야 한다고 한다.
  • float : The float data type is a single-precision 32-bit IEEE 754 floating point. Its range of values is beyond the scope of this discussion, but is specified in the Floating-Point Types, Formats, and Values section of the Java Language Specification. As with the recommendations for byte and short, use a float (instead of double) if you need to save memory in large arrays of floating point numbers. This data type should never be used for precise values, such as currency. For that, you will need to use the java.math.BigDecimal class instead. Numbers and Strings covers BigDecimal and other useful classes provided by the Java platform.
  • double : The double data type is a double-precision 64-bit IEEE 754 floating point. Its range of values is beyond the scope of this discussion, but is specified in the Floating-Point Types, Formats, and Values section of the Java Language Specification. For decimal values, this data type is generally the default choice. As mentioned above, this data type should never be used for precise values, such as currency.
    [출처] Oracle Java Tutorials - Primitive Data Types

IEEE 754

이진법에서의 부동소수점을 표현하는 표준이며
부동소수점은 가수부(fraction: 유효 숫자)와 지수부(exponent: 소수점의 위치)를 나눠서 소수를 표현한 것이다.
그리고 음수(1)인지, 양수(0)인지에 대한 표현에 대해 부호비트(sign bit)도 포함한다.

  • float : 부호비트 1bit, 지수부는 8bit, 가수부는 23bit의 크기를 가지게 됩니다.
  • double : 부호비트 1bit, 지수부는 11bit, 가수부는 52bit의 크기를 가지게 됩니다.

🔎문제의 코드

0.1을 1000번 더하는 프로그래밍을 실행하게 되면 당연히 100이 나오겠지?!

여기서 나는 실제 개발할 때 double로 소수 타입을 많이 하므로 double형으로 구현해보았다.

0.1을 1000번 더한 결과는?

내가 기대한 값과, 실제로 실행한 결과는 다르게 나왔다.

double result = 0;
for (int i = 0; i < 1000; i++) {
	result += 0.1;
}
System.out.println("result : " + result);
------------- 실행결과 -------------  
result : 99.9999999999986

0.1을 계속 더하면서 무슨일이 일어난거지?

더하는 과정중에 어떤 일이 일어났는지 더할 때마다의 실행결과를 출력해보았다.
아래 실행결과를 보면 0.1씩 더한 결과들이 나와야하는데 소수점 아래의 숫자들이 너무나 많다.
몇 번을 반복 실행해 봐도 결과는 같다. 왜 이러는 거지?

double result = 0;
for (int i = 0; i < 1000; i++) {
	result += 0.1;
    System.out.println((i+1) + " 번째 합계 : " + result);
}
------------- 실행결과 ------------- 
1 번째 합계 : 0.1
2 번째 합계 : 0.2
3 번째 합계 : 0.30000000000000004
4 번째 합계 : 0.4
5 번째 합계 : 0.5
6 번째 합계 : 0.6
7 번째 합계 : 0.7
8 번째 합계 : 0.7999999999999999
9 번째 합계 : 0.8999999999999999
10 번째 합계 : 0.9999999999999999
.
.
.
991 번째 합계 : 99.09999999999864
992 번째 합계 : 99.19999999999864
993 번째 합계 : 99.29999999999863
994 번째 합계 : 99.39999999999863
995 번째 합계 : 99.49999999999862
996 번째 합계 : 99.59999999999862
997 번째 합계 : 99.69999999999861
998 번째 합계 : 99.7999999999986
999 번째 합계 : 99.8999999999986
1000 번째 합계 : 99.9999999999986

🤔무엇이 문제인가?

소수점 이하의 수를 다룰 때 floatdouble타입은 사칙연산 시 우리가 기대한 값과 다른 값을 출력한다.
이유는 내부적으로 수를 저장할 때 이진수의 근사치를 저장하기 때문이다. 그 저장된 수를 다시 십진수로 표현하면서 문제가 발생한다.

문제는 부동소수점 계산! 컴퓨터는 0과 1밖에 모르는 데서 나온다!

이유를 살펴보기 전에 컴퓨터가 어떻게 실수(float, double)들을 저장하는지에 대한 것을 알아야 한다.
컴퓨터는 0과 1로 값을 저장한다. 즉 이진법으로 숫자를 저장한다.
이는 정수를 저장하는데 있어서 모든 수를 표현할 수 있지만, 소수를 저장하는데 문제가 생긴다.

이진수의 소수 표현

(이진수) = (십진수)
0.12=0.50.1_{2} = 0.5
0.012=0.250.01_{2} = 0.25
0.0012=0.1250.001_{2} = 0.125
0.00012=0.06250.0001_{2} = 0.0625
...

그러면 0.3을 컴퓨터에 저장하기 위해서는?

0.012+0.000012=0.281250.01_{2} + 0.00001_{2} = 0.28125 이고,
따라서 0.3=0.01001...20.3 = 0.01001..._{2}가 될 수 있다.
하지만, 0.30.3을 정확히 이진수로 정확하게 나타낼 수 없다.

이진수를 십진수로 변환하는 계산법

이진수를 십진수로 변환할 때에는 소수 부분의 첫째자리에 22를 계속해서 곱하면서 일의자리 숫자를 확인한다.

  • 0.a20.a * 2 계산 후, 일의자리가 00인 경우 : 해당 소수 자리에 00을 표시
  • 0.a20.a * 2 계산 후, 일의자리가 11인 경우 : 해당 소수 자리에 11을 표시
  • 곱한 결과가 1.01.0, 즉 소수 첫째자리가 00인 나머지가 없을 때까지 계속해서 진행한다.

[예시1] 나누어 떨어지는 계산

  • 0.5=0.120.5 = 0.1_{2}
    0.5 x 2 = 1.0 ----------------- 일의 자리가 1이므로 해당 소수 첫째 자리는 1
  • 0.25=0.0120.25 = 0.01_{2}
    0.25 x 2 = 0.5 --------------- 일의 자리가 0이므로 해당 소수 첫째 자리는 0
    0.5 x 2 = 1.0 ----------------- 일의 자리가 1이므로 해당 소수 둘째 자리는 1

[예시2] 나누어 떨어지는 않는 계산 0.1

  • 0.1=0.00011...20.1 = 0.00011..._{2}
    0.1 x 2 = 0.2 ---------------- 일의 자리가 0이므로 해당 소수 첫째 자리는 0
    0.2 x 2 = 0.4 ---------------- 일의 자리가 0이므로 해당 소수 둘째 자리는 0
    0.4 x 2 = 0.8 ---------------- 일의 자리가 0이므로 해당 소수 셋째 자리는 0
    0.8 x 2 = 1.6 ---------------- 일의 자리가 1이므로 해당 소수 넷섯째 자리는 1
    0.6 x 2 = 1.2 ---------------- 일의 자리가 1이므로 해당 소수 다섯째 자리는 1
    0.2 x 2 = 0.4 ---------------- 일의 자리가 0이므로 해당 소수 여섯째 자리는 0 <-- 계산이 계속된다.

위의 식을 통해 0.1이나 0.3과 같은 소수들이 이진수로 변환될 때 정확하게 유한소수로 나타낼 수 없는 것을 확인하였다.

IEEE 754 & JAVA specification

Round to nearest 채택

IEEE754에서는 실수의 처리에 대해 여러가지 방법을 제시한다.
그 중 JAVA스펙에서는 가까운 값으로 반올림(round to nearest)방식을 채택하였다.

가수부(fraction)에 따른 소수 처리

float과 double의 각각 가수부에 대한 비트수가 있다.
만약 무한소수에서 가수부가 각각 비트수(32/64bit)를 넘어가게 된다면, 소수점이 잘리면서 rounding이 이뤄지는 점을 주목해야 한다.

실수타입 연산의 오차 결과

  1. 십진수의 소수를 이진수로 변환하면서 어떤 소수들은 이진수의 유한소수로 나타낼 수 없어 무한소수로 나타내어진다.
  2. 각각 실수 자료형의 가수부의 비트 수에 따라 소수점이 잘리면서 반올림이 일어나 다시 십진수로 표현하면서 근사치인 소수를 얻게된다.
  3. 이러한 근사치 소수들끼리의 연산의 결과도 마찬가지로 가수부가 비트 수 만큼 소수점이 잘리면서 다시 십진수로 표현할 때 근사치인 소수를 얻게 될 수도 있을 것이다.

결론 : floatdouble의 경우 내부적으로 수를 저장할 때 이진수의 근사치를 저장한다.
저장된 수를 다시 십진수로 표현하면서 계산의 결과가 정확하지 않을 수도 있다는 결과를 확인할 수 있었다.

BigDecimal

🧐BigDecimal이란?

  • BigDecimalJava 언어에서 숫자를 정밀하게 저장하고 표현할 수 있는 객체이다.
  • 소수점을 저장할 수 있는 가장 크기가 큰 타입인 double이 소수점의 정밀도에 있어 한계가 있어 값이 유실될 수 있는 점을 방지한다.
  • Java 언어에서 돈과 관련된 소수점을 다룬다면 BigDecimal은 필수이다.
  • BigDecimal의 단점은 느린 속도와 기본 타입보다 복잡한 사용법이다.

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의 소수점 크기를 가진다.

BigDecimal 기본 상수

// 흔히 쓰이는 값은 상수로 정의
// 0
BigDecimal.ZERO

// 1
BigDecimal.ONE

// 10
BigDecimal.TEN

BigDecimal 초기화

BigDecimal 타입을 초기화하는 방법으로 가장 안전한 것은 문자열의 형태로 값을 생성자에 전달하여 초기화하는 것이다.

double 타입의 값을 그대로 전달할 경우 앞서 사칙연산 결과에서 본 것과 같이 이진수의 근사치를 가지게 되어 예상과 다른 값을 얻을 수 있다.

double result = 0.1;
BigDecimal bigDecimal = new BigDecimal(result);
System.out.println("result : " + bigDecimal);
------------- 실행결과 -------------  
result : 0.1000000000000000055511151231257827021181583404541015625

그래서 문자열로 값을 초기화해야 정상 인식한다.

 BigDecimal bigDecimal = new BigDecimal("0.01");
 System.out.println("result : " + bigDecimal);
------------- 실행결과 -------------  
result : 0.1

또는 double을 toString을 이용하여 문자열로 초기화 할 수 있다.

System.out.println("result : " + BigDecimal.valueOf(0.01));
------------- 실행결과 -------------  
result : 0.1

BigDecimal 비교연산

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

		BigDecimal a = new BigDecimal("2.01");
        BigDecimal b = new BigDecimal("2.01");
        BigDecimal c = new BigDecimal("2.010");
        BigDecimal d = new BigDecimal("2.000");

        // 객체의 레퍼런스 주소에 대한 비교 연산자
        // false
        System.out.println("(a == b) = " + (a == b));

        // 값의 비교를 위해 사용, 소수점 맨 끝의 0까지 완전히 값이 동일해야 true 반환
        // true
        System.out.println("a.equals(b) = " + a.equals(b));
        // false
        System.out.println("a.equals(c) = " + a.equals(c));

        // 값의 비교를 위해 사용, 소수점 맨 끝의 0을 무시하고 값이 동일하면 0, 적으면 -1, 많으면 1을 반환
        // 0
        System.out.println("a.compareTo(c) = " + a.compareTo(c));
        // 1
        System.out.println("a.compareTo(d) = " + a.compareTo(d));
------------- 실행결과 -------------  
(a == b) = false
a.equals(b) = true
a.equals(c) = false
a.compareTo(c) = 0
a.compareTo(d) = 1

BigDecimal 사칙연산

Java에서 BigDecimal 타입의 사칙 연산 방법은 아래와 같다.
보다시피 double 타입에서 지원하는 연산자로는 사칙연산을 할 수 없다.
따로 메소드가 있으니 BigDecimal API문서를 확인해 본다.

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

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

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

// 곱하기
// 30
a.multiply(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);

결론

  • 돈같이 정확한 소수점 계산을 처리하고 싶을 때에는 기본타입(float/double)보다는 BigDecimal을 이용해야 한다.
  • floatdouble같은 경우에는 연산처리를 할 때 값이 유실되어 예상치 못한 결과값을 얻을 수 있다.
  • JAVA에서 BigDecimal은 정확한 계산을 보장하지만, 성능은 기본타입보다 떨어질 수 있고 사용법이 기본연산과 다르므로 API문서를 확인해야한다.

참고

[출처] 블로그 - 부동소수점 계산
[출처] Oracle Java Tutorials - Primitive Data Types
[출처] Oracle Java Tutorials - 4.2.4. Floating-Point Operations
[출처] Wiki - IEEE 754
[출처] Oracle Java API - BigDecimal
[출처] baeldung -java-bigdecimal-biginteger
[출처] 지탄로보트 블로그 - Java, BigDecimal 사용법 정리
[출처] 니코딩코딩 블로그 - BigDecimal 정리

profile
쉽게쓰려고 노력하는 블로그

1개의 댓글

comment-user-thumbnail
2024년 1월 16일

좋은 공부가 됬습니다 감사합니다

답글 달기