쾌락코딩

TestCoroutineDispatcher 제어하기

|

Android Testing을 위한 사전 지식에서 어떻게 android code를 테스트하는지 살펴보았다. 주로 안드로이드 OS 특성과 코루틴의 특징들로 인해 주의해야할 점들을 알아보았다.

이번 포스트에서는 코루틴을 테스트 할 때 필요한 몇가지 기능들을 더 알아보자.

Progress bar 상태 변경 테스트

보통 ViewModel에서 Repository 혹은 UseCase로 데이터를 요청한다. 네트워크나 DB 조회로 인한 시간이 걸리기 때문에 그동안 Progress bar를 띄워주는 건 보편적이다.

class MyViewModel(): ViewModel() {

    private val _isLoading = MutableLiveData<Boolean>()
    val isLoading: LiveData<Boolean> = _isLoading

    fun loadData() = {
        _isLoading.value = true // TRUE 인지 테스트
        viewModelScope.launch {
            repository.fetchData() // 호출 되었는지 테스트
            _isLoading.value = false // FALSE 인지 테스트
        }
    }
}

loadData()라는 간단한 함수를 테스트 하기 위해서는 주석에 적혀 있듯 3가지를 테스트 한다.

  1. isLoading이 true인지 확인
  2. loadData가 호출되었는지 확인
  3. isLoading이 false인지 확인

테스트 코드는 대략 아래와 같이 시작될 것이다.

class MyViewModelTest() {
    private val testDispatcher = TestCoroutineDispatcher()
    ...
    fun test_loadData() = runBlockingTest {

        // when
        myViewModel.loadData()

        // then
        // 1. isLoading == true인지 확인
        // 2. loadData가 호출되었는지 확인
        // 3. isLoading == false인지 확인
    }
}

then에서 각자 선호하는 assertion 라이브러리를 사용해 원하는 값이 매칭되는지를 확인한다.

하지만 then 주석을 풀고 코드를 작성한 후 실행하면 첫 isLoadingfalse이기 때문에 테스트는 실패한다.

TestCoroutineDispatcher는 코루틴 작업들을 즉각적으로 실행하고 완료하게 설계되어있다. 즉 assertion 코드를 만나기 전에 loadData의 실행이 모두 끝난다는 것이다. 대부분의 경우에는 이같은 특성이 테스트를 빠르게, 쉽게, 가독성 좋게 해주지만 위 코드 처럼 코루틴 중간에 변경되는 값을 테스트할 때는 난감하다.

이럴 땐 TestCoroutineDispatcherpauseDispatcherresumeDispatcher를 사용하여 Dispatcher 타이밍을 제어할 수 있다.

pauseDispatcherTestCoroutineDispatcher를 멈춰놓음으로써 새로운 코루틴이 즉각 실행되지 않도록 막는다(단지 queue에 들어가서 대기한다). 따라서 viewModel.loadData()처럼 새로운 코루틴을 실행하는 함수를 테스트할 때 유용하게 쓰인다. 새로운 코루틴이 실행되기 전에 assertion을 할 수도 있고 필요한 setup을 해놓을 수도 있다.

resumeDispatcher는 paused Dispatcher를 resume시킨다.

따라서 아래와 같이 테스트 코드를 작성하면 isLoading 상태를 확인할 수 있다.

class MyViewModelTest() {
    private val testDispatcher = TestCoroutineDispatcher()
    ...
    fun test_loadData() = runBlockingTest {
        pauseDispatcher() // 새로운 코루틴은 실행되지 않고 준비만 한다.

        // when
        myViewModel.loadData()

        // then
        check(viewModel.isLoading.getOrAwaitValue() == true)

        // 아까 대기시켰던 코루틴을 resume시킨다.
        // repository.loadData() 와 _isLoading.value = false가 실행
        resumedDispatcher()

        isCalled(repository.fetchData()) // repository의 fecthData가 호출되었는지 확인
        check(viewModel.isLoading.getOrAwaitValue() == false)
    }
}

Android Testing을 위한 사전 지식

|

Local test vs Instrumented test

안드로이드 프로젝트를 만든 후 source sets을 살펴보면 main, test, androidTest 폴더들이 보인다.

main - 프로덕트 앱을 구성하는 코드들을 넣는 곳이다.

test - local test라고 불리는 test를 담는 곳이다. local test는 오직 개발 장비의 JVM에서 돌아가게 되며 실제 디바이스나 에뮬레이터에서 테스트 하지 않아도 되는 것들을 테스트 한다. 기기 부팅이나 에뮬레이터를 띄우지 않기 때문에 run 속도가 빠르지만, android 앱개발을 하는데 android 기기에서 테스트 해보지 않는다는 점에 있어서 정확도가 떨어질 수도 있다.

