[코틀린 인 액션] 6장, 코틀린 타입 시스템

kindof

·

2023. 8. 20. 23:24

[코틀린 인 액션] 책을 읽으면서 이해한 내용을 정리합니다.

책의 설명을 기반으로 하되, Java와의 비교나 주관적인 생각들도 써보려고 하는데요. 코드를 작성하면서 궁금한 부분이나 다른 기본적인 내용들은 최대한 공식 문서를 참고해서 작성해보겠습니다.

 

코틀린을 사용하는 대표적인 장점 중 하나는 코틀린이 제공하는 타입 시스템 때문입니다. 널 가능성(nullability)은 NPE를 피할 수 있게 돕기 위한 코틀린 차입 시스템의 특성인데요.

 

이번 글에서는 코틀린에서 null을 처리하는 방법, 코틀린 원시 타입과 컬렉션, 배열에 대해 소개합니다.

 

설명하는 코드는 책에서 소개한 코드에 기반하여 설명을 위해 조금씩 각색 + 추가한 부분이 있을 수 있습니다.

 

1. 코틀린에서 null 가능성, 어떻게 다루나

코틀린에서 null에 대한 접근 방법은 가능한 이 문제를 Runtime에서 Compile 시점으로 옮기는 것입니다.

 

널이 될 수 있는지 여부를 타입 시스템에 추가함으로써 컴파일러가 여러 가지 오류를 컴파일 시 미리 감지해서 런타임에 발생할 수 있는 예외(NPE)의 가능성을 줄일 수 있습니다.

 

책에서 소개하는 순서와는 달리, 먼저 코틀린에서 null을 처리하기 위한 몇 가지 키워드를 전체적으로 살펴보고, 테스트 코드를 통해 어떻게 사용하는지 검증해보겠습니다.

 

[1] '?' 어떤 타입이든 타입 이름 뒤에 물음표를 붙이면 그 타입의 변수나 프로퍼티에 null 참조를 저장할 수 있다는 뜻입니다.

 

널이 될 수 있는 타입의 변수에는 null 참조를 저장할 수 있다.

 

 

[2] '?.' 호출하려는 값이 null이 아니라면 ?.은 일반 메서드 호출처럼 작동합니다. 호출하려는 값이 null이면 이 호출은 무시되고 null이 결과 값이 됩니다.

안전한 호출 연산자는 널이 아닌 값에 대해서만 메서드를 호출한다.

 

 

[3] '?:' null 대신 사용할 디폴트 값을 지정할 때 편리하게 사용할 수 있는 엘비스(elvis) 연산자입니다. 코틀린에서는 return, throw 등의 연산도 식이기 때문에 엘비스 연산자의 우항에 return, throw 등의 연산을 넣을 수 있고, 엘비스 연산자를 더욱 편하게 사용할 수 있습니다.

엘비스 연산자는 널을 특정 값으로 바꿔준다,

 

 

[4] 'as?' 이 연산자는 어떤 값을 지정한 타입으로 캐스트하는데, 값을 대상 타입으로 변환할 수 없으면 null을 반환합니다.

타입 캐스트 연산자는 값을 주어진 타입으로 변환하려 시도하고 타입이 맞지 않으면 null을 반환한다.

 

 

[5] '!!' 키워드는 코틀린에서 널이 될 수 있는 타입의 값을 다룰 때 사용할 수 있는 도구입니다. 느낌표를 이중으로 사용하면 어떤 값이든 널이 될 수 없는 타입으로(강제로) 바꿀 수 있습니다.

 

중요한 포인트는, 근본적으로 !!는 컴파일러에게 "나는 이 값이 null이 아님을 잘 알고 있다. 내가 잘못 생각했다면 예외가 발생해도 감수하겠다"라고 말하는 것입니다.

널 아님 단언을 사용하면 값이 널일 때 NPE를 던질 수 있다.

 

 

[6] 'let' let 함수를 안전한 호출 연산자와 함께 사용하면 원하는 식을 평가해서 결과가 널인지 검사한 다음에 그 결과를 변수에 넣는 작업을 간단한 식을 사용해 한꺼번에 처리할 수 있습니다.

let을 안전하게 호출하면 수신 객체가 널이 아닌 경우 람다를 실행해준다.

 

 

여기까지 코틀린에서 널을 처리하기 위한 여섯가지 키워드를 살펴봤습니다.

 

다시 한 번 강조하지만, 코틀린의 타입 시스템은 코드에서 null 참조의 위험을 제거하는 것을 목표로 합니다.

 

이제, 위에서 소개했던 내용들을 테스트 코드로 다시 확인해보겠습니다.

import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import kotlin.test.assertFailsWith

data class Person(val country: Country?)
data class Country(val code: String?)

class NullSafetyTest {

