본문 바로가기

Android/Jetpack Compose

[Android] Compose 환경에서 데이터 init 방식에 대한 고찰.

728x90

최근 안드로이드 뉴스레터, 그리고 개발자 카톡방 등에서 초기 데이터 init 방식에 대한 글을 종종 봤다.

필자는 샘플에는 init 되는 타이밍이 한정적이고 예제이기 때문에 정말 가장 간단한 방식으로 설정하긴 했지만, 실무를 진행할 때는 2가지 방법을 나눠서 사용한 경험이 있다.

 

하지만, 위의 내용을 보고 확인을 하다 보니 안드로이드 쪽에서 유명한 Skydoves님의 글을 보고 새로운 init 방식에 대해 알게 되었다.

본 글에는 기존에 사용하던 방식과 더불어, 새롭게 Skydoves님의 방식을 작성해 보면서 그 장단점을 생각해보고자 한다.


우선,

필자가 사용했던 2가지 방식에 대해 설명하고자 한다.

 

첫 번째 방식은 LaunchedEffect를 사용하여 데이터를 초기화 하는 방식이다.

LaunchedEffect(key1 = Unit) {
    initTestViewModel.changeLaunchedEffectLoading()
}

//

LaunchedEffect(key1 = true) {
    initTestViewModel.changeLaunchedEffectLoading()
}

 

key 값 자체를 Unit이나 true로 설정해서 해당 컴포저블 함수가 구성될 때 최초 한 번만 블럭을 실행시킬 수 있는 구조이다.

 

필자가 예제를 사용할 때 주로 이 방식을 사용했다.

그 이유는 다음과 같다.

  1. UI단에 선언되는 함수이기 때문에, 명시적으로 어떤 타이밍에 초기화 되는지 알 수 있다.
  2. 어떤 데이터가 초기화 되는지 한눈에 볼 수 있다.
  3. Init 하는 시점을 개발자가 완전히 컨트롤할 수 있다.
  4. UI단에서 Init을 진행하기 때문에, Test 함수 작성 시 별도의 데이터 설정을 하지 않아도 된다.

크게 이와 같은 이유로 LaunchedEffect를 사용하여 초기화를 작성하였다.

 

간단한 샘플 예제들이기 때문에 viewModel 자체를 사용하지 않는 케이스가 상당히 많았고,

viewModel을 만들어서 작업해도 되지만, 간단한 샘플이기 때문에 오히려 한눈에 보이도록 작성하는 것이 가독성과 코드의 이해도 측면에서 더 좋다고 판단하였다.

 

물론, 이 방식에는 치명적인 단점이 하나 존재한다.

  1. LaunchedEffect는 컴포저블 함수가 "구성"될 때 최초 한번 호출 된다.
  2. Configuration이 변경되었을 때, 컴포저블 함수는 "재 구성"된다.
  3. 따라서, 화면 회전과 같은 Configuration이 변경되는 경우 LaunchedEffect함수를 "재호출" 하게 된다.

필자처럼 간단한 init 데이터 설정과 같은 경우에는 상관없겠지만, 실무에서 해당 방식을 사용하는 경우 상당히 치명적인 결과를 초래할 수 있다.

 

init함수를 통해서 보통 UI를 그리기 위한 API를 호출하고, 비즈니스 로직을 태워 연산하는 작업이 들어가게 된다.

이러한 일련의 과정들은 생각보다 무거운 작업일 수 있을 뿐 아니라 불필요한 네트워크 통신 및 데이터 세팅 작업을 수행하기 때문에 상당히 비효율적이거나 잘못된 데이터를 세팅할 수 있는 원인을 제공하곤 한다.

 

위의 내용을 확인하기 위해, 간단하게 viewModel을 만들고 다음과 같이 선언해 둔다.

private val _testLoadingCount = MutableStateFlow(0)
val testLoadingCount = _testLoadingCount.asStateFlow()

fun changeLaunchedEffectLoading() {
    Log.d("TAG", "LaunchedEffect Loading")
    _testLoadingCount.value++

    viewModelScope.launch {
        Log.d("TAG", "LaunchedEffect loadingCount : ${testLoadingCount.value}")

        _isLaunchedEffectLoading.value = true
        delay(3000L)
        _isLaunchedEffectLoading.value = false
    }
}

 