android test - Instrumented test라고 불리는 test를 담는 곳이다. Instrumented test는 실제 기기나 에뮬레이터에서 진행되기 때문에 실제 환경에서 테스트한다는 장점이 있지만 local test에 비해 많이 느린 편이다. 참고로 CI서버에서 테스트 자동화가 이뤄진다면, Instrumented test를 위해 에뮬레이터 혹은 실 기기를 서버에 연결해둬야 한다.

잘 설계된 아키텍쳐를 기반으로 앱을 설계하게되면, 굳이 Android framework class에 의존하지 않는 layer들이 있다. view에 표현될 data들을 가지고 로직적인 장난만 치는 viewModel, 앱의 핵심 기능을 담은 domin layer의 클래스 등등 여러 객체들은 local에서 테스트 해도 문제가 없으며 오히려 권장된다.

ActivityService와 같은 Android Framework Component 자체를 테스트 할 경우 때에따라 Instrumented test로 해야만 할 수도 있지만, 단지 그들을 의존하고 있다면 의존 객체를 mocking하여 local test로 돌릴 수 있다.

AndroidX Test Library

순수한 ViewModel은 android framework가 제공하는 어떤 것도 의존하지 않는다. 그러나 어떤 불가피한 이유로 viewModel이 Application context에 의존한다면 테스트 코드에서는 이를 어떻게 주입해야할까?

AndroidX Test Library가 이를 해결해준다. AndrodX Test Library는 테스트 전용 Component(Application, Activity 등)들과 메서드들을 제공해준다. local test중에 테스트용 Android framework class들이 필요하다면 이를 사용하자.

AndroidX Test Library를 사용하기 위해서는 아래와 같은 절차가 필요하다.

  1. AndroidX Test core 와 ext 라이브러리 추가
  2. Robolectric Testing Library 추가
  3. 사용하는 테스트 클래스에 @RunWith(AndroidJUnit4::class) 추가
// AndroidX Test - JVM testing
testImplementation "androidx.test.ext:junit-ktx:$androidXTestExtKotlinRunnerVersion"
testImplementation "androidx.test:core-ktx:$androidXTestCoreVersion"
testImplementation "org.robolectric:robolectric:$robolectricVersion"

잠깐, Robolectric과 @RunWith(AndroidJUnit4::class)는 왜 추가할까?

Robolectric이란?

AndroidX Test는 simulated android environment에서 테스트 전용 클래스와 메서드들을 제공해준다. simulated 환경이기 때문에 실제 안드로이드 기기의 환경이 아니다. simulated android environment를 만들어주는 것이 Robolectric이다. 예전에는 local test에서 context를 얻기 위해서 직접 Robolectric을 사용했지만 AndroidX Test 라이브러리 덕분에 직접 사용할 일은 없어졌다. 참고로 Instrumented test에서도 AndroidX Test Library의 같은 메서드를 사용하여 context를 얻을 수 있는데, 이때는 simulated environment가 아닌 부팅된 실제 기기의 Application context를 반환한다.

AndroidJUnit4::class

AndroidJUnit4는 test runner다. 즉 테스트를 실행하는 주체다. junit은 이런 runner가 없이는 테스트가 실행되지 않으며, runner를 따로 지정해주지 않으면 기본 제공하는 runner로 실행된다. @RunWith를 사용하여 Runner를 교체할 수 있다.

AndroidJUnit4는 AndroidX Test Library가 Local test와 Instrumented Test에서 서로 다르게 동작할 수 있도록 도와준다. Context를 얻을 때, local test에서는 simulated context를 제공하고, instrumeted test에서는 실제 context를 제공할 수 있는 이유가 AndroidJUnit4 Runner 덕분이다. 따라서 AndroidJUnit4 test runner없이 AndroidX Test를 사용하면 제대로 동작하지 않을 가능성이 크다. AndroidX Test 라이브러리를 사용할 땐 AndroidJUnit4 라이브러리를 사용하자.

Rule

JUnit에는 JUnit Rule이라는 것이 있다. 테스트를 작성하다보면 여러 테스트 클래스에서 테스트 사전 작업, 직후 작업이 동일할 때가 있다. 즉 코루틴을 사용하는 대부분의 테스트 클래스에서는 @Before에서 Main Dispatcher를 바꾸고, @After에서 되돌려 놓는다. 이런 작업을 하나의 Rule로 만들어 놓으면 @Before, @After마다 자동으로 수행되어 보일러 플레이트 코드를 줄일 수 있다. @get:Rule annotation을 붙여 사용한다.

@get:Rule
val mainCoroutineRule = MainCoroutineRule()

참고로 AndroidX test에는 ActivitySenarioRule, ServiceTestRule등과 같은 유용한 Rule들을 제공한다.

InstantTaskExecutorRule

LiveData를 사용하는 ViewModel 테스트를 진행한다고 해보자.

class MyViewModel: ViewModel() {
    private val _myLiveData = MutableLiveData<String>()
    val myLiveData: LiveData<String> = _myLiveData

