Search

코틀린 고급 활용

1. 제네릭

가. 학습 목표

제네릭 및 변성이 적용된 코드를 정확하게 이해할 수 있습니다. 코틀린 표준 라이브러리를 포함하여 다수의 라이브러리에서 제네릭과 변성의 개념을 적용하여, 클래스와 메서드의 시그니처를 설명하고 있습니다.
public interface List<out E> : Collection<E> { override fun contains(element: @UnsafeVariance E): Boolean override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean public operator fun get(index: Int): E }
Kotlin
복사
fun <C, R> C.ifEmpty( defaultValue: () -> R ): R where C : Array<*>, C : R inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo( destination: C, predicate: (T) -> Boolean ): C
Kotlin
복사
// ifEmpty 예시 코드 val emptyOrDefault: Array<Any> = emptyArray.ifEmpty { arrayOf("default") } println(emptyOrDefault.contentToString()) // [default]
Kotlin
복사
// filterTo 예시 코드 val numbers: List<Int> = listOf(1, 2, 3, 4, 5, 6, 7) val evenNumbers = mutableListOf<Int>() numbers.filterTo(evenNumbers) { it % 2 == 0 }
Kotlin
복사
제네릭을 활용하여 OOP(LSP, OCP)에 따라 추상객체 기반으로 구현객체를 유연하게 결합할 수 있습니다. 객체 간 유연한 결합을 위해서는 부모 타입으로 관리되는 요소를 특정 자식 타입으로 반환 받을 수 있어야 하기 때문에 제네릭 개념이 등장했습니다. 제네릭변성에 대한 개념이 명확하지 않으면, 무분별한 type casting에 따라 컴파일타임 또는 런타임의 타입 불일치 이슈를 경험할 수 있습니다.

나. 기초 개념

