...
실수의 2진수 표현
10진수의 정수를 2진수의 정수로 변환할 수 있듯이, 10진수의 소수를 2진수의 소수로 변환할 수 있다.
예를들어 10진수 11.765625 를 2진수 소수로 변환하는 방법은 다음과 같다. 먼저 숫자를 정수부 11 와 소수부 0.765625 로 나누어주고, 각각 2진수로 변환해주면 된다.
정수부 2진수 변환
- 정수부에 2를 지속적으로 나눈다.
- 몫은 계속 2로 나누어주고, 나머지값은 따로 모아준다.
- 더이상 나눌수 없을때 나머지 값을 모아 연결해주면 1011(2)로 간단히 변환이 된다.
실수부 2진수 변환
- 절대값이 1보다 작은 10진수 소수에 2를 곱한다.
- 2를 곱한 결과는 1을 넘거나 넘지 않을 것이다.
- 만약 1을 넘었을 경우 결과에서 1을 떼어내고, 아니면 0으로 처리하며 다음 계산을 이어나간다.
- 소수점 이하에 아무 숫자도 남지 않을 때까지 곱셈을 반복한다.
- 이 과정에서 만들어진 결과의 정수 부분(0 또는 1)들을 순서대로 쓰면 해당 소수의 2진수 변환 값이 나온다.
즉, 십진수 실수 11.765625 를 이진수로 변환해주면 1011.110001(2) 라는 결과가 나옴을 확인할 수 있다.
2진수에서 10진수 실수로 변환하는 방법은 역시 정수부와 소수부를 나누고, 정수부는 2의 n승을, 소수부는 2의 -n승을 곱하여 주면 된다.
예를 들어 101.101(2) 이진수 실수의 십진수 변환 계산법은 다음과 같다.
2진수 무한 소수
하지만 모든 십진수 실수를 위와 같이 깔끔하게 2진수로 변환하는 것은 아니다.
예를 들어 0.1을 2진수로 변환하게 된다면 값이 나누어 떨어지지 않고 0.0001100110011... 로 무한으로 반복되는 현상이 나타난다.
이를 무한 소수라고 불리운다.
이처럼 10진수의 세계에서 2진수의 세계로 소수점을 변환하는 것은 많은 경우 정확한 변환이 불가능하다.
소수의 끝이 5가 아닌 수를 2진수로 소수를 표현할경우 무한 소수가 발생한다고 보면 된다.
실수의 메모리 표현
컴퓨터의 메모리는 2진수 체계를 기반으로 데이터를 저장한다.
당연히 실수도 2진수로 메모리 비트로 표현해야 하며 정수에 비해서 상대적으로 복잡한 편이다.
컴퓨터에서 실수를 표현하는 방식으로는 대표적으로 고정 소수점 방식(Fixed-Point Number Representation)과 부동 소수점 방식(Floating-Point Number Representation)으로 나눌 수 있다.
고정 소수점 방식
고정 소수점 방식은 메모리를 정수부와 소수부로 고정으로 나누고 지정하여 처리하는 방식이다.
소수부의 자릿수를 미리 정하고 고정된 자릿수의 소수를 표현하기 때문에 직관적이다.
맨 좌측의 1bit 부호 비트란 양수/음수를 표현하기 위한 비트이다. 0이면 양수, 1이면 음수를 통칭한다.
예를들어 5.625 숫자를 이진수로 변환하고 컴퓨터 메모리에 고정 소수점 방식으로 표현한다면 다음과 같이 된다.
즉, 위에서 살펴보았던 이진수 실수 계산법을 그대로 적용하고 결과값을 각각 정수부, 소수부 메모리 비트에 넣어주면 실수 표현이 완료되는 것이다.
이처럼 직관적으로 메모리에 실수 표현을 할 수 있다는 장점이 있지만, 표현 가능한 범위가 매우 적다는 치명적인 단점이 있다.
Java의 float 타입을 기준으로 실수 메모리는 총 32비트를 할당받게 되는데, 고정 소수점 방식으로 메모리를 반띵으로 나누어 설계하였다면, 다음과 같이 정수부 비트에서 최대로 표현할수 있는 숫자는 2^15 - 1 인 32767 이 되게 된다.
즉, 40000.01 이라는 실수가 있다면 표현 범위를 넘어 메모리에 적재할수 없게 되는 것이다.
이밖에도 낭비되는 공간이 많이 생긴다는 단점도 존재한다.
22777.12 라는 실수가 있을 경우, 고작 0.12 라는 작은 숫자를 표현하기 위해 16비트의 소수부를 모두 사용한다는 것은 아무리 봐도 설계 미스라고 밖에 보이지 않는다.
같은 실수라고 해도 정수부가 큰 실수(32001.1)가 있고 소수부가 큰 실수(2.1008101)도 있기 때문이다.
따라서 이러한 공간 낭비를 줄이고 효율적으로 실수 메모리를 표현하기 위해 컴퓨터는 부동 소수점 방식을 사용한다.
부동 소수점 방식
부동 소수점(floating point) 방식은 소수점 (point) 이 둥둥 떠다닌다 (floating) 라는 의미로, 표현할 수 있는 값을 범위를 최대한 넓혀 오차를 줄이자는 시도에서 탄생한 녀석이다.
부동 소수점은 고정 소수점 방식과는 달리 메모리를 가수부(23bit)와 지수부(8bit)로 나눈다.
기수부 에는 실제 실수 데이터 비트들이 들어가고, 지수부에는 소수점의 위치를 가리키는 제곱승 이 들어간다고 보면 된다.
뭔가 직관적이지 않아 오히려 번거롭게 보일수도 있곘지만, 이런식으로 실수를 표현하는 이유는 큰 범위의 값을 표현하기 위해 서이다.
위의 고정 소수점 방식에서는 따로 물리적으로 정수부와 소수부로 나누어 각각 15bit, 16bit 밖에 사용하지 못하였지만, 부동소수점 방식은 실수의 값 자체를 가수부(23bit)에 넣어 표현하기 때문에 보다 큰 비트의 범위를 가지게 되며, 정수부가 크든 소수부가 크든 상관없이 가수부 내에서 전체 실수를 표현하기 때문에 공간 낭비 문제도 해결되는 것이다.
지수(e) 표기법
지수 표기법은 아주 큰 숫자나 아주 작은 숫자를 간단하게 표기할때 사용되는 표기법으로, 과학적 표기법(scientific notation) 이라도고 불리운다.
길다란 실수 숫자를 나타내는 데 필요한 자릿수를 줄여 표현해준다는 장점이 있다.
큰 범위의 값이라고 해서 얼마나 큰 범위냐 하면은, 실제로 자바의 타입 범위들을 보면 8바이트를 사용하는 정수형 long 타입 수 범위보다, 4바이트를 사용하는 실수형 float 타입 수 범위가 훨씬 더 크다는 것을 볼 수 있다.
double a = 9223372036854775808.0; // 비교를 위해 실수로 표현
double b = 3.4 * (Math.pow(10, 38)); // 3.4 x 10^38
System.out.println(a); // 9.223372036854776 x 10^18
System.out.println(b); // 3.4 x 10^38
System.out.println(a < b); // true
long 타입의 최대 크기인 9223372036854775808 와 float 타입의 최대 크기인 3.4 x 10^38 를 비교해보니 4바이트의 실수형 float 이 훨씬 수의 범위가 큰것을 코드를 통해 확인할 수 있다.
부동 소수점 계산 방법
현재 사용되고 있는 부동 소수점 방식은 대부분 IEEE 754 표준을 따르고 있다. 그리고 자바도 이 표준을 따른다.
IEEE 754 표준의 부동 소수점 수학적 수식은 다음과 같이 된다.
±(1.M) × 2^E-127
±(1.가수부) × 2^지수부-127
기호 | 의미 | 설명 |
S | 부호(Sign bit) | 0이면 양수, 1이면 음수 |
E | 지수부(Exponent) | 부호있는 정수. 지수의 범위는 -127 ~ 128(float), -1023 ~ 1024(double) |
M | 가수부(Mantissa) | 실제값을 저장하는 부분. 10진수로 7자리(float), 15자리(double)의 정밀도로 저장 가능 |
이제 그럼 -118.625 라는 실수를 부동 소수점으로 변환하는 예를 직접 행해보자.
1. 음수이기에 최상위 비트를 1로 설정해준다.
2. 절대값 118.625 를 이진법으로 변환해준다.
# 정수부 변환
118
= 1110110(2)
# 소수부 변환
0.625
= 0.625 x 2 = 1.250 → 정수부 1
= 0.250 x 2 = 0.500 → 정수부 0
= 0.500 x 2 = 1.000 → 정수부 1
= 101(2)
# 결과
118.625
= 1110110.101(2)
3. 소수점을 이동시켜 정수부가 한자리가 되도록 변환해준다. (그러면 지수 6이 만들어진다)
1110110.101 → 1.110110101 x 2^6
소수점을 이동시키는 것을 정규화(Normalization) 이라고 부른다.
정규화라는 단어는 수학이나 컴퓨터 분야에서 다양한 의미로 쓰이지만 여기서 말하는 정규화라는 것은 2진수를 1.xxxx... * 2^n 꼴로 변환하는 것을 말한다고 보면 된다.
4. 가수부 비트에 실수값 그대로를 넣는다 (점 무시)
5. 지수에 바이어스 값(127)을 더하고 지수부 비트에 넣는다.
6 + 127
= 133
= 100000101(2)
32bit IEEE 754 형식에는 bias라는 고정값(127)이 존재한다.
이 bias라는 값을 왜 쓰냐면, 지수가 음수가 될 수도 있는 케이스가 있기 때문이다. (2^10 or 2^-10)
예를 들면 0.000101(2)이라는 이진수가 있다고 가정하자
이를 1.xxxx... * 2^n 형식으로 표현하기 위해선, 오른쪽으로 소수점을 밀면 1.01 * 2^-4 가 된다.
이 -4 음수 지수를 8자리 비트로 표현하기 위해, (10진수 기준으로) 0~127 구간은 음수, 128~255 구간은 양수를 표현하도록 만든 것이다.
그래서 계산된 지수에 127 bias 값을 더하여, 127보다 작으면 음수, 127보다 크면 양수로 구분할 수 있는 것이다.
이와 같은 부동소수점 표현 방식은 고정소수점 표현 방식에 비해서 비트 수 대비 표현 가능한 수의 범위와 정밀도 측면에서 보다 우위에 있기 때문에, 정규화와 bias 같은 복잡한 과정이 들어감에도 불구하고 현재 대부분의 컴퓨터 시스템에서 부동소수점을 이용해 실수를 표현하고 있다.
배정도 부동 소수점 방식
자바에서는 float(32bit) 와 double(64bit) 자료형이 있는 것처럼,
부동 소수점 방식에도, 지금까지 위에서 살펴본 32비트 체계를 32비트 단정도 (Single-Precision), 64비트 체계를 64비트 배정도 (Double-Precision) 이라고 부른다.
double형 64비트 체계에서는 지수부가 11비트, 가수부가 52비트다.
지수부가 2^11 즉 2048개의 수를 표현할 수 있으므로 0~1023 구간은 음수, 1024~2047 구간은 양수 지수를 의미하며, 이때 bias 고정값은 1023이 된다는 차이점이 존재한다.
프로그래밍에서의 소수 계산 오차
지금까지 우리는 컴퓨터에서 실수를 저장하는 방식은 고정 소수점 방식과 부동 소수점 방식을 알아보았고, 좀더 큰 수를 표현하여 저장할 수 있는 부동 소수점 방식을 사용하는 이유에 대해 알아 보았다.
하지만 이 쯤에서 여러분이 간과한 부분이 있는데, 바로 포스팅 초반에 소개했었던 무한 소수 부분 이다.
0.625 같이 이진수 소수점으로 딱 떨어지는 수는 문제없지만 0.1 와 같이 0.0001100110011...(2) 로 무한 반복되는 이진수 실수는 아무리 큰 수를 저장하는 부동 소수점 방식이라 해도 무한대를 저장할수 없으니 결국 메모리 한계까지 소수점을 집어넣고 어느 부분에서 끊어 반올림을 해주어야 한다.
즉, 컴퓨터의 메모리는 한정적이기 때문에 실수의 소숫점을 표현할 수 있는 수의 제한이 존재하게 될 수 밖에 없다.
그리고 실수를 표현하는 숫자 제한이 있다는 것은 곧 부정확한 실수의 계산값을 초래한다는 뜻이기도 하다.
이것은 자바뿐만 아니라, 모든 프로그래밍 언어에서 발생하는 기본적인 문제이다.
다음 예제를 수행해보면 컴퓨터의 실수 연산 오차를 발견할 수 있다.
double value1 = 12.23;
double value2 = 34.45;
// 기대값 : 46.68
System.out.println(value1 + value2); // 46.68000000000001
12.23 와 34.45 을 더했으니 기대 결과로 46.68 이 나와야 되지만, 실제로는 46.68000000000001가 출력되어 버린다.
이는, 10진수 소수의 값인 12.23 과 34.45 을 2진수로 변환하는 과정에서 소수점이 안떨어지는 무한 소수 현상이 나타나 메모리 할당 크기의 한계 때문에 어느 자리수 까지의 반올림 표현밖에 못하고, 그런 부정확한 값을 이용해 연산을 하였으니 당연히 결과값도 부정확하게 나오게 된 것이다.
물론 수 자체의 크기로서는 근사한 차이 정도 밖에 안되겠지만, 금융이나 드론과 같은 관련 프로그램에서는 이 오차가 큰 영향을 미칠 수 있기 때문에 아주 정확한 계산이 필요하다.
따라서 이러한 컴퓨터의 실수 연산 문제를 해결 하기 위해 자바(Java)에서는 두가지 방법을 제공한다.
결론부터 말하자면 int, long 정수형 타입으로 치환하고 사용하거나, BigDecimal 클래스를 이용하면 된다.
소수 정확히 계산하는 방법
정수 치환하여 계산
예를 들어 25.35 에 100을 곱해서 2535 로 정수로 치환해서 계산하고 다시 100을 나누어서 소수 결과값을 도출하는 일종의 꼼수 방법이라고 보면 된다.
double a = 1000.0;
double b = 999.9;
System.out.println(a - b); // 0.10000000000002274
// 각 숫자에 10을 곱해서 소수부를 없애주고 정수로 형변환
long a2 = (int)(a * 10);
long b2 = (int)(b * 10);
double result = (a2 - b2) / 10.0; // 그리고 정수끼리 연산을 해주고, 다시 10.0을 나누기 하여 실수로 변환하여 저장
System.out.println(result); // 0.1
BigDecimal 클래스
다만 소수의 크기가 9자리를 넘지 않으면 int 타입을 사용히면 되고, 18자리를 넘지 않으면 long 타입을 사용하면 되지만, 컴퓨터에서는 데이터의 표현 범위가 제한되어 있기 때문에, 만일 18자리를 초과하면 BigDecimal 클래스를 사용해야 한다.
// BigDecimal 자료형을 사용
BigDecimal bigNumber1 = new BigDecimal("1000.0");
BigDecimal bigNumber2 = new BigDecimal("999.9");
BigDecimal result2 = bigNumber1.subtract(bigNumber2); // bigNumber1 - bigNumber2
System.out.println(result2); // 0.1
BigDecimal를 사용할 경우 기본형 타입보다 사용하기 불편하고 실행 속도가 느려진다는 단점이 있다. 하지만 소수를 계산함에 있어서 필수로 자주 이용되는 클래스이니 반드시 익히기를 권한다.
double과 float의 비교연산 문제
실수를 더하거나 빼는 계산에 대한 오차는 해결했지만 한 가지 더 문제점이 존재한다.
자바의 float는 4바이트 실수, double은 8바이트 실수 값을 저장 할 수 있는데, 문제는 컴퓨터는 부동 소수점으로 실수를 표현하기 때문에 double은 float 간의 정밀도(정확도) 차이가 발생한다는 점이다.
System.out.println(1.0 == 1.0f); // 결과 : true
System.out.println(1.1 == 1.1f); // 결과 : false
System.out.println(0.1 == 0.1f); // 결과 : false
System.out.println(0.9 == 0.9f); // 결과 : false
System.out.println(0.01 == 0.01f); // 결과 : false
위 코드를 보면 다 true일 것이라 착각 하기 쉽다. 하지만 결과는 맨 윗줄만 제외하고 모두 false를 출력한다.
눈에 보이지는 않지만 바로 float와 double자료형의 실수 표현의 정밀도의 차이가 발생하기 때문이다.
따라서 double과 float값을 비교 할때에는 모두 float로 형변환 하거나 정수로 변환하여 비교해야 한다.
System.out.println((float)1.1 == 1.1f); // 결과 : true
System.out.println(0.1f == (double)0.1f); // 결과 : true
System.out.println(0.1 == (double)0.1f); // 결과 : false
주의 할 점은 (double)0.1f 연산식은 double의 공간에 float의 정밀도를 갖는 값이 저장될 뿐이라서 double형의 0.1과 비교해도 결과가 true로 나오지 않는 다는 점이다.
# 참고 자료
https://velog.io/@maketheworldwise/float-double-%EB%B6%80%EB%8F%99%EC%86%8C%EC%88%98%EC%A0%90
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.