Posts 이펙티브 코틀린 간단 정리
Post
Cancel

이펙티브 코틀린 간단 정리

‘이펙티브 코틀린’ 학습 내용을 간단하게 정리한 스터디용 임시 TIL 포스팅입니다🙆‍♂️

1부: 안정성

아이템1: 가변성을 제한하라

  • 상태를 가지는 것은 장단점이 있다.
    • 장점: 시간에 따라 변화하는 요소를 표현 가능
    • 단점: 이해하고 디버깅 및 추론하기 어려움, 공유 상태 관리 필요, 테스트하기 어려움

코틀린에서 가변성 제한하기

1. 읽기 전용 프로퍼티 val

  • var보다는 val을 사용하자.
  • var은 게터이자 세터이므로 val을 var로 오버라이드 가능하다.
1
2
3
4
5
6
interface Element {
  val active: Boolean
}
class ActualElement : Element {
  override var active: Boolean = false // 오버라이드
}
  • 스마트캐스팅이란 코틀린 컴파일러가 자동으로 타입을 좁혀주는 기능이다.
1
2
3
4
5
val str: String? = "Hello"

if (str != null) {
    println(str.length) // ✅ 가능
}
  • 커스텀 게터는 스마트 캐스팅이 불가능하다.
  • 값을 사용하는 시점의 name에 따라 다른 결과가 나올 수 있기 때문
  • 반면 fullName2 논로컬 프로퍼티는 final이면서 사용자 정의 게터가 없기에 스마트 캐스팅이 가능하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
val name: String? = "Marton"
val surname: String = "Braun"

val fullName: String? 
    get() = name?.let { "$it $surname" }
val fullName2: String? = name?.let { "$it $surname" }

fun main() {
    if (fullName != null) {
        println(fullName.length) // 오류
    }
    
    if (fullName2 != null) {
        println(fullName2.length)
    }
}

2. 가변 컬렉션과 읽기 전용 컬렉션의 구분

  • 코틀린은 읽기 전용 컬렉션과 읽고 쓸 수 있는 컬렉션으로 구분된다.
    • 읽기 전용 : Iterable, Collection, Set, List 인터페이스
    • 읽고 쓸 수 있는 : MutableInterable, MutableCollection, MutableSet, MutableList 인터페이스
  • 읽기 전용 컬렉션은 내부 값을 변경할 수 없는 게 아니라, 인터페이스가 변경 기능을 제공하지 않기 때문에 못 바꾸는 것처럼 보일 뿐이다.
1
2
3
4
5
6
val mutable = mutableListOf(1, 2, 3)
val readOnly: List<Int> = mutable

mutable.add(4)

println(readOnly) // 👉 [1, 2, 3, 4] 읽기 전용 컬렉션 List도 인터페이스에서 변경 메서드가 없을뿐 실제로 변경될 수 있음!
  • Iterable<T>.map 과 Iterable<T>.filter 함수는 ArrayList를 반환한다.
1
2
3
4
5
6
7
8
9
inline fun<T, R> Iterable<T>.map(
    transformation: (T) -> R
): List<R> {
    val list = ArrayList<R>()
    for (elem in this){
        list.add(transformation(elem))
    }
    return list
}
  • 코틀린에서 읽기 전용 컬렉션을 가변 컬렉션으로 다운캐스팅하면 절대로 안된다.(p11 참고)
    • 만약, 읽기 전용 컬렉션 -> 가변 컬렉션으로 변경해야한다면 List.toMutableList 함수 사용

3. 데이터 클래스의 copy

  • 불변 객체의 데이터 변경 필요시 변경사항을 반영한 새로운 사본을 생성하는 메서드를 사용해야 한다.
  • data 한정자는 copy 메서드를 만들어준다. 물론 프로퍼티에 새로운 값을 지정하는 것도 가능하다.
1
2
3
4
5
6
7
8
data class(
    val name: String,
    val surname: String,
)

var user = User("Maja", "Markiewicz")
user = user.copy(usrname = "Moska") // 새로운 프로퍼티 지정 가능
print(user) // User(name=Maja, surname=Moska)
  • 도메인 모델, DTO, 응답(Response) 모델 생성시 유용할 것 같다.

다른 종류의 변경 지점

1
2
3
4
5
6
7
8
val list1: MutableList<Int> = mutableListOf()
var list2: List<Int> = listOf()

list1.add(1)
list2 = list2 + 1

list1 += 1 // list1.plusAssign(1)
list2 += 1 // list2 = list2.plus(1)
  • list1은 “자기 자신을 변경” 한다.(mutable) => 빠름(메모리 재사용), 사이드 이펙트 있음, 적절한 동기화 기법 필요
  • list2는 “새로운 리스트를 만들어서 교체” 한다. (immutable처럼 동작) => 새로운 리스트 생성, 안전함(불변처럼 동작), 복사 비용 발생

아이템1 요약

  • var 보다는 val을 선호하자.
  • 가변 프로퍼티보다는 불변 프로퍼티를 선호하자.
  • 가변 객체와 클래스보다 불변 객체와 클래스 사용을 선호하자.
  • 불변 객체를 변경해야 한다면 data 클래스로 만들고 copy 사용하는 것을 고려하자.
  • 상태를 저장해야 한다변, 가변 컬렉션보다는 읽기 전용 컬렉션을 선호하자.
  • 변경 지점을 현명하게 설계하고 꼭 필요한 경우에만 생성하자.

아이템2: 임계 영역을 제거하라

  • 아래와 같은 코드는 ‘동시성 문제’가 발생한다.
1
2
3
4
5
6
7
8
9
10
var num = 0
for (i in 1..1000) {
  thread {
    Thread.sleep(10)
    num += 1
  }
}

Thread.sleep(5000)
print(num) // 1000이 출력될 가능성이 거의 없다. 매번 다른 숫자가 출력된다. 동시성 문제..
  • 또한 기본 컬렉션은 순회하는 동안 요소가 수정되는 것을 지원하지 않기에 ConcurrentModificationException 예외가 발생한다.(p19 참고)

코틀린/JVM의 동기화

  • 자바와 동일하게 synchronized 함수를 사용한다.
1
2
3
4
5
6
7
8
9
10
11
val lock = Any()
for (i in 1..1000) {
  thread {
    Thread.sleep(10)
    synchronized(lock) {
      num += 1
    }
  }
}
Thread.sleep(1000)
print(num) // 1000

아토믹 객체

  • 자바에서 AtomicInter, AtomicLong, AtomicBoolean 등의 원자성 클래스로 원자적으로 실행되도록 보장하는 메서드를 제공하며 동시성 문제를 해결 가능하다.

동시성 컬렉션

  • ConcurrentHashMap, ConcurrentHashMap.newKeySet, ConccurrentLinkedQueue를 활용할 수 있다.
  • AtomicFU와 같은 코틀린 멀티플랫폼 라이브러리도 있다.

변경 가능한 지점을 유출하지 마세요

  • 공개 상태를 나타내는데 가변 객체를 사용하여 노출하는 것은 위험하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
data class User(val name: String)

class UseRepository {
  private val users: MutableList<User> = mutableLlistOf()
  
  fun loadAll(): MutableList<User> = users
  //...
}

val users = userRepo.loadAll()
users.add(User("Jeon"))
// ...
println(userRepo.loadAll()) // User(name=Jeon)
  • 이를 해결하기 위한 두 가지 방안이 있다.
    • 1)실제 참조 대신 객체의 복사본을 반환한다.(방어적 복사, 컬렉션은 toList, 데이터 클래스는 copy 메서드)
    • 2)읽기 전용 리스트를 사용한다.(멀티스레드 접근을 헐용하려면 리스트 수정 연산만 synchronized로 동기화, p27 참고)

아이템2 요약

  • 여러 스레드가 공유 자원에 접근시 예외 및 예상치 못한 동작이 발생할 수 있다.
  • 동시 수정으로부터 상태 보호를 위해 동기화를 사용할 수 있다.(synchronized)
  • 동시 수정을 처리하기 위해 자바는 Atomic 클래스와 동시성 컬렉션을 제공한다.
  • 클래스는 내부 상태를 보호해야 하며, 밖으로 노출해서는 안된다. 읽기 전용 객체로 작업을 수행하거나 방어적 복사를 이용하여 동시 수정으로부터 상태를 보호할 수 있다.

아이템3: 가능한 한 빨리 플랫폼 타입을 제거하라

  • 코틀린에서는 자바의 널 여부가 확인되지 않는 타입을 널 가능 타입으로 처리하는 대신 특별한 타입으로 처리하고 이를 ‘플랫폼 타입’이라 한다.
  • 코드에 직접 플랫폼 타입을 정의할 수 없으며, 널 가능 혹은 널 불가능 타입으로 지정하는 방식으로 해당 값을 처리해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
== Java ==
public class UserRepo {
  public User getUser() {
    // ...
  }
}

== Kotlin ==
val repo = UserRepo()
val user1 = repo.user // 플랫폼 타입, nonnull 선언 가능
val user2: User = repo.user // 플랫폼 타입, nonnull 선언 가능
val user3: User? = repo.user // 플랫폼 타입, nullable 선언 가능
  • 플랫폼 타입은 아래와 같은 문제가 발생할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
== Java ==
public class JavaClass {
  public String getValue() {
    return null;
  }
}

== Kotlin ==
fun statedType() {
  val value: String = JavaClass().value // 자바에서 값을 가져오는 라인에서 NPE 발생
  println(value.length)
}