그리고, 화면을 돌려 로그를 확인하면 다음과 같이 나오게 된다.

 

LaunmchedEffect에서 viewModel의 changeLaunchedEffectLoading() 함수를 호출하기 때문에, 화면 화전을 하였을 때 이와 같이 2번 호출되어 testLoadingCount 값이 2가 되게 된다.

 

이처럼 init 함수 내에서 기존의 데이터에 연관 지어서 연산하는 과정이 있다면 의도치 않은 결과를 보일 수 있게 된다.

 

하지만, 이러한 단점을 가지고 있더라도 이러한 문제점을 가지고 있다는 것을 인지하고 사용한다면 상황에 따라 충분히 효율적인 선택이 될 수 있다.

 

다음으로는,

viewModel의 init 블럭을 사용하는 방식이다.

 

위의 예시를 보여주기 위해 간단히 만들었던 viewModel을 그대로 사용하도록 하자.

class InitTestViewModel : ViewModel() {
    ...
    
    init {
        _testLoadingCount.value = 0
        changeViewModelInitLoading()
    }

    ...
}

 

viewModel의 init 블럭을 사용하여 초기화 로직을 넣게 되면, viewModel이 생성될 때 최초 한 번만 해당 블럭이 실행되는 것을 보장할 수 있다.

 

즉, LaunchedEffect를 사용했을 때의 문제점

Configuration이 변경되어 컴포저블 함수가 재구성되었을 때 해당 블럭이 재호출 되어 중복된 초기화가 발생된다.

라는 이슈를 원천적으로 차단할 수 있다.

 

Configuration이 변경되어도 viewModel은 그대로 유지된다는 것은 AAC ViewModel에 대해 조금이라도 알고 있는 사람이라면 당연한 결과이기 때문에 그 이유에 대한 자세한 설명은 생략하도록 한다.

 

이와 같이 설정했을 때의 장점은, LaunchedEffect와 크게 다를 바가 없다.

viewModel의 상단에 init 블럭을 위치시킴으로써, 어떤 데이터가 viewModel 생성 시 초기화되는지 한눈으로 볼 수 있고,

하나의 블럭에서 초기화를 모두 관리하기 때문에 유지보수가 편리하다.

무엇보다 이렇게 사용하는 가장 큰 이유는, 앞서 말했던 LaunchedEffect로 사용했을 때의 문제점을 해결해 준다.라는 것이기 때문에 이것에 집중하여 생각하면 될 것 같다.

 

그렇다면 이렇게 선언하는 것에 대한 단점은 없는가?

 

물론, 단점은 존재한다.

 

최초 viewModel이 생성되는 시점에만 init 블럭이 호출되기 때문에, 테스트할 때 해당 블럭을 마음대로 사용하지 못한다.

즉, init 블럭에 선언되어 있는 일련의 항목들을 별개의 함수로 빼내어 사용하지 않는 한, 재호출 하거나 할 수 없어 테스트가 매우 어려운 시나리오가 만들어질 수 있다.라는 것이다.

 

필자는 부끄럽게도, TDD 방식의 개발론을 많이 사용해보지 않았기 때문에 테스트할 때 어떤 점이 불편한지에 대해 명확하게 알고 있지 않다.

따라서 위의 내용들은, 필자가 직접 경험한 것이  공부를 하면서 찾아본 결과 나온 단점들이라고 볼 수 있다.

 

그래서 이러한 것들을 직접 확인해 보기 위해서 테스트 코드를 한번 작성해 보았다.

@RunWith(AndroidJUnit4::class)
class InitTestExampleUITest {

    @get:Rule
    val composeTestRule = createComposeRule()

    private lateinit var viewModel: InitTestViewModel

    // @Test가 실행되기 전에 반드시 실행된다
    @Before
    fun setUp() {
        // ViewModel 초기화
        viewModel = InitTestViewModel()
    }
    ...
}

 

우선 이와 같이 기본적인 세팅을 해준다.

