[Compose] Modifier.clickable { } 에 대한 고찰(부제: 터치 이벤트 전달)

easyhooon·2026년 4월 22일
post-thumbnail

서론

Modifier.clickable { } 은 컴포넌트에 클릭 이벤트를 부여하는 용도로 가장 자주 쓰이지만,
빈 람다({ })를 넘겨 하위 컴포넌트로의 클릭 전파를 차단하는 용도로도 종종 사용하곤 했다.
(ex. 로딩 컴포넌트를 전체 화면으로 구성 및 clickable { }를 추가하여 로딩이 화면에 노출되는 동안 다른 컴포넌트들을 클릭할 수 없도록 함)

어느날 문득, 위 내용과 관련해서 QA 팀에서 JIRA 티켓이 올라왔다.

"스플래시 노출 중 화면을 터치하면 클릭한 위치에 해당하는 메뉴가 노출되는 이슈"

분명히 스플래시 화면에 noRippleClickable { }을 적용하여 터치 이벤트를 막았는데, 하단 WebView에 터치 이벤트가 그대로 전달되고 있다는 의미였다.

clickable은 이벤트를 consume하지 않나? Composable 위에선 분명 막혔는데, AndroidView(WebView) 위에선 왜 뚫릴까?
Composable 간의 동작과 Composable - AndroidView 간의 동작이 다른 걸까?

이번 글은 이러한 의문을 해결하기 위해 Compose 포인터 입력 파이프라인이 AndroidView interop 구간에서 어떻게 동작하는지 알아보도록 하겠다.

본론

문제 발생

구조는 단순하다. WebView 기반 앱에서, 앱 실행 직후 WebView내 HTML/JS가 로딩되는 동안 노출되는 흰 화면을 가리기 위해 커스텀 스플래시 화면인 SplashOverlay를 위에 덮어둔다.

// MainScreen
Box {
    WebViewScreen(...)              // 하단: AndroidView(WebView)
    if (showSplash) {
        SplashOverlay(...)          // 상단: Composable
    }
}

당연하게도 이 스플래시가 떠있는 동안 사용자 터치가 하단 웹뷰에 도달하면 안 된기 때문에 스플래시 화면을 구성하는 최상단 Box에서 클릭 이벤트를 소비시키고자 하였다.

/**
 * ripple 없이 클릭 이벤트를 처리하는 clickable.
 * MaterialTheme 환경에서 ripple이 기본 indication으로 적용
 */
fun Modifier.noRippleClickable(
    enabled: Boolean = true,
    onClick: () -> Unit = {},
): Modifier = composed {
    val interactionSource = remember { MutableInteractionSource() }
    this.clickable(
        interactionSource = interactionSource,
        indication = null,
        enabled = enabled,
        onClick = onClick,
    )
}

이를 적용한 SplashOverlay는 다음과 같다.

@Composable
fun SplashOverlay(
    modifier: Modifier = Modifier,
    onComplete: () -> Unit = {},
) {
    Box(
        modifier = modifier
            .fillMaxSize()
            .background(Color.White)
            .noRippleClickable { },     // 빈 클릭 핸들러 = 이벤트 소비,
        contentAlignment = Alignment.Center,
    ) {
        SplashContent(onComplete = onComplete)
    }
}

여태 여러 앱을 개발해오면서 경험상 Composable끼리 겹친 화면에선 완벽하게 동작했다. 그런데 실제 앱에서 스플래시 노출 중 터치를 해보니...

  • 화면내 noRippleClickable의 onClick은 호출되지 않음 (빈 람다라 당연)
  • 그런데 하단 WebView 내 JS 이벤트 핸들러가 반응함

clickable이 해당 터치(클릭) 이벤트를 소비하고 있다고 생각했는데 이벤트가 WebView까지 내려간다...

Compose Pointer Input 이해하기

원인을 분석하기 전에, Compose에서 포인터 이벤트가 어떻게 전파되는지부터 짚고 넘어가야 한다. 사실 Compose는 이 과정을 크게 두 단계로 나누어 처리한다. 먼저 어느 Composable이 이벤트를 받을지 결정하는 단계(Hit Testing)를 거친 뒤, 선별된 Composable들에게 3번의 pass로 이벤트를 전달한다.

Hit Testing

