한 줄 정의

가독성은 코드가 짧은지가 아니라 읽는 사람의 뇌가 패턴을 얼마나 빨리 알아보느냐로 결정되므로, 기발한 코틀린 관용구 조합보다 누구나 아는 일반적인 구조를 골라 인지 부하를 줄여야 합니다.

쉽게 말하면

고속도로 표지판은 시속 100km로 스쳐 지나가면서도 읽힙니다. 운전자가 표지판을 해독하는 것이 아니라, 수없이 본 모양을 알아보는 것이기 때문입니다.

디자이너가 더 작고 세련되게 새로 고안한 표지판은 정보량이 같아도 속도를 줄여 뜯어봐야 하고, 초행길 운전자라면 아예 지나쳐 버립니다. 나중에 안내 문구 하나를 더 넣으려 해도 표준 표지판은 칸만 추가하면 되지만, 기발한 표지판은 처음부터 다시 그려야 합니다.

코드도 같습니다. 모든 개발자가 알아보는 if/else 가 표준 표지판이고, 코틀린 고유 관용구를 한 줄에 엮은 체인은 세련되지만 해독이 필요한 표지판입니다.

왜 중요한가?

개발자는 코드를 작성하는 것보다 읽는 데 훨씬 많은 시간을 씁니다. 일반적인 추정으로 작성에 1분을 쓰면 읽는 데 10분을 쓰고, 오류 하나를 찾느라 며칠씩 코드를 읽은 끝에 한 줄만 고쳐 해결한 경험은 누구나 있습니다. 새 API를 배울 때도, 로직이나 구현의 동작을 이해할 때도 결국 코드를 읽습니다.

프로그래밍이 주로 읽기라면 가독성은 취향이 아니라 비용의 문제입니다. 익숙하지 않은 구조는 읽는 시간을 늘릴 뿐 아니라 예상과 다르게 동작하는 코드를 쓰기 쉽게 만들고, 수정과 디버깅까지 어렵게 만듭니다.

핵심 내용

인지 부하의 감소

가독성의 기준은 사람마다 다르지만, 경험과 인지 과학으로 정립된 규약은 있습니다. 같은 일을 하는 두 구현을 비교해 봅니다.

// 구현 A
if (person != null && person.isAdult) {
    view.showPerson(person)
} else {
    view.showError()
}
 
// 구현 B
person?.takeIf { it.isAdult }
    ?.let(view::showPerson)
    ?: view.showError()

줄이 적은 쪽이 낫다는 순진한 논리는 답이 아닙니다. A의 줄 바꿈을 지워도 가독성이 오르지 않고, 문자 수도 79자 대 68자로 차이가 크지 않습니다. B가 약간 짧지만 읽기는 훨씬 어렵습니다.

누가 읽는가

얼마나 읽기 쉬운지는 읽는 사람의 뇌가 각 관용구(구조, 함수, 패턴)에 얼마나 훈련되어 있는지에 달려 있습니다. A는 if/else, &&, 메서드 호출처럼 어느 언어에나 있는 관용구만 쓰지만, B는 안전 호출 ?., takeIf, let, 엘비스 연산자 ?:, 제한된 함수 참조(bounded function reference) view::showPerson 까지 코틀린 고유 관용구를 다섯 개나 씁니다.

이 관용구들은 모두 코틀린에서 널리 쓰이므로 숙련된 코틀린 개발자라면 잘 압니다. 그럼에도 B가 더 어려운 이유는 두 가지입니다.

  • 코드는 숙련자만 읽지 않습니다. 몇 달을 찾다 겨우 뽑은 주니어는 let, takeIf, 제한된 함수 참조를 모를 수 있고, 엘비스 연산자가 이렇게 쓰이는 것은 본 적이 없을 가능성이 큽니다. 이 한 블록을 놓고 하루 종일 고민할 수도 있습니다.
  • 숙련자에게도 코틀린이 유일한 언어는 아닙니다. 대부분은 코틀린보다 더 널리 쓰이는 언어의 경험이 훨씬 많아서, 뇌는 코틀린 고유 관용구보다 일반적인 프로그래밍 관용구를 항상 더 빨리 알아봅니다. 코틀린을 몇 년 쓴 뒤에도 A가 훨씬 빨리 읽힙니다.