viewModel을 사용하는 경우 @Before 어노테이션이 붙어있는 함수에서 viewModel을 생성하는 작업을 반드시 해줘야 한다.

이렇게 viewModel이 생성되는 @Before 어노테이션이 붙어있는 함수에서 init이 호출되고 난 후, 더 이상 InitTestViewModel의 init 블럭은 실행될 수 없다.

 

기본적인 세팅을 했으니, 테스트 코드를 한번 작성해 보도록 하겠다.

@Test
fun verify_loading_count_remains_consistent_on_new_trigger() = runTest {
    // Given
    viewModel.changeLaunchedEffectLoading()

    // When
    composeTestRule.setContent {
        InitTestExampleUI(onBackButtonClick = {})
    }
    viewModel.isInitLoading.first { it }

    // Then
    assert(viewModel.testLoadingCount.value == 3) { "TestLoadingCount should be 3" }
}

 

간단하게, UI를 세팅하고 Init로딩 결과를 기다린 다음, testLoadingCount 값이 3인지 아닌지 체크하도록 하였다.

여기서 3인 이유는, viewModel에서 여러 가지 확인을 위한 코드를 통해 나온 결과이기 때문에 전체 코드 확인이 필요하다면, 해당 repository를 확인하길 바란다.

 

이와 같이 선언하고 일단 실행을 시켜보기 전, viewModel의 init 블럭이 제대로 실행되는지 확인하기 위해

init {
    Log.d("TAG", "Init ViewModel")
    _testLoadingCount.value = 0
    changeViewModelInitLoading()
}

 

다음과 같이 로그를 추가하도록 한다.

 

그리고 실행시켜보면, 정상적으로 테스트가 통과하는 것을 볼 수 있다.

 

그리고 함수 호출 순서를 확인하기 위해 추가했던 로그를 체크해 본다.

 

 

이와 같이 정상적인 순서로 호출되는 것을 확인할 수 있다.

 

그런데, 이제 어떤 케이스에서 viewModel의 init 부분을 재 호출해야 하는가?

이 부분에 대해서 여러 가지 케이스로 확인해 보고, 코드를 작성하고, 테스트 코드의 안티 패턴의 경우도 작성해 봤는데도 확인할 수 없었다. 그리고 곰곰이 생각해 보다 명확하게 설명하기 위해서는 현재 작성된 예제로는 불가능하다는 것을 깨달았다.

(다양하게 작성된 테스트 코드는 해당 프로젝트 내에 첨부되어 있으니 확인해도 괜찮을 것이다.)

 

지금 필자와 같은 경우, 간단하게 데이터를 초기화하고 증가시키는 부분만 들어가 있는데, 위에 작성한 것처럼 해당 init 블럭에서는 API 통신을 통해 데이터를 가져오고, 초기화시키는 로직이 들어가는 것이 보통이다.

 

일련의 과정에서, init 블럭을 호출하기 때문에 발생할 수 있는 문제는 다음과 같다.

  1. 테스트 함수가 실행되기 전 init 되기 때문에, viewModel이 init 되기 전 테스트 케이스의 상태를 확인할 수 없다.
  2. init 함수에서 다양한 api 호출 및 repository 등 매개 변수를 사용하게 된다면, 각각의 Mock 데이터를 주입해 주어야 하는데, 개수가 늘어날수록 테스트 코드 내부에서 가 데이터 주입이 어려워진다.
  3. init 함수를 호출하고, 이후에 테스트 함수를 호출하는데 이는 동기적으로 init이 끝난 후 처리되는 것이 아니다. 즉, 비동기 처리의 경우 문제가 발생할 수 있다.

위의 문제들은 init 블럭을 테스트 환경에서 마음대로 컨트롤할 수 없기 때문에 발생하는 문제들이라고 생각하면 된다.

 

테스트 코드에서는 환경이나 케이스에 따른 다양한 코드를 작성하여 테스트 커버리지를 높여야 하는 반면, init 블럭이 실제 구현부로 고정되어있다 보니, 그곳에서 오는 한계점이 명확해진다는 것이다.

 

그렇다면,

마지막으로 Skydoves님이 작성한 글을 토대로 초기화하는 방법에 대해 알아보도록 하겠다.

 