1) 예시 코드 설명
주요 동작 코드
fun main() { val cage = Cage() cage.put(Carp(" ")) val carp: Carp = cage.getFirst() // Error: Type Mismatch } fun main() { val cage = Cage() cage.put(Carp(" ")) val carp: Carp = cage.getFirst() as Carp } fun main() { val cage = Cage() cage.put(Carp(" ")) val carp: Carp = cage.getFirst() as? Carp ?: throw IllegalArgumentException() } fun main() { val cage = Cage2<Carp>() cage.put(Carp("잉어")) val carp: Carp = cage.getFirst() } fun main() { val goldFishCage = Cage2<GoldFish>() goldFishCage.put(GoldFish(" ")) val cage = Cage2<Fish>() cage.moveFrom(goldFishCage) // Error: Type Mismatch }
Kotlin
복사
기본 객체
class Cage { private val animals: MutableList<Animal> = mutableListOf() fun getFirst(): Animal { return animals.first() } fun put(animal: Animal) { this.animals.add(animal) } fun moveFrom(cage: Cage) { this.animals.addAll(cage.animals) } } class Cage2<T> { private val animals: MutableList<T> = mutableListOf() fun getFirst(): T { return animals.first() } fun put(animal: T) { this.animals.add(animal) } fun moveFrom(cage: Cage2<T>) { this.animals.addAll(cage.animals) } } abstract class Animal(val name: String) abstract class Fish(name: String) : Animal(name) class GoldFish(name: String) : Fish(name) class Carp(name: String) : Fish(name)
Kotlin
복사
2) 제네릭
제네릭 클래스: 타입 파라미터(T)로 정의된 클래스입니다.
class Cage2<T> { // 클래스 본문 생략 }
Kotlin
복사
Raw Type: 제네릭 클래스를 인스턴스화할 때, 타입 매개변수를 전달하지 않는 것입니다. 자바에서는 지원하지만 코틀린에서는 지원하지 않습니다. 내부적으로 타입추론에 따라 타입을 지정할 수 있습니다.
List list = new ArrayList():
Java
복사
val list = mutableListOf<Int>() val list = listOf('a', 'b', 'c')
Kotlin
복사
제네릭 제약: 제네릭 타입의 상속 관계를 제한하는 방법입니다. 이하 예시와 같이 제약을 걸게 되면 Animal과 이를 상속한 타입만 사용하도록 제한됩니다.
class Cage5<T : Animal> { private val animals: MutableList<T> = mutableListOf() }
Kotlin
복사
타입 소거: 런타임 환경에서 타입 파라미터가 제거되는 현상입니다. refined inline 키워드를 활용하면 타입 부분이 정적으로 재정의되므로 런타임 환경에서도 타입 정보를 확인할 수 있습니다.
Star Projection: 타입 파라미터로 어떤 것이 들어올지 알 수 없다는 표현입니다.
3) 변성
변성(variance): 타입 파라미터에 상속관계가 존재할 때, 양 타입으로 정의된 제네릭 클래스 사이에 대한 상속관계여부입니다.
무공변(불공변, in-variant): 타입 파라미터 간 상속관계가 존재하지만 제네릭 클래스 간에는 상속관계가 없는 상태입니다.
공변(co-variant): 타입 파라미터 간 상속관계가 동일하게 제네릭 클래스에도 적용되는 상태입니다. 코틀린에서는 변성 어노테이션 out을 사용하여 정의합니다.
반공변(contra-variant): 타입 파라미터의 상속관계에 반대로 제네릭 클래스에 적용되는 상태입니다. 코틀린에서는 변성 어노테이션 in을 사용하여 정의합니다.
변성 어노테이션(variance annotation): 변성을 정의하기 위한 방법입니다.
선언 지점 변성: 클래스 자체에 변성을 부여하는 방법입니다. 선언 지점 변성 시 해당 클래스는 데이터를 생산 또는 소비 중 하나만 할 수 있도록 제한됩니다.
class Cage3<out T> { private val animals: MutableList<T> = mutableListOf() fun getFirst(): T { return animals.first() } fun getAll(): List<T> { return this.animals } }
Kotlin
복사
사용 지점 변성: 클래스 메소드에 변성을 부여하는 방법입니다. 사용 지점 변성 시 해당 메소드 내부에서는 데이터를 생산 또는 소비 중 하나만 할 수 있습니다.
class Cage2<T> { private val animals: MutableList<T> = mutableListOf() fun getFirst(): T { return animals.first() } fun put(animal: T) { this.animals.add(animal) } fun moveFrom(cage: Cage2<out T>) { this.animals.addAll(cage.animals) } }
Kotlin
복사

다. 변성 정의

1) 공변성과 불공변성
공변한 타입이 필요할 경우, 개발자가 직접 정의해야 합니다. Java 타입은 공변성에 따른 부작용을 방지하기 위해 기본적으로 불공변하도록 설계되어 있습니다.
공변성에 따른 부작용이란 컴파일 타임에서 타입 불일치에 따른 예외를 잡아낼 수 없다는 것입니다.
컬럭션 타입의 내부 요소에 대해 제네릭 타입을 지정할 경우, 컬렉션 타입에 대한 공변성이 필요하다면 추가적인 작업이 필요합니다.
Object[] objs = new String[]{"A", "B", "C"}; objs[0] = 1; // java.lang.ArrayStoreException: java.lang.Integer
Kotlin
복사
2) 사용지점 변성 정의
타입을 공변하게 정의하기 위해 타입 파라미터의 상속관계를 고려하여 제네릭 클래스의 상속관계를 지정할 필요가 있습니다.
variance annotation 사용하여 제네릭 클래스 간의 상속 관계를 지정할 수 있습니다. 단, 타입 안정성을 고려하여 사용처에 따라 별개의 variance annotation을 사용해야 합니다.
out(variance annotation): 타입 파라미터에 따라 제네릭 클래스 사이의 상속관계를 정의합니다. 타입 파라미터로 전달된 객체를 읽기 전용으로 사용합니다.
in(variance annotation): 타입 파라미터와 반대로 제네릭 클래스 사이의 상속관계를 정의합니다. 타입 파라미터로 전달된 객체를 쓰기 전용으로 사용합니다.
fun main() { val goldFishCage = Cage2<GoldFish>() goldFishCage.put(GoldFish(" ")) val cage = Cage2<Fish>() cage.moveFrom(goldFishCage) // Error: Type Mismatch } class Cage2<T> { private val animals: MutableList<T> = mutableListOf() fun getFirst(): T { return animals.first() } fun put(animal: T) { this.animals.add(animal) } fun moveFrom(cage: Cage2<out T>) { this.animals.addAll(cage.animals) } fun moveTo(otherCage: Cage2<in T>) { otherCage.animals.addAll(this.animals) } }
Kotlin
복사
3) 선언지점 변성 정의
메소드가 아닌 객체에 변성을 정의하는 방법입니다. 선언지점 변성은 Java에서 지원하지 않고, Kotlin에서만 지원합니다.
이하의 예시 코드에서 @UnsafeVariance 어노테이션이 적용되면 변성의 정의에 에외를 둔다는 것을 의미합니다.
public interface List<out E> : Collection<E> { override fun contains(element: @UnsafeVariance E): Boolean override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean public operator fun get(index: Int): E }
Kotlin
복사