fun platformType() {
  val value = JavaClass().value // nonnull로 선언되었지만 null이 반환되었으므로 여기서 NPE 발생
  println(value)
}
  • 플랫폼 타입은 다른 코드나 실행 흐름으로 더 확산될 수 있다.(p34 참고)
  • 이러한 문제로 인해 코틀린과 상호운용해야 하는 자바 코드에 @Nullable, @NotNull 어노테이션을 적용하자.(javax.annotaion, org.jetbrains.annotations 등 p32 참고)
1
2
3
4
5
public clas UserRepo {
  public @NotNull User getUser() {
    // ...
  }
}
  • 그리고 애플리케이션 안정성을 위해 플랫폼 타입을 가능한 제거하자.

아이템3 요약

  • 다른 언어(ex. Java)에서 왓으며 널 가능성을 알 수 없는 타입을 플랫폼 타입이라 한다.
  • 플랫폼 타입은 위험하므로 전파되지 않도록 가능한 빨리 제거해야 한다.
  • 또한 자바 생성자, 메서드, 필드에 널 간으성을 지정하는 어노테이션을 지정하는 것이 좋다.

아이템6: 사용자 정의 오류보다 표준 오류를 선호하라

  • 가능하면 사용자 정의 오류를 정의하는 대신 표준 라이브러리 예외를 사용해야 한다.
    • -> 표준 라이브러리 예외는 잘 알려져 있으며 이를 재사용하는 것이 좋기에
    • ex. IllegalArgumentException, IllegalStateException, UnsupportedOperationException, …
  • 만약 적절한게 없을 경우 사용자 정의 오류를 사용하면 된다.

아이템7: 결과가 없을 가능성이 있는 경우 널 가능 또는 Result 반환 타입을 선호하라

  • 예외는 비정상적인 상황에서만 사용해야 한다. 이펙티브 자바에서 언급된 주된 이유는 다음과 같다.
    • 예외가 전파되는 과정은 직관적이지 않기에 코드에서 놓치기 쉽다.
    • 코틀린에서 모든 예외는 Unchekced Exception이며, 예외 처리를 강제하지 않는다. 그러므로 API를 사용할때 어떤 예외가 발생할지 알기 어렵다.
  • 오류가 예상되는 경우에는 null 또는 Result.failure를 반환하고 오류가 예상되지 않는 상황에서는 예외를 던져야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
inline fun <reified T> String.readObjectOrNull(): T? {
    // ...
    if (incorrectSign) {
        return null
    }
    // ...
    return result
}

inline fun <reified T> String.readObject(): Result<T> {
    // ...
    if (incorrectSign) {
        return Failure(JsonParsingException())
    }
    // ...
    return Success(result)
}

class JsonParsingException: Exception()

반환 타입으로 Result 사용

  • 성공 또는 실패가 될 수 있는 결과를 반환할 때는 코틀린 표준 라이브러리의 Result 클래스를 사용한다.
  • 실패에는 오류에 대한 정보를 가지고 있는 예외가 포함된다.
  • 실패시 추가 정보를 전달해야 하는 함수에서는 널 가능(nullable) 타입 대신 Result 를 사용해야 한다.
1
2
3
4
userText.readObject<Person>(
  .onSuccess { showPersonAge(it) }
  .onFailure { showError(it) }
)
  • 예외는 놓치기 쉽지만, null이나 Result 객체는 명시적으로 처리해야한다.
  • Result 클래스는 다양한 메서드가 제공된다.
    • isSuccess, isFailure, onSuccess, onFailure, getOrNull, getOrThrow, getOrDefault, getOrElse, exceptionOrNull, map, recover, fold
  • 예외를 던지는 함수를 Result를 반환하는 함수로 변환하려면 runCatching을 사용한다.
1
fun getA(): Result<T> = runCatching { getAThrowing() }

Result 타입의 사용 예제는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// ✅ 1. 기본 사용 예제 (성공 / 실패 반환)
fun parseInt(input: String): Result<Int> {
    return runCatching {
        input.toInt()  // 여기서 NumberFormatException 발생 가능
    }
}

fun main() {
    val success = parseInt("123")
    val failure = parseInt("abc")

    println(success.getOrNull())   // 123
    println(failure.getOrNull())   // null

    println(failure.exceptionOrNull()) // NumberFormatException
}

// ✅ 2. 결과 처리 (onSuccess / onFailure)
fun main() {
    parseInt("100")
        .onSuccess { value ->
            println("성공: $value")
        }
        .onFailure { exception ->
            println("실패: ${exception.message}")
        }

    parseInt("abc")
        .onSuccess { value ->
            println("성공: $value")
        }
        .onFailure { exception ->
            println("실패: ${exception.message}")
        }
}

// ✅ 3. map / recover 활용 (변환 + 에러 복구)
fun main() {
    val result = parseInt("123")
        .map { it * 2 }  // 성공 값 변환
        .recover { -1 }  // 실패 시 기본값

    println(result.getOrNull()) // 246

    val failed = parseInt("abc")
        .map { it * 2 }
        .recover { -1 }

    println(failed.getOrNull()) // -1
}

// ✅ 4. 실무 스타일 (서비스 로직)
data class User(val id: Int, val name: String)

fun findUser(id: String): Result<User> {
    return runCatching {
        val parsedId = id.toInt()
        // DB 조회라고 가정
        if (parsedId == 1) {
            User(1, "Alice")
        } else {
            throw IllegalArgumentException("User not found")
        }
    }
}

fun main() {
    val userName = findUser("1")
        .map { it.name }
        .getOrElse { "Unknown User" }

    println(userName) // Alice

    val unknown = findUser("abc")
        .map { it.name }
        .getOrElse { "Unknown User" }

    println(unknown) // Unknown User
}

반환 타입으로 null 사용

  • 함수에서 실패가 발생할때 추가 정보를 전달할 필요가 없다면, Result 대신 널 가능 타입을 사용하면 된다.

null은 적이 아닌 친구다.

  • 자바에서는 null을 적으로 취급한다.
  • 코틀린에서는 아니다. 예를 들어, 함수에서 빈 컬렉션을 반환하는 것은 사용자가 없다는 것을 의미하며, null 을 반환하면 값을 생성할 수 없으며 결과값 또한 없다는 것이다.
  • 코틀린의 타입 시스템은 널 가능 여부를 타입에 포함하여 null을 의도적으로 처리하도록 강제한다. 그러므로, 널을 피하지말고, 널을 사용하여 함수의 의도를 나타내야 한다.

아이템7 요약

  • 함수가 실패할 수 있을 떄는 예외를 던지는 대신 Result 또는 널 가능 타입 타입을 반환해야 한다.
  • 실패시 추가적인 정보를 전달해야할 경우 Result를 사용해야 한다.
  • null의 의미가 명확할때는 널 가능 타입을 사용해야 한다.
  • 널을 사용하는 것을 두려워하지 말아야 한다. 널을 받아들이고 의도를 표현하는데 사용해야 한다.

아이템7 개인 생각

  • 결과가 없을 가능성이 있을때 널 대신 Optional 타입이 더 낫지 않을까? 널이 정말 베스트한걸까?
    • -> 코틀린에서는 거의 Optional 을 사용하지 않는다. 코틀린의 ? 타입 시스템이 훨씬 간결하고 성능도 좋다.
1
2
3
4
5
6
7
8
// 코틀린 - 타입으로 null 가능 여부가 명확함 ✅
val name: String = getName()   // null 절대 불가
val name: String? = getName()  // null 가능

// 안전하게 다루는 문법도 내장
val length = name?.length          // Safe call
val length = name?.length ?: 0    // Elvis 연산자
val length = name!!.length         // Non-null 단언 (위험)

아이템9: 단위 테스트를 작성하라

  • 코드 안정성을 높이는 궁극적인 방법은 ‘단위 테스트’이다.
1
2
3
4
5
6
7
8
9
// 피보나치 수를 계산하는 fib 함수에 대한 단위테스트
@Test
fun `fib works correctly for the first 5 positions()` {
  assertEqulas(1, fib(0))
  assertEqulas(1, fib(1))
  assertEqulas(2, fib(2))
  assertEqulas(3, fib(3))
  assertEqulas(5, fib(4))
}
  • 단위 테스트 장점
    • 코드에 대한 신뢰성 증가(심리적인 부분 포함)
    • 리팩터링하는 것에 대한 두려움 없어짐(유지보수성 좋아짐)
    • 수동 테스트보다 훨씬 빠르다.
  • 단위 테스트 단점
    • 테스트 작성 시간 소요
    • 코드를 테스트 가능하도록 수정 -> 훌륭하고 안정적인 아키텍처를 강제하는 역할
    • 좋은 단위 테스트 작성은 어려움
  • 코틀린에서는 Junit을 활용할 수도 있고, 코틀린 전용 테스트 프레임워크인 Kotest를 사용할 수도 있다.

아이템10: 가독성을 목표로 설계하라

  • 프로그래밍은 주로 쓰기보다 읽는 것이 많은 부분을 차지하기에 가독성이 중요하다.

인지 부하의 감소

1
2
3
4
5
6
7
8
9
10
11
// 구현 A
if (person != null && perso.isAdult) {
    view.showPerson(person)
} else {
    view.showError()
}

