프로그래밍언어/Kotlin

Kotlin - 코틀린 타입 시스템(널이 될 수 있는 타입)

여성게 2020. 9. 19. 19:56

 

이번 포스팅에서는 코틀린의 타입 시스템에 대해 다루어볼 것이며, 주로 코틀린에서 Null을 다루는 방법을 주로 다루어볼 것이다.

 

널 가능성

널 가능성은 NPE 오류를 피할 수 있게 돕기 위한 코틀린 타입 시스템의 특성이다. 자바 객체는 기본적으로 null을 허용하고, null로 받은 객체 이지만, 해당 객체의 모든 메서드를 호출할 수 있게 설계 되었기 때문에, 런타입에 NPE가 많이 발생한다. 하지만 코틀린은 null 문제를 런타임 시점이 아니라 컴파일 시점으로 옮김으로써, 컴파일 시점에 실행 시점에 발생할 수 있는 여러가지 문제의 가능성을 줄여준다.

 

널이 될 수 있는 타입

코틀린과 자바의 가장 중요한 차이는 코틀린 타입 시스템은 널이 될 수 있는 타입을 명시적으로 지원한다는 것이다. 여기서 널이 될 수 있는 타입이란, 프로그램 안의 프로퍼티나 변수에 null을 허용하게 만드는 방법이다. 여기까지 자바와 무슨 차이가 있냐 생각이 들겠지만, 아래 예제를 보면 확실히 코틀린과 자바의 null을 다루는 방법은 다르다.

 

//컴파일 에러
fun strLen(string: String?) = string.length

 

자바는 위 예제의 메서드를 정의하는데 아무런 컴파일 에러 없이 넘어갈 수 있지만, 코틀린은 Null을 허용하는 타입을 인자로 받는 함수에서 위와 같이 작성하면 컴파일 에러가 발생한다. 그말은 null이 될 수 있는 타입의 메서드 호출을 컴파일 타임에 제한하여 런타임에 NPE 여부를 사전에 차단해버리는 것이다. 또한 아래와 같은 것도 컴파일 시점에 잡아버린다.

 

fun strLength(str: String) = str.length

fun main() {
    strLength(null) //컴파일 에러
}

 

함수의 정의에서 널이 될 수 없는 String을 인자로 받고 있으므로, str.length에서는 컴파일에러가 나지 않는다. 하지만, 이 함수를 가져다 쓸때 인자에 null을 넣으면 컴파일 에러가 발생한다. 그 이유는 널이 될 수 없는 타입에 null을 인자로 넘겼기 때문이다.

 

val x: String? = null
val y: String = x

 

위와 같은 코드도 허용하지 않는다. 널이 될 수 있는 타입을 널이 될 수 없는 타입에 대입이 불가능한것이다. 그러면 여기까지 널이 될 수 있는 타입으로 뭘할 수 있을까? 너무 제약이 많다고 생각이 들 수 있지만, 사실 널이 될 수 있는 타입과 null을 비교하여 null이 아닌 것을 증명하면 그 뒤로는 널이 될 수 없는 타입처럼 사용이 가능하다.

 

fun strLenSafe(s: String?): Int =
        if (s != null) s.length else 0

 

위 코드는 더 간결하게 표현 가능하지만, 여기서 다루지 않고 뒷 내용에서 다룬다.

 

타입의 의미

자바에서는 null을 다루기 위해, Optional이라는 해법이 나왔다. 하지만, 이 또한 어떠한 객체를 래핑하는 래퍼 역할이기 때문에 자칫하면 런타임의 성능이 떨어질 수 있다. 하지만 코틀린에서 널이 될 수 있는 타입이나 널이 될 수 없는 타입은 런타임에 각은 객체 타입이기 때문에 실행 시점의 성능은 똑같고, 부가 비용이 들지 않는다. 그 이유는 모든 가능성을 최대한 컴파일 타임에 잡기 때문이다.

 

안전한 호출 연산자: ?.

코틀린이 제공하는 가장 유용한 도구 중 하나가 안전한 호출 연산자인 "?."이다. "?."은 null 검사와 메서드 호출을 한번의 연산으로 수행한다. 예를 들어, "str?.toUpperCase()""if (str != null) s.toUpperCase() else null"과 같다. 다시 풀어서 이야기하면, 호출하려는 값이 null이 아니면 일반 메서드 호출처럼 작동하고 만약 호출하려는 값이 null이면 null이 결과가 된다.

 

foo?.bar() -> foo != null -> foo.bar()
           -> foo == null -> null

 

여기서 유의 해야할 점은 안전한 호출의 결과 타입도 널이 될 수 있는 타입이라는 것이다.

 

fun getUpperCase(s: String?) = s?.toUpperCase()

val upperCase: String? = getUpperCase("str")

 

안전한 호출은 클래스의 프로퍼티를 다룰때도 적용할 수 있다.

 

class Employee(val name: String, val manager: Employee?)
fun managerName(employee: Employee): String? = employee.manager?.name

 

이러한 안전한 호출은 연쇄해서 호출가능하다.

 

class Address(val streetAddress: String, val zipCode: Int, val city: String, val country: String)
class Company(val name: String, val address: Address?)
class Person(val name: String, val company: Company?)
fun Person.countryName(): String {
    val country = this.company?.address?.country
    return if (country != null) country else "Unknown"
}

 

널 검사가 들어간 호출이 연달아 있는 경우를 자바 코드에서 아주 자주 볼 수 있다. 하지만 코틀린에서는 훨씬 간결하게 널 검사가 가능하다. 사실 위 코드에서 if문을 없애서 간결하게 표현이 가능하다.

 

엘비스 연산자: ?:

코틀린은 null 대신 사용할 디폴트 값을 지정할 때 편리하게 사용할 수 있는 연산자를 제공한다. 그 연산자는 엘비스 연산자라고 한다. 위 코드를 엘비스 연산자를 사용하여 한줄로 리팩토링해보자.

 

fun Person.countryName() = this.company?.address?.country ?: "Unknown"

 

훨씬 더 코드가 간결해졌다. 엘비스 연산자는 이항 연산자로 좌항을 계산한 값이 널인지 검사한다. 좌항 값이 널이 아니라면 좌항값을 그대로 리턴하고, 만약 좌항값이 널이면 우항 값으로 리턴한다.

 

foo ?: bar -> foo != null -> foo
           -> foo == null -> bar

 

위 리팩토링한 코드 예제와 같이 엘비스 연산자를 객체가 널인 경우 널을 반환하는 안전한 호출 연산자와 함께 사용해서 객체가 널인 경우 디폴트 값을 반환하도록 많이 사용한다. 그리고 코틀린에서는 return&throw 등의 연산도 식이기 때문에, 엘비스 연산자 우항에 throw 식을 넣어 null 일 경우 예외를 발생시키는 등의 로직을 만들 수 있다. 이러한 패턴으로 함수의 전제 조건을 검사하는 경우 아주 유용한 연산자가 될 수 있다.

 

class Address(val streetAddress: String, val zipCode: Int, val city: String, val country: String)
class Company(val name: String, val address: Address?)
class Person(val name: String, val company: Company?)

fun printShippingLabel(person: Person) {
    val address = person.company?.address ?: throw IllegalArgumentException("empty address")
    with (address) {
        println(streetAddress)
        println("$zipCode $city, $country")
    }
}

 

안전한 호출 연사자, 엘비스 연산자, with 함수를 이용하여 위와 같은 코드를 만들 수 있다.

 

안전한 캐스트: as?

코틀린은 자바와 같이 타입 캐스트시 대상 타입으로 캐스팅을 할 수 없을 경우, ClassCastException이 발생한다. 하지만 역시 코틀린에서는 컴파일 타임에 최대한 해당 예외 발생을 줄여주기 위해 안전한 캐스트 연산자인 "as?" 를 지원한다.

 

as?연산자는 어떤 값을 지정한 타입으로 캐스팅하는데, 캐스팅할 수 없는 타입이라면 null을 반환한다.

 

foo as? Type -> foo is Type -> foo as Type
             -> foo !is Type -> null

 

안전한 캐스팅을 이용할때는 보통 엘비스 연산자를 같이 사용하는 경우가 많다.

 

class Person(val name: String, val company: Company?) {
    override fun equals(other: Any?): Boolean {
        val otherPerson = other as? Person ?: return false
        
        return otherPerson.name == this.name
    }
}

 

위 예제는 other가 Person 타입이 아닐 경우, 바로 equals 함수의 값을 false로 리턴한다.

 

let 함수

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

 

fun sendEmailTo(email: String) {
    println("send email to $email")
}

fun main() {
    val email: String? = null
    if (email != null) sendEmailTo(email)
}

 

위 예제를 보면 sendEmailTo 함수를 호출하기전에 인자 값이 널인지를 체크해야지만 컴파일 에러가 나지 않는다. 하지만 let 함수를 사용해 위와 같은 로직을 더 간결하게 표현할 수 있다.

 

