103. (Java to Kotlin) 3. Optional to Nullable

3. Optional to Nullable

원제 : Optional to Nullable

코틀린의 가장 큰 특징 중 하나는 Null-Safety이다.

자바의 옵셔널 개념에 빗대어 코틀린이 어떻게 Null을 안전하게 쓰는지 알아보도록 하자.

3.1. ‘없음’ 상태 표현하기

원제 : Representing Absence

옵셔널 개념이 나오기 전까지, 자바 생태계에서는 참조하는 대상이 Null이 아니라고 간주하였다.

따라서 모든 참조에 대해서 Null을 체크해야하는 불필요한 관습이 정착되었고

이를 간소화하기 위해 @Nullable이나 @NonNull, @NotNullable 등의 어노테이션을 사용하기도 하였다.

이후 자바8에서 옵셔널 개념이 도입되었으나, 근본적으로 Null 여부를 체크해야하는 것은 변함없다.

그렇다면 코틀린은 어떻게 처리할까?

코틀린은 Null을 그대로 포용한다.

대신 Null로 초기화될 수 있는 타입과 초기화될 수 없는 타입을 분류하여 처리한다.

참고 코틀린의 Null 체크는 완벽한가?
예를 들어 Map<K, V>.get(key)는 key에 해당하는 값이 없으면 null을 반환해주지만
List<T>.get(index)는 index에 해당하는 값이 없으면 IndexOutOfBoundsException을 던진다.
유사하게 Iterable<T>.first()는 null 대신 NoSuchElementException을 던진다.
사실 이것은 Null 체크 때문이 아니라 보다 안전한 코딩을 위해 코틀린의 구현체가 다르기때문에 발생한다.

코틀린의 타입 시스템에서 T라는 타입이 있다면 이는 Null을 허용하지 않는다는 뜻이다.

반대로 Null을 허용하려면 T?라고 타입을 명시해야한다.

따라서 TT?의 하위 타입이 된다.

반대로 TOptional<T>의 하위 타입이 아니다.

3.2. 옵셔널에서 Nullable로 리팩토링

원제 : Refactoring from Optional to Nullable

옵셔널이 포함된 Legs 클래스를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

public class Legs {

public static Optional<Leg> findLongestLegOver(
List<Leg> legs,
Duration duration
) {
Leg result = null;
for (Leg leg : legs) {
if (isLongerThan(leg, duration))
if (result == null ||
isLongerThan(leg, result.getPlannedDuration())
) {
result = leg;
}
}
return Optional.ofNullable(result);
}

private static boolean isLongerThan(Leg leg, Duration duration) {
return leg.getPlannedDuration().compareTo(duration) > 0;
}
}

이제 Legs를 코틀린으로 변환해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
object Legs {
@JvmStatic
fun findLongestLegOver(
legs: List<Leg>,
duration: Duration,
): Optional<Leg> {
var result: Leg? = null
for (leg in legs) {
if (isLongerThan(leg, duration))
if (result == null ||
isLongerThan(leg, result.plannedDuration))
result = leg
}
return Optional.ofNullable(result)
}

private fun isLongerThan(leg: Leg, duration: Duration) = leg.plannedDuration.compareTo(duration) > 0
}

이후 점진적인 마이그레이션을 위해 두 가지 버전의 findLongestLegOver()를 준비해아한다.

하나는 기존의 자바 코드처럼 Optional<Leg>을 반환하고 하나는 Leg?를 반환하는 메서드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
object Legs {
@JvmStatic
fun findLongestLegOver(
legs: List<Leg>,
duration: Duration,
): Optional<Leg> {
// 함수 추출 적용
return Optional.ofNullable(longestLegOver(legs, duration))
}

// Leg?를 반환하는 새로운 함수
fun longestLegOver(
legs: List<Leg>,
duration: Duration,
): Leg? {
var result: Leg? = null
for (leg in legs) {
if (isLongerThan(leg, duration))
if (result == null ||
isLongerThan(leg, result.plannedDuration))
result = leg
}
return result
}

private fun isLongerThan(leg: Leg, duration: Duration) = leg.plannedDuration.compareTo(duration) > 0
}

이제 자바에서 호출할 때는 findLongestLegOver()을, 코틀린에서 호출할 때는 longestLegOver()를 호출하면 된다.

3.3. 코틀린답게 리팩토링

원제 : Refactoring to Idiomatic Kotlin

3.2에서는 옵셔널에서 널 가능성을 가진 코드로 리팩토링을 하였다.

이제 변경한 코드를 좀 더 코틀린 답게 바꾸어보자.