터치가 발생하면 Compose는 가장 먼저 UI 트리를 훑으며 해당 좌표에 속한 Composable들을 추려낸다. 이를 Hit Testing이라 부른다.

Hit-testing flows from the top of the UI tree to the bottom. A composable is "hit" when the pointer event occurred within the bounds of that composable.
공식 문서 - Understand gestures

중요한 점은, 같은 트리 레벨에 겹쳐진 여러 Composable이 존재할 경우 기본적으로 z-index가 가장 높은 Composable만 hit 대상이 된다는 것이다. 예를 들어 Box에 두 개의 Button이 겹쳐서 쌓여 있다면, 위에 그려진 Button만 포인터 이벤트를 수신한다.

즉, 3번의 pass는 트리 내 모든 Composable이 아니라, 이 hit test를 통과한 Composable들에 대해서만 이루어진다.

3번의 Pass

Hit Testing을 통해 이벤트 수신 대상이 정해지면, 해당 Composable들에게 이벤트가 3번 전달된다. 각 pass는 트리를 순회하는 방향이 다르다.

관련 공식문서 참고

Pass순회 방향설명
Initial부모 → 자식부모에게 먼저 전달된 뒤 자식으로 내려감 (상위가 가로챌 기회)
Main자식 → 부모자식에게 먼저 전달된 뒤 부모로 올라감 (기본 처리 지점)
Final부모 → 자식Main에서의 consume 결과를 부모부터 자식까지 다시 내려보냄

여기서 주의할 점은 3번의 Pass(Initial, Main, Final) 가 각각 동일 Composable을 한 번씩 거친다는 것이다.

'Initial에서 부모가 이벤트를 먼저 받았는데 왜 Main에서 자식이 먼저 받는가?'라는 의문이 들 수 있는데, 이는 서로 다른 순회라 별개의 이야기다. 같은 이벤트가 트리를 3차례 왕복하면서 매번 다른 방향으로 순회한다고 이해하면 된다.

대부분의 기본 modifier (clickable, draggable, scrollable 등)는 Main pass에서 동작한다. 이는 자식이 부모보다 먼저 제스처를 처리하는 것이 가장 직관적인 동작이기 때문이다. 예를 들어 리스트 아이템 안의 버튼을 탭했을 때, 리스트가 아닌 버튼이 먼저 이벤트를 처리하는 것이 자연스럽다.

반면 Initial pass는 부모가 자식보다 먼저 이벤트를 가로채야 하는 특수한 상황에서 사용된다. 예컨대 툴팁 컴포넌트가 long-press를 자식보다 먼저 처리해야 하는 경우 등이 대표적이다.

Consume의 의미

포인터 이벤트에서 consume은 "이 이벤트는 내가 처리했으니 다른 핸들러는 무시하라"는 표시에 가깝다. 실제로 이벤트가 트리에서 제거되는 것은 아니고, 각 PointerInputChange의 consume 상태가 업데이트되어 이후 pass나 다른 핸들러가 이를 확인한 뒤 자신의 동작 여부를 결정할 수 있게 된다.

예를 들어 ListItem과 그 안에 있는 Button이 모두 제스처 핸들러를 가지고 있다고 가정해보자. 사용자가 Button을 탭하면 Button이 이벤트를 consume하고, ListItem은 consume된 이벤트를 보고 "이미 처리되었구나"라고 판단해 자신의 클릭 동작을 트리거하지 않는다.

정리하면, Compose의 포인터 이벤트 흐름은 다음과 같다.

[터치 발생]
   ↓
Hit Testing: 어떤 Composable이 이벤트를 받을지 선별
   ↓
Initial pass (부모 → 자식)
   ↓
Main pass (자식 → 부모) ← 대부분의 modifier가 여기서 동작
   ↓
Final pass (부모 → 자식) ← consume 결과 전파

문제 해결

clickable의 소비 타이밍

그렇다면 Modifier.clickable은 정확히 어느 시점에서 이벤트를 소비하는 걸까? Clickable 의 내부를 따라가보면 결국 detectTapGestures 계열의 제스처 감지기로 이어지는 것을 확인할 수 있다.

Clickable.kt
TapGestureDetector.kt

