Google I/O Extended 2024 Korea Android에 다녀온 이후로, Compose의 성능 향상에 더 관심을 갖게 되었다. 특히 지금까지 Recomposition은 크게 신경쓰지 않고 눈으로 봤을 때 성능적인 문제가 크게 느껴지지 않는다면 신경쓰지 않고 넘기곤 하였는데, 이 참에 Recomposition에 관한 이슈를 찾아보고자 하였다.
@Composable
fun CryptoList(
modifier: Modifier = Modifier,
tickers: List<TickerModel>,
listState: LazyListState = rememberLazyListState()
) {
val context = LocalContext.current
LazyColumn(
modifier = modifier,
state = listState
) {
items(
items = tickers,
) {
CryptoListItem(
modifier = Modifier
.fillMaxWidth()
.clickable {
val intent = Intent(context, TradeActivity::class.java).apply {
putExtra(TICKER, it)
}
context.startActivity(intent)
}
.padding(vertical = 16.dp),
ticker = it
)
}
}
}
약 100개 이상의 아이템으로 구성되어 있는 LazyColumn. Upbit API와 Flow를 이용하여 개별 코인의 정보를 받아온다. 받아온 정보는 Map을 이용하여 키 값에 해당하는 value만 값을 업데이트하므로, 해당 value 이외의 다른 value들에는 값이나 index의 변화는 없다.
즉, 원래의 예상대로라면 LazyColumn에서 해당하는 item이외의 것들은 Recomposition이 발생하지 않아야 했다.
하지만 무수히 많이 쌓인 Recomposition들….
아무래도 Recomposition에 대한 이해가 너무 부족했던 것 같아서, 기초부터 다시 시작하였다.
기존에는 Data Layer에서 API Response, Domain Layer에서 실제 사용할 Model을 정의해둔 뒤, Presentation Layer에서는 Domain Layer의 Model을 사용하였다.
그런데 이렇게 하니 Compose의 @Immutable 어노테이션을 사용할 수 없었다. 따라서 Presentation Layer에 새로이 Model을 정의하기로 결정하였다.
@Parcelize
@Immutable
data class TickerModel(
val code: String, // 마켓 코드 ex) KRW-BTC 웹소켓 전용
val signature: String, // 코인 시그니처 ex) BTC, ETH, XRP
val type: MarketType, // 마켓 구분 코드 KRW(원화), BTC(비트코인), USDT(테더)
val openingPrice: Double, // 시가
val highPrice: Double, // 고가
val lowPrice: Double, // 저가
val tradePrice: Double, // 현재가
val prevClosingPrice: Double, // 전일 종가
val change: ChangeType, // 전일 대비 RISE : 상승 EVEN : 보합 FALL : 하락
val changePrice: Double, // 부호 없는 전일 대비 값
val signedChangePrice: Double, // 전일 대비 값
val changeRate: Double, // 부호 없는 전일 대비 등락율
val signedChangeRate: Double, // 전일 대비 등락율
val tradeVolume: Double, // 가장 최근 거래량
val accTradeVolume: Double, // 누적 거래량 (UTC 0시 기준)
val accTradeVolume24h: Double, // 24시간 누적 거래량
val accTradePrice: Double, // 누적 거래대금 (UTC 0시 기준)
val accTradePrice24h: Double, // 24시간 누적 거래대금
val tradeTime: String, // 최근 거래 시각(UTC) - HHmmss
val tradeTimestamp: Long, // 최근 거래 타임스탬프
val tradeDate: String, // 최근 거래 일자(UTC) - yyyyMMdd
val highest52WeekPrice: Double, // 52주 최고가
val highest52WeekDate: String, // 52주 최고가 달성일 yyyy-MM-dd
val lowest52WeekPrice: Double, // 52주 최저가
val lowest52WeekDate: String, // 52주 최저가 달성일 yyyy-MM-dd
val timestamp: Long, // 타임스탬프
): Parcelable
이번엔 될 줄 알았다. Stable vs Unstable 및 Immutable까지 어느정도 익혔다고 생각했기에 잘 될 것으로 보였다.

