Android 개발/Jetpack Compose

썸네일이 상세에서 흰색으로 번쩍이던 걸 Compose 공유 요소 전환으로 잡은 기록

stackD 2026. 10. 8. 18:00
반응형

썸네일이 상세에서 흰색으로 번쩍이던 걸 Compose 공유 요소 전환으로 잡은 기록

 

화면 전환은 완벽하게 매끄러운데 이미지만 흰색으로 번쩍입니다. 진짜 범인은 애니메이션이 아니라 이미지 로더 캐시였어요.

 

지난 봄에 사이드 프로젝트 갤러리 앱을 다듬다가 이 문제로 반나절을 날렸습니다. 리스트에서 썸네일을 탭하면 상세 화면으로 넘어가는데, 전환 자체는 부드러운데 딱 이미지 자리만 0.2~0.3초 하얗게 빠졌다가 다시 채워지더라고요. 제미나이한테 물어봐도 "crossfade를 조정해보라"는 답만 돌아오는데, 정작 원인은 전혀 다른 곳에 있었습니다.

 

Navigation Compose에서 리스트→상세 썸네일이 툭 끊기던 자리

Navigation Compose로 리스트와 상세를 그냥 붙이면, 썸네일이 사라지고 상세 히어로 이미지가 새로 뜨는 짧은 공백이 생깁니다. 플러터(Flutter)의 Hero 전환을 본 눈이면 이 끊김이 유독 거슬리거든요.

 

여기서 헷갈리기 쉬운 게, 증상이 두 개가 섞여 있다는 점입니다. 하나는 이미지 위치·크기가 튀는 것(이건 모디파이어 문제), 다른 하나는 흰색으로 번쩍이는 것(이건 이미지 캐시 문제)이에요. 둘은 원인이 완전히 다른데 같이 나타나니까 하나로 뭉뚱그려 crossfade만 만지다가 시간을 버리게 됩니다.

 

목표는 명확했어요. 썸네일이 상세 이미지로 "날아가듯" 이어지는 전환. 그 중심축이 바로 Compose 공유 요소 전환(Shared Element Transition) 입니다.

 

SharedTransitionLayout 기본 구조와 sharedElement 사용법

일단 최외곽을 SharedTransitionLayout으로 감싸야 합니다. 그래야 그 안에서 SharedTransitionScope가 내려오고, 자식 컴포저블이 Modifier.sharedElement()나 Modifier.sharedBounds()를 쓸 수 있게 됩니다.

 

SharedTransitionLayout {
    // 이 스코프 안에서만 sharedElement / sharedBounds 동작
    NavHost(navController, startDestination = "list") {
        composable("list") { /* 리스트 */ }
        composable("detail/{id}") { /* 상세 */ }
    }
}

 

이미지 요소에는 이렇게 붙입니다. 두 화면 모두 같은 방식으로요.

 

Image(
    // ...
    modifier = Modifier.sharedElement(
        rememberSharedContentState(key = "image-${item.id}"),
        animatedVisibilityScope = animatedVisibilityScope
    )
)

 

여기서 제가 제일 오래 헤맨 함정이 key 매칭이었습니다. 두 화면의 key 문자열이 정확히 같아야 매칭되는데, 오타가 나도 에러 없이 조용히 실패하거든요. 전환이 그냥 안 먹혀서 "왜 안 되지" 하고 한참 봤는데 한쪽만 image_, 다른 쪽은 image-였더라고요. 그래서 동적 데이터는 "image-${item.id}"처럼 id 기반으로 파생하고, key 규칙을 한 곳에 상수 함수로 모아 양쪽에서 불러 쓰는 걸 권합니다.

 

그리고 자주 나오는 오해 하나. 텍스트에 sharedElement()를 쓰면 전환 중에 글자가 깨져 보입니다. sharedElement()는 두 화면 콘텐츠가 시각적으로 똑같을 때(이미지·아이콘) 쓰는 거고, 텍스트나 컨테이너처럼 내용은 다른데 경계만 이어야 하는 요소는 sharedBounds()가 정답이에요. sharedBounds()는 진입·이탈 콘텐츠가 둘 다 보이면서 기본으로 fadeIn/fadeOut이 걸립니다.

 

Navigation Compose에 공유 요소 전환 배선하기

NavHost의 각 목적지 composable 람다에는 AnimatedVisibilityScope가 넘어옵니다. 이걸 그대로 shared element에 전달하면 화면 전환이 자동으로 관리돼요. AnimatedContent 하나로 단일 화면 안에서 바꾸는 경우와 달리, 화면 자체가 이동하는 상황이라 이 배선이 필요합니다.

 

