[Jetpack Compose] DerivedStateOf 에 대해 알아보자

오규성·2025년 11월 6일

Jetpack Compose 기초

목록 보기
5/13
post-thumbnail

# remember 가 가진 문제점

이제 Recomposition 시에도 remember { mutableStateOf<T> } 의 계산식을 재실행하기 위해서는 key 를 설정해줘야 한다는 것을 알았다.

다만, 한 가지 가정을 해보자.
이전처럼 단순한 데이터라면 상관 없겠지만, 계산식에 의해 새로운 상태가 파생되어야 하는 경우는 어떻게 할 것인가?

다음과 같은 코드가 있다고 해보자.

class MainActivity() : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            enableEdgeToEdge()

            MaterialTheme {
                Scaffold {
                    Column(modifier = Modifier
                        .fillMaxSize()
                        .padding(it)
                    ) { DerivedStateOfTest() }
                }
            }
        }
    }
}

@Composable
fun DerivedStateOfTest(){
    val listState = rememberLazyListState()
    val page = remember(listState.firstVisibleItemIndex){
        listState.firstVisibleItemIndex / 5
    }

    Text("현재 페이지 - ${page}")
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        state = listState
    ) {
        itemsIndexed(List(500){ it }){ i, _ ->
            Text(text = "item $i")
        }
    }
}

이 코드는 LazyColumn 을 이용하여 하단 스크롤을 하는 경우 현재 보이는 첫 번째 Composable Index / 5 로 페이지를 나타낸다.

우리는 이전 시간에 UI 를 업데이트 하기 위해서는 remember 에 key 를 지정해줘야한다는 것을 알았다.
이제 이것을 실행해보자.

값이 잘 변경되는 것을 확인할 수 있다.
그렇다면 리컴포지션 횟수는 얼마나 될까?

Running Device - 오른쪽 상단의 네모 및 돋보기 모양 을 클릭하자.

이를 클릭하면 위와 같이 해당 화면에서 발생하는 Recomposition, 자식의 Recomposition 과 Skipped 을 확인할 수 있다.

Skipped 의 경우 Compose Runtime 이 UI 가 의존하는 데이터가 이전과 다름이 없다 판단하여 Recomposition 하지 않고 넘기는 기능이 존재하는데, 이것의 Count 이다.

이제 다시 화면을 스크롤하여 firstVisibleItemIndex 를 업데이트 해보자.

실제 계산은 listState.firstVisibleItemIndex / 5 로, 계산 결과가 바뀐 것은 2번만 발생하였음에도 DerivedStateOfTest Composable 은 15번의 불필요한 리컴포지션 과정을 거쳤다.

만약 이것이 수 천, 수 만번의 과정을 더 거친다면 앱이 버벅거리거나 심하면 크래쉬가 발생할 수 있는데, 이것이 발생한 이유는 다음과 같다.

@Composable
fun DerivedStateOfTest(){
    val listState = rememberLazyListState()
    val page = remember(listState.firstVisibleItemIndex) {
        listState.firstVisibleItemIndex / 5
    }

    Text("현재 페이지 - ${page}")
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        state = listState
    ) {
        itemsIndexed(List(500){ it }){ i, _ ->
            Text(text = "item $i")
        }
    }
}

위의 코드를 살펴보면 page 는 key 로 listState.firstVisibleItemIndex 를 읽어들이고 있다.

이것은 내부적으로 scrollPosition.index 를 가져오는데, 이 index 가 var index by mutableIntStateOf(initialIndex) 처럼 state 로 구현되어 있기 때문에 이를 읽는 가장 가까운 restartable (재시작가능한) Composable 에서 Recompostion 이 발생하는 것이다.

  • remember 의 경우 inline function 으로 이루어져 있기 때문에 컴파일 시 key 는 가장 가까운 스코프에서 선언한 것처럼 된다.
  • 이와 비슷하게 @NonRestartableComposable 이라는 어노테이션이 있는데 비슷한 기능을 한다.

자, 그렇다면 우리는 이 불필요한 리컴포지션 과정을 어떻게 줄일 수 있을까?

DerivedStateOf 사용하기

이럴 때 사용하는 것이 DerivedStateOf 이다.
이를 간단히 설명하자면 다음과 같다.

다른 상태를 참조하여 새로운 상태를 파생하는 함수
내부 계산식에 State.value 가 존재하다면 이를 자동으로 추적한다.
단, 일반 파라미터로 받는 State.value 인 경우 이를 추적하지 못한다.!

A 라는 상태를 이용하여 B 라는 상태로 계산을 진행해야하는 경우, key 를 사용하게 된다면 불필요한 리컴포지션이 발생하거나 변수에 의도치 않은 데이터가 들어갈 수 있다.

기능을 알았으니 이를 한 번 사용해보도록 하자.

@Composable
fun DerivedStateOfTest(){
    val listState = rememberLazyListState()
    val page by remember {
        derivedStateOf { listState.firstVisibleItemIndex / 5  }
    }

    Text("현재 페이지 - ${page}")
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        state = listState
    ) {
        itemsIndexed(List(500){ it }){ i, _ ->
            Text(text = "item $i")
        }
    }
}

리컴포지션이 확실하게 줄어든 것을 확인할 수 있다 !

profile
안드로이드 개발자 Gyu 의 개발 블로그 !

0개의 댓글