티스토리 뷰

SW 개발/Java

Java, BigDecimal 사용법 정리

지단로보트 2019.02.12 22:09

BigDecimal?

  • BigDecimalJava 언어에서 숫자를 정밀하게 저장하고 표현할 수 있는 유일한 방법이다.
  • 소수점을 가지는 double 타입은 소수점의 정밀도에 있어 한계를 가지고 이로 인해 값이 유실될 수 있다.
  • Java 언어에서 돈과 소수점을 다룬다면 BigDecimal은 선택이 아니라 필수이다.
  • BigDecimal의 유일한 단점은 느린 속도와 기본 타입보다 조금 불편한 사용법 뿐이다.

double, 무엇이 문제인가?

  • 소수점 이하의 수를 다룰 때 double 타입은 사칙연산시 아래와 같이 우리가 기대한 값과 다른 값을 출력한다. 이유는 double 타입이 내부적으로 수를 저장할 때 이진수의 근사치를 저장하기 때문이다. 저장된 수를 다시 십진수로 표현하면서 아래와 같은 문제가 발생한다. 아래 설명할 BigDecimal 타입은 내부적으로 수를 십진수로 저장하여 아주 작은 수과 큰 수의 연산에 대해 거의 무한한 정밀도를 보장한다. [관련 링크1] [관련 링크2]
double a = 10.0000;
double b = 3.0000;

// 기대값: 13
// 실제값: 13.000001999999999
a + b;

// 기대값: 7
// 실제값: 6.999999999999999
a - b;

// 기대값: 30
// 실제값: 30.000013000000997
a * b;

// 기대값: 3.33333...
// 실제값: 3.333332555555814
a / b;

BigDecimal 기본 용어

  • precision: 숫자를 구성하는 전체 자리수라고 생각하면 편하나, 정확하게 풀이하면 왼쪽부터 0이 아닌 수가 시작하는 기준부터 오른쪽 끝까지의 자리수이다. unscale과 동의어이다. (ex: 012345.67890의 precision은 11이 아닌 10이다.)
  • scale: 숫자를 구성하는 소수점 자리수를 의미한다. fraction과 동의어이다. (ex: 012345.67890의 scale은 5이다.) BigDecimal32bit의 소수점 크기를 가진다.

BigDecimal 기본 상수

// 흔히 쓰이는 값은 상수로 정의
// 0
BigDecimal.ZERO

// 1
BigDecimal.ONE

// 10
BigDecimal.TEN

BigDecimal 초기화

  • double 타입으로 부터 BigDecimal 타입을 초기화하는 방법으로 가장 안전한 것은 문자열의 형태로 생성자에 전달하여 초기화하는 것이다. double 타입의 값을 그대로 전달할 경우 앞서 사칙연산 결과에서 본 것과 같이 이진수의 근사치를 가지게 되어 예상과 다른 값을 얻을 수 있다. [관련 링크]
// double 타입을 그대로 초기화하면 기대값과 다른 값을 가진다.
// 0.01000000000000000020816681711721685132943093776702880859375
new BigDecimal(0.01);

// 문자열로 초기화하면 정상 인식
// 0.01
new BigDecimal("0.01");

// 위와 동일한 결과, double#toString을 이용하여 문자열로 초기화
// 0.01
BigDecimal.valueOf(0.01);

BigDecimal 비교 연산

BigDecimal a = new BigDecimal("2.01");
BigDecimal b = new BigDecimal("2.010");

// 객체의 레퍼런스 주소에 대한 비교 연산자로 무의식적으로 값의 비교를 위해 사용하면 오동작
// false
a == b;

// 값의 비교를 위해 사용, 소수점 맨 끝의 0까지 완전히 값이 동일해야 true 반환
// false
a.equals(b);

// 값의 비교를 위해 사용, 소수점 맨 끝의 0을 무시하고 값이 동일하면 0, 적으면 -1, 많으면 1을 반환
// 0
a.compareTo(b);