우선 코드부터 확인해 보자.

private val _isInitLoading = MutableStateFlow(false)
val isInitLoading = _isInitLoading
    .onStart { changeInitLoading() }
    .stateIn (
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000L),
        initialValue = false
    )

 

viewModel에서 이와 같이 선언하고,

val initLoadingFlag = initTestViewModel.isInitLoading.collectAsStateWithLifecycle()

 

UI단에서 이처럼 구독하여 사용하면 된다.

 

viewModel에 선언한 코드부터 한 줄 한줄 해석해 보도록 하자.

 

onStart 연산자는 모든 flow 타입에서 사용 가능한 연산자로, flow가 수집되기 시작할 때 실행할 동작을 작성할 수 있다.

즉, 해당 flow의 첫 번째 요소가 방출되기 전에 onStart 연산자를 통해 사전 작업을 할 수 있게 되는 것이다.

 

정리하면 다음과 같다.

_isInitLoading 자체는 MutableStateFlow가 되는데, onStart { ... } 라는 연산자를 추가하게 되면 이는 Flow로 변환이 되게 된다.

onStart라는 연산자를 통해 MutableStateFlow를 Flow로 변경하고, 해당 데이터가 방출되기 전 반드시 changeInitLoading() 함수를 호출한다.

 

다음에 있는

.stateIn 연산자는 flow 상태를 stateFlow로 반환해 주는 연산자이다.

stateIn 연산자를 눌러보면 다음과 같은 설명이 나온다.

Converts a cold Flow into a hot StateFlow that is started in the given coroutine scope, sharing the most recently emitted value from a single running instance of the upstream flow with multiple downstream subscribers.

 

간단하게 말하면, cold Stream인 Flow를 hot Stream인 StateFlow로 변환시켜 준다는 내용이다.

데이터를 기반으로 UI를 컨트롤하기 위해서는 stateFlow로 변환하여 사용하게 되는데 이를 활용한 것이라고 생각하면 된다.

 

stateIn의 구현 방식은 다음과 같이 나와있다.

public fun <T> Flow<T>.stateIn(
    scope: CoroutineScope,
    started: SharingStarted,
    initialValue: T
): StateFlow<T> {
    val config = configureSharing(1)
    val state = MutableStateFlow(initialValue)
    val job = scope.launchSharing(config.context, config.upstream, state, started, initialValue)
    return ReadonlyStateFlow(state, job)
}

 

scope와 initialValue는 뭔지 알겠는데, SharingStarted라는 객체는 처음 본다. 

해당 객체를 클릭해서 확인하면 다음과 같은 설명을 볼 수 있다.

A strategy for starting and stopping the sharing coroutine in shareIn and stateIn operators.

 

StateIn 연산자를 사용할 때 공유 코루틴을 시작하고 중단하기 위한 전략이라고 한다.

SharingStarted라는 것 자체가 인터페이스기 때문에, 해당 인터페이스의 구현체를 넣어주면 되는 것이다.

 

SharingStarted 인터페이스에 대한 것을 찾아보면, 여러 가지의 전략이 존재하고, 그에 따라 구독자에 대한 관리를 다르게 처리할 수 있다. 이것에 대해서는 필요하면 찾아보길 바란다.

 

우선, 

SharingStarted를 제외하고 scope와 initialValue의 값을 설정한 부분에 대해서는 쉽게 알 수 있을 것이다.

해당 flow는 viewModelScope를 가지고 있으며, 기본 값은 false로 설정된다.

 

그렇다면 

started = SharingStarted.WhileSubscribed(5000L),

 

해당 내용은 무엇인가?

 

WhileSubscribed라는 옵션은, 구독자가 있는 동안에만 활성화되고 마지막 구독자가 사라지면 중단된다는 것이다.

즉, 옵션 이름대로 구독자가 있는 동안에만 활성화된다는 의미가 된다.

매개 변수로 들어간 5000L는, 5초의 딜레이로 구독자가 사라진 후 5초 동안은 데이터를 유지하고 있겠다.라는 뜻이다.

 

이게 무슨 말인가?