잘 알려지지 않은 관용구 하나하나는 약간의 복잡성만 더하지만, 이것들을 한 문장 안에서 조합해 분석하려 하면 복잡성은 빠르게 증가합니다.

수정 · 디버깅 · 확장

구현 A는 고치기도 쉽습니다. if 블록에 연산을 하나 추가하는 상황이면 A는 한 줄 넣으면 끝이지만, B는 함수 참조를 더 쓸 수 없어 람다로 바꿔야 하고, else 쪽은 엘비스 오른쪽에 단일 표현식 대신 여러 구문을 넣어야 하므로 run 까지 끌어와야 합니다.

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

디버깅도 A가 훨씬 간단한데, 디버깅 도구 자체가 이런 기본 구조를 위해 만들어졌기 때문입니다. person 이 null 일 때와 성인이 아닐 때 다른 오류를 보여 주는 세 번째 분기를 추가하는 상황도 마찬가지로, A는 인텔리제이 리팩터링으로 분기를 쉽게 추가하지만 B는 거의 완전히 다시 작성해야 합니다. 덜 일반적이고 ‘창의적인’ 구조는 대개 덜 유연하고 도구 지원도 적습니다.

두 구현은 같은 방식으로 동작하지 않습니다

let 은 람다의 결괏값을 반환합니다. showPerson 이 null 을 반환하면 엘비스 연산자가 그 값을 받아 B는 showError 까지 호출합니다. 익숙하지 않은 구조를 쓰면 이렇게 예측하기 힘든 동작을 만들기 쉽습니다.

일반 규칙

가독성을 높이는 일반적인 규칙은 인지 부하를 줄이는 것 입니다. 뇌는 패턴을 인식하고 그 위에서 프로그램의 동작을 이해하므로, 가독성이란 패턴을 인식하고 이해하기까지의 간극을 줄이는 일입니다. 짧은 코드는 빠르게 읽을 수 있지만, 일반적이고 익숙한 구조는 그보다 더 빠르게 읽힙니다.

극단적이 되지 마세요

let 이 잘못 쓰일 수 있다고 해서 항상 피하라는 뜻은 아닙니다. let 은 다양한 상황에서 코드를 개선하는 인기 있는 관용구입니다.

가장 일반적인 예는 변경 가능한 널 가능 프로퍼티가 null 이 아닐 때만 작업하는 경우입니다. 변경 가능한 프로퍼티는 다른 스레드가 null 로 바꿀 수 있어 스마트 캐스트가 되지 않으므로, 안전 호출 let 이 좋은 방법입니다.

class Person(val name: String)
var person: Person? = null
 
fun printName() {
    person?.let {
        print(it.name)
    }
}

이 밖에도 인수를 계산한 뒤 특정 작업을 수행할 때와, 데코레이터로 객체를 래핑할 때 let 을 씁니다. 둘 다 함수 참조를 사용합니다.

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

두 경우 모두 디버깅이 어렵고 경험이 부족한 코틀린 개발자가 이해하기도 어려워 비용이 듭니다. 하지만 공짜로 얻는 것은 없고, 이 정도는 감수할 만한 가치가 있습니다. 문제는 타당한 이유 없이 상당한 복잡성이 추가될 때입니다.

각 구조가 복잡성을 얼마나 키우는지 이해하고, 확실한 의도를 갖고 쓰고 있는지 알아야 합니다. 두 구조를 함께 쓰면 복잡성은 대개 각각의 합보다 훨씬 커집니다.

컨벤션

가독성이 무엇인지는 사람마다 관점이 다릅니다. 함수 이름, 무엇이 명시적이고 무엇이 암시적이어야 하는지, 어떤 관용구를 써야 하는지를 두고 끊임없이 논쟁하는 것도 프로그래밍이 표현의 예술이기 때문입니다. 그럼에도 이해하고 기억해야 할 컨벤션은 있습니다.

코틀린으로 할 수 있는 최악의 일을 묻는 질문에 대한 답이 좋은 반례입니다.

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

