|

repeatOnLifecycle vs launchWhenStarted: 안드로이드 Flow 수집에서 왜 repeatOnLifecycle이 더 안전할까

repeatOnLifecycle과 launchWhenStarted 차이를 설명하는 안드로이드 대표 이미지
UI Flow 수집은 deprecated 여부보다 lifecycle 경계를 어떻게 끊고 다시 붙이느냐가 핵심이다

repeatOnLifecycle vs launchWhenStarted는 안드로이드에서 Flow를 UI에 붙일 때 한 번쯤 반드시 부딪히는 주제입니다. 결론부터 말하면 지금은 launchWhenStarted보다 repeatOnLifecycle을 기본값으로 잡는 편이 더 안전합니다. 핵심은 deprecated 딱지 자체보다, 화면이 보이지 않을 때 collect를 어떻게 멈추고 다시 보일 때 어떻게 재시작하느냐에 있습니다.

이번 글에서는 왜 repeatOnLifecycle이 더 안전한지, collector restart가 실제로 어떤 의미인지, lifecycle gap에서 어떤 찜찜함이 생기는지, 그리고 Fragment/View UI에서 어떤 코드 패턴을 쓰는 편이 좋은지 실무 기준으로 정리하겠습니다.


먼저 결론

  • 새 코드라면 launchWhenStarted보다 repeatOnLifecycle을 우선 선택하는 편이 안전하다
  • repeatOnLifecycle은 lifecycle이 STARTED 아래로 내려가면 내부 block을 취소하고, 다시 올라오면 block을 재시작한다
  • 그래서 UI 수집 코드는 다시 시작되어도 괜찮은 형태로 두는 것이 중요하다
  • Fragment에서는 fragment.lifecycleScope보다 viewLifecycleOwner.lifecycleScope 기준 패턴이 더 안전한 기본값이다

한 줄로 줄이면 보이지 않는 동안 애매하게 살아 있게 두기보다, 필요 없을 때는 끊고 다시 보일 때 명확하게 다시 붙이는 쪽이 UI에서는 더 예측 가능하다고 이해하면 됩니다.


launchWhenStarted의 한계

공식 AndroidX API reference 기준으로 launchWhenStarted, launchWhenResumed, launchWhenCreated는 모두 deprecated입니다. 문구도 꽤 직접적입니다. 일부 경우에 wasted resources를 만들 수 있으니, 대체로 suspending repeatOnLifecycle을 사용하라고 안내합니다.

여기서 중요한 것은 단순히 ‘deprecated라서 바꾸자’가 아닙니다. 실무에서는 지금 이 collect가 lifecycle 경계에서 정확히 어떻게 멈추고 다시 붙는가를 읽는 사람이 빠르게 이해할 수 있어야 합니다. launchWhenStarted는 그 설명력이 상대적으로 약합니다.


repeatOnLifecycle이 안전한 이유

repeatOnLifecycle의 공식 설명은 명확합니다. lifecycle이 지정 상태 아래로 내려가면 현재 coroutine block을 cancel하고, 다시 그 상태에 도달하면 block을 restart합니다. 즉, 수집의 생명주기가 화면의 가시 상태와 더 분명하게 맞물립니다.

  • 언제 collect가 살아 있는지 경계가 분명하다
  • 언제 collect가 완전히 끊기는지 예측하기 쉽다
  • 재진입 시 어떤 코드가 다시 실행되는지 코드만 보고도 설명하기 쉽다

UI는 보통 상태를 다시 그리면 되는 구조가 많습니다. 그래서 애매하게 잠든 채 남아 있는 모델보다, lifecycle이 내려가면 끊고 다시 올라오면 다시 그리는 모델이 더 관리하기 쉽습니다.


suspend vs cancel