// 구현 B
person?.takeIf { it.isAdult }
    ?.let(view::showPerson)
    ?: view.showError()
  • 두 구현중 A가 더 수정하기 쉽고 디버깅이 간단하다.
  • 우리 뇌는 항상 일반적인 프로그래밍 관용구보다 코틀린 고유의 관용구를 인식하는데 더 많은 시간을 필요로 한다.
    • 모든 잘 알려지지 않은 관용구는 약간의 복잡성을 추가한다.
    • 이러한 관용구는 약간의 복잡성을 추가한다.
    • 이런 관용구들을 모두 한 문장안에서 조합해 분석하려면 복잡성은 빠르게 증가한다.
    • 코틀린을 몇년 사용한 필자도 구현 A를 이해하는데 시간이 훨씬 덜 걸렸다..
  • 가독성을 높이는 일반적인 규칙은 인지 부하를 줄이는 것이다. 숙련된 개발자만을 위한 코드는 좋은 코드가 아니다.
  • 코드가 짧으면 빠르게 읽을 수 있겠지만 그보다 일반적인 구조나 익숙한 익숙한 구조인 경우 더 빠르게 읽을 수 있다.
  • 또한 구현B에서 let으로 실행되는 showPerson 이 널을 반환하면 showError 가 실행되는 구현A와의 차이가 있다.

극단적인 되지 마세요.

  • 그렇다고 let을 사용하지 말란 의도가 아니다.
  • let은 좋은 코드를 만들기 위해 다양하게 활용되는 인기 있는 관용구다. 이런 관용구는 널리 쓰이며 많은 사람들이 쉽게 인식한다.
  • 다음과 같은 경우에 let을 많이 쓴다.
    • 연산을 아규먼트 처리 이후로 이동시킬 때
    • 데코레이터를 써서 객체를 랩할 때

이 2가지를 예시로 확인한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun main() {
  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
}
  • 이 코드들은 디버그하기 어렵고 경험 적은 코틀린 개발자는 이해하기 어렵다. 따라서 비용이 발생한다.
  • 하지만 이 비용은 지불할 가치가 있어서 써도 괜찮다. 문제가 되는 경우는 비용을 지불할 만한 가치가 없는 코드에 비용을 지불하는 경우(정당한 이유 없이 복잡성을 추가할 때)다.
  • 어떤 것이 비용을 지불할 만한 코드인지 아닌지는 항상 논란이 있을 수 있다. 균형을 맞추는 게 중요하다.
  • 일단 어떤 구조들이 어떤 복잡성을 가져오는지 등을 파악하는 게 좋다. 또한 두 구조를 조합해서 사용하면 단순하게 개별적인 복잡성의 합보다 훨씬 커진다는 걸 기억해야 한다.

컨벤션

  • 사람마다 가독성이 무엇을 의미하는지에 대한 관점이 다르다.
  • 그럼에도 몇 가지 컨벤션은 이해하고 기억해야 한다. 뒤 아이템에서 소개할 예정이다.

아이템10 개인 생각

아이템12: 가독성을 높이려면 연산자를 사용하라

  • 큰 숫자(BigDecimal, BigInteger)와 시간 및 기간을 나타내는 객체(Instant, ZonedDateTime, LocalDate, Duration 등)과 같은 타입을 처리하고 비교할때 연산자를 사용하면 가독성이 좋아진다.
  • 요소가 컬렉션에 포함되어 있는지 확인할때도 마찬가지다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ex1 - 큰 숫자
val netPrice = BigDecimal("10")
val tax = BigDecimal("0.23")
val currentBalance = BigDecimal("20")
val newBalance = currentBalance.minus(netPrice.times(tax)) // 함수 사용
val newBalance2 = currentBalance - netPrice * tax // 연산자 사용 - 가독성 좋음
println(newBalance) // 17.70

// ex2 - 시간 및 기간
val now = ZonedDateTime.now()
val duration = Duration.ofDays(1)
val sameTimeTomorrow = now.plus(duration) // 함수 사용
val sameTimeTomorrow2 = now + duration // 연산자 사용 - 가독성 좋음

// ex3 - 컬렉션에 요소가 포함되어있는지 확인
val SUPPORTED_TAGS = setof("ADMIN", "TRAINER", "ATTENDEE")
val tag = "ATANDEE"

println(SUPPORTED_TAGS.contains(tag)) // 함수 사용
println(tag in SUPPORTED_TAGS) // 연산자 사용

아이템12 개인 생각

  • 컬렉션의 경우 강조하고 싶은 것을 앞에 두는 방식이 정말 가독성을 더 높일 수 있는 적절한 방안이 맞을까..?

아이템13: 타입 명시를 고려하라

  • 코틀린의 타입 추론 시스템으로 인해 타입을 생략하는 것이 가능하다.
1
2
3
val num = 10
val name = "Marcin"
val ids = listOf(12, 112, 554, 997)
  • 그러나 타입이 명확하지 않을때는 이를 아래와 같이 남용해서는 안된다.
    • 아래처럼 코드 구현부를 이동하여 매번 타입을 확인하는 것은 코드 가독성에 좋지 않다.
    • 중요한 정보를 숨겨선 안된다.
    • 타입은 중요한 정보이기에, 명확하지 않을 때는 이를 명시해야 한다.
1
val data = getSomeData()
  • 공개 API 를 설계할때는 노출되는 타입을 항상 분명하게 명시해야 한다.(p. 89 참고)

라이브러리 작성자를 위한 명시적 API 모드

  • 코틀린 1.4에서는 라이브러리 작성자를 위한 명시적 API 모드(explicit API mode)가 도입되었다.
  • 이를 활성화하면 공개 API 를 대상으로 타입과 가시성 한정자(visibility modifier)를 명시하도록 가에자한다.
  • 이는 코드의 가독성을 향상 시키는 좋은 방법이다.
1
2
3
4
5
6
7
8
9
// build.gradle(.kts)

koltin {
  // 엄격 모드용
  explicitApi()

  // 경고 모드용
  explicitApiWarning()
}

아이템14: 리시버를 명시적으로 참조하라

  • 무언가를 명시적으로 표현하기 위해 더 구체적인걸 선택할 때가 있는데, 이는 함수나 프로퍼티가 로컬 또는 최상위 변수가 아닌 리시버에서 가져온 것임을 강조하려고 할 때 그렇다.
  • 일례로 연메서드와 연관된 클래스를 참조하는 this, 확장 메서드에서 this가 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class User: Person() {
  private var beersDrunk: Int = 0
    fun drinkBeers(num: Int) {
      // ...
      this.beersDrunk += num
      // ...
  }
}

fun <T : Comparable<T>> List<T>.quickSort(): List<T> {
  if (this.size < 2) return this
  val pivot = this.first()
  val (smaller, bigger) = this.drop(1).partition { it < pivot }
  return smaller.quickSort() + pivot + bigger.quickSort()
}

여러 개의 리시버

  • 둘 이상의 리시버를 사용하고 스코프가 중첩되어 있을때 명시적 리시버 사용이 특별히 유용할 수 있다.(p93~94 예제 참고)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Node(val name: String) {
  fun makeChild(childName: String) = create("$name.$childName")
                                      .apply{ print("Created ${this.name}") } // 컴파일 오류
  fun create(name: String): Node? = Node(name)
}

fun main() {
  val node = Node("parent")
  node.makeChild("child")
}

class Node(val name: String) {
  fun makeChild(childName: String) = create("$name.$childName")
                                      .apply{ print("Created ${this?.name}") }

  fun create(name: String): Node? = Node(name)
}

fun main() {
  val node = Node("parent")
  node.makeChild("child")
}
  • 여기서 also 를 사용한 뒤 매개변수의 name을 호출하면 컴파일 에러가 발생하지 않고, 함수의 리시버가 강제되어, 명시적으로 리시버를 참조하게 된다.
  • 그러므로 추가적인 연산 또는 널 가능한 값을 다룰 때는 also와 let이 훨씬 더 나은 선택이다.
  • 리시버가 불분명할 경우 이를 피하거나 리시버를 명시적으로 사용하여 명확히 해야한다. 레이블 없이 리시버를 사용할 경우 리시버는 가장 가까운 리시버를 의미한다.
  • 스코프 외부의 리비서르 ㄹ사용하려면 레이블을 사용해야 하며, 레이블은 리시버처럼 명시적으로 지정해야 한다. 다음은 apply와 명시적 리시버를 모두 사용하는 예제이다.
  • 아래와 같이 하면, 어떤 리시버를 의도하는지 명확하게되어 오류를 방지할 수 있을 뿐만 아니라 가독성도 높일 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
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")
}

DSL 마커

  • 중첩된 스코프가 많아 서로 다른 리시버가 혼재되어 잇음에도 리시버를 명시적으로 지정할 필요가 없는 경우가 있다. 바로 코틀린 DSL이 그런 경우다.(p95~97 참고)

Reference

아이템15: 프로퍼티는 동작이 아닌 상태를 나타내야 한다.

  • 코틀린 프로퍼티는 자바의 필드와 비슷해 보이지만, 사실은 다른 개념을 나타낸다.
1
2
var name: String? = null
String name = null
  • 데이터 저장이라는 동일한 용도로 사용될 수 있기는 하지만, 프로퍼티에는 훨씬 더 많은 기능이 있음을 기억해야 한다. 그 중 첫째는 항상 사용자 정의 게터와 세터를 가질 수 있다는 점이다.