// androidx/compose/foundation/gestures/TapGestureDetector.kt
suspend fun PointerInputScope.detectTapGestures(...) = coroutineScope {
    awaitEachGesture {
        val down = awaitFirstDown()   // 기본: PointerEventPass.Main
        down.consume()
        ...
        // UP 대기, 탭 확정 시 consume
    }
}
package androidx.compose.foundation.gestures

suspend fun AwaitPointerEventScope.awaitFirstDown(
    requireUnconsumed: Boolean = true,
    pass: PointerEventPass = PointerEventPass.Main,
): PointerInputChange {
    var event: PointerEvent
    do {
        event = awaitPointerEvent(pass)
    } while (!event.isChangedToDown(requireUnconsumed))
    return event.changes[0]
}

여기서 핵심은 awaitFirstDown()의 기본 pass가 Main이라는 점이다.

즉, clickable이 DOWN 이벤트를 consume하더라도 그 타이밍은 Main pass이기 때문에, 그보다 먼저 Initial pass에서 이벤트를 가져가는 "무언가" 가 존재한다면 clickable은 손쓸 방법이 없다는 의미가 된다.

그리고 그 "무언가" 가 바로 AndroidView interop 경계에 숨어있다.

AndroidViewHolder의 pointerInteropFilter

AndroidView { }를 사용하면 내부적으로 AndroidViewHolder (ViewGroup)가 실제 View를 감싸게 된다. 이 홀더의 core modifier를 살펴보면 다음과 같이 pointerInteropFilter가 박혀있는 것을 확인할 수 있다.

AndroidViewHolder.android.kt

// androidx/compose/ui/viewinterop/AndroidViewHolder.android.kt
val coreModifier = Modifier
    .pointerInteropFilter(this)   // ← this = AndroidViewHolder
    .drawBehind { ... }
    .onGloballyPositioned { ... }
 
layoutNode.modifier = modifier.then(coreModifier)

그리고 이 pointerInteropFilter의 내부 구현은 아래와 같다.

PointerInteropFilter.android.kt

// androidx/compose/ui/input/pointer/PointerInteropFilter.android.kt
internal fun Modifier.pointerInteropFilter(view: AndroidViewHolder): Modifier {
    val filter = PointerInteropFilter()
    filter.onTouchEvent = { motionEvent ->
        view.dispatchTouchEvent(motionEvent)   // ← MotionEvent를 WebView로 직송
    }
    ...
}

즉 AndroidView의 자식 View (여기서는 WebView)로 MotionEvent를 forward하는 통로가 따로 존재하는 것이다. 그리고 결정적인 주석이 PointerInteropFilter.android.kt 클래스내에 존재한다.

When the type of event is not a movement event, we dispatch to the Android View as soon as possible (during PointerEventPass.Initial) so that the Android View can react to down and up events.

ACTION_DOWN과 ACTION_UP이 Initial pass 시점에서 AndroidView로 그대로 던져진다는 의미다. Main pass까지 기다려주지 않는다.

그래서 왜 clickable이 뚫렸는가

이제 전체 타임라인이 선명해진다.

[터치 발생]
   │
   ▼
Initial pass
   ├─ SplashOverlay의 clickable: 관심 없음 (Main pass에서 대기 중)
   └─ AndroidViewHolder의 pointerInteropFilter:
         → WebView.dispatchTouchEvent(MotionEvent)  ✅ 이미 전달됨
   │
   ▼
Main pass
   └─ SplashOverlay의 clickable: 이제야 DOWN consume  ❌ 이미 늦음

clickable은 제스처 감지용이지 이벤트 차단용이 아니다. 감지 시점이 Main pass이기 때문에, Initial pass에서 벌어지는 AndroidView interop의 MotionEvent forward를 막을 방법이 애초에 없었던 것이다.

Composable끼리 겹친 화면에서 정상 동작했던 이유도 여기서 설명된다. 해당 케이스에서는 pointerInteropFilter를 거치지 않으니 Initial pass에 별도의 forward 경로 자체가 존재하지 않는다.

또한 Compose는 pass가 시작되기 전 단계에서 히트 테스트(hit-testing) 를 통해 터치 좌표에 속한 Composable들을 추려내는데, 겹쳐진 경우 z-index 최상위 Composable만 hit 대상이 된다.

즉, 스플래시 화면이 히트 테스트 단계에서 이미 유일한 수신자로 확정되어 있었기 때문에, 하단 Composable로는 이벤트 자체가 전달되지 않았던 것이다.

