[Kotlin] Not Null 타입 변수에 Null이 들어갈 수 있다? | 타입 파라미터(Type Parameter)와 null에 대한 고찰

박희중·2023년 6월 3일
2

Kotlin

목록 보기
4/4

타입 파라미터(T)란 ?

코틀린은 타입 안전성을 제공하는 정적 타입 언어이고, 타입 파라미터(type parameter)를 사용해 제네릭 프로그래밍을 지원해준다.
제네릭은 코드의 재사용성타입 안정성을 향상시키기 위한 프로그래밍 기법이다.

타입 파라미터는 클래스, 함수, 인터페이스를 정의할 때 사용되고 타입 파라미터는 실제 타입이 아니라 타입을 나타내는 변수다.
이를 통해 코드를 작성할 때 실제 타입을 지정하거나 인자로 전달할 수 있다.

코틀린에서 타입 파라미터는 <T>와 같은 형식으로 표현되고 여기서 T는 임의의 식별자다.
일반적으로 T는 타입(Type)을 의미하며, 여러 개의 타입 파라미터를 사용할 경우에는 T, U, V 와 같은 알파벳 순서로 표현한다.

타입 파라미터는 클래스나 함수의 매개변수 타입, 반환 타입, 변수 타입 등 다양한 곳에서 사용될 수 있다.
따라서 동일한 로직을 여러 타입에 대해서 재사용하면서 타입 안전성을 보장할 수 있다는 장점이 있다.


문제인식

문제를 인식했던 과정을 살펴보자.

Table A
- a_id [not null]

Table B
- b_id [not null]
- a_id
- kk [not null]

위의 테이블 예시에서 테이블 A, B를 a_id 컬럼으로 left join을 할때
B 테이블의 not null 컬럼(ex. kk)이 당연히 left join이므로 null로 나오는 경우가 분명히 있을 것이다.
(join한 결과는 테이블이 아닌 결과집합 (or 조인결과)이므로 not null 속성이 유지되지 않음)

Exposed에서는 select를 하면 ResultRow 객체에 저장되고 ResultRow에서 dto로 변경하는 과정을 거친다.
ResultRow에서 dto로 변경할때 get() 함수를 통해 값을 가져와 할당한다.

get하는 과정 (exposed 라이브러리 소스코드)을 보자

이때 rawToColumnValue 함수를 보면 raw는 nullable한 T? 이고 null이 반환될 수 있음에도 리턴 자료형이 T인 것에 의문이 생겼다.

로직을 봐도 raw가 null일때는 null을 반환하지만 리턴 자료형이 T로 되어있어 T?가 되어야하는 것이 아닌가 생각했다.

하지만 T에는 숨겨진 (나만 몰랐었나) 비밀이 있었다.



타입 파라미터 T는 사실 nullable하다.

사실 코틀린에서 타입 파라미터 T는 클래스나 함수 안에서 타입 이름으로 사용하면 물음표가 없더라도 null이 될 수 있는 타입이었다.

따라서 타입 파라미터 TAny?로 간주할 수 있다.

이전의 생각처럼 타입 파라미터를 not null한 타입 파라미터로 사용하려면 T:Any 처럼 널이 될 수 없는 타입 상한을 지정해야한다.

fun <T> test(t: T) {} // t는 Any? 타입이 되고 따라서 nullable하다
fun <T: Any> test(t: T) {} // T는 not null 타입

따라서 상한이 지정되지 않은 기본적인 타입 파라미터 T는 null이 될 수 있다.
그럼 이때 TT?의 차이이 뭔지 궁금해졌다.

TT? 둘다 null이 가능하지만 타입 추론에 있어서 T를 nullable로는 체크 못할 것 같아
T은 not null로 타입 추론된다라고 가설을 세워본다.



타입 파리미터와 null 테스트

    fun <T> test(msg: T?): T {
        return when {
            msg == null -> null
            else -> {
                println("not null")
                msg
            }
        } as T
    }

위와 같은 test 함수가 있다고 가정하자.

test 함수는 (Exposed) ResultRowrawToColumnValue 함수의 형태와 비슷하게 작성하였다.

T는 nullable하므로 null과 not null 리턴을 as T로 캐스팅해도 잘되는 모습을 볼 수 있다.


Integer 자료형 테스트

    fun intTest() {
        val intNull: Int? = null

        test(null) // [KotlinNothingValueException] 발생

        test(intNull) // 오류 x (호출은 됨)

        val resultValue = test(intNull) // NullPointerException -> (할당은 안됨)
    }

