본문 바로가기
안드로이드

Compose에서 state를 어디서 읽느냐가 성능을 가른다

by 안드뽀개기 2026. 7. 13.
반응형

드래그 제스처 하나 처리하려고 만든 Composable이 프레임마다 리컴포지션을 일으키는 걸 본 적이 있을 것이다. 로직은 분명 맞는데 프로파일러를 열어보면 Composition, Layout, Drawing이 매 프레임 전부 돌고 있다. 원인은 대부분 하나다. state를 읽는 위치가 너무 이르다.

이 글은 Compose 렌더링 파이프라인에서 state read의 위치가 왜 성능에 직결되는지, 그리고 이를 어떻게 뒤로 미룰 수 있는지를 다룬다.


Compose가 프레임을 그리는 세 단계

Compose는 화면을 그릴 때 세 단계를 거친다.

Composition 단계에서는 Composable 함수를 실행해서 UI 트리를 만든다. Layout 단계에서는 각 노드를 측정하고 배치한다. Drawing 단계에서는 실제 픽셀을 그린다.

핵심은 이 세 단계 중 몇 개를 다시 실행해야 하느냐다. state가 Composition 중에 읽히면 그 state가 바뀔 때마다 서브트리 전체가 세 단계를 다시 거친다. Layout 중에 읽히면 Layout부터 다시 실행되고, Drawing 중에 읽히면 그리기 명령만 다시 실행된다.

즉 같은 state 변화라도 어디서 읽느냐에 따라 뒤따르는 작업량이 완전히 달라진다. 드래그 제스처처럼 프레임마다 값이 바뀌는 상황에서는 이 차이가 누적되어 눈에 보이는 렉으로 나타난다.


흔히 저지르는 실수: state를 너무 일찍 읽는다

카드를 드래그로 움직이는 상황을 생각해보자. 오프셋을 State으로 들고 있고, 다음과 같이 짜는 경우가 많다.

@Composable
fun SwipeableCard(state: SwipeableCardState) {
    Card(
        modifier = Modifier
            .offset(
                x = state.offset.x.dp,
                y = state.offset.y.dp
            )
            .graphicsLayer(
                scaleX = state.scaleFactor,
                scaleY = state.scaleFactor
            )
    ) {
        // content
    }
}

코드만 보면 문제가 없어 보인다. 하지만 state.offset을 읽는 위치가 SwipeableCard 함수 본문, 즉 Composition 단계다. offset이 매 프레임 바뀌니 Compose는 SwipeableCard 스코프 전체를 리컴포지션 대상으로 표시하고, 함수 재실행부터 modifier 체인 재평가, Layout, Drawing까지 전부 다시 돈다.

위치만 바뀌고 그리는 내용은 그대로인데 세 단계를 전부 지불하고 있는 셈이다.


해법: modifier의 람다 버전으로 state read를 미룬다

Modifier.offset과 Modifier.graphicsLayer는 모두 람다를 받는 버전을 제공한다.

@Composable
fun SwipeableCard(state: SwipeableCardState) {
    Card(
        modifier = Modifier
            .offset {
                IntOffset(
                    x = state.offset.x.roundToInt(),
                    y = state.offset.y.roundToInt()
                )
            }
            .graphicsLayer {
                scaleX = state.scaleFactor
                scaleY = state.scaleFactor
            }
    ) {
        // content
    }
}

겉보기엔 사소한 차이지만 실행 시점이 완전히 다르다. 람다 안에서 state.offset을 읽기 때문에 Compose는 Composition 시점에는 이 값을 평가하지 않는다. 실제 읽기는 Layout 또는 Drawing 단계에서, 해당 람다가 호출될 때 일어난다.

결과적으로 offset이 바뀌어도 SwipeableCard 함수 자체는 재실행되지 않는다. Compose는 Layout 노드에 붙은 람다만 다시 호출해서 위치를 갱신한다. graphicsLayer 람다는 더 나아가 Drawing 단계에서 읽히므로 scaleX, scaleY 변경은 Layout조차 건너뛰고 그리기 명령만 재실행된다.

세 단계를 지불하던 작업이 한 단계, 혹은 두 단계로 줄어드는 것이다.


왜 이게 중요한가: 읽기 시점과 스코프의 관계

여기서 헷갈리기 쉬운 부분은 "람다를 쓰면 무조건 빨라진다"는 오해다. 실제로는 람다가 state read를 어느 스코프로 옮기느냐가 핵심이다. offset { } 람다는 Layout 단계에서 실행되는 별도의 measure/place 콜백이고, graphicsLayer { } 람다는 Drawing 단계에서 실행되는 별도의 콜백이다.

Compose의 snapshot 시스템은 어떤 스코프에서 어떤 state를 읽었는지를 추적한다. 그 읽기가 발생한 스코프만 무효화 대상이 되므로, 읽기 위치를 Composable 함수 본문에서 벗어난 별도 콜백으로 옮기면 무효화 범위도 그만큼 좁아진다. 이게 derivedStateOf나 remember의 동작 원리와도 맞닿아 있다.

반대로 이런 함정도 있다. 람다 안에서 state를 읽더라도 그 람다가 Composition 시점에 즉시 호출되어 결과값만 넘기는 형태라면 아무 효과가 없다. 예를 들어 remember(state.offset) { computeSomething() } 처럼 쓰면 여전히 Composition 스코프에서 값을 읽는 것과 다르지 않다. 반드시 modifier가 지연 평가하는 람다 시그니처를 그대로 활용해야 한다.


이 최적화가 맞는 상황, 맞지 않는 상황

이 패턴은 애니메이션, 드래그, 스크롤처럼 값이 초당 수십 번씩 바뀌면서 위치나 시각적 속성만 영향을 주는 경우에 효과가 크다. Modifier.offset, graphicsLayer 외에도 Modifier.drawWithContent, Modifier.layout처럼 지연 읽기를 지원하는 API들이 이런 상황을 위해 존재한다.

반대로 state 변화가 실제로 UI 구조나 텍스트 내용, 자식 컴포저블 자체를 바꿔야 하는 경우라면 이 최적화는 의미가 없다. 그런 경우는 애초에 Composition을 다시 돌아야 하므로 람다로 감싸봐야 이득이 없고 오히려 코드만 복잡해진다. 또한 성능 문제가 실제로 측정되지 않은 상태에서 모든 modifier를 습관적으로 람다 버전으로 바꾸는 것도 권장하기 어렵다. 가독성을 희생하면서까지 미세 최적화를 넣기 전에 Layout Inspector나 Compose Compiler의 recomposition count를 먼저 확인하는 편이 낫다.

드래그 가능한 카드, 바텀시트, 커스텀 제스처 컨트롤처럼 프레임 단위로 state가 바뀌는 컴포넌트를 만들고 있다면, modifier 체인에서 state를 어디서 읽고 있는지 한 번쯤 점검해볼 가치가 있다.

※ 본 글은 정보 제공 목적이며 특정 제품·서비스의 추천이 아닙니다.

반응형