Compose LazyColumn Coil Image 다시 로드 되는 문제

easyhooon·2025년 3월 5일

TLDR;

LazyColumn 내에서 아이템 내에 이미지를 넣어줄 때, 너비(width)뿐만 아니라, 높이(height) 또한 빼먹지 않고 지정해주도록 하자.

서론

LazyColumn 을 통해 이미지가 포함된 아이템 리스트를 구성할때, 이미 로드된 아이템을 확인하기 위해 위로 스크롤 하였을 때 이미지가 다시 로드되는 문제의 원인과 이를 해결하는 방법에 대해 작성해보도록 하겠다.

본론

LazyColumn 을 통해 가로 길이는 화면 내 아이템의 전체 너비로 고정하고, 세로 길이는 원본 비율을 해치지 않도록 설정되도록 하여, 유동적인 높이의 이미지를 가진 작품 리스트 화면을 구성하던 중, 의도되지 않는 동작이 수행되는 것을 확인할 수 있었다.

// 아이템 내에 이미지 
NetworkImage(
    imageUrl = artwork.artworkImageUrl,
    contentDescription = "${artwork.title} by ${artwork.artist.name}",
    modifier = Modifier.fillMaxWidth(),
    contentScale = ContentScale.FillWidth,
)

// NetworkImage 구현체
@Composable
fun NetworkImage(
    imageUrl: String?,
    contentDescription: String?,
    modifier: Modifier = Modifier,
    loadingImage: Painter = painterResource(id = R.drawable.placeholder),
    failureImage: Painter = painterResource(id = R.drawable.placeholder),
    contentScale: ContentScale = ContentScale.Crop,
) {
    val context = LocalContext.current

    if (LocalInspectionMode.current) {
        Image(
            painter = loadingImage,
            contentDescription = "Image for Preview",
            modifier = modifier,
        )
    } else {
        AsyncImage(
            model = ImageRequest.Builder(context)
                .data(imageUrl)
                .crossfade(true)
                .build(),
            contentDescription = contentDescription,
            modifier = modifier,
            error = failureImage,
            contentScale = contentScale,
            onError = { exception ->
                Timber.e("${exception.result.throwable}")
            },
        )
    }
}

문제 발생

https://youtube.com/shorts/goYc5Yl-Od4

이미 한번 로드된 이미지 및 아이템이 다시 로드되는 현상이 발생하였다.
내가 알기론, Coil 을 통해 한번 로드된 이미지는 메모리 및 디스크에 캐싱되기에, 위와 같은 현상은 발생하지 않는 것으로 알고 있다...여태 경험 해본적이 없는...

또한 좀 더 디테일하게 확인해보면, 첫 로드시 화면에 보이는 작품 아이템이 무한의 높이로 설정이 되었다가, 이미지 로드 후 그 높이가 고정됨을 확인할 수 있었다. 또한 위로 스크롤할때는 한번씩 튕기는 듯한 현상도 존재한다. 매우 치명적 +_+...

문제 해결?

Coil Image 캐싱 문제인가?

@HiltAndroidApp
class ZiineApplication : Application(), ImageLoaderFactory {
    override fun newImageLoader(): ImageLoader {
        return ImageLoader.Builder(this)
            .diskCache {
                DiskCache.Builder()
                    .directory(cacheDir.resolve("image_cache"))
                    .maxSizeBytes(10 * 1024 * 1024)
                    .build()
            }
            .logger(DebugLogger())
            .respectCacheHeaders(false)
            .build()
    }
}

Coil Image 캐싱과 관련 코드는 아래의 글들을 참고하여 설정 해주었다.
Jetpack Compose Coil 이미지 캐싱, 잘 하고 계신가요?
Jetpack Compose Coil 캐싱, 어떻게 하고 있을까요?- 디스크 캐싱

위로 스크롤하였을 때, 메모리 캐싱된 이미지가 성공적으로 로드되었다는 로그가 찍히는 걸로 보아, 이미지가 캐싱되지 않아서 발생하는 문제는 아니라는 것을 알 수 있었다... 그럼 뭐지?

문제 해결!

앱의 첫 출시 MVP 에서는 작품 등록시 사진의 어떠한 크롭 기능 및 편집 기능을 지원하지 않도록 결정되어, 안내를 통해 사진을 미리 정방형의 비율(1:1)로 편집 후 올려달라는 안내 메세지를 추가하였다.

따라서, 등록되는 작품내 이미지의 높이 역시 너비와 거의 같다고 가정할 수 있게되어, contentScale 옵션을 수정하고, aspectRatio Modifier 를 통해 너비와 같게 높이가 설정되도록 하는 코드를 추가 해주었다.

NetworkImage(
    imageUrl = artwork.artworkImageUrl,
    contentDescription = "${artwork.title} by ${artwork.artist.name}",
    modifier = Modifier
        .fillMaxWidth()
        .aspectRatio(1f),
)

그랬더니...

https://youtube.com/shorts/aujoshQLO8o

다음과 같이 기존의 발생했던 문제가 해결되어, 이미 로드된 이미지가 다시 로드되는 듯한 현상은 발생하지 않았다!(이미지가 튕기는 듯한 현상도 사라짐!)

결론

LazyColumn 은 RecyclerView 와 다르게 화면에 보이는 뷰만 스크롤시 화면 밖으로 사라진 뷰를 재사용하지 않고, 삭제 및 새로 생기는 뷰는 새로 생성한다.

