본문 바로가기
Java

돈(Money) 타입

by ybs 2023. 4. 29.
반응형

1. 돈(Money) 타입으로 BigDecimal 사용

주문서 프로젝트에서는 돈(Money) 과 관련된 필드를 BigDecimal 타입으로 통일시켰다. 실제론 정수값이라 해도 돈은 중요하니까 별도의 타입으로 표현하고자 하는 의도가 담겨 있다. 또한 예전에 라인 서비스와 연동할 때 환전과 관련된 스펙이 있었는데 기존에는 Long 타입으로 돈을 다루고 있다 보니 소수점 화폐를 처리하기 위한 수정 범위가 너무 커 진행하지 못한 히스토리도 있었다. 그래서 이후에라도 비슷한 요구가 왔을때를 대비해 BigDecimal 타입으로 하는게 좋겠다는 의견도 반영된거다.

 

BigDecimal 타입으로 금액을 비교하고 계산하기 위해서는 유틸리티 클래스를 만들어 사용하는게 좋다. 아래 코드는 0.0 과 0 을 비교하는  로직이다. equals 를 썼을때랑 compareTo 를 썼을 때랑 결과가 다르다. compareTo 결과가 올바른것인데 금액 비교를 할 때마다 -1, 0, 1 중 어떤건지 확인하는 작업은 코드를 이해하기 어렵게 만들기 때문이다.

BigDecimal money1 = BigDecimal.valueOf(0.0);
BigDecimal money2 = BigDecimal.valueOf(0);

System.out.println(money1.equals(money2)); // false
System.out.println(money1.compareTo(money2) == 0); // true

 

아래와 같이 BigDecialUtils 클래스를 만들었다. 그안에 BigDecimalWrapper 클래스가 있고 wrapper 에서 금액 비교 및 계산을 수행한다.

public class BigDecimalUtils {
	public static BigDecimalWrapper is(BigDecimal source) {
		return new BigDecimalWrapper(source);
	}

	public static class BigDecimalWrapper {
		private final BigDecimal source;

		private BigDecimalWrapper(BigDecimal source) {
			this.source = source;
		}

		public boolean equalTo(BigDecimal target) {
			return this.source.compareTo(target) == 0;
		}
        
		// ... 생략
	}
}

 

이제 금액비교를 할 땐 is 메서드를 통해 wrapper 로 감싸고, wrapper 에서 제공하는 메서드를 사용하면 된다.

BigDecimal money1 = BigDecimal.valueOf(0.0);
BigDecimal money2 = BigDecimal.valueOf(0);

BigDecimalUtils.is(money1).equalTo(money2); // true

 

금액이 특정 가격대안에 있는지 비교할 때도 BigDecialUtils 를 이용하면 편하다. between 메서드를 활용해 include(같은 가격도 허용), exclude(같은 가격은 허용안함) 네이밍으로 직관적으로 표현할 수 있다.

BigDecimal money3 = BigDecimal.valueOf(3000L);
BigDecimal money4 = BigDecimal.valueOf(3000L);
BigDecimal money5 = BigDecimal.valueOf(3000L);


BigDecimalUtils.is(money4).between(
	BigDecimalUtils.include(money3),
	BigDecimalUtils.exclude(money5)
);

 

구조는 아래와 같다. 내부에 IncludeOrExclude 인터페이스를 만들고 구현체로 Include, Exclude 를 만든다. between 메서드 인자로 T 타입 두개를 받는데, IncludeOrExclude 를 구현한 타입으로 제한시켰다. Include 타입에 해당되면 같은(=) 금액은 true 이고 Exclude 타입에 해당되면 flase 다.

public class BigDecimalUtils {
	public static class BigDecimalWrapper {
		private static final int ZERO = 0;
		private final BigDecimal source;

		private BigDecimalWrapper(BigDecimal source) {
			this.source = source;
		}

