안드로이드 앱

젯팩 컴포즈에서 XYZScope 의미와 동작원리 (RowScope, ColumnScope, BoxScope)

스무비 2025. 11. 28. 17:58
반응형

 

젯팩 컴포즈를 공부하다보면 레이아웃 컴포저블 종류에 대해 접하게 된다. 예를 들면 RowScope, ColumnScope, BoxScope 과 같은 것들이다. 이런것들을 본 포스팅에서는 편의상 XYZScope 이라고 하자. 일단 이것이 무엇인지 코드로 살펴보자.

@Composable
fun MainDisplay1() {
    Row {
        Text("Row", Modifier.align(Alignment.Top))
    } // <-- 이 람다함수의 수신객체는 RowScope
}

@Composable
fun MainDisplay2() {
    Column {
        Text("Column", Modifier.align(Alignment.Top))
    } // <-- 이 람다함수의 수신객체는 ColumnScope
}

@Composable
fun MainDisplay3() {
    Box {
        Text("Box", Modifier.align(Alignment.Top))
    } // <-- 이 람다함수의 수신객체는 BoxScope
}

 

위 코드를 보면 Row, Column, Box 레이아웃 컴포저블 함수를 호출할때 마지막 인자로 전달되는 람다함수를 볼 수 있다. 이 람다함수의 주석을 보면 Row 의 경우, RowScope 이 수신객체라고 되어 있다. 이것의 어떤 의미일까? 일단, 각 컴포저블의 마지막 파라미터로 전달되는 람다함수는 젯팩 컴포즈 런타임(젯팩 컴포즈 라이브러리)에서 호출한다.

(참고: Kotlin 함수 호출할 때, 제일 마지막 파라미터로 인자가 없는 람다함수를 전달하는 경우, 가독성 향상을 위해 함수의 괄호 바깥에 중괄호로 람다함수를 정의할수 있다. 이를 Trailing Lambda 라고 한다.)

 

 

컴포저블 함수의 마지막 파라미터 "람다함수 시그니처" (그리고 수신객체 지정람다)

컴포저블 함수가 젯팩 런타임에 의해 호출되는 형태를 이해하기 위해 먼저 컴포저블의 마지막 파라미터인 람다의 시그니처를 확인해보자. 아래는 Row 컴포저블의 예시인데, 마지막 파라미터인 content 에 람다함수가 전달되는 구조이다. 근데 자세히 보면 일반적인 람다함수라면 () -> Unit 와 같은 타입을 사용할텐데, RowScope.() -> Unit 이라는 타입으로 정의되어 있다.

@Composable
fun Row(
    // ... (modifier, horizontalArrangement, verticalAlignment 등)
    content: @Composable RowScope.() -> Unit // 여기가 핵심!
) { ... }

 

우리가 Row { ... } 와 같이 컴포저블 함수를 호출하면, 코틀린 컴파일러에 의해 Row(..., content: @Composable RowScope.() -> Unit) 시그니처를 가진 함수호출로 자동변환 된다고 한다. 따라서 우리가 작성한 람다함수는 RowScope 객체의 멤버함수가 되고, 람다함수내에서 RowScope 의 멤버함수에 접근이 가능한 것이다. 이때 RowScope 을 수신객체 지정람다라고 한다. 다시 Row 컴포저블 함수의 마지막 람다함수는 RowScope 이라는 객체의 멤버로 지정되도록 젯팩 컴포즈가 설계되어 있는것이다.

 

 

젯팩 컴포즈의 스코프(Scope)

앞에서 Row를 예시로 컴포저블 함수, 스코프, 수신객체 지정람다에 대해 살펴봤다. Scope 는 Kotlin 에서 제공하는 핵심기능이며, 젯팩 컴포즈에서는 코틀린의 기능을 사용하여 RowScope, BoxScope 와 같은 특정 인터페이스로 구현하여 기능을 적용한 것이다.

 

Kotlin에서 스코프(Scope)는 특정 코드블록 내에서 사용할 수 있는 함수나 프로퍼티를 제한하거나 추가하는 개념이다. 이러한 Scope는 1)수신 체 지정 람다(Lambda with Receiver)2)확장함수(Extension Function)를 통해 구현된다.

 

그럼 여기서 또 질문이 나온다. (Row 를 예시로 한다)

1) 사용자가 정의한 { ... } 람다함수내에서 사용할수 있는 RowScope 의 속성은 무엇인가?
2) Modifier.align(Alignment.Top) 와 같은 부분이 RowScope 기능을 사용한 코드라고 한다. 이 코드는 Modifier 의 align 함수를 호출한 것인데, 왜 RowScope 의 속성을 사용했다고 하는가?

 

 

RowScope 은 젯팩 컴포즈 런타임에서 정의한 인터페이스이다(아래코드 참고) 인터페이스에 정의된 함수를 보면 기본적인 Modifier 객체에는 없는 weight, align 이라는 함수를 추가(확장)한 것을 볼수 있다. 따라서 사용자가 정의한 람다함수내에서 RowScope 인터페이스에 정의한 weight, align 함수를 참조할 수 있게 되는 것이다. 이것을 확장함수라고 한다.

 

// 가상의 Jetpack Compose 코드 구조
interface RowScope {
    // Modifier에 대한 확장 함수를 정의 (RowScope 내에서만 사용 가능)
    fun Modifier.weight(weight: Float, fill: Boolean = true): Modifier
    
    // 이와 유사하게 align 함수도 정의됨
    fun Modifier.align(alignment: Alignment.Vertical): Modifier
}

 

 

정리

위에서는 코드에 초점을 잡고 Scope 에 대해 살펴보았는데, 하이레벨에서 이 기능이 왜 이렇게 설계되었는지 정리해보자.

젯팩컴포즈의 XYZScope 내부 동작원리

 

사용자가 Row { ... } 와 같이 레이아웃 컴포저블을 호출하는 상황을 생각해보면 람다함수 내부에서 Box 의 자식 컴포저블을 호출하여 화면에 UI 컴포넌트가 보이도록 할 것이다. 이때, Row 라는 컴포저블의 특징을 고려하여, 자식컴포넌트에 어떤 속성(예. align)을 적용할 것이고, 이는 Modifier 를 사용하는 방법이 제일 자연스럽다(기존에도 이런 종류의 속성을 수정하고 싶을때 Modifier 를 사용하기 때문에, 개발자의 자연스러운 개발경험 측면). 근데, 여러가지 레이아웃 컴포저블마다 지원해야 할 속성은 모두 다르기 때문에, 모든 속성을 Modifier 에 넣어놓을 수는 없다는 문제가 발생한다.

 

이러한 문제를 해결하기 위한 젯팩 컴포즈의 2가지 설계는 다음과 같다.

1) 코틀린의 Scope 기능을 활용하여 각 레이아웃마다 매핑되는 Scope 객체에 람다함수를 붙이고(수신객체 지정람다)

2) 해당 람다함수 내부에서는 기존의 코딩스타일(Modifier)을 해치지 않으면서, 레이아웃 컴포저블 타입에 맞는 멤버함수를 Modifier객체에 동적으로 붙일수 있도록(확장함수) 설계

 

 

Spark Then Move !!

 

 

 

반응형