    @Test
    @DisplayName("?. 키워드는 호출하려는 값이 null이 아닐 때만 메서드를 호출한다.")
    fun safe_call_occurs_only_with_not_null_object() {
        val p1: Person? = Person(Country("ENG"))
        val res1 = p1?.country?.code
        val p2: Person? = Person(Country(null))
        val res2 = p2?.country?.code

        assertEquals(res1, "ENG")
        assertNull(res2)
    }

    @Test
    @DisplayName("elvis 연산자를 통해 null일 때 디폴트 값을 지정할 수 있다.")
    fun elvis_keyword_enable_default_value_on_nullable_object() {
        val value1: String? = null
        val res1 = value1?.length ?: -1
        val value2: String? = "name"
        val res2 = value2?.length ?: -1

        assertEquals(res1, -1)
        assertEquals(res2, 4)
    }

    @Test
    @DisplayName("let 키워드는 null이 될 수 있는 타입의 값을 널이 될 수 없는 타입으로 바꿔 람다에 전달한다.")
    fun let_keyword_only_deliver_non_null_parameter_to_lambda_exp() {
        val firstName = "Tom"
        val secondName = "Michael"
        val names: List<String?> = listOf(firstName, null, secondName)

        var res = listOf<String?>()
        for (item in names) {
            item?.let { res = res.plus(it) }
        }

        assertEquals(2, res.size)
        assertTrue { res.contains(firstName) }
        assertTrue { res.contains(secondName) }
    }

    @Test
    @DisplayName("as? 키워드는 값을 대상 타입으로 변환할 수 없으면 null을 반환한다.")
    fun as_keyword_returns_null_when_not_available_type_casting() {
        val num: Number = 42
        val intNum = num as? Int
        assertEquals(42, intNum)

        val doubleNum = num as? Double
        assertNull(doubleNum)
    }

    @Test
    @DisplayName("!! 키워드는 null이 아님을 단언하고 NPE를 발생시킬 수 있다.")
    fun not_null_assertion_can_throw_NPE() {
        val nullableValue: String? = null

        assertFailsWith<NullPointerException> {
            val nonNullableValue: String = nullableValue!!
        }
    }
}

각 테스트는 위에서 소개했던 코틀린 타입 시스템의 키워드들의 동작을 검증했습니다.

 

아래처럼 모든 테스트가 성공합니다.

테스트 코드 수행 결과

 

 

2. 코틀린 원시 타입

자바는 원시 타입과 참조 타입을 구분합니다. 원시 타입(primitive type)이 변수에는 그 값이 직접 들어가지만, 참조 타입(reference type)의 변수에는 메모리상의 객체 위치가 들어갑니다.

 

하지만 코틀린은 원시 타입과 래퍼 타입을 구분하지 않고 항상 같은 타입을 사용하며 실행 시점에 각 래퍼 타입을 가능한 한 가장 효율적인 방식으로 표현합니다.

 

대부분의 경우(변수, 프로퍼티, 파라미터, 반환 타입 등) 코틀린의 Int 타입은 자바 int 타입으로 컴파일됩니다.

 

이런 컴파일이 불가능한 경우는 컬렉션과 같은 제네릭 클래스를 사용하는 경우 뿐인데, 예를 들어 Int 타입을 컬렉션의 타입 파라미터로 넘기면 그 컬렉션에는 Int의 래퍼 타입에 해당하는 java.lang.Integer 객체가 들어갑니다.

 

한편, null 참조를 자바의 참조 타입의 변수에만 대입할 수 있기 때문에 널이 될 수 있는 코틀린 타입은 자바 원시 타입으로 표현할 수 없습니다. 따라서, 코틀린에서 널이 될 수 있는 원시 타입을 사용하면 그 타입은 자바의 래퍼 타입으로 컴파일됩니다.

 


자바에서 Object가 클래스 계층의 최상위 타입이듯 코틀린에서는 Any 타입이 모든 널이 될 수 없는 타입의 조상 타입입니다.

 

자바에서 원시 타입(int, boolean 등)은 당연히 Object의 자손이 아니기 때문에 Object 타입 객체로 변환하려면 래퍼 타입으로 감싸야 합니다. 하지만, 코틀린에서는 앞에서도 말했듯 원시 타입을 사용하지 않기 때문에 Int 등의 타입도 Any의 자손이 됩니다.

 

Any Class

그리고 당연하게도, 코틀린 클래스에는 toString, equals, hashcode 라는 세 메서드가 들어있는데 이 세 메서드는 Any에 정의된 메서드를 상속한 것입니다.

 

 

3. 코틀린 컬렉션에서 달라진 점

