ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Jetpack Compose 1.9 다시 읽기 — 그림자, 가시성 추적, 그리고 프리페치가 남긴 함정
    Android📱 2026. 8. 10. 15:35

    Compose 1.9(August '25 릴리스)는 나온 지 1년쯤 된 버전입니다. 그런데 이 릴리스에서 들어온 API 중 지금 프로덕션 코드에서 여전히 잘못 쓰이고 있는 것이 있어서, 늦었지만 정리해 둡니다.

    BOM 기준:

    implementation(platform("androidx.compose:compose-bom:2025.08.00"))

    핵심 변경은 6가지입니다. 그림자 모디파이어, 가시성 추적, OutputTransformation 스타일링, LazyLayout 안정화 + 프리페치, 2D 스크롤, 크래시 스택트레이스 진단.


    1. 드디어 box-shadow — dropShadow / innerShadow

    그동안 Compose에서 그림자를 그리는 방법은 Modifier.shadow() 하나였습니다. 문제는 이게 elevation 기반, 즉 가상의 조명 모델을 통해 계산되는 그림자라서 디자이너가 Figma에서 준 box-shadow 스펙(오프셋, 블러, spread, 색상)을 그대로 옮길 수가 없었죠. 그래서 다들 커스텀 drawBehind나 9-patch로 우회했습니다.

    1.9에서 두 개가 추가됐습니다.

    Box(
        Modifier
            .size(200.dp)
            .dropShadow(
                RoundedCornerShape(20.dp),
                dropShadow = DropShadow(
                    15.dp,
                    color = pinkColor,
                    spread = 10.dp,
                    alpha = 0.5f
                )
            )
            .background(purpleColor, shape = RoundedCornerShape(20.dp))
    )
    • Modifier.dropShadow() — 콘텐츠 에 그림자
    • Modifier.innerShadow() — 도형 안쪽(inset) 에 그림자

    여기서 한 번씩 다 걸립니다: 모디파이어 순서

    Compose 모디파이어는 순서가 의미를 갖습니다. 그림자 모디파이어는 특히 그렇습니다.

    • dropShadowbackground보다 먼저 와야 합니다. 그림자 위에 콘텐츠가 덮이는 구조니까요.
    • innerShadow는 콘텐츠 위에 그려집니다. 그래서 background 뒤에 와야 보입니다.

    Image 위에 inner shadow를 얹으려면 모디파이어 체인만으로는 안 됩니다. 이미지와 같은 크기의 Box를 위 레이어에 겹쳐서 거기에 innerShadow를 주는 방식이 공식 예제입니다.

    Box(Modifier.size(200.dp)) {
        Image(
            painter = painterResource(id = R.drawable.cape_town),
            contentDescription = null,
            contentScale = ContentScale.Crop,
            modifier = Modifier.fillMaxSize().clip(shape)
        )
        Box(
            Modifier
                .fillMaxSize()
                .innerShadow(shape, innerShadow = InnerShadow(15.dp, spread = 15.dp))
        )
    }

    2. 가시성 추적 — 이게 이 릴리스에서 가장 중요합니다

    Compose UI 1.8에 onLayoutRectChanged(성능 좋은 위치 추적)가 들어왔고, 1.9는 그 위에 실무에서 바로 쓰는 두 API를 올렸습니다.

    LazyColumn {
        items(feedData) { video ->
            VideoRow(
                video,
                Modifier.onVisibilityChanged(
                    minDurationMs = 500,
                    minFractionVisible = 1f
                ) { visible ->
                    if (visible) video.play() else video.pause()
                },
            )
        }
    }

    minDurationMs(얼마나 오래 보였는지)와 minFractionVisible(얼마나 많이 보였는지)를 조건으로 줄 수 있는 게 핵심입니다. "화면에 절반 이상 500ms 이상 노출되면 임프레션" 같은 실제 로깅 스펙을 API 레벨에서 표현할 수 있게 됐습니다.

    임프레션 로깅용으로는 Modifier.onFirstVisible()도 함께 들어왔습니다. 다만 이건 Compose 1.11(April '26)에서 deprecated 됐습니다 — lazy layout에서 스크롤할 때 여러 번 트리거되는 문제가 있었죠. 지금 새로 짜신다면 onVisibilityChanged() 하나로 통일하는 게 맞습니다.


    3. OutputTransformation에서 스타일 적용

    BasicTextFieldOutputTransformation은 "입력 값은 그대로 두고 화면 표시만 바꾸는" 장치입니다. 1.9부터 여기서 색·굵기 같은 스타일까지 적용할 수 있습니다. 새로 추가된 TextFieldBuffer.addStyle()SpanStyle / ParagraphStyle을 적용하면서도 하위 TextFieldState는 건드리지 않습니다.

    // 1234567890 -> (123) 456-7890, 구분자만 회색으로
    val phoneTransformation = OutputTransformation {
        if (length == 10) {
            insert(0, "(")
            insert(4, ") ")
            insert(9, "-")
    
            val gray = Color(0xFF666666)
            addStyle(SpanStyle(color = gray), 0, 1)
            addStyle(SpanStyle(color = gray), 4, 5)
            addStyle(SpanStyle(color = gray), 9, 10)
        }
    }
    
    BasicTextField(
        state = myTextFieldState,
        outputTransformation = phoneTransformation
    )

    전화번호, 카드번호, 사업자등록번호처럼 표시용 구분자만 흐리게 보여주고 싶은 케이스가 이제 깔끔하게 처리됩니다. addStyle()OutputTransformation 내부에서만 호출 가능합니다.


    4. LazyLayout 안정화와 LazyLayoutCacheWindow

    LazyLayoutMeasurePolicy, LazyLayoutItemProvider, LazyLayoutPrefetchState가 모두 stable이 됐습니다. 즉 직접 Lazy 컴포넌트를 만들 수 있습니다. 커스텀 카루셀이나 스프레드시트형 레이아웃을 LazyLayout 위에 얹을 수 있게 된 거죠.

    더 실용적인 건 프리페치입니다. 기존 기본 동작은 이랬습니다.

    • 스크롤 방향으로 아이템 딱 1개만 미리 컴포즈
    • 화면 밖으로 나가면 즉시 폐기 → 되돌아 스크롤하면 다시 컴포즈

    LazyLayoutCacheWindow로 앞으로 프리페치할 양과 뒤로 유지할 양을 직접 지정할 수 있습니다.

    @OptIn(ExperimentalFoundationApi::class)
    @Composable
    private fun LazyColumnCacheWindowDemo() {
        // 앞으로 150.dp 프리페치, 뒤로 100.dp 유지
        val dpCacheWindow = LazyLayoutCacheWindow(ahead = 150.dp, behind = 100.dp)
        // 비율로도 가능
        // val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f)
    
        val state = rememberLazyListState(cacheWindow = dpCacheWindow)
        LazyColumn(state = state) {
            items(1000) { Text(text = "$it", fontSize = 80.sp) }
        }
    }

    당연히 트레이드오프가 있습니다. 범위를 넓히면 스크롤은 매끄러워지지만 동시에 살아있는 컴포지션 수와 메모리가 늘어납니다. 아이템이 이미지나 중첩 리스트를 품고 있으면 과하게 잡지 마세요.


    5. 그래서, 지금도 잘못 쓰이고 있는 것

    위 두 기능(가시성 API와 프리페치)은 사실 하나의 이야기입니다. 공식 문서도 이걸 Note로 강조합니다.

    프리페치는 화면에 보이지 않는 아이템까지 컴포즈합니다. 즉 아이템 안의

    // ❌ 프리페치된, 아직 안 보이는 아이템에서도 실행됩니다
    LaunchedEffect(Unit) { logImpression(item) }

    이 코드는 아이템이 실제로 보이기 전에 실행됩니다. 임프레션 로깅을 이렇게 구현했다면 거짓 노출이 섞여 들어갑니다. 영상 자동재생도 같습니다.

    // ✅ 실제 가시성 기준
    Modifier.onVisibilityChanged(minDurationMs = 500, minFractionVisible = 1f) { visible ->
        if (visible) logImpression(item)
    }

    중요한 건 "나는 LazyLayoutCacheWindow를 안 쓰니까 괜찮다"가 아니라는 점입니다. 공식 문서는 이 프리페치 메커니즘이 향후 릴리스에서 기본 활성화될 예정이라고 명시하고 있습니다. 지금 opt-in하지 않았더라도, composition effect를 가시성 신호로 쓰는 코드는 미리 걷어내는 게 안전합니다.

    LaunchedEffect / DisposableEffect는 "컴포즈됐다"는 신호이고, "보인다"는 신호가 아닙니다. 1.9 이전에는 그 둘이 대충 같아서 넘어갔던 것뿐입니다.


    6. 2D 스크롤 — Modifier.scrollable2D

    Draggable2D에 이어 Scrollable2D가 들어왔습니다. 기존 Scrollable은 단일 축만 다뤘는데, 이제 2차원 스크롤 + 플링이 가능합니다. 2D 시나리오의 nested scroll도 지원합니다. 스프레드시트, 대형 이미지 뷰어, 좌표 캔버스류에 쓰입니다.

    val offset = remember { mutableStateOf(Offset.Zero) }
    Box(
        Modifier
            .size(150.dp)
            .scrollable2D(
                state = rememberScrollable2DState { delta ->
                    offset.value = offset.value + delta
                    delta // 델타를 전부 소비했다고 알림
                }
            )
            .background(Color.LightGray),
        contentAlignment = Alignment.Center,
    ) {
        Text("X=${offset.value.x.roundToInt()} Y=${offset.value.y.roundToInt()}")
    }

    View interop 스크롤 버그 수정

    XML과 Compose를 섞어 쓰는 코드베이스라면 이쪽이 더 반가울 수 있습니다.

    • ViewTreeObserver.OnScrollChangedListenerCompose 스크롤 이벤트를 수신할 수 있게 됨
    • Compose↔View 간 플링 중 잘못된 velocity가 전달되던 문제 수정
    • Compose가 View의 nested scroll 콜백을 올바른 순서로 호출
    • AndroidView 안의 NestedScrollView에서 nested scroll이 정상 동작

    기존에 "Compose 안에 View를 넣으면 스크롤이 이상하게 끊긴다"로 알려진 이슈 상당수가 여기 해당합니다.


    7. Compose 크래시에 내 코드가 안 보이던 문제

    Compose 크래시 스택트레이스에 프레임워크 내부만 찍히고 내 composable은 안 보여서 디버깅이 힘들었던 경험, 다들 있으실 겁니다. opt-in API로 composable 이름과 위치를 스택트레이스에 넣을 수 있습니다.

    class App : Application() {
        override fun onCreate() {
            // 릴리스 성능 저하를 피하려면 debug 플레이버에서만
            Composer.setDiagnosticStackTraceEnabled(BuildConfig.DEBUG)
        }
    }

    주의사항:

    • 컴포지션이 생성되기 전(앱 엔트리포인트)에 설정해야 정보가 수집됩니다.
    • 추가 정보 수집 비용이 있어 릴리스 빌드 비권장입니다.
    • minify된 APK에서는 동작하지 않습니다. 즉 프로덕션 크래시 리포트를 개선해 주는 도구는 아니고, 로컬 재현·디버깅용입니다.

    8. 새 어노테이션과 Lint

    runtime-annotation이라는 새 라이브러리가 생겼습니다. 컴파일러·툴링용 어노테이션을 compose-runtime 의존성 없이 쓸 수 있게 하는 게 목적입니다. @Stable, @Immutable, @StableMarker가 여기로 이동했습니다 — 이제 Compose를 모르는 도메인 모듈의 클래스에도 안정성 어노테이션을 붙일 수 있습니다. 멀티모듈 프로젝트에서 stability 문제를 잡을 때 꽤 유용합니다.

    새 어노테이션 2종:

    어노테이션 의미 Lint 레벨
    @RememberInComposition 생성자·함수·프로퍼티 getter에 표시. composition 안에서 remember 없이 직접 호출하면 안 됨 error
    @FrequentlyChangingValue 함수·프로퍼티 getter에 표시. composition 안에서 직접 읽으면 빈번한 recomposition 유발 (스크롤 위치, 애니메이션 값 등) warning

    @FrequentlyChangingValue는 특히 scrollState.value를 composable 본문에서 그대로 읽어 매 프레임 recomposition을 유발하는 흔한 실수를 잡아줍니다.


    그 외

    • AGP / Lint 8.8.2 이상이 필수입니다. lint 체크 호환성·안정성을 단순화하기 위한 조치입니다.
    • 텍스트 컨텍스트 메뉴 커스터마이징 API 2종:
      • Modifier.appendTextContextMenuComponents() — 항목 추가
      • Modifier.filterTextContextMenuComponents() — 항목 제거

    정리

    1년 지난 릴리스지만, 실제로 챙겨야 할 건 두 가지로 압축됩니다.

    1. 디자인 스펙대로 그림자를 그릴 수 있게 됐다dropShadow / innerShadow, 단 모디파이어 순서 주의.
    2. LaunchedEffect로 임프레션을 찍는 코드는 이제 틀렸다 — 프리페치가 기본 활성화될 예정이므로 onVisibilityChanged()로 옮겨야 한다.

    나머지(2D 스크롤, LazyLayout 공개 API, 스택트레이스 진단)는 필요할 때 찾아 쓰면 되는 도구들입니다.

     

    원문: What's new in the Jetpack Compose August '25 release

     

    What’s new in the Jetpack Compose August ’25 release

    The Jetpack Compose 1.9 release introduces features like advanced shadow modifiers, new visibility modifiers, and enhanced rich styling capabilities.

    android-developers.googleblog.com

     

    What's new in the Jetpack Compose August '25 release 을 번역하였습니다

Designed by Tistory.