1
2
3
4
5
6
7
var name: String? = null
  get() = field?.toUpperCase()
  set(value) {
    if (!value.isNullOrBlack()) {
      field = value
    }
  }
  • 코틀린의 모든 프로퍼티는 디폴트로 캡슐화되어 있다.
  • 프로퍼티에는 필드가 필요하지 않다. 오히려 개념적으로 접근자(val의 경우 게터, var의 경우 게터와 세터)를 나타낸다. 그렇기 때문에 인터페이스로 정의할 수 있다.
1
2
3
4
// 아래 인터페이스에는 게터가 있다는 것을 의미한다.
interface Person {
  val name: String
}
  • 원칙적으로 프로퍼티는 상태를 나타내거나 설정하는데 사용해야 하며, 다른 로직을 포함하지 않아야 한다.
  • ‘이 프로퍼티를 함수로 정의한다면 접두어에 get이나 set을 붙일것인가’ 를 기준으로 프로퍼티가 되어야 하는지 함수가 되어야하는지 판단할 수 있다.
  • get/set을 붙이지 않을거라면 프로퍼티가 되어선 안된다.
  • 프로퍼티 대신 함수를 사용해야 하는 가장 일반적인 경우는 다음과 같다.
    • 1)연산의 비용이 크거나 복잡도가 O(1)보다 높은 경우: 사용자는 프로퍼티 사용 비용이 높을거라 예상하지 않음
    • 2)연산이 예외를 던지거나 비즈니스 로직을 포함하는 경우: 사용자는 그럴거라 예상하지 않음
    • 3)호출할때마다 다른 결과가 나올 수 있는 경우
    • 4)Int.toDouble() 같은 변환이 있는 경우: 변환은 메서드로 구현하는 것이 관례이다.
    • 5)프로퍼티 상태가 변경되는 경우: 일반적으로 게터를 사용할때 프로퍼티 상태 변경이 일어날거라고 예상하지 않음
  • 아래와 같이 노드들의 합을 계산하는 것은 노드들을 순회하는 과정이 필요하기에 선형 복잡도를 갖는다. 따라서 이는 프로퍼티가 되어서는 안되며, 표준 라이브러리에서도 이를 함수로 정의한다.
1
2
3
4
5
6
7
8
9
10
11
12
// 프로퍼티로 정의
val Tree<Int>.sum: Int
  get() = when (this) {
    is Leaf -> value
    is Node -> left.sum + right.sum
  }

// 함수로 정의
fun Tree<Int>.sum(): Int = when (this) {
    is Leaf -> value
    is Node -> left.sum() + right.sum()
}
  • 코틀린에서는 상태를 가져오고 설정하는데 프로퍼티를 사용한다. 이때 특별한 이유가 없는 한 함수를 사용하지 말아야 한다.
  • 프로퍼티를 사용해서 상태를 나타내고 설정하며, 나중에 수정해야 할 때는 사용자 저으이 게터와 세터를 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 아래처럼 간단한 상태를 가져오고 설정할때 함수를 사용해선 안됨
class UserIncorrect {
    private var name: String = ""

    fun getName() = name
    fun setName(name: String) {
        this.name = name
    }
}

// 아래와 같이 프로퍼티로 정의
class UserCorrect {
    var name: String = ""
}

note: 프로퍼티는 상태를 나타내고 설정하는 반면, 함수는 동작을 나타낸다.

2부: 코드 설계

아이템28: 외부 API를 래핑하는 것을 고려하라

  • 장점은 다음과 같다.
    • 외부 API가 변경되더라도 래퍼(wrapper)내에서 사용중인 곳만 변경하면 됨
    • 프로젝트 스타일과 로직에 맞게 API 조정 가능
    • 라이브러리 교체 쉬움
    • 필요하다면 사용한 외부 API와 다르게 동작하게 만들 수 있음
  • 단점은 다음과 같다.
    • 모든 래퍼를 정의해야함.(래핑 공수 필요)
    • 개발자가 내부 API를 배워야함
    • 내부 API 작동 방식을 가르치는 과정이 없음
1
2
3
4
5
6
7
8
9
10
11
// 외부 API 직접 사용
Picasso.get()
  .load(url)
  .into(imageView)

// 래핑한 추상화 계층
fun ImageView.loadImage(url: String) {
  Picasso.get()
    .load(url)
    .into(this)
}

단점보다 장점이 더 많으므로 항상 기억하고 있는 것이 좋다. 이에 대한 예제로 아래와 같이 활용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// ===== 1. 추상화 인터페이스 =====
interface FileStorageClient {
  fun upload(key: String, data: ByteArray, contentType: String = "application/octet-stream"): String
  fun download(key: String): ByteArray
  fun delete(key: String)
  fun exists(key: String): Boolean
  fun getPublicUrl(key: String): String
}

// ===== 2. AWS S3 래퍼 구현체 =====
// ✨ 다른 오브젝트 스토리지로 변경이 필요할 경우 새로운 구현체만 구현해주면됨
class S3FileStorageClient(
  private val bucketName: String,
  region: String = "ap-northeast-2"
) : FileStorageClient {

  // AWS S3Client (외부 라이브러리)
  private val s3Client: S3Client = S3Client.builder()
      .region(Region.of(region))
      .build()

  override fun upload(key: String, data: ByteArray, contentType: String): String {
      val request = PutObjectRequest.builder()
          .bucket(bucketName)
          .key(key)
          .contentType(contentType)
          .build()

      s3Client.putObject(request, RequestBody.fromBytes(data))

      return key
  }

  override fun download(key: String): ByteArray {
      val request = GetObjectRequest.builder()
          .bucket(bucketName)
          .key(key)
          .build()

      return s3Client.getObject(request).readAllBytes()
  }
  
  // ...
}

// ===== 3. 비즈니스 로직 - FileStorageClient에만 의존 =====
class UserProfileService(
  private val storage: FileStorageClient   // AWS든 InMemory든 상관없음
) {
  fun uploadProfileImage(userId: Long, imageBytes: ByteArray): String {
      val key = "profiles/$userId/avatar.jpg"
      storage.upload(key, imageBytes, contentType = "image/jpeg")
      return storage.getPublicUrl(key)
  }

  fun getProfileImageUrl(userId: Long): String {
      val key = "profiles/$userId/avatar.jpg"
      check(storage.exists(key)) { "Profile image not found for user $userId" }
      return storage.getPublicUrl(key)
  }
}

아이템32: 보조 생성자 대신 팩토리 함수를 고려하라

  • 복잡한 클래스는 객체를 생성하는 다양한 방법을 필요로 하며, 함수(linkedListOf, toLinkedList, copy)로 정의하는 것이 더 좋다.
  • 그 이유는 다음과 같다.
    • 함수에는 이름이 있다. 객체가 생성되는 방법과 인수에 대해 설명한다.(ex. ArrayList(3)은 의미가 모호하지만 List.withCapacity(3)은 의미를 명확하게 나타낸다)
    • 함수는 반환타입의 모든 하위타입 객체를 반환할 수 있다. 즉, 반환 타입을 인터페이스로 설정하여 인터페이스만 노출하고 실제 객체를 숨길 수 있다.(listOf의 반환 타입은 List 인터페이스)
    • 함수를 사용하면 캐싱처럼 객체 생성을 최적화할 수 있고, 싱글톤 패턴으로 재사용할 수도 있다.
    • 아직 존재하지 않을 수도 있는 객체를 제공할 수도 있다.
    • 객체 외부에서 팩토리 함수를 정의하면 객체의 가시성을 제어할 수 있다. 예를 들면, 동일한 파일(private 한정자) 또는 동일한 모듈(internal 한정자)에서만 접근할 수 잇는 최상위 팩토리 함수를 만들 수 있다.
    • 생성자는 슈퍼클래스의 생성자나 기본 생성자를 즉시 호출해야 하지만, 팩토리 함수를 사용하면 이를 뒤로 미룰 수 있다.
  • 객체를 사용하는데 사용되는 함수를 팩토리 함수라 한다.
  • 코틀린 공식 라이브러리를 찾아보면 보조 생성자가 잇는 경우는 거의 없고, 실제로 모든 클래스는 생성자가 단 한 개만 존재하며 주로 다양한 종류의 팩토리 함수를 통해 객체를 생성한다.(ex. List의 listOf, toList, List, 가짜생성자)
  • 따라서 몇몇 중요한 팩토리 함수들과 각각의 특징에 대해 알아보자.
    • 1)동반 객체 팩토리 함수
    • 2)최상위 수준 팩토리 함수
    • 3)빌더
    • 4)변환 메서드
    • 5)가짜 생성자
    • 6)팩토리 클래스의 메서드

동반 객체 팩토리 함수

  • 자바의 정적 팩터리 메서드와 유사한 방식으로 가장 일반적인 방법 중 하나이다.
1
2
3
4
5
6
7
8
9
10
11
12
class LinkedList<T>(
 	val head : T,
    val tail : LinkedList<T>?
){
 	companion object{
     fun <T> of(vararg elements : T) : LinkedList<T>? {
      /*...*/
     }
    }
 }
 
val list = LinkedList.of(1, 2)
  • 동반 객체의 확장 함수로 정의할 수 도 있다.
1
2
3
4
5
6
7
8
9
interface Tool {
  copanion object { ... }
}

fun Tool.Companion.createBigTool(...): Tool {
  // ...
}

