Java

삼쩜삼에서 부동소수점을 다루는 방법

Jaime.Lee 2024. 9. 23. 15:23

회사 블로그에 작성한 글을 제 개인 블로그에도 함께 올립니다.
원본은 여기서 확인하실 수 있습니다.

 

삼쩜삼은 자비스앤빌런즈에서 제공하는 서비스로, 사용자들이 간편하게 세금을 환급받을 수 있도록 돕습니다. 이를 위해 먼저 결정 세액을 계산하고, 최종 환급액을 산출하는 과정이 필요한데, 이 때, 가장 중요한 요소 중 하나는 정확한 계산입니다. 특히, 세금과 관련된 모든 금액을 처리하는 과정에서 발생하는 작은 오차조차 법적 문제나 금전적 손실로 이어질 수 있어, 높은 정밀도가 요구됩니다. 한화(원)와 같은 통화는 소수점이 필요 없지만, 세율 계산 등 소수점 이하의 복잡한 연산을 처리할 때는 부동소수점(Floating Point) 방식을 사용하게 됩니다. 그러나 이 방식은 소수점 이하의 값을 정확히 표현하지 못해 예상치 못한 문제가 발생할 수 있으며, 이는 개발자가 간과하기 쉬운 부분입니다.

부동소수점 이슈란?

부동소수점은 컴퓨터가 소수점을 포함한 실수를 표현하는 일반적인 방법입니다. 하지만, 10진수를 2진수로 변환하는 과정에서 정확히 표현되지 못하는 수가 발생하며, 그 결과로 예상과 다른 계산 결과가 나올 수 있습니다. 예를 들어, 0.1과 같은 단순한 수조차도 이진수로는 무한 소수로 표현되기 때문에, 컴퓨터는 이를 근사치로 저장합니다. 이 작은 차이가 반복된 계산에서 점점 더 큰 오차로 누적될 수 있습니다.

이를 간단한 자바 코드로 확인해보면,

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.math.BigDecimal;
import org.junit.jupiter.api.Test;

class BigDecimalTest {

  @Test
  void testFloatingPointIssue() {
    double sum = 0.0;

    for (int i = 0; i < 10; i++) {
      sum += 0.1;
    }

    double expected = 1.0;
    assertEquals(expected, sum, "The sum should be exactly 1.0");
  }
}

0.1을 10번 더하는 코드이므로 결과가 1일 거 같지만 실제로 수행해보면,

테스트 코드 수행 결과

이렇게 오차가 생기는 것을 확인할 수 있습니다.

 

assertNotEquals를 사용하는 게 더 나은 테스트 코드 작성 방식이지만 에러 로그 출력을 위해 일부러 실패하는 테스트를 작성하였습니다.

 

세무에서의 부동소수점 오차 문제

세금 계산에서 부동소수점 오차가 발생하면 법적, 재무적 리스크가 생길 수 있습니다. 예를 들어, 세율 계산에서 발생한 미세한 오차가 대규모 거래에서는 큰 금액 차이로 이어질 수 있습니다. 또한, 이러한 오차는 세금 보고서 작성 시 불일치 문제를 유발하여 기업에 불이익을 줄 수 있습니다.

자비스앤빌런즈는 현재 대부분의 코드를 자바로 작성하고 있고, 자바에서 발생할 수 있는 부동소수점 문제 해결을 위해 BigDecimal을 사용하고 있습니다.

BigDecimal 이란?

BigDecimal은 자바에서 부동소수점 연산의 정확도를 보장하기 위해 사용되는 클래스입니다. BigDecimal은 임의의 정밀도를 지원하며, 소수점 이하 자릿수의 오차 없이 숫자를 정확하게 표현할 수 있습니다. 이는 특히 금액 계산에서 중요한데, 부동소수점의 근사치 문제로 인한 오차를 피할 수 있기 때문입니다.

BigDecimal의 주요 특징

BigDecimal은 아래와 같은 특징을 가지고 있습니다.

  • 정밀한 연산: BigDecimal은 내부적으로 숫자를 정수로 저장하고, 소수점 위치를 따로 관리합니다. 이 방식은 소수점 이하의 미세한 값까지도 정확하게 처리할 수 있게 해줍니다.
  • 임의의 자릿수 지정 가능: 계산 과정에서 원하는 만큼의 자릿수를 지정할 수 있으며, 계산 결과를 반올림, 올림, 버림 등 다양한 방식으로 처리할 수 있습니다. 이를 통해 필요한 소수점 이하 자리까지 정확한 계산이 가능합니다.

이러한 특징 덕분에 금융, 세무, 회계 등 정확한 계산이 필요한 분야에서 사용됩니다.

BigDecimal 사용 예시