문제는 리스트 아이템과 상세가 서로 다른 컴포저블이라, SharedTransitionScope와 AnimatedVisibilityScope를 하위로 전달하는 배선이 은근히 지저분해진다는 점입니다. 저는 파라미터로 명시적으로 넘기는 쪽을 택했는데, 스코프가 어디서 시작해서 어디까지 내려가는지 경계를 분명히 그어두는 게 나중에 유지보수할 때 덜 고생한다고 봅니다.

 

흰색 깜빡임의 진짜 범인은 Coil 캐시였어요

자, 이제 처음 말한 그 흰색 번쩍임 얘기입니다. 모디파이어를 다 제대로 붙였는데도 이미지만 하얗게 빠지길래 한참 봤더니, 증상에 결정적 단서가 보이더군요. 처음 열 때만 번쩍이고, 뒤로 갔다 다시 들어가면 멀쩡했습니다.

 

이유는 이렇습니다. 리스트 썸네일은 작은 사이즈로 로드돼 있는데, 상세 화면은 더 큰 사이즈를 요청하니까 코일(Coil)이 고해상도로 새로 로드하거든요. 그 재로드되는 찰나에 빈 프레임이 뜨는 거예요. 재진입 때 안 번쩍인 건 이미 큰 이미지가 메모리에 캐싱돼 있어서였습니다.

 

해결은 두 가지를 같이 챙겨야 했어요.

 

  1. memoryCacheKey()와 placeholderMemoryCacheKey()를 shared key에서 파생한 같은 문자열로 지정합니다. 그러면 상세 화면이 리스트가 이미 캐싱해둔 저해상도 비트맵을 placeholder로 즉시 깔아주니까 빈 프레임이 사라집니다.
  2. shared element는 하드웨어 비트맵과 호환이 안 됩니다. 그래서 allowHardware(false)를 반드시 켜줘야 해요. 코일 기본값이 하드웨어 비트맵이라, 이걸 안 끄면 전환 중에 이미지가 이상하게 어긋나는데 한글 자료에선 이 함정을 짚은 글이 잘 없더군요.

 

val request = ImageRequest.Builder(context)
    .data(item.url)
    .memoryCacheKey("image-${item.id}")          // 두 화면 동일 문자열
    .placeholderMemoryCacheKey("image-${item.id}") // 상대 화면 캐시를 placeholder로
    .allowHardware(false)                          // shared element 필수
    .build()

 

이 두 줄 넣으니까 그 하얀 번쩍임이 딱 사라졌습니다. 반나절을 crossfade에 쏟은 게 허무하더라고요. 참고로 코일 2.x와 3.x는 세부 API가 조금 달라서, 쓰는 버전 문서는 한 번 확인하시는 게 좋습니다.

 

공유 요소 전환 절제 기준과 Experimental 리스크

솔직하게 짚고 넘어갈 게 있어요. 이 API는 2024년 구글 I/O에서 소개된 뒤로도 여전히 @ExperimentalSharedTransitionApi로 남아 있습니다. BOM을 올렸다가 시그니처가 바뀌어 컴파일이 깨질 수 있다는 얘기예요. 대형 앱이라면 이 리스크를 감안하고 도입하셔야 합니다.

 

그리고 남발하면 안 됩니다. 단순히 리스트에서 상세로 넘어가는 정도면 AnimatedContent에 crossfade만 얹어도 충분히 저렴하게 처리돼요. 공유 요소 전환은 오버레이 렌더와 lookahead 측정이 겹치면서 저사양 기기에서 프레임이 떨어질 여지가 있습니다.

 

그래서 저는 조건부로 끄는 장치를 같이 둡니다. 저사양이나 접근성 설정이 걸린 상황에서는 전환을 비활성화하는 식으로요. 이건 릴리스별로 제공 여부·이름이 달라질 수 있으니, 조건부 제어가 필요하면 쓰시는 BOM의 공식 카탈로그를 먼저 확인하시는 게 정확합니다.

 

참고로 이 글의 코드는 Compose BOM 2026.04.01(compose-animation 1.11.0) 기준으로 확인했는데, 이 버전에서도 SharedTransitionScope는 여전히 @ExperimentalSharedTransitionApi라 sharedElement() 인자 형태가 BOM 버전마다 달라질 수 있으니, 쓰시는 버전으로 시그니처 한 번 맞춰보시는 걸 권합니다.

 

돌이켜보면 그날 제가 crossfade 값을 열 번쯤 바꿔가며 미리보기를 새로고침하던 게 제일 기억에 남네요. 정작 답은 애니메이션 코드가 아니라 이미지 요청 빌더 안에 있었으니까요. 전환이 아무리 매끄러워 보여도 이미지 자리만 번쩍인다면, 그때는 모디파이어를 더 만지지 말고 이미지 로더 캐시부터 열어보는 게 순서였습니다.

 

반응형
LIST