		public <T extends IncludeOrExclude> boolean between(T from, T to) {
			return (from instanceof Include
				? greaterThanOrEqualTo(from.getSource())
				: greaterThan(from.getSource()))
				&& (to instanceof Include
				? lessThanOrEqualTo(to.getSource())
				: lessThan(to.getSource()));
		}

		public boolean greaterThan(BigDecimal target) {
			return this.source.compareTo(target) > ZERO;
		}

		public boolean greaterThanOrEqualTo(BigDecimal target) {
			return this.source.compareTo(target) >= ZERO;
		}

		public boolean lessThan(BigDecimal target) {
			return this.source.compareTo(target) < ZERO;
		}

		public boolean lessThanOrEqualTo(BigDecimal target) {
			return this.source.compareTo(target) <= ZERO;
		}
	}

	interface IncludeOrExclude {
		BigDecimal getSource();

		final class Include implements IncludeOrExclude {
			private final BigDecimal source;

			private Include(BigDecimal source) {
				this.source = source;
			}

			@Override
			public BigDecimal getSource() {
				return source;
			}
		}

		final class Exclude implements IncludeOrExclude {
			private final BigDecimal source;

			private Exclude(BigDecimal source) {
				this.source = source;
			}

			@Override
			public BigDecimal getSource() {
				return source;
			}
		}
	}

	public static IncludeOrExclude include(BigDecimal source) {
		return new Include(source);
	}

	public static IncludeOrExclude exclude(BigDecimal source) {
		return new Exclude(source);
	}
}

 

다음으로 BigDecimal 타입의 금액이 정수인지 검사하고, 정수라면 소수점을 제거하는 로직을 살펴보자. 먼저 정수값인지 확인하는 로직이다. stripTrailingZeros 를 사용해 소수점 자리가 모두 0이면 짜른다. 그리고 소수점 자리값에 해당하는 scale 값(소수점 첫째자리부터 시작해서 오른쪽으로 가면서 0이 아닌값이 나올때까지의 수)이 0보다 작거나 같은지 비교해서 정수인지 판별한다.

	public static class BigDecimalWrapper {
		public boolean isIntegerValue() {
			return this.source.stripTrailingZeros().scale() <= 0;
		}
	}

 

소수점 뒤의 자리들이 다 0이면 정수라고 판단하기 때문에, 정수값일 때 소수점을 제거하는 로직도 필요하다(FE에 응답을 줄 때나 연동 서비스에 금액값을 제공할 때 필요).

BigDecimalUtils.is(BigDecimal.valueOf(12345)).isIntegerValue(); // true
BigDecimalUtils.is(BigDecimal.valueOf(12345.00)).isIntegerValue(); // true
BigDecimalUtils.is(BigDecimal.valueOf(12345.0)).isIntegerValue(); // true
BigDecimalUtils.is(BigDecimal.valueOf(0)).isIntegerValue(); // true
BigDecimalUtils.is(BigDecimal.valueOf(0.0)).isIntegerValue(); // true
BigDecimalUtils.is(BigDecimal.valueOf(12345.01)).isIntegerValue(); // false
BigDecimalUtils.is(BigDecimal.valueOf(0.01)).isIntegerValue(); // false

 

isIntegerValue 를 이용해 정수값인지 확인하고, 정수값이면 scale 을 0 그리고 RoundingMode.DOWN(내림) 으로 한다.

	public static class BigDecimalWrapper {
		public BigDecimal setScaleZeroIfInteger() {
			if (isIntegerValue()) {
				return setScaleZero();
			}
			return this.source;
		}

		public boolean isIntegerValue() {
			return this.source.stripTrailingZeros().scale() <= 0;
		}

		public BigDecimal setScaleZero() {
			return this.source.setScale(0, RoundingMode.DOWN);
		}
	}

 

2. 도메인 별 프로젝트마다 적합한 돈(Money) 타입이 다른 이슈

