본문 바로가기
Android/Compose

[Android] Compose의 중첩 스크롤(Nested Scroll) 완벽하게 이해하기

by 태크민 2025. 10. 12.

최근 프로젝트에서 바텀 시트를 표시하고 그 안에서 LazyColumn 혹은 Column에 세로 스크롤(vertical scroll) 을 적용한 적이 있습니다.

하지만 스크롤 도중, 의도치 않게 바텀 시트가 닫히는 현상이 발생했습니다.

구체적으로는, 자식 컴포넌트의 스크롤을 하단 끝까지 내린 뒤(아래 → 위), 바로 다시 위로 스크롤(위 → 아래) 할 때 바텀 시트가 함께 내려가며 닫히는 상황이었습니다.

 

다음과 같이 말이죠.

 

 

결론부터 말하자면, 이 문제의 원인은 중첩 스크롤(Nested Scroll) 구조 때문입니다.
즉, 사용자의 스크롤 제스처에 따라 자식이 먼저 스크롤을 소비(consumed) 하고, 남은 스크롤(available)부모가 이어받아 처리하는 과정에서 부모인 바텀 시트가 스크롤 이벤트를 소비하게 되어 닫히는 것이죠.

 

이번 포스팅에서는 이러한 중첩 스크롤의 개념과 동작 원리를 살펴보고, 필자가 마주한 이 상황을 의도한 대로 “자식만 스크롤되도록” 해결한 방법을 공유하겠습니다.

 


Compose에서의 Scroll 방법 

중첩 스크롤(Nested Scroll)을 이해하기 전에, 먼저 Compose에서 스크롤이 동작하는 방식과 종류를 살펴보겠습니다.
Compose에서는 scrollable, verticalScroll, LazyColumn 등 다양한 방식으로 스크롤을 구현할 수 있으며,
각 방식은 내부적으로 스크롤 이벤트를 처리하는 방식이 조금씩 다릅니다.

 

LazyColumn / LazyRow — 효율적인 리스트 표시

LazyColumn과 LazyRow는 스크롤 가능한 리스트를 효율적으로 표시하기 위한 컴포저블입니다.
이들은 "지연 로딩(lazy loading)" 방식을 사용해, 현재 화면에 보이는 항목만 compose(렌더링)하고, 화면을 벗어나면 자동으로 dispose됩니다.

 

즉, 수백 개의 아이템이 있어도 메모리와 성능을 절약하면서 스크롤이 가능합니다.

@Composable
fun LazyColumnExample() {
    LazyColumn {
        items(100) { index ->
            Text("Item $index", modifier = Modifier.padding(8.dp))
        }
    }
}
  • 무한 리스트 구현에 적합
  • 성능이 뛰어나며 recomposition 부담이 적음
  • 아이템 개수가 많을수록 LazyColumn이 유리

verticalScroll / horizontalScroll — 간단한 스크롤

verticalScroll과 horizontalScroll은 콘텐츠가 부모의 크기보다 클 때 스크롤 가능하도록 만드는 간단한 Modifier입니다.
다만 이 방식은 모든 자식 컴포저블을 한 번에 compose하기 때문에, 대량의 아이템을 표시할 때는 성능이 떨어집니다.

@Composable
fun VerticalScrollExample() {
    Column(
        modifier = Modifier
            .size(150.dp)
            .verticalScroll(rememberScrollState())
            .background(Color.LightGray)
    ) {
        repeat(30) {
            Text("Item $it", modifier = Modifier.padding(8.dp))
        }
    }
}
  • 단순한 스크롤이 필요할 때 사용
  • 모든 자식이 미리 compose됨 → 작은 리스트에 적합
  • ScrollState를 이용해 스크롤 위치를 제어 가능 (scrollTo, animateScrollTo 등)


중첩 스크롤 (Nested Scroll)

