✅ BigDecimal 타입을 사용하는 이유

devdo·2026년 1월 12일

DB

목록 보기
8/8
post-thumbnail

금융권 프로젝트나 정밀한 수치가 필요한 백엔드 개발에서 BigDecimal을 사용하는 이유는 한 마디로 "부동 소수점(Floating Point) 오차를 방지하여 정확한 계산을 하기 위함"입니다.

자바의 기본 타입인 doublefloat은 내부적으로 이진수(Binary) 방식으로 실수를 표현하는데, 이때 발생하는 한계점이 존재합니다.


1. 부동 소수점의 한계 (double/float의 문제)

컴퓨터는 모든 숫자를 0과 1로 표현합니다. 하지만 0.1 같은 숫자는 이진수로 변환하면 무한 소수가 됩니다. 메모리는 한정되어 있기 때문에 컴퓨터는 이 무한 소수를 중간에 끊어서 저장하는데, 여기서 미세한 오차가 발생합니다.

double a = 0.1;
double b = 0.2;
System.out.println(a + b); 
// 결과: 0.30000000000000004 (0.3이 아님!)

이런 아주 작은 오차도 돈을 다루는 서비스(결제, 환율, 이자 계산)에서는 심각한 금전적 손실이나 데이터 불일치로 이어질 수 있습니다.


2. BigDecimal을 사용하는 이유

정확한 십진수 연산

BigDecimal은 숫자를 이진수로 바꾸지 않고, 문자열 형태로 숫자를 보관하여 십진수 연산을 수행합니다.

사람이 계산하는 방식과 동일하게 동작하므로 오차가 전혀 없습니다!

소수점 처리의 유연성 (Scale & Rounding)

소수점 몇째 자리까지 유지할지, 반올림/내림/올림 처리를 어떻게 할지(Rounding Mode)를 매우 정밀하게 제어할 수 있습니다.

매우 큰 숫자 처리

double은 표현할 수 있는 숫자의 범위에 한계가 있지만, BigDecimal은 메모리가 허용하는 한 거의 무제한의 숫자를 다룰 수 있습니다.


3. 실무에서의 사용법 및 주의사항

반드시 '문자열' 생성자를 사용하세요

가장 흔히 하는 실수 중 하나가 double을 그대로 전달하는 것입니다.

// ❌ 잘못된 예: double의 오차까지 그대로 담김
BigDecimal val1 = new BigDecimal(0.1); 

// ✅ 올바른 예: 문자열로 전달하면 오차가 없음
BigDecimal val2 = new BigDecimal("0.1");
BigDecimal val3 = BigDecimal.valueOf(0.1); // 내부적으로 Double.toString() 사용

연산 방식

BigDecimal은 객체이므로 +, -, *, / 연산자를 사용할 수 없고 별도의 메서드를 사용해야 합니다.

연산메서드
더하기.add()
빼기.subtract()
곱하기.multiply()
나누기.divide()
비교.compareTo() (equals 대신 권장)

4. JPA/DB 매핑 시 고려사항

사용하시는 PostgreSQL이나 MySQL에서도 BigDecimal에 대응하는 타입이 있습니다.

  • DB 타입: DECIMAL(precision, scale) 또는 NUMERIC
  • JPA 매핑:
@Column(precision = 19, scale = 4)
private BigDecimal price;
  • precision: 전체 자릿수
  • scale: 소수점 이하 자릿수

요약

  • 사용 이유: double의 이진수 변환 오차를 피하고 정확한 값을 계산하기 위해.
  • 주요 분야: 금융, 정산(세금), 통계, 과학 계산.
  • 핵심 팁: 생성 시 반드시 문자열을 사용하고, 연산 시에는 메서드를 호출하세요.

예시(JPA)

결제나 정산 시스템에서는 단 1원의 오차도 허용되지 않기 때문에, 말씀하신 BigDecimal을 사용하는 것이 표준입니다. PostgreSQLNUMERIC 타입과 Spring Boot(JPA)를 연동한 실무적인 예시를 들어드리겠습니다.

1. DB 설계 (PostgreSQL)

PostgreSQL에서는 DECIMAL 또는 NUMERIC 타입을 사용합니다.

  • precision: 소수점을 포함한 전체 자릿수
  • scale: 소수점 이하 자릿수
