Android ViewPager2 + Fragment 최적화 — FragmentStateAdapter, 화면 선로딩, 상태 유지와 메모리 관리
Android 앱에서 여러 화면을 좌우 스와이프로 전환하거나 상단 탭을 이용해 화면을 이동할 때 자주 사용하는 컴포넌트가 ViewPager2입니다.
대표적으로 다음과 같은 앱에서 사용합니다.
뉴스 및 콘텐츠 앱
조류·식물·곤충 도감 앱
쇼핑 및 상품 상세 화면
여러 웹사이트를 표시하는 WebView 앱
사진 갤러리 및 이미지 슬라이더
탭 기반 검색 화면
ViewPager2는 Fragment와 함께 사용하면 화면을 쉽게 구성할 수 있지만, 실제 프로젝트에서는 예상하지 못한 문제가 발생하기도 합니다.
예를 들어 다음과 같은 상황입니다.
사용자가 선택하지 않은 탭의 Fragment가 미리 생성됨
보이지 않는 WebView에서 네트워크 요청이 발생함
탭을 이동했다가 돌아오면 화면이 다시 로딩됨
RecyclerView의 스크롤 위치가 초기화됨
Fragment를 반복해서 생성하면서 메모리 사용량이 증가함
ViewPager2의 페이지 순서를 변경하면 잘못된 Fragment가 표시됨
Fragment에서 ViewBinding을 해제하지 않아 메모리 누수가 발생함
이러한 문제는 ViewPager2 자체의 오류라기보다 Fragment의 생명주기와 페이지 생성 방식을 정확히 이해하지 못해 발생하는 경우가 많습니다.
이번 글에서는 Kotlin + XML 기반 Android 프로젝트에서 ViewPager2, FragmentStateAdapter, TabLayoutMediator, ViewModel을 활용해 이러한 문제를 해결하는 방법을 정리하겠습니다.
작성 기준: 2026년 10월
1. ViewPager2의 기본 구조 이해하기
ViewPager2는 RecyclerView를 기반으로 페이지를 전환하는 컴포넌트입니다.
각 페이지는 일반 View 또는 Fragment로 구성할 수 있습니다.
Fragment를 사용하는 경우에는 FragmentStateAdapter를 사용합니다.
MainActivity
│
└── ViewPager2
│
├── FragmentStateAdapter
│ │
│ ├── HomeFragment
│ ├── SearchFragment
│ └── FavoriteFragment
│
└── TabLayout
│
└── TabLayoutMediator이 구조에서 중요한 점은 ViewPager2가 현재 보이는 페이지뿐 아니라 인접 페이지도 미리 생성할 수 있다는 것입니다.
따라서 Fragment의 onCreateView()나 onViewCreated()가 호출되었다고 해서 사용자가 해당 페이지를 실제로 보고 있다고 판단하면 안 됩니다.
2. ViewPager2와 기존 ViewPager의 차이
기존 ViewPager는 FragmentPagerAdapter, FragmentStatePagerAdapter와 함께 사용했습니다.
ViewPager2에서는 FragmentStateAdapter를 사용합니다.
| 구분 | ViewPager | ViewPager2 |
|---|---|---|
| 기반 구조 | 기존 ViewPager 구현 | RecyclerView 기반 |
| Fragment 어댑터 | FragmentPagerAdapter 등 | FragmentStateAdapter |
| 세로 스와이프 | 기본 지원하지 않음 | 지원 |
| RTL 레이아웃 | 제한적 | 지원 |
| 페이지 갱신 | 제한적 | RecyclerView 어댑터 기반 |
| 권장 방향 | 기존 코드 유지보수 | 신규 구현에 적합 |
기존 ViewPager 프로젝트를 유지보수하는 경우에는 즉시 ViewPager2로 변경할 필요는 없습니다.
하지만 새로운 탭 화면을 구현한다면 ViewPager2를 우선적으로 고려할 수 있습니다.
3. ViewPager2 XML 레이아웃 구성하기
먼저 TabLayout과 ViewPager2를 배치합니다.
res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="fixed"
app:tabGravity="fill" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>이 예제는 상단 탭과 하단 페이지 영역으로 구성되어 있습니다.
실제 프로젝트에서 Edge-to-edge를 적용하고 있다면 상태 표시줄과 내비게이션 바의 WindowInsets도 별도로 처리해야 합니다.
4. FragmentStateAdapter 구현하기
세 개의 Fragment를 사용하는 예제입니다.
class MainPagerAdapter(
activity: FragmentActivity
) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = 3
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> HomeFragment()
1 -> SearchFragment()
2 -> FavoriteFragment()
else -> throw IllegalArgumentException(
"Invalid page position: $position"
)
}
}
}createFragment()는 해당 페이지에 필요한 Fragment를 생성하는 메서드입니다.
여기서 주의할 점이 있습니다.
createFragment()가 호출될 때마다 반드시 새로운 Fragment가 화면에 표시되는 것은 아닙니다.
FragmentStateAdapter는 FragmentManager와 RecyclerView의 상태 관리 기능을 이용해 페이지를 생성하고 복원합니다.
따라서 Fragment 객체를 Activity에서 직접 배열로 관리하거나, 무조건 새로 생성해야 한다고 가정하면 문제가 발생할 수 있습니다.
5. TabLayoutMediator 연결하기
Activity에서 ViewPager2와 TabLayout을 연결합니다.
class MainActivity : AppCompatActivity() {
private lateinit var viewPager: ViewPager2
private lateinit var tabLayout: TabLayout
private var tabMediator: TabLayoutMediator? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
viewPager = findViewById(R.id.viewPager)
tabLayout = findViewById(R.id.tabLayout)
viewPager.adapter = MainPagerAdapter(this)
tabMediator = TabLayoutMediator(
tabLayout,
viewPager
) { tab, position ->
tab.text = when (position) {
0 -> "홈"
1 -> "검색"
2 -> "즐겨찾기"
else -> ""
}
}.also { it.attach() }
}
override fun onDestroy() {
tabMediator?.detach()
tabMediator = null
super.onDestroy()
}
}이렇게 하면 탭 선택과 ViewPager2 페이지 이동이 연동됩니다.
단, 실제 앱에서 탭 목록을 동적으로 변경한다면 어댑터의 데이터와 탭 제목이 함께 갱신되도록 설계해야 합니다.
6. 보이지 않는 Fragment가 미리 생성되는 이유
ViewPager2를 사용하면서 가장 혼동하기 쉬운 부분입니다.
예를 들어 현재 첫 번째 탭을 보고 있다고 가정하겠습니다.
현재 선택: HomeFragment
[HomeFragment] [SearchFragment] [FavoriteFragment]
▲
사용자 화면이때 ViewPager2는 스와이프를 부드럽게 처리하기 위해 인접 페이지를 미리 준비할 수 있습니다.
따라서 사용자가 SearchFragment를 선택하지 않았더라도 다음 메서드가 호출될 수 있습니다.
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?
) {
super.onViewCreated(view, savedInstanceState)
Log.d("SearchFragment", "onViewCreated")
}문제는 이 메서드에서 곧바로 네트워크 요청이나 WebView 로딩을 실행하는 경우입니다.
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?
) {
super.onViewCreated(view, savedInstanceState)
webView.loadUrl("https://example.com")
}사용자가 해당 탭을 열지 않았는데도 웹페이지가 로딩될 수 있습니다.
여러 WebView를 사용하는 앱에서는 이러한 선로딩으로 인해 메모리와 네트워크 사용량이 증가할 수 있습니다.
7. onResume()을 이용한 최초 로딩 제어
FragmentStateAdapter가 관리하는 일반적인 ViewPager2 환경에서는 현재 선택된 페이지가 RESUMED 상태가 되고, 인접 페이지는 보통 STARTED 상태로 제한됩니다.
이 특성을 이용하면 실제 선택된 페이지에서 처음 로딩하도록 구현할 수 있습니다.
class WebPageFragment : Fragment(R.layout.fragment_web_page) {
private var webView: WebView? = null
private var hasLoaded = false
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?
) {
super.onViewCreated(view, savedInstanceState)
webView = view.findViewById(R.id.webView)
}
override fun onResume() {
super.onResume()
if (!hasLoaded) {
hasLoaded = true
webView?.loadUrl(
"https://example.com"
)
}
}
override fun onDestroyView() {
webView?.destroy()
webView = null
hasLoaded = false
super.onDestroyView()
}
}이렇게 하면 Fragment의 View가 미리 생성되더라도 onResume() 전까지 웹페이지 로딩을 미룰 수 있습니다.
다만 이 예제는 View가 파괴되면 로딩 상태도 초기화합니다.
따라서 Fragment의 View가 다시 생성되면 웹페이지를 다시 로딩합니다.
로그인 상태, 스크롤 위치, 입력 폼 등을 유지해야 한다면 별도의 상태 복원 전략이 필요합니다.
또한 onResume()이 항상 사용자에게 완전히 보이는 순간과 동일한 의미를 갖는 것은 아니므로 복잡한 UI에서는 현재 선택된 페이지 ID도 함께 관리하는 편이 명확합니다.
8. WebView 최초 로딩 상태를 ViewModel로 관리하기
Fragment의 View가 파괴되었다가 다시 생성될 때 로딩 상태를 유지하고 싶다면 ViewModel을 활용할 수 있습니다.
class WebPageViewModel : ViewModel() {
var hasRequestedInitialLoad = false
private set
fun markInitialLoadRequested() {
hasRequestedInitialLoad = true
}
}Fragment에서는 다음과 같이 사용할 수 있습니다.
private val viewModel: WebPageViewModel by viewModels()
override fun onResume() {
super.onResume()
if (!viewModel.hasRequestedInitialLoad) {
viewModel.markInitialLoadRequested()
webView?.loadUrl("https://example.com")
}
}하지만 이 구조에는 중요한 한계가 있습니다.
ViewModel에 로딩 여부를 저장한다고 WebView의 실제 화면 상태까지 보존되는 것은 아닙니다.
ViewModel에는 hasRequestedInitialLoad = true가 남아 있지만 WebView 자체가 파괴되었다면 새 WebView는 빈 화면일 수 있습니다.
따라서 WebView를 새로 생성하는 경우에는 saveState()와 restoreState()를 이용하거나, URL을 다시 로딩하는 방식으로 화면 상태를 복원해야 합니다.
단순한 최초 로딩 플래그만으로는 충분하지 않습니다.
9. WebView saveState()와 restoreState()
WebView는 탐색 상태를 저장하고 복원할 수 있는 API를 제공합니다.
val state = Bundle()
webView.saveState(state)복원할 때는 다음과 같이 사용할 수 있습니다.
webView.restoreState(state)Fragment에서는 View가 파괴되기 전에 상태를 저장할 수 있습니다.
class WebPageFragment : Fragment(R.layout.fragment_web_page) {
private var webView: WebView? = null
private var webState: Bundle? = null
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?
) {
super.onViewCreated(view, savedInstanceState)
webView = view.findViewById(R.id.webView)
val state = savedInstanceState?.getBundle("web_state")
?: webState
if (state != null) {
webView?.restoreState(state)
} else {
webView?.loadUrl("https://example.com")
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
val state = Bundle()
webView?.saveState(state)
outState.putBundle("web_state", state)
}
override fun onDestroyView() {
webView?.let { current ->
webState = Bundle().also { state ->
current.saveState(state)
}
(current.parent as? ViewGroup)
?.removeView(current)
current.destroy()
}
webView = null
super.onDestroyView()
}
}이 예제는 WebView의 탐색 상태 복원 구조를 설명하기 위한 것입니다.
주의할 점은 saveState()가 모든 화면 상태를 완벽하게 저장하는 기능은 아니라는 것입니다.
JavaScript 실행 상태, 동적으로 생성된 DOM, 입력 중인 모든 폼 상태, 로그인 세션 등을 완전하게 복원한다고 보장할 수 없습니다.
특히 복잡한 웹서비스에서는 상태 복원보다 현재 URL을 다시 로딩하는 방식이 더 안정적일 수도 있습니다.
10. offscreenPageLimit 설정 이해하기
ViewPager2에는 인접 페이지 유지 범위를 설정하는 offscreenPageLimit 속성이 있습니다.
viewPager.offscreenPageLimit = 1이 설정은 현재 페이지 양쪽에 유지할 페이지 수를 지정합니다.
하지만 다음과 같은 코드는 주의해야 합니다.
viewPager.offscreenPageLimit = 5페이지 수가 많고 각 페이지에 WebView나 대량의 이미지가 포함되어 있다면 메모리 사용량이 증가할 수 있습니다.
특히 offscreenPageLimit을 크게 설정하면 더 많은 페이지의 View가 유지될 수 있습니다.
반대로 다음과 같이 기본값을 사용할 수도 있습니다.
viewPager.offscreenPageLimit =
ViewPager2.OFFSCREEN_PAGE_LIMIT_DEFAULT기본값에서는 ViewPager2가 내부 RecyclerView의 캐싱 정책을 활용합니다.
중요한 점은 offscreenPageLimit을 기본값으로 설정한다고 인접 Fragment의 생성이 완전히 차단되는 것은 아니라는 것입니다.
WebView 선로딩을 막으려면 별도의 로딩 시점 제어가 필요합니다.
11. FragmentStateAdapter에서 Fragment를 직접 캐싱해야 할까?
다음과 같은 코드를 작성하는 경우가 있습니다.
private val fragments = listOf(
HomeFragment(),
SearchFragment(),
FavoriteFragment()
)그리고 createFragment()에서 기존 Fragment를 반환합니다.
override fun createFragment(position: Int): Fragment {
return fragments[position]
}고정된 페이지가 적고 단순한 구조에서는 동작할 수 있지만, 일반적으로 FragmentStateAdapter의 상태 복원 및 FragmentManager 관리 방식과 충돌하지 않도록 주의해야 합니다.
특히 Activity 재생성, 프로세스 복원, 페이지 동적 변경 상황에서는 직접 캐싱한 Fragment 인스턴스에 의존하는 구조가 복잡해질 수 있습니다.
대부분의 경우 다음과 같이 새 Fragment를 생성하도록 구현하는 것이 명확합니다.
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> HomeFragment()
1 -> SearchFragment()
2 -> FavoriteFragment()
else -> error("Invalid position")
}
}Fragment의 상태는 FragmentManager, SavedStateHandle, ViewModel 등을 이용해 관리하는 것이 좋습니다.
12. 페이지 순서가 변경될 때 Fragment가 잘못 표시되는 문제
페이지 목록을 동적으로 변경하는 경우에는 FragmentStateAdapter의 항목 ID를 관리해야 합니다.
예를 들어 다음과 같은 페이지가 있다고 가정하겠습니다.
기존:
[홈] [검색] [즐겨찾기]
변경:
[홈] [즐겨찾기] [설정]단순히 position만 기준으로 Fragment를 생성하면 기존 Fragment 상태가 다른 페이지에 연결되는 문제가 발생할 수 있습니다.
이때 getItemId()와 containsItem()을 재정의할 수 있습니다.
data class PageItem(
val id: Long,
val title: String
)
class DynamicPagerAdapter(
activity: FragmentActivity
) : FragmentStateAdapter(activity) {
private var pages = listOf(
PageItem(10L, "홈"),
PageItem(20L, "검색"),
PageItem(30L, "즐겨찾기")
)
override fun getItemCount(): Int = pages.size
override fun getItemId(position: Int): Long {
return pages[position].id
}
override fun containsItem(itemId: Long): Boolean {
return pages.any { it.id == itemId }
}
override fun createFragment(position: Int): Fragment {
return when (pages[position].id) {
10L -> HomeFragment()
20L -> SearchFragment()
30L -> FavoriteFragment()
40L -> SettingsFragment()
else -> error("Unknown page")
}
}
fun updatePages(newPages: List<PageItem>) {
pages = newPages
notifyDataSetChanged()
}
}이 예제에서는 페이지마다 고정된 ID를 사용합니다.
페이지의 위치가 변경되더라도 같은 ID를 유지할 수 있습니다.
실제 프로젝트에서는 페이지 목록 변경 후 현재 선택된 페이지가 삭제되었는지도 확인해야 합니다.
13. RecyclerView 스크롤 위치 유지하기
ViewPager2의 각 Fragment에 RecyclerView가 있는 경우 탭을 이동했다가 돌아오면 스크롤 위치가 초기화되는 문제가 발생할 수 있습니다.
이 문제는 Fragment의 View가 재생성되거나 RecyclerView의 어댑터에 데이터가 늦게 전달될 때 발생할 수 있습니다.
간단한 방법은 LayoutManager의 상태를 저장하는 것입니다.
private var listState: Parcelable? = null
override fun onDestroyView() {
listState = binding.recyclerView
.layoutManager
?.onSaveInstanceState()
super.onDestroyView()
}데이터가 준비된 뒤 복원합니다.
adapter.submitList(items) {
listState?.let { state ->
binding.recyclerView.layoutManager
?.onRestoreInstanceState(state)
listState = null
}
}다만 submitList()의 완료 콜백은 새 목록이 실제로 반영되는 상황과 어댑터의 세대 관리 방식에 영향을 받을 수 있습니다.
여러 목록이 빠르게 제출되는 경우 모든 콜백이 반드시 실행된다고 가정해서는 안 됩니다.
또한 화면 회전이나 프로세스 재생성까지 고려한다면 Fragment의 SavedState와 RecyclerView의 상태 복원 정책을 함께 사용하는 것이 좋습니다.
14. StateRestorationPolicy 활용하기
RecyclerView 어댑터에서는 상태 복원 정책을 설정할 수 있습니다.
adapter.stateRestorationPolicy =
RecyclerView.Adapter.StateRestorationPolicy
.PREVENT_WHEN_EMPTY이 설정은 어댑터에 항목이 없는 동안 상태 복원을 지연하는 데 도움이 됩니다.
예를 들어 SQLite에서 비동기로 데이터를 조회하는 경우 다음과 같은 흐름이 발생합니다.
Fragment View 생성
│
▼
RecyclerView 초기화
│
▼
SQLite 비동기 조회
│
▼
데이터 도착
│
▼
Adapter 목록 반영
│
▼
스크롤 상태 복원이 구조에서는 데이터가 도착하기 전에 스크롤 위치 복원이 시도되는 문제를 줄일 수 있습니다.
다만 빈 목록이 정상적인 최종 결과인 화면에서는 복원 정책을 어떻게 적용할지 별도로 검토해야 합니다.
15. Fragment ViewBinding 메모리 누수 방지
Fragment에서 ViewBinding을 사용할 때 가장 중요한 것은 View 생명주기입니다.
Fragment 객체의 생명주기와 Fragment View의 생명주기는 다릅니다.
Fragment는 살아 있지만 View만 파괴될 수 있습니다.
따라서 ViewBinding 참조를 계속 유지하면 이미 파괴된 View를 참조하는 문제가 발생할 수 있습니다.
권장 구조는 다음과 같습니다.
class SearchFragment : Fragment() {
private var _binding: FragmentSearchBinding? = null
private val binding: FragmentSearchBinding
get() = requireNotNull(_binding)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentSearchBinding.inflate(
inflater,
container,
false
)
return binding.root
}
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
}이렇게 하면 Fragment View가 파괴될 때 Binding 참조를 해제할 수 있습니다.
단, Binding만 null로 변경한다고 모든 메모리 누수가 해결되는 것은 아닙니다.
View를 참조하는 콜백, Handler, WebView, Adapter, Observer 등도 함께 점검해야 합니다.
16. Fragment에서 Coroutine 사용 시 주의사항
Fragment에서 UI와 관련된 Coroutine을 실행할 때는 생명주기를 고려해야 합니다.
다음과 같은 코드를 생각해 보겠습니다.
lifecycleScope.launch {
delay(3000)
binding.textView.text = "완료"
}Fragment 자체는 살아 있지만 View가 먼저 파괴될 수 있습니다.
이 경우 ViewBinding 접근 시 문제가 발생할 수 있습니다.
UI 작업은 다음과 같이 View 생명주기에 연결하는 것이 좋습니다.
viewLifecycleOwner.lifecycleScope.launch {
delay(3000)
binding.textView.text = "완료"
}이렇게 하면 Fragment의 View 생명주기가 종료될 때 관련 Coroutine이 취소됩니다.
또한 Flow를 수집하는 경우에는 repeatOnLifecycle()을 활용할 수 있습니다.
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(
Lifecycle.State.STARTED
) {
viewModel.uiState.collect { state ->
renderUi(state)
}
}
}이 방식은 View가 STARTED 상태 이상일 때 Flow를 수집하고, 그 아래 상태에서는 수집을 중단하도록 구성할 수 있습니다.
17. Fragment 간 데이터 공유하기
여러 탭에서 같은 데이터를 사용하는 경우 Fragment마다 데이터베이스를 반복 조회할 필요는 없습니다.
예를 들어 홈 화면에서 좋아요를 변경하면 즐겨찾기 화면에도 반영되어야 한다고 가정하겠습니다.
이 경우 Activity 범위의 공유 ViewModel을 사용할 수 있습니다.
class SharedBirdViewModel : ViewModel() {
private val _likedBirdIds =
MutableLiveData<Set<Long>>(emptySet())
val likedBirdIds: LiveData<Set<Long>> =
_likedBirdIds
fun updateLikedBirds(ids: Set<Long>) {
_likedBirdIds.value = ids
}
}각 Fragment에서는 다음과 같이 접근합니다.
private val sharedViewModel: SharedBirdViewModel
by activityViewModels()이렇게 하면 여러 Fragment가 같은 ViewModel 인스턴스를 공유할 수 있습니다.
단, 공유 ViewModel은 화면 간 상태 전달을 위한 도구입니다.
사용자 좋아요 데이터의 영구 저장은 Repository와 SQLite에서 처리해야 합니다.
또한 Fragment의 View가 생성될 때마다 데이터를 중복 요청하지 않도록 ViewModel에서 로딩 상태를 관리하는 것이 좋습니다.
18. ViewPager2와 WebView 메모리 관리
WebView는 일반적인 TextView나 ImageView보다 많은 리소스를 사용할 수 있습니다.
여러 WebView를 동시에 생성하면 메모리 사용량이 증가할 수 있습니다.
따라서 다음과 같은 정책을 고려할 수 있습니다.
| 항목 | 권장 방향 |
|---|---|
| WebView 생성 | 필요한 시점에 생성 |
| 페이지 로딩 | 선택된 페이지부터 실행 |
| 인접 페이지 | 불필요한 선로딩 제한 |
| 상태 유지 | saveState/restoreState 검토 |
| 화면 종료 | WebView 리소스 정리 |
| 데이터 공유 | ViewModel/Repository 활용 |
| 네트워크 요청 | 중복 요청 방지 |
특히 탭이 10개 이상인 앱에서 모든 WebView를 동시에 로딩하는 방식은 피하는 것이 좋습니다.
다만 WebView를 매번 파괴하고 새로 생성하면 페이지 재로딩 비용이 발생할 수 있습니다.
따라서 메모리 사용량과 사용자 경험 사이의 균형을 고려해야 합니다.
19. ViewPager2 디버깅 로그 작성하기
Fragment의 생성 및 생명주기를 확인하려면 로그를 남기는 것이 도움이 됩니다.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d("PagerDebug", "onCreate: ${this::class.simpleName}")
}
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?
) {
super.onViewCreated(view, savedInstanceState)
Log.d("PagerDebug", "onViewCreated: ${this::class.simpleName}")
}
override fun onResume() {
super.onResume()
Log.d("PagerDebug", "onResume: ${this::class.simpleName}")
}
override fun onPause() {
Log.d("PagerDebug", "onPause: ${this::class.simpleName}")
super.onPause()
}
override fun onDestroyView() {
Log.d("PagerDebug", "onDestroyView: ${this::class.simpleName}")
super.onDestroyView()
}이렇게 하면 탭 이동 시 어떤 Fragment가 생성되고 어떤 Fragment가 재사용되는지 확인할 수 있습니다.
다만 로그만으로 메모리 누수 여부를 확정할 수는 없습니다.
메모리 사용량은 Android Studio Memory Profiler 등을 함께 활용해 확인해야 합니다.
20. ViewPager2 성능 테스트 시나리오
실제 프로젝트에서는 다음 상황을 테스트하는 것이 좋습니다.
앱 최초 실행 후 첫 번째 탭 표시
인접 Fragment가 미리 생성되는지 확인
선택하지 않은 WebView의 네트워크 요청 확인
두 번째 탭으로 이동 후 첫 번째 탭 복귀
RecyclerView 스크롤 위치 유지 확인
화면 회전 후 현재 탭 및 상태 복원 확인
앱을 백그라운드로 이동했다가 복귀
개발자 옵션의 '활동 유지 안 함' 환경 테스트
여러 탭을 빠르게 반복 이동
WebView가 포함된 Fragment의 메모리 사용량 확인
특히 WebView를 포함한 Fragment는 단순히 화면이 정상적으로 표시되는지만 확인해서는 안 됩니다.
탭을 반복해서 이동했을 때 불필요한 웹페이지 로딩이나 메모리 증가가 발생하지 않는지도 확인해야 합니다.
21. ViewPager2 최적화 체크리스트
FragmentStateAdapter 사용
TabLayoutMediator 연결 및 해제 관리
onViewCreated()와 실제 페이지 선택 상태 구분
WebView 선로딩 필요 여부 검토
offscreenPageLimit을 무조건 크게 설정하지 않음
동적 페이지에 안정적인 itemId 사용
Fragment ViewBinding을 onDestroyView()에서 해제
UI Coroutine은 viewLifecycleOwner 기준으로 관리
RecyclerView 상태 복원 정책 검토
ViewModel을 이용한 데이터 재조회 최소화
WebView 상태 복원 및 재로딩 정책 수립
Fragment 생성과 폐기 로그 확인
반복 탭 이동 시 메모리 사용량 점검
화면 회전 및 프로세스 복원 테스트
22. 마무리
ViewPager2는 Android 앱에서 탭 화면과 스와이프 기반 UI를 구현할 때 유용한 컴포넌트입니다.
하지만 Fragment와 함께 사용하면 페이지 생성, 생명주기, 상태 복원, 메모리 관리까지 고려해야 합니다.
특히 여러 WebView를 사용하는 앱에서는 다음 원칙이 중요합니다.
onViewCreated()가 호출되었다고 현재 페이지가 선택된 것은 아닙니다.
WebView는 필요한 시점에 로딩하도록 제어합니다.
Fragment의 상태와 View의 상태를 구분해 관리합니다.
RecyclerView의 스크롤 상태는 데이터 로딩 시점과 함께 고려합니다.
동적 페이지에서는 안정적인 ID를 사용합니다.
ViewBinding과 WebView는 View 생명주기에 맞춰 정리합니다.
ViewPager2 최적화는 단순히 페이지 수를 줄이는 작업이 아닙니다.
언제 Fragment를 만들고, 언제 데이터를 로딩하고, 어떤 상태를 유지할지 명확하게 설계하는 것이 핵심입니다.
참고 자료
Android Developers — ViewPager2
https://developer.android.com/guide/navigation/navigation-swipe-view-2Android Developers — FragmentStateAdapter
https://developer.android.com/reference/androidx/viewpager2/adapter/FragmentStateAdapterAndroid Developers — ViewPager2 API
https://developer.android.com/reference/androidx/viewpager2/widget/ViewPager2Android Developers — Fragment 생명주기
https://developer.android.com/guide/fragments/lifecycleAndroid Developers — Fragment 상태 저장
https://developer.android.com/guide/fragments/saving-stateAndroid Developers — RecyclerView 상태 복원
https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.Adapter.StateRestorationPolicyAndroid Developers — WebView
https://developer.android.com/develop/ui/views/layout/webapps/webviewAndroid Developers — Kotlin Flow와 Lifecycle
https://developer.android.com/topic/libraries/architecture/coroutines
태그: Android, Kotlin, ViewPager2, Fragment, FragmentStateAdapter, TabLayout, TabLayoutMediator, WebView, RecyclerView, ViewModel, ViewBinding, Fragment Lifecycle, 메모리 최적화, 화면 상태 복원