시스템아 미안해

Chapter 2: Readability - Item 10: Design for readability 본문

책/Effective Kotlin

Chapter 2: Readability - Item 10: Design for readability

if else 2026. 1. 23. 10:50

프로그래밍에서는 개발자가 코드를 작성하는 시간보다 읽는 시간이 훨씬 더 많다는 점이 잘 알려져 있습니다.
일반적으로는 코드를 1분 작성할 때마다 약 10분을 코드를 읽는 데 사용한다고 추정합니다¹⁸.
이 말이 믿기지 않으신다면, 오류를 찾기 위해 코드를 읽는 데 얼마나 많은 시간을 쓰는지 떠올려 보시면 됩니다.
개발자라면 누구나 경력 중 한 번쯤은 며칠 또는 몇 주 동안 오류를 찾다가,

결국 단 한 줄을 수정하는 것으로 문제를 해결했던 경험이 있을 것입니다.

 

새로운 API를 사용하는 방법 역시 대부분 코드를 읽으면서 익히게 됩니다.
우리는 코드의 로직을 이해하거나 구현이 어떻게 동작하는지를 파악하기 위해 코드를 읽습니다.
프로그래밍은 대부분 ‘작성’이 아니라 ‘읽기’의 작업입니다.
따라서 코드를 작성할 때는 반드시 가독성을 염두에 두어야 합니다.

인지 부하 줄이기 (Reducing cognitive load)

가독성은 모든 사람에게 다른 의미를 갖습니다. 하지만, 경험을 기반으로 형성되거나 인지 과학 연구에서 나온 몇 가지 규칙들이 있습니다. 다음 두 구현을 비교해보세요:

 

Implementation A

if (person != null && person.isAdult) {
    view.showPerson(person)
} else {
    view.showError()
}

 

Implementation B

person?.takeIf { it.isAdult }
    ?.let(view::showPerson)
    ?: view.showError()

A와 B 중 어느 쪽이 더 나을까요?
단순히 줄 수가 더 적은 쪽이 더 낫다고 판단하는 것은 좋은 답이 아닙니다. 첫 번째 구현에서 줄바꿈을 제거할 수도 있겠지만, 그렇다고 해서 가독성이 더 좋아지지는 않습니다.
문자 수를 세는 것 역시 그다지 유용하지 않습니다. 특히 그 차이가 크지 않기 때문입니다. 첫 번째 구현은 79자이고, 두 번째 구현은 68자입니다. 두 번째 구현이 약간 더 짧기는 하지만, 가독성은 훨씬 떨어집니다.

 

두 구현이 얼마나 읽기 쉬운지는 우리가 각각을 얼마나 빠르게 이해할 수 있는지에 달려 있습니다. 그리고 이는 우리의 뇌가 각 관용구(구조, 함수, 패턴)를 얼마나 잘 학습했는지에 크게 좌우됩니다.
Kotlin 초보자라면 분명 구현 A가 훨씬 더 읽기 쉬울 것입니다. 구현 A는 일반적인 관용구(if/else, &&, 메서드 호출)를 사용합니다. 반면 구현 B는 Kotlin 특유의 관용구들(safe call ?., takeIf, let, Elvis 연산자 ?:, 바인딩된 함수 참조 view::showPerson)을 사용합니다.
물론 이러한 관용구들은 Kotlin 전반에서 흔히 사용되므로, 숙련된 Kotlin 개발자라면 대부분 잘 알고 있을 것입니다. 그럼에도 불구하고 두 구현을 비교하는 일은 쉽지 않습니다.

 

Kotlin은 대부분의 개발자에게 첫 번째 언어가 아닙니다. 우리는 Kotlin 프로그래밍보다 일반적인 프로그래밍에 대한 경험이 훨씬 많습니다. 또한 우리는 숙련된 개발자만을 위해 코드를 작성하지 않습니다. 몇 달간 시니어 개발자를 찾지 못한 끝에 채용한 주니어 개발자가 let, takeIf, 바인딩된 참조가 무엇인지 모를 가능성도 큽니다.
더 나아가, Elvis 연산자가 이런 방식으로 사용된 것을 한 번도 본 적이 없을 수도 있습니다. 그런 사람은 이 코드 블록 하나를 이해하는 데 하루 종일을 보낼지도 모릅니다.

 

