TIL

[Kotlin] List가 MutableList를 사용하는 이유

친환경사과 2025. 3. 30. 20:15

🍎 Kotlin List 사용 중 내부 코드에서 List가 MutableList를 사용하는 것을 발견해 어떤 이유로 MutableList를 사용하는지 정리합니다.

+ 또한 이에 적용된 설계 Practice를 정리하고 어떤 방식으로 설계에 녹여낼 수 있을지 정리합니다.


🍏 List 생성 내부 Code

✓ Immutable(불변)인 List를 호출하는 것을 기대했지만 MutableList를 호출하는 모습을 볼 수 있습니다.

kotlin.collections.collections.kotlin_builtins 속 List Inline function

 

🍏 MutableList 생성 내부 Code

 List 함수 바로 아래 위치해 있는 MutableList 함수는 ArrayList를 내부 변수로 선언한 후 반복을 통해 Element를 집어 넣습니다.

kotlin.collections.collections.kotlin_builtins 속 MutableList Inline function

 

❓ 왜 ListMutableList를 호출하는 것일까요??

List 또한 MutableList 함수를 호출하여 내부에서 ArrayList를 호출하여 list를 반환하는데 말이죠

✓ 이유를 알기 위해선 MutableList InterfaceList Interface를 살펴봐야 합니다.

 

🍏 List Interface, MutableList Interface

List InterfaceCollection Interface를 상속하고 List에 read-only를 허용한다고 설명되어 있습니다.

 

MutableList InterfaceList Interface와 MutableCollection을 상속하여 Collection의 Element를 추가 삭제할 수 있다고 설명되어 있습니다.

(Plus, MutableCollection Interface는 Collection과 MutableIterable Interface를 상속합니다.)

 

✓ 상속 관계를 통해 알 수 있는 것은 MutableList InterfaceList Interface를 부모로 두고 상속을 진행하고 있다는 점입니다. 

✓ 다시 List, MutableList 함수로 돌아가 함수 호출부가 아닌 반환값에 주목해야 합니다.

✓ List 함수는 MutableList를 호출하고 있지만 반환값은 List <T>이고, MutableList 함수의 반환값은 MutableList <T>입니다.

Upcasting을 통해 MutableList Instance를 List로 Upcasting 하여 List를 반환하는 과정을 볼 수 있습니다.

 

❓ Upcasting은 무엇이며 위 코드에서 이유에서 사용한 것인가요?

Upcasting이란 OOP에서 자식 클래스의 객체를 부모 클래스 타입으로 참조하는 것을 말합니다.

// 실제 객체는 여전히 ArrayList(MutableList 구현체)입니다
val mutableList = mutableListOf(1, 2, 3)  // ArrayList 타입
val readOnlyList: List<Int> = mutableList  // 업캐스팅

// 데이터는 그대로 유지됩니다
println(readOnlyList)  // [1, 2, 3] 출력

// 원본을 변경하면 readOnlyList에도 반영됩니다 (같은 객체를 참조하기 때문)
mutableList.add(4)
println(readOnlyList)  // [1, 2, 3, 4] 출력

코드에서 볼 수 있듯 List <Int>로 Upcasting 한 readOnlyList는 Element의 추가적인 조작이 제한되어 read_only만을 제공합니다.

✓ 리스트 안의 구성 요소를 변경하는 작업은 오직 원본인 mutableList를 통해서만 가능합니다.

 

이를 통해 볼 수 있는 Upcasting의 장점은 "접근 가능한 기능 제한"입니다.

 

❗️ List 함수에선 MutableList를 호출하지만 반환값을 List로 지정함으로 Upcasting 되어 요소들의 추가/제거 기능을 제한한 것입니다.


❓ Upcasting이 JVM Memroy Level에선 어떻게 구성되는지 궁금해졌습니다. 🐍

 

동물(부모) - 강아지(자식) 관계의 예제 코드를 통해 살펴봅니다.

open class Animal {
    open fun makeSound() {
        println("Some animal sound")
    }

    fun eat() {
        println("Animal is eating")
    }
}

// 자식 클래스
class Dog : Animal() {
    // 부모 클래스의 메서드 오버라이드
    override fun makeSound() {
        println("Woof! Woof!")
    }

    // 자식 클래스만의 추가 메서드
    fun fetch() {
        println("Dog is fetching the ball")
    }
}