BigDecimal 사칙 연산

  • Java에서 BigDecimal 타입의 사칙 연산 방법은 아래와 같다. 보다시피 double 타입보다 장황하고 귀찮은 편이다. (아래 설명할 Kotlin에서는 double 타입을 사용하는 것처럼 매우 간결한 문법을 제공한다.)
BigDecimal a = new BigDecimal("10");
BigDecimal b = new BigDecimal("3");

// 더하기
// 13
a.add(b);

// 빼기
// 7
a.subtract(b);

// 곱하기
// 30
a.multiply(b);

// 나누기
// 3.333333...
// java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
a.divide(b);

// 나누기
// 3.333
a.divide(b, 3, RoundingMode.HALF_EVEN);

// 절대값
// 3
new BigDecimal("-3").abs();

// 두 수 중 최대값
// 10
a.max(b);

BigDecimal 소수점 처리

  • RoundingMode.HALF_EVENJava의 기본 반올림 정책으로 금융권에서 사용하는 Bankers Rounding와 동일한 알고리즘이다. 금융권에서는 시스템 개발시 혼란을 막기 위해 요구사항에 반올림 정책을 명확히 명시하여 개발한다.
// 소수점 이하를 절삭한다.
// 1
new BigDecimal("1.1234567890").setScale(0, RoundingMode.FLOOR);

// 소수점 이하를 절삭하고 1을 증가시킨다.
// 2
new BigDecimal("1.1234567890").setScale(0, RoundingMode.CEILING);
// 음수에서는 소수점 이하만 절삭한다.
// -1
new BigDecimal("-1.1234567890").setScale(0, RoundingMode.CEILING);

// 소수점 자리수에서 오른쪽의 0 부분을 제거한 값을 반환한다.
// 0.9999
new BigDecimal("0.99990").stripTrailingZeros();

// 소수점 자리수를 재정의한다.
// 원래 소수점 자리수보다 작은 자리수의 소수점을 설정하면 예외가 발생한다.
// java.lang.ArithmeticException: Rounding necessary
new BigDecimal("0.1234").setScale(3);

// 반올림 정책을 명시하면 예외가 발생하지 않는다.
// 0.123
new BigDecimal("0.1234").setScale(3, RoundingMode.HALF_EVEN);

// 소수점을 남기지 않고 반올림한다.
// 0
new BigDecimal("0.1234").setScale(0, RoundingMode.HALF_EVEN);
// 1
new BigDecimal("0.9876").setScale(0, RoundingMode.HALF_EVEN);

BigDecimal 나누기 처리

BigDecimal b10 = new BigDecimal("10");
BigDecimal b3 = new BigDecimal("3");

// 나누기 결과가 무한으로 떨어지면 예외 발생
// java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
b10.divide(b3);

// 반올림 정책을 명시하면 예외가 발생하지 않음
// 3
b10.divide(b3, RoundingMode.HALF_EVEN);

// 반올림 자리값을 명시
// 3.333333
b10.divide(b3, 6, RoundingMode.HALF_EVEN);

// 3.333333333
b10.divide(b3, 9, RoundingMode.HALF_EVEN);

// 전체 자리수를 7개로 제한하고 HALF_EVEN 반올림을 적용한다.
// 3.333333
b10.divide(b3, MathContext.DECIMAL32);

// 전체 자리수를 16개로 제한하고 HALF_EVEN 반올림을 적용한다.
// 3.333333333333333
b10.divide(b3, MathContext.DECIMAL64);

// 전체 자리수를 34개로 제한하고 HALF_EVEN 반올림을 적용한다.
// 3.333333333333333333333333333333333
b10.divide(b3, MathContext.DECIMAL128);

// 전체 자리수를 제한하지 않는다.
// java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result. 예외가 발생한다.
b10.divide(b3, MathContext.UNLIMITED);

Kotlin에서의 BigDecimal

  • Kotlin에서의 BigDecimal 연산은 놀라울 정도로 간단하다. 두 객체 간의 메써드 연산자 대신 +, -, *, /, %, .. 연산자를 사용하면 된다. 나누기 연산자에 대해서만 유의하면 된다.
val a: BigDecimal = BigDecimal(10)
val b: BigDecimal = BigDecimal(3)

