도넛 홀 스킵핑과 상태 읽기 지연, 뭐가 다를까?

2026. 3. 16. 17:45·우아한테크코스/레벨1

들어가며

얼마전에 레아가 리컴포지션 관련해서 아티클을 공유해주셨어요. 그래서 제가 알고있던 한가지 최적화 기법과 닮아있다고 생각이 들었고 의문이 들었어요. 🤔

"도넛 홀 스킵핑이랑 Defer State Read가 결국 같은 말 아닌가?

둘 다 '상태를 어디서 읽느냐'의 문제 아님???"

직관적으로는 틀린 말이 아니라고 생각해요. 실제로 두 기법의 공통 철학은 "상태를 읽는 위치를 최대한 좁혀라" 이면서 "불필요한 재실행을 줄이는 것." 이라는 목표도 같아요.

차이점은 동작하는 레이어가 다릅니다. 목표는 같지만 다른 계층에서 작동하는 두 개의 최적화에 대해서 이 글은 그 차이를 명확하게 짚는 것을 목표로 합니다. 👍🏻

1. 전제조건 - Compose의 렌더링 파이프라인

두 기법의 차이를 이해하려면 Compose가 화면을 그리는 3단계 파이프라인을 먼저 이해해야 합니다.

Composition  →  Layout  →  Draw
  • Composition — @Composable 함수를 실행해 UI 트리를 구성합니다. 무엇을 그릴지 결정하는 단계입니다.
  • Layout — 각 요소의 크기와 위치를 결정합니다.
  • Draw — 실제로 화면에 픽셀을 그립니다.

상태가 변경되면 Compose는 이 파이프라인을 다시 실행합니다. 핵심은 상태를 어느 단계에서 읽느냐에 따라 어느 단계부터 재실행되는지가 결정된다는 점입니다!!

이 파이프라인을 머릿속에 두고, 두 기법을 각각 살펴봅시다!

2. 도넛 홀 스킵핑 - Composition 안에서의 최적화

핵심 아이디어

도넛 홀 스킵핑은 Composition Phase 안에서 작동합니다. Composition은 발생하되 모든 @Composable 함수를 재실행하지 않고 상태를 읽는 가장 작은 스코프만 재실행하는 최적화인데요.

Compose 런타임은 함수를 통째로 하나의 단위로 보지 않습니다. @Composable 람다 블록 단위로 독립적인 리컴포지션 스코프를 나눠 추적합니다.

Composable 함수 본체          →  하나의 스코프
@Composable 람다 인자         →  별도의 독립 스코프
일반 람다 (() -> Unit 등)     →  스코프 아님

코드를 봐야한다!!

@Composable
fun ParentComponent() {
    val counter by remember { mutableStateOf(0) }

    // ParentComponent 스코프에서 counter를 직접 읽음 → 재실행 대상
    val label = "현재 카운터: $counter"
    Text(label)

    MiddleComponent {
        // @Composable 람다 → 독립 스코프
        // counter를 여기서도 읽음
        Text("Counter: $counter")
        Button(onClick = { counter++ }) { Text("증가") }
    }
}

@Composable
fun MiddleComponent(content: @Composable () -> Unit) {
    // counter를 읽지 않음, 파라미터도 변경 없음
    Box(modifier = Modifier.padding(16.dp)) {
        content()
    }
}

counter가 변경되면:

✅ ParentComponent 본체   — counter를 직접 읽으므로 재실행
  ⏭ MiddleComponent 본체 — 스킵! (도넛)
    ✅ @Composable 람다    — counter를 읽으므로 재실행 (도넛 홀)
      ✅ Text              — 입력 변경, 재실행

MiddleComponent(도넛)는 건너뛰고, 그 안의 @Composable 람다(도넛 홀)만 실행됩니다. 이것이 "도넛 홀 스킵핑"이라는 이름의 유래입니다.

…이게 왜 됨..?

Compose 팀의 Leland Richardson은 다음과 같이 설명합니다.

"...@chuckjaz had the brilliant realization that if the lambdas were state objects, and invokes were reads, then this is exactly the result."
(뭐래..)

Compose는 @Composable 람다를 일종의 상태 객체로 취급합니다. 람다 호출이 곧 상태 읽기이기 때문에 런타임이 해당 스코프를 직접 찾아 재실행할 수 있습니다. 부모 함수를 거칠 필요가 없습니다.

단, 스킵핑이 동작하려면 전제조건이 있습니다. 파라미터가 Stable해야 합니다. Unstable 타입이 파라미터로 넘어오면 Compose 컴파일러는 해당 함수를 non-skippable로 분류하고 매번 재실행합니다.

UnStable? Stable?

Compose 컴파일러 관점에서 Stable이란 값이 변경될 때 반드시 Compose에게 알림이 온다는 보장이 있는 타입입니다. 원시 타입과 MutableState는 Stable하지만 일반 List나 Map은 내부가 바뀌어도 Compose가 감지할 수 없어 Unstable로 분류됩니다.

// ❌ List는 Unstable → MiddleComponent 스킵 불가
@Composable
fun MiddleComponent(items: List<String>, content: @Composable () -> Unit)

// ✅ ImmutableList는 Stable → 스킵 가능
// (kotlinx.collections.immutable 의존성 필요)
@Composable
fun MiddleComponent(items: ImmutableList<String>, content: @Composable () -> Unit)

3. Defer State Read — Phase 자체를 바꾸는 최적화

핵심 아이디어

Defer State Read(상태 읽기 지연)는 Composition Phase 자체를 건너뛰는 최적화입니다. 상태 읽기를 람다로 감싸 Layout 또는 Draw Phase로 미루면, 해당 상태가 바뀌어도 Composition이 아예 발생하지 않습니다.

// ❌ Composition Phase에서 읽음 → 상태 변경 시 Composition 재실행
Modifier.offset(offsetState.value.dp)

// ✅ Layout Phase로 읽기를 미룸 → 상태 변경 시 Composition 스킵
Modifier.offset { IntOffset(offsetState.value, 0) }

두 코드는 동일한 결과를 화면에 그립니다. 하지만 offset 람다 버전은 상태를 Layout Phase까지 읽지 않기 때문에 offsetState가 변경되어도 Composition이 발생하지 않고 Layout부터 재실행됩니다.

Modifier 람다 API들

Compose의 여러 Modifier가 이 패턴을 지원합니다.

// Composition에서 읽음 → Layout/Draw까지 파이프라인 전체 재실행
Modifier.alpha(alphaState.value)
Modifier.offset(offsetX.value.dp, offsetY.value.dp)

// Layout Phase로 지연
Modifier.offset { IntOffset(offsetX.value, offsetY.value) }

// Draw Phase로 지연
Modifier.drawBehind {
    drawRect(color = colorState.value)  // Draw Phase에서 읽힘
}

Modifier.graphicsLayer {
    alpha = alphaState.value  // Draw Phase에서 읽힘
}

graphicsLayer와 drawBehind는 Draw Phase 람다이므로, 내부에서 읽는 상태가 변경되면 Composition과 Layout을 모두 건너뛰고 Draw만 재실행됩니다.

4. 둘의 차이, 제대로 비교하기

지금까지 각각을 살펴봤으니 이제 두개를 비교해봐야겠죠?

구분 도넛 홀 스킵핑 Defer State Read
작동 레이어 Composition 안 (Scope 단위) Composition 앞 (Phase 단위)
누가? 런타임이 자동으로 개발자가 직접
Composition 발생 여부 발생함 (일부 스코프만 재실행) 발생 안 함
최적화 강도 상대적으로 약함 더 강력
주요 사용처 컴포넌트 트리 구조 설계 애니메이션, 스크롤 등 빈번한 상태 변경

같은 상황, 다른 작동 방식

두 기법이 어떻게 다르게 작동하는지 같은 상황으로 보겠습니다.

// 시나리오: 스크롤 오프셋에 따라 위치가 바뀌는 헤더

val scrollState = rememberScrollState()

