서론

파스타(PASTA)에서 AI 체형 예측 기능을 사용하기 위한 정보 입력 화면입니다. 사진에서 알 수 있듯이 키와 체중은 소수점 첫째 자리까지 입력이 가능합니다. 정책상 현재 체중의 입력 가능한 범위는 입력된 키에 영향을 받고 목표 체중의 입력 가능한 범위는 키와 현재 체중에 영향을 받습니다. 입력 가능한 정보 중 하나라도 값이 바뀔 때마다 소수점 연산이 계속해서 일어나게 되고 아무 생각 없이 Float 타입끼리의 연산 코드를 작성한 주인장은 예상치 못한 크래시와 오차 값들을 마주하게 됩니다.
부동 소수점
fun main() {
val a = 1.1f
val b = 0.2f
println(a+b) // 1.3이 아닌 1.3000001이 출력됩니다.
}
대부분의 사람들은 위 코드를 실행하면 1.3이 출력될 것이라고 기대합니다. 하지만 연산 결과를 출력해보면 1.3000001과 같이 예상과 다르게 값이 출력됨을 알 수 있습니다. 이 현상은 컴퓨터가 실수를 저장하고 표현하는 방식 때문입니다. 컴퓨터는 0과 1(이진수)로 이루어진 기계어를 이용해 정보를 저장 및 처리하고 이는 10진수의 실수를 완벽히 표현하는 것에 한계가 있습니다.
컴퓨터는 왜 이진법을 사용할까?
0과 1은 전기적 신호의 ON/OFF 상태와 일치하기 때문에 하드웨어 설계와 구현에 있어 효율적입니다. 또한 AND, OR, NOT, XOR 등의 논리 연산도 이진법을 기반으로 동작하기 때문에 컴퓨터의 안정성과 신뢰성을 높여 오류를 최소화할 수 있습니다.
실수는 이진법으로 어떻게 변환될까?
실수는 정수부와 소수부로 이루어져 있습니다. 그리고 2진수는 2의 거듭제곱으로 수가 이루어집니다. 예를 들어 1101.01₂ 은 아래와 같이 표현이 가능합니다. 정수부는 2의 양의 거듭제곱, 소수부는 2의 음의 거듭제곱으로 표현됨을 알 수 있습니다.
1101.01₂ = 1 × 2³ + 1 × 2² + 0 × 2¹ + 1 × 2⁰ + 0 × 2⁻¹ + 1 × 2⁻²
그렇다면 10진수 실수를 2진수로 어떻게 변환할까요? 13.25를 2진수로 변환하는 예를 들어 보겠습니다. 정수부인 13과 소수부인 0.25로 나눠서 계산할 수 있습니다. 10진수 정수를 2진수로 변환하는 방법은 몫이 0이 될 때까지 2로 계속해서 나눈 나머지들을 나열하면 됩니다. 따라서 13을 2진수로 변환하려면 아래의 과정을 거칩니다. 그리고 나머지들을 거꾸로 나열하면 1101₂와 같이 2진수로 변환이 됩니다.
13 ÷ 2 = 6...1
6 ÷ 2 = 3...0
3 ÷ 2 = 1...1
1 ÷ 2 = 0...1
정수부를 2씩 나눈 것과는 반대로 소수부는 정수가 될 때까지 소수 부분만 2씩 곱하는 과정을 반복합니다. 정수 부분을 나열하면 됩니다. 0.25를 2진수로 변환하면 아래의 과정을 거칩니다. 순서대로 계산 결과의 정수 부분을 나열하면 0.01₂이 됩니다.
0.25 × 2 = 0.5 (정수가 아니므로 소수 부분인 0.5만 다시 2를 곱합니다)
0.5 × 2 = 1 (정수이므로 변환 과정을 종료합니다)
이렇게 계산한 정수부와 소수부를 합쳐서 최종적으로 13.25는 1101.01₂로 변환할 수 있습니다. 그런데 소수부의 이진수 변환은 정수가 될때까지 2를 곱하기 때문에 0.1과 같은 숫자를 이진수로 변환하려고 하면 변환 과정이 무한히 반복됨을 알 수 있습니다.
| 변환 과정 | 정수 부분 | 소수 부분 |
| 0.1 × 2 = 0.2 | 0 | 0.2 |
| 0.2 × 2 = 0.4 | 0 | 0.4 |
| 0.4 × 2 = 0.8 | 0 | 0.8 |
| 0.8 × 2 = 1.6 | 1 | 0.6 |
| 0.6 × 2 = 1.2 | 1 | 0.2 |
| 0.2 × 2 = 0.4 | 0 | 0.4 |
| 0.4 × 2 = 0.8 | 0 | 0.8 |
| 0.8 × 2 = 1.6 | 1 | 0.6 |
| 계속 반복 … | … | … |
컴퓨터는 한정된 메모리 자원을 가지고 있기 때문에 0.1과 같이 무한히 반복되는 2진수를 정확하게 저장할 수 없습니다. 따라서 상단의 코드처럼 1.1 + 0.2를 했을 때 1.3이 아닌 결과가 도출되는 것입니다. 그렇다면 실수 2진수는 메모리에 어떻게 저장될까요? IEEE(미국 전기전자학회) 754는 국제 표준으로 대부분의 OS, CPU는 다음과 같은 부동 소수점 방식으로 2진수를 저장합니다.
먼저 2진수를 1.xxx× 2ⁿ 과 같은 형태로 정규화해줍니다. 예를 들어 1101.01은 1.10101× 2³ 과 같이 정규화할 수 있습니다. 이렇게 소수점을 옮기는 과정이 소수점이 움직이면서 떠다니는 것으로 형상화하여 Floating Point, 부동 소수점이라는 명칭이 생겼습니다.