중첩 스크롤((Nested Scroll)이란?

Nested Scrolling(중첩 스크롤)은 하나의 스크롤 제스처에 대해 여러 스크롤 가능한 컴포넌트가 서로 협력하며 동작하는 시스템입니다.

즉, 내부 스크롤 컴포넌트와 외부 스크롤 컴포넌트가 같은 제스처를 공유하고, 각각의 스크롤 변화량(delta) 을 주고받으며 함께 반응합니다.

이 시스템은 계층적으로 연결된(scrollable한) 컴포넌트 간의 스크롤 동작을 조정해주며, 보통 자식–부모 관계로 연결된 컴포넌트 간에서 작동합니다.
이를 통해 스크롤 이벤트가 상하위 컴포넌트 간에 전달 및 공유됩니다.

 

예를 들어, 스크롤 가능한 부모 Composable 안에 또 다른 스크롤 가능한 자식 Composable이 존재하는 경우를 생각해볼 수 있습니다.
이때 사용자의 하나의 스크롤 제스처가 부모와 자식 모두에 전달되어, 자식이 먼저 스크롤을 소비하고, 남은 스크롤은 부모가 이어서 처리하게 됩니다.

앞서 언급한 바텀 시트(Bottom Sheet) 도 같은 구조를 가집니다.
바텀 시트 자체가 스크롤 가능한 Composable이고, 그 내부에 LazyColumn이나 Column과 같은 스크롤 가능한 자식이 존재한다면 이 상황이 바로 중첩 스크롤로 동작하게 되는 것입니다.

 

자동 중첩 스크롤 (Automatic nested scrolling)

사실 이러한 중첩 스크롤은 개발자가 별도로 처리하지 않아도 자동으로 동작합니다.

사용자가 제스처를 통해 스크롤을 시작하면, 해당 제스처는 자식 → 부모 방향으로 자동 전파되며, 자식이 더 이상 스크롤할 수 없을 때 부모가 스크롤을 이어받습니다.

즉, "자식이 먼저 스크롤을 소비하고, 남은 스크롤을 부모가 이어받는 구조" 입니다.

 

Compose의 다음 요소들은 이미 내부적으로 nested scrolling 지원을 내장하고 있습니다 

  • verticalScroll / horizontalScroll
  • scrollable
  • LazyColumn, LazyRow (Lazy APIs)
  • TextField

이 말은 즉, 이 컴포넌트들을 중첩해서 사용하면, 별도의 설정 없이도 스크롤 델타가 부모에게 자동으로 전달된다는 뜻입니다.

 

아래 예시는 verticalScroll을 적용한 여러 박스(Box)가 다시 상위 verticalScroll 컨테이너 안에 들어있는 구조입니다.
즉, “리스트 안의 리스트” 형태로 자동 중첩 스크롤이 작동하는 예시입니다.

@Composable
private fun AutomaticNestedScroll() {
    val gradient = Brush.verticalGradient(0f to Color.Gray, 1000f to Color.White)

    Box(
        modifier = Modifier
            .background(Color.LightGray)
            .verticalScroll(rememberScrollState())
            .padding(32.dp)
    ) {
        Column {
            repeat(6) {
                Box(
                    modifier = Modifier
                        .height(128.dp)
                        .verticalScroll(rememberScrollState())
                ) {
                    Text(
                        "Scroll here",
                        modifier = Modifier
                            .border(12.dp, Color.DarkGray)
                            .background(brush = gradient)
                            .padding(24.dp)
                            .height(150.dp)
                    )
                }
            }
        }
    }
}

 

위의 동작 예시를 보면,

내부 박스를 스크롤하면 해당 영역만 스크롤되고, 내부 박스가 더 이상 스크롤되지 않으면, 상위 박스(부모)가 스크롤을 이어받습니다.

즉, 안쪽과 바깥쪽 스크롤이 자연스럽게 연동됩니다.

 

NestedScroll Modifier 사용하기

만약 여러 요소 간에 고급 스크롤 동기화(coordinated scroll) 가 필요하다면 nestedScroll modifier를 사용하여 명시적으로 중첩 스크롤 계층(hierarchy) 을 정의할 수 있습니다.

이 modifier는 NestedScrollConnection 과 NestedScrollDispatcher 를 연결시켜 스크롤 델타가 부모–자식 간에 어떻게 전달될지 직접 제어할 수 있게 해줍니다.

 

예를 들어, verticalScroll / LazyColumn 등은 이미 내부적으로 중첩 스크롤을 지원하지만, BoxColumn 같이 기본적으로 스크롤을 지원하지 않는 컴포넌트에서는 스크롤 델타가 자동으로 전달되지 않습니다.

이 경우 nestedScroll을 명시적으로 적용해야, 부모 또는 다른 컴포넌트와의 스크롤 연동이 가능해집니다.

즉, nestedScroll을 사용하면 “스크롤 가능한 컴포넌트가 아닌 요소”도 중첩 스크롤 시스템에 참여시킬 수 있습니다.

 

중첩 스크롤 동작 과정 (Nested Scroll Cycle)

이제 한 단계 더 나아가, 중첩 스크롤이 실제로 어떤 원리로 동작하는지 살펴보겠습니다.

Nested Scroll Cycle은 스크롤 가능한 여러 컴포넌트(노드)가 nestedScroll 시스템을 통해 상호작용할 때, 스크롤 델타(이동 거리)가 위→아래, 아래→위로 전파되는 흐름을 말합니다.
이는 scrollable 또는 nestedScroll modifier를 사용하는 컴포넌트 간에 발생합니다.

 

Nested Scrolling의 3단계

터치 제스처 같은 트리거 이벤트가 스크롤 가능한 컴포넌트에 의해 감지되면, 실제 스크롤 동작이 일어나기 전에 해당 스크롤 델타(delta) 값이 중첩 스크롤 시스템으로 전달되어 다음의 세 단계를 거칩니다:

  1. Pre-scroll (사전 스크롤 단계)
  2. Node consumption (노드 소비 단계)
  3. Post-scroll (사후 스크롤 단계)

1️⃣ Pre-scroll 단계

Pre-scroll 단계에서는 이벤트를 받은 자식(Child) 컴포넌트가 먼저 스크롤 델타를 부모 방향으로 위로 전달(dispatch up) 합니다. 즉, 자식이 실제로 스크롤하기 전에 부모에게 먼저 스크롤 기회를 주는 과정입니다.

  • 부모 계층(Parent1 → Parent2 …)까지 델타가 올라간 뒤, 다시 루트 부모에서 자식으로 델타가 전파(bubble down) 됩니다.

전달된 델타는 부모 계층(Parent1 → Parent2 → …)을 따라 위로 전파된 뒤, 다시 루트 부모에서 자식 방향으로 델타가 전파(bubble down) 됩니다.

이 과정에서 각 부모는 자식이 스크롤을 수행하기 전에 스크롤 델타의 일부를 미리 소비(pre-consume) 할 수 있습니다.
예를 들어, 상위 스크롤 컨테이너가 스크롤 이벤트를 일부 처리해 자식이 사용할 수 있는 델타 양을 줄일 수 있는 것이죠.

 

2️⃣ Node Consumption 단계

Node Consumption 단계에서는 부모가 사용하지 않은 남은 델타를 자식 노드가 소비합니다.

이 시점이 실제로 화면에서 스크롤이 일어나는 순간입니다.
자식은 델타를 전부 사용할 수도 있고, 일부만 사용할 수도 있습니다.

  • 예: 부모가 10px 중 2px을 썼다면(Pre-scroll 단계에서), 자식은 남은 8px을 소비할 수 있습니다.
  • 만약 자식이 8px 중 5px만 소비한다면, 남은 3px은 다시 위로 올라가며 post-scroll 단계로 이동합니다.

3️⃣ Post-scroll 단계

Post-scroll 단계에서는 자식이 사용하지 않고 남긴 델타가 부모 방향으로 다시 전파(dispatch up) 됩니다.

  • 부모들은 이 남은 델타를 받아 추가로 소비할 수 있는 기회를 얻습니다.
  • pre-scroll과 마찬가지로,
    델타는 부모 방향으로 올라간 뒤,
    다시 버블 다운(bubble down) 하며 각 노드가 최종적으로 델타를 확인합니다.

이 과정을 통해 모든 노드가 델타를 공유하고, 일부를 처리할 수 있는 구조가 만들어집니다.

 

요약하면,

Pre-scroll → 부모가 먼저 처리할 기회
Node consumption → 자식이 실제 스크롤 수행
Post-scroll → 남은 델타를 부모가 다시 처리

이 전체 흐름을 통해 Compose의 스크롤 계층 간 협력적 스크롤 처리가 구현됩니다.

 

+ Fling (플링) 단계

드래그 제스처가 끝난 후, 사용자의 손이 화면에서 떨어질 때 발생하는 속도(velocity) 를 기반으로 플링(fling) 이라는 스크롤 애니메이션이 실행됩니다.

이 플링 동작 역시 nested scroll cycle의 일부로 간주되며, 플링 시에도 다음과 같은 유사한 단계를 거칩니다:

  1. Pre-fling
  2. Node consumption
  3. Post-fling

단, 플링 애니메이션은 터치 제스처에만 해당하며, 접근성(a11y) 이벤트나 하드웨어 스크롤(예: 키보드, 마우스 휠)에서는 발생하지 않습니다.

 

중첩 스크롤 동작에 참여하기 (Participate in the Nested Scrolling Cycle)

위에서 언급한 바와 같이 중첩 스크롤 동작에 참여한다는 것은 계층(hierarchy) 안에서 스크롤 델타(delta)가로채고(intercept), 소비(consume) 하며, 그 소비 결과를 보고(report) 하는 것을 의미합니다.

 

Compose는 이 중첩 스크롤 시스템이 어떻게 동작하는지를 제어하거나, 스크롤 가능한 컴포넌트가 실제로 스크롤을 시작하기 전에
스크롤 델타에 직접 접근하여 동작을 제어할 수 있도록 돕는 도구를 제공합니다.

대표적으로 다음 두 가지가 있습니다.

  1. NestedScrollConnection — 스크롤 주기의 각 단계에 반응하고, 스크롤 동작을 제어할 수 있는 인터페이스
  2. NestedScrollDispatcher — 스크롤 주기를 직접 시작(trigger)하는 객체

이 두 객체는 Modifier.nestedScroll()에 전달하여 사용할 수 있으며, 특히 NestedScrollConnection은 중첩 스크롤의 핵심 로직을 담당합니다.

NestedScrollConnection은 중첩 스크롤 주기의 각 단계(phase)에 응답하거나 영향을 미치는 방법을 제공합니다.

이 인터페이스는 네 가지 콜백(callback) 메서드로 구성되어 있으며, 각각 스크롤 소비(consumption) 단계에 대응합니다:

  • onPreScroll → 사전 스크롤 단계
  • onPostScroll → 사후 스크롤 단계
  • onPreFling → 사전 플링 단계
  • onPostFling → 사후 플링 단계

아래 예시는 기본적인 NestedScrollConnection의 형태입니다:

val nestedScrollConnection = object : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        println("Received onPreScroll callback.")
        return Offset.Zero
    }

    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        println("Received onPostScroll callback.")
        return Offset.Zero
    }
}

 