그런데 프로젝트마다 도메인 성격이 다르기 때문에 돈 타입을 BigDecimal 로 일관되게 통일시킬 순 없었다. 예를 들어 포인트/혜택 프로젝트에서는 0.XX 같은 포인트 단위가 있을 수 없다. 그리고 포인트/혜택 정보를 얻기위해 많은 외부 api 들이 요청오는데 BigDecimal 타입으로 주게 되면 소수점도 포함되서, 응답받는 서버에서 유효성검사가 실패하는 문제도 발생했다. 그래서 Long 타입으로 변환해서 응답을 줘야했기에 BigDecimal 타입으로 얻는 이득이 많지 않았다.

 

반대로 정산 프로젝트는 수수료 때문에 소수점 계산이나 금액 나누기가 많아서 BigDecimal 타입으로 한게 좋은 결정이었다. 물론 이슈도 있었다. 나누기할 때 / 연산자를 사용하면 안됐다. 아래코드에서 500 나누기 1000 하면 0.5가 나오길 기대했지만 실제 결과는 0이 나왔다. 

fun bigDecimalDivTest():BigDecimal {
    // 코틀린이라 / 연산가능 자바는 안됌
    return BigDecimal.valueOf(500) / BigDecimal.valueOf(1000) // 0
}

 

이유는 내부 로직에서 RoundingMode.HALF_EVEN 으로 설정됐기 때문이다. 즉 반올림 이슈다. 아래 예제를 보면 5.5는 6이 되는데 2.5 는 2가 된다. 의도치 않은 결과를 초래할 수 있으니 / 연산자를 사용하면 안된다.

@kotlin.internal.InlineOnly
public inline operator fun BigDecimal.div(
  other: BigDecimal
): BigDecimal = this.divide(other, RoundingMode.HALF_EVEN)

Example:
Rounding mode HALF_EVEN Examples
Input Number
Input rounded to one digit with HALF_EVEN rounding
5.5    6
2.5    2
1.6    2
1.1    1
1.0    1
-1.0    -1
-1.1    -1
-1.6    -2
-2.5    -2
-5.5    -6

 

3. 새로운 돈(Money) 타입을 만드는 방법

위에서 말했듯 돈 타입을 BigDecimal 로 통일할 순 없었다. 그러다보니 새로운 프로젝트에서는 돈 타입을 어떻게 할지 논의가 필요했다. 주문서와 일치시켜 BigDecimal 로할지, 아니면 DB 컬럼 타입과 근접한 타입으로 할지가 핵심 의제였다. 예를 들어 배송비 DB 컬럼 타입은 number(19,0) 으로 number(scale, precision) 소수점 아래 개수를 나타내는 precision 이 0 이므로 소수를 표현하지 않는다. 그래서 소수를 표현하는 BigDecimal 보단 Long 이 더 적합한 타입이라는 의견도 있었다.

 

결론은 BigInteger 와 BigDecimal 두개의 타입을 사용하고, 우리만의 돈 타입을 새롭게 만들어 wrapping 하기로 했다. 그 이름을 MoneyInteger 와 MoneyDecimal 로 지었다. MoneyDecimal 만 사용하지 않고 MoneyInteger 를 사용해 명확히 정수인 금액을 따로 관리했다.

 

먼저 MoneyInteger 부터 살펴보자. BigInteger 를 wrapping 했고, Comparable 인터페이스를 구현했기 때문에 compareTo 메서드를 따로 구현했다. MoneyInteger 의 compareTo 는 BigInteger 의 compareTo 로 위임시켰다.

@Value
public class MoneyInteger implements Comparable<MoneyInteger> {
	public static final MoneyInteger ZERO = new MoneyInteger(BigInteger.ZERO);
	BigInteger value;

	private MoneyInteger(BigInteger value) {
		this.value = value;
	}

	public static MoneyInteger valueOf(BigInteger value) {
		return new MoneyInteger(value);
	}

	public static MoneyInteger valueOf(long value) {
		return new MoneyInteger(BigInteger.valueOf(value));
	}

	public boolean equalTo(MoneyInteger money) {
		return this.equalTo(money.value);
	}

	public boolean equalTo(BigInteger value) {
		return this.value.compareTo(value) == 0;
	}