라. 제네릭 활용

1) 제네릭 쉐도윙 이슈
객체의 멤버 변수와 메소드 내 지역변수의 관계와 마찬가지로 타입 파라미터에도 정의된 위치에 따라 주입되는 곳이 달라집니다.
이하의 예시코드에서 Carp를 추가했지만 타입 불일치 이슈가 발생하지 않습니다. 왜냐하면 Cage 정의 시 전달한 타입 파라미터와 Cage::addAnimal에 전달한 타입 파라미터가 다르기 때문입니다.
class Cage<T : Animal> { fun <T : Animal> addAnimal(animal: T) { } } val cage = Cage<GoldFish>() cage.addAnimal(GoldFish(" ")) cage.addAnimal(Carp(" "))
Kotlin
복사
2) 제네릭 클래스 상속
제네릭 클래스를 상속하는 방법에는 두 가지가 있습니다. 하나는 자식 클래스에서 부모 클래스의 타입 파라미터를 함께 명시하는 것입니다. 다른 방법은 자식 클래스에서는 타입을 명시하지 않고, 부모 클래스에서만 타입을 특정하여 정의하는 것입니다.
class CageV2<T: Animal> : CageV1<T>() {}
Kotlin
복사
class GoldFishCageV2 : CageV1<GoldFish>() {}
Kotlin
복사
3) Type Alias 활용
타입 파라미터 자체가 너무 길면, typealias를 활용하여 타입을 변수화할 수 있습니다.
typealias PersonDtoStore = Map<PersonDtoKey, MutableList<PersonDto>> fun handleCacheStore(store: PersonDtoStore) {}
Kotlin
복사

2. 지연과 위임

가. 학습 목표

지연 및 위임 키워드의 내부 동작 원리를 이해하고, 적재적소에 활용할 수 있습니다.
지연 컬렉션의 동작을 이해하고, 적재적소에 활용할 수 있습니다.

나. 기초 개념

property: 클래스의 field와 accessor method를 엮은 개념입니다.
backing property: 외부에 드러나지 않는 property로, 외부에 드러나는 property의 보조격으로 사용됩니다. 언더바(_)를 변수명에 붙이는 것이 코틀린 표준입니다.
class Person { private var _name: String? = null // backing property val name: String get() { if (_name == null) { Thread.sleep(2_000) this._name = " " } return _name!! } }
Kotlin
복사
lateinit: 객체의 인스턴스화 시점과 분리하여 변수를 초기화하는 키워드입니다.
class Person { lateinit var name: String }
Kotlin
복사
by lazy: 초기화에 따른 비용이 발생할 경우, 속성이 사용되는 시점(get)에 초기화 로직을 수행합니다.
class Person { val name: String by lazy { Thread.sleep(2_000) "테스트" } }
Kotlin
복사

다. 지연과 위임 키워드

1) lateinit vs by lazy
공통점: 키워드를 붙여서 변수를 정의하면 nullable type으로 정의되지만 null을 값으로 주입받는 것을 방지할 수 있습니다.
차이점: lateinit 키워드는 var로 정의된 property에 대해 초기화를 지연하기 때문에 여러번에 걸쳐서 다른 초기화 값을 할당할 수 있습니다. by lazy의 경우 val로 정의된 property에 대해 단 1회만 초기화 로직을 수행할 수 있습니다.
2) lateinit vs not null
lateinit은 원시타입(Int, Long)에 대해 사용할 수 없지만, not null 키워드는 원시타입에 대해 사용할 수 있습니다.
class Person { var age: Int by notNull() }
Kotlin
복사

