컴퓨터에서는 데이터를 2진수로 표현합니다.
이는 정수도 마찬가지고 실수도 마찬가지입니다.
많은 프로그래밍 언어들은 2진수로 숫자를 표현하기 위해 고정소수점 방식과 부동소수점 방식을 채택합니다.
부동소수점은 고정소수점 방식보다 더 넓은 범위의 수를 표현할 수 있어 주로 채택됩니다.
하지만 두 방식 모두 정확한 값이 아닌 실수를 근사하여 표현하는 방법이므로, 연산이 더해질수록 정확도가 떨어지게 됩니다.
본 포스팅은 부동소수점이 뭔데? 고정소수점이 뭔데? 를 다루는게 핵심은 아니니 링크로 대체하겠습니다.
Java 에서는?
Java 에서는 실수 타입으로 float 과 double 을 제공합니다.
하지만 두 타입은 부동소숫점을 채택하므로 정확한 연산을 해 낼수 없습니다.
public class ActualNumberTest {
@Test
public void test() {
double a = 1.005;
double b = 0.009;
assertThat(a + b).isEqualTo(1.014);
}
}
위 테스트는 언뜻 보면 당연히 성공해야할 것으로 보입니다.
하지만 결과는?

실패합니다.
언뜻 생각하기에는 소숫점 아주 아래의 수기때문에 무시해도 되지않나?
해당 소숫점 아래만큼 10의 지수승이 곱해진다거나, 연산이 많아질수록 큰 차이가 나게됩니다.
그래서 어떻게 하라는거지?
Java 에서는 두 타입 외에 정확한 실수 계산을 위한 클래스를 제공합니다.
BigDecimal
BigDecimal 은 float, double 에 비해 속도는 느리지만, 실수에 대한 정확한 연산을 할 수 있어
돈계산과 같은 정확한 실수 연산이 필요할 때 사용합니다.
선언
BigDecimal a = new BigDecimal("1.005");
BigDecimal 은 일반적으로 문자열을 넘겨 생성합니다.
double 을 받는 생성자도 제공하지만 double 타입을 제공할 경우 BigDecimal 역시 정확한 결과를 얻을 수 없습니다.
비교
@Test
public void testEquals() {
BigDecimal a = new BigDecimal("1.005");
BigDecimal b = new BigDecimal("1.005");
assertThat(a.equals(b)).isTrue();
}
@Test
public void testCompare() {
BigDecimal a = new BigDecimal("1.005");
BigDecimal b = new BigDecimal("1.006");
assertThat(a.compareTo(b)).isEqualTo(-1);
assertThat(b.compareTo(a)).isEqualTo(1);
}
BigDecimal 은 equals and hashcode, comparable 이 구현되어 있기 때문에 숫자의 크기 비교가 가능합니다.
연산
@Test
public void testCalculate() {
BigDecimal a = new BigDecimal("1.2");
BigDecimal b = new BigDecimal("0.6");
assertThat(a.add(b)).isEqualTo(new BigDecimal("1.8"));
assertThat(a.subtract(b)).isEqualTo(new BigDecimal("0.6"));
assertThat(a.multiply(b)).isEqualTo(new BigDecimal("0.72"));
assertThat(a.divide(b)).isEqualTo(new BigDecimal("2"));
assertThat(a.remainder(b)).isEqualTo(new BigDecimal("0.0"));
}
기본적인 사칙연산과 나머지를 구하는 연산을 제공합니다.
또 divide 메서드는 여러 버전으로 오버라이딩 되어 옵션을 줄 수 있습니다.
Rounding Mode
@Test
public void testDivideMode() {
BigDecimal a = new BigDecimal("1.2");
BigDecimal b = new BigDecimal("0.84");
assertThat(a.divide(b, RoundingMode.CEILING)).isEqualTo(new BigDecimal("1.5"));
assertThat(a.divide(b, RoundingMode.FLOOR)).isEqualTo(new BigDecimal("1.4"));
assertThat(a.divide(b, RoundingMode.HALF_UP)).isEqualTo(new BigDecimal("1.4"));
}
Divide 의 경우 딱 나누어 떨어지지 않는 경우가 자주 발생합니다. 이럴 경우 결과를 어떻게 처리할지를 결정하는 것이 Rounding Mode d
입니다.
옵션은 다음과 같습니다.
- CEILING – 올림
- FLOOR – 내림
- UP – 양수일 경우 올림, 음수일 경우 내림
- DOWN – 양수일 경우 내림, 음수일 경우 올림
- HALF_UP – 반올림(5기준으로 반올림)
- HALF_EVEN – 반올림(반올림 자리의 값이 짝수면 HALF_DOWN, 홀수면 HALF_UP)
- HALF_DOWN – 반올림(6기준으로 반올림)
- UNNECESSARY – 나눗셈 결과가 딱 떨어지지 않으면, Exception 을 뱉음
소숫점 처리
@Test
public void testScale() {
BigDecimal a = new BigDecimal("1.2");
assertThat(a.setScale(1)).isEqualTo(new BigDecimal("1.2"));
assertThat(a.setScale(2)).isEqualTo(new BigDecimal("1.20"));
assertThat(a.setScale(3)).isEqualTo(new BigDecimal("1.200"));
}
setScale 메서드는 소수점 n 자리 이하를 자르거나, 붙일 수 있다.
divide 와 마찬가지로 잘리는 경우에 올림/내림 정책을 지정할 수 있습니다.
++
Kotlin 에서는 BigDecimal 에 대한 +, - , *, /, % 연산자를 지원합니다.
'/' 연산에 대해서만 조심해서 사용하면 됩니다. (기본 반올림 정책 = HALF_EVEN)
여담
다른 DB 는 모르겠지만 MySQL의 소수타입 double, float 역시 부동소숫점 방식을 채택합니다.
MySQL 의 경우에는 Decimal 타입을 이용해주어야 합니다. JPA 역시 BigDecimal 과 Decimal 타입을 매핑합니다.
현재 회사에서 돈 관련된 도메인을 많이 다루다보니 자세히 알아보게 되었습니다.
레거시를 많이 개선 했지만 데이터베이스의 타입을 바꾸기란 쉽지 않아
아직도 코어부분에 double 타입이 대부분을 이루고있어 오차가 발생하는 경우가 많이 있습니다.
기존의 레거시 코드가 그랬다는 것은 부동소숫점 방식을 데이터베이스와 언어가 선택한다는걸 모르는 개발자가 많다는 뜻으로 받아들여져서 좋은 내용을 함께 공유하고 정리하게 되었습니다.
틀린 내용이나 피드백은 언제나 환영입니다~
'Java' 카테고리의 다른 글
| DynamicProxy & CGLib (0) | 2022.02.20 |
|---|---|
| ThreadLocal - 쓰레드 내에서 변수를 공유하고 싶다면 (0) | 2022.02.17 |