// ❌ Composition Phase에서 상태를 읽는 방식
// scrollState.value가 바뀔 때마다 Header의 Composition이 발생
@Composable
fun Header(scrollOffset: Int) {
    Box(modifier = Modifier.offset(y = (-scrollOffset).dp)) {
        Text("헤더")
    }
}

// ✅ Defer State Read 적용
// scrollState.value가 아무리 바뀌어도 Composition 발생 없음
@Composable
fun Header(scrollState: ScrollState) {
    Box(modifier = Modifier.offset { IntOffset(0, -scrollState.value) }) {
        Text("헤더")
    }
}

스크롤은 초당 수십 번 상태가 변경되는데요. 🤔 첫 번째 방식은 그때마다 Composition이 발생하지만 두 번째 방식은 Layout Phase만 재실행됩니다. 글로만 읽어도 성능에 차이가 생기겠죠?

헷갈리기 쉬운데요..

두 개념이 비슷해 보이는 이유는 둘 다 "상태를 읽는 위치를 좁힌다"는 철학을 공유하기 때문이에요.

  • 도넛 홀 스킵핑 → 읽는 스코프를 좁힌다
  • Defer State Read → 읽는 Phase를 늦춘다

"좁힌다"는 행위가 같아 보이지만, 스코프와 Phase는 완전히 다른 개념입니다.

5. 언제 무엇을 선택할까?

두 기법은 하나만 선택해야하는 이지선다가 아닙니다. 함께 사용할 수 있고 상황에 따라 적합한 기법이 다릅니다. 😊

도넛 홀 스킵핑에 집중해야 할 때

  • 컴포넌트 트리의 중간 노드가 불필요하게 재실행될 때
  • 파라미터 타입의 Stability를 관리할 때 (@Stable, @Immutable, ImmutableList)
  • 상태를 읽는 위치를 최대한 하위 @Composable 람다로 내릴 때

Defer State Read를 적극 활용해야 할 때

  • 애니메이션처럼 상태가 매우 빈번하게 변경될 때
  • 스크롤 오프셋, 드래그 위치처럼 연속적으로 바뀌는 값을 Modifier에 반영할 때
  • graphicsLayer, drawBehind, offset { } 등 람다를 받는 Modifier API를 사용할 때

결론을 냅시다.

두 기법의 차이를 한 문장으로 압축하면 이렇습니다.

도넛 홀 스킵핑은 Composition 안에서 불필요한 함수 본체를 건너뛰고 Defer State Read는 Composition 자체를 건너뜁니다.

처음엔 "결국 같은 말 아님?" 했는데, 파고들수록 작동하는 레이어가 다르다는 게 명확하게 보였어요.

둘을 함께 이해하고 상황에 맞게 꺼내 쓸 수 있다면 Compose 마스터 한거 아닐까요? (제발)

틀린 내용이 있거나 의견이 있으시다면 편하게 남겨주세요🙇🏻‍♂️

참고 문서

  • Compose phases 공식 문서
  • Defer state reads 공식 문서
  • Compose Stability 공식 문서
  • Compose 성능 최적화 가이드

'우아한테크코스 > 레벨1' 카테고리의 다른 글

??님 Preview가 흐릿한데 왜그래요?  (0) 2026.03.08
'우아한테크코스/레벨1' 카테고리의 다른 글
  • ??님 Preview가 흐릿한데 왜그래요?
아키001
아키001
Android 개발자가 되기까지.
  • 아키001
    미래 가젯 연구소
    아키001
  • 전체
    오늘
    어제
    • 분류 전체보기 (39)
      • Android (13)
        • Compose (12)
        • Jetpack (2)
      • Kotlin (2)
      • 우아한테크코스 (10)
        • 일상, 회고 (0)
        • 레벨1 (2)
        • 레벨0 (4)
        • 프리코스 (4)
  • 블로그 메뉴

    • 홈
    • 안드로이드
    • 태그
  • 링크

    • GitHub
  • 인기 글

  • 태그

    Android
    우테코
    coroutine
    jetpack
    레벨0
    Gradle
    compose
    runcatching
    build-logic
    Kotlin
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
아키001
도넛 홀 스킵핑과 상태 읽기 지연, 뭐가 다를까?
상단으로

티스토리툴바