이펙티브 코틀린(Effective Kotlin Best Practices) 책을 통해 스터디 진행하면서 정리한 글입니다.
가독성은 사람에 따라 다르게 느끼겠지만 일반적으로 코드를 읽고 얼마나 빠르게 이해할 수 있는지를 의미합니다. 아래에 코드 가독성을 높이기 위한 방법을 소개하고 있습니다.
// 구현 A
if(person != null && person.isAdult){
view.showPerson(person)
}else{
view.showError()
}
// 구현 B
person?.takeIf { it.isAdult }
?.let(view::showPerson)
?: view.showError()
구현 B처럼 단순히 코드가 짧다고 가독성이 좋다고 말하기는 어렵습니다. 구현 B를 이해하기 위해서는 안전호출(?.), takeIf, let, Elvis 연산자에 대한 이해가 선행되어야 합니다. 그에 비해 구현 A는 if/else, 메소드 호출을 사용하기 때문에 빠르게 이해가 되어 가독성이 더 좋습니다.
// 구현 A
if(person != null && person.isAdult){
view.showPerson(person)
view.hideProgressWithSuccess()
}else{
view.showError()
view.hideProgress()
}
// 구현 B
person?.takeIf {it.isAdult}
?.let{
view.showPerson(it)
view.hideProgressWithSuccess()
} ?: run{
view.showError()
view.hideProgress()
}
가독성 뿐만 아니라 수정이 필요할 경우에도 구현 A가 더 간편합니다. 작업을 더 추가할 경우에 구현 B에서 view::showPerson
과 같은 제한된 함수 레퍼런스를 사용할 수 없고 run 연산자에 대한 추가 이해가 필요합니다. 결론적으로 인지 부하
를 줄이는 방향으로 코드를 작성해야 모두에게 가독성이 좋고 추후에 수정도 편리할 수 있습니다.
class Person(val name: String){
var person: Person? = null
fun printName(){
person?.let{
print(it.name)
}
}
}
위에서 언급했던 let, run 에 대한 이해가 필요하다고 무조건 좋지 않다는 것은 아닙니다. 다음 예시처럼 person 변수처럼 가변 프로퍼티가 있고 null이 아닐 때만 특정 작업을 수행할 때 let을 사용하면 편리하게 작성할 수 있습니다.
students
.filter {it.result >= 50}
.joinToString(separator = "\n"){
"${it.name} ${it.surname}, ${it.result}"
}
.let(::print)
그 외에도 일련의 연산 이후 연산 결과를 특정 메소드의 매개변수로 이동할 때도 let을 사용하면 편리하게 작성할 수 있습니다.
var obj = FileInputStream("/file.gz")
.let(::BufferedInputStream)
.let(::ZipInputStream)
.let(::ObjectInputStream)
.readObject() as SomeObject
데코레이터 패턴 방식으로 사용하여 객체를 래핑할 때도 유용합니다. 위 코드들은 모두 디버깅이 어렵고 코틀린 초보자에게는 인지 부하가 늘어나지만, 충분히 지불할 만한 가치가 있으므로 사용해도 좋습니다. 결론적으로 "단순히 인지 부하가 늘어난다고 사용을 하면 안된다" 라고 극단적으로 생각하지 않고 정당한 이유가 있다면 사용해도 좋을 것 같습니다.
연산자 오버로딩을 사용하면 강력한 도구로 활용할 수 있지만 때로는 중복된 의미로 인해 오용될 가능성도 존재합니다.
fun Int.factorial(): Int = (1..this).product()
fun Iterable<Int>.product(): Int =
fold(1) { acc, i -> acc * i }
operator fun Int.not() = factorial()
팩토리얼을 !
연산자로 나타내기 위해서 not 연산자를 오버로딩하였습니다. 기능 동작을 할지라도 기존 사용자에게는 논리 연산으로 사용될지 새로 정의한 연산자로 사용될지 혼란스럽고 오해의 소지가 있습니다.
operator fun Int.times(operation: () -> Unit): () -> Unit =
{ repeat(this) { operation() } }
val tripledHello = 3 * { print("Hello") }
tripleHello() // HelloHelloHello
또한 대략적으로 유추는 가능하지만 확실하지 않을 경우가 있습니다. 위 예시는 times 연산자를 오버로드했고 특정 사용자는 함수를 세 번 반복하여 호출한다고 생각할 수도 있고 아닐 수도 있습니다. 이처럼 의미가 명확하지 않은 경우에는 infix
를 활용한 확장 함수를 사용하는 것이 좋습니다.
infix fun Int.timesRepeated(operation: () -> Unit): () -> Unit =
{ repeat(this) { operation() } }
val tripledHello = 3 timesRepeated { print("Hello") }
tripleHello() // HelloHelloHello
infix를 사용하여 이항 연산자처럼 사용할 수 있으며, 사실 가장 좋은 것은 top-level function을 사용하는 것이 좋습니다. n번 호출하는 함수에는 repeat(3) { }
가 이미 stdlib에 구현되어 있습니다.
연산자를 오버로딩을 할 때는 의미에 맞게 사용하되 의미가 모호할 경우에는 infix를 활용하라고 하였습니다. 하지만 DSL(Domain-Specific Language)를 정의할 때는 규칙을 무시해도 괜찮습니다. 😎
Kotlin에서 Unit?을 사용하는 경우는 Boolean 리턴 값 대신 Unit과 null 값으로 표현하는 경우입니다. Boolean과 동일한 기능을 할지라도 Unit? 의 경우 가독성이 좋지 않으며 예측하기 어려운 오류도 만들 수 있기에 지양해야 하는 습관입니다.
// 구현 A
fun keyIsCorrect(key: String) : Boolean = // ...
if(!keyIsCorrect(key)) return
// 구현 B
fun verifyKey(key: String): Unit? = // ...
verifyKey(key) ?: return
다음 스니펫처럼 구현 B 코드를 구현 A처럼 바꾸는게 좋은 습관이며 오해를 불러일으키지 않기에 가독성이 더 좋다고 말할 수 있습니다.
변수타입을 명확하게 지정하는 것은 안정성 뿐만 아니라 가독성에도 도움이 되는 습관입니다. Kotlin은 기본적으로 타입추론이 가능하여 타입을 명시적으로 지정하지 않아도 되지만 확실하지 않은 경우에는 남용하지 않는 것이 좋습니다.
val data = getSomeData() // X
val data: UserData = getSomeData() // O
다음과 같이 UserData 타입을 지정하면 코드를 훨씬 쉽게 읽을 수 있다는 것을 느낄 수 있을 것입니다. 😆
class User: Person(){
private var beersDrunk: Int = 0
fun drinkBeers(num: Int){
// ...
this.beersDrunk += num
// ...
}
}
위 코드처럼 클래스의 참조를 가리키는 this
리시버를 사용하여 명시적으로 작성하는 것이 좋습니다. 명시적으로 리시버를 나타내지 않아도 문제되지 않은 경우도 많겠지만 혹시 모를 오류 가능성이 있으므로 대비하는 것이 현명합니다.
class Node(val name: String){
fun makeChild(childName: String) =
create("$name.$childName")
.apply{ print("Created $name") }
fun create(name: String): Node? = Node(name)
}
fun main(){
val node = Node("parent")
node.makeChild("child")
}
위 코드 결과는 Create parent.child
가 출력될 것으로 예상할 수 있지만, Created parent
가 출력됩니다. 결과가 예상대로 나타나지 않은 이유는 name 변수가 create 메소드로 생성된 Node 객체의 name로 호출된 것이 아닌 프로퍼티 name을 호출하고 있기 때문입니다.
class Node(val name: String){
fun makeChild(childName: String) =
create("$name.$childName")
.apply{ print("Created ${this?.name}") }
fun create(name: String): Node? = Node(name)
}
이와 같이 리시버가 명확하지 않는 경우에는 this
키워드를 통해 명확하게 해주는 편이 좋습니다. 레이블 없이 리시버를 사용하면 가장 가까운 스코프의 리시버를 의미하고 다른 리시버를 사용하기 위해서는 레이블을 사용해야 합니다.
class Node(val name: String){
fun makeChild(childName: String) =
create("$name.$childName")
.apply{ print("Created ${this?.name} in "+
"${this@Node.name}") }
fun create(name: String): Node = Node(name)
}
fun main(){
val node = Node("parent")
node.makeChild("child")
}
레이블까지 사용함으로써 어떤 리시버를 활용하는지 명확해졌고 이로 인해 코드를 안전하게 사용할 뿐만 아니라 가독성도 향상됩니다.
자바의 필드(field)와 코틀린의 프로퍼티(property)는 많이 헷갈렸던 개념이였는데 책을 통해 확실히 알게된 것 같습니다. 코틀린의 프로퍼티의 개념을 살펴보면 대부분의 블로그들이
자바(field) + 게터(getter) + 세터(setter)
라고 정의합니다. 하지만 엄밀히 말하면 프로퍼티는 필드가 필요하지 않고val
접근자에서는 게터,var
의 경우에 게터/세터를 나타내는 것이라고 볼 수 있습니다.
// Kotlin Code
val name = "hello"
val surname = "world"
val fullName: String
get() = "$name $surname"
// Java Decompile
public final class MainKt {
@NotNull
private static final String name = "hello";
@NotNull
private static final String surname = "world";
@NotNull
public static final String getName() {
return name;
}
@NotNull
public static final String getSurname() {
return surname;
}
@NotNull
public static final String getFullName() {
return name + ' ' + surname;
}
}
코틀린 코드에서 fullName
변수는 읽기 전용 프로퍼티로 초기화하지 않고 게터만 별도로 정의하였습니다. 자바로 디컴파일 시에 name
과 surname
은 자바 필드로 존재하지만 fullName
은 없는 것을 확인할 수 있습니다. 이처럼 프로퍼티일지라도 필드가 무조건 존재해야 하는 것은 아닙니다. 별개로 프로퍼티가 필드와의 또 다른 특징은 사용자 정의 게터/세터를 가질 수 있습니다.
interface Person{
val name: String
}
open class Supercomputer{
open val theAnser: Long = 42
}
class AppleComputer : Supercomputer(){
override val theAnswer: Long = 1_800_275_2273
}
프로퍼티가 게터/세터로 표현되기에 인터페이스에서도 정의할 수 있습니다. 인터페이스에 프로퍼티를 정의해두었기에 상속받은 클래스는 게터를 가질 것이라고 생각할 수 있고 오버라이드 또한 가능합니다.
val db: Database by lazy { connectToDb() }
val Context.preferences: SharedPreferences
get() = PreferenceManager.getDefaultSharedPreference(this)
val Context.inflater: LayoutInflater
get() = getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val Context.notificationManager: NotificationManager
get() = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
위 스니펫에서는 프로퍼티를 위임 가능하다는 점과 확장 프로퍼티를 만들 수 있다는 점을 보여주고 있습니다. 위임에 대해서는 추후 스터디를 진행하기에 생략하고 프로퍼티가 필드가 아닌 게터/세터라고 하였으니 본질적으로 함수이므로 예시와 같이 확장 프로퍼티를 만들 수 있습니다.
// property
val Tree<Int>.sum: Int
get() = when(this){
is Leaf -> value
is Node -> left.sum + right.sum
}
// function
fun Tree<Int>.sum(): Int = when(this){
is Leaf -> value
is Node -> left.sum() + right.sum()
}
하지만, 프로퍼티를 함수처럼 사용하는 것은 좋지 않습니다. sum 프로퍼티는 모든 트리를 탐색하는 알고리즘으로 크기가 클수록 시간 복잡도가 증가하게 됩니다. 일반적으로 게터는 특정 상태를 가져오는 것이지 로직을 수행하는 작업이 아니기에 아래와 같이 함수를 정의하는 편이 바람직합니다.
프로퍼티로 정의하는 것이 아닌 함수로 정의해야 하는 예시를 소개해드리겠습니다.
- 계산량이 많거나, 시간 복잡도가 O(1)보다 클 경우
- 비즈니스 로직이 포함되는 경우
- 외부의 요인으로부터 변경이 될 가능성이 있는 경우
- 변환의 경우 ex) Int.toDouble()
- 게터에서 프로퍼티의 상태가 변경이 일어나야 하는 경우
이와 같은 경우에서는 프로퍼티로 표현하기보다는 함수로 정의하는 것이 좋다고 말할 수 있습니다.
함수의 매개변수의 의미가 명확하지 않은 경우는 직접 지정해서 명확하게 만들어야 합니다.
val separator = "|" val text = (1..10).joinToString(separator = separator)
joinToString 메소드를 아는 개발자에게는 매개변수 이름을 지정하지 않더라도 알겠지만 모르는 개발자에게는 함수 내의 정의를 살펴보는 일련의 과정이 필요하게 됩니다. 이를 대비하기 위해 separator 매개변수를 지정함으로써 빠르게 이해할 수 있습니다.
이름 있는 아규먼트를 사용하면 코드는 길어지지만 가독성이 좋아지고 파라미터의 순서와 상관 없이 명확하게 지정하기에 안전합니다. 절대적으로 기준이 정해진 것은 아니지만 이름 있는 아규먼트를 사용하기 좋은 경우에 대해서 몇가지 소개하겠습니다.
디폴트 아규먼트는 옵셔널하기에 함수 이름만으로는 어떤 의미인지 파악하기 어려운 경우가 있습니다. 따라서 디폴트 아규먼트일 경우에 보통 아규먼트 이름을 붙여서 사용하는 것이 좋습니다.
파라미터가 다른 경우에 잘못된 타입을 입력하면 컴파일 에러가 발생하기에 쉽게 문제를 발견할 수 있습니다. 하지만 파라미터에 같은 타입이 있다면 문제를 발견하기 어려울 수도 있습니다. 따라서 이 경우에도 아규먼트 이름을 지정하여 사용하는 것이 좋습니다.
fun sendEmail(to: String, message: String) { /*..*/ }
sendEmail(
to = "contact@kt.academy",
message = "Hello, ..."
}
코틀린은 함수형 프로그래밍을 지원하기에 고차함수를 정의할 수 있고 함수의 마지막 매개변수에 함수가 정의되어 있을 경우 특별한 형태로 사용할 수 있습니다.
public fun thread(
start: Boolean = true,
isDaemon: Boolean = false,
contextClassLoader: ClassLoader? = null,
name: String? = null,
priority: Int = -1,
block: () -> Unit
): Thread {
val thread = object : Thread() {
public override fun run() {
block()
}
}
if (isDaemon)
thread.isDaemon = true
if (priority > 0)
thread.priority = priority
if (name != null)
thread.name = name
if (contextClassLoader != null)
thread.contextClassLoader = contextClassLoader
if (start)
thread.start()
return thread
}
fun main(){
thread {
//...
}
}
thread 메소드는 많은 매개변수가 필요하고 마지막 매개변수로 함수가 정의되어 있습니다. 이 경우에는 함수를 ()
괄호 안에 대입할 필요없이 중괄호로 바로 표현할 수 있습니다.
하지만 함수가 하나가 아닐 경우에는 헷갈릴 경우가 발생할 수 있습니다.
fun call(before: () -> Unit = {}, after: () -> Unit = {}){
before()
print("Middle")
after()
}
call({print("CALL")}) // CALLMiddle
call { print("CALL") } // MiddleCALL
위와 같이 사용할 경우에는 정의된 함수가 before
인지 after
인지 명확하지 않을 수 있습니다. 따라서 아래와 같이 before, after을 명시적으로 지정하면 훨씬 더 쉽게 이해할 수 있습니다.
call(before = {print("CALL") }) // CALLMiddle
call(after = {print("CALL") }) // MiddleCALL
코틀린 개발 시에 정해진 코딩 컨벤션을 가지고 개발을 하는 것이 좋습니다. 코딩 컨벤션을 따르게 되면 어떤 프로젝트를 접해도 빠르게 이해가 가능하고 협업도 편리하며 코드 병합과 같은 작업이 훨씬 간편해질 수 있습니다.
아래 코틀린 공식 문서에서 확인이 가능하며 조금씩 변화는 가져갈 수 있지만 전체적으로는 반드시 지켜주는 것이 좋습니다. 😎
2장에서는 코틀린 코드 작성을 할 때 가독성을 높이기 위한 방법에 대해 많이 배울 수 있었습니다. 나름 코드를 가독성 있게 작성하려고 노력하는 입장에서 자연스럽게 하는 방법들이 권장되는 방법인 것을 알게 되어서 기분이 좋았던 것 같습니다. 🤜