이 차이는 coroutine 이론보다 버그 양상에서 더 크게 체감됩니다. suspend 쪽에 가까운 모델은 겉보기에는 멈춘 것처럼 보여도, 상위 작업이나 주변 맥락이 어떻게 남아 있는지 읽는 사람이 한 번 더 해석해야 합니다. 반대로 cancel + restart 모델은 내려갈 때 끊고 올라오면 다시 시작한다고 생각하면 되기 때문에, UI 코드를 설계할 때 훨씬 직관적입니다.

특히 화면 생명주기와 함께 움직이는 코드는 필요 없을 때 확실히 끊기고, 다시 보여질 때 다시 붙는 구조가 유지보수에 유리합니다. 이 점이 repeatOnLifecycle이 더 안전한 이유의 본체라고 보는 편이 맞습니다.


기본 패턴

class UserProfileFragment : Fragment(R.layout.fragment_user_profile) {

    private val viewModel: UserProfileViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    render(state)
                }
            }
        }
    }

    private fun render(state: UserProfileUiState) {
        // TextView, ProgressBar, RecyclerView 등을 state 기준으로 갱신
    }
}

이 패턴의 장점은 세 가지입니다. 첫째, viewLifecycleOwner 기준이라 Fragment의 View 생명주기와 맞습니다. 둘째, 화면이 STARTED보다 내려가면 collect가 끊깁니다. 셋째, 다시 올라오면 수집이 다시 시작됩니다. UI 반영 코드가 상태 기반으로 idempotent하다면 이 모델은 꽤 안정적입니다.


흔한 냄새

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    viewLifecycleOwner.lifecycleScope.launchWhenStarted {
        viewModel.uiState.collect { state ->
            render(state)
        }
    }
}

이 코드가 당장 틀렸다고 단정할 필요는 없습니다. 다만 시간이 지나면 이런 질문이 남습니다. STOPPED일 때 upstream은 어떻게 되고 있지? 다시 돌아오면 어디부터 이어지지? collect를 여러 개 붙이면 중복은 어떻게 관리하지? repeatOnLifecycle은 적어도 ‘이 block은 lifecycle이 내려가면 끊기고, 다시 올라오면 여기부터 다시 시작된다’는 읽기 쉬운 기준을 줍니다.


collector restart

repeatOnLifecycle의 핵심은 restart입니다. 즉, STARTED에 다시 진입할 때 block이 다시 실행됩니다. 그래서 안에 들어가는 코드는 ‘다시 시작되어도 괜찮은가’를 먼저 물어봐야 합니다.

  • 안에 넣기 좋은 것: uiState.collect { render(it) } 같은 상태 기반 UI 수집
  • 조심할 것: 한 번만 실행되어야 하는 초기화 코드
  • 조심할 것: 재진입 때마다 중복 등록될 수 있는 listener, adapter 재설정, one-shot side effect
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        adapter = UserAdapter()
        recyclerView.adapter = adapter

        viewModel.uiState.collect { state ->
            adapter.submitList(state.items)
        }
    }
}

위 코드는 작동은 할 수 있지만, 재시작마다 adapter를 새로 만들 필요가 있는지 먼저 점검해야 합니다. 대개는 초기화는 바깥에서, collect는 안에서 두는 편이 더 명확합니다.


duplicate collection

중복 collect는 repeatOnLifecycle이냐 아니냐보다 같은 Flow에 collector를 몇 번 붙이고 있는가에서 더 자주 생깁니다. 예를 들어 observe 함수를 여러 경로에서 반복 호출하거나, 같은 Flow를 바깥과 안쪽에서 따로 수집하면 API가 무엇이든 collector는 여러 개 생길 수 있습니다.

  1. 같은 observe 함수를 여러 번 호출하면서 매번 새 collector를 붙이는 경우
  2. 같은 Flow를 로딩 표시용, 리스트 갱신용, 로그용으로 여기저기 따로 collect하는 경우
  3. 상태와 one-shot 이벤트를 같은 방식으로 collect해서 재진입 시 의미가 꼬이는 경우

즉, repeatOnLifecycle이 안전한 이유는 lifecycle 경계를 분명하게 해주기 때문이지, 설계 실수를 자동으로 지워주기 때문은 아닙니다.


