Kotlin 안전한 Type casting을 알아보고, 코드 가독성도 올려보자.



개인 광고 영역

이번 글은 매우 기초적인 내용을 다루는 글이다.

Type casting을 다루고, Type casting을 잘 못 활용한 케이스를 안드로이드 코드에서 찾아보았다. Type casting에서 작성한 코드를 기반으로 짧게 코드 가독성을 올리는 방법을 정리했다.


이 글에서 알아볼 내용

  • Kotlin type casting 기법을 알아본다.
  • Kotlin type casting의 잘 못 사용한 케이스를 알아보고, 개선점을 정리했다.
  • Type casting을 통해 코드 가독성을 올리는 방법을 짧게 정리했다.
  • 간단한 scope을 활용하는 형태를 알아본다.


Type Casting

kotlin의 Type casting은 as와 is를 이용해 casting 할 수 있다.

Type Checks and Casts: ‘is’ and ‘as’에서 잘 설명되어 있다.

type casting 하는 방법은 매우 간단하다.

@Test
fun test() {
    val defaultValue: Any = 0
    val value = defaultValue as Int
    println("defaultValue $defaultValue value $value")
}

Any의 값을 Int로 치환한다. 단, 이 코드는 Int가 아닌 다른 primitive type으로 치환을 시도하면 즉시 오류가 발생한다.