라. 지연 컬렉션

1) Sequence vs Iterable
Iterable은 중간 연산에 따라 중간 결과를 담은 Collection이 생성됩니다. 다시 말해서 모든 요소에 대해 모든 연산이 적용됩니다.
Sequence는 요소별로 연산을 적용하고 최종 연산에 따른 결과를 새로운 Collection에 반영합니다.
대용량의 데이터를 대상으로 연산을 한다면 Sequence 사용이 적절합니다. 하지만 저용량의 데이터라면 Sequence 사용에 따른 오버헤드가 발생하여 성능이 상대적으로 떨어질 수 있습니다.
2) Sequence vs Stream
두 가지 모두 지연 컬렉션이고, JDK 1.8 이상의 환경에서 코틀린을 사용한다면 Stream을 사용할 수 있습니다.
코틀린 환경에서는 Sequence 보다 Stream 사용을 권장합니다. Sequence에는 Inline 등의 코틀린 중심의 최적화가 되어 있기 때문입니다.

3. 함수형 프로그래밍

가. 학습 목표

코틀린 함수 리터럴의 내부 동작 원리를 이해할 수 있습니다.
람다식과 익명함수를 상황에 따라 최적화하여 활용할 수 있습니다.

나. 기초 개념

1급 시민(first-class citizen): 일반적으로 다음의 조건을 만족하면 1급 시민으로 분류합니다. 함수의 매개변수 및 반환타입으로 지정 가능합니다. 또한 변수에 할당 가능합니다. 코틀린의 함수는 앞의 조건을 만족하지만 자바의 함수는 그렇지 못합니다.
함수 리터럴(또는 함수값): 1급 시민으로 간주할 수 있는 함수입니다. 함수의 마지막 매개변수로 함수 타입을 정의할 경우, 해당 함수 호출 시 함수타입을 중괄호로 빼서 전달할 수 있습니다.
compute(5, 3) { a, b -> a + b }
Kotlin
복사
람다: 일반적으로 이름 없는 함수를 가리킵니다.
return: 키워드가 정의된 함수를 종료하고 값을 반환하는 키워드입니다.
고차함수(higher order function): 함수를 매개변수 또는 반환타입으로 정의할 수 있는 함수입니다.
람다식/익명함수: 둘 다 함수 리터럴의 일종입니다. 두 개념의 차이에 대해서는 이하의 본문을 참고하세요.
inline 함수: 컴파일 시 함수의 정의부가 함수의 호출부에 재정의되어 실행되는 함수입니다. 결과적으로 함수 호출부에서도 다른 함수를 호출하는 과정이 생략되므로 성능 상 이점이 있습니다.
SAM(Single Abstract Method) Interface: 추상 메소드가 하나만 존재하는 인터페이스입니다.

다. 함수 타입 활용