각 콜백 메서드는 다음과 같은 정보를 제공합니다:

  • available:
    현재 단계에서 사용할 수 있는 스크롤 델타 양
  • consumed:
    이전 단계에서 이미 소비된 델타 양
  • source:
    스크롤 이벤트의 발생 원인 (예: 터치, 플링, 사이드 이펙트 등)

이 정보를 통해 스크롤이 위로 전달되는 과정을 세밀하게 제어할 수 있습니다.

 

어떤 상황에서는 더 이상 스크롤 델타를 상위로 전달하고 싶지 않을 때가 있습니다.
이럴 때는 NestedScrollConnection에서 델타를 직접 반환하여 상위로의 전파를 중단할 수 있습니다:

val disabledNestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            return if (source == NestedScrollSource.SideEffect) {
                available // 델타를 그대로 반환 → 상위로 전달되지 않음
            } else {
                Offset.Zero // 상위로 전달
            }
        }
    }
}

이렇게 하면 특정 소스(SideEffect 등)에서 발생한 스크롤만 가로채고, 그 외의 스크롤은 평소처럼 전파되도록 제어할 수 있습니다.

 

앞서 NestedScrollConnection을 직접 구현하여 사용하던데, 그렇다면 NestedScrollDispatcher도 별도로 구현해야 할까요?