하지만 결과는 그대로 🥺… 여전히 모든 item들에서 동시에 recomposition이 일어나고 있었다.
List와 같은 Collection들은 기본적으로 Unstable이다. 타입이 불변인 List 일지라도 실제 생성이 MutableList로 이루어졌을 가능성이 있기 때문이다. 그렇기에 Recomposition을 다룰 때 경우에 따라 Collection을 Immutable로 지정해주는 것이 중요하다.
나의 경우 List를 ImmutableList로 지정하거나 하지는 않았다. 이것이 지금의 상황에 대한 원인일 것이라고는 생각하지 않았기 때문이다. 그랬다면 LazyColumn 자체의 Recomposition Count도 증가했을 것이니 말이다.
@Composable
fun CryptoList(
modifier: Modifier = Modifier,
tickers: List<TickerModel>, // Unstable
listState: LazyListState = rememberLazyListState()
) { ... }
그래도 혹시 모르니 시도는 해보았다. Immutable 처리를 해주기 보다는, List의 위치를 CryptoList Composable 내부로 위치하였다. 아래와 같이 말이다.
fun CryptoList(
modifier: Modifier = Modifier,
viewModel: TickerViewModel = hiltViewModel(),
listState: LazyListState = rememberLazyListState()
) {
val tickers = viewModel.tickers // SnapshotStateList
...
}
예상했던대로… 문제의 해결이 되진 않았다.🫠
물론 나중을 생각하면 ImmutableList 처리도 해주어야 하긴 하지만 지금의 문제와는 관련이 없었기에 넘어갔다.
람다는 기본적으로 Stable하므로 recomposition을 생략할 수 있다. 그러나 람다가 unstable한 값을 capture하는 경우, Stable임에도 recomposition이 생략되지 않는다. 현재 나의 코드에선 clickable 수정자가 unstable한 값 : context를 capture하고 있으므로, 이것이 recomposition의 원인이 될 수 있으리라 생각하였다. 따라서 clickable 람다 몸체를 주석처리하고 다시 시도하였다.
modifier = Modifier
.fillMaxWidth()
.clickable {
// val intent = Intent(context, TradeActivity::class.java).apply {
// putExtra(TICKER, it)
// }
// context.startActivity(intent)
}
.padding(vertical = 16.dp)

이번에도 결과는 그대로였다…
사실 위의 과정까지 거쳤을 때 많이 좌절했다. 이 문제를 해결하기 위해 Compose의 안정성에 대한 공부도 하고, 검색도 여러 차례 해보고, GPT도 많이 괴롭혀보고… 하였지만 결과에 전혀 변화가 없었기 때문이다. 그래도 해볼 수 있는 시도는 계속해보았다. ticker의 모든 업데이트를 ticker list의 0번째에만 몰아 넣는다던지.. LazyColumn에 Key를 지정한다던지…
💡그런데 문제가 Modifier에 있을 거라고는 전혀 생각하지 못했다.
Modifier를 지우니 recomposition이 특정 ticker의 item에만 발생하였다…!! 😮
다시 Modifier를 복원하고 가장 가능성이 높아보였던 clickable만 지우고 다시 시도해보았다.
전부 Skip되는 모습!!
Clickable은 Recomposition을 일으킨다는 사실을 알아냈다!
🤔 그런데 왜 Clickable만 Recomposition을 일으킬까?
검색해보았지만 Clickable과 Recomposition에 관한 결과는 많지 않았다. 그래도 궁금하긴 해서 Modifier 수정자들의 내부 구현을 보던 중 이와 같은 사실을 발견하였다.
@Stable
fun Modifier.border(width: Dp, brush: Brush, shape: Shape) =
this then BorderModifierNodeElement(width, brush, shape)
@Stable
fun Modifier.fillMaxWidth(@FloatRange(from = 0.0, to = 1.0) fraction: Float = 1f) =
this.then(if (fraction == 1f) FillWholeMaxWidth else FillElement.width(fraction))
fun Modifier.clickable(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) = composed(
inspectorInfo = debugInspectorInfo {
name = "clickable"
properties["enabled"] = enabled
properties["onClickLabel"] = onClickLabel
properties["role"] = role
properties["onClick"] = onClick
}
) {
Modifier.clickable(
enabled = enabled,
onClickLabel = onClickLabel,
onClick = onClick,
role = role,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
다른 수정자와 달리 clickable 함수에는 @Stable 어노테이션이 명시되어 있지 않았다!
clickable이 unstable 하니, recomposition이 일어날 때마다 모든 item들에서 recomposition이 발생하는 것이었다.
🤔 왜 Clickable은 unstable일까?
When applied to a function or a property, the Stable annotation indicates that the function will return the same result if the same parameters are passed in. This is only meaningful if the parameters and results are themselves Stable, Immutable, or primitive.
@Stable 어노테이션에 대한 설명이다. @Stable이 함수에 적용되면, 이 함수는 동일한 입력에 대해 동일한 결과를 반환할 것임을 나타낸다고 한다.
Clickable을 다시 보았을 때, Clickable이 Stable이 될 수 없는 이유가 2가지 있는 것 같다.
매개변수의 onClick 람다
람다에서 unstable 값을 capture할 가능성이 존재한다.
remember 함수의 사용
remember는 상태를 저장하고 유지한다. 이는 상태가 변경될 수 있음을 의미한다.
위의 이유로 인해 동일한 입력이 동일한 결과로 이어지지 않을 수 있고, 따라서 Unstable 할 것이라고 생각해보았다.
modifier를 remember로 묶어, recomposition 시에 새로운 Modifier가 생성되지 않도록 하였다. (+TickerModel 클래스의 @Immutable처리)
CryptoListItem(
modifier = remember {
Modifier
.fillMaxWidth()
.clickable {
val intent = Intent(context, TradeActivity::class.java).apply {
putExtra(TICKER, it)
}
context.startActivity(intent)
}
.padding(vertical = 16.dp)
},
ticker = it
)
정상적으로 필요한 item에서만 recomposition 되었다!