본문 바로가기
오늘은 뭘 배울까?/책

[이펙티브 코틀린] 2장. 가독성

by Kim Juhwan 2022. 11. 9.

1. 가독성을 목표로 설계하라
2. Unit?을 리턴하지 말라
3. 변수 타입이 명확하지 않은 경우 확실하게 지정하라
4. 프로퍼티는 동작이 아니라 상태를 나타내야 한다
5. 이름 있는 아규먼트를 사용하라

 

 

 


 

 

1.  가독성을 목표로 설계하라 (71p ~ )

이번 목차를 요약하자면 항상 가독성을 생각하며 코드를 작성하자는 것이다.

일반적이지 않고 굉장히 창의적인 구조는 변화에 유연하게 대응하지도 못하고 디버깅 도구의 지원조차 제대로 받지 못한다.

 

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

// 구현 B
person?.takeIf { it.isAdult }
	?.let(view::showPerson)
        ?: view.showError()

위 코드 중 어떤 것이 더 좋은 코드일까? 정답은 A이다.

B는 코틀린의 특성을 잘 살리고 축약해서 작성했지만, 가독성이 좋은 코드라고 볼 수는 없다.

특히 나처럼 경험이 적은 코틀린 개발자들은 더욱이 이해하기 어렵다 🤔

 

그렇다면 let과 같은 함수를 무조건 피하는 것이 좋은 코드인가? 그렇지도 않다.

극단적으로 생각하지 말자. 적당히 사용하면 오히려 좋은 코드를 만드는 데 도움이 된다.

다음 예제를 보자.

 

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

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

가변 프로퍼티는 스레드와 관련된 문제를 발생시킬 수 있으므로 스마트캐스팅이 불가능하다.

해결 방법으로 여러가지가 있는데 그중 하나가 바로 let을 사용하는 것이다.

let은 외에도 좋은 코드를 만들기 위해서 중요한 재료가 된다.

 

  • 연산을 아규먼트 처리 후로 이동시킬 때
  • 데코레이터를 사용해서 객체를 랩할 때
// 이 코드를
print(students.filter{}.joinToString{})

// 이렇게 바꿀 수 있다.
students.filter{}.joinToString().let(::print)

연산을 아규먼트 처리 후로 이동시킨다는 것은 위 코드를 보면 이해할 수 있다.

이렇게 하면 마치 문장을 읽듯 순서대로 해석을 할 수 있다.

즉, 가독성이 올라간다는 장점이 있다.

 

어떻게 보면 경험이 적은 주니어 개발자들에겐 이해하기 어려운 코드일 수 있다.

하지만 위 코드처럼 비용을 지불할만한 코드라면 사용해도 괜찮다.

지불할만한 코드인지 아닌지 고민하고 그 균형을 맞추는 것이 중요하다는 것을 기억하자.

 

 

2. Unit?을 리턴하지 말라 (82p ~ )

// 이 코드를
fun keyIsCorrect(key: String): Boolean = //...

if(!keyIsCorrect(key)) return


// 이렇게 사용할 수도 있다.
fun verifyKey(key: String): Unit? = //...

verifyKey(key) ?: return

Unit?은 Unit 또는 null을 리턴할 수 있다.

그래서 Boolean을 리턴하는 첫 번째 코드를 Unit?을 활용하여 두 번째 코드처럼 작성할 수 있다.

하지만 Unit?으로 Boolean을 표현하는 것은 오해의 소지가 있고 예측하기 어려운 오류를 만들 수 있다고 한다.

그렇기 때문에 기본적으로는 Unit?을 리턴하거나 이를 기반으로 연산하지 않는 것이 좋다.

 

Unit을 제대로 활용해본 적이 없어서 솔직히 잘 몰라서 검색을 해봤다.

Unit은 자바의 void와 비슷한 역할을 한다. 

 

// 리턴하지 않아도 되고
private fun test(): Unit {
        
}
    
// 리턴해도 된다.
private fun test(): Unit {
    return Unit
}

// Unit이나 null을 리턴해야 한다.
private fun test(): Unit? {
    return null
}

그래서 Unit을 리턴 타입으로 주면 리턴을 해도 되고 하지 않아도 된다.

Unit?은 이전에도 말했듯 Unit이나 null을 리턴해야 한다.

아니 그렇다면 대체 이 쓸데없어 보이는 Unit은 왜 사용하는 걸까? 🤔🤔🤔

 

(궁금해서 찾아본 내용)

코틀린은 primitive 타입을 사용하지 않고 전부 클래스로 만들어 사용한다.