    fun method1() {
        myLiveData.postValue("value")
    }
}

위와 코드가 있을때 주의할 점은, postValue가 백그라운드 Thread에서 일을 처리한다는 것이다. MyViewModelTest.kt 에서는 myViewModel.method1() 수행 이후 liveData의 value가 제대로 변했는지 확인하고자 할 것이다. 그러나 백그라운드에서 작업으로인한 비동기 처리 때문에 값이 변경되기도 전에 테스트가 끝나버려 실패하게 된다. 이럴때 사용하는 것이 InstantTaskExecutorRule이다.

InstantTaskExecutorRule : 모든 Architecture Components-related background 작업을 백그라운드에서가 아닌 동일한 Thread에서 돌게하여 동기적인 처리가 가능하도록 해준다.

따라서 LiveData를 테스팅한다면 이 Rule을 적용하자.

local test에서 주의할 점

local test에서는 postValue가 아닌 setValue를 사용한다 하더라도 InstantTaskExecutorRule을 적용하지 않는다면 여전히 테스트는 실패한다. 에러는 아래와 같다.

java.lang.NullPointerException
	at androidx.arch.core.executor.DefaultTaskExecutor.isMainThread(DefaultTaskExecutor.java:77)
	at androidx.arch.core.executor.ArchTaskExecutor.isMainThread(ArchTaskExecutor.java:116)
	at androidx.lifecycle.LiveData.assertMainThread(LiveData.java:461)
	at androidx.lifecycle.LiveData.setValue(LiveData.java:304)
	at androidx.lifecycle.MutableLiveData.setValue(MutableLiveData.java:50)
    ...

LiveData.setValue()는 내부적으로 isMainThread()함수를 사용하여 현재 쓰레드가 메인 쓰레드인지를 확인한다. local test에서는 당연하게도 real Android환경이 아니기 때문에 MainThread(=UiThread)를 사용하지 못한다(Android MainThread는 android.os의 Looper를 사용). 그래서 에러가 난다.

그렇다면 InstantTaskExecutorRule을 사용하더라도 MainThread(UiThread)를 사용하는 건 아니니까 여전히 에러가 나야하지 않을까?라는 의문이 떠오른다. 그래서 Rule의 내부를 타고 들어가보니 isMainThread()true로 하드코딩 되어있는걸 확인할 수 있었다.

/**
 * A JUnit Test Rule that swaps the background executor used by the Architecture Components with a
 * different one which executes each task synchronously.
 * <p>
 * You can use this rule for your host side tests that use Architecture Components.
 */
public class InstantTaskExecutorRule extends TestWatcher {
    @Override
    protected void starting(Description description) {
        super.starting(description);
        ArchTaskExecutor.getInstance().setDelegate(new TaskExecutor() {
            @Override
            public void executeOnDiskIO(Runnable runnable) {
                runnable.run();
            }

            @Override
            public void postToMainThread(Runnable runnable) {
                runnable.run();
            }

            @Override
            public boolean isMainThread() {
                return true;
            }
        });
    }

    @Override
    protected void finished(Description description) {
        super.finished(description);
        ArchTaskExecutor.getInstance().setDelegate(null);
    }
}

결론은 InstantTaskExecutorRule을 써야한다는 것이다.

코루틴 테스트를 위한 MainCoroutineRule

이번에는 코루틴 테스트를 해보자

class MyViewModel: ViewModel() {
    var name: String = "hello"
        private set

    fun changeName(newName: String) = viewModelScope.launch {
        name = newName
    }
}

위의 코드는 default Dispatcher가 Dispatchers.main으로 설정된 viewModelScope.launch로 코루틴을 실행한다. 이 코드를 테스트하기 위해 아래와 같은 테스트 코드가 작성될 것이다.

class MyViewModelTest: ViewModel() {
   @Test
   fun changeNameTest() = runBlocking {
       val myViewModel = MyViewModel()
       val newName = "world"
       myViewModel.changeName(newName)
       Assert.assertEquals(myViewModel.name, newName)
   }
}

테스트를 실행하면 에러가 난다.

Exception in thread "main @coroutine#1"
java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize.
For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

InstantTaskExecutorRule 설명에서도 나왔듯이 viewModelScope.launch가 사용하는 Dispatcher.main은 Android main looper를 사용하기 때문에 local test에서는 사용할 수 없다. 이럴땐 kotlinx-coroutines-test에서 제공하는 TestCoroutineDispatcher를 사용하여 해결할 수 있다. kotlinx-coroutines-test는 coroutine testing 전용으로 만들어진 라이브러리이다.

@ExperimentalCoroutinesApi
class MyViewModelTest {
    private val testDispatcher = TestCoroutineDispatcher()

    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
    }

    @After
    fun tearDown() {
        //  원래의 상태로 되돌려 놓는다.
        Dispatchers.resetMain()
        // 테스트가 끝났으니 혹시 모를 실행중인 작업을 clean up 시켜준다.
        testDispatcher.cleanupTestCoroutines()
    }

    @Test
    fun testSomething() = runBlockingTest {
        ...
    }
}