코틀린 컬렉션과 자바 컬렉션을 나누는 가장 큰 중요한 특성 하나는 코틀린에서는 컬렉션 안의 데이터에 접근하는 인터페이스와 컬렉션 안의 데이터를 변경하는 인터페이스를 분리했다는 점입니다.

 

MutableCollection

코틀린에서 MutableCollection은 일반 인터페이스인 kotlin.collections.Collection을 확장하면서 원소의 추가, 삭제 등의 메서드를 제공합니다.

 

즉, 코틀린에서 컬렉션의 데이터를 수정하려면 kotlin.collections.MutableCollection 인터페이스를 사용해야 한다는 것입니다.

 

어떤 함수가 MutableCollection이 아닌 Collection 타입의 인자를 받는다면 그 함수는 컬렉션을 변경하지 않고 읽기만 해야 합니다. 반면, 어떤 함수가 MutableCollection을 인자로 받는다면 그 함수가 컬렉션의 데이터를 바꾸리라 가정할 수 있죠.

 


한편, 컬렉션 인터페이스를 사용할 때 항상 염두에 둬야 할 핵심은 "읽기 전용 컬렉션이라고해서 꼭 변경 불가능한 컬렉션일 필요는 없다"는 점입니다.

 

동일한 컬렉션 객체를 가리키는 읽기 전용 컬렉션 타입의 참조와 변경 가능한 컬렉션 타입의 참조가 있는 경우에 컬렉션을 사용하는 도중에 다른 컬렉션이 그 컬렉션의 내용을 변경하는 상황이 생길 수 있고, 이런 상황에서는 ConcurrentModificationException이 발생할 수 있는데요.

 

실제 아래 테스트 코드를 실행해보면 이 상황을 재현할 수 있습니다.

@Test
@DisplayName("읽기 전용 컬렉션이 항상 쓰레드 안전하지는 않다")
fun read_only_collection_is_not_thread_safe() {

    val sharedList: MutableList<Int> = mutableListOf(1, 2, 3, 4)
    
    val readOnlyList: List<Int> = sharedList
    val concurrentList: MutableList<Int> = sharedList

    val readingThread = thread {
        println("readingThread start!")
        try {
            // 읽기 전용 리스트에서 원소를 읽음
            for (element in readOnlyList) {
                println("Reading readOnlyView: $element")
            }
        } catch (e: Exception) {
            println("Exception in readingThread: ${e.javaClass.simpleName}")
        }
    }

    val concurrentThread = thread {
        println("concurrentThread start!")
        try {
            // 수정 가능한 컬렉션에서 sharedList에 수정 시도
            for (element in concurrentList) {
                concurrentList.add(20)
                println("Modifying concurrentList")
            }
        } catch (e: Exception) {
            println("Exception in concurrentThread: ${e.javaClass.simpleName}")
        }
    }

    readingThread.join()
    concurrentThread.join()
}


// output
readingThread start!
Reading readOnlyView: 1
Reading readOnlyView: 2
Reading readOnlyView: 3
Reading readOnlyView: 4
concurrentThread start!
Modifying concurrentList
Exception in concurrentThread: ConcurrentModificationException

따라서, "읽기 전용 컬렉션이 항상 Thread safe하지는 않다"는 점을 알아야 할 것 같습니다.

 

마지막으로 코틀린 컬렉션과 자바 컬렉션을 비교한 그림을 보겠습니다.

 

코틀린 컬렉션 인터페이스의 계층 구조. 자바 클래스 ArrayList, HashSet은 코틀린의 변경 가능 인터페이스를 확장한다.

 

 

4. 정리 / Reference

이번 글에서는 코틀린에서 null을 다루는 방법, 코틀린의 원시 타입과 컬렉션에 대해 살펴봤습니다.

 

이제 각 주제마다 알아야 할 내용들이 많아지고, 조금 더 효율적으로 이런 개념들을 사용하기 위한 방법론들에 대해서도 알아야 할 것 같습니다.

 

책에서는 다뤘지만 정리하지 않은 내용, 더 깊게 정리해야 하는 내용 등은 다른 글에서 따로 빼서 정리해야겠습니다.

 

다음 주제는 [코틀린의 연산자 오버로딩과 기타 관례]에 대한 내용입니다.

 

 

Null-safety :: Spring Framework

One of Kotlin’s key features is null-safety, which cleanly deals with null values at compile time rather than bumping into the famous NullPointerException at runtime. This makes applications safer through nullability declarations and expressing “value

docs.spring.io

 

Kotlin in Action | 드미트리 제메로프 - 교보문고

Kotlin in Action | 코틀린이 안드로이드 공식 언어가 되면서 관심이 커졌다. 이 책은 코틀린 언어를 개발한 젯브레인의 코틀린 컴파일러 개발자들이 직접 쓴 일종의 공식 서적이라 할 수 있다. 코틀

product.kyobobook.co.kr