이 시리즈는 Hotspot JVM - JSR-392(Java SE 17)를 기준으로 작성되었습니다.
Java에서 float이나 double 타입은 정확한 계산에 사용하면 안 된다고 한다.
왜냐하면 이들 타입은 IEEE-754로 실수를 나타내기 때문이다. 그럼 도대체 IEEE-754로 계산하는 것이 왜 위험한지, 그럼 정확한 계산을 하려면 어떻게 해야 하는지 알아보자.
IEEE-754
Java에는 실수를 표현하는 Primitive Type이 2가지 있는데 32bit로 표현하는 float, 64비트로 표현하는 double이 그것이다. 이 타입들은 IEEE-754방식으로 실수를 표현한다.
IEEE-754는 부동소수점을 표현하는 가장 널리 쓰이는 표준이다. 여기서 부동이란 不動이 아닌 浮動이며 떠다니며 움직인다는 뜻이다. 즉, 고정 소수점과 다르게 소수점의 위치를 유동적으로 표현하는 방식이라고 이해하면 된다.
이 표준은 값을 단 정밀도, 배 정밀도로 구분하는데, 각각 32bit, 64bit로 표현되는 값이다.
구분 | 값 |
---|---|
단 정밀도 | 최상위 부호 비트, 8비트 지수부, 23비트 가수부 |
배 정밀도 | 최상위 부호 비트, 11비트 지수부, 52비트 가수부 |
IEEE-754가 적용된 타입은 정확한 계산에 사용하면 안 된다라는 말을 이해하기 위해서 우선 이 표준이 실수를 어떻게 표현하는지를 알아야 한다.
지금부터 3.625라는 숫자가 단 정밀도로 표현되는 과정을 살펴보자.
변환 과정은 이진수 변환
-> 정규화
-> 정밀도 변환
순으로 진행된다.
이진 기수법
컴퓨터는 2진법을 사용하므로 우리가 사용하는 10진법의 실수를 2진수로 변환해야한다.
정수 부분은 2로 나누면서 구하면 되고, 소수점 이하 부분은 다음 규칙에 따라 구하면 된다.
- 소수 부분에 2를 곱한다.
- 곱한 뒤의 정수 부분이 이진수의 값이 된다.
- 계산은 소수점 이하로만 진행하며 곱한 값이 1.0이 되면 종료한다.
3.625를 아래 규칙에 맞게 계산해보자.
1
2
3
4
5
6
7
8
// 정수 변환
3 % 2 = 1 -> 1
1 % 2 = 1 -> 1
// 소수 변환
0.625 * 2 = 1.25 -> 1
0.25 * 2 = 0.5 -> 0
0.5 * 2 = 1.0 -> 1
규칙대로 계산해보면 3.625의 2진수 표현은 11.101이 된다.
정규화
정규화란 소수점 앞에 1이 하나만 있는 형태로 소수점을 이동시키는 것이다.
이 과정에서 소수점이 이동될 때마다 2의 지수(2진법이기 때문에)를 더하거나 빼줘야 한다.
다음 예를 보면 쉽게 이해될 것이다.
이진수 예시 | 소수점 이동 | 소수점이 이동된 이진수 | 정규화된 값 |
---|---|---|---|
1101.11 | 왼쪽으로 3칸 이동 | 1.10111 | 1.10111 * 23 |
11.0001 | 왼쪽으로 1칸 이동 | 1.10001 | 1.10001 * 21 |
0.00111 | 오른쪽으로 3칸 이동 | 1.11 | 1.11 * 2-3 |
이제 이진수 11.101을 정규화하면 1.1101 * 21이 된다는 것을 알 수 있다.
정밀도 표현
마지막으로 정규화된 1.1101 * 21 을 정밀도로 표현해보자.
우리는 단 정밀도로 표현하는 것이 목적이므로 다음 규칙대로 32bit로 변환한다.
- 최상위 부호 비트
최상위 부호 비트는 양수면 0, 음수면 1. - 지수부
지수에 바이어스값(2n-1 - 1)을 더하고 이진수로 표현한다. - 가수부
소수점 이하 자리수를 그대로 적고 남은 비트 자리들은 0으로 채운다.
지수에 바이어스값을 왜 더할까?
위 예시 테이블의 0.00111값을 보자. 2-3을 식으로 표현하면 1/23이다. 즉, bit로 다시 소수를 표현해야 하는 상황이 발생하는 것이다. 따라서 음의 지수가 나오더라도 정수처럼 계산할 수 있도록 바이어스 값을 더해 0을 음수의 최소값으로 사용하도록 하는 것이다. 참고로 32bit의 바이어스 값은 28-1 - 1 = 127, 64bit의 값은 211-1 - 1 = 1023 이다.
1.1101 * 21을 위 규칙대로 변환하면 아래와 같이 변환된다.
최상위 부호 비트 | 지수부 8비트 | 가수부 23비트 |
---|---|---|
0 | 10000000 | 11010000000000000000000 |
그래서 왜 정밀 계산에 쓰지 말라는 걸까?
이제 우리는 실수를 IEEE-754방식으로 변환할 줄 알게 되었다.
그럼 0.1을 단 정밀도로 변환해보도록 하고 우선 이진수로 변환을 해보자.
1
2
3
4
5
6
7
8
9
0.1 * 2 = 0.2 -> 0
0.2 * 2 = 0.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1
0.6 * 2 = 1.2 -> 1
0.2 * 2 = 0.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1
...
그렇다. 무한 소수가 된다.
이처럼 실수를 이진수로 표현해보면 무한 소수인 경우가 발생한다.
하지만 표현할 수 있는 bit의 개수는 정해져 있기 때문에 무한 소수를 IEEE-754로 변환하는 과정에서 소실되는 부분이 생기고 오차가 발생하게 되는 것이다. 실제로 float이나 double형으로 0.3 + 0.6를 실행해보면 틀린 결과가 나오는 것을 확인할 수 있다.
BigDecimal
정확한 계산이 필요할 때는 BigDecimal타입을 사용하면 된다.
그런데 BigDecimal타입은 어떻게 실수 계산을 정확하게 하는 걸까?
다음과 같이 BigDecimal 객체를 생성해보자.
1
2
BigDecimal a = new BigDecimal("3.625");
BigDecimal b = new BigDecimal("1.300005");
변수 a의 생성자에서는 3.625에서 sacale값 3, precision값 4를 구하고, 정수로 변환해 내부 값을 3625로 저장한다.
➤ scale 소수점의 전체 자리수. 소수점부터 0이 아닌 가장 오른쪽 숫자까지의 길이.
➤ precision 숫자의 전체 길이. 0이 아닌 가장 왼쪽의 수부터 0이 아닌 가장 오른쪽 숫자까지의 길이.
즉, 위 예제의 변수 a, b의 내부 상태는 다음과 같다.
변수명 | 내부 값 | scale | precision |
---|---|---|---|
a | 3625 | 3 | 4 |
b | 1300005 | 6 | 7 |
이제 변수 a에서 변수 b를 더하는 과정을 통해 왜 오차가 나지 않는지 살펴보자.
BigDecimal이 값을 더하는 과정은 다음과 같다.
- 피연산자들의 scale값이 다르다면, 10에 scale의 차이만큼 제곱한 후 scale이 작은 쪽의 값에 곱한다.
- 값을 더한다.
- 더한 값으로 새로운 BigDecimal 객체를 생성한다.
위 과정대로 연산을 진행해보면
- a의 scale값 3이 b의 scale값인 6보다 짧으므로, a의 내부 값 3625에 10(6 -3)을 곱해 소수점 이하의 자리수를 맞춘다.
- a의 내부 값 3625000에서 b의 내부 값 1300005를 더한다. (3625000 + 1300005 = 4925005)
- BigDecimal의 생성자를 통해 내부 값 4925005, scale값 6, precision값 7을 가진 객체를 생성하고 반환한다.
즉, BigDeciaml의 내부적으로 연산을 정수 형태로 하기 떄문에 오차가 나지 않는 것이다.