1) 매개변수로 함수 리터럴 전달
함수 타입을 매개변수로 받는 함수에 대해 아래와 같이 람다식을 매개변수로 전달할 수 있습니다.
compute(5, 3) { a, b -> a + b } fun compute(num1: Int, num2: Int, op: (Int, Int) -> Int): Int { return op(num1 ,num2) }
Kotlin
복사
동일한 함수타입 활용 함수에 대해 아래와 같이 익명함수를 매개변수로 전달할 수 있습니다.
compute(5, 3, fun(a, b) = a + b) // 매개변수 타입 X, 반환 타입 X compute(5, 3, fun(a: Int, b: Int) = a + b) // 매개변수 타입 O, 반환 타입 O compute(5, 3, fun(a: Int, b: Int): Int { // 매개변수 타입 O, 반환 타입 O, return O return a + b })
Kotlin
복사
2) return 키워드 활용
람다식 활용 시 내부에 return 키워드를 사용할 수 없으므로 early return 방식으로 구현할 수 없습니다.
iterate(listOf(1, 2, 3, 4, 5)) { num -> if (num == 3) { return // 'return' is not allowed here } println(num) } fun iterate(numbers: List<Int>, exec: (Int) -> Unit) { for (number in numbers) { exec(number) } }
Kotlin
복사
익명함수 활용 시 return 키워드를 사용할 수 있습니다. early return 구현이 가능합니다.
iterate(listOf(1, 2, 3, 4, 5), fun(num) { if (num == 3) { return // 'return' is allowed here } println(num) })
Kotlin
복사
단, inline 키워드로 정의된 함수에 람다식을 매개변수로 전달한 경우, 이를 호출하는 함수에서는 람다식 내부에서 return을 사용할 수 있습니다. 이 때, 람다식 내부의 return은 외부 함수에 대한 return을 의미합니다. 이하 코드에서 return은 main 함수의 종료를 의미합니다.
fun main() { iterate(listOf(1, 2, 3, 4, 5)) { num -> if (num == 3) { return } println(num) } } inline fun iterate(numbers: List<Int>, crossinline exec: (Int) -> Unit) { for (num in numbers) { exec(num) } }
Kotlin
복사
3) 확장 함수의 함수 타입
이하 예시에서 첫번째 Int가 수신객체 타입입니다. this가 수신객체를 가리킵니다. 확장함수에 전달된 매개변수는 수신객체와 구분하기 위해 소괄호로 감쌉니다.
이하의 예시의 타입은 Int.(Long) → Int가 됩니다.
fun Int.add(other: Long): Int = this + other.toInt()
Kotlin
복사

라. 함수 리터럴 원리와 최적화

1) 함수 리터럴 동작 원리
함수 리터럴 사용 시 Compile 과정에서 새로운 객체를 생성하게 되므로 오버헤드가 발생합니다.
함수 리터럴로 정의된 것은 컴파일 과정에서 FunctionN 객체로 생성됩니다. N은 함수리터럴에 전달되는 매개변수의 개수를 의미합니다.
함수 리터럴 내부에서 참조하는 외부 변수는 Ref 객체로 감싸서 참조합니다.
// origin compute(2, 3) { num1, num2 -> num1 * num2 } // decompiled compute(2, 3, (Function2)null.INSTANCE); // origin fun compute(num1: Int, num2: Int, op: (Int, Int) -> Int): Int { return op(num1, num2) } // decompiled public static final int compute(int num1, int num2, @NotNull Function2 op) { return ((Number)op.invoke(num1, num2)).intValue(); }
Kotlin
복사
2) 함수 리터럴 최적화
함수 리터럴 사용 시 함수 객체가 인스턴스화되기 때문에 만약 함수 리터럴을 반복 사용하는 케이스가 있다면 inline 함수를 활용하여 최적화 처리가 필요합니다.
함수 리터럴을 매개변수로 받는 함수에 대해 인라인을 적용하면, 해당 함수 뿐만 아니라 매개변수로 전달된 함수 리터럴까지도 인라인됩니다.
fun main() { repeat(2) { println("Hello World") } } inline fun repeat(times: Int, exec: () -> Unit) { for (i in 1..times) { exec() } } public static final void main() { int i$iv = 1; while(true) { System.out.println("Hello World"); if (i$iv == 2) { return; } ++i$iv; } }
Kotlin
복사

마. SAM & Reference 활용

1) 코틀린과 자바의 SAM 정의
아래와 같이 코틀린과 자바에서 SAM을 정의할 수 있습니다.
코틀린에서 SAM을 정의할 때 주의할 점은 interface 앞에 fun 키워드를 반드시 붙여야 합니다.
fun interface KStringFilter { fun predicate(str: String): Boolean }
Kotlin
복사
@FunctionalInterface public interface StringFilter { abstract public boolean predicate(String str); }
Java
복사
2) 코틀린의 SAM 활용
코틀린은 자바와 달리 람다식 만으로 SAM을 인스턴스화할 수 없습니다.
아래와 같이 SAM 생성자를 활용해야 합니다. 다음을 SAM 생성자라 합니다. StringFilter { }
val filter: StringFilter = { s -> s.startsWith("A") } // Error: Type mismatch val filter = StringFilter { s -> s.startsWith("A") } // SAM 생성자 활용
Kotlin
복사
3) callable reference
:: 활용하여 이미 정의된 함수를 변수에 할당하는 것을 호출가능 참조(callable reference)라고 합니다.
fun add(a: Int, b: Int): Int { return a + b } fun main() { val add3 = ::add }
Kotlin
복사
:: 뒤에 클래스 이름을 붙이면 클래스 생성자에 대한 호출가능 참조를 적용할 수 있습니다.
fun main() { val personConstructor = ::Person } class Person( val name: String, val age: Int )
Kotlin
복사
4) callable reference 내부 원리
코틀린에서 호출 가능 참조의 결과값은 리플렉션 객체이므로 일급 시민으로 활용할 수 있습니다. 하지만 자바의 경우 함수형 인터페이스(Consumer, Supplier)이기 때문에 일급 시민으로 활용할 수 없습니다.
자바의 경우, 참조만으로 객체를 바로 얻을 수 없고, 메소드 이름에 대한 문자열을 전달하여 리플렉션을 완성하는 과정이 필요합니다.
코틀린의 경우, 참조만으로 리플렉션으로 객체를 바로 얻을 수 있습니다. 코틀린의 리플렉션 API (kotlin.reflect)를 사용하여 호출 가능 참조를 변수에 할당하고 직접 호출 가능합니다.
import java.lang.reflect.Method; import java.util.function.Function; public class Main { public static void main(String[] args) throws Exception { Function<String, Integer> func = Integer::parseInt; int result = func.apply("123"); System.out.println(result); // 출력: 123 Method method = Main.class.getMethod("exampleMethod"); method.invoke(null) } public static void exampleMethod() { System.out.println("Hello, Reflection!"); } }
Java
복사
import kotlin.reflect.KFunction fun exampleMethod() { println("Hello, Reflection!") } fun main() { val parseInt: (String) -> Int = String::toInt val result = parseInt("123") println(result) // 출력: 123 val method: KFunction<Unit> = ::exampleMethod method.call() }
Kotlin
복사
5) bound callable reference
인스턴스에 대한 호출 가능 참조를 얻는 것을 바인딩된 호출 가능 참조(bound callable reference)라고 합니다.
바인딩된 호출 가능 참조는 클래스의 멤버 외에도 확장함수에도 적용할 수 있습니다.
val p1 = Person("A", 100) val boundingGetter = p1::name.getter fun Int.addOne(): Int { return this + 1 } fun main() { val plus = Int::addOne }
Kotlin
복사

4. Kotlin DSL

가. 학습목표

Kotlin Gradle에 적용된 Kotlin DSL의 내부 원리를 이해할 수 있습니다.

나. 기초개념

연산자 오버로딩: ‘+’, ‘-’와 같이 연산자에 해당하는 함수를 오버로딩하여 맥락에 따라 간편하게 함수를 호출하는 기능입니다.
DSL(Domain Specific Language): 일부 domain에 한해서 선언적인 형태로 간략하게 사용되는 언어입니다.
내부 DSL: DSL의 단점인 추가적인 학습 비용을 상쇄하기 위해 범용 프로그래밍 언어 기반의 DSL입니다.
Kotlin DSL: Kotlin 기반의 내부 DSL입니다.

다. Kotlin Gradle & DSL

1) invoke 관례와 Kotlin Gradle
Kotlin Gradle에서 특정 설정 항목이 많을 경우 중첩된 블록 구조를 사용할 수 있습니다.
invoke 관례를 활용하여 중첩된 블록 구조를 간단하게 만들 수 있습니다.
val dependencies = DependencyHandler() dependencies.compile("io.kotest:kotest-runner-junit5") dependencies { compile("io.kotest:kotest-runner-junit5") compile("io.kotest:kotest-assertions-core") compile("io.kotest:kotest-property") } // 위의 invoke 관례의 변환 결과 // dependencies.invoke({ // this.compile("io.kotest:kotest-runner-junit5") // this.compile("io.kotest:kotest-assertions-core") // this.compile("io.kotest:kotest-property") // }) class DependencyHandler { fun compile(coordinate: String) { // 생략 } operator fun invoke(body: DependencyHandler.() -> Unit) { this.body() } }
Kotlin
복사
2) invoke 관례
관례란 특수 문법에 따라 일부 함수를 더 간단한 방법으로 호출하는 기능입니다. ex) boo.get(bar)boo[bar]
invoke 관례란 invoke 함수가 정의된 객체를 간단하게 호출하는 것입니다. 오직 소괄호와 매개변수만을 사용하여, 마치 함수를 호출하는 것처럼 사용할 수 있습니다. ex) boo.invoke(bar)boo(bar)
invoke 관례를 활용하면 람다 본문을 유연하게 사용할 수 있습니다. 람다 본문이 복잡할 경우, 인터페이스를 구현하는 방향으로 람다를 분리할 수 있습니다.

라. Kotest & DSL

1) Kotest 내부 DSL(중위함수 호출 연쇄 활용)
Kotest 내 중위 함수(should, with)를 연쇄적으로 호출했습니다.
중위함수 호출 연쇄가 가능한 이유는 should 함수가 with이라는 함수를 호출 할 수 있는 래퍼 객체를 반환하기 때문입니다.
// 중위 함수 호출 연쇄 "kotlin" should start with "kot" // 일반 함수 호출 연쇄 "kotlin".should(start).with("kot") object start infix fun String.should(x: start): StratWrapper = StartWrapper(this) class StartWrapper(val value: String) { infix fun with(prefix: String) = // ... }
Kotlin
복사
2) 중위함수 호출 연쇄
중위함수란 수신객체와 하나의 매개변수가 존재할 경우, 간편하게 호출하도록 정의한 함수입니다다.
중위함수 호출 연쇄를 활용하여 마치 영어 문장을 읽는 것과 유사한 수준의 가독성을 가질 수 있습니다.
// 중위 함수 정의 infix fun Int.add(other: Int): Int { return this + other } // 중위 함수 호출 val result = 1 add 2 println("Result is $result") // 출력 결과: Result is 3
Kotlin
복사

5. 어노테이션 & 리플렉션

가. 학습 목표