아래는 BigDecimal을 사용하여 앞서 부동소수점 문제를 해결하는 예제입니다.

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.math.BigDecimal;
import org.junit.jupiter.api.Test;

class BigDecimalTest {

  @Test
  void testBigDecimalAddition() {
    BigDecimal sum = BigDecimal.ZERO;
    BigDecimal value = new BigDecimal("0.1"); // BigDecimal.valueOf(0.1)을 사용해도 동일

    for (int i = 0; i < 10; i++) {
      sum = sum.add(value);
    }

    BigDecimal expected = new BigDecimal("1.0");
    assertEquals(expected, sum, "The sum should be exactly 1.0");
  }
}

실행 결과

수정된 테스트 코드 수행 결과

위 코드에서 보듯이, BigDecimal을 사용하면 0.1을 10번 더한 결과가 정확하게 1.0이 됩니다. 이처럼 BigDecimal을 활용하면, 세금 계산에서 발생할 수 있는 미세한 오차를 완벽히 방지할 수 있습니다.

주의 사항

여기까지는 많이 알려져있고 이해하는 데 큰 어려움이 없는 내용입니다. 하지만 BigDecimal 객체를 생성하는 과정에서 흔히 하는 실수가 있습니다. 이를 피하기 위해 정확한 방법을 이해하는 것이 중요합니다.

BigDecimal.valueOf(double val)와 new BigDecimal(double val) 의 차이점

BigDecimal에는 다양한 생성자가 있지만, 이 섹션에서는 특히 double 타입을 전달하는 생성자들에 대해 다룹니다.

new BigDecimal(double)을 사용할 때, double 값을 직접 전달하는 것은 위험할 수 있습니다. 앞서 언급한 것처럼 double은 부동소수점 연산의 특성상 근사치로 표현되기 때문에, 이를 그대로 BigDecimal로 변환하면 의도하지 않은 값이 생성될 수 있습니다. 예를 들어,

BigDecimal bd = new BigDecimal(0.1);
System.out.println(bd);  // 출력 결과: 0.1000000000000000055511151231257827021181583404541015625

위 코드에서 0.1이라는 숫자를 BigDecimal로 변환하면, 의도와 다르게 매우 긴 소수점 이하의 숫자가 포함된 값이 생성됩니다. 이는 double의 정확하지 않은 표현이 그대로 전달되기 때문입니다.

반면, BigDecimal.valueOf(double) 메서드는 double 값을 정확하게 BigDecimal로 변환해줍니다. 이 메서드는 double의 부동소수점 표현 문제를 해결하기 위해 내부적으로 String 변환을 사용하여 값을 처리합니다. 따라서, new BigDecimal(double) 대신 BigDecimal.valueOf(double)를 사용하는 것이 좋습니다.

BigDecimal.valueOf(double)는 내부적으로 new BigDecimal(Double.toString(double))을 호출하여 double 값을 String으로 변환한 후 BigDecimal을 생성합니다. 또한, 이 메서드는 특정 범위의 값에 대해 미리 캐싱된 객체를 반환하여 성능을 최적화합니다(이 캐싱에 대해서는 뒤에서 자세히 설명할 예정입니다). 이로 인해 double 값이 포함된 BigDecimal 객체를 생성할 때, BigDecimal.valueOf(double)는 더 안정적이고 효율적인 방법입니다.

BigDecimal bd = BigDecimal.valueOf(0.1);
System.out.println(bd);  // 출력 결과: 0.1

위 코드에서는 BigDecimal.valueOf(0.1)을 사용하여, 예상한 대로 정확하게 0.1이라는 값을 얻을 수 있습니다.

Javadoc에서 위 내용을 확인할 수 있습니다.

new BigDecimal(double val)
double 값을 BigDecimal로 변환하여, 해당 double의 이진 부동소수점 값을 정확히 표현하는 BigDecimal을 생성합니다. 반환된 BigDecimal의 소수 자릿수(scale)는 (10^scale × val)이 정수가 되는 가장 작은 값입니다.
주의: 이 생성자의 결과는 다소 예측하기 어려울 수 있습니다. 예를 들어, Java에서 new BigDecimal(0.1)을 작성하면, BigDecimal이 정확히 0.1과 같을 것(확대되지 않은 값이 1이고 소수 자릿수가 1인 상태)이라고 가정할 수 있습니다. 하지만 실제로는 0.1000000000000000055511151231257827021181583404541015625와 같습니다. 이는 0.1이 double로 정확히 표현될 수 없기 때문입니다(또는, 어떤 유한한 길이의 이진 분수로도 정확히 표현될 수 없습니다). 따라서 생성자에 전달되는 값은 외견상 0.1과 동일하지 않습니다.
반면, String 생성자는 매우 예측 가능하게 동작합니다. new BigDecimal("0.1")를 작성하면, 기대한 대로 정확히 0.1과 같은 BigDecimal이 생성됩니다. 따라서, 이 생성자보다는 String 생성자를 사용하는 것이 일반적으로 권장됩니다.
double을 BigDecimal의 소스로 사용해야 하는 경우, 이 생성자는 double 값을 그대로 반영한 BigDecimal을 생성하며, double의 정확한 이진 부동소수점 값을 표현합니다. 그러나 이 변환은 double 값을 String으로 변환한 후 BigDecimal(String) 생성자를 사용하는 것과는 다른 결과를 가져옵니다. 만약 double의 값을 정확히 표현하는 BigDecimal을 얻고자 한다면, static valueOf(double) 메서드를 사용하는 것이 적절합니다.