그러다 보니 void를 대체할 클래스가 필요했고, 그게 바로 Unit이라고 한다.

Unit은 특별한 상태나 행동을 가지고 있지 않기 때문에 싱글톤으로 되어있고, 하나의 인스턴스만을 사용한다고 한다.

 

// 이거랑
private fun test() {
        
}

// 이거랑 같다.
private fun test(): Unit {

}

즉, 자바에서 void가 생략이 가능했듯이 Unit 또한 생략이 가능하다.

아무것도 안 적으면 그건 리턴형이 Unit이라는 뜻이다.

이번 목차는 Unit?을 리턴하지 말라는 거지 Unit을 리턴하지 말라는 뜻은 아니라는 거 - !

 

3. 변수 타입이 명확하지 않은 경우 확실하게 지정하라 (84p ~ )

val num = 10
val name = "아이유"
val ids = listOf(12, 112, 554, 997)

코틀린은 타입 추론을 제공하기 때문에 타입을 명시적으로 지정해주지 않아도 된다.

넣는 값에 따라 이게 Int인지, String인지 알아서 다 추론한다.

 

val data = getSomeData()

위 코드는 돌아가는 데 아무런 문제가 없는 코드다.

하지만 읽는 데는 확실히 문제가 있다.

여기서 data는 과연 무슨 타입의 변수인가? 우리는 알 수 없다.

 

val data: UserData = getSomeData()

그렇기 때문에 이런 경우는 위처럼 타입을 지정해주는 것이 좋다. 가독성을 위해서 말이다.

이렇듯 타입 추론은 유형이 명확할 때는 가독성에 도움이 되지만

남용하면 오히려 가독성을 떨어트리게 된다.

 

"요즘 IDE가 잘 되어 있어서 타고 타고 들어가면 다 나오는 데 뭐가 문제인가요?"

 

만약 그런 기능이 제공되지 않는 환경이라면 문제가 될 수 있다.

가령 깃허브에서 코드를 확인할 수 있는 것이고

또, 그렇게 찾아 들어가서 본다는 게 어떻게 보면 비용이기 때문에

코드를 작성할 때 명시적으로 표시해주는 것이 좋다.

정말 간단한 것인데도 나 또한 생각하지 못했던 문제라 이번 장에서 가장 흥미로웠던 주제였다.

 

4. 프로퍼티는 동작이 아니라 상태를 나타내야 한다 (92p ~ )

// 코틀린의 프로퍼티
var name: String? = null

// 자바의 필드
String name = null;

코틀린의 프로퍼티와 자바의 필드는 데이터를 저장한다는 점에서 비슷해 보이지만

프로퍼티는 더 많은 기능이 있고, 기본적으로 사용자 정의 세터와 게터를 가질 수 있다는 점이 다르다.

 

var name: String? = null
    get() = field?.toUpperCase()
    set(value) {
    	if(!value.isNullOrBlank()) {
            field = value
        }
    }

예를 들어 이렇게 get을 할 때는 대문자로 변환해서 가져오고

set 할 때는 값이 있을 때만 저장하도록 설정해줄 수 있다.

 

참고로 위 코드에서 field라는 식별자는 프로퍼티의 데이터를 저장해주는 백킹 필드에 대한 레퍼런스이다.

따로 만들지 않아도 디폴트로 생성된다.

 

// 이렇게 하지 마세요!!
val Tree<Int>.sum: Int
	get() = when (this) {
    	is Leaf -> value
        is Node -> left.sum + right.sum
    }

만약 값을 get을 할 때마다 해야 하는 동작이 있다면 위와 같이 구현할 수도 있을 것이다.

이처럼 함수를 따로 만들지 않고 프로퍼티 내 get에다가 구현이 가능하긴 하지만 이는 좋은 방법이 아니다.

 

예를 들어, 로직의 연산 비용이 높거나 복잡도가 O(1)이 넘는다고 해보자.

대게 사용자들은 게터의 비용이 많지 않다고 생각한다. (당연하지! 그냥 변수 가져다 쓰는 건데..)

연산 비용을 예측하거나 이를 기반으로 캐싱 등을 고려할 때 오해를 불러일으킬 수 있으므로

이런 경우는 게터가 아닌 함수를 따로 만들어 로직을 구현하는 것이 맞다.

 

그 외에도 함수를 사용해야 하는 경우, 프로퍼티를 사용해야 하는 몇 가지 경우들을 책에서 다루고 있다.

