코틀린을 다루는 기술 6장을 읽고 정리한 내용입니다.
요약된 표현이 많기에 보다 자세한 설명은 책을 통해 확인하실 수 있습니다.

 

참조 타입의 데이터가 없음을 표현하는 가장 일반적인 방법으로 아무것도 가리키지 않는 포인터를 사용한다. 이런 포인터를 널 포인터(Null pointer)라고 부른다. Null reference는 아무것도 가리키지 않는 참조로, 나중에 이 참조가 어떤 데이터를 가리키도록 대입할 수 있다. 이번 장에서는 오류가 발생하지 않았는데 데이터가 존재하지 않는 경우인 선택적 데이터를 처리하는 방법을 배운다.

6.1 Null pointer의 문제점

Java를 사용하는 개발자들을 환장하게 만드는 마법의 단어가 하나 있다. NullPointerException이다. 이 예외는 데이터가 필요한 부분에서 데이터가 null일 경우 발생한다.

비즈니스 널

자바에는 데이터가 없을 경우 인자를 null로 지정해야 하는 API가 존재한다. 참조가 올바른 파라미터로 쓰이는 경우를 비즈니스 널(business null)이라고 부른다.

비즈니스 널의 예시는 java.net.Socket 클래스의 생성자다.

public Socket(String host,
              int port,
              InetAddress localAddr,
              int localPort)
       throws IOException1

If the specified host is null it is the equivalent of specifying the address as InetAddress.getByName(null). In other words, it is equivalent to specifying an address of the loopback interface.

host가 null이면 주소를 InetAddress.getByName(null), 즉 루프백 주소는 지정하는 것과 동일하다고 한다. 여기서 루프백 호스트는 컴퓨터 자기 자신을 의미한다. 호스트명은 localhost, IPv4에서 주소는 127.0.0.1, IPv6에서는 ::1이다.

InetAddress에서 getAllByName의 구현을 확인해보면 host가 null일 때 loopbackAddress를 반환하고 있다.

private static InetAddress[] getAllByName(String host, InetAddress reqAddr)
        throws UnknownHostException {

        if (host == null || host.length() == 0) {
            InetAddress[] ret = new InetAddress[1];
            ret[0] = impl.loopbackAddress();
            return ret;
        }
        // ...
}

이때 인자로 넘기는 null이 개발자가 의도한 것이든 오류에 의한 것이든 오류는 발생하지 않는다. 값이 존재하지 않는 이유가 의도/오류 둘 중 무엇에 해당되든 값이 없음을 표현하는 더 쉽고 안전한 방법이 필요하다.

이번 장에서는 정상적인 상황인데 값이 없는 경우를 표현하는 방법을 배운다. 이런 데이터를 선택적 데이터(Optional data)라고 부른다.

6.2 코틀린이 Null reference를 처리하는 방식

코틀린은 null 참조를 사용한다. 하지만 참조가 null이 될 수 있다는 사실을 인식하고 있다고 컴파일러에게 미리 알려야 한다. 이것만으로 NPE를 완전히 방지할 수는 없지만, NPE 발생을 예방할 수 있도록 컴파일러가 여러 방식으로 도움을 준다.

코틀린에서 일반 타입에 대한 참조는 null로 설정할 수 없다. null이 될 수 있는 타입을 사용하려면 똑같은 타입 이름을 쓰되 뒤에 ?를 덧붙여야 한다. (ex. Int는 null 참조 불가, Int?는 null 참조 가능)

6.3 Null reference에 대한 대안

IntInt?의 하위 타입이고 StringString?의 하위 타입이다. 따라서 nullable한 값을 인자로 받는 함수에 non-null 값을 넘길 수 있으므로 이 경우는 문제가 되지 않는다.

문제는 반환 값이다. 선택적 데이터를 반환하는 함수의 예로 리스트에서 평균을 구하는 average() 를 만들어보자.

fun average(list: List<Int>) : Double = when {
    list.isEmpty() -> ??? // 평균을 구할수가 없다!
    else -> list.sum() / list.size.toDouble()
}

평균을 구할 리스트가 없다면 어떻게 처리해야 할까?

1. 센티넬 값(sentinel value) 반환하기

In computer programming, a sentinel value (also referred to as a flag value, trip value, rogue value, signal value, or dummy data) is a special value in the context of an algorithm which uses its presence as a condition of termination, typically in a loop or recursive algorithm.

센티넬 값은 루프의 종료 조건이나 값이 존재하지 않음을 표현하기 위한 특별한 값이다. Double의 센티넬 값은 Double.Nan(Not a number)다. kotlin.collections API에서 제공하는 max()나 average()의 구현을 보면 이렇게 센티넬 값을 활용하고 있다.

@SinceKotlin("1.1")
public fun Array<out Double>.max(): Double? {
    if (isEmpty()) return null
    var max = this[0]
    if (max.isNaN()) return max
    for (i in 1..lastIndex) {
        val e = this[i]
        if (e.isNaN()) return e
        if (max < e) max = e
    }
    return max
}
@kotlin.jvm.JvmName("averageOfByte")
public fun Array<out Byte>.average(): Double {
    var sum: Double = 0.0
    var count: Int = 0
    for (element in this) {
        sum += element
        count += 1
    }
    return if (count == 0) Double.NaN else sum / count
}

하지만 센티넬 값을 이용하는 방식엔 문제점이 있다.

  • 이 원칙은 제네릭 타입이나 정수 타입에 적용이 불가능 하다. Float, Double과 달리 Int, Long 클래스는 Nan에 해당되는 타입을 제공하지 않기 때문이다.
  • 사용자에게 센티넬 값을 반환한다는 사실을 알려줄 필요성이 있다.