타입 파라미터 T는 결과적으로 null이 될 수 있음을 한번더 확인할 수 있다.

새로운 점을 많이 발견할 수 있다

  • test 함수에 직접 null을 넣어 호출하면 KotlinNothingValueException 예외가 발생한다

  • test 함수에 null값을 넣은 intNull 변수로 호출하면 호출 자체는 된다.

  • 하지만 test 함수를 resultValue 변수에 할당하려고 할때 NPE가 발생한다. 새로운 변수에 할당함에도...
    위에서 봤듯이 test 함수는 T를 반환함에도 불구하고 not null Int를 반환하는 것으로 타입추론된다.
    따라서 not nullInt 변수 resultValuenull을 할당하려고 하다보니 NPE가 발생했다.


내가 내린 결론

test 함수의 반환형이 T일때 null을 반환할 수 있다.

하지만 T를 반환하는 함수를 할당받는 객체는 not null로 취급된다.

따라서 실제로 null을 반환하는 함수일 경우 리턴 자료형을 T?로 선언해야한다.


String 자료형 테스트

하지만 Integer와는 다르게 String으로 테스트했을 때는 다른 결과가 나타났다.

    fun stringTest() {
        val stringNull: String? = null

        test(null) // [KotlinNothingValueException] 발생

        test(stringNull) // 오류 x (호출은 됨)

        val resultValue = test(stringNull) // 오류 x
        // 할당도 됨 String이라 타입 체크를 딱히 안하나?

        println(resultValue) // 오류 x -> 출력: null
        if (resultValue == null) println("I'm null")
    }

test 함수 호출까지 되는 것은 Integer와 같지만
String은 resultValue에 할당까지도 되는 모습을 볼 수 있다.

혹시해서 println으로 출력하고 null 체크(I'm null 출력)까지 했음에도 null로 할당되어있는 모습이다.

타입 추론으로 봤을 때 분명히 not null인 String으로 타입 추론이 되어있음에도 실제로는 null값이 들어가 있는 모습을 볼 수 있다.

심지어 Integer때는 not null 변수에 null을 넣으니 NPE가 발생했지만
String에는 not null 변수에 null이 정상적으로 할당됐다.


이렇게 not null 변수에 null이 오류없이 들어가게 되면 추후에 해당 변수를 사용할 때 문제가 발생한다.

변수가 초기화되는 시점에 Exception이 발생하지 않고 해당 변수를 사용하는 시점에 NPE가 발생할때 디버깅으로 문제 원인을 찾을 때 근본적인 문제(변수 초기화 시점)를 찾기 어려울 수 있다.

간단히 말해서 실제 문제가 되는 시점과 Exception이 발생하는 시점이 달라 Exception을 추적하기 어려울 수 있다.

따라서 resultValue 변수가 integer처럼 할당하는 시점에 NPE가 발생하는 것이 바람직해보인다.

나중에 integer처럼 할당시점에 NPE가 나도록 Kotlin contribute를 통해 해당 이슈를 해결해보려고 한다.


T? 테스트

하지만 test 함수 반환형을 T?로 고쳤을때 intTest도 그렇고 stringTest도 NPE없이 정상적으로 작동하는 것을 볼 수 있다.
또한 test 함수 반환형의 타입 추론도 String?으로 되는 모습을 볼 수 있다.



T vs T? 정리

위에서 발견할 수 있는 T와 T? 와의 차이를 정리하자

  1. T는 nullable이 될 수 있지만, 이것이 nullable하다는 것을 코틀린의 타입 체커는 알 수 없다.
    따라서 코틀린의 타입 체커는 not null로 판단하므로 실제값과 어긋날 수 있다.

  2. 반면 T?는 명시적으로 nullable를 나타내므로 코틀린의 타입 체커는 이를 인식하고 nullable한 타입으로 추론한다.

따라서, T와 T?의 차이점은 코틀린의 타입 체커가 nullability를 제대로 인식하고 처리하는 능력이다.


타입 파라미터 T 자체는 nullable하지만
T를 반환하는 함수를 만들때 해당 함수가 null을 반환할 가능성이 있다면 T?로 명시적으로 표기하는 것이 좋을 것 같다.




개인 생각이 포함되어있고 직접 테스트하면서 알아냈기 때문에 잘못된 정보가 있다면 편하게 정정 부탁드립니다.

profile
백엔드 엔지니어 박희중입니다.

0개의 댓글