[Kotlin] Null safety

RID·2024년 5월 2일
0

Kotlin

목록 보기
3/3

배경


Kotlin을 써서 어느정도 간단한 프로그램들을 몇 개 개발하다 보니 처음 왜 Kotlin이어야 할까 하는 생각이 들었다. 단순히 요즘 나온 언어이고, Java를 대체하고 있다라는 말만 들어서 막연하게 시작했던 것이지 왜 Java를 대체하고 있고, 요즘 나왔다면 어떤 부분이 좋기 때문에 나온 것인지 깊게 생각해보지 않았다.
그래서! Kotlin과 Java를 비교하면 가장 먼저 나오는 주제인 Null Safety에 대해서 한 번 공부하고자 했다. Kotlin 공식문서에 나와 있는 내용을 바탕으로 글을 정리해보고자 한다.

https://kotlinlang.org/docs/null-safety.html

1. Null과 NPE


가장 먼저 주제가 되는 null이 대체 무엇인지 한 번 살펴보자. C언어에서 볼 수 있는 포인터 개념을 한 번 보자. 포인터 변수는 특정 메모리의 주소를 저장할 수 있는 변수이다. 실제 값이 저장되어 있는 공간(메모리)이 있고, 포인터 변수는 그 해당 공간의 주소를 저장할 수 있는 것이다.

근데 만약 이 포인터 변수를 초기화하지 않는다면 이 변수는 대체 어떤 상태에 있는 것일까? null은 이 상태 즉, 주소값이 없는 것 혹은 아무것도 가리키고 있지 않은 상태를 표현하는 키워드이다.

하지만 Kotlin으로 작업하다 보면 이런 포인터를 다룰 일이 없다. 실제로 Kotlin의 경우 Call by Reference 형식으로 모든 것을 다루지만, 편의성을 위해 유저에게 숨겨져 있는 것이다.

여튼 Java의 경우 NPE(Null Pointer Exception) 문제가 개발자들에게 큰 문제였다고 한다. 특정 reference 변수가 null값을 참조하고 있는 경우에, 만약 해당 변수로 인스턴스의 메소드를 호출하거나 등의 행위를 하게 되면 나타나는 것이다. 하지만 가장 심각했던 부분은 해당 문제가 runtime 동안에 발생하기 때문에 컴파일 시점에 알 수가 없다는 것이다.

어떤 상황에 NPE가 발생하고, 왜 발생하는지에 대해 더 알아볼 수 있겠지만 Java를 깊게 다루어 보지 않은 상황이고 단순히 배경만 이해하고 싶으니 넘어가보자.

2. Nullable type 과 non-nullable type


제목 그대로 null값을 가질 수 있는 타입과 그렇지 못한 타입에 대한 설명이다. 아래 코드들을 살펴보자.

var a: String = "abc"
a = null

우리가 일반적으로 변수를 선언하는 방식이고, 해당 값에 null을 assign하려고 하면 컴파일 에러가 발생한다. 반면 아래 경우를 보자.

var b: String? = "abc" 
b = null // ok
print(b)

이 경우에는 b에 null값 할당이 가능하다. 그 이유는 선언에 있는 ? 때문이다. 저렇게 type 뒤에 등장하는 ?의 경우 해당 변수가 nullable type이라는 것을 의미한다. 그렇기 때문에 null이라는 값 할당이 가능했고, print 결과 "null"이 출력된다.


Q. 그렇다면 print함수는 null값으로 들어온 걸 어떻게 처리할까?

내 예상은 다음과 같았다.

  • print 함수의 인자는 nullable type으로 지정되어 있을 것이다.
  • null이 인자로 들어오는 경우 check하여 String "null"을 출력하도록 했을 것이다.
    한 번 확인해보자.

Kotlin print함수 정의

@kotlin.internal.InlineOnly
public actual inline fun print(message: Any?) {
    System.out.print(message)
}

예상 했던 것처럼 message라는 인자를 nullable type으로 받고 있다. 다시 system.out.print도 살펴보자.

public void print(Object obj) {
        write(String.valueOf(obj));
    }

이렇게 되어 있는데, 결국 넘어온 obj라는 String.valueOf()함수로 넘겨주는 것을 볼 수 있다. 그리고 해당 함수의 return 값을 write() 함수를 통해 콘솔에 출력할 것이다. 이제 다시 valueOf 함수로 들어가보자.
(해당 print 함수는 nullable type을 받지 않는 것 처럼 보이지만 @Nullable annotation을 사용하고 있었다!)

    public static String valueOf(Object obj) {
        return (obj == null) ? "null" : obj.toString();
    }

역시 결국 반드시 null 체크하는 부분이 있고, null인 경우 "null"(문자열)로 처리하는 것을 알 수 있다.

결국 이를 통해 알 수 있는 것은 Kotlin에서 nullable type의 변수를 다룰 때에는 반드시 null check를 해야 하는 것이다. 그리고, check되지 않은 null 변수의 참조가 예상될 때는 이를 compile 단계에서 잡아낸다.


아래 간단한 예시를 보자(위에 nullable type 설명에서 사용했던 코드와 이어서 보자).

val l = a.length
val l = b.length // error: variable 'b' can be null

이 경우 a에 대해서는 메소드 호출이나 property 접근이 가능하지만(NPE가 발생하지 않음을 보장하기 때문) b의 경우 null safety가 보장되지 않으므로 컴파일 단계에서 error가 발생한다.

3. 조건문에서 Null Check하기


다시 말해서, nullable type의 변수에 대해 메소드 호출이나 property 접근을 하기 위해서는 컴파일러에게 NPE가 발생하지 않음을 보장시켜 주어야 한다.

가장 쉬운 방법은 아래 처럼 조건문을 사용하는 것이다.