정확히는 유사한 UI 구조를 가진 항목의 LayoutNode(UI 요소의 레이아웃 정보를 담고 있는 객체)를 재사용하여 레이아웃 계산 비용을 최소화한다. 만약 크키나 속성이 동일하다면, 재측정 과정을 생략할 수 있다.
더욱 자세한 내용은 아래의 글을 참고
LazyColumn 작동 방식 이해하기

LazyColumn 내에 아이템의 높이가 NetworkImage Composable 에 의해 고정되지(정해지지) 않았기 때문에, 위로 스크롤을 하여 이미 로드된 이미지를 가진 아이템을 삭제 후 다시 생성할때, 높이를 재측정하는 과정을 매번 거쳐야 했고, 따라서 이미지가 다시 로드되는 듯한 현상이 발생한 것이었다!

또한 LazyColumn 은 초기 측정시 무한의 높이 제약 조건을 사용하므로 문제가 되었던 부분 중, 무한의 높이로 설정된 이후 이미지 로드 완료시 높이가 고정되는 현상은 의도된 동작임을 알 수 있었다.
(placeholder 또는 초기 크기를 지정해야하는 이유)

기존의 기획을 유지한채로 문제를 해결하려면?

만약 기존의 스펙처럼 작품의 원본 이미지 비율을 살리는 방향으로 구현한다면, aspectRatio Modifier 를 통해 비율을 고정하는 식의 해결방법은 적절하지 않을 것이다. 그렇다면 어떤 해결 방법을 사용 해야할까?

placeholder 를 사용하기엔, 각 아이템의 이미지의 너비 대비 높이의 비율이 매번 다르므로, 일정한 높이를 가진 placeholder 를 넣어주는 것은 적합하지 않다.

높이를 지정해주지 않은 것이 문제라면 wrapContentHeight 와 같은 Modifier 등을 통해 적용해주면 되지 않나 싶었는데, 적용 이후에도 높이 재측정으로 인한 위로 스크롤시 튕김 현상은 그대로 였다.

1. ContentScale 을 Fit 으로 설정

ContentScale.Fit은 이미지 전체가 보이도록 가장 큰 차원을 기준으로 크기를 일관되게 조정한다.

테스트 해본 결과, 이 방식이 LazyColumn 의 아이템의 높이를 modifier 를 통해 지정해주지 않아도, 아이템의 크기가 다시 측정되어 발생하는 문제들이 나타나지 않았다.

다만 높이를 지정하지 않았기에, 초기 로드시 이미지의 높이가 무한으로 설정되고 이미지 로드 완료 이후 높이가 고정된다.

2. 서버로 부터 이미지의 높이와 너비를 받아오기

또 다른 해결법을 공유해주신 junyong008님 감사합니다.

API 를 통해 이미지 url 을 받아올때, 이미지의 높이와 너비를 같이 받아올 수 있다면, modifier 를 통해 이미지의 높이, 너비 혹은, 비율 등을 지정하여 로드할 수 있기 때문에, 매번 그 크기를 재측정하는 문제는 발생하지 않을 것이다.

클라이언트에서 이미지 url 을 받아올 때, 이 이미지의 높이 및 너비를 측정하는 방식이 불가능하진 않겠지만, 오버헤드가 있을 수 있기 때문에, 서버에 이미지를 저장할때 DB 에 넣어두고 이를 바로바로 꺼내오는게 성능적으로 이점을 챙길 수 있을 듯 하다.

어떤 문제를 클라이언트단에서만 서버팀과 같이 협력한다면, 아주 간단하게 해결할 수 있다. 이 케이스도 여기에 해당하는 듯 하다.

뿐만 아니라, 서버와 협력을 통해 이미지의 평균 색상 등을 서버에서 저장하여, 이를 같이 받아온다면 스켈레톤 이미지에 이 색상을 적용할 수 도 있을 듯하다. 헤이딜러 처럼

Paging3 을 사용한다면?

enablePlaceholders 옵션을 true 로 설정하면, 가변적인 높이를 가진 아이템들에 대해 대응을 할 수 있다.

핀터레스트와 같이 여러개의 열을 가진 리스트의 경우, enablePlaceholders 옵션을 false 로 설정할 경우, 항목의 위치가 섞이는 등의 문제가 있는데, enablePlaceholders 를 활성화하여 해결할 수 있다.

reference)
https://medium.com/@csh153/jetpack-compose-coil-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%BA%90%EC%8B%B1-%EC%9E%98-%ED%95%98%EA%B3%A0-%EA%B3%84%EC%8B%A0%EA%B0%80%EC%9A%94-806252d9c73a
https://medium.com/@csh153/jetpack-compose-coil-%EC%BA%90%EC%8B%B1-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%98%EA%B3%A0-%EC%9E%88%EC%9D%84%EA%B9%8C%EC%9A%94-%EB%94%94%EC%8A%A4%ED%81%AC-%EC%BA%90%EC%8B%B1-b6595c05bd5a
https://developer.android.com/develop/ui/compose/lists?hl=ko
https://medium.com/@wisemuji/lazycolumn-%EC%9E%91%EB%8F%99-%EB%B0%A9%EC%8B%9D-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-0a5433f31306
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:paging/paging-common/src/commonMain/kotlin/androidx/paging/PagingConfig.kt?q=enablePlaceholders&ss=androidx%2Fplatform%2Fframeworks%2Fsupport
https://velog.io/@vov3616/%EC%82%BD%EC%A7%88-paging-3-remoteMediator-%EA%B9%9C%EB%B9%A1%EC%9E%84%ED%98%84%EC%83%81-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글