게다가 숙련된 Kotlin 개발자에게도 Kotlin은 유일하게 사용하는 언어가 아닙니다. 코드를 읽는 많은 개발자들은 Kotlin 경험이 있더라도, 일반적인 프로그래밍 경험이 훨씬 더 많을 것입니다.
뇌는 언제나 Kotlin에 특화된 관용구를 인식하는 데 일반적인 프로그래밍 관용구보다 더 많은 시간을 필요로 합니다. Kotlin을 수년간 사용해 왔음에도 불구하고, 저는 여전히 구현 A를 이해하는 데 훨씬 적은 시간이 듭니다.
덜 알려진 관용구 하나하나는 작은 복잡성을 추가합니다. 그리고 우리가 거의 동시에 이해해야 하는 하나의 문장 안에 이런 요소들이 모두 들어가면, 그 복잡성은 빠르게 커집니다.

 

또한 구현 A는 수정하기도 더 쉽습니다. 예를 들어 if 블록 안에 연산을 하나 추가해야 한다고 가정해 보겠습니다. 구현 A에서는 이는 매우 간단합니다. 그러나 구현 B에서는 더 이상 함수 참조를 사용할 수 없게 됩니다.
구현 B에서 “else” 쪽에 무언가를 추가하는 일은 더 어렵습니다. Elvis 연산자의 오른쪽에 하나 이상의 표현식을 담으려면, 이를 감싸기 위한 어떤 함수가 필요해지기 때문입니다.

 
kotlin
if (person != null && person.isAdult) {
    view.showPerson(person)
    view.hideProgressWithSuccess()
} else {
    view.showError()
    view.hideProgress()
}
 
 
kotlin
person?.takeIf { it.isAdult }
    ?.let {
        view.showPerson(it)
        view.hideProgressWithSuccess()
    } ?: run {
        view.showError()
        view.hideProgress()
    }

 

구현 A는 디버깅 역시 훨씬 더 단순합니다. 이는 전혀 놀랄 일이 아닌데, 디버깅 도구들은 이러한 기본적인 구조들을 기준으로 만들어졌기 때문입니다.
일반적으로 덜 사용되는 “창의적인” 구조일수록 유연성이 떨어지고, 도구의 지원도 충분하지 않은 경우가 많습니다.

 

예를 들어, person 변수가 null인 경우와 성인이 아닌 경우에 서로 다른 오류를 표시하기 위해 세 번째 분기를 추가해야 한다고 가정해 보겠습니다. if/else를 사용하는 구현 A에서는 IntelliJ의 리팩터링 기능을 통해 if/else를 when으로 쉽게 변경한 뒤, 추가 분기를 간단히 넣을 수 있습니다.
반면 구현 B에서 동일한 변경을 시도하는 것은 상당히 고통스러울 것이며, 아마도 코드를 거의 전면적으로 다시 작성해야 할 것입니다.

 

그런데 구현 A와 B가 실제로 동일하게 동작하지 않는다는 점을 눈치채셨나요? 차이점을 발견하실 수 있으신가요?

잠시 돌아가서 다시 한 번 생각해 보시기 바랍니다.

 

그 차이는 let이 람다 표현식의 결과를 반환한다는 사실에 있습니다.

즉, showPerson이 null을 반환하면 두 번째 구현에서는 showError까지 호출되게 됩니다.
이는 결코 직관적인 동작이 아니며, 덜 익숙한 구조를 사용할 경우 예상치 못한 코드 동작에 쉽게 빠질 수 있다는 점을 잘 보여줍니다.

 

여기서의 일반적인 원칙은 인지 부하를 줄이는 것입니다.

우리의 뇌는 패턴을 인식하고, 이를 바탕으로 프로그램이 어떻게 동작하는지에 대한 이해를 구축합니다.
가독성을 고려할 때 우리는 이 이해에 도달하기까지의 거리를 줄이고자 합니다.
코드의 양은 적기를 바라지만, 동시에 더 보편적인 구조를 선호합니다.
우리는 충분히 자주 접해 온 익숙한 패턴을 더 쉽게 인식하며, 다른 분야에서도 친숙한 구조를 항상 더 선호하게 됩니다.

극단적으로 받아들이지 마십시오. (Do not get extreme)

앞선 예제에서 let이 잘못 사용될 수 있음을 보여드렸다고 해서, 이것이 항상 피해야 할 대상이라는 의미는 아닙니다.
let은 다양한 상황에서 코드를 더 나아지게 만드는 데 합리적으로 사용되는, 널리 쓰이는 관용구입니다.

 

흔한 예로, nullable한 가변 프로퍼티가 있고, 해당 값이 null이 아닐 때만 어떤 연산을 수행해야 하는 경우가 있습니다.
가변 프로퍼티는 다른 스레드에 의해 변경될 수 있기 때문에 스마트 캐스팅을 사용할 수 없지만,

이런 상황을 처리하는 매우 좋은 방법이 바로 safe call과 let을 함께 사용하는 것입니다.

 
 
