시스템아 미안해
Chapter 2: Readability - Item 12: Use operators to increase readability 본문
Chapter 2: Readability - Item 12: Use operators to increase readability
if else 2026. 1. 25. 11:01이전 항목에서는 연산자 오버로딩의 오용에 대해 경고했습니다.
이번 장에서는 가독성을 개선하는 데 있어 연산자가 얼마나 유용한지를 보여드리고자 합니다.
먼저 명확한 예시부터 살펴보겠습니다.
연산자를 사용하면 BigDecimal과 BigInteger를 일반적인 숫자처럼 다룰 수 있습니다.
val netPrice = BigDecimal("10")
val tax = BigDecimal("0.23")
val currentBalance = BigDecimal("20")
val newBalance = currentBalance - netPrice + tax
println(newBalance) // 17.70
시간에 duration을 추가할 수도 있습니다:
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)
여기서 연산자를 사용하는 가치가 분명하게 느껴지기를 바랍니다.
Comparable을 구현한 모든 클래스는 비교 연산자(>, <, >=, <=)나 범위 검사(value in min..max)를 통해 비교할 수 있습니다.
여기에는 큰 수를 다루는 타입(BigDecimal, BigInteger)뿐만 아니라,
시간과 기간을 표현하는 데 사용되는 객체들(Instant, ZonedDateTime, LocalDate, Duration 등)도 포함됩니다.
이는 매우 중요한데, 우리는 이러한 타입들을 자주 다루며, 또 자주 비교해야 하기 때문입니다.
(금액을 표현할 때는 일부 숫자를 반올림하거나 정밀도를 잃을 수 있는 Double 대신 BigDecimal을 사용해야 한다는 점은 이미 잘 알고 계시리라 생각합니다.)
val now = LocalDateTime.now()
val start = LocalDate.parse("2021-10-17").atStartOfDay()
val end = LocalDate.parse("2021-10-21").atStartOfDay()
if(now > start) { /*...*/ }
if(now < end) { /*...*/ }
if(now in start..end) { /*...*/ }
val price = BigDecimal("100.00")
val minPrice = BigDecimal("10.00")
val maxPrice = BigDecimal("1000.00")
if(price > minPrice) { /*...*/ }
if(price < maxPrice) { /*...*/ }
if(price in minPrice..maxPrice) { /*...*/ }
아래 코드는 다음 메서드들을 사용하는 대안입니다:
if(now.isAfter(start)) { /*...*/ }
if(now.isBefore(end)) { /*...*/ }
if(!now.isBefore(start) && !now.isAfter(end)) { /*...*/ }
if(price.compareTo(minPrice) > 0) { /*...*/ }
if(price.compareTo(maxPrice) < 0) { /*...*/ }
if(minPrice.compareTo(price) <= 0 &&
price.compareTo(maxPrice) <= 0) { /*...*/ }
isAfter나 isBefore가 비교 연산자보다 더 읽기 쉬운 경우도 있을 수 있지만, 다른 많은 상황에서는 연산자가 훨씬 직관적이라는 점도 분명하다고 생각합니다.
또 하나 주목할 만한 점은 BigDecimal의 equals와 compareTo 함수 사이에 존재하는 불일치입니다.
equals 함수는 소수점 자릿수까지 비교하기 때문에, BigDecimal("1.0")과 BigDecimal("1.00")은 서로 같지 않다고 판단됩니다.
이는 두 숫자를 비교할 때 반드시 고려해야 할 사항입니다.
이러한 이유 중 하나로, 실제 프로젝트에서는 BigDecimal을 사용할 때 전체 프로젝트에 걸쳐 동일한 정밀도를 유지하는 경향이 있습니다.
반면 compareTo 함수는 정밀도를 고려하지 않기 때문에,
A >= B이면서 동시에 A <= B이지만 A != B인 상황이 발생할 수 있습니다.
이는 Item 44: compareTo의 계약을 존중하라에서 설명한 것처럼, compareTo의 계약이 깨지는 사례에 해당합니다.
val num1 = BigDecimal("1.0")
val num2 = BigDecimal("1.00")
println(num1 == num2) // false
println(num1 <= num2 && num1 >= num2) // true
연산자를 도입하는 마지막 전형적인 경우는 컬렉션이나 집합에 요소가 있는지 확인해야 할 때입니다. 전통적인 방법은 contains를 사용하는 것이지만, in 연산자를 사용할 수도 있습니다:
val SUPPORTED_TAGS = setOf("ADMIN", "TRAINER", "ATTENDEE")
val tag = "ATTENDEE"
println(SUPPORTED_TAGS.contains(tag)) // true
// or
println(tag in SUPPORTED_TAGS) // true
위의 접근 방식들을 비교해 보고, 어떤 쪽이 더 읽기 쉬운지 한 번 생각해 보시기 바랍니다.
저와 이 주제에 대해 논의했던 동료들의 의견으로는, in을 사용한다고 해서 항상 가독성이 좋아지는 것은 아니었습니다.
이는 무엇이 주된 요소(active element)인지에 따라 달라집니다.
이 예제에서는 tag가 중심이 되는 요소이기 때문에, 이를 앞에 두는 편이 코드 이해에 더 도움이 됩니다.
이는 “냉장고에 음료수가 있다”라는 표현이 “냉장고가 음료수를 포함하고 있다”보다 더 직관적으로 느껴지는 것과 같은 이치입니다.
반대로, 컬렉션이 더 중요한 경우도 있습니다.
예를 들어 “사람에게 간이 있다”라는 표현이 “간이 사람 안에 있다”보다 더 자연스럽게 느껴지는 것처럼 말입니다.
이와 동일한 맥락을 다음 코드에서도 확인할 수 있습니다.
val ADMIN_TAG = "ADMIN"
val admins = users.map { user.tags.contains(ADMIN_TAG) }
// or
val admins = users.map { ADMIN_TAG in user.tags }
이 경우에는 contains를 사용하는 편이 코드의 의미를 더 명확하게 만들어 준다고 생각합니다.
물론 이에 대해 다른 의견을 가진 분들도 있을 수 있으니, 이것을 절대적인 규칙으로 받아들이지는 말아 주시기 바랍니다(코드 리뷰에서 강요해서도 안 됩니다).
정말로 읽기 쉬운 코드를 작성하는 일은 하나의 예술에 가깝기 때문에, 모든 규칙은 어디까지나 제안 정도로 다루어져야 합니다.
연산자는 단위(unit of measure), 금액을 감싸는 래퍼, 기타 다양한 수치 타입 등과 같이 우리가 직접 정의한 클래스에도 추가할 수 있습니다.
@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)
// ...
}
이것들은 제가 Kotlin 연산자를 도입하는 가장 일반적인 경우들이지만, 표준 라이브러리에는 명백히 훨씬 더 많은 오버로드된 연산자들이 있습니다.