val l = if (b != null) b.length else -1

조건문을 통해 null이 아닌 경우에만 b의 property에 접근했으므로, 이는 컴파일 에러를 발생시키지 않는다.

다른 예시도 한 번 더 보자.

val b: String? = "Kotlin"
if (b != null && b.length > 0) {
    print("String of length ${b.length}")
} else {
    print("Empty string")
}

여기서 if (b != null && b.length > 0) 부분을 살펴보면 먼저 null check를 하고 그 이후에 length에 접근하므로 역시 컴파일 에러가 생기지 않는다.

if(A && B) 의 조건문에서 만약 A가 false인 경우 B에 대한 조건 체크는 하지 않는다. 그래서, B의 조건 확인은 반드시 A가 true인 경우에 일어난다.

조건문을 통해 이러한 과정이 가능한 이유는 b라는 변수의 null check 시기와 사용 시기 사이에 어떠한 수정이 일어나지 않았기 때문이다. 아래 처럼 null check이후 변수를 수정하고 참조하려고 하는 경우 당연히 컴파일 에러가 발생한다.

    var b : String? = "Kotlin"
    if (b != null && b.length > 0) {
        b= null
        print("String of length ${b.length}") // error 
    } else {
        print("Empty string")
    }

4. Safe Calls


Nullable type의 property에 접근할 수 있는 두 번째 방법은 Safe Call Operator라고 불리는 ?. 이다.

val b: String? = null
println(b?.length)

이 경우 nullable type 변수인 b에 대해서 ?. 연산을 진행하면 해당 식의 의미는 아래와 완전히 동일하다

val b: String? = null
if(b == null){
	println(null)
}else{
	println(b.length)
}

즉 Safe Call 연산자는 내부적으로 null check를 하고 null인 경우 역시나 null을, 그게 아닌 경우 해당 property를 가져와주는 것이라 생각하자.

그렇다면 그냥 조건문과 다를 바가 없다고 느껴지는데 이는 여러 property에 대한 chain에서 효과가 극명하게 나타난다.

bob.department.head.name

이런 참조가 필요하다고 생각해보자. 이 때 만약 각각의 property들이 모두 nullable하다면 조건문으로 사용하는 경우 굉장히 복잡해진다. 하지만 ?. 연산자를 사용하면 아래와 같이 쉽게 쓸 수 있다.

bob?.department?.head?.name

이는 property중 하나라도 null인 경우 null을 return하는 것이다.

추가적으로, 만약에 non-null values에 대해서만 뭔가 행위를 취하고 싶다면 아래와 같이 작성하면 된다.

val listWithNulls: List<String?> = listOf("Kotlin", null)
for (item in listWithNulls) {
    item?.let { println(it) } // prints Kotlin and ignores null
}

5. Nullable Receiver


Kotlin의 특정 함수들은 nullable receiver로 정의되어 있는 경우가 있다. 말 그대로, nullable한 변수의 입력을 받아들이고, 함수의 호출 시점에 null check를 굳이 하지 않아도 된다는 것이다.
아까 print문에 대한 분석에서도 보았듯이 함수 내부에서 null check를 진행한다.

val person: Person? = null
logger.debug(person.toString()) // Logs "null", does not throw an exception

해당 코드는 toString() 함수가 nullable receiver이므로 컴파일 에러가 발생하지 않는다.
(toString()의 경우 null에 대해서 "null" 문자열을 return한다)

만약 "null" 문자열이 아니고 진짜 nullable string을 받고 싶으면 어떻게 하면 될까?
위에서 배운 safe-call 연산자를 사용하면 된다.

var timestamp: Instant? = null
val isoTimestamp = timestamp?.toString() 
if (isoTimestamp == null) {
   // null인 경우 하고 싶은 로직
}

6. Elvis Operator


nullable type에 대한 참조 중, null일 때는 따로 처리하고 싶고, 아닐 때는 그 값을 그대로 받아오고 싶어!

이런 경우들이 있을 것이다. 이 경우 간단하게 Elvis 연산자 ?:를 사용해 해결하면 된다.

val l: Int = if (b != null) b.length else -1

val l = b?.length ?: -1

위의 두 줄은 정확히 같은 기능을 한다. 코드의 가독성을 높일 수 있으니 Elvis 연산자를 적극 활용하자.

7. !! 연산자


해당 Document를 읽어보면 Kotlin에서 NPE가 발생할 수 있는 몇 가지 경우의 수를 알려준다.
그 중 하나는 직접 NPE를 throw하는 것, 그리고 또 하나는 !! 연산자를 사용하는 경우이다.

해당 연산자는 해당 nullable type 변수가 절대 null 이 아니라고 얘기해주는 것이다.

val l = b!!.length

위와 같이 작성하면 b가 nullable type임에도 컴파일 단계에서 에러가 나지 않고, 런타임에 b가 실제로 null인 경우 NPE가 발생한다.

공식문서에서도 해당 옵션을 NPE lover를 위한 것이라 해두었다. 컴파일 시점에 에러를 확인할 수 없는 만큼 위험하므로 정말 필요한 경우에 사용하도록 하자!


마무리

Kotlin을 처음 배울 때 부터 NPE에 대한 개념에 대해 들어봤지 Kotlin이 어떻게 이를 컴파일 단계에서 확인하는지, 혹은 그 기법들은 무엇인지 알지 못했었다. 그래서 단순히 컴파일이 안되면 IntelliJ에서 추천하는 대안으로 코드를 수정하고 작성했었다.

오늘 이렇게 한 번 시간을 가지고 공식문서를 살펴보며 정리하다 보니 개념이 잘 정리된 것 같아 기쁘다.

0개의 댓글