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...
}
This post is licensed under CC BY 4.0 by the author.

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

-