‘이펙티브 코틀린’의 학습 내용을 간단하게 정리하기 위한 목적의 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...
}
|