안녕하세요! Android 개발자 한민재입니다. 이번에는 과제전형을 진행하면서 Paging3와 Compose의 LazyVerticalGrid를 함께 사용하면서 발견한 정말 사소한 문제와 해결 과정을 공유하려고 해요.
🤔 어떤 문제가 있었나요?
과제에서는 Picsum API를 사용해서 자유롭게 뷰를 구현하는 것이 목표였어요. API에 페이징이 있는 것을 발견하고 Paging3를 적용한 이미지 그리드를 구현하던 중, 앱 초기 실행 시 의도치 않게 많은 페이지가 한 번에 요청되는 현상이 발생했어요.
문제가 있던 코드예요
@Composable
fun PhotoGrid(
photosPagingData: LazyPagingItems<PhotoDto>,
gridState: LazyGridState,
modifier: Modifier = Modifier
) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
state = gridState,
modifier = modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(
count = photosPagingData.itemCount,
key = { index -> photosPagingData[index]?.id ?: "" }
) { index ->
photosPagingData[index]?.let { photo ->
AuthorImageItem(
imageUrl = photo.downloadUrl,
author = photo.author
)
}
}
}
}
토막지식: Compose에서 key를 설정하는 이유
Compose에서 key를 설정하는 주된 이유는 다음과 같아요:
- Recomposition 최적화
- 각 아이템을 고유하게 식별할 수 있어 불필요한 리컴포지션을 방지해요
- 예를 들어, 리스트의 중간에 아이템이 삽입되었을 때 모든 아이템을 다시 그리지 않고 필요한 부분만 업데이트할 수 있어요
- 상태 보존
- 각 아이템의 로컬 상태를 key를 통해 유지할 수 있어요
- 예를 들어, 체크박스의 체크 상태나 텍스트 필드의 입력 상태가 리컴포지션 후에도 유지돼요
- 애니메이션 처리
- key를 기반으로 아이템의 추가/삭제/이동에 대한 애니메이션을 자연스럽게 처리할 수 있어요
- 리스트 아이템의 위치가 변경될 때 어떤 아이템이 어디로 이동했는지 추적할 수 있어요
그래서 위의 코드를 실행했더니 초기 로드시 아래처럼 로그가 출력되었어요.
I --> GET <https://picsum.photos/v2/list?page=1&limit=30>
I --> GET <https://picsum.photos/v2/list?page=2&limit=30>
I --> GET <https://picsum.photos/v2/list?page=3&limit=30>
...
I --> GET <https://picsum.photos/v2/list?page=10&limit=30>
초기 로드 시 1페이지부터 10페이지까지 연속적으로 요청이 발생했어요.
실행 중에 과도한 렉이 발생하거나 OOM이 발생하진 않았지만 과도한 네트워크 요청은 네트워크 요금도 과하게 부과될 수도 있기에 이 문제를 해결하려 했어요.
🔍 원인을 파헤쳐봤어요
pagingConfig가 잘못되었을까?
첫번째로는 pagingConfig의 설정이 잘못된 줄 알았어요.
class PhotoRepositoryImpl @Inject constructor(
private val photoService: PhotoService
) : PhotoRepository {
override fun getPhotoStream(): Flow<PagingData<PhotoDto>> {
return Pager(
config = PagingConfig(
pageSize = PAGE_SIZE,
initialLoadSize = PAGE_SIZE * 2,
prefetchDistance = 7,
enablePlaceholders = false
),
pagingSourceFactory = { PhotoPagingSource(photoService) }
).flow
}
companion object {
private const val PAGE_SIZE = 15
}
}
이곳에서 size, initialSize, prefetch 모두 수정해 보면서 테스트했지만 원인이 해결되지 않았어요. 때문에 저는 뷰에서는 보이지 않지만 모든 요소에 대해서 접근하고 있다고 가정한 뒤에 key값에 대해서 분석했어요.
key값의 설정이 의심된다!
LazyPagingItems의 내부 구현을 살펴보면, 문제의 원인을 찾을 수 있었어요
// LazyPagingItems의 get 연산자 내부 구현
public operator fun get(index: Int): T? {
// 아이템 접근 시마다 load state를 체크하고 필요한 경우 새로운 페이지 로드를 트리거해요
peek(index) // Triggers loading if needed
return storage[index]
}
여기서 두 가지 중요한 점을 발견했어요
- get 연산자의 동작
- get 메서드는 호출될 때마다 Paging 라이브러리에 해당 아이템 접근을 알려요
- 이는 prefetchDistance 설정과 관계없이 추가 페이지 로드를 트리거하게 돼요
- key 람다의 특성
- key 람다는 화면에 보이지 않는 아이템을 포함한 모든 아이템에 대해 즉시 호출돼요
- 이로 인해 실제로 화면에 표시되지 않는 아이템들에 대해서도 불필요한 페이지 요청이 발생했어요
💡 어떻게 해결했을까요?
Paging3 라이브러리에서 제공하는 itemKey 함수를 사용하여 문제를 해결했어요.
개선된 코드예요
@Composable
fun PhotoGrid(
photosPagingData: LazyPagingItems<PhotoDto>,
gridState: LazyGridState,
modifier: Modifier = Modifier
) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
state = gridState,
modifier = modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(
count = photosPagingData.itemCount,
key = photosPagingData.itemKey { it.id }
) { index ->
photosPagingData[index]?.let { photo ->
AuthorImageItem(
imageUrl = photo.downloadUrl,
author = photo.author
)
}
}
}
}
itemKey 함수의 내부 구현을 보면
fun <T : Any> LazyPagingItems<T>.itemKey(
key: (T) -> Any
): (Int) -> Any {
return { index ->
// 이미 로드된 아이템에 대해서만 키를 생성해요
// 아직 로드되지 않은 아이템에 대해서는 임시 키를 사용해요
peek(index)?.let(key) ?: index
}
}
해결 원리는 아래와 같아요
- itemKey 함수의 동작 방식
- 이미 로드된 아이템에 대해서만 key 람다를 호출해요
- 아직 로드되지 않은 아이템에 대해서는 임시로 index를 키로 사용해요
- 이를 통해 불필요한 페이지 로드를 방지할 수 있어요
- 내부 구현의 차이
- peek 메서드는 아이템을 안전하게 참조만 하고 추가 로드를 트리거하지 않아요
- 이를 통해 실제로 필요한 데이터만 로드할 수 있게 됐어요
📈 개선 결과
- 초기 로드 최적화
- 이전: 약 10페이지가 동시에 요청됐어요
- 개선: prefetchDistance 설정에 따라 1-2페이지만 요청돼요
- 네트워크 트래픽 감소
- 불필요한 API 호출이 제거됐어요
- 사용자 데이터 사용량이 줄었어요
- 메모리 사용 최적화
- 필요한 데이터만 메모리에 로드돼요
- 가비지 컬렉션 부담이 줄었어요
📝 정리해볼까요?
- 문제의 본질
- key 파라미터에서 get 연산자를 직접 사용하면 과도한 페이지 로드가 발생해요
- 이는 모든 인덱스에 대해 get 연산자가 호출되기 때문이에요
- 해결 방법
- Paging3의 itemKey 함수를 사용했어요
- 이미 로드된 아이템에 대해서만 키를 생성해요
- 아직 로드되지 않은 아이템은 임시 키를 사용해요
- 라이브러리 사용 전에 내부 구현을 잘 분석하자
- Paging 라이브러리의 공식 API를 활용하는 게 중요해요
- 내부 구현을 이해하면 더 효율적인 코드를 작성할 수 있어요
📚 참고한 자료들
더 좋은 해결 방법이나 잘못된 정보 혹은 의견이 있으시다면 댓글로 공유해 주시면 감사하겠습니다 :)
'Android > Jetpack' 카테고리의 다른 글
[Android Jetpack] 이미지 압축 최적화하기: ImageDecoder 도입 (0) | 2025.02.20 |
---|