해결: Initial pass에서 raw consume

원인이 명확해졌으니 해결도 단순하다. AndroidViewHolder.pointerInteropFilter가 forward하기 이전 단계, 즉 Initial pass 시점에서 이벤트를 consume해버리면 된다. 이를 위해 아래와 같은 blockTouches라는 확장 함수를 도입하였다.

/**
 * 모든 터치 이벤트를 Initial pass에서 소비해 하위 AndroidView(WebView 등)로
 * 전달되지 않도록 차단.
 */
fun Modifier.blockTouches(): Modifier = pointerInput(Unit) {
    awaitPointerEventScope {
        while (true) {
            awaitPointerEvent(PointerEventPass.Initial)
                .changes.forEach { it.consume() }
        }
    }
}

동작 과정은 다음과 같다.

  • awaitPointerEvent(PointerEventPass.Initial) — Main이 아닌 Initial pass에서 이벤트를 수신
  • changes.forEach { it.consume() } — 모든 PointerInputChange를 즉시 consume
  • consume된 change는 이후 PointerInteropFilter가 확인하는 시점에 이미 소비 상태이므로, view.dispatchTouchEvent(motionEvent) 호출이 스킵됨
    기존의 noRippleClickable { } 대신 blockTouches()를 적용한 후 동일 시나리오를 재현해보니, WebView의 JS 핸들러가 더 이상 반응하지 않는 것을 확인할 수 있었다.
Box(
    modifier = Modifier
        .fillMaxSize()
        .background(Color.White)
        .blockTouches(),    // noRippleClickable { } → blockTouches() 로 교체
) { ... }

결론

정리하면 다음과 같다.

화면 modifier하단이 Composable하단이 AndroidView(WebView)
noRippleClickable { }✅ 차단❌ 뚫림
blockTouches()✅ 차단✅ 차단

이번 작업을 통해 clickable에 대해 가지고 있던 관점을 다시 정리해볼 수 있었다.

clickable은 결국 "클릭 감지 + 부가 기능(ripple, focus, semantics 등)"이 본질이지, 이벤트 차단을 보장해주는 API는 아니었던 것이다. 탭이 확정된 시점(Main pass + UP)에 consume한다는 계약만 존재할 뿐, 그 이전 단계에서 벌어지는 일까지 책임져주지는 않는다.

특히 이번 케이스처럼 Compose와 AndroidView의 경계가 얽혀있는 상황에서는 더욱 그렇다. AndroidViewHolder가 내부적으로 pointerInteropFilter를 통해 Initial pass에서 MotionEvent를 Native View로 던지는 구조이기 때문에, 일반 Composable 간 히트 테스트와는 완전히 다른 레이어에서 동작하는 별도의 forward 경로가 존재하고 있었다.

따라서 이벤트 전달 자체를 끊어야 하는 상황이라면 pointerInput + PointerEventPass.Initial + consume() 조합이 필요하다. Scrim, Modal dim, Loading Indicator 등 하위 뷰로의 전달을 원천적으로 차단해야 하는 패턴에도 동일하게 적용할 수 있을 것이다.

"그냥 되겠지~"하며 넘어갔을 clickable 한 줄 뒤에 Compose 포인터 파이프라인과 AndroidView interop 전체가 얽혀있었다는 점이 흥미로웠던 트러블 슈팅이었다.

P.S

GPT 진짜 이미지 잘뽑아준다 이제(위의 이미지들 전부 생성한것들 ㅇㅇ)

reference)

Android 소스 코드

AndroidViewHolder.android.kt
PointerInteropFilter.android.kt
Clickable.kt
TapGestureDetector.kt

공식 문서

Pointer input in Compose
Understand gestures (PointerEventPass: Initial / Main / Final)
Tap and press
AwaitPointerEventScope

관련 아티클

Android Touch System Part 5: How Gestures Work in Jetpack Compose (droidcon)

profile
실력은 고통의 총합이다. Android Developer

2개의 댓글

comment-user-thumbnail
2026년 4월 24일

좋은 글 감사합니다.
전체 화면 터치를 막을 경우에 저는 취소불가능한 dialog를 노출하도록 하는 편인데 이 부분은 어떻게 생각하시나요?

1개의 답글