-- 예: 전체 19자리 중 소수점은 4자리까지 허용 (금융권 표준 중 하나)
CREATE TABLE payment (
    id BIGSERIAL PRIMARY KEY,
    amount NUMERIC(19, 4) NOT NULL,
    tax_amount NUMERIC(19, 4) NOT NULL,
    total_amount NUMERIC(19, 4) NOT NULL
);

2. JPA 엔티티 구현

자바 엔티티에서는 BigDecimal을 사용하며, @Column 어노테이션으로 정밀도를 지정합니다.

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "payments")
public class Payment {

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

    // 결제 원금
    @Column(precision = 19, scale = 4, nullable = false)
    private BigDecimal amount;

    // 부가세 (예: 10%)
    @Column(precision = 19, scale = 4, nullable = false)
    private BigDecimal taxAmount;

    // 최종 합계
    @Column(precision = 19, scale = 4, nullable = false)
    private BigDecimal totalAmount;

    // 비즈니스 로직: 계산 시 BigDecimal 메서드 사용
    public void calculateTotal(BigDecimal taxRate) {
        // taxAmount = amount * taxRate
        this.taxAmount = this.amount.multiply(taxRate)
                                   .setScale(0, java.math.RoundingMode.HALF_UP); // 소수점 반올림 처리
        
        // totalAmount = amount + taxAmount
        this.totalAmount = this.amount.add(this.taxAmount);
    }
}

3. 정산 로직 예시 (Service 레이어)

실제 정산 시 여러 항목을 더하고 나누는 과정입니다.

@Service
public class SettlementService {

    public void processSettlement() {
        // 1. 값 생성 (반드시 문자열로 생성!)
        BigDecimal orderAmount = new BigDecimal("10000.55"); 
        BigDecimal feeRate = new BigDecimal("0.03"); // 수수료 3%

        // 2. 수수료 계산 (곱셈)
        BigDecimal fee = orderAmount.multiply(feeRate)
                                   .setScale(2, RoundingMode.HALF_UP); 

        // 3. 정산 금액 계산 (뺄셈)
        BigDecimal settlementAmount = orderAmount.subtract(fee);

        System.out.println("주문금액: " + orderAmount); // 10000.55
        System.out.println("수수료: " + fee);         // 300.02
        System.out.println("정산금액: " + settlementAmount); // 9700.53
    }
}

4. 실무 팁 (중요!)

  1. 나눗셈 주의 (ArithmeticException):
    10 / 3처럼 나누어떨어지지 않는 연산을 할 때 scale을 지정하지 않으면 에러가 발생합니다. 반드시 divide(divisor, scale, roundingMode) 형식을 사용하세요.
// ❌ 에러 발생 가능성 있음
a.divide(b); 

// ✅ 안전함 (소수점 2자리까지 구하고 반올림)
a.divide(b, 2, RoundingMode.HALF_UP); 
  1. 비교 연산:
    BigDecimalequals() 대신 compareTo()를 사용해야 합니다.
  • equals()는 값뿐만 아니라 소수점 자릿수(scale)까지 같아야 true를 반환합니다. (1.0 vs 1.00은 false)
  • compareTo()는 수치적 값만 비교합니다. (1.0 vs 1.00은 0으로 같음)
  1. 성능:
    모든 숫자를 BigDecimal로 하면 Long이나 Double보다 연산 속도는 느립니다. 하지만 정산/결제 데이터는 속도보다 정확성이 훨씬 중요하므로 BigDecimal 사용이 강제됩니다.

수수료와 환율이 복잡하게 얽히는 글로벌 정산 시스템에서는 계산 순서와 단수 처리(Rounding)가 매우 중요합니다. 특히 환율은 소수점 자릿수가 길기 때문에 이를 어떻게 처리하느냐에 따라 정산 금액이 달라질 수 있습니다.

실무에서 자주 사용하는 "해외 판매 대금 정산 로직"을 예시로 들어보겠습니다.


수수료율이나 환율 계산처럼 복잡한 로직 예시

1) 정산 로직 시나리오

  1. 판매 금액: $150.00 (USD)
  2. 플랫폼 수수료율: 12.5%
  3. 환율: 1,320.45 (KRW/USD)
  4. 계산 순서: * (1) 수수료 계산 (USD)
  • (2) 수수료 제외 정산금 계산 (USD)
  • (3) 정산금 원화 환산 (KRW)
  • (4) 원화 최종 정산금의 절사(Floor) 처리

2) 수수료 및 환율 계산 Service 예시

import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;

@Service
public class SettlementService {

    public void calculateGlobalSettlement() {
        // 1. 초기 데이터 설정 (반드시 String 생성자)
        BigDecimal salesAmountUsd = new BigDecimal("150.00");
        BigDecimal commissionRate = new BigDecimal("0.125"); // 12.5%
        BigDecimal exchangeRate = new BigDecimal("1320.45"); // 1 USD = 1,320.45 KRW

        // 2. 수수료 계산 (소수점 3자리에서 반올림하여 2자리 유지)
        BigDecimal commissionUsd = salesAmountUsd.multiply(commissionRate)
                .setScale(2, RoundingMode.HALF_UP);
        // 결과: 18.7500 -> 18.75 USD

        // 3. 정산 금액 계산 (USD)
        BigDecimal settlementUsd = salesAmountUsd.subtract(commissionUsd);
        // 결과: 131.25 USD

        // 4. 원화 환산 (환율 적용)
        // 외화 계산 시에는 오차를 줄이기 위해 먼저 곱한 후 마지막에 통화 단위에 맞춰 절사합니다.
        BigDecimal settlementKrwRaw = settlementUsd.multiply(exchangeRate);
        // 결과: 131.25 * 1320.45 = 173309.0625

        // 5. 최종 원화 정산금 (소수점 절사 및 원 단위 이하 절사)
        // 한국 원화(KRW)는 보통 소수점이 없고, 정책에 따라 1원 혹은 10원 단위로 절사합니다.
        BigDecimal finalSettlementKrw = settlementKrwRaw.setScale(0, RoundingMode.FLOOR);
        // 결과: 173309 KRW

        System.out.println("최종 정산 금액: " + finalSettlementKrw + "원");
    }
}

3) 복잡한 로직 설계 시 핵심 포인트

① 소수점 정밀도 (Scale) 정책

중간 계산 과정에서의 scale은 최종 결과물보다 2~3자리 더 길게 가져가는 것이 좋습니다.

  • 예: 최종 결과가 소수점 2자리라면, 중간 수수료 계산은 4~5자리까지 유지한 뒤 마지막에 반올림해야 오차가 누적되지 않습니다.

② 환율 고시 시점 고정

환율은 초 단위로 변하기 때문에, 정산 시점의 환율을 반드시 별도의 환율 이력 테이블이나 결제 마스터 테이블에 기록해 두어야 합니다.

// 정산 시점에 사용된 환율을 엔티티에 저장하여 추적성 확보
payment.applyExchangeRate(currentRate); 

③ 나누기 연산의 무한 소수 처리

환율 역산(KRW -> USD) 등을 할 때는 반드시 scaleRoundingMode를 지정해야 ArithmeticException을 방지할 수 있습니다.

// 환율 역산 시 소수점 6자리까지 정밀하게 계산
BigDecimal usdAmount = krwAmount.divide(exchangeRate, 6, RoundingMode.HALF_UP);

4) PostgreSQL JPA 매핑 (환율/정산용)

환율은 일반 금액보다 정밀도가 높아야 하므로 scale을 6 이상으로 잡는 경우가 많습니다.

@Entity
public class GlobalSettlement {

    @Column(precision = 19, scale = 4)
    private BigDecimal amountUsd; // 판매금액

    @Column(precision = 19, scale = 6)
    private BigDecimal exchangeRate; // 환율 (소수점 6자리)

    @Column(precision = 19, scale = 0)
    private BigDecimal settlementKrw; // 최종 정산금 (원화는 소수점 없음)
}

이런 정산 로직은 보통 배치 작업(Spring Batch)으로 처리하는 경우가 많은데, 혹시 수만 건 이상의 데이터를 한 번에 정산해야 하는 상황인가요? 그렇다면 대량 연산 시의 성능 최적화나 Querydsl에서의 BigDecimal 처리 방법도 함께 고민해볼 수 있습니다.

대량의 정산 데이터를 처리할 때는 서버 메모리로 모든 데이터를 가져와서 계산하는 것보다, DB 레벨에서 연산(Querydsl)하거나 배치 프로세스(Spring Batch)를 활용하는 것이 성능 핵심입니다.


1) Querydsl에서의 BigDecimal 연산

Querydsl을 사용하면 DB의 강력한 산술 연산 기능을 활용할 수 있습니다. 특히 MathExpressionsNumberTemplate을 사용하면 복잡한 수수료 계산을 DB에서 바로 수행하여 결과값만 받아올 수 있습니다.

[기본 산술 연산]

// 정산 금액 = (판매금액 - 수수료) * 환율
NumberPath<BigDecimal> sales = payment.salesAmount;
NumberPath<BigDecimal> fee = payment.commission;
NumberPath<BigDecimal> rate = payment.exchangeRate;

List<BigDecimal> results = queryFactory
    .select(sales.subtract(fee).multiply(rate))
    .from(payment)
    .fetch();

[반올림 및 절사 (Template 활용)]

PostgreSQL의 ROUNDTRUNC 함수를 써야 할 때는 NumberTemplate을 사용합니다.

// 소수점 제거 및 절사 (PostgreSQL의 TRUNC 함수 호출)
NumberExpression<BigDecimal> truncatedAmount = Expressions.numberTemplate(BigDecimal.class,
    "TRUNC({0}, 0)", 
    sales.subtract(fee).multiply(rate)
);

List<BigDecimal> finalAmounts = queryFactory
    .select(truncatedAmount)
    .from(payment)
    .where(payment.status.eq(SettlementStatus.READY))
    .fetch();

2) 대량 연산 성능 최적화 전략

수만 건 이상의 정산을 처리할 때 발생할 수 있는 성능 병목을 해결하는 세 가지 전략입니다.

① 페이징 처리를 이용한 Chunk 지향 프로세싱

모든 데이터를 한 번에 List로 읽으면 OutOfMemoryError가 발생합니다. Spring Batch의 JdbcPagingItemReader나 JPA의 ScrollableResults를 사용하여 데이터를 쪼개서 처리해야 합니다.

② Bulk Update (일괄 업데이트)

건당 save()를 호출하면 매번 UPDATE 쿼리가 날아가 성능이 매우 떨어집니다. 계산이 끝난 데이터는 Querydsl의 update 문으로 한 번에 반영하세요.

// 특정 조건의 모든 데이터를 한 번에 정산 완료 상태로 변경
long count = queryFactory
    .update(payment)
    .set(payment.status, SettlementStatus.COMPLETED)
    .set(payment.settledAt, LocalDateTime.now())
    .where(payment.id.in(processedIds))
    .execute();

③ DB 함수 및 인덱스 활용

복잡한 연산이 쿼리 필터링(where 절)에 들어가는 경우, DB에 함수 기반 인덱스를 생성하거나 계산된 컬럼을 미리 보관하는 것이 좋습니다.


3) 실무형 구현 예시: 수수료 일괄 계산 및 업데이트

public void massSettlementUpdate(List<Long> paymentIds) {
    QPayment payment = QPayment.payment;

    // 1. 대량 연산 시에는 필요한 컬럼만 추출하는 것이 성능상 유리합니다.
    List<SettlementDto> targets = queryFactory
        .select(Projections.constructor(SettlementDto.class,
            payment.id,
            payment.salesAmount,
            payment.commissionRate,
            payment.exchangeRate
        ))
        .from(payment)
        .where(payment.id.in(paymentIds))
        .fetch();

    // 2. 비즈니스 로직 연산 (병렬 스트림 활용 가능)
    targets.parallelStream().forEach(dto -> {
        BigDecimal result = dto.calculate(); // 위에서 배운 BigDecimal 연산 로직
        
        // 3. 개별 업데이트 대신 리스트에 모았다가 한꺼번에 반영하거나
        // JdbcTemplate.batchUpdate()를 사용하여 성능 극대화
    });
}

4) PostgreSQL 특화 팁: NUMERIC 성능

PostgreSQL에서 NUMERIC은 매우 정확하지만, 정수형(BIGINT) 연산보다 느립니다.

  • 만약 소수점이 필요 없는 원화(KRW)만 다룬다면, DB에는 BIGINT로 저장하고 자바에서 Long으로 처리하는 것이 성능상 가장 빠릅니다.
  • 하지만 환율이 개입되는 순간 오차 방지를 위해 NUMERICBigDecimal 조합은 필수입니다.
profile
자바 스프링 백엔드 개발자입니다. 배운 것을 기록합니다.

0개의 댓글