// equalsTo()와 동일하게 소수점 마지막의 0까지 비교
// false
println(b == c)

// compareTo()는 소수점 마지막의 0을 무시
// true
println(b.compareTo(c) == 0)

// 이 경우 == 기호는 equalsTo()가 아닌 compareTo()로 작동
// true
println(b <= c)

// 이 경우 == 기호는 equalsTo()가 아닌 compareTo()로 작동
// true
println(b >= c)

// add()와 동일
// 13.0
println(a + b)

// subtract()와 동일
// 7.0
println(a - b)

// multiply()와 동일
// 30.00
println(a * b)

// divide(other, RoundingMode.HALF_EVEN)와 동일
// 3.3
println(a / b)

// 34자리 정밀도로 나누기 처리
// 3.333333333333333333333333333333333
println(a.divide(b, MathContext.DECIMAL128))

// 연산자 오버로드 선언
// 프로젝트 내에서 동일 연산자 메써드에 대해 1번만 선언 가능
operator fun BigDecimal.div(other: BigDecimal): BigDecimal = this.divide(other, 6, RoundingMode.HALF_EVEN)

// 연산자 오버로드 선언 후 결과 확인
// 3.333333
println(a / b)
  • 위를 응용하면 특정 연산자 오버로드가 선언된 클래스를 아래와 같이 별도로 분리해두는 것이 가독성과 관리 측면에서 좋다.
package com.jsonobjet.example.bigdecimal

import java.math.BigDecimal
import java.math.RoundingMode

operator fun BigDecimal.div(other: BigDecimal): BigDecimal = this.divide(other, BigDecimalUtils.SCALE_SIX, BigDecimalUtils.BANKERS_ROUNDING_MODE)

class BigDecimalUtils {

    companion object {

        const val SCALE_SIX = 6
        val BANKERS_ROUNDING_MODE = RoundingMode.HALF_EVEN
    }
}
  • 주의할 점은 연산자를 사용하는 클래스 내에서 반드시 오버로드가 선언된 클래스를 import해야 오버로드가 정상적으로 적용된다.
import com.jsonobjet.example.bigdecimal.div

BigDecimal 문자열 변환 출력

  • .setScale()을 사용하여 소수점 자리수를 제한하면 원본의 소수점 값은 상실해 버린다. 문자열로 출력하는 것이 목적이라면 NumberFormat 클래스를 사용하는 것이 적합하다.
NumberFormat format = NumberFormat.getInstance();
format.setMaximumFractionDigits(6);
format.setRoundingMode(RoundingMode.HALF_EVEN);
// 0.123457
format.format(new BigDecimal("0.1234567890"));

JPA에서의 BigDecimal 처리

  • JDBC에서 MySQL/MariaDBDECIMAL 타입은 ResultSet 인터페이스의 getBigDecimal(), getString() 2개 메써드로 획득이 가능하다. [관련 링크] JPA 또한 별도의 작업 없이 엔티티 필드에 BigDecimal 타입을 사용하여 처리하면 된다.
  • 만약, 데이터베이스 저장시 소수점 이하 자리수와 반올림 방법을 자동으로 처리되게 하고 싶다면 JPA가 제공하는 커스텀 컨버터를 제작하면 된다. 커스텀 컨버터 작성 예는 아래와 같다. Kotlin으로 작성하였다.
// 소수점 이하 6자리에서 Bankers Rounding을 적용하여 BigDecimal 값을 생성한 후 데이터베이스에 저장하는 역할의 컨버터
class BigDecimalScale6WithBankersRoundingConverter : AttributeConverter<BigDecimal, String> {

    companion object {

        const val SCALE_SIX = 6
        val BANKERS_ROUNDING_MODE = RoundingMode.HALF_EVEN
    }

    override fun convertToDatabaseColumn(attribute: BigDecimal?): String? {

        return when (attribute?.scale()) {
            null -> null
            SCALE_SIX -> attribute.toString()
            else -> attribute.setScale(SCALE_SIX, BANKERS_ROUNDING_MODE).toString()
        }
    }