먼저 현재 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
object Legs {

// ...

fun longestLegOver(
legs: List<Leg>,
duration: Duration,
): Leg? {
var result: Leg? = null
for (leg in legs) {
if (isLongerThan(leg, duration))
if (result == null ||
isLongerThan(leg, result.plannedDuration))
result = leg
}
return result
}

private fun isLongerThan(leg: Leg, duration: Duration) = leg.plannedDuration.compareTo(duration) > 0
}

isLongerThan() 메서드의 파라미터를 수신 객체로 변환해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
object Legs {

// ...

fun longestLegOver(
legs: List<Leg>,
duration: Duration,
): Leg? {
var result: Leg? = null
for (leg in legs) {
if (leg.isLongerThan(duration))
if (result == null ||
leg.isLongerThan(result.plannedDuration))
result = leg
}
return result
}

// AS-IS
private fun isLongerThan(leg: Leg, duration: Duration) = leg.plannedDuration.compareTo(duration) > 0
// TO-BE
private fun Leg.isLongerThan(duration: Duration) = plannedDuration.compareTo(duration) > 0
}

이번엔 비즈니스 로직을 바꾸어보자.

longestLegOver() 메서드가 하려는 일은 주어진 Leg 객체들 중 가장 긴 구간을 찾아내는 것이다.

코틀린의 표준 API를 이용하면 아래와 같이 처리할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
object Legs {

// ...

fun longestLegOver(
legs: List<Leg>,
duration: Duration
): Leg? {
val longestLeg: Leg? = legs.maxByOrNull(Leg::plannedDuration)
if (longestLeg != null && longestLeg.plannedDuration > duration)
return longestLeg
else
return null
}
}

이후 아래 블록에 해당하는 명령문(statement)

1
2
3
4
if (longestLeg != null && longestLeg.plannedDuration > duration)
return longestLeg
else
return null

아래와 같이 표현식(expression) 으로 전환할 수 있다.

1
2
3
4
return if (longestLeg != null && longestLeg.plannedDuration > duration)
longestLeg
else
null

표현식을 적용하면 아래와 같이 변경된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
object Legs {

// ...

fun longestLegOver(
legs: List<Leg>,
duration: Duration
): Leg? {
val longestLeg: Leg? = legs.maxByOrNull(Leg::plannedDuration)
return if (longestLeg != null && longestLeg.plannedDuration > duration)
longestLeg
else
null
}
}

이후 코틀린의 엘비스 연산자 ?:를 적용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
object Legs {

// ...

fun longestLegOver(
legs: List<Leg>,
duration: Duration
): Leg? {
val longestLeg: Leg? = legs.maxByOrNull(Leg::plannedDuration) ?: return null

return if (longestLeg != null && longestLeg.plannedDuration > duration)
longestLeg
else
null
}
}

참고 엘비스 연산자(Elvis Operation)
코틀린의 엘비스 연산자는 ?:로 표현한다.
연산자 왼쪽의 객체가 Null이 아니면 그 객체를 반환하고, Null이면 오른쪽에 지정한 값을 반환한다.

다른 방식으로는 let을 쓰는 방식이 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
object Legs {

// ...

fun longestLegOver(
legs: List<Leg>,
duration: Duration
): Leg? =
legs.maxByOrNull(Leg::plannedDuration)?.let { longestLeg ->
if (longestLeg.plannedDuration > duration)
longestLeg
else
null
}
}

?.는 수신 객체가 null이면 null로 평가하고, 아니면 let 블록으로 값을 전달한다.

따라서 let 블록 안에 전달된 객체는 null이 아님이 보장된다.

let 함수의 구현체는 아래와 같다.

1
2
3
inline fun <T, R> T.let(block: (T) -> R): R {
return block(this)
}

하지만 가독성 확보가 조금 힘들어지기 때문에, 아래와 같이 when으로 분기하는 것이 더 깔끔할 수 도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
object Legs {

// ...

fun longestLegOver(
legs: List<Leg>,
duration: Duration
): Leg? {
val longestLeg = legs.maxByOrNull(Leg::plannedDuration)
return when {
longestLeg == null -> null
longestLeg.plannedDuration > duration -> longestLeg
else -> null
}
}
}

또 다른 방법으로는 takeIf가 있다.

takeIf는 내부 블록의 결과가 true면 수신 객체를 반환하고, 아니면 null을 반환한다.

따라서 아래와 같이 쓸 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
object Legs {

// ...

fun longestLegOver(
legs: List<Leg>,
duration: Duration
): Leg? =
legs.maxByOrNull(Leg::plannedDuration)?.takeIf { longestLeg ->
longestLeg.plannedDuration > duration
}
}

목적을 달성하기위해 쓸 수 있는 방법은 많지만 가장 중요한 것은 가독성이다.

따라서 여러가지 방법 중 협업 관계에 놓인 사람들에게 가장 높은 가독성을 제공하는 방식을 선택하는 것이 좋다.