stateFlow는 hot Stream으로 구독자가 없어도 데이터를 발행해야 하지만, WhileSubscribed 옵션을 통해 구독자가 있을 때만 Flow가 활성화된다.

따라서, 구독자가 있을 때 데이터를 생성해서 가지고 있으며, 모든 구독자가 사라진 후 5초까지는 현재 데이터를 유지하고 있다가 5초가 넘어서게 되면 데이터를 제거하게 된다.

 

이제 해당 flow를 다시 확인해 보도록 하자.

mutableStateFlow인 _isInitLoading를 onStart를 사용해 flow로 변환하고, 해당 블럭에 있는 함수를 호출 한 다음에 데이터를 발행한다.

onStart는 flow이기 때문에 데이터를 구독해야지만 호출되기 때문에, 여기까지만 작성하더라도 isInitLoading 변수는 cold Stream으로 동작하게 된다.

stateIn을 통해 flow를 StateFlow로 변경하는데, 라이프사이클은 viewModelScope로 지정하고, 기본 값은 false로 지정한다.

그리고 SharingStarted 인터페이스를 통한 구독 관련 설정으로는 구독자가 사라진 후 5초까지만 데이터를 유지시킨다.

 

이와 같은 흐름으로 isInitLoading을 구독했을 때 함수가 실행되게 된다.

 

자,

그럼 이걸 어떻게 init 하는 데 사용하는가? 싶을 수 있다.

하지만 위의 설명을 보면 init 하는데 충분히 사용할 수 있게 된다.

 

stateFlow지만

구독을 해야지만 데이터를 발행하고,

첫 데이터 발행 전에 반드시 실행되는 함수를 지정할 수 있으며,

구독이 해제되지 않으면 해당 함수는 다시 호출되지 않는다.

 

즉,

isInitLoading을 필요할 때 한번 구독하게 되면 init 함수가 호출될 것이고,

구독이 해제되지 않는 한 init 함수는 재호출 되지 않는다.

또한, 해당 변수는 viewModelScope를 갖고 있기 때문에 Configuration이 변경되더라도 재 호출되지 않으며,

데이터가 초기화되는 순간을 개발자가 정하여 관리할 수 있다.

 

이렇게 되면 LaunchedEffect의 장점과 viewModel의 init 블럭의 장점을 모두 가져올 수 있을뿐더러,

단점 또한 없이 사용할 수 있게 된다.

 

테스트 코드에서도 발생할 수 있는 문제를 해결할 수 있는데,

1번 init 이전의 데이터는 별도로 구독을 하지 않거나 구독을 해제한 후에 확인하면 되는 것이며 

3번 init 함수 내부의 동기적인 처리는, onStart 내부에서 await을 통해 데이터 수신을 기다린 후, isInitLoading 값을 갱신시키는 로직을 추가하게 되면 모든 비동기 통신이 끝난 후에 로직을 처리할 수 있게 된다.

 

하지만 역시 테스트 코드에서 2번 항목,

Mocking을 하는 것에는 viewModel의 init을 사용하는 것과 동일한 문제가 발생하지 않을까 싶다.

 

위의 코드에서 지금까지 언급하지 않은 내용이 2가지 존재한다.

첫 번째로는, 5000L을 사용한 이유,

두 번째로는 collectAsStateWithLifecycle을 사용한 이유에 대해서이다.

 

구독자가 없어진 후 5초의 딜레이를 주는 이유는, ANR가 트리거 되는 타임아웃 시간과 연관이 있다.

해당 구글 개발자 문서를 확인하면 알 수 있듯이, 5초의 시간 동안 입력 이벤트에 응답이 없으면 ANR 오류를 발생하며 크래시가 발생하게 된다.

즉, 어떠한 작업을 하던 5초 이내에 동작을 해야 하기 때문에 구독자가 해제된 후 5초의 시간이 지난 후에 데이터를 없애더라도 문제가 발생하지 않게 된다는 의미이다.

 

collectAsStateWithLifecycle를 사용한 이유는, 앱이 백그라운드로 넘어갔을 때를 생각하면 쉽게 이해할 수 있다.

