올리브영 테크블로그 포스팅 부동소수점 이야기
Settlement

부동소수점 이야기

돈 계산에는 특별한 방법이 필요한 법

2023.10.20

안녕하세요! 올리브영 정산 스쿼드 개발자 ano입니다. 🐈

오늘은 정산 시스템에서 사용하는 숫자 계산 방식에 대해 소개하려고 합니다.

📢 ‘부동소수점’ 방식의 실수 계산에 대해 다루는 글입니다.

🔥 서론 없이 빠르게 갑니다!


‘부동소수점’ 계산 방식

정산 프로그램은 여러 수식을 사용하여 업무에서 필요로 하는 금액을 계산하고 있습니다.

보통 자바에서 특별한 경우가 아니면 숫자 계산을 할 때 기본 자료형을 사용하는 경우가 많은데요.

정산에서는 돈 계산에 기본 자료형을 사용하지 않습니다❗️

왜냐고요..⁇

기본 자료형으로 실수를 계산했을 때 의도와 다른 결과를 구해오는 경우가 많이 있거든요.

대체 어떤 문제가 있는지, 이해를 돕기 위해 자바 기본 자료형 중 하나인 double 자료형으로 계산하는 경우를 예제로 사용하여 설명하겠습니다.

[예제1]

public class Test{
    public static void main(String[] args) {
        double num1 = 55; // 금액
        double num2 = 4; // 수량
        double num3 = 1.1; // 계산식
        double num4 = num1 / num2;          // 출력 결과 : 13.75
        double num5 = num1 / num2 / num3;   // 출력 결과 : 12.499999999999998
        double num6 = Math.round(num5);     // 출력 결과 : 12.0
    }
}

기본 실수형인 double을 사용하여 계산했을 때 프로그램이 구한 출력 결과는 위와 같습니다. 그런데.. 출력 결과가 뭔가 이상합니다. 눈치채셨나요?

본래 이 수식이 구하고자 했던 숫자는 아래 '기대 결과' 값입니다.

[예제2]

public class Test{
    public static void main(String[] args) {
        double num1 = 55; // 금액
        double num2 = 4; // 수량
        double num3 = 1.1; // 계산식
        double num4 = num1 / num2;          // 기대 결과 : 13.75
        double num5 = num1 / num2 / num3;   // 기대 결과 : 12.5
        double num6 = Math.round(num5);     // 기대 결과 : 13.0
    }
}

num5를 계산하는 시점부터 프로그램이 계산한 결과와 사람이 계산한 결과에 차이가 발생하기 시작합니다. 급기야 최종 값인 num6에 가서는 반올림을 하면서 정수 단위가 달라지고 말았습니다.

이런 현상은 좀 더 간단한 수식에서도 발생합니다.

[예제3]

public class Test{
    public static void main(String[] args) {
        double num1 = 0.1;
        double num2 = 0.2;
        double sum = 0.3; // 출력 결과 : 0.30000000000000004
    }
}

1+2는 3입니다. 그런데, 0.1+0.2는 0.3이 아닙니다.

결론부터 말하자면, 이는 프로그램에서 기본 실수형을 사용했을 때 부동소수점(Floation point) 방식으로 계산하기 때문에 발생하는 문제입니다.

고정소수점 방식으로 계산할 수 있도록 BigDecimal형을 사용하면 아래 [예제4]와 같이 간단히 해결할 수 있습니다.

[예제4]

public class Test{
    public static void main(String[] args) {
        BigDecimal bigNum1 = new BigDecimal("0.1");
        BigDecimal bigNum2 = new BigDecimal("0.2");
        BigDecimal bigSum = bigNum1.add(bigNum2); // 출력 결과 : 0.3
    }
}

BigDecimal 을 사용하니 문제를 바로 해결했습니다.

원하는 대로 딱 떨어지는 0.3이란 값을 얻었어요. 그런데.. 앞서 double 자료형으로 계산한 결과는 왜 저렇게 이상한 값을 구해온 거죠?

다시 [예제3]으로 돌아가서

0.1 + 0.2 계산 결과가 0.3으로 정확히 떨어지지 않은 이유에 대해 알아보겠습니다.

부동소수점이란 ‘소수점이 움직인다’라는 의미로 고정소수점과 반대 개념으로 이해하면 됩니다.

자바에서 기본적으로 제공하는 실수형 연산은 10진수를 그대로 계산하지 않고 2진수로 변환하여 다룹니다.

이 때 10진수의 소수점 이하 값을 2진수에서는 완벽히 표현할 수 없기 때문에, 정확한 값이 아닌 그 근사치를 사용하게 됩니다.

부동소수점 방식을 사용했을 때, 프로그램이 0.1이라는 숫자를 어떤 방법으로 관리하는지 아래 [예제5]에서 설명하겠습니다.

[예제5]