Sign 비트는 부호를 나타내는 것으로 0이면 양수, 1이면 음수를 의미합니다. 23비트인 가수부(Significand)에는 정규화한 소수부를 넣어줍니다. 남은 자리는 0으로 채워집니다. 8비트인 지수부(Exponent)에는 지수에 해당하는 3이 들어가면 되는데 바로 3을 넣는게 아닌 IEEE 표준에서 정한 bias를 더한 값을 2진수로 변환하여 넣습니다. IEEE 표준 32비트의 bias는 127이기 때문에 127 + 3 = 130 의 2진수인 10000010이 들어간다. 이렇게 bias를 더해주는 이유는 지수가 음수인 경우를 표현하기 위함입니다.
위 사진처럼 32비트 크기로 2진수를 저장하는 자료 타입이 Float이고, 64비트 크기로 저장하는 경우에는 정확도가 2배라는 double precision의 앞부분을 따와 Double이라는 자료 타입명이 생겼습니다.
부동 소수점 오차 해결 방법
BigDecimal
코틀린에서의 Float, Double 또한 부동 소수점 방식으로 실수를 저장하기 때문에 Float과 Double을 사용하면 부동 소수점 오차 문제를 근본적으로 해결할 수는 없습니다. 만약 정확한 실수 저장 및 계산이 필요한 경우에는 BigDecimal 타입을 사용해야 합니다.
BigDecimal은 내부적으로 소수점 자리수(Scale)와 소수점을 제거한 정수를 저장합니다. 따라서 13.25는 소수점 자리 수인 Scale 2와 소수점을 제거한 1325를 BigInterger로 저장하게 됩니다. BigDecimal은 내부적으로 BigInteger를 사용하여 BigInteger가 가지는 임의 정밀도 특징을 이용합니다.
임의 정밀도란 정수를 숫자의 배열로 간주해 자릿수를 쪼개어 메모리가 허용하는 내에서 무한한 정수를 저장할 수 있는 방식입니다. Int나 Float 과 같은 타입은 CPU 레지스터에 바로 들어가기 때문에 크기 제한이 생겨 표현할 수 있는 값의 범위가 한정되지만 BigIntger는 임의 정밀도의 특징을 가져 메모리에 배열로 정수를 나누어서 저장하기 때문에 메모리 크기 한도까지 값의 범위를 늘릴 수 있습니다.
다만 Float과 Double은 CPU의 레지스터에서 FPU(Floating Point Unit), 부동 소수점 처리 장치에 의해 빠르게 연산이 되지만 BigDecimal은 메모리에 올라가는 객체이기 때문에 연산 속도가 비교적 느리며, 한 번 생성된 BigDecimal은 불변 객체이기 때문에 매번 새로운 객체가 생성된다는 점을 염두에 두고 사용해야 합니다.
커스텀 Epsilon 정의하기
컴퓨터에서 부동 소수점 연산으로 발생할 수 있는 오차의 상한을 Machine Epsilon이라고 합니다. 즉, 두 실수 값의 차이가 Machine Epsilon 보다 작으면 두 실수는 동일한 값으로 판단하는 것입니다. 만약 미세한 오차가 허용되는 도메인이나 비즈니스 로직이라면 정책상 적절한 Epsilon을 정의하여 사용하는 방법이 있습니다.
val epsilon = 1e-9
val a = 0.1 + 0.2
val b = 0.3
if (abs(a - b) < epsilon) {
println("a와 b는 동일한 값으로 인정")
}
정수로 변환하여 사용하기
실수에 일정한 값을 곱해서 정수로 변환하여 연산을 하면 부동 소수점 문제를 없앨 수 있습니다. 예를 들어 몸무게를 표현할 때 73.4kg을 코드상으로는 항상 1000을 곱한 73400으로 표현하는 것 입니다.
val weight1 = 73400 // 73.4kg → g 단위
val weight2 = 52100 // 52.1kg → g 단위
val total = weight1 + weight2 // 125500 (125.5kg)
이렇게 하면 Int 타입을 사용하기 때문에 BigDecimal에 비해 성능상 유리하고 Float과 Double을 사용하지 않아 부동 소수점 오차가 발생하지 않는다는 장점이 있지만 여전히 나누거나 곱하는 숫자의 결과가 실수가 나오거나 실수를 나누어야 한다면 결국 부동 소수점 오차 문제가 발생할 수 있다는 점과 1000을 곱하는 것과 같이 정수 변환을 위한 상수를 새로 만들어 팀 내 새로운 컨벤션이 지켜져야 한다는 점을 고려해야 합니다.