이 코드가 동작하려면 다음이 필요합니다.

operator fun String.invoke(f: ()->String): String =
    this + f()
 
infix fun String.and(s: String) = this + s
위반한 규칙이유
연산자 의미 위배String 은 호출하는 대상이 아니므로 invoke 를 구현하면 안 됩니다
’람다를 마지막 인자로’ 컨벤션이 모호해짐함수 뒤에 붙이는 것은 좋지만, invoke 연산자와 함께 쓰면 매우 주의해야 합니다
이상한 infix 이름and 는 연결이 아니라 논리 연산을 떠올리게 합니다. append 나 plus 가 훨씬 낫습니다
이미 있는 기능 재발명문자열 연결은 언어에 내장되어 있습니다

이런 제안의 바탕에는 좋은 코틀린 스타일을 보장하는 좀 더 일반적인 규칙이 있습니다.

비교 / 트레이드오프

기준구현 A (if/else)구현 B (관용구 체인)
길이79자68자
필요한 지식어느 언어에나 있는 구조코틀린 고유 관용구 5개
초보자 · 타 언어 출신바로 읽힘하루를 쓸 수도 있음
숙련된 코틀린 개발자그래도 더 빨리 읽힘알지만 인식이 느림
블록에 연산 추가한 줄 추가함수 참조를 람다로, 엘비스 뒤에 run
세 번째 분기 추가IDE 리팩터링으로 간단거의 다시 작성
디버깅도구가 이 구조를 위해 만들어짐어려움
동작 예측보이는 대로let 반환값에 따라 showError 가 추가 호출될 수 있음

내 생각

  • “더 짧게 만들 수 있어요”는 리뷰 코멘트로 부족합니다. 79자 대 68자가 보여 주듯 길이는 가독성의 대리 지표가 아니라서, 팀에서 코틀린 경험이 가장 적은 사람이 읽는 시간을 기준으로 삼아야 합니다.
  • ?.let { } ?: run { } 체인은 스프링 서비스 코드에서 가장 흔한 구현 B입니다. 조회 결과가 없으면 예외를 던지는 로직에 이 체인을 쓰면 람다 안의 호출이 null 을 반환하는 순간 엘비스 쪽이 실행되는 함정이 그대로 재현되므로, 분기와 부수 효과가 섞이면 if/else 로 풀어 쓰는 편이 안전합니다.
  • 관용구 조합의 복잡성이 곱셈이라는 말은 코드 리뷰 비용으로 나타납니다. 리뷰어가 체인을 머릿속에서 if/else 로 번역해야 승인할 수 있다면 그 번역 비용을 리뷰마다 치르는 셈이라, 처음부터 번역된 형태로 쓰는 편이 팀 전체로는 싸게 먹힙니다.
  • 컨벤션 위반의 무서운 점은 컴파일도 테스트도 통과한다는 것입니다. "A" { "B" } and "C" 는 정상 동작하므로 이런 코드를 막는 것은 코드 리뷰와 ktlint·detekt 같은 린터뿐이고, 구글이 가독성 제도를 코드 리뷰에 붙여 둔 이유도 여기 있습니다.
  • let 데코레이터 체인은 자바 스트림 래핑의 정확한 번역입니다. 자바에서는 new ObjectInputStream(new ZipInputStream(new BufferedInputStream(fis))) 처럼 안쪽부터 읽어야 하는 중첩 생성자를, 적용되는 순서대로 펼쳐 주므로 이 경우는 코틀린 관용구가 오히려 인지 부하를 줄입니다.

관련 개념

  • Ch03 지식 공유 — 구글의 가독성 제도는 “읽는 사람” 기준을 개인 취향에 맡기지 않고 코드 리뷰로 조직 차원에서 맞추는 장치라, 이 아이템의 컨벤션 절이 제도화된 형태입니다
  • Ch08 스타일 가이드와 규칙 — 좋은 규칙의 원칙 중 “읽는 사람에게 맞춘다”와 “일관되어야 한다”가 이 아이템과 같은 근거이고, 사람이 아니라 도구가 집행해야 한다는 결론까지 이어집니다