모든 테스트 클래스마다 작성하기 귀찮으므로 MainCoroutineRule을 만들어서 사용하자.

@ExperimentalCoroutinesApi
class MainCoroutineRule(
    val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {

    override fun starting(description: Description?) {
        super.starting(description)
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description?) {
        super.finished(description)
        Dispatchers.resetMain()
        testDispatcher.cleanupTestCoroutines()
    }
}

@ExperimentalCoroutinesApi
fun MainCoroutineRule.runBlockingTest(block: suspend () -> Unit) =
    this.testDispatcher.runBlockingTest {
        block()
    }

TestCoroutineDispatcher를 사용하려면 kotlinx-coroutines-test 라이브러리를 추가해야한다.

testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version"

runBlockingTest

바로 위의 코드에서 runBlockingTest() 함수를 사용했다. kotlinx-coroutines-test는 coroutines test library에서 제공되는 함수로써 특별한 점이 있다.

  1. 코드 블럭을 특별한 coroutine context에서 실행하여 동기적으로 즉각 실행한다.
  2. delay() 함수가 사용된다면, 실제 delay 시간 만큼 기다리지 않고 테스트를 실행한다. 즉 모든 pending task들을 즉각 실행하며 가상의 clock-time을 딜레이된 시간으로 조정한다.

즉 테스트 코드에서는 대부분 실제로 delay만큼 기다릴 필요가 없기 떄문에 runBlockingTest() 함수를 사용하여 테스트 코드 시간을 단축 시킬 수 있다.

참고자료

코틀린 개발자는 null을 어떻게 바라봐야 하는가

|

(널 포인터는) 내 10억 달러짜리 실수였다. 1965년 당시, 나는 ALGOL W라는 객체 지향 언어에 쓰기 위해 포괄적인 타입 시스템을 설계하고 있었다. 내 원래 목표는 어떤 데이터를 읽든 항상 안전하도록 컴파일러가 자동으로 확인해 주는 것이었다. 그러나 나는 널 포인터를 집어넣으려는 유혹을 이길 수가 없었다. 그렇게 하는 게 훨씬 쉬웠기 때문이다. 이 결정은 셀 수도 없는 오류와 보안 버그, 시스템 다운을 낳았다. 지난 40년 동안 이러한 문제들 때문에 입은 고통과 손해는 10억 달러는 될 것이다. - 토니 호어

Null이라는 단어를 보면 무슨 느낌이 드는가. 쳐다보기도 싫고, 스트레스를 받고, 두렵고, 피하고 싶다면 당신은 아마 높은 확률로 코틀린 개발자는 아닐 것이다(그래야만 한다).

Java를 프로로써 다뤄본 적은 없어도 한 번쯤은 모두 접해보았을 거라 생각한다. 나 역시도 자바를 다뤄본 적이 있다. 그때를 생각해보면, null은 항상 회피와 두려움의 대상이었다. NullPointException을 마주칠까 두려워했었다. 그래서 null을 어떻게 다뤄야 하는지에 대한 레퍼런스를 검색하곤 했었다.

Java에서 null을 다루는 몇 가지 지침

지금도 Java와 null을 함께 검색하면, null을 다룰때 권장하는 지침들이 나온다. 아래는 대표적인 3가지 지침이다.

  1. API에 null을 최대한 쓰지 말아라.
  2. 초기화를 명확히 해라.
  3. Optional을 잘 익혀 사용해라.

3번 같은 경우 실제로 JAVA코드에서 많이 보이고 있는것 같다.

그러나 코틀린 개발자 입장에서 위의 3가지 지침들이 안타깝게 느껴진다. 위의 지침들은 null을 회피하게 만들거나, 많은 boilerplate 코드들을 필요로 하기 때문이다. 자바를 다룬다면 지키는게 좋은 사항들이지만 아쉬운건 어쩔 수 없다.

null의 본질

null의 본질을 생각해보자. 자바의 지침들이 말하고 있는 것 처럼 null은 정말 나쁜놈이며, 피해야할 대상일까?

나는 그렇게 생각하지 않는다. 현대 많은 programming lanaguage 디자이너들도 그렇게 생각하지 않는것 같다.

이 글의 가장 첫 부분에 토니 호어의 말을 인용했다.

(널 포인터는) 내 10억 달러짜리 실수였다. 1965년 당시, 나는 ALGOL W라는 객체 지향 언어에 쓰기 위해 포괄적인 타입 시스템을 설계하고 있었다. 내 원래 목표는 어떤 데이터를 읽든 항상 안전하도록 컴파일러가 자동으로 확인해 주는 것이었다. 그러나 나는 널 포인터를 집어넣으려는 유혹을 이길 수가 없었다. 그렇게 하는 게 훨씬 쉬웠기 때문이다. 이 결정은 셀 수도 없는 오류와 보안 버그, 시스템 다운을 낳았다. 지난 40년 동안 이러한 문제들 때문에 입은 고통과 손해는 10억 달러는 될 것이다. - 토니 호어

이 글을 자세히 보자. 토니 호어는 null pointer, 즉 null을 가르키는 것이 실수이지 null 그 자체의 개념적인 문제라고 한 것이 아니다. 소프트웨어는 어찌되었든 값이 없음을 나타낼 무언가가 필요하고, 실제로 null은 값이 없음을 나타내는 가장 명확하고 효율적인 방법이다. 따라서 null은 null의 의미를 살려서 같이 가져가야 할 존재이지, 두려움과 회피의 대상이 아니다.

결국 자바 진영에서 “null을 회피하는 지침”들이 나온다는 것은, 자바 타입시스템 설계에 결함이 있다고 볼 수 밖에 없다. 왜 자바 타입 시스템이 명백한 오류인지를 조금 더 구체적으로 증명해 보자.

Type이란?

타입 시스템을 다루기 전에 타입이 무엇인지를 명확히 알아야 한다. 그렇다면 객체지향 관점에서 타입은 무엇일까?

타입이란, 객체의 퍼블릭 메서드들, 즉 퍼블릭 인터페이스의 집합을 의미한다.

image 예를들어 A라는 객체에 날기, 먹기, 숨쉬기 라는 퍼블릭 메서드가 있다면, 이 것을 하나의 집합으로 보아 타입으로 분류할 수 있다. 이 세가지 메서드 집합을 Bird라는 타입으로 분류해보자. B와 C역시 동일한 퍼블릭 메서드 집합을 구현하고 있으므로 Bird 타입이라고 볼 수 있게 된다. 여기서 중요한 점은, 객체가 어떤 상태를 가지고 있는지(어떤 지역변수를 가지고 있는지)는 관심 대상이 아니다. 오로지 객체의 퍼블릭 인터페이스를 보고 타입을 정한다. 즉 객체지향 관점에서 Type이란, 객체의 퍼블릭 인터페이스의 집합이다.

실생활에서의 Type

아주 간단한 실생활의 예를 들어보자. 우리는 가끔 이런 말을 하곤 한다.

너 되게 짜증나는 타입이구나??

이 말에는 엄청나게 객체지향적인 관점이 담겨있다. “너의 속사정은 모르겠지만, 너는 굉장히 사람을 짜증나게 하는 public 메서드를 가지고 있구나”라고 판단하여 짜증 유발자 타입으로 분류하곤 한다. 이렇듯 객체지향에서의 타입과 실생활에서 말하는 type은 거의 동일하다. 어려운 개념이 아니다.

타입이 무엇인지 알아보았으니 타입 시스템과 연관시켜보자.

Type System

image

여기 8개의 객체가 있다. 아마 이 객체들을 보고 본능적으로 “자동차”라는 타입으로 분류했을 것이다. 그 본능의 내막을 살펴보면 나름대로 객체지향적인 논리가 숨어있다.

우리가 흔히 “자동차” 타입에 기대하는 퍼블릭 인터페이스는 이렇다.

  1. 시동을 걸 수 있다.
  2. 전진, 후진, 좌회전, 우회전을 할 수 있다.
  3. 사람을 태울 수 있다.
  4. 땅 위를 달릴 수 있다.

위 객체들은 모두 이 퍼블릭 메서드를 잘 수행(수신)할 수 있기 때문에 “자동차”라는 타입으로 분류한 것이다.

그렇다면 상상을 해보자. 겉모습을 전혀 모르는 내용물이 빨간 박스 안에 숨겨져 있다. 이 객체는 시동을 걸 수 있는 인터페이스가 전혀 없다. 앞뒤로 움직일 수도 없고 좌회전 우회전도 못한다. 심지어 사람을 태우려고하면 완전히 먹통이 되어버린다. 우리는 이 빨간 박스 안의 내용물을 “자동차”타입으로 분류할 수 있을까? 아무도 그렇게 분류하지는 않을 것이다. “자동차”라면 마땅히 가져야 할 public interface를 아무것도 가지지 않기 때문이다.

이 내용을 그대로 코드 레벨에서 살펴보자.

Java의 타입시스템

String str = ...;

여기 자바의 String 타입 변수가 하나 있다. String에 대해서 우리가 기대하는 퍼블릭 인터페이스들이 여럿 있다. 예를 들어 substring,split 등이 있을 것이다. 그러나 분명히 String 타입이라고 해놓았으면서 String의 퍼블릭 인터페이스를 호출할 때 마다 터져버리는 경우가 있다. 바로 String 타입에 null을 할당했을 때이다.

String str = null;

null은 String이 제공하는 퍼블릭 interface를 모두 수행할 수 없다. 이러한 null을 String 타입으로 간주하는건 Int를 String에 넣는 것과 마찬가지로 너무나도 위험하고 말도 안되는 행위다. 즉, 이것은 null 자체에 문제가 있는게 아니라, String 타입에 뜬금없이 null을 허용하는 자바 타입 시스템의 문제라고 볼 수 밖에 없다.

null을 여기저기 어느 타입에도 끼워 넣을 수 있게 허용한 자바의 타입 시스템 때문에 많은 자바 개발자들이 null을 두려워 하게 된게 아닐까 싶다. 자바 개발자들은 잘못이 없다. 타입 시스템의 문제다.

또한 자바 개발자들이 상당히 많고 커뮤니티가 방대하다 보니 자연스레 null을 피하는 식의 프로그래밍이 표준이 되어버린 느낌이다(물론 자바에서는 피하는게 답일수도..).

자연스레 Java로 입문하는 뉴비들은 자신이 왜 null을 피하고 있는지도 모르고 그저 관습처럼 피하게 된다. 여기서 중요한 점은, null을 왜 피하는지도 모른채 관습처럼 null을 피해온 자바 개발자가 kotlin으로 넘어올 경우 여전히 java스러운 코드를 짤 가능성이 크다. 타입에 대한 고찰이 없으면 null을 바라보는 관점이 다를 수 있다는 사실조차 모르기 때문이다.

아무튼 자바의 타입 시스템은 그렇게 생겼기 때문에 어쩔 수 없는 현실이지만 많이 아쉬운 부분이다.

더 나은 타입 시스템

그렇다면 좀 더 나은 타입 시스템은 어떻게 생겼을까. 현재로써는 코틀린과 스위프트처럼 널을 허용하는 타입과 그렇지 않은 타입으로 나누는 것으로 보인다.

var str1: String = null // 컴파일 에러
var str2: String? = null // 컴파일 성공
str2.split(':')  // 컴파일 에러
str2?.split(':')  // 컴파일 성공

첫 줄 str은 순수한 String 타입이다. null일 수 없는 순수한 String이다. 따라서 null을 허용하면 런타임 에러가 아니라 컴파일 에러가 나야 더 안전한 타입 시스템이다.

str2는 null일수도 있는 String타입이므로 null을 할당해도 에러가 나지 않는다. str2은 null일 수도 있기 때문에, 사용하는 측에서는 null check가 강제되어야만 한다.

3번째 라인에서 보듯 null check를 하지 않으면 당연히 컴파일 에러가 나야 안전한 타입 시스템이다.

더 나은 API

좀 더 세세하게 들어가서 API수준을 들여다보자. 좀 더 명확하고 안전한 API는 어떻게 생겼을까.

코틀린 Collection API에 있는 List의 확장 함수, getOrNull(), firstOrNull()같은 함수가 좋은 함수라고 생각한다.

public fun <T> List<T>.getOrNull(index: Int): T?

getOrNull 함수는 인덱스에 해당하는 T객체가 있으면 T를 반환하고, 없으면 null을 반환한다. 값이 없기 때문에 값이 없음을 나타내는 가장 명확하고 효율적인 표현인 null을 반환하는게 상식적이다.

또한 T?를 반환하기 때문에 사용하는 측에서는 null check가 강제된다.

자바스러운 API

더 나은 API와 반대로, 불명확하고 불안전한 API를 살펴보자. 대표적으로 자바 ArrayList의 indexOf함수를 예로 들 수 있겠다.

public int indexOf(Object o)

indexOf함수는 리스트에서 o객체와 동일한 객체가 있다면 그 인덱스를 추출한다. 그러나 만족하는 인덱스가 없다면?

다들 알고 있겠지만 -1을 반환한다. 그러나 사실 만족하는 인덱스가 없을 경우 null을 반환하는지, -1을 반환하는지, -100을 반환하는지, 에러를 내뿜는지는 이 함수를 까보지 않고는 모르는 일이다. 누군가는 값이 없을 때 당연히 null을 반환하리라고 확신하며 코드를 짤 수도 있으나 큰 코 다칠 것이다.

타입 시스템이 null을 회피하게끔 설계되어있다면 이렇게 모호한 api가 만들어 질 수 밖에 없다. 값이 없다면 null을 반환하는게 가장 상식적인 방법이다.

마무리

코틀린이 nullable 타입을 도입한건 단순히 ?. 나 ?: 연산자를 통해 자바보다 null을 쉽게 피하기 위해서가 아니다. 오히려 값이 없을 때 null을 적극 사용하여 명확하고 안전하게 코딩하기 위해 도입되었다. 이러한 사실을 알고 코딩을 하는 것과 모르고 코딩을 하는 것은 코틀린 스러운 코드를 짤 수 있냐 없냐의 문제로 이어지기 때문에 중요한 점이라고 생각한다.

또한 자바의 타입시스템은 익히 알려진대로 문제가 있고, 쉽게 바뀔 것 처럼 보이지 않는다. 최신 자바는 나름의 발전을 해오고 있지만 타입 시스템은 바뀌지 않고 있다. 하위호환이라는 철학 때문에 쉽게 바뀌지 않을 것 같아 보인다. 그렇다면 더 안전하고, 명확한 소프트웨어를 위해 kotlin을 도입해보는건 어떨까?

Jetpack ViewModel 내부 동작 원리

|

Jetpack ViewModel을 사용하는 이유

화면 회전과 같은 configuration change가 발생하면 activity instance는 죽고, 다시 새로운 activity instance가 생성된다. instance의 hashcode 값 조차 다르다.

구글이 제공하는 ViewModelProvider를 사용하여 viewModel을 만들고 액티비티에 두면, 액티비티가 재생성 되어도 viewModel은 재생성되지 않고 유지된다.

뷰모델 생성 방법

// In Activity

viewModel = ViewModelProvider(this, MyViewModelFactory()).get(CounterViewModel::class.java)

보통 ViewModelProvider에서 get함수를 사용해 viewModel객체를 얻어온다.

ViewModelProvider 생성자

ViewModelProvider 생성자

ViewModelStoreOwner만 받는 생성자와, ViewModelStoreOwnerfactory 두 개를 받는 생성자가 존재한다. 뷰모델에 파라미터가 없다면 factory를 넘기지 않는데, 이럴땐 ViewModelProvider내부적으로 Default Factory를 사용한다.

ViewModelStoreOwner는 getViewModelStore()메서드 하나만 가진 인터페이스이다.

image

우리는 주로 뷰모델 프로바이더 생성자의 첫번째 인자로 activity를 넣어준다. ViewModelStoreOwner타입으로 Activity를 넣어준다는 의미인데, 이때는 꼭 ComponentActivity의 child 클래스로 구현된 Activity를 넣어주어야 한다. ComponentActivityViewModelStoreOwner를 구현하고 있기 때문이며 대표적으로 FragmentActivity와, 그의 하위 클래스인 AppCompatActivity가 있다.

즉, 우리의 Activity에는 이미 ViewModelStore를 제공해주는 책임이 구현되어 있었다고 볼 수 있다.

ViewModelProvider 생성자는 파라미터로 받은 값들을 class member로 저장해놓는다.

image

뷰모델 객체 얻기

.get(MyClass::class.java)를 호출하면 아래의 함수가 호출된다.

image

클래스의 canonical(정식) name을 가져다가, static 하게 선언된 DEFAULT_KEY 뒤에 붙여서 오버로딩된 get을 호출한다. 오버로딩된 get을 보자.

image

이전 get함수에서 만들었던 key와 class를 넘겨받는다. Activity가 만들어놓은 ViewModelStore를 이용하여 key에 해당하는 viewModel이 이미 존재하는지, 혹은 존재하지 않는지를 확인하는 로직이 들어있다. viewModel이 존재한다면 그 viewModel을 반환하지만, 그렇지 않을 경우 factory 객체의 도움을 받아 새로운 viewModel을 생성한다. 새로 생성한 viewModelviewModelStore에 저장해놓고 viewModel을 반환한다.

사실 별다른 로직은 없어 보인다. 여기까지만 보았을 때는 알 수 있는 점은, 같은 타입의 뷰 모델을 여러번 생성하려 할 경우, 중간 과정에서 만들어지는 tag가 같기 때문에 하나의 뷰 모델 객체만 생성된다는 점 정도겠다.

아직은 어떻게 activity의 멤버 변수로 가지고 있는 viewModel이 Activity 재생성시 유지될 수 있는지를 알 수 없다. viewModelStore에 비밀이 숨겨져 있을까? 좀 더 파악해보자.

ViewModelStore

image

ViewModelStore는 위의 코드가 전부이다. 비밀 로직은 없다. 단지 HashMap을 사용하여 ViewModel을 관리할 뿐이다.

그렇다면 우리가 아직 구현을 파헤치지 못한 부분이 딱 한군데 남아있게 된다. 바로 ViewModelStoreOwner 인터페이스의 getViewModelStore() 메서드다. ViewModelProvider의 생성자를 다시 보자.

image

우리는 아직 ViewModelStoreOwner로 넘긴 Activity의 getViewModelStore()의 구현을 보지 못했다. 단순하게 ViewModelStore를 가져올 거라고 가볍게 생각했는데 살펴봐야 할 때가 왔다. getViewModelStore를 구현한 ComponentActivity.java 파일을 열어 확인해보자.

ComponentActivity::getViewModelStore

image

NonConfigurationInstances 타입의 nc라는 객체가 보인다. 액티비티가 configuration change로 인해 재생성 되어도, nc객체는 소멸된 액티비티의 몇몇 멤버 변수 객체들을 가지고있다. 중간에 보이는 주석 “Restore the ViewModelStore from NonConfigurationInstances”를 보니 상태 변화의 타겟이 아닌 객체를 복구해주는 것 같다. 추가적으로, 액티비티가 재생성될 때 viewModel이 유지되는것으로 보아 액티비티가 맨 처음 생성될 때만 ncnull이고, 그 이후부터는 null이 아니다.

결국 getViewModelStore도 평범했다. 어디까지 더 깊이 들어가야 할지 모르겠지만 끝이 보이는 느낌이 온다😭.

NonConfigurationInstances

NonConfigurationInstances를 알아보기 전에 먼저 getLastNonConfigurationInstance() 함수를 타고 들어가보았더니, 이건 또 ComponentActivity의 메서드가 아니라 더 상위 클래스인 Activity 클래스의 메서드다.

// Activity.java image

mLastNonConfigurationInstances를 타고 들어가니 static final class NonConfigurationInstances 클래스가 있다.

그렇다면 액티비티 객체가 재생성 되고 나서는 mLastNonConfigurationInstancesnull이 아니라는 말일테고, 그말은 즉 onDestroy될 때쯤 mLastNonConfigurationInstances를 세팅해주는 곳이 어딘가에는 있어야 한다는 의미이다.

구글링을 해보니 역시나 onRetainNonConfigurationInstance() 라는 메서드가 있다. 이 메서드는 ComponentActivity에 구현되어 있으며, configuration change로 인해 Activity가 재생성될 때마다(정확히는 onStop과 onDestroy() 사이에) 시스템에 의해 호출된다고 한다.

// ComponentActivity.java image

이 함수의 반환값은 NonConfigurationInstances타입의 객체다. 공식문서에는 아래와 같이 나와있다.

The object you return here will always be available from the getLastNonConfigurationInstance() method of the following activity instance as described there.

즉 여기서 리턴하는 nci라는 값은 시스템에 의해 유지된다는 의미이다. nci값은 getLastNonConfigurationInstance() 함수를 통해 사용할 수 있다.

마무리

ViewModel이 유지되는 원리는 알아보았다. 핵심은 activity가 재생성될 때 시스템이 call 해주는 onRetainNonConfigurationInstance() 함수라고 볼 수 있겠다. onRetainNonConfigurationInstance() 함수의 리턴값이 어떻게 getLastNonConfigurationInstance() 메서드에서 사용이 가능한지는 공식 문서에도 나와있지 않을 뿐더러 시스템 내부의 일이라 알아보는 의미가 크지 않을것 같다.

참고자료

HackerRank - Organizing Containers of Balls

|

Organizing Containers of Balls

Organizing Containers of Balls 링크

  • 초기에 각 컨테이너에 들어있는 공의 갯수는 절대 바뀔수 없다(서로 맞바꾸는 행위밖에 없으므로)
  • 특정 컨테이너에는 특정 타입의 공으로만 채워져야 한다. 즉 특정 컨테이너는 애초에 특정 타입의 공만 수용할 수 있도록, 특정 타입의 공 갯수 만큼 공을 가지고 있어야만 한다.

초록색 공의 갯수를 10개라고 가정하자. 그렇다면 이미 (어떤 색깔들일지는 몰라도) 공 10개를 담고있는 컨테이너가 존재해야만 한다. 초기에 정해진 컨테이너 안의 공 갯수는 절대 바뀔수가 없기 때문에 꼭 10개를 담고 있는 컨테이너가 있어야만 한다(초기에 담긴 공 갯수는 절대 바뀔수 없다는 점을 주의하자).

마찬가지로 빨간색 공의 갯수가 12개라면, 12개를 미리 담고 있는 컨테이너가 꼭 필요하다. 초기에 11개 혹은 13개를 담고 있는 컨테이너에는 절대 빨간색을 넣을 수 없다.

다른 색의 공이 더 있다고 해도 마찬가지 로직이 반복된다.

결국 (특정 컨테이너가 가진 공의 합 == 특정 타입 공의 합)쌍이 모두 매칭되어야 하며, 하나라도 매칭되지 않으면 불가능하다.

fun organizingContainers(container: Array<Array<Int>>): String {
    val ballsCountOfEachContainer = mutableListOf<Int>()
    val ballsCountOfEachColor = mutableListOf<Int>()
    for (x in container.indices) {
        ballsCountOfEachContainer.add(container[x].sum())
        var sum = 0
        for (y in container.indices) {
            sum += container[y][x]
        }
        ballsCountOfEachColor.add(sum)
    }
    ballsCountOfEachContainer.sort()
    ballsCountOfEachColor.sort()

    ballsCountOfEachContainer.forEachIndexed { index, countOfCotainer ->
        if (countOfCotainer != ballsCountOfEachColor[index]) {
            return "Impossible"
        }
    }
    return "Possible"
}