한 줄 정의

금액·시간·범위처럼 연산 의미가 자명한 타입에서는 메서드 호출 대신 연산자를 써서 코드를 수식처럼 읽히게 해야 합니다.

쉽게 말하면

계산서에 “잔액 = 20 − 10 × 0.23”이라고 적지, “20에서 10과 0.23을 곱한 값을 뺀다”라고 풀어 쓰지 않습니다.

숫자를 다룰 때는 기호가 문장보다 빨리 읽히고, 코틀린 연산자는 BigDecimal 이나 Duration 같은 객체에도 이 기호를 쓸 수 있게 해 줍니다.

왜 중요한가?

자바에서 BigDecimal 계산은 a.subtract(b.multiply(c)) 처럼 괄호가 겹겹이 쌓여 연산 순서를 눈으로 따라가기 어렵고, compareTo(...) > 0 은 부등호 방향을 매번 머릿속에서 번역해야 합니다.

금액 계산과 기간 비교는 백엔드 비즈니스 로직의 중심이라, 이 부분이 수식처럼 읽히느냐가 곧 버그를 리뷰에서 잡을 수 있느냐로 이어집니다.

핵심 내용

산술 연산: 숫자처럼 계산하기

BigDecimal · BigInteger 와 시간 타입은 연산자를 그대로 쓸 수 있습니다.

val netPrice = BigDecimal("10")
val tax = BigDecimal("0.23")
val currentBalance = BigDecimal("20")
val newBalance = currentBalance - netPrice * tax
println(newBalance)  // 17.70
 
val now = ZonedDateTime.now()
val duration = Duration.ofDays(1)
val sameTimeTomorrow = now + duration

명시적인 메서드로 쓰면 다음과 같이 연산 순서가 괄호 중첩으로 바뀝니다.

val newBalance = currentBalance.minus(netPrice.times(tax))
val sameTimeTomorrow = now.plus(duration)

금액에는 BigDecimal

Double 은 일부 숫자를 반올림해 정밀도를 잃으므로 액수를 표현할 때는 BigDecimal 을 씁니다.

비교 · 범위: Comparable 이면 모두 가능

모든 Comparable 클래스는 비교 연산자(> < >= <=)와 범위 확인(value in min..max)을 쓸 수 있습니다.

연산자 버전메서드 버전
if (now > start)if (now.isAfter(start))
if (now in start..end)if (!now.isBefore(start) && !now.isAfter(end))
if (price > minPrice)if (price.compareTo(minPrice) > 0)
if (price in minPrice..maxPrice)if (minPrice.compareTo(price) <= 0 && price.compareTo(maxPrice) <= 0)

isAfter · isBefore 는 단독 비교에서는 연산자보다 읽기 쉬울 수 있지만, 범위 확인으로 가면 이중 부정이 섞여 연산자가 확실히 낫습니다.

compareTo 쪽은 어느 경우든 연산자가 압도적으로 읽기 쉽습니다.

BigDecimal 의 == 와 compareTo 는 다릅니다

equals(==)는 소수 자릿수(scale)까지 비교하고 compareTo 는 값만 비교합니다. 그래서 1.0 과 1.00 은 >= 이고 <= 인데도 == 는 아닙니다.

val num1 = BigDecimal("1.0")
val num2 = BigDecimal("1.00")
println(num1 == num2)                  // false
println(num1 >= num2 && num1 <= num2)  // true

엄밀히는 compareTo 규약 위반이고, 프로젝트 전체에서 같은 정밀도로 BigDecimal 을 써야 하는 이유이기도 합니다.

포함 여부: in vs contains

컬렉션 포함 여부는 contains 대신 in 으로도 쓸 수 있는데, 항상 in 이 낫지는 않습니다. 기준은 문장의 주어로 두고 싶은 쪽이 무엇인가 입니다.

강조 대상자연스러운 형태영어 문장 비유
요소tag in SUPPORTED_TAGS”There’s a soda in the fridge”
컬렉션user.tags.contains(ADMIN_TAG)”A human has a liver”
println(tag in SUPPORTED_TAGS)  // tag가 관심사 → in
 
val admins = users.map { user -> user.tags.contains(ADMIN_TAG) }  // user가 관심사 → contains
val admins = users.map { user -> ADMIN_TAG in user.tags }

두 번째 예는 contains 가 더 명확해 보이지만 사람마다 다르게 느낄 수 있으므로, 코드 리뷰에서 강제할 규칙이 아니라 제안 정도로 받아들이는 것이 좋습니다.

자체 클래스에 연산자 추가

측정 단위, 금액 같은 자체 타입에도 연산자를 정의할 수 있습니다.

@JvmInline
value class Centimeter(private val value: Double) {
    operator fun plus(other: Centimeter): Centimeter =
        Centimeter(value + other.value)
 
    operator fun plus(other: Millimeter): Centimeter =
        Centimeter(value + other.value * 10)
    // ...
}

plus 가 실제로 “더하기”를 뜻하므로 연산자 의미와 함수 이름이 일치하는 좋은 사례입니다.

내 생각

  • BigDecimal 동등 비교는 compareTo 로 통일합니다. DB에서 DECIMAL(10,2) 로 읽은 100.00 과 코드의 BigDecimal("100") 을 == 로 비교하면 false 가 나오므로, 금액 비교는 a.compareTo(b) == 0 이나 setScale 로 정규화한 뒤 비교하는 편이 안전합니다.
  • 기간 검증은 in start..end 가 가장 읽기 좋습니다. 쿠폰 유효기간, 이벤트 기간 체크처럼 경계 포함 여부가 중요한 로직에서 !isBefore && !isAfter 조합은 경계 버그를 숨기기 쉽고, .. 는 양끝 포함이라는 의미가 한눈에 보입니다.
  • 금액은 Money 값 객체 + 연산자로 감싸는 것이 한 단계 더 좋습니다. 통화·scale 정책을 plus 안에 가둬 두면 위의 equals 함정도 도메인 경계에서 한 번에 막을 수 있습니다.

관련 개념