img 2
  1. ‘부호’는 음수, 양수를 구분하는 값입니다. 0.1은 양수이므로 부호는 0이 됩니다.
  2. ‘지수’는 소수점이 어느 방향으로 몇 번이나 이동했는지를 나타냅니다. 2진수 ‘01111011’은 10진수 123입니다. 127과의 차이가 4이므로, 소수점이 네 번 움직였다는 의미가 됩니다.
  3. ‘가수’는 주어진 10진수를 2진수로 변환한 결과를 ‘1.xxx…’와 같은 형태로 변경한 후, 소수점 이하 값만 가져와 사용합니다. 가수를 구하는 방식은 아래와 같습니다.
    1. 10진수 ‘0.1’을 2진수로 변환하여 ‘0.00011001100110011001101…(무한 반복)’값을 구합니다.
    2. 위에서 구한 2진수를 ‘1.xxx…’형태로 변경하기 위해 소수점을 오른쪽으로 네 번 옮깁니다.
    3. 그럼 ‘1.1001100110011001101…’값을 얻을 수 있습니다.
    4. 소수점 아래 숫자인 ‘1001100110011001101…’을 가수 값으로 합니다.
  4. 이렇게 구한 부호(0), 지수(01111011), 가수(1001100110011001101…)를 모두 붙여주면 부동소수점 방식으로 표현한 값(‘00111101110011001100110011001100’)을 얻을 수 있습니다.

지금까지 얻어낸 부동소수점 값을 다시 10진수로 변환하게 되면,

본래 입력했던 10진수 0.1이 아니라 그 근사치인 ’0.09999990463256836..’ 이러한 숫자를 얻게 됩니다.

즉, double 타입에 우리는 0.1이라는 값을 주고 초기화했지만 프로그램상 double 데이터 타입은 ’0.09999990463256836..’라는 0.1 근사치 값을 갖게 됩니다.

이와 같이 부동소수점 방식은 10진수를 2진수로 변환하여 최초 초기화한 10진수 값이 아닌 그 근사치 값으로 연산을 수행하기 때문에, 소수점 처리 시 정밀한 결과를 계산해 낼 수 없습니다.


사실 이 차이는 아주 근소하므로 소수점 단위까지 정확히 계산할 필요가 없는 업무에서는 무시해도 될 수준입니다.

그러나 정확한 계산을 요구하는 정산과 같은 업무에서는 [예제1], [예제2]과 같이 차이가 발생하는 경우 1원 또는 그 이상의 금액 차가 발생하여 계산 오류로 이어질 수 있으므로,

반드시 BigDecimal을 사용하여 부동소수점 정밀도 문제로 인한 반올림 오차를 방지하는 것이 중요합니다.

다만 BigDecimal은 오브젝트이기 때문에 float, double과 같은 기본 자료형과는 연산 방법이 다르므로,

비교 연산 등 사용 시 예상치 못한 결과를 얻을 수 있어 주의를 기울여 사용할 필요가 있습니다.

마지막으로 BigDecimal에 대해 정리하자면

  1. BigDecimal은 내부적으로 십진수로 숫자를 저장하므로, 0,1과 같은 십진수를 정확히 표현 가능합니다.
  2. BigDecimal은 필요한 만큼 많은 자릿수로 값을 저장하고 계산 가능하므로, 무한 소수 정밀도를 가집니다.
  3. BigDecimal은 문자열로 초기화하는 방법을 통해 부동소수점 방식의 한계를 우회합니다.
  4. 기본 자료형에 비해 연산 속도가 느립니다.


끝으로

Java에서 BigDecimal을 사용하는 몇 가지 예제를 남기고 글을 마치겠습니다.

[예제6]

public class Test{
    public static void main(String[] args) {
        BigDecimal numZ = MibDecibal.ZERO; // 초기화에 사용할 수 있는 상수 정의
        BigDecimal numO = MibDecibal.ONE;
        BigDecimal numT = MibDecibal.TEN;

        BigDecimal num1 = new BigDecimal("0.1"); // 문자로 초기화
        BigDecimal num2 = new BigDecimal("0.2");
        BigDecimal num3 = new BigDecimal("0.123450");

        num1.equals(num2); // 소수점 끝 0까지 모두 일치해야 true
        num1.compareTo(num2); // 소수점 끝의 0을 무시하고 비교하여 작으면 -1, 같으면 0, 크면 1

        num1.add(num2); // 덧셈
        num2.subtract(num1); // 뺼셈
        num1.multiply(num2); // 곱셈
        num2.divide(num1); // 나눗셈
        num2.divide(num1, MathContext.UNLIMITED); // 나눗셈(자릿수 제한 없음)
        num2.divide(num1, 2, BigDecimal.ROUND_HALF_UP); // 나눗셈(소수점 2자리 반올림)
        num3.divide(3, MathContext.DECIMAL32);
        num3.divide(3, MathContext.DECIMAL64);
        num3.divide(3, MathContext.DECIMAL128);
        num2.remainder(num1); // 나머지

        num1.max(num2); // 최대값 구하기
        num1.min(num2); // 최소값 구하기

        num3.stripTrailingZeros(); // 소수점 끝 0을 제거한 값을 반환
        num3.setScale(0, RoundingMode.FLOOR); // 소수점 이하 절사
        num3.setScale(0, RoundingMode.CEILING); // 소수점 이하 절사 후 +1
        num3.setScale(8); // 소수점 자릿수 재정의
        num3.setScale(4, RoundingMode.HALF_EVEN); // 본래보다 작은 값으로 재정의하는 경우, 반올림 정책 명시
        num3.setScale(3, RoundingMode.HALF_UP); // 소수점 3자리 반올림
    }
}

다음에 또 새로운 내용으로 찾아뵙게 되길 기약하며, 감사합니다!

Settlement
올리브영 테크 블로그 작성 부동소수점 이야기
🐈
ano |
Back-end Engineer
정산러입니다.