본문 바로가기
프로그래밍/kotlin

[kotlin] 코틀린 차곡차곡 - 7. NPE(NullPointException) 안전하게 코딩하기

by 사바라다 2021. 6. 20.

안녕하세요. 오늘은 코틀린이 NPE에서 안전할 수 있는 언어적인 특성에 대해서 알아보도록 하겠습니다 !

NPE(NullPointerException) 문제

null 이라는 개념은 1965년 ALGOL W의 창시자 Tony Hoare에 의해서 발명되었다고 합니다. 그리고 2009년 소프트웨어 컨퍼런스에서 null에 대해서 개념상 편해서 만들어졌으며 스스로 1조원 짜리 실수(The Billion Dollar Mistake) 평가했습니다.

여러분이 기존에 Java 언어로 개발을 해보셨다면 NullPointerException을 한번쯤은 경험해 보셨을 것이라고 생각되어집니다. NullPointerException은 기본적으로 객체에 값이 할당되지 않았을 때(nul 일 경우) 해당 값을 호출하면 발생하는 에러입니다. 해당 에러를 기피하기 코드에는 기본적으로 null을 체크하는 로직이 들어가게 됩니다. 그렇지 않다면 예상하지 못한 경우에 NullPointerException이 발생할 수 있기 때문입니다. java를 통해서 코딩을 하시면 아래와 같은 로직은 많이 보신적이 있으실 것입니다.

public void test(String str) {
    // str의 null 체크 없이 사용한다면 NPE 발생 가능성 존재
    if (str == null || str.equals("")) {
        // str 을 이용한 동작
    }
}

Java8 에서는 이러한 지속적인 null check를 막기 위해서 Optional이 나오기도 했습니다. Optional에 대해서는 제 이전 포스팅은 [Java 8] Java 8에서의 새로운 특징를 참조해주시기 바랍니다.

코틀린에서 null은 어떻게 처리하는가 ?

코틀린의 타입 시스템은 코드가 런타임에서 NullPointerException을 제거하는 것을 목표로 하고 있습니다. 기본적으로 NullPointerException(이하 NPE)가 일어날 수 있는 상황에서는 컴파일이 되지 않습니다. 그럼에도 NPE가 발생할 수 있으며 그 상황은 아래에 한정되어집니다.

  • throw NullPointerException() 으로 코드에서 명시적으로 예외 던짐
  • !! 키워드를 사용했을 때 null 이 들어갔을 경우
  • 객체 초기화 시 아래의 이유로 데이터의 불일치의 경우
    • 생성자에서 this를 초기화하지 않고도 사용할 수 있으며, 다른 곳으로 전달되어 사용할 수 경우(“leaking this”)
    • 부모 클래스 생성자와 자식 클래스의 구현에서 초기화되지 않은 상태를 사용하는 open member를 호출 했을 경우
  • Java와의 상호 작용
    • java의 null 참조중인 객체에 접근 (kotlin은 java와 100% 상호 운용 가능)
    • java와 상호작용하는 collection 등의 제네릭 타입의 이유로 잘못된 null이 들어올 수 있습니다. java code에서 list에 null 이 들어오고 이를 코틀린에서 null인지 인지하지 못한채 사용하는 것입니다.
    • java와 병행하여 코드운영하며 발생할 수 있는 기타 예외가 있음

각 NullPointerException이 일어날 수 있는 경우에 대해서는 아래에서 한번씩 코드로 알아보도록 합시다.

코틀린에서의 null 대입의 일반적인 경우

일반적으로 코틀린에서 아래처럼 변수에 null을 대입하려고 하면 컴파일 에러가 발생하게 됩니다. 컴파일 에러의 내용은 Null can not be a value of a non-null type String 입니다. 즉, name 변수는 non-null 타입이므로 null을 대입할 수 없다는 의미입니다.

var name: String = "sabarada"
name = null // 컴파일 에러 발생

경우에 따라서 변수에서 null을 허용하고 싶을 수 있습니다. 그럴 경우 변수 선언시 데이터 타입에 ? 키워드를 함게 사용해주면 됩니다.

var name: String? = "sabarada"
name = null // OK

그렇다면 null이 될 수 있는 타입을 주고 그 내부 함수를 호출하면 NPE가 발생하지 않을까요 ? 그렇지 않습니다. 이 또한 코틀린 컴파일러에서 미리 막아버립니다. 컴파일 에러의 내용은 Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String? 입니다. 즉, null 허용 타입일 경우에는 타입 체크또는 더 명확한 명시를 하지 않으면 내부 함수 또는 변수를 호출할 수 없다는 내용입니다.

var name: String? = "sabarada"
val size = name.length // 컴파일 에러 발생

Null 체크 방법

코틀린에서는 null 체크가 없다면 null이 허용되는 변수에 대해서 사용하려고 했을 때 컴파일에러가 발생하는 것을 알게되었습니다. 그렇다면 이어서 null 체크를 하는 방법에 대해서 알아보도록 하겠습니다.

조건문으로 체크하기

가장 먼저 알아볼 방법은 조건문으로 체크하는 방법입니다. 이 방법은 java와 같은 다른 언어에서도 동일하게 사용할 수 있는 가장 보편적인 방법입니다. 위의 length를 구하는 예제에 조건문을 통해서 null을 체크해보도록 하겠습니다. 아래 코드에서 if / else를 이용하여 name의 null 체크를 했기 때문에 위에서는 컴파일 에러가 발생하던 name.length가 정상적으로 컴파일 되는 것을 확인할 수 있습니다.

var name: String? = "sabarada"
if (name != null && name.isNotEmpty()) {
    println("이름은 $name , 이름의 길이 = ${name.length}") // 결과 : 이름은 sabarada , 이름의 길이 = 8
} else {
    println("Empty string")
}

안전한 호출(safe call)

코틀린에서는 nullable한 변수에 대해서 안전한 호출이 가능하도록 키워드를 별도로 제공합니다. 해당 키워드를 이용하면 NPE가 출력되지 않고 안전한 호출이 가능합니다.

아래 예제코드를 보도록 하겠습니다. 내부 함수 또는 변수를 호출 할 때 ?. 으로 호출합니다. 이렇게 하면 null 일 경우 null을 반환하며 그렇지 않을경우 정상적으로 값을 반환합니다.

var name: String? = "sabarada"
var nullName: String? = null
println("name = ${name?.length}") // 결과 : name = 8
println("name = ${nullName?.length}") // 결과 : name = null

객체는 내부에 또 다른 객체를 가질 수 있습니다. 이럴경우 null 체크가 2번이 필요하게 됩니다. 아래의 예제를 보시면 이해가 되실 것입니다. 아래의 코드를 보시면 OutterEntity 클래스 내부에 InnerEntity가 있는 것을 확인할 수 있습니다.

class OutterEntity(
    val innerEntity: InnerEntity? = null
) {
}

class InnerEntity(
    val name: String? = null
) {

}

이런 코드에서 InnerEntity의 값을 가져오려고 하면 어떻게 해야할까요? if / else 를 이용하면 아래와 같을것입니다. outterEntity, outerEntity.innerEntity 그리고 outterEnitty.innnerEntity.name까지 각각 null 체크를 해줘야합니다.

val outterEntity: OutterEntity? = OutterEntity(InnerEntity("sabarada"))

if (outterEntity != null && outterEntity.innerEntity != null && outterEntity.innerEntity.name != null) {
    println("name = ${outterEntity.innerEntity.name}")
} else {
    println("name is null")
}

이런 경우가 ?. 키워드를 잘 사용하면 체이닝 호출이 간편하게 됩니다. 아래처럼 사용할 수 있습니다. 이렇게 사용하면 outterEntity, innerEntity가 null 이라면 NPE가 아닌 null을 name 변수에 할당하게 됩니다.

