Friday, June 28, 2019

안드로이드 ViewPager2와 Fragment: RecyclerView가 갱신되지 않는 현상 완벽 분석

안드로이드 앱 개발에서 가장 보편적으로 사용되는 UI 패턴 중 하나는 탭(Tab)과 함께 스와이프로 페이지를 전환하는 ViewPager입니다. 특히 최신 버전인 ViewPager2RecyclerView를 기반으로 만들어져 성능과 유연성이 크게 향상되었고, Fragment와 함께 사용하는 것이 거의 표준처럼 자리 잡았습니다. 이 조합은 사용자에게 매우 직관적인 인터페이스를 제공하지만, 개발자에게는 종종 예기치 않은 복병을 안겨줍니다. 그중 가장 악명 높은 것이 바로 'ViewPager2 내부에 있는 Fragment의 RecyclerView가 갱신되지 않는 문제'입니다.

분명히 데이터는 변경했고, 어댑터에 notifyDataSetChanged()submitList()를 호출했는데도 화면에는 아무런 변화가 없는 상황. 개발자는 코드를 수십 번 다시 보고, 디버거를 붙잡고 씨름하며 반나절, 혹은 하루를 꼬박 날려버리기도 합니다. 이 글에서는 수많은 개발자를 괴롭혔던 이 미스터리한 현상의 근본적인 원인을 파헤치고, 가장 확실하고 현대적인 해결책을 상세한 코드와 함께 제시합니다.

현상: 왜 내 RecyclerView만 그대로인가?

먼저 우리가 마주하는 문제 상황을 구체적으로 정의해 봅시다. 시나리오는 보통 다음과 같습니다.

  1. Activity 또는 상위 FragmentTabLayoutViewPager2가 있다.
  2. ViewPager2의 어댑터는 FragmentStateAdapter를 상속받아 여러 개의 Fragment를 관리한다.
  3. Fragment는 내부에 RecyclerView를 가지고 있고, 자신만의 데이터를 표시한다.
  4. 사용자의 특정 액션(예: 다른 탭의 버튼 클릭, 서버로부터 푸시 메시지 수신 등)으로 인해 현재 보이지 않는(off-screen) Fragment의 RecyclerView가 가진 데이터를 갱신해야 하는 요구사항이 발생한다.
  5. 개발자는 해당 Fragment의 어댑터에 새로운 데이터를 설정하고 notifyItem...() 계열 메소드를 호출하지만, 사용자가 해당 탭으로 스와이프했을 때 RecyclerView는 이전 상태 그대로 멈춰 있다.

심지어 이런 구조가 더 복잡해져서, ViewPager 안의 Fragment가 다시 내부에 ViewPager와 자식 Fragment(Child Fragment)를 갖는 중첩 구조일 때 문제는 더욱 심각하고 디버깅하기 어려워집니다. 아이템을 추가해도, 삭제해도 감감무소식인 RecyclerView를 보며 개발자는 깊은 좌절에 빠지게 됩니다.

근본 원인: Fragment의 생명주기와 ViewPager2의 영리한 전략

이 문제의 핵심을 이해하려면 안드로이드 Fragment의 '생명주기(Lifecycle)'와 ViewPager2의 '상태 관리' 메커니즘을 알아야 합니다. 결론부터 말하자면, 우리가 업데이트하려는 Fragment의 '뷰(View)'가 메모리상에 존재하지 않았기 때문입니다.

좀 더 자세히 살펴보겠습니다.

1. FragmentStateAdapter의 동작 방식

ViewPager2와 함께 쓰이는 FragmentStateAdapter는 메모리를 효율적으로 사용하기 위해 모든 Fragment를 항상 메모리에 올려두지 않습니다. 이름에 'State'가 들어간 것에서 짐작할 수 있듯이, 이 어댑터는 Fragment의 '상태(State)'만 저장해두고, 화면에서 벗어나면 Fragment의 '뷰(View)'를 가차없이 파괴(destroy)합니다.

  • 사용자가 1번 페이지를 보고 있을 때, ViewPager2는 보통 좌우의 0번, 2번 페이지만 메모리에 유지합니다. (이 범위는 offscreenPageLimit 속성으로 조절할 수 있습니다.)
  • 만약 3번 페이지가 있다면, 이 시점에 3번 Fragment의 인스턴스는 존재할 수 있어도, 그 안의 UI 요소들(RecyclerView, TextView 등)로 구성된 '뷰'는 onDestroyView() 콜백이 호출되며 파괴된 상태일 가능성이 높습니다.
  • 이후 사용자가 3번 페이지로 스와이프하면, FragmentStateAdapter는 저장해둔 상태를 바탕으로 onCreateView()를 다시 호출하여 '뷰'를 새롭게 생성합니다.

이것이 바로 문제의 시발점입니다. 개발자가 1번 페이지에서 3번 페이지의 데이터를 업데이트하려고 할 때, 3번 Fragment 객체 자체에는 접근할 수 있을지 몰라도, 정작 데이터를 그려줘야 할 RecyclerView는 존재하지 않는 '유령' 상태인 것입니다. 허공에 대고 "새로고침해!"라고 외치는 것과 같습니다.

2. 잘못된 접근 방식들

이 원리를 모르는 상태에서 개발자들은 보통 다음과 같은 시도를 하게 됩니다.

  • Fragment 참조를 리스트로 관리하며 직접 접근:
    
    // 어딘가에 Fragment 리스트를 관리
    val fragmentList = listOf(FragmentA(), FragmentB(), FragmentC())
    
    // ... ViewPager2 어댑터에 이 리스트를 전달
    
    // 업데이트 시점
    (fragmentList[2] as FragmentC).updateRecyclerView(newData)
            
    이 코드는 FragmentC의 뷰가 파괴된 상태라면 updateRecyclerView 내부에서 recyclerView 프로퍼티에 접근할 때 IllegalStateException: view must not be null 또는 NullPointerException을 유발할 수 있습니다.
  • 어댑터를 새로 생성해서 교체: 갱신이 안 되니 아예 RecyclerView의 어댑터를 `new` 키워드로 새로 만들어서 통째로 갈아 끼우려는 시도입니다. 이는 근본적인 해결책이 아닐뿐더러, 뷰가 없는 상태에서는 역시나 무용지물이며 비효율의 극치입니다.
  • notifyDataSetChanged() 무한 호출: 가장 고전적인 방법이지만, 이 역시 뷰가 없는 상태에서는 효과가 없으며, 뷰가 있다 하더라도 DiffUtil을 사용하지 않는 비효율적인 방식입니다.

이러한 시도들은 모두 '파괴된 뷰'라는 거대한 장벽 앞에서 좌절하게 됩니다.

해결책: 생명주기를 존중하는 '상태 중심' 설계

문제를 해결하는 열쇠는 Fragment의 뷰 생명주기에 의존하지 않고, Fragment들이 공유하는 '데이터(상태)' 자체에 집중하는 것입니다. 이를 가장 우아하고 효과적으로 구현하는 방법이 바로 안드로이드 아키텍처 컴포넌트(AAC)의 ViewModelLiveData (또는 `StateFlow`)를 사용하는 것입니다.

Shared ViewModel 패턴

핵심 아이디어는 다음과 같습니다. ViewPager2를 호스팅하는 Activity나 상위 Fragment의 생명주기에 맞춰 ViewModel을 생성하고, ViewPager2 내부의 모든 자식 Fragment들이 이 동일한 ViewModel 인스턴스를 공유하는 것입니다.

  1. 데이터의 중앙 저장소: 모든 Fragment에 필요한 데이터는 SharedViewModel이 관리합니다. 데이터 업데이트 로직 또한 ViewModel이 책임집니다.
  2. 관찰(Observing): 각 Fragment는 자신의 뷰가 생성될 때(onViewCreated), SharedViewModel이 가진 데이터를 LiveDataStateFlow를 통해 '구독(observe)'합니다.
  3. 자동 업데이트: ViewModel의 데이터가 변경되면, 이를 구독하고 있는 모든 Fragment에 변경 사항이 통지됩니다. 중요한 것은, 이때 해당 Fragment의 뷰가 살아있는(active) 상태일 때만 UI 업데이트 코드가 실행된다는 점입니다.

이 패턴이 마법 같은 이유는 다음과 같습니다.

  • off-screen 상태라 뷰가 파괴된 Fragment는 데이터 변경 알림을 받지 않습니다. (메모리 누수나 크래시 방지)
  • 이후 사용자가 스와이프하여 해당 Fragment의 뷰가 다시 생성되면, onViewCreated에서 다시 구독 로직이 실행되면서 ViewModel로부터 가장 최신 데이터를 즉시 가져와 RecyclerView를 그리게 됩니다.