전부 다 적기엔 킹작권 이슈가 있을 듯 하니 (그리고 그냥 쭉 읽어보면 될듯한 내용들이라) 책을 참고하자.

 

5. 이름 있는 아규먼트를 사용하라 (97p ~ )

// 음...? joinToString에 들어가는 저 값은 무슨 의미를 가질까?
val text = (1..10).joinToString("|")

// 음~ joinToString에 들어가는 값이 separator로 쓰이는구나!
val text = (1..10).joinToString(separator = "|")

아규먼트를 사용하면 파라미터가 무슨 의미로 사용되는지 더 명확하게 알 수 있다.

특히 어떤 상황에 사용하면 좋은지 알아보자면... 다음과 같다.

 

5-1. 디폴트 아규먼트의 경우

fun foo(a: Int, b: Int = 0) {

}

foo(5)

디폴트 아규먼트란 인자의 값이 기본으로 설정되는 것을 말한다.

이런 경우 함수를 사용할 때 무엇이 디폴트 아규먼트이고 내가 넘겨주는 값이 뭔지 알 수가 없다.

 

물론 IDE에서 알려주기도 하고 함수를 타고 들어가면 알 수 있지만

이 기능을 끄고 사용하거나 다른 IDE를 사용하는 등 이 코드를 읽는 사람이 다른 환경에 놓일 수 있다는 것을 기억하자.

그래서 이런 경우는 파라미터를 명시해주는 것이 좋다.

 

5-2. 같은 타입의 파라미터가 많은 경우

fun sendEmail(to: String, message: String) { /* ... */ }

sendEmail(
    to = "contact@google.com"
    message = "Hello, ..."
)

만약 파라미터 타입이 다 다르다면, 위치를 잘못 입력했을 때 문제를 바로 발견할 수 있다. (빨간 줄 쫙!)

하지만 파라미터 타입이 같다면? 문제를 찾아내기 어려울 수 도 있다.

그렇기 때문에 이런 경우는 파라미터를 명시해주는 것이 좋다.

 

5-3. 함수 타입 파라미터

fun call(before: ()->Unit = {}, after: ()->Unit = {}){
    before()
    print("middle")
    after()
    println()
}

fun main() {
    call({ print("before ") }) // before middle
    call { print(" after") } // middle after
}

함수 타입의 옵션 파라미터가 있는 경우는 아규먼트가 없으면 더욱더 헷갈린다.

위 코드는 다음과 같이 사용하면 더 쉽게 이해할 수 있다.

 

    call(before = { print("before ") })
    call(after = { print(" after") })

이제 뭐가 before고 after인지 확실하게 구분할 수 있을 것이다.

 

// Java
observable.getUsers()
        .subscribe((List<User> users) -> { // onNext
            // ...
        }, (Throwable throwable) -> { // onError
            // ...
        }, () -> { // onComplete
            // ...
        });

이와 관련된 흥미로운 내용이 있어서 적어보려 한다.

RxJava는 자바로 작성되어 있기 때문에 이름 있는 파라미터를 사용할 수 없다.

그래서 코틀린에서는...

 

// Kotlin
observable.getUsers()
        .subscribeBy(
            onNext = { users: List<User> ->
                // ...
            },
            onError = { throwable: Throwable ->
            	// ...
            },
            onComplete = {
            	// ...
            })

subscribe대신 subscribeBy를 사용한다.

세상에나 둘의 차이가 고작 그거였다니! 나는 그래도 뭔가 내부적으로 기능적인 차이가 있을 거라 생각했는데...

RxJava에 대해서도 알아갑니당...

 


💡 느낀 점

  • 2장 가독성은 전체적으로 내용이 쉬웠다. 어떻게 보면 그만큼 당연한 것들이라는 뜻이겠지.
    그럼에도 불구하고 많이 놓치고 실수하는 부분들을 알 수 있어서 재밌었다.
  • [목차 5-3]에서 함수를 파라미터로 넘기는 코드가 나왔는데, 코드 짤 때 잘 사용하지 않던 패턴이라 낯설었다.
    call을 ()로 감싸지 않을 때 after로 들어가는 부분이 아직 잘 이해가 가지 않는다.
    {} 만으로 사용이 가능하다니...? 람다식은 마지막 파라미터부터 채워져서 after로 들어가는 건가..?
    스터디 때 질문해야겠다..
  • 읽기 좋은 코드가 좋은 코드다라는 책에서 가독성에 대한 내용을 읽었었는데,
    그 책과는 또 다른 가독성에 대한 이야기들을 들을 수 있어서 좋았다.

📘 참고한 자료


 

 

반응형

댓글