java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String
(java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')


안전하게 Type Casting

안전하게 Type casting 하는 방법은 2가지 정도가 있다.


is를 이용하는 방법

is를 이용해 Type casting을 할 수 있는데, 아래와 같이 if-else 문을 활용하는 방법이다.

간단하지는 않지만 안전하게 name이 String type 인지 확인하고 넘어간다.

@Test
fun test() {
    val defaultValue: Any = 0

    var value: String
    if (defaultValue is String) {
        value = defaultValue
    } else {
        value = "-1"
    }
    println("defaultValue $defaultValue value $value")
}

결과는 String type이 아니기 s때문에 defaultValue 0 value -1이 출력된다.

그리고 when 문을 이용하는 방법 역시 아래와 같이 사용할 수 있다.

@Test
fun test() {
    val defaultValue: Any = 0

    var value: String
    when (defaultValue) {
        is String -> value = defaultValue
        else -> value = "-1"
    }
    println("defaultValue $defaultValue value $value")
}


as를 이용하는 방법

as의 사용방법은 아래와 같다.

defaultValue가 as String 이거나, Nullable(?) 이거 나를 나타낸다.

여기서는 as?null의 값을 나타낼 테니 당연히 ?: "-1"의 else 문으로 이동한다.

@Test
fun test() {
    val defaultValue: Any = 0

    val value: String = defaultValue as? String ?: "-1"
    println("defaultValue $defaultValue value $value")
}

디컴파일을 통해 한 번 더 확인하면 다음과 같다.

이 코드에서 확인할 수 있지만, defaultValue에 대한 String을 체크하고, String이 아니면 null을 먼저 대체한다.

그리고 var5에 var10000의 값을 Type casting 한다. 여기에서는 null 일 테니 1번 더 체크하고, -1을 반환한다.

Object defaultValue = 0;
Integer var10000 = defaultValue;
if (!(defaultValue instanceof String)) {
   var10000 = null;
}

String var5 = (String)var10000;
if (var5 == null) {
   var5 = "-1";
}


문서를 보면

문서를 보면 “Unsafe” cast operator라는 부분이 있다.

오픈 소스를 간혹 보면 이 방법을 사용해 Type casting 하는 부분들이 나온다. 이 방법이 어떠한 문제점이 있는지를 살펴보자.

위에서 작성한 as의 코드를 수정해 Unsafe cast operator 방법을 따르도록 한다.

아래 코드를 그대로 해석하면 defaultValue는 항상 Nullable 한 String이다. 만약 Nullable이 맞으면 “-1”을 반환해라.

언뜻 보면 이 코드는 문제없어 보인다.

@Test
fun test() {
    val defaultValue: Any = 0

    val value: String = defaultValue as String? ?: "-1"
    println("defaultValue $defaultValue value $value")
}

하지만 실행시켜보면 이전에 발생했던 오류와 동일한 오류가 발생한다.

java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String
(java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')

오류의 이유를 찾아보면

  • defaultValue는 Nullable이 아니다.
  • defaultValue는 String이 아니다.
  • defaultValue는 Nullable도 아니고, String도 아니니 elvis operator(?:)도 동작하지 않는다.

그러니 위 코드는 동작도 하지 못하고 종료되어 버린다.

그리고 디컴파일을 통해 이를 좀 더 확인해보면, 매우 간결하게 처리한다. as?와 많이 다르게 그냥 Typecasting 해버린다.

어차피 자바는 Nullable을 허용하니 널일 수도 아닐 수도 있다.

Object defaultValue = 0;
String value = (String)defaultValue;
String var3 = "defaultValue " + defaultValue + " value " + value;
boolean var4 = false;
System.out.println(var3);


이 코드가 좋을 수도 아닐 수도 있지만…

이 코드는 좋을 수도, 아닐 수도 있다. 일단 가져왔으니 개선을 위해서 어떤 문제가 있을지를 생각해보자.

val biometricManager = getSystemService(Context.BIOMETRIC_SERVICE) as BiometricManager?

이 코드는 동작에 문제가 없다. 그래도 좀 더 개선할 점은 있어 보여 2가지 정도를 나열해본다.

  • getSystemService는 Object 리턴한다.(Kotlin Any)
  • Activity.java는 상속받아져 있지만 @Nullable을 포함하지 않는다. 이를 따라가면 Context.java에는 @Nullable이 포함되어 있다.

2가지 정도로 나열했는데, 원하는 Type을 던졌다면 return 역시 맞는 결과가 와야 하는 게 당연하다.

하지만 코드 상의 의미를 한번 보자.

  • Object로 return 한다는 건 자바의 다형성 상 다른 Type이 올 수도 있다는 말이다. 안전하게 사용하려면 치환해 사용해야 한다.
  • @Nullable 표시가 없지만 Nullable 할 수도 있다. 역시 안전하게 사용할 필요가 있다.

이 2개의 조건을 가지고 있기 때문에 아래의 코드가 더 맞아 보인다.

BiometricManager가 Nullable인 게 아닌 return 하는 ObjectNullable이거나, 다른 Type임을 Type casting 하는 형태가 더 맞아 보인다.

val biometricManager = getSystemService(Context.BIOMETRIC_SERVICE) as? BiometricManager


이런 경우는 제외

아래와 같은 Java로 작성한 코드가 있다. name으로 넘어오는 값은 Nullable 일 수도, 아닐 수도 있다.

public class JavaSample {

    public String getName() {
        return null;
    }
}

그래서 이를 활용하는 Kotlin에서는 아래처럼 사용했다.

@Test
fun test() {
    val test = JavaSample()
    val value: String = test.getName()
    println("value is $value")
}

이 경우라면 Type은 명확하지만 name이 Nullable 일 수 있는 상황이다. 그렇기에 아래처럼 수정하는 게 맞다.

// 후보 1
val value: String = test.getName() as String? ?: "NONE"

// 또는

// 후보 2
val value: String? = test.getName()

후보 1, 2두 개 모두 정답이다.

  • 코드상 Nullable 이면 동작에 문제가있다면 후보 1번처럼 ?:을 처리함이 필요하다.
  • 코드상 Nullable 이어도 상관없다면 후보 2번이 맞고

결국 이 코드처럼 Type casting을 할 필요가 없지만 Nullable인 경우라면 as String?의 형태를 써야 하고, getSystemService처럼 Object로 리턴하고, Nullable인 경우라면 as? BiometricManager의 형태로 써야 안전하게 사용할 수 있다.


번외 - 그렇지만 … 안드로이드니깐

사실 위 코드는 굳이 Nullable 하게 작성할 필요가 없다고 생각한다. 어차피 Nullable 이면 다음을 진행하지 못한다.

context를 통해 system 권한을 획득해 사용해야하는데, 이 값이 Nullable이거나, 다른 값이 온다면 비정상 진입이라는 것이다.

그래서 필자가 가장 추천하는 코드는 그냥 null 체크를 하지 않고, 그냥 아래처럼 쓰는 걸 추천한다. 어차피 진입하더라도 할 수 있는 게 없다.

val biometricManager = getSystemService(Context.BIOMETRIC_SERVICE) as BiometricManager

사실 진입하더라도 할 수 있는 게 없지는 않다. 오류를 묶어서 오류보고를 하고, 조치를 취할 수도 있다. 다만 이 코드에서의 오류는 시스템 오류라 처리할 수 있는 부분이 많지 않고, 회피는 가능하겠다.


번외 - 안드로이드 시스템을 사용할 때는 NotNull을 무서워하지 마라.

안드로이드도 Nullable과 NotNull에 대한 어노테이션을 추가하는 작업을 계속해서 했다.

그중 하나가 Fragment에서 requireActivity(), requireContext() 등을 제공하는 것이다.

이를 보면 알 수 있듯 OS에서 제공하는 것은 그냥 requireContext, requireActivity를 사용해 오류가 발생했다면 비정상적인 진입이며, 이는 구글의 문제로 보고 넘어가거나 버그 레포팅하는 편이 좋다고 생각한다.

그래도 Nullable을 써야겠다고 하면 말리지는 않는다. 다면 아래와 같은 함수 일부가 결국 requireContext 등을 활용하고 있어 오류 나는 건 동일하다는 것이다.

/**
 * Return <code>requireActivity().getResources()</code>.
 */
@NonNull
final public Resources getResources() {
    return requireContext().getResources();
}

결국 구글도 진입의 문제나, 자신들의 플랫폼에서 발생하는 오류는 그냥 오류라고 생각하고 흘려보내는 것이다. 어차피 수정이 필요하고, 개선이 필요하다면 저건 구글에서 할 수 있는 형태여야 한다.

개발자가 받는 스트레스가 없지는 않겠으나… 안드로이드에서 제공하는 오류는 안드로이드가 해결해 줘야지…


Type casting을 정리하면?

이런 Type casting은 자바에서 설명하는 다형성(Polymorphism)을 이용해 개발할 때 주의해야 한다.

Any는 Kotlin에서 최상위에서 여러 가지 형태를 가질 수 있도록 만들어져있다. 그래서 필요한 게 바로 Type casting(as, is)이다.

Type casting을 통해 구체화하고, 내가 원하는 객체가 맞는지를 찾고 이를 통해 객체의 함수들과 변수들에 접근을(당연히 public 접근자에 한한다.) 허용 받을 수 있다.

그래서 as와 is를 적절하게 사용해야 한다.


코드 가독성

또 다른 부분은 바로 가독성이다. 위에서 작성했던 casting 코드를 다시 가져와보았다.

이 내용은 Effective Kotlin 책을 참고해 작성한다.

2개의 test 모두에서 볼 수 있는 공통적인 문제가 하나 있다. 바로 var를 사용해 값을 언제든 변경할 수 있는 형태로 작성했다는 것이다.

이 코드는 사실 var 대신 val로도 가능하다. 코틀린에서는 아래와 같이 즉시 값을 if-else로 초기화 시키는 경우 val을 허용해 준다. 그래서 var 대신 val로 처리가 가능하다.

// 생략

var value: String
if (defaultValue is String) {
    value = defaultValue
} else {
    value = "-1"
}
// 생략
var value: String
when (defaultValue) {
    is String -> value = defaultValue
    else -> value = "-1"
}
// 생략

참고로 이 코드는 var 대신 val을 사용하더라도 아래와 같은 형태도 사용하는 게 가능하다.

val value: String
val value2: String
if (defaultValue is String) {
    value = defaultValue
} else {
    value = "-1"
}
when (defaultValue) {
    is String -> value2 = defaultValue
    else -> value2 = "-1"
}

var을 사용했을 때의 문제는 명확하다.

  • var의 값은 언제든 변경 가능성이 있다.
  • 길이가 수백 줄에 달하는 코드에서 var로 값을 변경하면 사실 트래킹 하는 게 쉽지는 않다.

그래서 val로 사용했다. 하지만 위에서 보았지만 if-else 문이 어디에 있던 초기화를 허용해 준다. 동일한 scope에 코드가 있다면 위치는 상관없이 초기화에 문제가 없다.

@Test
fun test() {
    val defaultValue: Any = 0

    val value: String // 변수 1
    val value2: String // 변수 2

    // 변수 1 초기화
    if (defaultValue is String) {
        value = defaultValue
    } else {
        value = "-1"
    }
    // ... 여기선 다른 행동을 한다.
    val name = "ABC"
    fun test() {

    }
    if (value == "-1") return
    // ...
    when (defaultValue) {
        is String -> value2 = defaultValue
        else -> value2 = "-1"
    }
    println("defaultValue $defaultValue value $value value2 $value2")
}

이렇게 작성하는 경우는 없겠지만 위 코드는 정상 동작한다. value2의 값 초기화 위치는 점점 더 길어지는 함수라도 상관없다. 다만 val이니 처음 값을 기준으로 초기화하고, 나머지는 오류가난다.

위 코드의 가독성을 높이는 방법 중 가장 좋은 방법은 아래와 같다.

@Test
fun test() {
    val defaultValue: Any = 0

    val (value, value2) = when (defaultValue) {
        is String -> defaultValue to defaultValue
        else -> "-1" to "-1"
    }
    println("defaultValue $defaultValue value $value value2 $value2")
}

to는 Pair의 infix function으로 value, value2의 값에 하나씩 대칭된다.

val의 사용으로 value, value2의 값은 명확해졌고, 코드도 기존 보다 읽기 좋아졌다. 코드 가독성을 높이는 건 별거 없다. 언어에서 제공해 주는 기법을 잘 활용하고, 코드를 정리를 잘하는 등의 형태만 해줘도 충분하다고 생각한다.

참고로 위에서 작성한 샘플은 2개의 값을 동시에 받는 것이나, 일반적으론 1개만 받을 태고 아래와 같이 코드를 작성한다.

val value = when (defaultValue) {
    is String -> defaultValue
    else -> "-1"
}

// 또는

val value = if (defaultValue is String) {
    defaultValue
} else {
    "-1"
}


마무리

매우 짧을 거라고 생각한 글이 상당히긴 글이 되었다.

이 글의 내용을 요약정리하면

  • as를 이용한 Type casting 할 때는 넘어오는 값이 Any 인지, 넘어오는 값 자체가 Nullable 인지 확인하고 사용해라.
  • Android SDK에서 제공하는 값을 사용할 때는 상황에 따라서 값을 사용하길 추천한다.
  • val/var에 값을 채울 때는 가독성을 지켜 사용하는 게 좋다.

더 잘 알고 쓸 수 있도록 몇 개의 글을 더 정리할 예정입니다.

사실 이펙티비 자바나, 이펙티브 코틀린, 코틀린 인 액션과 같은 책들을 사전에 보신 분은 이 글이 필요치는 않죠…


이 글만 보기 아쉽죠. Effective Kotlin 더 보기

지금까지 작성한 EffectiveKotlin 관련 글은 태그로 모아 링크를 추가한다.

더 보기 - Effective Kotlin 관련 글



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments