Java value는 언제나 null이다. 이를 Kotlin에서 활용 시 주의할 점을 알아본다.



개인 광고 영역

Java 변수는 언제나 null을 허용하고, Kotlin 변수는 null을 허용치 않는다. 그래서 타입 정의에서 항상 null을 허용할지 말지를 직접 정의해야 한다.

// null 허용
val name: String? = null

// NonNull
val name: String = null // 문법 오류

Kotlin은 null을 허용하려면 ?을 추가해 null 임을 알려야 한다. 코틀린 만 사용한다면 별다른 문제는 없다. 하지만 Kotlin은 언제나 Java를 활용할 수 있고, 안드로이드 개발에선 Java SDK를 활용한다.

어떤 경우에 null을 허용하고, 어떨 때 null을 허용치 않아야 하는지 명확하지 않을 수 있다.

이 글에서는 자바와 함께 사용할 때 null 관련 어노테이션의 중요성을 알아본다.


이 글에서 알아볼 내용

  • Java는 항상 null 임을 알아본다.
  • Android SDK 사용 시에는 Nullable로 처리해야 할까?
  • Java에서 Kotlin의 값을 호출 시에는 null에 주의해야 하는데 알아본다.


자바는 항상 null 일 수 있다.

코틀린에서 자바의 값을 부를 때 별도의 어노테이션 정의가 없다면 항상 NotNull로 취급하는데, 아래와 같이 null을 리턴하더라도 코틀린은 항상 Not null로 취급한다.

public class JavaSample {

    public String getName() {
        return null;
    }
}

그래서 Kotlin에서 코드를 작성하더라도 별도의 오류 또는 워닝 메시지가 없다.

@Test
fun testJava() {
    val javaSample = JavaSample()
    val name: String = javaSample.name
    println("out $name size ${name.length}")
}

이 코드는 당연하게도 런타임 Exception이 발생한다.

java.lang.NullPointerException: javaSample.name must not be null

	at com.example.myapplication.TestTest.testJava(TestTest.kt:12)

디컴파일 해보면 이해할 수 있는데 아래와 같다.

@Test
public final void testJava() {
   JavaSample javaSample = new JavaSample();
   String var10000 = javaSample.getName();
   Intrinsics.checkNotNullExpressionValue(var10000, "javaSample.name");
   String name = var10000;
   String var3 = "out " + name + " size " + name.length();
   boolean var4 = false;
   System.out.println(var3);
}

checkNotNullExpressionValue의 동작에 따라 null이 아님을 체크하는 코드가 포함되어 있는데, 이 코드에서 NullPointerException이 발생한다.

그리고 Type을 정의하지 않을 경우엔 checkNotNullExpressionValue이 빠져있음을 알 수 있다.

@Test
fun testJava() {
    val javaSample = JavaSample()
    val name = javaSample.name
    println("out $name size ${name.length}")
}

하지만 이 코드에서도 오류는 발생하는데, name.length에 접근 시 NullPointerException(NPE)가 발생한다.

정리하면 자바를 호출할 때 별도의 명시가 없다면 코틀린은 항시 Not null로 값을 처리한다. 코드나 문법상 이상이 없어 보이지만 실제론 RunntimeException이 발생한다.


null 관련 어노테이션은 중요하다.

이런 내용은 필자의 블로그에서 여러 번 언급했고, 다양한 블로그에 글들이 많다. Effective Kotlin 책에서도 챕터 1에서 많은 양 다르고 있으니 참고하시면 좋을 듯하다.

내가 작성한 코드가 아닌 자바로 작성하는 코드에서는 @NonNull@Nullable 어노테이션을 처리하는 방법은 다양하다.

그런데 제공하는 라이브러리별 어노테이션 import의 종류가 많다.

sample_01

이 외에도 더 많은데 Effective Kotlin 책을 참고해 아래와 같이 추가해본다.

  • JetBrains : @Nullable, @NotNull org.jetbrains.annotations
  • Android : @Nullable, @NonNull androidx.annotation
  • JavaX : @Nullable, @CheckForNull, @Nonnull javax.annotation
  • ReactiveX : @Nullable, @NonNull io.reactivex.annotations
  • Eclipse : @Nullable, @NonNull org.eclipse.jdt.annotation
  • Lombok : @NonNull

사용하는 라이브러리에 따라 더 많은 null 처리 annotation이 존재할 수 있다.


다행히 안드로이드는 null 관련 어노테이션 적용이 많아졌다.

안드로이드에서는 이러한 어노테이션의 적용이 많아지고 있다.

하지만 안드로이드는 대부분 Lifecycle에 따른 동작을 해야 하고, 대부분 권한에 대한 부분들이 포함되어 있어 내부적으로 requireXXX로 시작하는 함수들을 만들고, @NonNull을 정의하고 있다.

이전에 작성한 Kotlin 안전한 Type casting을 알아보고, 코드 가독성도 올려보자. - 링크에서 안드로이드 부분에 작성한 부분이 있는데, 이 부분은 매우 중요하다고 생각한다.

Context를 이용해 시스템의 권한 획득 시 null을 무시해도 좋다. 대부분 비정상적인 동작으로 보고, 차라리 구글에 레포팅하는 편이 좋다.

예를 들면 Fragment 사용 시 밖에서 열심히 null이라고 생각해 ?. 처리하더라도, 내부 코드는 이미 @NonNull을 바라보도록 설계되어 있는 코드가 있을 수 있다.

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

그리고 이와 같이 @NonNull@Nullable 어노테이션 처리가 잘 되어있는 코드들이 있으니 이에 따라 사용할 순 있다.

/**
 * Return the {@link Context} this fragment is currently associated with.
 *
 * @see #requireContext()
 */
@Nullable
public Context getContext() {
    return mHost == null ? null : mHost.getContext();
}

결국 이런 어노테이션 @NonNull@Nullable이 잘 처리되어 있는 코드는 코드 작성하기 쉽다.

이런 안드로이드 레퍼런스 활용 시에는 Lifecycle에 따라 사용하는 코드라면 @Nullable을 사용해 !! 또는 ?.을 처리하는 것이 아닌 그냥 @NonNull임을 간주하고 requireXXX 함수를 사용하길 추천한다.

  • getContext() 접근 시에는 requireContext()을 활용하라
  • getActivity() 접근 시에는 requireActivity()을 활용하라
  • view!! 대신 requireView()을 활용하라

결국 requireXXX를 사용한다는 점은 Lifecycle에 따라 보증을 받아야 하는 코드이고, 그 외에는 비정상적인 동작이거나, 코드를 잘 못 작성했을 가능성이 있으니 다시 확인하길 바란다.


비동기에서의 처리는? - LiveData 활용

비동기에서 오류가 발생해서 null 값을 사용한다면 일부는 잘못 알고 사용할 수도 있음을 알아야 한다.

특히 Lifecycle을 따르는 LiveData를 사용 시에는 어차피 라이프사이클 대로 동작하기 때문에 문제가 생긴다면 SDK 제공사인 구글의 잘못이다.

class MainViewModel : ViewModel() {
  private val _callLiveData: MutableLiveData<String> = MutableLiveData()
  val callLiveData: LiveData<String>
      get() = _callLiveData
}

class MainFragment : Fragment() {

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
      super.onViewCreated(view, savedInstanceState)
      viewModel.callLiveData.observe(viewLifecycleOwner, {
        // Use...
      })
  }
}


비동기에서의 처리는? - 직접 처리하는 Lifecycle

Presenter에서는 LiveData 대신 직접 RxJava를 활용하거나, view를사용하는 경우가 많은데, 이때는 Lifecycle을 직접 처리하는 코드를 포함해 주어야 한다.

interface View {
    fun onShow()
}

class MainPresenter(
    private var view: View?
) {

    fun loadItem() {
        // 비동기 작업 후 view가 Non null이면 onShow 호출
        view?.onShow()
    }

    fun destroy() { // 여기서 종료 처리
        view = null
    }
}

class MainFragment : Fragment() {

  override fun onDestroy() {
    super.onDestroy()

    presenter.destroy()
  }
}

결국 어느 정도는 개발 습관을 통해 안드로이드 플랫폼에서 제공하는 문제를 사전에 예방할 수 있다.


정리하면 어노테이션을 잘 활용하자

안드로이드에서는 모든 코드에 잘 해주지는 않지만 일부라도 잘 해주고 있음을 알 수 있는데, 특히 Fragment에서 잘 해주고 있다.

