코드스쿼드 3 - BigDecimal

Alex·2024년 7월 1일
0

리팩토링

목록 보기
4/17

BigDecimal 이라고 자바에서 화폐단위를 나타낼때 주로 쓰는 타입이 있는데 Integer 보다 해당 타입을 쓰시는게 좋을거 같아요~
왜 BigDecimal 을 쓰는지는 한번 공부해보셔도 좋습니다! 생각보다 현업에서 화폐 같은걸 다루다보면 반올림으로 인한 문제를 많이 겪거든요.

비용을 정산할 때 Bigdecimal을 쓰려고 했는데 시간 관계상 사용하지 못했었다.

BigDecimal A to Z: 정확한 계산을 위한 숫자 처리 클래스
What data type to use for money in Java?


    public static void main(String[] args) {
        double a = 0.1;
        double b = 0.2;
        
        if(a+b == 0.3){
            System.out.println("계산 완료");
        }

        System.out.println("계산 실패");
    }
}

이렇게 했을 때 계산 실패가 콘솔에 찍힌다.

  public static void main(String[] args) {
        System.out.println ( .1f + .1f + .1f - .1f - .1f - .1f );
    }

이 코드는 0이 나와야 할 거 같지만 이런 결과가 나온다.

float과 double은 정확한 값이 아닌 근삿값을 담고 있다. 즉, 정확히 1.1이 아니라 1.121312423 이런 값이 들어가 있는 것이다. 이에 따라 정확한 계산이 일어나지 않는 것.

BigDecimal

BigDecimal은 아무리 큰 숫자라도 표현할 수 있는 임의 정밀도를 갖는다.

불변 객체라서 연산마다 새로운 객체르 만들어서 생성하지만 높은 정밀도로 인해서 금융과 관련된 곳에 주로 사용된다.

만약, double을 BigDecimal에 담아서 사용하면 근삿값이 담겨 버려서 계산을 정확하게 하지 못하게 된다.

BigDecimal a = new BigDecimal("7");
BigDecimal b = new BigDecimal("3");

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

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

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

// 나누기 - 기본적으로 정확한 몫을 반환함
// 2.33333...
// java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
a.divide(b);

// 나누기 - 소수점 아래 첫째 자리까지 반올림
// 2.3
a.divide(b, 1, RoundingMode.HALF_UP);

// 나누기 - 총 자릿수를 34개로 제한하고 반올림(HALF_EVEN)
// 2.333333333333333333333333333333333
a.divide(b, MathContext.DECIMAL128);

// 나누기 - 총 자릿수를 제한하지 않고 반올림
// 2.33333...
// java.lang.ArithmeticException: Non-terminating decimal expansion; no exact
a.divide(b, MathContext.UNLIMITED);

// 나머지(%)
// 1
a.remainder(b);

// 나머지(%) - 총 자릿수를 34개로 제한
// 1
a.remainder(b, MathContext.DECIMAL128);

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

// 최대값
// 7
a.max(b);

// 최소값
// 3
a.min(b);

// 부호 변환
// -7
a.negate();

BigDecimal a = new BigDecimal("3.14"); // unscaled value = 314, scale = 3 
BigDecimal b = new BigDecimal("3.140"); // unsclaed value = 314, scale =4

// 주솟값을 비교한다
// false    
a == b;

// unscaled value와 scale을 비교한다 (값과 소수점 자리까지 함께 비교)
// false
a.equals(b);

// unscaled value만 비교한다 (값만 비교)
// true
a.compareTo(b) == 0;
 
    // 0에서 멀어지는 방향으로 올림 
    // 양수인 경우엔 올림, 음수인 경우엔 내림
    UP(BigDecimal.ROUND_UP),
    
    // 0과 가까운 방향으로 내림
    // 양수인 경우엔 내림, 음수인 경우엔 올림
    DOWN(BigDecimal.ROUND_DOWN),
    
    // 양의 무한대를 향해서 올림 (올림)
    CEILING(BigDecimal.ROUND_CEILING),
    
    // 음의 무한대를 향해서 내림 (내림)
    FLOOR(BigDecimal.ROUND_FLOOR),
    
    // 반올림 (사사오입) 
    // 5 이상이면 올림, 5 미만이면 내림
    HALF_UP(BigDecimal.ROUND_HALF_UP),
    
    // 반올림 (오사육입) 
    // 6 이상이면 올림, 6 미만이면 내림
    HALF_DOWN(BigDecimal.ROUND_HALF_DOWN),

    // 반올림 (오사오입, Bankers Rounding)
    // 5 초과면 올리고 5 미만이면 내림, 5일 경우 앞자리 숫자가 짝수면 버리고 홀수면 올림하여 짝수로 만듦
    HALF_EVEN(BigDecimal.ROUND_HALF_EVEN),
    
    // 소수점 처리를 하지 않음
    // 연산의 결과가 소수라면 ArithmeticException이 발생함
    UNNECESSARY(BigDecimal.ROUND_UNNECESSARY);
    

기본 계산은 위처럼 하면 된다.

참고로 동등성을 비교할 때는 equals()는 값과 소수점 이하의 자릿수를 모두 비교하고 compareTo()는 값만 비교한다고 한다.

API 통신 시엔 별도의 deserializer 설정이 없다면 JSON 역직렬화 과정에서 의도치 않은 값이 할당될 수 있으므로 역직렬화된 객체의 BigDecimal 필드를 비교할 땐 더욱 주의해야 한다.

사실 대부분의 상황에선 값만을 비교할 뿐, 소수점 이하의 자릿수까지 체크해야 하는 경우는 거의 없으므로 헷갈린다면 compareTo() 사용이 좋다고 한다.

리팩토링


public class Charge {

    public static BigDecimal CHARGE_FOR_GUEST = new BigDecimal("0.142");
}


public class Reservation {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Version()
    private long version;
    @Column(nullable = false)
    private LocalDateTime registeredAt;
    @Column(nullable = false)
    private BigDecimal amount;
    @Column(nullable = false)
    private int personCount;
    @Column(nullable = false)
    private LocalDate checkIn;
    @Column(nullable = false)
    private LocalDate checkOut;
private BigDecimal calculateTotalAmount(LocalDate checkIn
            , LocalDate checkOut
            , Accommodation accommodation
            , Long userId
            , Long couponsToUse) {

        /*
         의도치 않는 중복 할인을 막기 위해서, 호스트가 설정한 할인 금액과 주 단위 4% 할인을 별도로 처리했음.

         ex) 숙박 요금(10만원)에서 호스트가 10% 할인을 설정함 --> 1만원 할인
             7일 숙박 기간에 따른 주 단위 할인(4%) --> 10만원 * 0.04 --> 4천원 할인

             총 결제 금액 = 10만원 -(1만원+4천원) --> 8만 6천원
         */

        int days = (int) ChronoUnit.DAYS.between(checkIn, checkOut);
        int weeks = days / 7;
        int remainingDays = days % 7;  BigDecimal accommodationPrice = new BigDecimal(accommodation.getPrice());
        BigDecimal couponDiscountAmount = BigDecimal.ZERO;
        BigDecimal totalAmount = BigDecimal.ZERO;

        BigDecimal discountRate = new BigDecimal("0.96");

        BigDecimal amountByDays = BigDecimal.ZERO;

        for (int i = 0; i < weeks; i++) {
            amountByDays = amountByDays.add(accommodationPrice.multiply(new BigDecimal(7)).multiply(discountRate));
        }

        totalAmount = totalAmount.add(amountByDays).add(accommodationPrice.multiply(new BigDecimal(remainingDays)));



        // 게스트는 air_dnb에 숙박 요금의 14.2%를 수수료로 낸다.

        BigDecimal chargeForPlatform = totalAmount.multiply(CHARGE_FOR_GUEST);

        if (accommodation.isOnSale()) {
            totalAmount = totalAmount.subtract(calculateDiscountAmount(accommodation));
        }

        if (couponsToUse != null) {
            couponDiscountAmount = calculateCouponAmount(userId, couponsToUse, totalAmount, accommodation.getAccommodationType());
        }

        // 호스트가 설정해 둔 서비스 비용에 대한 요금 추가


        List<ServiceCharge> serviceCharges = serviceChargeRepository.findByAccommodationId(accommodation.getId());
        if (!serviceCharges.isEmpty()) {
            totalAmount = totalAmount.add(plusServiceCharge(serviceCharges, days));
        }

        return totalAmount.add(chargeForPlatform).subtract(couponDiscountAmount);
    }

    private BigDecimal calculateCouponAmount(Long userId
            , Long couponToUse
            , BigDecimal amount
            , AccommodationType accommodationType) {

        List<UserCoupon> couponList = userCouponRepository.findByCouponId(couponToUse);

        UserCoupon coupon = couponList.get(0);

        validateCoupon(userId, amount, accommodationType, couponList, coupon);

        BigDecimal couponAmount = coupon.calculateCouponAmount(amount);

        /*
         쿠폰으로 할인 받는 금액과 쿠폰에서 설정한 최대 할인 가능 금액을 비교한다.
         쿠폰 할인 금액 > 쿠폰의 최대 할인 가능 금액인 경우, 최대 할인 가능 금액 만큼만 요금을 할인해 준다.
         */

        BigDecimal maximumDiscountAmount = coupon.getMaximumDiscountAmount();

        if (maximumDiscountAmount.compareTo(couponAmount)<0 ) {
            return maximumDiscountAmount;
        }

        return couponAmount;

    }

이거 말고도 코드 전반적으로 다 고쳤다....

Bigdecimal은 일반적인 연산이 안 돼서 거기에 맞춰서 다 고쳐줬는데... 처음부터 double이나 float을 쓰지 말고 Bigdecimal을 잘 써야겠다.

profile
답을 찾기 위해서 노력하는 사람

1개의 댓글

comment-user-thumbnail
2024년 7월 3일

잘보고 갑니다~

답글 달기