	@Override
	public int compareTo(@NotNull MoneyInteger obj) {
		return this.value.compareTo(obj.value);
	}
}

 

MoneyDecimal 도 다르지 않다.

@Value
public class MoneyDecimal implements Comparable<MoneyDecimal> {
	public static final MoneyDecimal ZERO = new MoneyDecimal(BigDecimal.ZERO);
	BigDecimal value;

	private MoneyDecimal(BigDecimal value) {
		this.value = value;
	}

	public static MoneyDecimal valueOf(BigDecimal value) {
		return new MoneyDecimal(value);
	}

	public static MoneyDecimal valueOf(long value) {
		return new MoneyDecimal(BigDecimal.valueOf(value));
	}

	@Override
	public int compareTo(@NotNull MoneyDecimal obj) {
		return this.value.compareTo(obj.value);
	}
}

 

그런데 이렇게 MoneyInteger 와 MoneyDecimal 를 사용하려면 한가지 더 작업해줘야 하는게 있다. 바로 ValueExtractor 작업이다. 아래 코드와 같이 MoneyInteger 에 붙인 @PositiveOrZero validation 애노테이션이 MoneyInteger 내부의 BigInteger 값으로 적용되게 ValueExtractor 를 재정의 해줘야 한다.

@PositiveOrZero
MoneyInteger money

 

@ExtractedValue 애노테이션은 MoneyInteger 클래스 내부 필드 중에서 BigInteger 타입의 값을 추출해야 함을 나타낸다. 재정의한 extractValues 메서드에서 추출한 BigInteger 값을 receiver 에게 전달한다. 첫번째 인자는 추출한 값(BigInteger) 이 속하는 그룹을 명시적으로 지정하는 용도인데 MoneyInteger 는 그룹화를 사용하지 않기 때문에 null 을 전달해도 문제가 없다. MoneyDecimal 은 네이밍만 다르고 똑같기 때문에 코드는 생략한다.

public class MoneyIntegerValueExtractor
	implements ValueExtractor<@ExtractedValue(type = java.math.BigInteger.class) MoneyInteger> {

	@Override
	public void extractValues(
		@ExtractedValue(type = BigInteger.class) MoneyInteger moneyInteger,
		ValueReceiver receiver
	) {
		receiver.value(null, moneyInteger.getValue());
	}
}

 

이렇게 MoneyIntegerValueExtractor 를 만들었으면 이제 resources/META-INF/services 디렉토리 안에 javax.validation.valueextraction.ValueExtractor 파일을 만들고 패키지 포함 MoneyIntegerValueExtractor 클래스이름을 넣어줘야 런타임 시에 Bean Validation API 가 구현체를 찾을 수 있다.

 

cf) 자바에서 제공하는 SPI 기능을 이용한건데, 이외에도 ValidatorFactory 에 직접 등록해주는 방법도 있고 자바9부터 지원하는 모듈 시스템을 이용하는 방법도 있다.

 

 

마지막으로 BigDecimal 을 생성할 때 주의할 점이 하나 있다. 소수점이 있으면 문자열로 취급을 해야 의도한 값이 나온다.

public class Main {
	public static void main(String[] args) {
		BigDecimal money1 = new BigDecimal(2.2);
		BigDecimal money2 = new BigDecimal("2.2");
		BigDecimal money3 = BigDecimal.valueOf(2.2);

		System.out.println(money1); // 2.20000000000000017763568394002504646778106689453125
		System.out.println(money2); // 2.2
		System.out.println(money3); // 2.2
	}
}

 

BigDecimal.valueOf 은 사용해도 되는데 내부적으로 toString 처리를 해주기 때문이다.

public static BigDecimal valueOf(double val) {
    // Reminder: a zero double returns '0.0', so we cannot fastpath
    // to use the constant ZERO.  This might be important enough to
    // justify a factory approach, a cache, or a few private
    // constants, later.
    return new BigDecimal(Double.toString(val));
}
반응형