NestedScrollConnection은 중첩 스크롤 흐름에 참여하기 위한 인터페이스이고, NestedScrollDispatcher는 그 흐름을 시작하고 전파하는 역할을 합니다.

  • scrollable, LazyColumn 같은 기본 컴포넌트에는 이미 내장 Dispatcher가 있어 따로 구현할 필요가 없습니다.
  • 직접 제스처(pointerInput 등)로 스크롤 주기를 시작해야 하는 커스텀 스크롤러를 만들 때만 NestedScrollDispatcher를 직접 생성해 사용하면 됩니다.

 

예시로 이해하는 중첩 스크롤 동작 과정

예를 들어서, Child → Parent 구조라고 할 때, Child가 스크롤 델타를 10px 발생시켰다고 해봅시다.

 

① Pre-scroll 단계

  1. Child가 10px available 을 위로 보냄
  2. Parent이 onPreScroll()을 받고 4px 소비했다고 리턴함

👉 각 부모는 리턴 값으로 자신이 소비한 양을 보고함
👉 최종적으로 Child에게는 “부모가 4px 소비했으니 너는 6px만 남았다”가 전달됨

 

② Node consumption 단계

Child가 스크롤 가능한 6px중 2px을 스크롤하고, 실제 화면 이동이 일어남.

 