val tool = Tool.createBigTool()
  • 동반 객체 팩토리 함수에는 몇 가지 자바에서 오랫동안 사용된 명명 규칙이 있다.
    • from: 단일 인수 사용(Date.from(instant))
    • of: 같은 타입의 인스턴스 여러 개를 인수로 받고, 인수로 받은 인스턴스의 컬렉션을 반환하는 집계 함수(val FaceCards: Set<Rank> = EnumSet.of(JACK, QUEEN, KING))
    • valueOf: from 또는 of와 동일한 기능을 하지만 좀 더 긴 이름(BigInteger.valueOf(Integer.MAX_VALUE))
    • instance 또는 getInstance: 싱글톤인 객체 인스턴스를 가져오기 위해
    • createInstance 또는 newInstance: 새로운 인스턴스를 생성하여 반환
    • get{Type}: getInstance와 비슷하지만 팩토리 함수가 다른 클래스에 있는 경우(val fs: FileStore = Files.getFileStore(path))
    • new{Type}: newInstance와 비슷하지만 팩토리 함수가 다른 클래스에 있는 경우(val br: BufferedReader = Files.newBufferedReader(path))
  • 정적 요소를 대신하여 동반 객체를 사용하는 경우가 많지만, 동반 객체에는 더 많은 기능이 있다.
    • 인터페이스를 구현할 수 도 있고 클래스를 확장할 수도 있다.
    • 예를 들어, 코틀린의 코루틴 라이브러리에서 코루틴 컨텍스트의 거의 모든 동반 객체가 CoroutineContext.Key 인퍼테이스를 구현한다. CoroutineContext.Key는 컨텍스트를 식별하는 역할을 한다.
    • ‘정적’ 요소를 상속할 수 없어 불편하다는 의견이 많아 코틀린에서는 동반 객체로 만든 것이다.
  • 아래 코드처럼 동반 객체가 상속할 수 있는 추상 빌더 클래스를 만들 수도 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 아래와 같은 추상 동반 객체 팩토리는 값을 가질 수 있으므로, 캐싱을 구현할 수도 있고 테스트 용도로 가짜 객체를 만들 수도 있음
abstract class ActivityFactor{
	abstract fun getIntent(context: Cotnext) : Intent
	
  fun start(context : Context)[
    val intent = getIntent(context)
      context.startActivity(intent)
  }
}

class MainActivity : AppCompatActivity{

  companion object : ActivityFactory() {
    overide fun getIntext(cotnext: Context) Intent =
      Intent(context ,MainActivity::class.java
    }
}

// 사용법
val intent = MainActivity.getIntent(context)
MainActivity.start(context)

최상위 수준 팩토리 함수

  • 최상위 수준에서 정의한 팩토리 함수를 사용하는 것이다.(ex. listOf, setOf, mapOf)
  • List나 Map같이 크기가 작고 생성이 빈번한 객체를 만들 때 간단하고 읽기 쉽기에 가장 좋은 방법이라 할 수 있다.

빌더

  • 최상위 수준 팩토리 함수로, 아주 중요한 역할을 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
val list = buildList {
  add(1)
  add(2)
  add(3)
}
println(list) // [1, 2, 3]

val s = sequence {
  yield("A")
  yield("B")
  yield("C")
}
println(s.toList()) // [A, B, C]

변환 메서드

  • 타입을 변환하여 객체 생성시 사용한다.(ex. List -> Sequence, Int -> Double,)
  • to{Type} 또는 as{Type}으로 명명된다.
    • to{Type}: 다른 타입을 가진 새 객체를 생성한다는 의미
    • as{Type}: 새로 생성된 객체가 래퍼이거나 원본 객체의 추출된 부분임을 의미
1
2
val sequence: Sequence = list.asSequence()
val double: Double = i.toDouble()
  • 타입 간 변환을 하기 위해 자체 변환 함수를 정의하고 한다.(ex. UserJson과 User간의 변환이 필요한 경우)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class User(
  val id: UserId,
  val name: String,
  val surname: String,
  val age: Int,
  val tokens: List<Token>
)

class UserJson(
  val id: UserId,
  val name: String,
  val surname: String,
  val age: Int,
  val tokens: List<Token>
)

fun User.toUserJson() = UserJson(
  id = this.id,
  name = this.name,
  surname = this.surname,
  age = this.age,
  tokens = this.token,
)

fun UserJson.toUser() = User(
  id = this.id,
  name = this.name,
  surname = this.surname,
  age = this.age,
  tokens = this.token,
)

복사 메서드

  • 복사 생성자를 정의하는 대신 복사 메서드를 정의하라(그대로 복사하고 싶으면 copy, 약간의 변경사항을 추가하고 싶으면 with 로 시작하여 변경하는 프로퍼티를 명시)
1
2
val user2 = user.copy()
val user3 = user.withSurname(newSurname)
  • 데이터 클래스는 기본 생성자 프로퍼티를 수정할 수 있는 복사 메서드를 지원한다.(copy 함수)

가짜 생성자

  • 코틀린의 생성자는 최상위 수준 함수와 같은 형태로 사용된다.
1
2
class A
val a = A()
  • 아래와 같은 최상위 수준 함수는 생성자처럼 보이고 생성자처럼 작동한다. 하지만 팩토리 함수와 같은 모든 장점을 갖는다.
    • 이게 가능한건 함수는 대문자로 시작할 수 있기 때문이다..
1
2
3
4
5
6
7
8
9
10
11
12
13
public inline fun <T> List(
    size: Int,
    init: (index: Int) -> T
): List<T> = MutableList(size, init)

public inline fun <T> MutableList(
  size: Int,
  init: (index: Int) -> T
): MutableList<T> {
  val list = ArrayLilst<T>(size)
  repeat(size) { index -> list.add(init(index))}
  return list
}
  • 많은 개발자들이 이를 최상위 함수인지 잘 모르기에, 이를 가짜 생성자라 불린다.
  • 생성자 대신 가짜 생성자를 만드는 이유는 다음과 같다.
    • 1)인터페이스를 위한 ‘생성자’를 만들고 싶을 때
    • 2)구체화된 타입 매개변수를 가지기 위해
  • 생성자처럼 동작해야 한다. 캐싱, 널 가능 타입 반환, 서브 클래스를 반환하고 싶다면 팩토리 함수를 사용하는 편이 낫다.
  • 호출 연산자와 함께 동반객체를 사용하면 비슷한 결과를 얻을 수 있다.

팩토리 클래스의 메서드

  • 팩토리 클래스의 생성 패턴은 다양하다.(ex. 추상 팩토리, 프로토타입)
  • 팩토리 클래스는 상태가 있기에 팩토리 함수에는 없는 기능을 추가할 수 있다.
1
2
3
4
5
6
7
8
9
10
data class Student(
    val id: Int,
    val name: String,
    val surname: String
)

class StudentsFactory {
    var nextId = 0
    fun next(name: String, surname: String) = Student(nextId++. name, surname)
}
  • 캐싱 및 이전 생성된 객체의 복제와 같이 객체 생성을 최적화하는데 사용할 수 잇는 프로퍼티가 있을 수 있다.(객체 생성 속도를 높이기 위해)
  • 실제로 객체를 생성할때 여러 서비스나 저장소가 필요하다면 팩토리 클래스로 추출하는 경우가 일반적이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class UserFactory(
  private val uuidProvider: UuidProvider,
  private val timeProvider: TimeProvider,
  private val tokenProvider: TokenProvider,
) {
  fun create(newUserData: NewUserData): User {
    val id = uuidProvider.next()
    return User(
      id = id,
      creationTime = timeProvider.now(),
      token = tokenServicwe.generateToken(id),
      name = newUserData.name,
      surname = newUserData.surname,
      // ...
    )
  }
}

아이템32 요약

코틀린은 팩토리 함수를 명시하는 다양한 방법을 제공하며, 각각 다른 목적을 가지고 있기에 적절한 방법을 선택해서 사용하자.

1)동반 객체 팩토리 함수 → 클래스 내부에서 “정적 생성 역할” 수행 (캡슐화 + 명확한 생성 의도 표현)

2)최상위 수준 팩토리 함수 → 간단하고 자주 생성되는 객체를 “짧고 읽기 쉽게” 만들기 위함

3)빌더 (buildList, sequence 등) → 복잡한 객체를 “단계적으로 구성”하기 위함 (가독성 + DSL 스타일)

4)변환 메서드 (toX / asX) → 기존 객체를 “다른 타입으로 변환”하여 생성하기 위함

5)복사 메서드 (copy / withX) → 기존 객체를 기반으로 “일부만 변경한 새 객체” 생성

6)가짜 생성자 (Top-level 함수 + 대문자) → 생성자처럼 보이면서도 “팩토리 함수의 유연성” 확보 (인터페이스 생성 등)

7)팩토리 클래스 → 상태/의존성을 활용해 “복잡한 생성 로직 관리 및 최적화”

아이템32 개인생각

  • PhoneNumber 기준으로 모바일단말(MobileTerminal) 모델 객체를 팩토리 클래스로 생성하는 예제
  • 코틀린으로 StringUtils 유틸리티 클래스 구현 예제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 1. object 키워드로 싱글톤 유틸리티 클래스
object StringUtils {

  fun capitalize(str: String): String =
      str.replaceFirstChar { it.uppercaseChar() }

  fun reverse(str: String): String =
      str.reversed()

  fun isPalindrome(str: String): Boolean {
      val cleaned = str.lowercase().replace("\\s".toRegex(), "")
      return cleaned == cleaned.reversed()
  }