collectAsStateWithLifecycle를 사용하게 되면 앱이 foreground에 있을 때. 즉, 앱의 라이프 사이클에서 onStart가 된 이후 실행 중인 상태일 때만 구독을 하겠다는 것이 된다.

앱이 백그라운드로 넘어가면 onPause로 들어가면서 구독이 해제되게 되는데, 백그라운드에서 계속해서 구독을 하며 데이터를 갱신하는 것 자체가 메모리 낭비가 될 수 있고, 비효율적으로 동작하기 때문에 이를 방지하기 위해서 collectAsStateWithLifecycle를 사용하는 것이다.

무엇보다, SharingStarted.WhileSubscribed를 통해 구독하지 않았을 경우에는 데이터를 날려야 하는데, collectAsState를 사용하게 되면 백그라운드에 있을 때도 계속해서 구독을 하고 있기 때문에 원하는 것과 다른 동작을 할 수도 있게 된다.

 

하지만, 이것을 사용하는 것이 단점이 될 수 있는데,

백그라운드에 6초 정도 놔두고 다시 포그라운드로 넘기는 경우, SharingStarted.WhileSubscribed 조건으로 인해 해당 데이터가 제거되고, 다시 구독하는 flow를 호출하여 init 되게 된다.

6초라는 시간 자체가 상당히 짧기 때문에, 메신저를 한다던지, 잠깐 어떠한 작업을 하고 다시 돌아왔을 때 마다 init 함수가 호출되어 초기화가 발생하게 된다.

 

이것은 단점이라고 볼 수 있지만, 반대로 이점이 될 수 있다.

백그라운드에 오랜 시간 방치되어 있다가 다시 앱을 실행시켰을 때, 그 사이에 많은 데이터들이 변경되는 경우가 발생할 수 있다.

이때, 데이터를 다시 불러와야 최신 데이터를 확인할 수 있을 뿐 아니라 다양한 사이드 이펙트도 방지할 수 있는데, 해당 방식을 사용하게 되면 현재 가지고 있는 데이터는 제거되고 새롭게 init 되기 때문에 항상 최신의 데이터를 유지할 수 있게 된다.

 

따라서,

짧은 시간 동안 많이 움직여서 발생하는 단점 보다, 오랜 시간 지난 후 돌아왔을 때 다시 초기화를 하면서 얻을 수 있는 이점이 더 크다고 생각된다. 앱을 사용하면서, 백그라운드에 넣었다가, 잠시 후 다시 열고 작업의 반복을 하는 경우는 생각보다 흔치 않기도 하고 말이다.


이것으로 데이터를 초기화하는 3가지 방법에 대해 디테일하게 알아보았다.

 

이 3가지 방법 모두 장단점이 존재하고, 결국 trade-off 관계에 놓여 있는 방법이라고 생각한다.

정답은 없기 때문에, 현재 구현하고자 하는 방식에서 어떤 부분이 중요한지 생각하고 여러 가지 케이스를 생각해 봤을 때 가장 적절한 방법을 선택해야 한다.

 

요구사항에 따라서, 적절한 상황에 맞춰 가장 효율적인 개발을 하기 위해서는 많이 알아야 하고, 깊게 알아야 한다는 사실을 다시 한번 느낄 수 있었다.

 

필자는 이번 글을 작성하기 전에는 그냥 어렴풋이 이런 문제가 있는 것으로 알고 있다.라고 생각하며 init 되는 방식을 정하고 사용하였는데 이번 기회를 통해 명확하게 장단점에 대해 생각해 볼 수 있는 시간이 되었다.

 

또한,

Skydoves님이 생각하신 방법에 대해서는 정말 놀랐고, 

역시 근본적으로 파고 들어가서 알고 있어야 새로운 효율적인 방법이 떠오르는구나.라고 느끼게 되었다.

 

필자가 아직 테스트 코드에 대해서는 많이 작성해보지 않았다 보니,

이 부분에서 느낄 수 있는 장단점에 대해서는 피부로 직접 느끼지 못해 아쉽다는 생각이 든다.

 

해당 게시글에 사용한 예제는 Github에 올려두었다.

https://github.com/HeeGyeong/ComposeSample

 

GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.

This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample

github.com

728x90