③ Post-scroll 단계

이제 Child가 소비된(consumed) 2px, 남은(available) 4px 정보를 부모에게 보냄.

  • Parent1의 onPostScroll(consumed=2px, available=4px) 호출
    Parent이 남은 px 소비 처리
    (Offset.zero을 리턴 함으로써 부모 컴포저블에서 남은 스크롤을 이동하도록 처리하거나, available을 return 함으로써 소비 했다고하고 스크롤 이동 없이 종결 시킬 수 있음)  

자, 이제 위에서 개념을 어느 정도 설명했으니, 코드로 다른 경우의 예시를 들어보겠습니다.

다음은 "Resize an image on scroll" (스크롤에 따라 이미지 크기 조절) 예제이며, Compose의 NestedScrollConnection을 이용해 스크롤 위치에 따라 이미지 크기가 동적으로 변하는 예시입니다.

@Composable
fun ImageResizeOnScrollExample(
    modifier: Modifier = Modifier,
    maxImageSize: Dp = 300.dp,
    minImageSize: Dp = 100.dp
) {
    var currentImageSize by remember { mutableStateOf(maxImageSize) }
    var imageScale by remember { mutableFloatStateOf(1f) }

    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                // 스크롤 델타에 따라 이미지 크기 변경량 계산
                val delta = available.y
                val newImageSize = currentImageSize + delta.dp
                val previousImageSize = currentImageSize

                // 이미지 크기를 최소/최대 범위 내로 제한
                currentImageSize = newImageSize.coerceIn(minImageSize, maxImageSize)
                val consumed = currentImageSize - previousImageSize

                // 이미지의 스케일(확대/축소 비율) 계산
                imageScale = currentImageSize / maxImageSize

                // 소비한 스크롤 양을 반환
                return Offset(0f, consumed.value)
            }
        }
    }

    Box(Modifier.nestedScroll(nestedScrollConnection)) {
        LazyColumn(
            Modifier
                .fillMaxWidth()
                .padding(15.dp)
                .offset {
                    IntOffset(0, currentImageSize.roundToPx())
                }
        ) {
            // 예시용 리스트 아이템
            items(100, key = { it }) {
                Text(
                    text = "Item: $it",
                    style = MaterialTheme.typography.bodyLarge
                )
            }
        }

        Image(
            painter = ColorPainter(Color.Red),
            contentDescription = "Red color image",
            Modifier
                .size(maxImageSize)
                .align(Alignment.TopCenter)
                .graphicsLayer {
                    scaleX = imageScale
                    scaleY = imageScale
                    // 이미지가 확대/축소될 때 수직 방향으로 가운데 정렬 유지
                    translationY = -(maxImageSize.toPx() - currentImageSize.toPx()) / 2f
                }
        )
    }
}

 