    override fun convertToEntityAttribute(dbData: String?): BigDecimal? {

        return when (dbData) {
            null -> null
            else -> BigDecimal(dbData)
        }
    }
}
  • 작성한 커스텀 컨버터를 엔티티에 적용하는 예는 아래와 같다.
// DECIMAL(21,6) 타입에 대한 맵핑 상세 정의
// 숫자 범위는 -999999999999999.999999 ~ +999999999999999.999999
@Column(name = "foo", nullable = false, precision = 21, scale = 6)
@Digits(integer = 15, fraction = 6)
@Convert(converter = BigDecimalScale6WithBankersRoundingConverter::class)
var foo: BigDecimal? = BigDecimal.ZERO

Spring Data MongoDB에서의 BigDecimal 처리

  • Spring Data MongoDB는 기본적으로 BigDecimal 타입의 필드를 String 타입으로 저장하고 읽어들인다. 문제는 String 타입의 필드는 도큐먼트 간의 정렬 및 연산시 의도하지 않은 결과를 초래할 수 있다. [관련 링크] 이러한 문제를 예방하려면 String이 아닌 Decimal128(2016-11-29 출시된 MongoDB 3.4부터 지원함에 유의) 타입으로 저장하도록 컨버터를 제작해야 한다. MongoDB의 경우 JPA와는 다르게 개별 필드 레벨로는 컨버터 생성이 불가능하고 별도의 통합된 org.springframework.data.mongodb.core.convert.CustomConversions 커스텀 빈을 작성해야 한다.
  • 첫번째 방법은 아래와 같이 개별 컨버터를 따로 만드는 것이다. 저장할 때와 불러올 때의 컨버터를 각각 따로 만들어야 한다.
package com.jsonobject.example;

import java.math.BigDecimal;
import org.bson.types.Decimal128;
import org.springframework.core.convert.converter.Converter;

public class Decimal128ToBigDecimalConverter implements Converter<Decimal128, BigDecimal> {

    @Override
    public BigDecimal convert(Decimal128 source) {

        return source == null ? null : source.bigDecimalValue();
    }
}
package com.jsonobject.example;

import java.math.BigDecimal;
import org.bson.types.Decimal128;
import org.springframework.core.convert.converter.Converter;

public class Decimal128ToBigDecimalConverter implements Converter<BigDecimal, Decimal128> {

    @Override
    public Decimal128 convert(BigDecimal source) {

        return source == null ? null : new Decimal128(source);
    }
}
  • 앞서 제작한 컨버트를 적용하여 컨버터 빈을 생성하면 첫번째 방법은 적용이 완료된다.
package com.jsonobject.example;

import java.util.Arrays;
import com.jsonobject.example.BigDecimalToDecimal128Converter;
import com.jsonobject.example.Decimal128ToBigDecimalConverter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.MongoDbFactory;
import org.springframework.data.mongodb.core.convert.CustomConversions;
import org.springframework.data.mongodb.core.convert.DbRefResolver;
import org.springframework.data.mongodb.core.convert.DefaultDbRefResolver;
import org.springframework.data.mongodb.core.convert.DefaultMongoTypeMapper;
import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;

@Configuration
public class MongoConfig {

    @Autowired
    private MongoDbFactory mongoFactory;

    @Autowired
    private MongoMappingContext mongoMappingContext;

    @Bean
    public MappingMongoConverter mongoConverter() {

        DbRefResolver dbRefResolver = new DefaultDbRefResolver(mongoFactory);
        MappingMongoConverter mongoConverter = new MappingMongoConverter(dbRefResolver, mongoMappingContext);
        mongoConverter.setTypeMapper(new DefaultMongoTypeMapper(null));
        mongoConverter.setCustomConversions(new CustomConversions(Arrays.asList(
            new BigDecimalToDecimal128Converter(),
            new Decimal128ToBigDecimalConverter()
        )));

        return mongoConverter;
    }
}
  • 두번째 방법은 훨씬 간단하다. CustomConversions 빈만 생성하면 된다. 개별 컨버터를 제작할 필요도 없다.
package com.jsonobject.example;