kotlin
class Person(val name: String)
var person: Person? = null

fun printName() {
    person?.let {
        print(it.name)
    }
}

 

이러한 관용구는 널리 사용되며, 많은 개발자에게 익숙합니다.
또한 let을 합리적으로 사용할 수 있는 경우는 이 외에도 다양합니다. 예를 들면 다음과 같습니다.

 

  • 인자를 계산한 이후에 연산을 수행하도록 구조를 옮길 때
  • 객체를 데코레이터로 감싸는 용도로 사용할 때

 

다음은 위 두 가지 사용 사례에 대한 예시입니다(두 예제 모두 함수 참조를 함께 사용합니다).

 
kotlin
students
    .filter { it.result >= 50 }
    .joinToString(separator = "\n") {
        "${it.name} ${it.surname}, ${it.result}"
    }
    .let(::print)

var obj = FileInputStream("/file.gz")
    .let(::BufferedInputStream)
    .let(::ZipInputStream)
    .let(::ObjectInputStream)
    .readObject() as SomeObject

이 두 경우 모두에서 우리는 대가를 치르게 됩니다.
이 코드는 디버깅이 더 어렵고, Kotlin 경험이 적은 개발자에게는 이해하기도 더 어렵습니다.
그러나 공짜로 얻을 수 있는 것은 없으며, 이 정도의 대가는 충분히 감수할 만하다고 볼 수 있습니다.

 

문제는 특별한 이유도 없이 많은 복잡성을 도입하는 경우입니다.
무엇이 타당하고 무엇이 그렇지 않은지에 대한 논의는 언제나 존재할 수밖에 없습니다.
그 균형을 맞추는 것은 하나의 예술입니다.

 

다만 서로 다른 구조들이 어떤 방식으로 복잡성을 증가시키는지, 또는 어떻게 코드를 더 명확하게 만드는지를 인식하는 것은 매우 중요합니다.
특히 이러한 구조들이 함께 사용될 때는 더욱 그렇습니다.
여러 구조를 함께 사용할 때의 복잡성은 보통 각 구조가 개별적으로 가지는 복잡성의 단순한 합보다 훨씬 더 커집니다.

 

관습 (Conventions)

가독성이 무엇을 의미하는지에 대해서는 사람마다 서로 다른 관점을 가지고 있다는 점을 우리는 이미 인정했습니다.
우리는 함수 이름을 두고 끊임없이 논쟁하고, 무엇을 명시적으로 표현해야 하는지 혹은 암묵적으로 두어야 하는지, 어떤 관용구를 사용해야 하는지 등 수많은 주제에 대해 토론합니다.
프로그래밍은 표현의 예술이기 때문입니다.

 

그럼에도 불구하고, 반드시 이해하고 기억해야 할 몇 가지 규칙과 관례는 존재합니다.
제가 샌프란시스코에서 진행했던 워크숍 중 한 그룹이 Kotlin에서 할 수 있는 최악의 행동이 무엇이냐고 물었을 때, 저는 다음과 같은 답을 주었습니다.

 
 
kotlin
val abc = "A" { "B" } and "C"
print(abc) // ABC

이 끔찍한 문법을 가능하게 하는 데 필요한 것은 다음 코드뿐입니다:

 
 
kotlin
operator fun String.invoke(f: ()->String): String =
    this + f()

infix fun String.and(s: String) = this + s

이 코드는 이후에 설명할 여러 규칙을 위반하고 있습니다.

  • 연산자의 의미를 위반합니다. invoke는 이런 방식으로 사용되어서는 안 되며, String은 호출될 수 있는 대상이 아니기 때문입니다.
  • 여기서 사용된 ‘람다를 마지막 인자로 전달하는’ 관례는 혼란을 줍니다. 함수 뒤에 사용하는 것은 괜찮지만, invoke 연산자에 적용할 때는 특히 주의해야 합니다.
  • 이 중위(infix) 메서드의 이름으로 and는 명백히 좋지 않습니다. append나 plus가 훨씬 더 적절할 것입니다.
  • 문자열 연결을 위해 이미 언어 차원의 기능이 제공되고 있으므로, 굳이 바퀴를 다시 발명하듯 새로운 방식을 만들 필요가 없습니다.

이러한 각각의 지침 뒤에는 Kotlin 스타일을 올바르게 유지하기 위한 보다 일반적인 원칙이 숨어 있습니다.
이 장에서는 그중 가장 중요한 규칙들을 다룰 것이며, 그 시작으로 연산자 오버라이딩에 초점을 맞춘 첫 번째 항목부터 살펴보겠습니다.