결과적으로 우리는 더 이상 Fragment의 뷰가 지금 살아있는지 걱정할 필요 없이, 그저 '데이터 소스'인 ViewModel만 업데이트하면 됩니다.

구체적인 코드 구현 예제

이제 실제 코드를 통해 'Shared ViewModel' 패턴을 어떻게 적용하는지 단계별로 살펴보겠습니다. 3개의 탭(Fragment)이 있고, 각 탭이 서로 다른 목록을 보여주지만 데이터 소스는 하나인 상황을 가정합니다.

1. build.gradle (Module: app) 의존성 추가

먼저 필요한 라이브러리를 추가합니다. lifecycle-viewmodel-ktx는 by viewModels() 같은 편리한 확장 함수를 제공합니다.


dependencies {
    // ViewModel
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1"
    // LiveData
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.5.1"
    // Fragment KTX for by activityViewModels()
    implementation "androidx.fragment:fragment-ktx:1.5.4"
}

2. 공유할 ViewModel 생성 (SharedViewModel.kt)

세 개의 Fragment가 공유할 데이터를 관리할 ViewModel입니다. 각 탭에 맞는 데이터 리스트를 `LiveData`로 노출합니다.


import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel

data class MyItem(val id: Long, val text: String)

class SharedViewModel : ViewModel() {

    // 각 Fragment가 관찰할 LiveData. 외부에서는 변경 불가하도록 private Mutable, public LiveData로 노출
    private val _itemsForTab1 = MutableLiveData<List<MyItem>>()
    val itemsForTab1: LiveData<List<MyItem>> = _itemsForTab1

    private val _itemsForTab2 = MutableLiveData<List<MyItem>>()
    val itemsForTab2: LiveData<List<MyItem>> = _itemsForTab2

    private val _itemsForTab3 = MutableLiveData<List<MyItem>>()
    val itemsForTab3: LiveData<List<MyItem>> = _itemsForTab3

    init {
        // 초기 데이터 로드 (예: Repository에서 비동기 로드)
        loadInitialData()
    }

    private fun loadInitialData() {
        // 임시 데이터
        _itemsForTab1.value = (1..10).map { MyItem(it.toLong(), "탭 1 - 아이템 $it") }
        _itemsForTab2.value = (1..15).map { MyItem(it.toLong(), "탭 2 - 아이템 $it") }
        _itemsForTab3.value = (1..5).map { MyItem(it.toLong(), "탭 3 - 아이템 $it") }
    }

    // 외부에서 탭 2의 데이터를 변경하는 함수 (문제 상황 시뮬레이션)
    fun addNewItemToTab2(newItemText: String) {
        val currentList = _itemsForTab2.value?.toMutableList() ?: mutableListOf()
        val newId = (currentList.maxOfOrNull { it.id } ?: 0) + 1
        currentList.add(MyItem(newId, newItemText))
        _itemsForTab2.value = currentList // LiveData에 새로운 리스트를 할당하면 관찰자에게 알림이 간다.
    }
}

3. ViewPager2를 가진 Activity 또는 상위 Fragment (MainActivity.kt)

이곳에서 SharedViewModel이 생성되고, ViewPager2와 어댑터가 설정됩니다.


import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.activity.viewModels
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayoutMediator
import com.your.package.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    
    // Activity 스코프의 ViewModel 생성. 이 ViewModel은 MainActivity가 살아있는 동안 유지된다.
    private val sharedViewModel: SharedViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        setupViewPager()
        
        // 1번 탭에서 2번 탭의 데이터를 변경하는 버튼 이벤트 (시뮬레이션을 위해)
        binding.someButtonToUpdateTab2.setOnClickListener {
            // ViewModel의 데이터를 변경. Fragment에 직접 접근하지 않는다!
            sharedViewModel.addNewItemToTab2("새로 추가된 아이템!")
        }
    }

    private fun setupViewPager() {
        binding.viewPager.adapter = MyFragmentStateAdapter(this)

        TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
            tab.text = "탭 ${position + 1}"
        }.attach()
    }

    private inner class MyFragmentStateAdapter(activity: AppCompatActivity) : FragmentStateAdapter(activity) {
        override fun getItemCount(): Int = 3

        override fun createFragment(position: Int): Fragment {
            // 각 Fragment에 position 정보를 Bundle로 넘겨줄 수 있다.
            return PageFragment.newInstance(position)
        }
    }
}

4. ViewPager2 내부의 각 Fragment (PageFragment.kt)