이 예제를 실행하면,사용자가 스크롤할 때 이미지가 스크롤 방향에 따라 자연스럽게 크기가 변합니다.

onPreScroll에서 부모가 리턴한 델타만큼은 이미 부모가 소비(consumed) 한 것이고, 그 나머지(available - consumed) 만 자식(LazyColumn)이 실제로 스크롤하게 됩니다.

  • 아래로 스크롤 → 이미지 축소
  • 위로 스크롤 → 이미지 확대
  • 크기는 minImageSize ~ maxImageSize 범위 내에서만 변경

즉, 스크롤 위치에 따라 동적으로 반응하는 헤더 이미지 효과를 구현할 수 있습니다.

 


바텀 시트에서 자식 스크롤만 가능하도록 구현하고 싶다면?

이제 처음으로 돌아가 봅시다.

바텀 시트에서 아래와 같이 자식 스크롤 도중에 부모 바텀 시트가 내려간 이유는 무엇일까요?

 

 

마지막 하단까지 스크롤한 뒤 바로 위로 올리면, 자식 컴포넌트는 이미 스크롤을 끝냈다고 판단해 모든 스크롤을 소비(consumed) 합니다.
그 결과 남은 스크롤 양(available)이 부모에게 전달되고, 부모인 바텀 시트는 이 남은 스크롤을 처리하면서 시트가 함께 내려가는 현상이 발생합니다.

 

이 문제를 해결하려면,부모가 스크롤을 소비하지 못하도록 막고 자식만 스크롤을 소비하도록 만들어야 합니다.
즉, 바텀 시트에서는 NestedScrollConnection을 구현해 부모가 스크롤을 처리하지 않게 함으로써 자식 스크롤만 동작하도록 제어할 수 있습니다.

val disabledNestedScrollConnection = remember {
    object : NestedScrollConnection {  
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            return Offset.Zero
    	}
        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            return available
        }
    }
}

 

onPreScroll에서는 Offset.Zero를 반환하여 부모가 스크롤을 소비하지 않고, 스크롤 이벤트를 온전히 자식에게 전달하도록 해야 합니다.
또한 onPostScroll에서는 남은 스크롤 값인 available을 반환하여 부모가 남은 스크롤을 소비할 수 없도록 막아야 합니다.

이러한 방식으로 필자는 바텀 시트 내부에서 자식만 스크롤할 수 있도록 구현했습니다.
따라서 바텀 시트는 상단의 dragHandle을 이용해 닫거나, 뒤로가기 동작으로 종료할 수 있습니다.

 

 

끝.


참고자료

https://developer.android.com/develop/ui/compose/touch-input/pointer-input/scroll

 

스크롤  |  Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 스크롤 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 참고: 항목 목록을 표시하려면 이러한 API 대신

developer.android.com

https://velog.io/@rhkrwngud445/Bottom-sheet-nested-scroll-%EA%B0%9C%EC%84%A0

 

Bottom sheet nested scroll 개선

서론 모달바텀시트를 구현하면서 UX적으로 2가지 개선사항이 있었다. 문제 1|문제2 :----:|:----: | 문제1은 nested Scroll 상태로 빠르게 스크롤을 할 경우, 바텀시트가 jumping 되는 현상이었다. 문제2는

velog.io