  fun countWords(str: String): Int =
      if (str.isBlank()) 0 else str.trim().split("\\s+".toRegex()).size

  fun truncate(str: String, maxLength: Int): String =
      if (str.length <= maxLength) str else "${str.take(maxLength)}..."

  fun camelToSnake(str: String): String =
      str.replace("([a-z])([A-Z])".toRegex(), "$1_$2").lowercase()
}

// 사용 예시
fun main() {
    println(StringUtils.capitalize("hello world"))        // Hello world
    println(StringUtils.reverse("Kotlin"))                // niltoK
    println(StringUtils.isPalindrome("racecar"))          // true
    println(StringUtils.countWords("Hello Kotlin World")) // 3
    println(StringUtils.truncate("Hello, Kotlin!", 5))    // Hello...
    println(StringUtils.camelToSnake("camelCaseString"))  // camel_case_string
}

// 2. 코틀린스러운 확장 함수 방식으로도 가능
fun String.capitalize2(): String =
    replaceFirstChar { it.uppercaseChar() }

fun String.isPalindrome(): Boolean {
    val cleaned = lowercase().replace("\\s".toRegex(), "")
    return cleaned == cleaned.reversed()
}

fun String.countWords(): Int =
    if (isBlank()) 0 else trim().split("\\s+".toRegex()).size

fun String.truncate(maxLength: Int): String =
    if (length <= maxLength) this else "${take(maxLength)}..."

// 사용 예시
fun main() {
    println("hello world".capitalize2())   // Hello world
    println("racecar".isPalindrome())      // true
    println("Hello Kotlin World".countWords()) // 3
    println("Hello, Kotlin!".truncate(5)) // Hello...
}

아이템33: 이름 있는 선택적 인수를 갖는 기본 생성자 사용을 고려하라

  • 코틀린에서는 기본 생성자를 선택하는게 대체로 좋다.
  • 자바에서의 두 가지 객체 생성 패턴은 다음과 같은 이유로 대체 가능하다.
    • 점층적 생성자 패턴
    • 빌더 패턴

점층적 생성자 패턴

  • 생성자를 다양한 인수를 가지는 조합으로 여러 개 생성하는 패턴이다.
  • 이는 생성자의 갯수가 무한히 늘어나 코드가 방대해지는 단점이 있는데, 코틀린의 디폴트 인수(default argument)를 사용하여 생성자 정의 없이도 인수의 갯수를 조절할 수 있다.
  • 디폴트 인수가 더 강력한 이유는 다음과 같다.
    • 사용하고 싶은 디폴트 인수만 지정하여 매개변수의 하위집합을 정의할 수 있다.
    • 인수를 어떤 순서로든 제공할 수 있다.
    • 인수의 이름을 명시적으로 지정할 수 있어, 각 값의 의미를 명확하게 할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Pizza {
  val size: String
  val cheese: Int
  val olives: Int
  val bacon: Int

  constructor(
    size: String,
    cheese: Int,
    olives: Int,
    bacon: Int
  ) {
    // ...
  }

  constructor(
    size: String,
    cheese: Int,
    olives: Int
  ) {
    // ...
  }
}

== 디폴트 인수 사용 ==
class Pizza (
  val size: String,
  val cheese: Int = 0,
  val olives: Int = 0,
  val bacon: Int = 0
)

val myFavorite = Pizze("L", olives = 3)
val myFavorite = Pizze("L", olives = 3, cheese = 1)

빌더 패턴

  • 자바 빌더 패턴의 아래와 같은 장점은 코틀린 기본 생성자의 이름 있는 인수와 디폴트 인수로 충분히 충족시킬 수 있다.
    • 1)매개변수 이름 지정
    • 2)매개변수를 원하는 순서로 지정
    • 3)기본값 지정
  • DSL 빌더는 더 유연하고 깔끔한 표기법을 제공한다.
1
2
3
4
5
6
val vaillagePizze = Pizza(
  size = "L",
  cheese = 1,
  olives = 2,
  bacon = 3,
)
  • 아래와 같이 빌더 패턴이 더 유용한 경우가 있는데, 이때 DSL 빌더를 사용하자.
    • 1)하나의 이름에 대해 필요한 여러 값을 전달할 때(p221 참고)
    • 2)하나의 객체에 빌더 함수를 연속해서 사용하는 것(p221 참고)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
== DSL 빌더 ==
val dialog = context.alert(R.string.fire_missiles) {
  positiveButton(R.string.fire) {
    // 미사일 발사!
  }
  negativeButton {
    // 사용자가 대화사장에서 취소를 누른 경우
  }
}

val route = router {
  "/home" directsTo ::showHome
  "/users" directsTo ::showUsers
}
  • 고전적인 빌더의 또 다른 이점은 일부 프로퍼티만 설정한 상태에서 다른 객체에 전달될 수 있어 팩토리로 사용할 수 있다는 것이다.
  • 코틀린에서는 이에 더 적합한 대안이 있다. 기본 객체를 만들고 copy를 사용해서 프로퍼티를 사용자화하거나 선택적 매개변수가 있는 함수를 사용하여 해당 클래스를 만드는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
== 일부 프로퍼티만 설정한 상태에서 다른 객체에 전달하는 팩토리 방식 ==
fun Context.makeDefaultDialogBuilder() =
  AlertDialog.Builder(this)
             .setIcon(R.drawable.ic_dialog)
             .setTitle(R.string.dialog_title)
             .setOnCancleListener { it.cancel() }

== 코틀린의 선택적 매개변수가 있는 함수를 사용하는 대안 ==
data class DialogConfig(
  val icon: Int,
  val title: Int,
  val onCancelListener: (() -> Unit)?,
  // ...
)

val defaultDialogConfig = DialogConfig(
  icon = R.drawable.ic_dialog,
  title = R.string.dialog_title,
  onCancelListener = { it.cancel() },
  // ...
)

// 또는
fun defaultDialogConfig(
  val icon: Int = R.drawable.ic_dialog,
  val title: Int = R.string.dialog_title,
  val onCancelListener: (() -> Unit)? = { it.cancel() }
) = DialogConfig(icon, title, onCancelListener, /*..*/)
  • 고전적인 빌더 패턴이 코틀린에서 최선의 선택지인 경우는 거의 없다.

아이템33 요약

  • 코틀린에서는 기본 생성자를 사용해 객체를 생성하는 것이 가장 좋은 방법이다.
  • 점층적 생성자 패턴 대신 디폴트 인수를 사용하자.
  • 빌더 패턴 대신 이름 있는 인수를 사용하는 기본 생성자만으로 충분하다.
  • 더 복잡한 객체를 생성할 때는 DSL 빌더를 사용해 정의하는 것이 좋다.

DSL 빌더 패턴

  • 단일 스코프는 @DslMarker 가 불필요하고, 중첩 스코프는 필요하다.
  • @DSLMarker의 목적은 “스코프 탐색을 제한” 하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
// 1. 단일 스코프 DSL — @DslMarker 불필요
data class Product(
    val name: String,
    val price: Int,
    val category: String,
    val tags: List<String>
)

class ProductBuilder {
    var name: String = ""
    var price: Int = 0
    var category: String = ""
    val tags = mutableListOf<String>()

    fun tag(vararg values: String) { tags.addAll(values) }
    fun build() = Product(name, price, category, tags)
}

fun product(block: ProductBuilder.() -> Unit): Product =
    ProductBuilder().apply(block).build()

val product = product {
    name = "코틀린 완벽 가이드"
    price = 35000
    category = "도서"
    tag("개발", "코틀린", "베스트셀러")
}

// 2. 중첩 스코프 DSL — @DslMarker 필요
@DslMarker
annotation class ProductDsl

// 데이터 클래스
data class Discount(val rate: Int, val condition: String)
data class Shipping(val fee: Int, val estimatedDays: Int)
data class Product(
    val name: String,
    val price: Int,
    val discount: Discount,
    val shipping: Shipping
)

// 중첩 빌더
@ProductDsl
class DiscountBuilder {
    var rate: Int = 0
    var condition: String = "상시"
    fun build() = Discount(rate, condition)
}

@ProductDsl
class ShippingBuilder {
    var fee: Int = 3000
    var estimatedDays: Int = 3
    fun build() = Shipping(fee, estimatedDays)
}

@ProductDsl
class ProductBuilder {
    var name: String = ""
    var price: Int = 0
    private var discount = DiscountBuilder()
    private var shipping = ShippingBuilder()

    fun discount(block: DiscountBuilder.() -> Unit) {
        discount = DiscountBuilder().apply(block)
    }

    fun shipping(block: ShippingBuilder.() -> Unit) {
        shipping = ShippingBuilder().apply(block)
    }

    fun build() = Product(name, price, discount.build(), shipping.build())
}

fun product(block: ProductBuilder.() -> Unit): Product =
    ProductBuilder().apply(block).build()

// 클라이언트 코드
Product(name="무선 키보드", 
        price=89000,
        discount=Discount(rate=10, condition="회원 전용"),
        shipping=Shipping(fee=0, estimatedDays=2))

val product = product {
    name = "무선 키보드"
    price = 89000

    discount {
        rate = 10
        condition = "회원 전용"
        // name = "오타"  ← 컴파일 에러! ProductBuilder.name 접근 차단
    }

    shipping {
        fee = 0
        estimatedDays = 2
    }
}

println(product)
  • 중첩 스코프가 있을 경우 @DSLMarker 를 사용해야하는 이유는 다음과 같다.
1
2
3
4
5
6
7
8
9
// 핵심 원리 — 람다는 바깥 스코프를 그대로 캡처하여 참조 가능하다.
// discount { } 블록은 DiscountBuilder를 this로 갖지만, 바깥 product { } 블록의 this(= ProductBuilder)도 동시에 접근 가능한 상태이다.
val product = product {           // this = ProductBuilder
    name = "무선 키보드"
    discount {                    // this = DiscountBuilder, 하지만 바깥 ProductBuilder도 스코프에 살아있음
        rate = 10
        name = "???"              // DiscountBuilder.name? → 없음, ProductBuilder.name? → 있음! ← 이걸 건드림
    }
}
  • 참고로 @DSLMarker 어노테이션은 다음과 같이 구현되어 있기에, 새 어노테이션을 만들때만 붙일 수 있다.
1
2
3
4
@Target(AnnotationTarget.ANNOTATION_CLASS)  // ← 어노테이션 클래스에만 붙일 수 있음
@Retention(AnnotationRetention.BINARY)
@MustBeDocumented
annotation class DslMarker

아이템34: 복잡한 객체 생성을 위해 DSL 정의를 고려하라

  • DSL 을 사용하면 복잡하고 계층적인 데이터 구조를 만드는 것이 더 쉽다.
  • 하지만 익숙치 않은 사람들에게 혼란을 줄 수 있으니, 아래와 같은 경우에 유용하게 사용하면 좋다.
    • 1)복잡한 데이터 구조
    • 2)계층구조
    • 3)엄청난 양의 데이터
  • DSL 은 보일러플레이트 코드를 제거할 수 있는 방법 중 하나이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// HTML DSL
body {
    div {
        a("https://kotlinlang.org") {
            target = ATarget.blank + "Main site"
        }
    }
    +"some content"
}

// Andriod View DSL (Anko 라이브러리)
verticalLayout {
    val name = editText()
    button("Say Hello") {
        onClick { toast("Hello, ${name.text}!")}
    }
}

// Test DSL
class MyTests: StringSpec({
    "반환되는 길이는 String의 크기이어야 합니다." {
        "test string".length shouldBe 5
    }
    "startsWith 함수는 prefix를 반환해야 합니다." {
        "world" should startWith("wor")
    }
})

// Gradle DSL
plugins {
    'java-library'
}

dependencies {
    api("junit:junit:4.12")
    implementation("junit:junit:4.12")
    testImplementation("junit:junit:4.12")
}

configurations {
    implementation {
        resolutionStrategy.failOnVersionConflict()
    }
}

자신만의 DSL 정의하기

  • 자세한 내용은 p228 ~ 235 참고
  • 코틀린에서 ‘리시버가 있는 함수 타입’은 “특정 객체를 this처럼 암묵적으로 사용할 수 있는 함수 타입”이라고 보면 된다.
  • A.(B) -> C에서 A는 리시버 (this), B는 파라미터, C는 반환 타입을 의미한다.(즉, “A 객체 안에서 실행되는 함수”)_
1
2
3
4
5
6
val plus: Int.(Int) -> Int = { other ->
    this + other
}

val result = 10.plus(5)
println(result) // 15
  • 아래는 간단한 DSL을 직접 정의한 예시이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Dialog {
  var title: String = ""
  var text: String = ""
  fun show() { /*..*/}
}

// 리시버가 있는 람다 함수를 사용하면 this가 dialog를 가리키게 되어, dialog를 반복해서 적을 필요 없이 생략할 수 있음
fun main() {
  val dialog = Dialog()
  val init: Dialog.() -> Unit = {
    title = "my dialog"
    text = "some text"
  }
  init.invoke(dialog)
  dialog.show()
}

// dialog 생성 및 표시의 모든 공통 부분을 취하고 프로퍼티 설정만 사용자에게 맡기는 함수를 정의할 수도 있음 
fun showDialog(init: Dialog.() -> Unit) {
  val dialog = Dialog()
  init.invoke(dialog)
  dialog.show()
}

fun main() {
  showDialog {
    title = "my dialog"
    text = "some text"
  }
}

아이템34 요약

  • 매우 복잡한 객체 생성 또는 계층 구조처럼 특별한 경우에만 사용해야 한다.
  • 주로 라이브러리에서 DSL을 많이 사용하고 있다.
  • DSL을 만드는 것은 쉽지 않지만, 잘 정의된 DSL은 프로젝트를 훨씬 더 좋게 만들 수 있다.

코틀린 DSL 기반 엑셀 모듈 구현 예제✨

  • 엑셀 다운로드 모듈을 DSL로 직접 정의하여 다음과 활용하는 것도 어떨까 싶다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// 의존성 (build.gradle.kts)
dependencies {
    implementation("org.apache.poi:poi-ooxml:5.2.3")
}


// ────────────────────────────────────────────
// DSL 빌더 클래스들(DSL 정의)
// ────────────────────────────────────────────
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.Row

class ExcelBuilder {
  private val workbook: XSSFWorkbook = XSSFWorkbook()
  private var sheetIndex = 0

  fun sheet(name: String = "Sheet${++sheetIndex}", block: SheetBuilder.() -> Unit) {
      val sheet = workbook.createSheet(name)
      SheetBuilder(sheet).apply(block)
  }

  fun build(): Workbook = workbook
}

class SheetBuilder(private val sheet: Sheet) {
  private var rowIndex = 0

  fun row(block: RowBuilder.() -> Unit) {
      val row = sheet.createRow(rowIndex++)
      RowBuilder(row).apply(block)
  }
}

class RowBuilder(private val row: Row) {
  private var cellIndex = 0

  fun cell(vararg values: Any) { // 가변 인자로 여러 값 입력 가능
      values.forEach { value ->
          val cell = row.createCell(cellIndex++)
          when (value) {
              is String  -> cell.setCellValue(value)
              is Double  -> cell.setCellValue(value)
              is Int     -> cell.setCellValue(value.toDouble())
              is Boolean -> cell.setCellValue(value)
              else       -> cell.setCellValue(value.toString())
          }
      }
  }
}


// ────────────────────────────────────────────
// 진입점 DSL 함수
// ────────────────────────────────────────────
fun excelBuilder(block: ExcelBuilder.() -> Unit): Workbook {
    return ExcelBuilder().apply(block).build()
}


// 사용 예제
import java.io.FileOutputStream

fun main() {
  val workbook = excelBuilder {
    sheet("직원 목록") {
      // 헤더
      row {
        cell("이름", "부서", "연봉")
      }
      // 데이터 반복
      val employees = listOf(
        Triple("김철수", "개발팀", 5000),
        Triple("이영희", "디자인팀", 4500),
        Triple("박민준", "기획팀", 4800),
      )
      for ((name, dept, salary) in employees) {
        row {
            cell(name, dept, salary)
        }
      }
    }

    sheet("숫자 테이블") {
      for (i in 1..5) {
        row {
            cell("행 $i", i * 10, i * 3.14)
        }
      }
    }
  }

  FileOutputStream("output.xlsx").use { workbook.write(it) }
  workbook.close()
  println("output.xlsx 생성 완료!")
}

전체적인 구조는 다음과 같다.

1
2
3
4
excelBuilder (ExcelBuilder)
└── sheet(name) (SheetBuilder)
    └── row (RowBuilder)
        └── cell(vararg values)

그리고 빌더 클래스별 역할은 다음과 같다.

  • ExcelBuilder: XSSFWorkbook 관리, sheet {} 블록 제공
  • SheetBuilder: 시트 내 행 인덱스 관리, row {} 블록 제공
  • RowBuilder: 셀 인덱스 관리, cell() 로 값 입력

핵심 DSL 패턴 포인트는 다음과 같다.

  • apply(block) — 수신 객체를 this로 넘겨 블록 내부에서 메서드를 직접 호출할 수 있게 함
  • @DslMarker — 필요하다면 아래처럼 추가해서 중첩 스코프 오염 방지 가능
1
2
3
4
5
6
@DslMarker
annotation class ExcelDsl

@ExcelDsl class ExcelBuilder { ... }
@ExcelDsl class SheetBuilder { ... }
@ExcelDsl class RowBuilder { ... }

@DslMarker를 붙이면 “같은 마커가 붙은 바깥 스코프의 수신 객체 메서드” 호출을 컴파일 시점에 차단한다.

  • SheetBuilder 안에서 → ExcelBuilder.sheet() 차단 ✅
  • RowBuilder 안에서 → SheetBuilder.row(), ExcelBuilder.sheet() 모두 차단 ✅

Reference

아이템37: 데이터 묶음을 표현할 때 data 한정자를 사용하라

data 한정자가 오버라이드하는 메서드

  • data 한정자를 사용시 toString, equals&hashcode, copy, componentN(구조 분해)와 같은 메서드가 생성된다.

구조 분해는 언제, 어떻게 사용해야 할까?

  • 구조 분해 사용시 기본 생성자의 프로퍼티 순서를 변경하면 오류가 발생하기 쉽다.
  • 기본 생성자 프로퍼티와 같은 이름을 할당하면 IDE 에서 순서 변경에 대한 오류를 탐지할 수 있어 좋다.
1
2
3
4
5
6
7
8
9
10
data class FullName(
    val firstName: String,
    val secondName: String,
    val lastName: String
)