BigDecimal.valueOf(double val)
double 값을 Double.toString(double) 메서드가 제공하는 double의 표준 문자열 표현을 사용하여 BigDecimal로 변환합니다.

BigDecimal의 캐싱 기능

BigDecimal.valueOf(long val) 메서드는 캐싱을 사용하여 자주 사용되는 값을 재활용합니다. 특히, 0에서 10 사이의 정수 값에 대해 캐싱이 이루어집니다. 이 범위 내의 값에 대해 BigDecimal.valueOf를 호출하면, 새로운 객체를 생성하는 대신, 이미 생성된 객체를 반환합니다. 이는 메모리 사용을 줄이고, 성능을 향상시키는 데 기여합니다.

예제 코드

import static org.junit.jupiter.api.Assertions.assertSame;

import java.math.BigDecimal;
import org.junit.jupiter.api.Test;

class BigDecimalTest {

  @Test
  void testBigDecimalCaching() {
    BigDecimal a = BigDecimal.valueOf(10);
    BigDecimal b = BigDecimal.valueOf(10);

    assertSame(a, b, "The two BigDecimals should be the same object");
  }
}

실행 결과

예제 코드 수행 결과

위 예제에서 BigDecimal.valueOf(10)을 두 번 호출했을 때, 두 객체가 같은 인스턴스를 참조하는 것을 확인할 수 있습니다. 이는 BigDecimal이 10에 대해 캐싱된 객체를 반환하기 때문입니다.

이처럼 0~10 사이의 정수가 캐싱되는 이유는 valueOf(long val) 메서드의 구현 내용에서 확인할 수 있습니다.

// Cache of common small BigDecimal values.
private static final BigDecimal ZERO_THROUGH_TEN[] = {
    new BigDecimal(BigInteger.ZERO,       0,  0, 1),
    new BigDecimal(BigInteger.ONE,        1,  0, 1),
    new BigDecimal(BigInteger.TWO,        2,  0, 1),
    new BigDecimal(BigInteger.valueOf(3), 3,  0, 1),
    new BigDecimal(BigInteger.valueOf(4), 4,  0, 1),
    new BigDecimal(BigInteger.valueOf(5), 5,  0, 1),
    new BigDecimal(BigInteger.valueOf(6), 6,  0, 1),
    new BigDecimal(BigInteger.valueOf(7), 7,  0, 1),
    new BigDecimal(BigInteger.valueOf(8), 8,  0, 1),
    new BigDecimal(BigInteger.valueOf(9), 9,  0, 1),
    new BigDecimal(BigInteger.TEN,        10, 0, 2),
};

// 이하 생략

public static BigDecimal valueOf(long val) {
  if (val >= 0 && val < ZERO_THROUGH_TEN.length)
    return ZERO_THROUGH_TEN[(int)val];
  else if (val != INFLATED)
    return new BigDecimal(null, val, 0, 0);
  return new BigDecimal(INFLATED_BIGINT, val, 0, 0);
}

이 배열은 0부터 10까지의 정수를 미리 생성하여 배열에 할당한 것으로, 이로 인해 valueOf(long val) 메서드가 해당 범위의 값을 요청받으면 새로운 객체를 생성하는 대신 미리 생성된 객체를 반환하게 됩니다.

참고로 캐싱은 BigDecimal.valueOf(long) 메서드에서만 제공됩니다. new BigDecimal(long)이나 new BigDecimal(double)을 사용하면 캐싱이 적용되지 않으며, 항상 새로운 객체가 생성됩니다. 따라서, 가능한 경우 BigDecimal.valueOf(long)을 사용하는 것이 좋습니다.

BigDecimal 사용 시 고려해야 할 단점

