‘이펙티브 코틀린’ 학습 내용을 간단하게 정리한 임시 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
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