만약 자바로 라이브러리를 만드는 경우에는 이러한 어노테이션을 잘 지켜주어야 한다.

위에 JavaSample로 작성한 코드는 별도의 어노테이션 명시가 없었다. 그렇기 때문에 언제든 null이 리턴될 수 있고, 코틀린은 별도의 명시가 없을 경우 Non null로 처리하기 때문에 더 잘 지켜야 한다.

이를 지키기 어렵다면 라이브러리 내부에서 Exception을 다르게 나도록 만드는 방법도 좋다.

public class JavaSample {

    @Nullable
    public String getName() {
        return null;
    }
}

이렇게 작성하면 위에서 작성한 Kotlin 샘플 코드에서 바로 오류가 발생한다.

Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?

명확하게 작성되어 좀 더 좋은 결과를 볼 수 있기에 적절한 어노테이션은 개발에 좋은 영향을 준다.


Java에서 코틀린을 부를 때

반대로 Java에서 코틀린을 부를 때의 문제가 발생 한다. 자바는 언제나 null을 허용하고, Kotlin은 ?을 통해 null임을 명시한다.

class KotlinSample {

    fun setName(value: String) {
       // 어떤 값 처리
    }
}

코틀린 클래스 접근 시 Java에서 아래와 같이 null을 입력하는게 가능하다.

다만 워닝은 뜬다.

Passing ‘null’ argument to parameter annotated as @NotNull

@Test
public void test() {
    KotlinSample sample = new KotlinSample();
    sample.setName(null);
}

자바는 null에 대한 부분을 강하게 제어하지 않기에 이 코드는 빌드에 문제가 없지만, 런타임에 NPE(NullPointerException)가 발생한다.

그 이유는 디컴파일을 통해 알 수 있다.

public final class KotlinSample {
   public final void setName(@NotNull String value) {
      Intrinsics.checkNotNullParameter(value, "value");
   }
}

이런 부분은 코틀린을 메인으로 사용하는 경우라면 크게 문제는 없지만, 자바와 코틀린을 혼용해 개발하는 경우라면 매우 흔하게 발생할 수 있는 문제이다.

  • Nullable을 허용하는 코드로 수정
  • Java에서 호출 시 사전에 Non null 처리
@Test
public void test() {
    String name = null;
    KotlinSample sample = new KotlinSample();
    if (name != null) {
        sample.setName(name);
    }
}

과거에는 이러한 문제가 발생 가능성을 누락한 적이 있어 오류가 발생했고, Kotlin에서 null을 처리하도록 허용해 수정했다.

결국 이런 문제는 제거 대상인 자바를 희생해 추후 완전한 코틀린 변환에 도움을 주는 형태를 만드는 것이 더 좋은 결과를 볼 수 있다.

  • Java에서 호출 시 사전에 Non null 처리하는 편을 추천한다.
  • Java 전용 함수를 별도로 overload 하고, 내부에서 Non null 처리하는 방법도 있다.


마무리

너무나도 간단한 문제를 정리해보았다. 결국 null에 대한 값을 잘 처리해 주는 게 좋다.

이번 글 요약

  • Android Lifecycle을 따르는 코드에서는 Non null을 선호해라.
    • 어차피 시스템 접근 시 Non null이 아니라면 비정상 동작이라 사실상 접근 못하는 케이스가 더 많다.
  • 직접 작성한 코드에서는 어노테이션을 잘 활용하라.
  • 많은 @Nullable, @NonNull이 있는데 개발하려는 라이브러리/코드에서는 해당 androidx의 어노테이션을 활용을 추천한다.
  • ViewModel 활용 시에는 LiveData를 잘 활용하면 좋다.
  • RxJava를 활용할 경우에는 별도의 라이프사이클 처리를 필요로 한다.
  • Presenter를 활용할 경우에는 별도의 라이프사이클 처리를 필요로 한다.
  • Java에서 Kotlin 함수를 호출할 때는 null의 값에 주의해야 한다.
  • 자바를 생각해 Kotlin에서 무조건적인 null 처리는 추후 완전한 코틀린 변환 시 가독성을 해칠 수 있다.


이 글만 보기 아쉽죠. 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