코틀린 배열은 타입 파라미터를 받는 클래스이며 배열의 원소 타입은 타입 파라미터에 의해 정해집니다.
1. 배열 초기화 방법
- arrayOf : 인자로 받은 원소들을 포함하는 배열을 생성
val numbers = arrayOf(1, 2, 3, 4, 5)
// 출력: 1, 2, 3, 4, 5
- arrayOfNulls : 모든 원소가 null인 정적인 크기의 배열 생성
val nullArray = arrayOfNulls<String>(3)
// 출력: null, null, null
- Array : 배열 크기와 람다를 인자로 받아 생성
val arr = Array(26){ i -> ('a' + i).toString() }
// 출력 : abcdefghijklmnopqrstuvwxyz
2. Collection To Array
- toTypedArray : 컬랙션을 배열로 변환
fun main() {
val strings = listOf("a", "b", "c")
println("%s%s%s".format(*strings.toTypedArray()))
}
//abc
3. Array Decompile
fun main() {
val arr = arrayOf<Int>()
}
// Decompile
public final class MainKt {
public static final void main() {
Integer[] arr = new Integer[0];
}
// $FF: synthetic method
public static void main(String[] args) {
main();
}
}
배열 타입의 타입 인자는 항상 객체(참조) 타입이 됩니다. 디컴파일 된 코드를 보면 arrayOf를 통해 생성된 배열은 박싱 된 정수(Integer)의 배열입니다. Boxing이란, 원시 타입(primitive type)을 객체(참조 타입)로 감싸서 래퍼(wrapper) 객체로 만드는 행위를 말합니다. 이는 자바의 제네릭스와 관련되어 있습니다.
List<Integer> list = new ArrayList<>();
list.add(10); // int → Integer (오토박싱)
int x = list.get(0); // Integer → int (언박싱)
JVM의 제네릭스는 런타임에 타입소거(type erasure)됩니다. 예를 들어 List <String>은 java.util.List로 간주되며, 타입 인자인 "String"은 런타임에 사라집니다.
즉, List 객체가 어떤 타입의 원소를 저장하는지 런타임에는 알 수 없습니다. 이에 대한 검증으로 다음과 같이 리플렉션을 사용해 런타임 클래스 타입을 비교했을 때 참이 반환되는 것을 확인할 수 있습니다.
fun main() {
val list1: List<Int> = listOf(1, 2, 3)
val list2: List<String> = listOf("a", "b", "c")
// Kotlin KClass
println(list1::class == list2::class) // true
// Java Class
println(list1.javaClass == list2.javaClass) // true
}
Oracle의 Java 튜토리얼 중 제네릭 타입의 소거(Erasure of Generic Types) 섹션에서는 타입 소거에 대해 다음과 같이 소개하고 있습니다.
타입 소거란?
Java 컴파일러는 제네릭 타입을 처리할 때 타입 파라미터를 제거하고 해당 타입 파라미터의 경계(bound)로 대체합니다. 만약 타입 매개변수에 경계가 지정되지 않았다면, 이를 Object로 대체합니다.
경계(bound)란?
경계(bound)는 타입 파라미터가 어떤 타입을 사용할 수 있는지를 제한하는 규칙입니다. 예를 들어, 다음과 같이 타입 파라미터에 상한 경계를 지정할 수 있습니다. 제네릭의 타입을 인스턴스화할 때 사용하는 타입 인자는 반드시 그 상계 타입이거나 하위 타입이어야 합니다. 경계를 지정할 때는 타입 파라미터 이름 뒤에 콜론(:)을 붙이고, 그 뒤에 상한 타입을 작성합니다.
// java
class Box<T extends Number>{
T item;
}
// kotlin
class Box<T: Number>(val item: T)
Kotlin에서 Number는 Int, Double, Long 등의 상위 타입으로, 다음과 같은 객체 생성이 가능합니다
val box = Box<Int>(1)
즉, 타입 파라미터에 대한 경계를 지정해 주면 해당 경계 이하의 타입만 인자로 받을 수 있도록 제한하고, 경계를 지정해주지 않으면 Java 클래스 계층의 가장 최상위 타입인 Object로 대체됩니다. Oracle의 Java 튜토리얼 예제를 Kotlin으로 변경해 경계가 없을 때와 있을 때 비교해 보겠습니다.
📌 경계가 없는 경우
class Node<T>(
private val data: T,
private val next: Node<T>?
) {
fun getData(): T = data
}
이 경우 T는 어떤 타입이든 가능하며, 디컴파일된 코드를 보면 Object로 대체됩니다.
📌 경계가 있는 경우
class Node<T: Comparable<T>>(
private val data: T,
private val next: Node<T>?
) {
fun getData(): T = data
}
여기서는 T가 반드시 Comparable <T>을 구현한 타입이어야 하며, 컴파일 시에도 이 경계 타입이 유지됩니다.
4. 왜 제네릭의 타입 파라미터는 참조 타입이어야 할까요?
지금까지 살펴본 것처럼 제네릭은 컴파일 타임에 타입 정보를 체크하고 런타임에는 타입 정보를 소거(type erasure) 합니다. 때문에 기본 타입(primitive type) 은 사용할 수 없고 반드시 참조 타입(reference type) 이어야 합니다.
그렇다면 왜 하필 "참조 타입"이어야 할까요?
제네릭의 타입 파라미터는 경계를 지정하지 않으면 Object로 대체됩니다. 이때, Object는 자바에서 참조 타입의 최상위 타입이며 기본 타입(primitive type) 은 이 계층 구조에 속하지 않기 때문입니다. 자바에서는 이러한 이유로 int 같은 원시 타입을 사용할 수 없기 때문에 Integer와 같은 래퍼 클래스로 감싸야합니다.
코틀린에서는 자바의 Object에 대응하는 Any가 존재하고 이는 바이트코드에서 Object로 컴파일됩니다.
또한 코틀린에서는 원시 타입과 래퍼 타입을 구분하지 않습니다. Int, Double 등 기본 타입처럼 보이지만 실제로는 필요시 자동으로 박싱/언박싱되기 때문에 자바에 비해 제네릭을 사용할 때 더 직관적이고 편리하게 다룰 수 있습니다.
5. 원시 타입 배열
만약 객체로의 박싱이 필요 없는 원시 타입의 배열을 사용하고 싶다면 코틀린은 8개의 primitive 배열 클래스와 4개의 unsigned 배열 클래스를 제공합니다.
ByteArray | byte 배열 |
ShortArray | short 배열 |
IntArray | int 배열 |
LongArray | long 배열 |
FloatArray | float 배열 |
DoubleArray | double 배열 |
CharArray | char 배열 |
BooleanArray | boolean 배열 |
Kotlin 1.3부터 도입된 Unsigned 타입에 대한 배열입니다. 이들은 부호 없는 정수 타입을 다루기 위한 배열입니다:
UByteArray | 부호 없는 byte 배열 |
UShortArray | 부호 없는 short 배열 |
UIntArray | 부호 없는 int 배열 |
ULongArray | 부호 없는 long 배열 |
fun main() {
val arr = intArrayOf()
}
정수형 원시 타입 배열을 만들고 디컴파일 해보면 int형 배열로 컴파일되는 것을 확인할 수 있습니다.
6. 참조 타입으로 인한 제약
@JvmInline value class Color(val rgb: Int)
fun main() {
val colors = mutableListOf<Color>()
colors.add(Color(0))
}
Kotlin의 value class는 본래 원시 타입을 객체처럼 다루되, 실제로는 박싱 없이 인라이닝는하는 구조를 가지고 있습니다. 위 예제처럼 Color와 같은 value class를 제네릭 타입 파라미터로 사용하면 내부적으로 박싱(Boxing) 이 일어납니다.
JVM의 제네릭 시스템은 타입 정보를 런타임에 유지하지 않기 때문에 MutableList<Color> 같은 제네릭 컨테이너에 value class를 사용할 경우 컴파일러는 이를 일반 참조 타입(Object)으로 처리하며 이 과정에서 Color는 박싱 된 객체로 변환되어 저장됩니다.
value class를 디컴파일해보면 box-impl, unbox-impl 같은 정적 메서드가 생성되어 있는 것을 확인할 수 있습니다. 이 메서드들은 박싱과 언박싱을 수행하는 메서드입니다. 위 코드에서도 Color(0)이 리스트에 추가될 때, 내부적으로는 Color.box-impl(0)과 같은 메서드가 호출되어 객체가 생성되고, 해당 객체가 리스트에 저장됩니다.
결과적으로 제네릭을 사용하는 순간 value class의 핵심 장점인 인라인 최적화 효과를 얻을 수 없게 됩니다.
이를 바이트코드로 확인해 보면 박싱이 실제로 어떻게 발생하는지 더욱 명확하게 알 수 있습니다. 앞서 본 예제에서 List<Color> 타입의 리스트에 Color 인스턴스를 추가하는 코드의 바이트코드는 다음과 같습니다.
이 바이트코드를 보면 다음과 같은 과정이 일어납니다:
- constructor-impl을 호출하여 Color 값 인스턴스를 생성
- box-impl 메서드를 통해 박싱(Boxing)이 수행
- List<Color>는 타입 소거에 의해 java.util.List로 변환
- Color -> Object로 변환
7. Kotlin Evolution and Enhancement Process (KEEP)
Kotlin/KEEP 저장소는 Kotlin 언어의 발전 방향과 기능 개선을 논의하고 문서화하기 위한 공식 채널입니다. Kotlin 언어와 표준 라이브러리의 새로운 기능을 제안하고 실험하는 과정에서 나온 설계 제안서들(KEEPs)이 모여 있으며, 아직 공식화되지 않은 아이디어나 논의 중인 기능들도 포함되어 있습니다.
해당 링크에선 앞서 살펴본 Value Class와 제네릭에 관한 문제를 다루고 있습니다.
value class의 박싱 문제는 JVM의 제약과 제네릭 타입 소거(generic type erasure)로 인해 발생합니다. 이러한 문제를 극복하자 value class를 더 효율적으로 사용할 수 있도록 Kotlin 커뮤니티에서는 VArray <T> 라는 새로운 개념을 제안하고 있습니다(현재 도입되지 않음!).
VArray<T>는 value class 및 primitive 타입을 저장하기 위한 특수한 배열 타입으로, 기존의 Array<T>가 참조를 저장하는 데 반해 VArray<T>는 값 자체를 인라이닝 합니다.
하지만 컴파일러가 실제 타입을 알고 있어야만 해당 타입에 맞는 원시 배열로 컴파일할 수 있기 때문에 VArray<T>는 T가 구체적인(reified) 타입일 경우에만 사용 가능하다는 제약이 있습니다.
reified
제네릭 함수는 함수 내부에서 호출 시 전달된 타입 인자를 알 수 없습니다. 이는 지금껏 살펴본 타입 소거(type erasure) 때문으로 런타임에 타입 정보가 지워지기 때문입니다. 따라서 이런 제네릭 함수에서 타입 정보를 활용하고 싶다면 inline 함수로 바꿔야 합니다.
에러 메시지에서도 “타입 매개변수를 구체화하고 함수를 인라인 화하라”라고 안내하는데, 이는 다음과 같은 의미입니다.
- inline 키워드를 붙이면, 컴파일러는 해당 함수의 본문을 호출 지점에 그대로 복사합니다.
- 이 과정에서 람다 인자도 함께 인라인 되며 이로 인해 람다 객체 생성을 피할 수 있어 성능 상 이점이 있습니다.
- 그리고 제네릭 타입 파라미터에 reified 키워드를 사용하면, 런타임에도 타입 정보를 사용할 수 있게 됩니다.
inline fun <reified T> isString(value: Any) = value is T
reified는 타입 소거를 우회할 수 있게 해 주며, 이를 "실체화된 타입 파라미터(reified type parameter)"라고 부릅니다.
inline fun <reified T> isString(value: Any) = value is T
fun main() {
println(isString<String>("Hello")) // true
println(isString<String>(123)) // false
}
위 예제의 바이트 코드를 보면 기존에 Object로 대체되던 타입이 String으로 유지되는 것을 확인할 수 있습니다.
코틀린에선 VArray<T>에 대해 제안하고 있지만 해당 문서가 작성된 것은 3년 전인지라 도입 여부를 알 수는 없을 것 같습니다.
8. 참고 문헌
- Kotlin In Action(2/e)
- Oracle Java Tutorial: Erasure of Generic Types
- Kotlin KEEP
'KOTLIN' 카테고리의 다른 글
Abstract class vs interface in Kotlin (2) | 2025.04.01 |
---|---|
다시 읽는 Effective Kotlin - Item39. 태그 클래스보다는 클래스 계층을 사용하라 (2) | 2025.03.23 |
다시 읽는 Effective Kotlin - Item33. 생성자 대신 팩토리 함수를 사용하라 (2) | 2025.03.02 |
Channel 내부 동작 분석을 분석해보자 (0) | 2025.02.07 |
Kotlin Value Class With Project Valhalla (4) | 2024.11.22 |