BigDecimal은 정밀한 계산을 위해 매우 유용하지만, 몇 가지 단점도 고려해야 합니다.

  • 성능 문제: BigDecimal은 숫자를 내부적으로 문자열로 처리하기 때문에, 기본 원시 타입(double, long 등)에 비해 연산 속도가 느릴 수 있습니다. 이는 특히 대규모 계산 작업에서 성능 저하로 이어질 수 있습니다.
  • 메모리 사용: BigDecimal은 원시 타입보다 더 많은 메모리를 사용합니다. 복잡한 계산이 많은 경우, 메모리 사용량이 증가할 수 있으며, 이는 시스템 자원에 부담을 줄 수 있습니다.
  • 복잡성 증가: BigDecimal은 다루기 복잡할 수 있으며, 개발자가 올바른 사용법을 숙지하지 않으면 의도하지 않은 결과를 초래할 수 있습니다. 특히, 반올림 모드나 소수 자릿수 처리에 주의를 기울여야 합니다.

추가 고려 사항

위와 같은 장단점과 도메인적인 특징 때문에 실무에선 아래와 같은 사항들을 추가로 고려해볼 수 있습니다.

  • 가장 작은 단위 사용: 통화 계산에서는 정확성을 높이기 위해 가장 작은 단위로 long형을 사용하여 금액을 저장하는 것이 좋습니다. 한국의 원(₩)은 더 작은 단위가 없기 때문에 원 단위로 처리하면 되고, 미국처럼 센트(¢)를 사용하는 경우, 달러($) 대신 센트로 금액을 저장해 소수점 처리를 피할 수 있습니다. 예를 들어, 1.25달러를 표현하려면 125센트를 long 타입으로 저장하여 소수점 연산으로 인한 오류를 방지할 수 있습니다.
  • 기본 연산 long 타입 사용: 더하기나 빼기 같은 간단한 연산은 long형을 사용하는 것이 성능 면에서 효율적입니다.
  • 결과는 정수 사용: 곱셈이나 나눗셈 결과는 반올림이나 반내림을 통해 정수로 처리하여 계산의 정확성을 유지해야 합니다. 세무 도메인에서는 10원 단위 절삭(절사), 소수점 한 자리에서 반올림, 절상 등을 사용합니다.
  • 52비트 범위 안에서 계산: 계산 결과는 항상 double의 52비트 범위 안에서 처리되도록 유의해야 합니다. 대부분의 금전적 계산은 이 범위 내에서 처리되지만, 계산 중에 값이 이 범위를 넘어가는 경우도 있을 수 있습니다. 이럴 때는 숫자를 문자열로 변환한 후, BigDecimal을 사용하여 더 정밀하게 계산하는 것이 좋습니다
  • 나눗셈/곱셈 시 MathContext 사용: BigDecimal로 나눗셈이나 곱셈을 할 때는 MathContext.DECIMAL64와 같은 옵션을 사용해 무한 소수점이 나오지 않도록 설정해야 합니다. 기본값은 MathContext.UNLIMITED인데, 무한 소수점이 발생하면 ArithmeticException이 발생할 수 있습니다.
import java.math.BigDecimal;

public class ArithmeticExceptionExample {
    public static void main(String[] args) {
        BigDecimal dividend = BigDecimal.ONE;
        BigDecimal divisor = BigDecimal.valueOf(3);

        try {
            // MathContext 없이 나눗셈을 시도 (무한 소수 발생)
            BigDecimal result = dividend.divide(divisor);
            System.out.println(result);
        } catch (ArithmeticException e) {
            System.out.println("ArithmeticException 발생: " + e.getMessage());
        }
    }
}

위 코드를 실행하게되면,

예제 코드 수행 결과

이렇게 에러가 발생하는 것을 확인할 수 있습니다.

MathContext.DECIMAL64는 국제 표준으로 유효 자릿수를 16자리로 제한하여 소수점이 무한히 이어지는 문제를 막을 수 있습니다.

BigDecimal result = dividend.divide(divisor, MathContext.DECIMAL64);

수정 후 수행 결과

결론

자비스앤빌런즈와 같은 택스테크 스타트업에서 BigDecimal의 활용은 필수적입니다. 부동소수점 오차로 인한 법적 리스크를 피하고, 고객에게 신뢰할 수 있는 세금 계산 서비스를 제공하기 위해, 코드 작성 시 항상 BigDecimal을 적절히 사용하는 것이 중요합니다. BigDecimal의 정확한 계산과 캐싱 기능을 활용하면, 금전적인 오차를 방지하고 성능을 최적화할 수 있습니다. 결국, 정확한 계산은 고객의 신뢰를 쌓는 데 중요한 역할을 하며, 이는 택스테크 솔루션의 핵심 가치라고 할 수 있습니다.

그리고 BigDecimal 사용시 고민할 필요 없이 숫자 타입(long, double)은 BigDecimal.valueOf로, 문자열 타입(char[], String)은 new BigDecimal로 생성해야 정확성을 유지할 수 있습니다.