[유니티 / C#] 부동소수점의 원리

부동 소수점이란?

부동 소수점은 영어로 Floating Point라고 합니다.

말 그대로 소수점이 떠다닌다는 의미로 소수점의 위치를 고정하지 않고 자유롭게 움직여

매우 크거나 작은 실수를 표현할 수 있는 방법입니다.

 

 

 

 

 

실수를 2진수로 표현하는 방법

10진수의 정수를 2진법으로 나타내듯 10진수의 실수 또한 2진법을 이용하여 변환할 수 있습니다.

 

예를 들어 15.125라는 10진수를 2진법을 이용해 변환해 보겠습니다.

 

 

<15.125 실수 중 정수부 변환 과정>

  1. 정수부를 2로 나눕니다.
  2. 나머지는 따로 빼두고 몫은 2로 계속 나눕니다.
  3. 더 이상 나누어지지 않을 때 결괏값을 반대로 나열하면 됩니다.

결과: 10진법 15 = 2진법 1111과 같습니다.

 

 

<15.125 실수 중 정수부 변환 과정>

 

  1. 실수부를 2로 곱합니다.
  2. 2로 곱한 값의 정수부를 빼둡니다.
  3. 소수점 이하의 숫자가 없어지면 순서대로 나열합니다.

결과: 10진법 0.125 = 2진법 0.001과 같습니다.

 

이를 통해 10진수 15.125를 2진법으로 변환하면 1111.001이 된다는 것을 알 수 있습니다.

 

 

 

 

 

컴퓨터가 실수를 저장하는 방법

컴퓨터는 숫자를 저장할 때 RAM(메모리)에 저장하게 됩니다.

RAM(메모리)은 수많은 비트 단위의 칸들로 구성되어 있으며 컴퓨터가 읽을 수 있는 2진법으로 변환되어 저장됩니다.

 

정수의 경우 5(byte형)를 저장하는 경우에는 0000 0101로 저장됩니다.

 

각 정수 타입 별 저장 크기

  • byte -> 8비트
  • short -> 16비트
  • int -> 32비트
  • long -> 64비트

각 실수 타입 별 저장 크기

  • float -> 32비트
  • double -> 64비트
  • decimal -> 128비트

 

 

그렇다면 실수의 경우 어떻게 저장될까요?

 

 

 

고정 소수점 방식

고정 소수점 방식은 정수부와 소수부를 비트 수로 미리 나누어 처리하는 방식입니다.

소수부의 자릿수를 미리 정하여 고정된 자릿수의 소수를 표현하기 때문에 직관적입니다.

32비트에서 1비트(부호) + 15비트(정수부) + 16비트(소수부) 형태로 구성되어 있습니다.

 

예를 들어 10진수 실수 15.125를 2진법으로 변환하여 고정 소수점 방식으로 메모리에 저장할 경우 다음과 같이 표현할 수 있습니다.

15.125(10) = 1111.001(2)

 

 

장점

  • 표현 구조가 단순하여 연산 속도가 빠릅니다.
  • 하드웨어 구현이 간단하고 메모리 소모가 적습니다.

단점

 

  • 표현할 수 있는 범위가 좁습니다.
  • 작은 소수가 매우 큰 수를 정확하게 표현하기 어렵습니다.
  • 오버플로우(overflow)나 언더플로우(underflow)에 취약합니다.

 

 

 

부동 소수점 방식

부동 소수점 방식은 쉽게 말해 소수점의 위치가 가변적인 방식이라고 생각하시면 됩니다.

IEEE 754 표준에서 32비트는 1비트(부호) + 8비트(지수) + 23비트(가수) 형태로 구성되어 있습니다.

 

마찬가지로 10진수 실수 15.125를 2진법으로 변환하여 부동 소수점 방식으로 메모리에 저장할 경우 다음과 같이 표현할 수 있습니다.

 

1. 15.125는 양수이기에 부호비트를 0으로 설정해 줍니다.

 

 

 

2. 실수 15.125를 이진법으로 변환해 줍니다. 

* 단 부호비트를 통해 값의 부호를 지정해 주었기 때문에 -15.125일 경우에도 동일하게 진행합니다.

15.125(10) -> 1111.001(2)

 

3. 변환한 2진법을 정수부 한 자릿수만 남겨둔 채 왼쪽으로 이동시킵니다. 

* 왼쪽으로 3번 옮겨주었으므로 지수는 3이 됩니다.

1111.001(2) -> 1.111001(2) x 2^3
//실수값 111001은 mantissa 라고 부릅니다.

 

4. 가수부 비트에 실수값을 그대로 넣습니다.

 

 

5. 지수에 바이어스(bias) 값을 더해 이진법으로 변환 후 지수부 비트에 넣습니다.

* bias 값은 127입니다.

//지수값 + 바이어스(bias)값
3 + 127 = 130
//이진법 변환 후
130 -> 10000010

 

 

장점

  • 비트 수 대비 표현할 수 있는 범위가 굉장히 넓고 정밀도가 좋습니다.
부동소수점 방식에서 32비트 체계는 32비트 단정도, 64비트 체계는 64비트 배정도라고도 부릅니다.

 

 

 

 

 

1.1 + 0.1 = 1.2

부동 소수점 방식은 비트 수 대비 표현 가능한 범위가 넓고 정밀하기 때문에 대부분의 시스템에서 실수를 표현하기 위한 방법으로 사용되고 있습니다.

 

그렇다면 부동 소수점 방식을 사용했을 때 문제는 없을까요?

아래 코드를 통해 프로그래밍에서의 소수 계산에 관한 문제를 살펴보겠습니다.

 

//float 실수형의 연산
1.1f + 0.1f == 1.2f => true
//double 실수형의 연산
1.1 + 0.1 == 1.2 => false

 

분명 둘 다 실수를 저장하는 자료형이지만 float형의 연산은 true가 나오고, double형의 연산은 false가 나왔습니다.

이는 위에서 보았던 컴퓨터의 실수를 저장하는 방식과 연관되어 있습니다.

 

실수 0.125는 이진법으로 변환하면 딱 떨어지게 됩니다.

하지만 실수 0.1은 0.00011001100110011......로 무한히 반복되게 됩니다.

이런 경우 컴퓨터의 메모리는 자원이 한정되어 있기 때문에 정해진 비트만큼만 저장 후 남은 부분은 버리게 됩니다.

이 과정에서 유실되는 값이 있기 때문에 오차가 생길 수밖에 없습니다.

 

float형의 연산과 double형의 연산 결과가 다른 이유 역시 float은 32비트, double은 64비트로 double형의 경우 더 많은 메모리칸을 사용하기 때문에 정밀도가 높아 오차가 감지되고 float형에서는 감지되지 않은 것입니다.

 

 

오차 범위를 해결하는 방법?

 

정수로 변환한 후 계산

 

간단한 연산의 경우 실수 부분을 ^n을 통해 곱해 정수로 만들어 연산 후 다시 실수형으로 만들어주면 됩니다.

단 소수점 자릿수를 미리 정해야 하는 경우에만 사용하는게 좋습니다. (ex: 2자리 = x 100, 3자리 = x 1000)

double a = 1.1;
double b = 0.1;
double floatResult = a + b;
Console.WriteLine(floatResult == 1.2); 	//False


int intA = (int)(a * 10);
int intB = (int)(b * 10);
double intResult = (intA + intB) / 10.0;

Console.WriteLine(intResult == 1.2); 	//True

 

 

decimal 타입 사용

 

decimal은 128비트를 사용하여 저장하는 자료형으로 double(64비트) 보다 훨씬 정확하게 표현이 가능합니다.

또한 훨씬 넓은 범위의 표현이 가능하고 금융, 회계등의 소수점 계산이 중요한 경우에 많이 쓰이게 됩니다.

하지만 그만큼 메모리와 연산 속도가 느리기 때문에 정말 필요할 때만 사용하는 것을 권장합니다.

decimal a = 1.1m;
decimal b = 0.1m;
decimal result = a + b;
Console.WriteLine(result == 1.2m);	//True

 

 

Epsilon 사용

Epsilon은 실수 단위의 비교를 할 때 어느정도의 오차 범위를 허용해주기 위해 사용하는 값입니다.

비교를 할 때 정확히 같음이 아니라 거의 같음인지 판단하는거라고 생각하시면 됩니다.

유니티에서는 Mathf.epsilon / C#에서는 자료형.epsilon (ex: float.epsilon, double.epsilon)으로 사용하면 됩니다.