val elon = FullName("Elon", "Reeve", "Musk")
val (name, surname) = elon // X
val (firstName, secondName) = elon = // O
print("It is $name $surname!") // It is Elon Reeve!

튜플 대신 데이터 클래스 사용하기

1
2
3
4
5
6
7
8
9
10
11
fun String.parseName(): Pair<String, String>? {
    val indexOfLastSpace = this.trim().lastIndexOf(' ')
    if (indexOfLastSpace < 0) return null
    val firstName = this.take(indexOfLastSpace)
    val lastName = this.drop(indexOfLastSpace)
    return Pair(firstName, lastName)
}

val fullName = "Marcin Moskala"
val (lastName, firstName) = fullName.parseName() ?: return
print("His name is $firstName") // His name is Moskala
  • Pair<String, String> 타입만 보고 이름 전체를 알아채기는 힘들며, 값의 순서가 분명하지 않은 단점이 있다.
  • 안정성과 가독성을 높이려면 다음과 같이 데이터 클래스를 사용해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
data class FullName(
    val firstName: String,
    val lastName: String
)

fun String.parseName(): FullName? {
    val indexOfLastSpace = this.trim().lastIndexOf(' ')
    if (indexOfLastSpace < 0) return null
    val firstName = this.take(indexOfLastSpace)
    val lastName = this.drop(indexOfLastSpace)
    return FullName(firstName, lastName)
}

val fullName = "Marcin Moskala"
val (lastName, firstName) = fullName.parseName() ?: return
  • 튜플 대신 데이터 클래스를 사용시 아래와 같은 장점을 얻을 수 있다.
    • 1)함수의 반환 타입이 더 명확해지고
    • 2)반환 타입이 더 짧아지고 전달하기 쉬워진다.
    • 3)사용자가 데이터 클래스에서 사용된 이름과 같은 이름으로 구조 분해할 때, 순서가 잘못되었다면 경고가 표시될 것이다.

아이템51: 함수형 타입 매개변수를 갖는 함수에 inline 한정자를 사용하라

  • inline 한정자를 사용하면, 컴파일시 함수 호출부가 함수의 본문으로 대체된다,
1
2
3
4
5
6
7
8
repeat(10) {
  print(it)
}

// 아래 코드로 대체됨
for (index in 0 until 10) {
  print(index)
} 
  • inline 한정자는 다음과 같은 장점들이 있다.
    • 1)타입 인수가 구체화될(reified)수 있다.
    • 2)함수형 매개변수가 있는 함수는 인라인화되었을때 더 빠르다.
    • 3)비지역 반환(non-local return)이 허용된다.

타입 인수가 구체화될 수 있다.

  • reified 한정자를 사용시 타입 매개변수를 실제 타입 인수로 대체할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
inline fun <T> printTypeName() {
  print(T::class.simpleName) // ERROR
}

inline fun <reified T> printTypeName() {
  print(T::class.simpleName) // OK
}

printTypeName<Int>()
printTypeName<Char>()
printTypeName<String>()

함수형 매개변수가 있는 함수는 인라인화되었을때 더 빠르다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// 더 빠름. 호출자 부분에 대체되어 실행시 점프하거나 백스택(back-stack)을 추적할 필요가 없음
inline fun repeat(times: Int, action: (Int) -> Unit) {
  for (index in 0 until times) {
    action(index)
  }
}

// 훨씬 더 느림. 왜? 인수로 들어온 함수 때문에 매번 객체를 가져온 뒤 처리하기 때문(p.348 참고)
fun noinlineRepeat(times: Int, action: (Int) -> Unit) {
  for (index in 0 until times) {
    action(index)
  }
}
  • 작은 ms 차이일지라도 함수가 객체가 되면 실행될 때마다 부하가 누적되어 성능이 크게 저하된다.
  • 함수형 타입 매개변수가 있는 유틸리티 함수는 인라인으로 만드는 것이 좋다.(특히 컬렉션 처리처럼 함수형 타입 매개변수가 있는 유티릴리 함수)
  • 표준 라이브러리의 함수형 타입 매개변수가 있는 확장 함수 대부분이 inline 으로 정의된건 성능 향상을 고려했기 때문이다.

비지역 반환이 허용된다

1
2
3
4
5
6
7
8
9
10
11
12
13
for main() {
  noinlineRepeat(10) {
    print(it)
    return // ERROR: 허용되지 않음
  }
}

for main() {
  repeat(10) {
    print(it)
    return // OK: 코드가 어차피 main 함수내에 위치하게 되므로 반환이 가능하게됨, 다른 제어구조와 비슷하게 동작하게됨
  }
}

inline 한정자의 비용(단점, p.352 참고)

  • 인라인 함수는 제한된 가시성을 가진 요소를 사용할 수 없다.
    • private 또는 internal 제어자가 있는 함수나 프로퍼티를 public inline 함수에서 사용할 수 없다.
    • public 또는 internal인 inline 함수에서 private 프로퍼티를 사용할 수 없다.
  • 인라인 함수로 세부사항을 숨길 수 없으므로, 클래스 내부에서는 인라인 함수를 거의 사용하지 않는다. 인라인 함수가 대부분 유틸리티 함수인 이유도 바로 이 때문이다.
  • 인라인 함수는 재귀적으로 사용할 수 없다. 호출부가 무한히 대체되는 문제가 발생할 수 있다.
  • 인라인 함수는 코드 규모를 증가시킨다.(인라인 함수가 인라인 함수를 호출시 코드 규모가 기하급수적으로 증가할 위험이 있음)

crossineline과 noinline

  • crossinline: 인자로 인라인 함수를 받지만, 비지역적 리턴을 하는 함수는 받을 수 없게 만듭니다.
  • online: 인자로 인라인 함수를 받을 수 없게 만듭니다. 인라인 함수가 아닌 함수를 인자로 사용하고 싶을 때 사용한다.
  • 위 내용은 인텔리제이가 한정자를 제안하므로 반드시 기억할 필요는 없다. 자세한 내용은 여기를 참고하면 좋다.

요약

  • 다음과 같은 경우에는 인라인 함수를 사용하자.
    • print 같이 매우 빈번하게 사용되는 함수
    • filterIsInstance와 같이 타입 인수로 구체화된 타입을 전달해야 하는 함수
    • 함수형 타입 매개변수가 있는 최상위 함수를 정의시(특히 컬렉션 처리 함수(map, filter, flatMap, joinToString 등), 스코프 함수(also, apply, let 등), 최상위 유틸리티 함수(repeat, run, with 등)와 같은 헬퍼 함수)
  • 인라인 함수는 호출 지점에 코드를 복사하므로, 함수 본문이 크면 바이트코드가 늘어납니다. 작고 자주 호출되는 고차 함수에 쓰는 것이 이상적이다.

인라인 함수 구현 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 1. 실행 시간 측정
inline fun measureTime(block: () -> Unit): Long {
    val start = System.currentTimeMillis()
    block()
    return System.currentTimeMillis() - start
}

// 클라이언트 코드
val elapsed = measureTime {
    heavyDatabaseQuery() // 람다를 넘길 때마다 객체가 생성되는 오버헤드를 없애줌
}


// 2. 로그 + 조건부 실행
inline fun logIfDebug(tag: String, message: () -> String) {
    if (BuildConfig.DEBUG) {
        // 릴리즈 빌드에선 message() 자체를 호출 안 함
        // inline이 없으면 릴리즈 빌드에서도 message() 람다 객체가 생성됨
        Log.d(tag, message())
    }
}

// 클라이언트 코드
logIfDebug("Network") { "응답 데이터: ${response.body()}" } 


// 3. reified — 타입 안전한 JSON 파싱
inline fun <reified T> String.fromJson(): T {
    return Gson().fromJson(this, T::class.java)
}

// 클라이언트 코드
val user: User = jsonString.fromJson<User>()
val list: List<Item> = jsonString.fromJson<List<Item>>() // reified 없이는 제네릭 타입을 런타임에 알 수 없어서 T::class.java가 불가능


// 4. 트랜잭션 패턴
inline fun <T> db.transaction(block: () -> T): T {
    beginTransaction()
    return try {
        val result = block()
        setTransactionSuccessful()
        result
    } finally {
        endTransaction()
    }
}

// 사용
db.transaction {
    insertUser(user)
    updateProfile(profile)
}

아이템59: 가변 컬렉션 사용을 고려하라

  • 가변 컬렉션의 장점은 성능이 더 빠르다는 것이다.
    • 왜? 불변 컬렉션에 요소를 추가시 새로운 컬렉션을 생성하고, 기존 요소들을 전부 추가한뒤, 마지막에 인수로 들어온 요소를 추가하기 때문
  • 불변 컬렉션의 장점은 아이템1(가변성을 제한하라)에서 살펴본것처럼 안정성이다.
    • 하지만 로컬 변수는 동기화나 캡슐화로 인한 문제가 발생할 가능성이 적기에 굳이 가변성을 제한하지 않아도된다.
    • 그러기에 로컬 변수로는 가변 컬렉션을 사용하는 것이 더 합리적이다.
  • 참고로 표준 라이브러리도 성능을 높이고자 모든 컬렉션 처리 함수(ex. filter, map 등) 내부에서 가변 컬렉션을 사용하여 구현했다.
This post is licensed under CC BY 4.0 by the author.

자바 개발자를 위한 코틀린 입문 내용 정리

-