fun main() {
    // 자식 클래스 객체 생성
    val myDog = Dog()

    // 기본 상태 - Dog의 모든 메서드 사용 가능
    myDog.makeSound()  // 출력: Woof! Woof!
    myDog.eat()        // 출력: Animal is eating
    myDog.fetch()      // 출력: Dog is fetching the ball

    // 업캐스팅: Dog 객체를 Animal 타입으로 참조
    val animalRef: Animal = myDog

    // 업캐스팅 후 접근 가능한 메서드
    animalRef.makeSound()  // 출력: Woof! Woof! (다형성에 의해 여전히 Dog의 메서드 호출)
    animalRef.eat()        // 출력: Animal is eating

    // animalRef.fetch()   // 컴파일 오류: Animal 타입에는 fetch() 메서드가 없음

    println(myDog.toString()) // 출력: Dog@372f7a8d
    println(animalRef.toString()) // 출력: Dog@372f7a8d
}

 

✓ 코드 블록은 런타임 때 인스턴스 생성 시 아래 도표와 같이 구성됩니다.

 

컴파일 단계에서 검증된 바이트 코드(. class)는 JVM 클래스 로딩 시점에서 클래스 내부 파일에 존재하는 정보 바탕으로 상속 관계, 메서드 등을 동적으로 Method 영역에 구성합니다.

컴파일된 클래스 파일이 갖는 요소들

 

✓ 다음으로 Instance 생성 시 myDog, animalRef 참조 변수들이 Stack 영역에 저장됩니다.

 

✓ 생성된 myDog을 Animal 부모 클래스로 Upcasting 되어 생성된 animalRef는 myDog와 같은 주소값을 갖게 됩니다. 

 

 동일한 주소 값을 갖게 되었는데 왜 animalRef는 fetch() 함수를 사용하지 못하는 것일까요?

 

이유는 컴파일링 시 Animal.class에 fetch() 함수가 존재하지 않기 때문입니다.

 

지금까지 ClassFile의 MetaData에서 상속 관계가 존재하니 해당 상속 관계에서 어떠한 필터링 과정을 통해 부모 인스턴스가 자식의 자식의 함수를 사용하지 못한 것으로 짐작하고 있었는데 그게 아닌 컴파일 시 부모 클래스의 정의를 검사하고 해당 메서드가 없음을 확인한 후 컴파일 파일 오류가 발생되는 것을 확인할 수 있었습니다.

 

타입 캐스팅이 되면 부모 인스턴스가 자식 인스턴스의 함수를 사용할 수 있는데 이 때는 어떤 과정을 거치는 건가요?

// 객체 자체는 여전히 Dog 타입임을 증명
if (animalRef is Dog) {
    println("Reference is actually a Dog")

    // 스마트 캐스트: Kotlin이 자동으로 Animal을 Dog로 캐스팅
    animalRef.fetch()  // 출력: Dog is fetching the ball
}

 

위 코드에선 명시적으로 타입이 주어집니다. 런타임 단계에서 타입이 지정되는 것이 아닌 컴파일러가 타입 체크 후 블록 내에서 animalRef를 Dog 타입으로 취급합니다.

컴파일 시간에 명시적인 캐스팅을 포함하는 바이트 코드를 생성하는 것이죠

✓ 결과적으로 런타임에서는 이미 객체가 Dog 타입이므로 캐스팅이 성공하고 fetch() 메서드가 정상적으로 호출됩니다.


🍎 결과

✓ List와 MutableList의 생성 과정을 파헤치면서 Upcasting과 Upcasting 시 JVM 메모리 레벨에서 어떤 일들이 발생하는지 알아봤습니다.

✓ 이번 글을 통해 지레짐작하고 있던 잘못된 지식을 바로 잡았고 무엇보다 큰 수확은 Upcasting을 사용했을 때 "접근 가능한 기능 제한"을 할 수 있다는 것을 알았다는 점입니다.

✓ 앞으로 모델을 설계할 때 모델의 성격에 따라 제한된 접근이 필요한 상황이라면 이번에 알게된 Practice를 활용해야겠습니다.


📚 Reference

 JVM Class 구조

 

Chapter 4. The class File Format

Attributes are used in the ClassFile, field_info, method_info, Code_attribute, and record_component_info structures of the class file format (§4.1, §4.5, §4.6, §4.7.3, §4.7.30). For all attributes, the attribute_name_index item must be a valid unsigne

docs.oracle.com