이곳이 바로 마법이 일어나는 장소입니다. by activityViewModels()를 통해 부모 ActivitySharedViewModel에 접근하고, LiveData를 관찰합니다.


import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.your.package.databinding.FragmentPageBinding

class PageFragment : Fragment() {

    private var _binding: FragmentPageBinding? = null
    private val binding get() = _binding!!

    // `by activityViewModels()`: 이 Fragment를 호스팅하는 Activity의 ViewModel을 가져온다.
    // 모든 PageFragment 인스턴스가 동일한 SharedViewModel을 공유하게 됨.
    private val sharedViewModel: SharedViewModel by activityViewModels()

    private lateinit var myAdapter: MyRecyclerViewAdapter
    private var position: Int = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // newInstance를 통해 전달받은 position 값 가져오기
        arguments?.let {
            position = it.getInt(ARG_POSITION)
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentPageBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        setupRecyclerView()
        observeViewModel()
    }
    
    private fun setupRecyclerView() {
        myAdapter = MyRecyclerViewAdapter() // 어댑터 생성
        binding.recyclerView.apply {
            layoutManager = LinearLayoutManager(requireContext())
            adapter = myAdapter
        }
    }

    private fun observeViewModel() {
        // 현재 프래그먼트의 위치(position)에 따라 적절한 LiveData를 관찰
        val liveDataToObserve = when (position) {
            0 -> sharedViewModel.itemsForTab1
            1 -> sharedViewModel.itemsForTab2
            2 -> sharedViewModel.itemsForTab3
            else -> throw IllegalArgumentException("Invalid position")
        }

        // viewLifecycleOwner: Fragment의 View 생명주기를 따르는 LifecycleOwner.
        // 이 Fragment의 View가 파괴되면(onDestroyView) 자동으로 관찰이 중지된다. (메모리 누수 방지)
        liveDataToObserve.observe(viewLifecycleOwner) { items ->
            // LiveData가 변경될 때마다 이 블록이 실행됨.
            // ListAdapter의 submitList를 사용하면 DiffUtil을 통해 효율적으로 UI를 갱신한다.
            items?.let {
                myAdapter.submitList(it)
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        // 메모리 누수 방지를 위해 binding 참조를 null로 설정
        _binding = null
    }

    companion object {
        private const val ARG_POSITION = "position"

        fun newInstance(position: Int) =
            PageFragment().apply {
                arguments = Bundle().apply {
                    putInt(ARG_POSITION, position)
                }
            }
    }
}

이 코드가 동작하는 흐름을 다시 정리하면,

  1. 사용자가 1번 탭(position 0)에 있는 버튼을 누른다.
  2. MainActivityonClickListenersharedViewModel.addNewItemToTab2()를 호출한다.
  3. SharedViewModel 내부의 _itemsForTab2 MutableLiveData의 값이 변경된다.
  4. _itemsForTab2를 관찰(observe)하고 있던 모든 관찰자에게 변경이 통지된다.
  5. 만약 2번 탭(position 1) Fragment가 현재 화면에 보이고 있다면, observe 블록이 즉시 실행되어 myAdapter.submitList()를 통해 RecyclerView가 부드럽게 갱신된다.
  6. 만약 2번 탭 Fragment가 off-screen 상태라 뷰가 파괴된 상태였다면 아무 일도 일어나지 않는다.
  7. 이후 사용자가 2번 탭으로 스와이프하면, PageFragment의 뷰가 새로 생성되고 onViewCreated가 호출된다.
  8. observeViewModel()이 다시 호출되면서 LiveData 구독이 시작되고, LiveData는 구독이 시작되자마자 가장 최신의 데이터(이미 새 아이템이 추가된 리스트)를 방출(emit)한다.
  9. 결과적으로 새로 생성된 RecyclerView는 완벽하게 최신 데이터를 반영하여 그려진다.

이제 우리는 더 이상 Fragment의 생명주기를 수동으로 추적하거나 뷰가 존재하는지 확인할 필요가 없습니다. 안드로이드 아키텍처 컴포넌트가 이 모든 복잡성을 알아서 처리해줍니다.

'bringToFront()'는 해결책이 될 수 있는가?

간혹 오래된 자료나 특정 케이스에서 view.bringToFront() 메소드를 호출하여 문제가 해결되었다는 경험담을 볼 수 있습니다. bringToFront()는 부모 ViewGroup 내에서 특정 자식 뷰를 가장 위(z-index상)로 끌어올려 다른 뷰에 가려지지 않게 하는 함수입니다. 이것이 왜 일부 상황에서 해결책처럼 보였을까요?

가능성은 두 가지입니다.

  1. 실제 뷰가 가려진 경우: 레이아웃을 FrameLayout 등으로 복잡하게 구성한 경우, 업데이트된 RecyclerView 위를 다른 투명한 뷰가 덮고 있었을 수 있습니다. 이 경우 bringToFront()는 단순히 가려진 뷰를 앞으로 꺼내 문제를 해결한 것입니다. 이것은 ViewPager의 갱신 문제라기보다는 레이아웃 구성의 문제입니다.
  2. 강제 리드로우(Re-draw) 유발: bringToFront() 호출은 뷰 계층 구조의 변경을 의미하므로 부모 뷰에 requestLayout()invalidate()를 촉발시킵니다. 이는 뷰의 측정, 배치, 그리기를 다시 수행하라는 신호입니다. 어떤 이유로든 데이터는 업데이트되었지만 화면 갱신만 누락된 매우 특수한 상황에서, 이 강제 리드로우가 갱신을 트리거했을 수 있습니다.

하지만 bringToFront()는 절대 근본적인 해결책이 아닙니다. 이는 증상에 대한 임시방편일 뿐, FragmentStateAdapter의 뷰 생명주기 관리라는 핵심 원인을 해결하지 못합니다. 위에서 설명한 SharedViewModel 패턴이야말로 어떤 상황에서도 안정적으로 동작하는, 아키텍처 관점에서의 정답입니다.

결론 및 권장사항

ViewPager2Fragment, RecyclerView의 조합에서 발생하는 갱신 문제는 안드로이드 생명주기에 대한 깊은 이해를 요구하는 대표적인 난제입니다. 이 문제와 마주쳤다면 다음의 원칙을 기억하세요.

  1. 문제의 본질은 '생명주기': 데이터 갱신이 안 되는 것은 대부분 내가 접근하려는 Fragment의 뷰가 파괴되었기 때문이다.
  2. 뷰가 아닌 '상태'를 관리하라: Fragment의 뷰를 직접 제어하려는 시도를 멈추고, 여러 Fragment가 공유하는 데이터 '상태'를 관리하는 것에 집중하라.
  3. Shared ViewModel을 사용하라: Activity나 상위 Fragment 스코프의 ViewModel을 만들어 데이터의 중앙 저장소로 활용하라. 이것이 가장 현대적이고 안정적인 해결책이다.
  4. LiveData 또는 StateFlow로 관찰하라: FragmentonViewCreated에서 viewLifecycleOwner와 함께 LiveData를 관찰하여 생명주기를 존중하는 안전한 UI 업데이트를 구현하라.
  5. ListAdapter와 DiffUtil을 활용하라: RecyclerView의 성능을 최적화하고 부드러운 애니메이션 효과를 얻기 위해 ListAdapter 사용을 적극 권장한다.

복잡한 갱신 문제로 인해 스트레스를 받았다면, 이제는 SharedViewModel 패턴을 도입하여 구조적으로 문제를 해결할 때입니다. 이 접근 방식은 코드의 테스트 용이성을 높이고, 역할과 책임을 명확히 분리하며, 앱의 전반적인 아키텍처를 견고하게 만들어 줄 것입니다. 더 이상은 원인 모를 갱신 오류로 시간을 낭비하지 마세요.


1 comment:

  1. 안녕하세요.
    해당 글을 보고 문제가 비슷한 것 같은데 해결이 안되고 있어서 문의 남깁니다.
    저는 fragment안에 NestedScrollView안에 RecyclerView가 있고
    RecyclerView에서 버튼클릭해서 Dialog를 띄웁니다. 그리고 확인을 하면 RecyclerView의 해당 뷰가 갱신이 되어야하는데....
    Dialog에서 EditText가 있는데 아무 동작없이 확인을 하면 RecyclerView가 notifyItemChanged를 사용해서 잘 갱신이 됩니다.
    그런데 EditText에 포커스를 주고 키보드를 띄웠다가 확인을 하면 notifyItemChanged를 해도 갱신이 안되고 RecyclerView를 살짝 움직여야 onBindViewHolder를 타면서 갱신이 됩니다.
    혹시 해결방법이 있을까 싶어 문의 남깁니다...

    ReplyDelete