면접 질문 목록으로 가기
면접 질문실전 질문꼬리 질문

SubcomposeLayout과 측정 패스 도중의 서브컴포지션

skydovesJaewoong Eum (skydoves)||12분 소요

SubcomposeLayout과 측정 패스 도중의 서브컴포지션

언뜻 보면 SubcomposeLayout은 또 하나의 Layout처럼 보입니다. Modifier와 측정 정책(measure policy)을 넘겨주면, UI 트리에 들어갈 노드(node)를 돌려줍니다. 차이는 측정 스코프(measure scope)에 있는 단 하나의 메서드, subcompose에 숨어 있습니다. 이 메서드를 호출하면 이미 존재하는 자식을 측정하는 것이 아닙니다. 측정 패스(measure pass)가 진행되는 도중에, SubcomposeLayout이 소유하는 컴포지션(composition) 안으로 자식을 컴포즈합니다. 바로 이 하나의 능력 때문에 SubcomposeLayoutLayout이나 Box보다 무거운 도구이며, 뚜렷한 이유 없이 손을 댔다가는 예상보다 큰 비용을 치르게 됩니다.

면접에서 SubcomposeLayout은 Compose의 측정·레이아웃 단계에 대한 깊이 있는 이해를 평가하는 단골 주제입니다. 특히 BoxWithConstraints나 lazy 리스트의 내부 동작과 비용을 설명할 수 있는지가 자주 갈리는 지점이므로, 동작 원리와 성능 비용을 함께 정리해 두시는 것이 중요합니다.

이번 면접 질문을 통하여 아래 내용들을 학습하실 수 있습니다.

  • SubcomposeLayout이 측정 패스 도중에 컴포지션을 수행하는 이유와, subcompose가 각 슬롯(slot)마다 무엇을 만들어 내는지 설명할 수 있습니다.
  • 서브컴포지션(subcomposition)을 소유하는 방식이 일반 Layout에 자식을 내보내는 방식과 어떻게 다른지 구분할 수 있습니다.
  • 런타임 비용, 즉 슬롯마다 생기는 컴포지션과 슬롯 테이블(slot table), 측정에 끼어드는 컴포지션, 그리고 재측정(remeasure)으로까지 번질 수 있는 리컴포지션(recomposition)을 짚어 낼 수 있습니다.
  • BoxBoxWithConstraints를 비교하여, 매일 사용하는 컴포넌트 속에서 그 비용을 직접 확인할 수 있습니다.
  • 언제 SubcomposeLayout이 올바른 선택이고, 언제는 평범한 Layout이나 Modifier.layout, onSizeChanged만으로 충분한지 판단할 수 있습니다.

레이아웃처럼 보이지만, 컴포지션을 소유한다

일반적인 레이아웃에서는 컴포지션과 측정이 서로 분리된 단계입니다. 먼저 컴포저블 함수가 컴포지션 단계에서 실행되어 노드 트리를 만들어 냅니다. 그런 다음에야 Layout이 전달받은 자식을 측정하고 배치합니다. 측정이 시작되는 시점에는 자식이 이미 존재하며, 측정 정책은 그저 자식의 크기와 위치만 결정합니다.

SubcomposeLayout은 이 분리를 의도적으로 깨뜨립니다. 측정 스코프에 메서드 하나를 더합니다.

interface SubcomposeMeasureScope : MeasureScope {
    // @Composable 람다를 받아 그 자리에서 컴포즈한 뒤, 측정 가능한 Measurable 목록을 반환
    fun subcompose(slotId: Any?, content: @Composable () -> Unit): List<Measurable>
}

이 시그니처에 모든 것이 담겨 있습니다. @Composable 람다를 넘기면 List<Measurable>을 돌려받는데, 이는 콘텐츠가 측정 패스 안에서 그 자리에서 곧바로 컴포즈되어 이제 측정할 준비가 되었다는 뜻입니다. 일반적인 Layout은 다른 누군가가 컴포즈해 둔 자식을 소비합니다. 반면 SubcomposeLayout은 자식을 스스로 만들어 냅니다.

이 능력이 존재하는 이유는 단 하나입니다. 어떤 콘텐츠는 측정이 이루어지기 전까지 결정할 수 없기 때문입니다. BoxWithConstraints는 축소형 레이아웃과 확장형 레이아웃 중 무엇을 고를지 결정하려면 들어오는 제약 조건(constraints)이 필요합니다. lazy 리스트는 몇 개의 아이템을 컴포즈할지 알려면 뷰포트(viewport)의 높이가 필요합니다. 제약 조건은 측정 중에만 존재하므로, 그에 의존하는 컴포지션 역시 그 시점에 일어날 수밖에 없습니다. 이 글의 나머지에서 다루는 비용은 바로 이 능력에 대한 대가입니다. 따라서 실무에서 던져야 할 질문은 "어떻게 동작하는가"만이 아니라, "이 콘텐츠가 정말로 그 비용을 치를 만큼 제약 조건에 의존적인가"입니다.

subcompose가 슬롯마다 할당하는 것

SubcomposeLayout이 그저 또 하나의 Layout이 아닌 이유는, subcompose를 처음 호출할 때 무엇을 만들어 내는지에 있습니다. 서로 다른 slotId마다 각자의 런타임 객체 묶음이 생깁니다. 새로운 슬롯을 따라가 보면, 상태(state)는 먼저 루트의 자식으로 가상 LayoutNode를 생성합니다.

// 새 슬롯을 위한 가상 LayoutNode를 만들어 루트의 자식으로 삽입
private fun createNodeAt(index: Int) =
    LayoutNode(isVirtual = true).also { node ->
        ignoreRemeasureRequests { root.insertAt(index, node) }
    }

노드와 더불어 NodeState를 함께 보관하며, 이 NodeState가 슬롯의 컴포지션을 쥐고 있습니다.

// 슬롯 식별자, 콘텐츠 람다, 그리고 그 슬롯 전용 컴포지션을 함께 보관하는 상태 객체
private class NodeState(
    var slotId: Any?,
    var content: @Composable () -> Unit,
    var composition: ReusableComposition? = null,
)

이 컴포지션 자체는 createSubcomposition이 만들어 내는 진짜 컴포지션입니다.

// 부모 CompositionContext에 연결된 실제 자식 컴포지션을 생성
internal fun createSubcomposition(
    container: LayoutNode,
    parent: CompositionContext,
): ReusableComposition = ReusableComposition(createApplier(container), parent)

이 면접 질문은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 면접 질문의 전체 내용을 볼 수 있습니다.

구독하기
면접 질문 목록으로 가기