val name = outterEntity?.innerEntity?.name
println("name = $name") // 결과 : name = sabarada

추가적으로 Scope Functionslet을 사용하면 값이 null 이 아닐경우에만 실행하도록 할 수 있습니다. Scope Functions에 대해서는 추후에 별도로 포스팅 예정이므로 지금은 이런게 있구나 정도로만 알고 넘어가면 될 것 같습니다. :)

var name: String? = "sabarada"
var nullName: String? = null
name?.let { println("name = ${name.length}") }
nullName?.let { println("name = ${nullName.length}") }

엘비스 연산자(Elvis operator)

null을 처리할 수 있는 또 다른 방법은 엘비스 연산자(?:)를 이용하는 것입니다. 엘비스 연산자를 사용하면 null 일경우 추가적인 표현식을 실행할 수 있습니다. 아래 코드를 보도록 하겠습니다. 아래 코드는 outterEntity?.innerEntity?.name 가 null 이라면 default 값을 name에 대입합니다.

val outterEntity: OutterEntity? = OutterEntity()

val name = outterEntity?.innerEntity?.name ?: "default"
println("name = $name") // 결과 : name = default

엘비스 연산자 값에는 literal value 뿐만 아니라 return, function 그리고 exception 호출까지 가능합니다.

The !! operator

null 일 경우 NPE를 내게 할 수 도 있습니다. 바로 !! 키워드를 이용하는 것입니다. 아래의 예제 코드를 보시면 해당 코드는 NullPointerException이 발생하고 프로그램은 종료됩니다.

val outterEntity: OutterEntity? = OutterEntity()

val name = outterEntity!!.innerEntity!!.name // java.lang.NullPointerException

클래스 초기화 과정에서 발생할 수 있는 NPE

클래스 초기화 과정에서 잘못 된 경우 NPE가 발생할 수 있습니다. 잘못된 초기화 과정 2가지를 소개해드립니다. 첫번째는 initialize block에서 간접적으로 초기화 되지 않은 변수에 접근하는 것입니다. 아래 코드는 그러한 예제입니다.

class Demo2 {
    val some: Int
    val someString: String

    init {
        fun Demo2.indirectSome() = some
        fun Demo2.indirectSomeString() = someString
        println(indirectSome()) // prints 0
        println(indirectSomeString().replace("1", "2")) // someString은 초기화 되지 않았지만 간접적으로 접근하여 NPE가 발생할 수 있습니다.
        some = 1
        someString = ""
    }
}

두분째는 상속 관계에서 부모 클래스 그리고 자식 클래스 순으로 초기화가 됩니다. 아래 코드를 보시면 자식 클래스의 값을 부모클래스의 값으로 대입하고 있습니다. 따라서 초기화 순서에 의헤 code 에는 값이 "sabarada"가 아닌 null로 들어가게되며 사용함에 있어서 NPE가 발생하게 됩니다.

abstract class Base {
    val code: String = calculate()
    abstract fun calculate(): String
}

class Derived(private val x: String) : Base() {
    override fun calculate() = x
}

fun testIt() {
    Derived("sabarada").code.replace("", "") // Expected: NPE 발생
}

마무리

오늘은 이렇게 코틀린의 NPE에 대해서 알아보는 시간을 가져보았습니다.

확실히 코틀린의 NPE 처리만 잘 사용하여도 상당히 코드가 간결해 질 것 같습니다.

그리고 추가로 원하지 않는 NPE가 클래스 초기화 과정에서 일어날 수 있다는 사실을 알게 되었습니다.

이런 부분은 코딩에 주의해야할것 같습니다.

감사합니다.

참조

lucidchart_the-worst-mistake-of-computer-science

kotlinlang_null-safety

stackoverflow_leaking-this-in-constructor-warning-should-apply-to-final-classes-as-well-as

댓글