2. 예외 던지기

fun average(list: List<Int>) : Double = when {
    list.isEmpty() -> throw IllegalArgumentException()
    else -> list.sum() / list.size.toDouble()
}

이 방법 역시 여러 문제가 있다.

  • 예외는 보통 오류 값으로 취급된다. 하지만 빈 리스트가 넘어가는 것을 오류로 취급하는 것이 맞을까?
  • 예외를 던지는 함수는 순수 함수가 아니다.

3. nullable한 반환 타입을 사용하기

함수를 사용하는 사람이 셀프로 예외처리를 하길 기대하며 null을 던질수도 있다.

fun average(list: List<Int>) : Double? = when {
    list.isEmpty() -> null
    else -> list.sum() / list.size.toDouble()
}

일반적인 언어에서 이것은 가장 나쁜 해법이다. 아래의 Java 코드는 실행 가능한 코드다.

public class Solution {

    public static void main(String[] args) {
        double a = average(Collections.emptyList()); // NPE
        double b = aver + 10.0;
    }

    static Double average(List<Integer> list) {
        if (list.isEmpty()) {
            return null;
        } else {
            double result = 0;
            for (Integer integer : list) {
                result += integer;
            }
            return result / list.size();
        }
    }
}

실행하면 원시타입인 a에 null이 할당된 순간 NullPointerException가 발생하며 프로그램이 종료된다.

Double a = average(Collections.emptyList());
Double b = aver + 10.0; // NPE

타입을 박싱 타입으로 바꾸더라도 더하기 연산을 실행한 순간 NPE가 발생한다.

kotlin은 이런 문제에 대한 해결책을 자체적으로 어느정도 갖추고 있다.

  • 호출한 쪽에서 null인 case를 반드시 처리하게 강제한다.
  • 원시 타입이 없기 때문에 null 참조를 언박싱할 일이 없다.
  • nullable을 반환하는 함수를 합성할 수 있는 연산자를 제공한다.

반환 타입으로 nullable을 사용한다는 사실을 명시했기 때문에 컴파일러는 null에 대한 처리를 강제하며, 더하기 연산자를 그냥 사용할 경우 오류가 발생한다.

fun main(args: Array<String>) {
    val a = average(listOf())
    val b = a + 10.0 // syntax error
}

fun average(list: List<Int>) : Double? = when {
    list.isEmpty() -> null
    else -> list.sum() / list.size.toDouble()
}

4. 디폴트 값 사용하기

함수를 호출하는 사람이 직접 기본값을 지정하게 만들 수도 있다.

fun average(list: List<Int>, default: Double) : Double = when {
    list.isEmpty() -> default
    else -> list.sum() / list.size.toDouble()
}
fun main(args: Array<String>) {
    val aver2 = average(listOf(), -1.0)
    val aver = average(listOf(-1), -1.0)
    println(aver)
    println(aver2)
}

실행해보면 -1.0이 두 번 출력된다. 컴파일은 잘 되고 있지만 근본적으로 이 출력은 올바르지 않다. 인자가 비어있을 때와 -1일 때 실행 결과가 동일하기 때문이다.

도로 가든 모로 가든 결국 null참조를 해결해야하는 문제로 돌아오게 된다. 뭔가 더 좋은 해법이 필요하다!

6.4 Option 타입 사용하기

선택적인 값을 처리하는 Option 타입을 만들어서 문제를 해결해 보자. 데이터가 있는 경우와 없는 경우를 표현하는 추상 클래스로 Option을 정의할 것이다.

Option의 구성 요소

  • None : 데이터의 부재를 나타낸다.
  • Some : 데이터의 존재를 나타내며, Some 안에는 대상 값이 저장된다.
sealed class Option<out A> {

    abstract fun isEmpty(): Boolean

    // 모든 타입에 동일하게 사용 가능한 객체이므로 싱글톤으로 선언한다.
    internal object None: Option<Nothing>() {

        override fun isEmpty() = true

        override fun toString(): String = "None"

        override fun equals(other: Any?): Boolean = other is None

        override fun hashCode(): Int = 0
    }

    internal data class Some<out A>(internal val value: A) : Option<A>() {

        override fun isEmpty() = false
    }

    companion object {

        operator fun <A> invoke(a: A? = null): Option<A> = when (a) {
            null -> None
            else -> Some(a)
        }
    }
}

Java의 Optional

직접 구현한 Option과 동일한 역할을 하는 API가 java.util.Optional로 이미 만들어져 있다.

A container object which may or may not contain a non-null value. If a value is present, isPresent() will return true and get() will return the value.

언제 nullable을 쓰고 언제 Option을 써야할까?

  • nullable : 제너레이터 등에서 작업을 끝내야 함을 표현하기 위해 null을 반환하는 등 내부적으로 사용하는 일부 함수에서 유용하다. 하지만 이런 null은 절대 밖으로 내보내면 안되며 함수 scope 내에서만 사용해야 한다.
  • Option : 정말 선택적 데이터라면 Option이 좋다. 하지만 일반적으로 전통적인 경우라면 예외를 던지고 받아 처리해야 할 경우 발생한 예외에 대한 데이터가 모두 사라진다.
    Option 자체는 아주 유용한 데이터 타입은 아니지만, 이후 챕터에서 사용하게 될 데이터 타입 중 가장 단순한 형태다. 함수형 프로그래밍 언어에서 사용하는 모나드(monad)라는 디자인 패턴의 근본적인 개념을 보여준다.

Reterences