fun main() {
    val email: String? = null
    email?.let { sendEmailTo(it) }
}

 

email의 안전한 호출 구문을 호출했고, email이 널이 아닐 때만, let의 수신 객체로 들어가게 된다. 즉, let 함수 안의 it이 널이 될 수 없는 email이 되는 것이다.

 

foo?.let { -> foo != null -> let함수가 수행되고, it은 널이 아니다.
	..it.. -> foo == null -> 아무일도 일어나지 않고, null이 담긴다.
}

 

let 함수는 긴 식이 있고, 그 값이 널이 아닐 때 수행해야하는 로직이 있을 때 아주 유용하다.

 

Lazy init

코틀린에서 널이 될 수 없는 타입의 프로퍼티는 반드시 생성자 안에서 초기화하지 않고, 특별한 메서드 안에서 초기화할 수는 없다. 코틀린에서는 일반적으로 생성자에서 모든 프로퍼티를 초기화해야 한다. 게다가 널이 될 수 없는 타입의 프로퍼티는 널이 아닌 값으로 초기화해야한다. 그렇다고 널이 될 수 있는 프로퍼티에 null를 할당하고, 생성자가 아닌 함수에서 초기화를 해버리면 해당 프로퍼티를 사용하는 곳에서 항상 null 체크를 하는 코드를 넣어줘야 한다.

 

class MyService {
    fun performAction(): String = "foo"
}

class MyTest {
    private var myService: MyService = null
    
    @Before
    fun setUp() {
        myService = MyService()
    }
    
    @Test
    fun testAction() {
        Assert.assertEquals("foo", myService!!.performAction())
    }
}

 

위와 같은 예제와 비슷하게 스프링 프레임워크와 코틀린을 같이 사용하는 경우 DI를 위해 생성자 레벨에서 초기화를 하지 못하는 경우가 종종있다. 이러한 문제 해결을 위해 코틀린에서는 lazy init(지연 초기화)를 제공한다.

 

class MyTest {
    private lateinit var myService: MyService

    @Before
    fun setUp() {
        myService = MyService()
    }

    @Test
    fun testAction() {
        Assert.assertEquals("foo", myService.performAction())
    }
}

 

지연 초기화를 사용하기 위해서는 프로퍼티가 항상 var여야 한다. val 프로퍼티는 final 필드로 컴파일되며, 생성자에서 반드시 초기화해야한다. 만약 lateinit으로 선언한 프로퍼티를 초기화하지 않고 어딘가에서 사용한다면 어떻게 될까? 그럴 경우에는 코틀린은 런타임 시점에 아직 초기화가 되지 않았다라는 에러를 발생시켜준다.

 

널이 될 수 있는 타입 확장 함수

널이 될 수 있는 타입의 확장함수를 정의하면 null에 대한 대비를 조금더 우아하게 할 수 있다. String 클래스의 함수를 예제로 살펴보자.

 

@kotlin.internal.InlineOnly
public inline fun CharSequence?.isNullOrBlank(): Boolean {
    contract {
        returns(false) implies (this@isNullOrBlank != null)
    }

    return this == null || this.isBlank()
}

 

수신 객체가 null임을 체크함으로써 String 객체가 null 일 경우를 대비할 수 있다.

 

타입 파라미터의 널 가능성

코틀린에서 타입 파라미터는 기본적으로 널이 될 수 있는 타입이다. 즉, Any? 타입이 되는 것이다.

 

fun <T> printObject(t: T) {
    println(t?.toString())
}

fun <T: Any> printObject(t: T) {
    println(t.toString())
}

 

하지만 위 예제처럼 타입 파라미터의 upper bound를 지정하여 널이 될 수 없는 타입 파라미터로 정의할 수 있다.

 

여기까지 코틀린에 타입 시스템에 대해 간단히 다루어 보았다. 마지막으로, 코틀린에서 자바 API를 이용하며, 상속 혹은 인터페이스를 구현할때, 보통 메서드를 오버라이드한다. 그때 자바 상위타입 메서드 선언의 인자의 타입을 널이 허용되는 타입으로 봐야할까 널이 허용되지 않는 타입으로 봐야할까? 그것은 코틀린에서 구현하는 사람이 정해야한다. 그 말은 둘다 가능하다는 말이다. 실제 그 상위 인터페이스 혹은 클래스의 용도를 잘 분석해 널을 허용하는 인자로 볼지 널을 허용하지 않는 인자로 볼지 잘 결정하고 구현해야한다.