여러 Flow 수집

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            launch {
                viewModel.uiState.collect { state ->
                    render(state)
                }
            }

            launch {
                viewModel.isRefreshing.collect { refreshing ->
                    binding.swipeRefreshLayout.isRefreshing = refreshing
                }
            }

            launch {
                viewModel.snackBarEvent.collect { message ->
                    Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show()
                }
            }
        }
    }
}

이 구조는 lifecycle 경계를 한 곳에서 잡고, 각 Flow의 책임을 분리하기 좋습니다. 화면이 내려가면 이 수집들이 함께 끊기고, 다시 올라오면 필요한 수집들이 함께 다시 시작됩니다. 다만 이벤트 Flow는 재수집이 자연스러운지 꼭 따로 점검해야 합니다.


lifecycle gap

사용자가 화면을 잠깐 벗어났다가 다시 돌아오는 순간을 떠올려보면 이해가 쉽습니다. 이때 UI는 보통 최신 상태만 다시 그리면 됩니다. 오래 걸리는 작업까지 UI collector 생명주기에 묶어두기보다, 비즈니스 작업은 ViewModel 쪽 상태로 유지하고 UI는 그 상태만 lifecycle-aware하게 표현하는 구조가 더 안정적입니다. repeatOnLifecycle은 바로 이런 역할 분리와 잘 맞습니다.


만능은 아니다

좋은 기본값이지만, 두 가지는 여전히 개발자가 판단해야 합니다. 첫째, 재구독 비용이 큰 cold flow라면 UI 재시작 모델과 upstream 비용 관리를 분리해서 봐야 합니다. 이런 비용 제어는 보통 ViewModel 쪽의 stateIn, shareIn 같은 전략으로 다루는 편이 자연스럽습니다. 둘째, 토스트나 네비게이션처럼 한 번만 처리되어야 하는 이벤트는 상태와 같은 방식으로 다루면 안 됩니다.

이 부분은 StateFlow와 SharedFlow 차이 글과도 연결됩니다. 화면 상태를 어디까지 복원할지 고민 중이라면 SavedStateHandle은 언제 써야 할까도 함께 보면 흐름이 잘 이어집니다.


실전 체크리스트

  1. 새 코드라면 launchWhenStarted 대신 repeatOnLifecycle을 썼는가
  2. Fragment라면 viewLifecycleOwner.lifecycleScope 기준으로 두었는가
  3. repeatOnLifecycle 안에 재시작되면 곤란한 초기화 코드가 섞여 있지 않은가
  4. 같은 Flow를 여러 곳에서 중복 collect하고 있지 않은가
  5. 상태와 이벤트를 같은 방식으로 처리하고 있지 않은가
  6. 비싼 upstream 재구독 비용은 UI가 아니라 ViewModel 쪽에서 관리하고 있는가

이 여섯 가지를 기준으로 보면 lifecycle-aware Flow collection 코드는 대부분 빠르게 진단할 수 있습니다.


정리

launchWhenStarted와 repeatOnLifecycle의 차이는 문법보다 생명주기 경계를 얼마나 분명하게 드러내느냐에 있습니다. 지금 안드로이드에서 더 안전한 기본값은 repeatOnLifecycle에 가깝고, 그 이유는 deprecated 문구 자체보다 화면이 내려가면 끊고 다시 올라오면 다시 시작하는 모델이 UI 수집과 더 잘 맞기 때문입니다.

마지막으로 핵심만 다시 적으면 이렇습니다. launchWhenStarted는 현재 deprecated이고, repeatOnLifecycle은 cancel + restart 모델이며, 그래서 UI 수집 코드는 restart 가능하게 작성해야 합니다. 그리고 중복 collection 문제는 API 이름보다 scope와 코드 배치에서 더 자주 생깁니다.

공식 참고 문서는 LifecycleCoroutineScope API reference, RepeatOnLifecycleKt API reference, Kotlin coroutines on Android guide입니다.

함께보면 좋은 글