Android RecyclerView에서 Jetpack Compose LazyColumn으로 이전하기: ViewBinding·ListAdapter 비교
Android RecyclerView에서 Jetpack Compose LazyColumn으로 이전하기

기존 XML 화면의 RecyclerView, ViewBinding, ListAdapter를 Jetpack Compose로 옮길 때 가장 중요한 변화는 어댑터를 새 문법으로 바꾸는 것이 아닙니다. ViewHolder가 화면 상태를 직접 갱신하던 구조를 버리고, 하나의 UI 상태를 Composable 함수에 전달하는 구조로 바꾸는 것이 핵심입니다.
이 글에서는 즐겨찾기 가능한 사용자 목록을 예제로 다음 과정을 한 번에 연결합니다.
ListAdapter와DiffUtil을LazyColumn의items()로 전환ViewModel의StateFlow를 생명주기에 맞게 수집- 안정적인
key로 스크롤 위치와 아이템 상태 보존 - 로딩·빈 목록·오류·정상 목록을 하나의 상태 모델로 처리
- 기존 Fragment에
ComposeView를 넣어 화면을 단계적으로 이전 - Compose UI 테스트로 클릭 결과 확인
이 글은 2026년 7월 Android 공식 문서를 기준으로 작성했습니다. Compose와 AndroidX 버전은 고정 숫자를 복사하기보다 프로젝트의 최신 안정 Compose BOM과 공식 릴리스 정보를 확인하세요.
RecyclerView와 LazyColumn은 무엇이 다른가
둘 다 화면에 보이는 항목 중심으로 UI를 구성하므로 긴 목록에 적합합니다. 그러나 개발자가 관리하는 책임은 크게 다릅니다.
| RecyclerView 기반 구현 | Compose 기반 구현 | 달라지는 책임 |
|---|---|---|
| XML 아이템 레이아웃 | @Composable 아이템 함수 |
레이아웃과 바인딩 로직을 Kotlin 함수로 통합 |
RecyclerView.ViewHolder |
별도 객체 없음 | 현재 상태를 받아 UI를 선언 |
ListAdapter |
LazyColumn |
목록 배치와 지연 구성 담당 |
DiffUtil.ItemCallback |
안정적인 데이터와 key |
목록 변경은 새 상태를 전달해 반영 |
submitList(newList) |
uiState 갱신 |
ViewModel이 단일 상태 흐름을 제공 |
| 클릭 리스너를 ViewHolder에 연결 | 이벤트 람다 전달 | 상태는 위로, 이벤트는 아래에서 위로 전달 |
LazyColumn은 모든 아이템을 한꺼번에 구성하지 않고 보이는 항목을 중심으로 구성합니다. 다만 RecyclerView의 뷰 재활용 구현을 그대로 흉내 내거나 notifyItemChanged()에 해당하는 호출을 찾을 필요는 없습니다. 데이터가 바뀌면 새 상태를 만들고 Compose가 필요한 부분을 다시 그리도록 해야 합니다.
기존 View 화면이 안정적으로 운영되고 있다면 앱 전체를 한 번에 바꾸지 않아도 됩니다. Android는 View와 Compose를 함께 사용할 수 있도록 상호 운용 API를 제공하며, 화면이나 컴포넌트 단위의 점진적 이전을 권장합니다.
예제 구조와 의존성
예제는 ViewModel → UiState → Route → Screen → Row 방향으로 데이터를 전달합니다.
UserListViewModel
│ StateFlow<UserListUiState>
▼
UserListRoute
│ collectAsStateWithLifecycle()
▼
UserListScreen
│ users + event lambdas
▼
LazyColumn → UserRow
앱 모듈의 build.gradle.kts에는 Compose, Material 3, Lifecycle 연동 의존성이 필요합니다. 아래 예시는 Version Catalog를 사용하는 프로젝트를 기준으로 합니다.
android {
buildFeatures {
compose = true
}
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation("androidx.activity:activity-compose")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-runtime-compose")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose")
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
Version Catalog를 사용하지 않는다면 공식 Compose 설정 문서에서 현재 안정 BOM 버전을 확인해 androidx.compose:compose-bom 좌표에 적용하세요. BOM을 사용하면 Compose 라이브러리마다 서로 다른 버전을 직접 맞추는 실수를 줄일 수 있습니다.
1. 변경 가능한 아이템 대신 UI 상태를 정의한다
기존 RecyclerView 코드에서는 아이템 객체의 isFavorite 값을 직접 바꾼 뒤 notifyItemChanged()를 호출하기 쉽습니다. Compose에서는 불변 데이터를 복사해 새 목록을 만드는 편이 상태 흐름을 추적하고 테스트하기 좋습니다.
import androidx.compose.runtime.Immutable
@Immutable
data class UserUiModel(
val id: Long,
val name: String,
val description: String,
val isFavorite: Boolean = false,
)
data class UserListUiState(
val isLoading: Boolean = false,
val users: List<UserUiModel> = emptyList(),
val errorMessage: String? = null,
)
id는 화면에 표시하는 순서와 별개의 영구 식별자여야 합니다. API나 데이터베이스의 기본 키가 있다면 그것을 사용하고, 목록 위치인 index를 ID처럼 사용하지 마세요.
2. ViewModel에서 StateFlow와 이벤트를 관리한다
Composable 안에서 비즈니스 데이터를 직접 변경하지 않고 ViewModel이 상태의 소유자가 되도록 합니다.
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class UserListViewModel : ViewModel() {
private val _uiState = MutableStateFlow(
UserListUiState(
users = listOf(
UserUiModel(1, "윤서준", "Android Developer"),
UserUiModel(2, "김하린", "Product Designer"),
UserUiModel(3, "박도윤", "Backend Developer"),
),
),
)
val uiState: StateFlow<UserListUiState> = _uiState.asStateFlow()
fun toggleFavorite(userId: Long) {
_uiState.update { current ->
current.copy(
users = current.users.map { user ->
if (user.id == userId) {
user.copy(isFavorite = !user.isFavorite)
} else {
user
}
},
)
}
}
fun retry() {
// 실제 프로젝트에서는 repository 호출을 viewModelScope에서 다시 실행합니다.
}
}
MutableStateFlow는 ViewModel 내부에 숨기고 외부에는 읽기 전용 StateFlow만 노출합니다. 네트워크 요청이 필요하다면 viewModelScope에서 실행하고, 성공·실패·재시도 결과를 모두 UserListUiState에 반영하세요.
3. Route와 Screen을 분리한다
Route는 ViewModel과 생명주기를 알고, Screen은 전달받은 값만 그리게 분리하면 Preview와 UI 테스트가 쉬워집니다.
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun UserListRoute(
viewModel: UserListViewModel = viewModel(),
) {
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
UserListScreen(
uiState = uiState,
onFavoriteClick = viewModel::toggleFavorite,
onRetryClick = viewModel::retry,
)
}
Android 앱에서 Flow를 Compose 상태로 바꿀 때는 collectAsStateWithLifecycle()을 사용하면 화면의 Lifecycle에 맞춰 수집을 시작하고 중지할 수 있습니다.
4. LazyColumn에 안정적인 key를 지정한다
이제 Adapter, ViewHolder, DiffUtil 없이 목록을 작성합니다.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun UserListScreen(
uiState: UserListUiState,
onFavoriteClick: (Long) -> Unit,
onRetryClick: () -> Unit,
modifier: Modifier = Modifier,
) {
when {
uiState.isLoading -> LoadingContent(modifier)
uiState.errorMessage != null -> ErrorContent(
message = uiState.errorMessage,
onRetryClick = onRetryClick,
modifier = modifier,
)
uiState.users.isEmpty() -> EmptyContent(modifier)
else -> LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
items = uiState.users,
key = { user -> user.id },
contentType = { "user" },
) { user ->
UserRow(
user = user,
onFavoriteClick = { onFavoriteClick(user.id) },
)
}
}
}
}
key를 생략하면 기본적으로 아이템 위치가 식별에 사용됩니다. 정렬, 삽입, 삭제가 발생하는 목록에서 위치를 key로 사용하면 아이템 내부 상태가 다른 행으로 이동하거나 스크롤 복원이 어색해질 수 있습니다. 직렬화 가능한 안정적 ID를 지정하면 아이템 위치가 바뀌어도 Compose가 같은 항목임을 식별할 수 있습니다.
contentType은 서로 다른 형태의 아이템이 섞인 목록에서 특히 유용합니다. 이 예제는 한 종류뿐이지만, 헤더·광고·사용자 행이 함께 있다면 각 타입을 구분해 주세요.
5. 아이템 Composable은 상태와 이벤트만 받는다
행 UI는 ViewModel을 직접 참조하지 않고 필요한 데이터와 콜백만 받습니다.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun UserRow(
user: UserUiModel,
onFavoriteClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(modifier = modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = user.name,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = user.description,
style = MaterialTheme.typography.bodyMedium,
)
}
IconButton(onClick = onFavoriteClick) {
Icon(
imageVector = if (user.isFavorite) {
Icons.Filled.Favorite
} else {
Icons.Outlined.FavoriteBorder
},
contentDescription = if (user.isFavorite) {
"즐겨찾기 해제"
} else {
"즐겨찾기 추가"
},
)
}
}
}
}
아이콘만 있는 버튼에는 contentDescription을 제공해야 TalkBack 사용자가 기능을 이해할 수 있습니다. 행 전체 클릭과 즐겨찾기 버튼이 서로 다른 동작이라면 중첩 클릭 영역이 혼란스럽지 않도록 의미와 터치 영역도 함께 점검하세요.
로딩·오류·빈 화면은 다음처럼 각각 작은 Composable로 분리할 수 있습니다.
@Composable
private fun LoadingContent(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
@Composable
private fun EmptyContent(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text("표시할 사용자가 없습니다.")
}
}
@Composable
private fun ErrorContent(
message: String,
onRetryClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(message)
Button(onClick = onRetryClick) {
Text("다시 시도")
}
}
}
예제 파일을 나눠 작성한다면 각 코드에 필요한 import를 IDE의 자동 import로 정리하고 린트도 실행하는 것이 좋습니다.
기존 Fragment에서 점진적으로 이전하기