어노테이션 정의 시 적용할 수 있는 여러 특성을 이해하고 활용할 수 있습니다.
코틀린 리플렉션과 자바 리플렉션의 차이를 이해할 수 있습니다.

나. 기초 개념

Retention: 어노테이션이 유지되는 순간을 제한합니다. 기본값으로 런타임에 어노테이션이 유지되고 리플렉션을 사용할 수 있습니다.
import kotlin.annotation.AnnotationRetention import kotlin.annotation.Retention @Retention(AnnotationRetention.RUNTIME) annotation class RuntimeAnnotation(val message: String) @RuntimeAnnotation("This annotation is retained at runtime") class MyClass fun main() { val annotations = MyClass::class.annotations annotations.forEach { println("Annotation: ${it.annotationClass.simpleName}, Message: ${(it as RuntimeAnnotation).message}") } }
Kotlin
복사
Target: 어노테이션의 적용 대상을 제한합니다. 기본값으로 모든 대상에 적용 가능합니다.
import kotlin.annotation.AnnotationTarget import kotlin.annotation.Target @Target(AnnotationTarget.FUNCTION) annotation class FunctionAnnotation(val description: String) class AnotherClass { @FunctionAnnotation("This annotation is only for functions") fun annotatedFunction() { println("This function is annotated") } } fun main() { val function = AnotherClass::class.members.find { it.name == "annotatedFunction" } val annotations = function?.annotations annotations?.forEach { println("Annotation: ${it.annotationClass.simpleName}, Description: ${(it as FunctionAnnotation).description}") } }
Kotlin
복사
Repeatable: 어노테이션 적용 대상에 반복 적용이 가능합니다.
import kotlin.annotation.Repeatable @Repeatable annotation class RepeatableAnnotation(val info: String) class ExampleClass { @RepeatableAnnotation("First annotation") @RepeatableAnnotation("Second annotation") fun repeatedAnnotations() { println("This function has repeatable annotations") } } fun main() { val function = ExampleClass::class.members.find { it.name == "repeatedAnnotations" } val annotations = function?.annotations annotations?.forEach { println("Annotation: ${it.annotationClass.simpleName}, Info: ${(it as RepeatableAnnotation).info}") } }
Kotlin
복사