import java.math.BigDecimal;
import java.util.Arrays;
import org.bson.types.Decimal128;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.mongodb.core.convert.CustomConversions;

@Configuration
public class MongoConfig {

    @Bean
    CustomConversions customConversions() {

        Converter<Decimal128, BigDecimal> decimal128ToBigDecimal = new Converter<Decimal128, BigDecimal>() {
            @Override
            public BigDecimal convert(Decimal128 source) {

                return source == null ? null : source.bigDecimalValue();
            }
        };

        Converter<BigDecimal, Decimal128> bigDecimalToDecimal128 = new Converter<BigDecimal, Decimal128>() {
            @Override
            public Decimal128 convert(BigDecimal source) {

                return source == null ? null : new Decimal128(source);
            }
        };

        return new CustomConversions(Arrays.asList(decimal128ToBigDecimal, bigDecimalToDecimal128));
    }
}

BigDecimal과 Java Stream

// POJO 목록에서 BigDecimal 타입을 가진 특정 필드의 합을 반환
BigDecimal sumOfFoo = fooList.stream()
    .map(FooEntity::getFooBigDecimal)
    .filter(foo -> Objects.nonNull(foo))
    .reduce(BigDecimal.ZERO, BigDecimal::add);

// 특정 BigDecimal 필드를 기준으로 오름차순 정렬된 리스트를 반환
foolist.stream()
    .sorted(Comparator.comparing(it -> it.getAmount()))
    .collect(Collectors.toList());

// 위와 동일한 기능, 정렬된 새로운 리스트를 반환하지 않고 원본 리스트를 바로 정렬
foolist.sort(Comparator.comparing(it -> it.getAmount()));

BigDecimal 타입의 JSON 문자열 변환

  • JSON 스펙에서는 BigDecimal 타입의 표현 방법에 대해 명확히 규정하고 있지 않다. 그래서 API 응답을 표현할 때 혹시 모를 소수점 이하에서의 데이터 유실을 예방하기 위해 BigDecimal을 숫자가 아닌 문자열로 응답하는 경우도 있다. 아래는 Jackson 라이브러리를 사용하여 POJO-JSON 변환시 소수점 이하 6자리에서 Bankers Rounding을 적용하여 응답하는 커스텀 JsonSerializer를 제작한 예이다.
class BigDecimalScale6WithBankersRoundingSerializer : JsonSerializer<BigDecimal>() {

    companion object {

        const val SCALE_SIX = 6
        val BANKERS_ROUNDING_MODE = RoundingMode.HALF_EVEN
    }

    override fun serialize(value: BigDecimal?, gen: JsonGenerator?, serializers: SerializerProvider?) {

        gen?.writeString(value?.setScale(SCALE_SIX, BANKERS_ROUNDING_MODE).toString())
    }
}
  • 앞서 제작한 JsonSerializer를 아래와 같이 POJO에 명시하면 의도한대로 JSON 응답 처리를 할 수 있다.
@JsonSerialize(using = BigDecimalScale6WithBankersRoundingSerializer::class)
var fooDecimal: BigDecimal? = BigDecimal.ZERO,

BigDecimal과 통화

  • BigDecimal은 돈을 다루는데 있어 가장 확실하고 안전한 타입이다. 하지만, 여러 국가의 통화를 표현하기에는 부족함이 있다. 이러한 통화를 다루기 위한 Java 표준으로 JSR 354가 존재한다. 그리고 이를 구현한 구현체 라이브러리로 Moneta가 존재한다. 여러 국가의 통화를 직접 처리하는 것보다 이러한 정식 구현체를 적용하는 것이 효율적이다. [라이브러리 링크]
  • 아래는 build.gradle에 해당 라이브러리의 종속성을 추가하는 방법이다.
compile group: 'org.javamoney', name: 'moneta', version: '1.3', ext: 'pom' // JavaMoney
compile group: 'org.javamoney.lib', name: 'javamoney-lib', version: '1.0', ext: 'pom' // JavaMoney
compile group: 'org.javamoney.lib', name: 'javamoney-calc', version: '1.0' // JavaMoney Caculations

참고 글

댓글
댓글쓰기 폼