가장 안전한 순서는 작은 컴포넌트 또는 독립된 화면부터 Compose로 옮기고, Navigation·테마·공통 컴포넌트를 차례로 확장하는 것입니다.
기존 Fragment의 XML을 제거하고 화면 전체를 Compose로 바꾸는 경우 ComposeView를 반환할 수 있습니다.
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
class UserListFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed,
)
setContent {
AppTheme {
UserListRoute()
}
}
}
}
}
Fragment 안의 ComposeView에는 View 트리의 Lifecycle이 파괴될 때 Composition을 정리하는 전략을 지정해야 합니다. 반대로 Compose 화면 안에 아직 옮기지 못한 기존 View가 있다면 AndroidView를 사용할 수 있습니다. 하지만 전체 RecyclerView를 장기간 AndroidView로 감싸기보다, 이전하기 어려운 작은 View만 경계에 남기는 편이 상태 관리가 단순합니다.
점진적 이전 순서는 다음처럼 잡을 수 있습니다.
- 앱의 기존 테마 색상과 타이포그래피를 Compose Theme에 연결합니다.
- 재사용 범위가 작은 아이템 또는 독립 화면을 Compose로 바꿉니다.
- Fragment의 ViewModel과 상태 흐름은 유지하고 렌더링 부분만 교체합니다.
- UI 테스트와 접근성 검사를 통과한 뒤 다음 화면으로 범위를 넓힙니다.
- 더 이상 참조되지 않는 Adapter, ViewHolder, XML을 제거합니다.
스크롤 위치와 성능 점검
LazyColumn의 스크롤 상태를 읽거나 제어하려면 rememberLazyListState()를 사용합니다.
val listState = rememberLazyListState()
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
LazyColumn(
state = listState,
) {
items(
items = uiState.users,
key = { it.id },
) { user ->
UserRow(
user = user,
onFavoriteClick = { onFavoriteClick(user.id) },
)
}
}
스크롤할 때마다 바뀌는 값을 단순 조건으로 UI 전체에서 읽으면 불필요한 재구성이 늘 수 있습니다. 위처럼 특정 경계를 넘었는지만 필요할 때는 derivedStateOf를 고려할 수 있습니다.
성능 문제가 보이면 추측으로 remember를 추가하기 전에 다음 항목부터 확인하세요.
- Release 빌드에서 실제 기기로 측정했는가
- 아이템에 안정적인
key를 제공했는가 - ViewModel이 같은 내용의 거대한 새 목록을 지나치게 자주 발행하는가
- 스크롤 중 변하는 상태를 목록 전체가 읽고 있는가
- 이미지 크기와 로딩 방식이 적절한가
- 한 아이템 안에서 무거운 계산이나 객체 생성을 반복하는가
Compose는 입력 상태를 기반으로 UI를 다시 계산합니다. 재구성 자체를 무조건 없애려 하지 말고, 비싼 작업이 반복되는 경로를 측정해 줄이는 것이 중요합니다.
Compose UI 테스트
상태 없는 UserListScreen은 가짜 ViewModel 없이 바로 테스트할 수 있습니다.
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class UserListScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun favoriteClick_sendsUserId() {
var clickedId: Long? = null
composeTestRule.setContent {
UserListScreen(
uiState = UserListUiState(
users = listOf(
UserUiModel(
id = 10,
name = "윤서준",
description = "Android Developer",
),
),
),
onFavoriteClick = { clickedId = it },
onRetryClick = {},
)
}
composeTestRule.onNodeWithText("윤서준").assertIsDisplayed()
composeTestRule
.onNodeWithContentDescription("즐겨찾기 추가")
.performClick()
assertEquals(10L, clickedId)
}
}
이 테스트는 UI가 사용자 이름을 표시하고, 즐겨찾기 클릭 시 올바른 ID를 이벤트로 내보내는지 확인합니다. ViewModel 테스트에서는 별도로 toggleFavorite() 호출 후 StateFlow의 값이 바뀌는지 검사하세요.
자주 생기는 문제
목록 아이템을 눌렀는데 화면이 바뀌지 않는다
기존 객체의 프로퍼티만 내부에서 변경하면 Compose가 새 상태로 인식하지 못할 수 있습니다. copy()로 새 아이템과 새 목록을 만들고 StateFlow에 다시 할당하세요.
정렬 후 아이템 내부 상태가 다른 행으로 이동한다
items()에 안정적인 key가 없거나 index를 key로 사용했는지 확인하세요. 서버 또는 데이터베이스의 영구 ID를 사용해야 합니다.
화면을 벗어나도 Flow 수집이 계속된다
Android UI에서는 collectAsStateWithLifecycle()을 사용하고, View 시스템에서 직접 수집한다면 repeatOnLifecycle()로 수집 범위를 제한하세요.
Fragment를 왕복하면 Compose 상태가 이상하다
ComposeView의 ViewCompositionStrategy가 Fragment View Lifecycle과 맞는지 확인하세요. ViewModel이 Fragment 범위인지 Navigation Graph 범위인지도 의도와 일치해야 합니다.
RecyclerView의 DiffUtil 코드는 어디로 옮겨야 하나
대부분은 옮기지 않습니다. ViewModel이 불변 목록을 발행하고, LazyColumn이 안정적인 key로 아이템을 식별하게 합니다. 데이터가 매우 크다면 페이징과 저장소 계층의 데이터 흐름을 별도로 설계하세요.
마이그레이션 체크리스트
- [ ] 각 아이템에 위치가 아닌 안정적인 ID가 있다.
- [ ]
Adapter가 하던 상태 변경을 ViewModel 이벤트로 옮겼다. - [ ] UI 상태가 로딩·성공·빈 목록·오류를 모두 표현한다.
- [ ]
StateFlow를collectAsStateWithLifecycle()로 수집한다. - [ ]
LazyColumn.items()에key를 지정했다. - [ ] 아이템 Composable이 ViewModel 대신 값과 이벤트 람다를 받는다.
- [ ] 아이콘 버튼에 접근성 설명이 있다.
- [ ] Fragment의
ComposeView정리 전략을 지정했다. - [ ] 정렬·삽입·삭제·화면 회전·뒤로 가기 후 상태를 확인했다.
- [ ] UI 테스트와 ViewModel 단위 테스트를 추가했다.
- [ ] Release 빌드와 실제 기기에서 스크롤 성능을 측정했다.
정리
RecyclerView에서 LazyColumn으로 이전할 때의 대응 관계는 단순합니다.
XML + ViewHolder → Composable
ListAdapter → LazyColumn
DiffUtil + submitList → 불변 UiState + stable key
ViewBinding 갱신 → 상태를 입력받는 선언형 UI
클릭 리스너 → 이벤트 람다 → ViewModel
하지만 실제 품질을 결정하는 것은 LazyColumn 문법보다 상태의 소유권입니다. ViewModel이 단일 UI 상태를 제공하고, 화면은 상태를 그리며, 사용자 이벤트만 위로 전달하도록 만들면 목록 정렬·필터·새로고침·오류 처리까지 자연스럽게 확장할 수 있습니다.
XML 기반 구현이 필요한 경우에는 기존 글인 Android RecyclerView Kotlin 예제: ViewBinding과 ListAdapter로 목록 만들기에서 완성형 코드를 확인할 수 있습니다.
댓글
댓글 쓰기