다. 코틀린 리플렉션

1) KClass<T>와 Class<T>
코틀린에서 KClass<T>Class<T>는 객체의 클래스 유형을 나타냅니다.
Class<T>가 자바 리플렉션 클래스, KClass<T>는 그 코틀린 리플렉션 클래스입니다.,
리플렉션 클래스에서 클래스의 메타데이터를 추출할 수 있는 메소드가 정의되어 있습니다.
::class 구문을 사용하면 리플렉션 클래스의 인스턴스를 얻을 수 있습니다.
import kotlin.reflect.KClass class MyClass fun main() { val kClass: KClass<MyClass> = MyClass::class val javaClass: Class<MyClass> = MyClass::class.java println("KClass: $kClass") println("Java Class: $javaClass") }
Kotlin
복사
2) KClass.cast()
KClass.cast() 함수는 객체를 지정된 클래스 타입으로 캐스팅할 때 사용합니다.
이 방식으로 런타임에 객체가 예상된 타입인지 확인할 수 있습니다.
import kotlin.reflect.full.cast fun main() { val kClass: KClass<MyClass> = MyClass::class val obj: Any = MyClass() val castedObj: MyClass = kClass.cast(obj) println("Casted Object: $castedObj") }
Kotlin
복사
3) 리플렉션과 DI 컨테이너
리플렉션은 의존성 주입(DI) 컨테이너에서 클래스의 인스턴스를 동적으로 생성하고 런타임에 의존성을 주입하는 데 사용됩니다.
import kotlin.reflect.full.createInstance import kotlin.reflect.full.primaryConstructor class Service class Controller(val service: Service) fun main() { val serviceClass: KClass<Service> = Service::class val controllerClass: KClass<Controller> = Controller::class val service: Service = serviceClass.createInstance() val controller: Controller = controllerClass.primaryConstructor!!.call(service) println("Controller: $controller with Service: $service") }
Kotlin
복사
4) 리플렉션과 타입 안전성
코틀린에서 리플렉션은 타입 안전성을 염두에 두고 설계되어 있기 때문에 런타임 예외가 발생하지 않습니다.
코틀린의 리플렉션 API는 타입 정보를 보존하고 검사하여 컴파일 타임 보장을 제공하며, ClassCastException의 발생 가능성을 줄입니다.
fun <T : Any> printClassName(kClass: KClass<T>) { println("Class name: ${kClass.simpleName}") } fun main() { printClassName(Service::class) printClassName(Controller::class) }
Kotlin
복사

참고

드미트리 제메로프 & 스베트라나 이사코바, Kotlin In Action
제네릭 변성의 실무 활용 예시, https://www.inflearn.com/questions/1115827/comment/305993
lateinit의 실무 활용 예시, https://www.inflearn.com/questions/1098327