본문 바로가기

Compose

[ComposeInternals] Composable 함수들

Jetpack Compose Internals를 참고해 작성하였습니다

 

Composable 함수들

 

Composable 함수

 

Compose의 가장 기본이 되는 요소, Composable 트리 구조 작성하는데 사용

Compose Runtime이 Composable 함수를 메모리에서 큰 트리의 구성된 하나의 노드로 이해하고 나타낸다

 

예시)

@Composable
fun NamePlate(name:String) {
    // code
}

 

Composable 어노테이션

함수가 데이터를 하나의 노드로 변환하여 Composable 트리에 올리겠다는 의도 전달

데이터란 함수의 인자를 포함한 함수의 구현 정보를 포괄적으로 의미

 

입력값은 데이터, 출력은 방출(Emitting)

방출은 트리에 요소를 삽입하기 위해 기재된 일련의 동작(action)

함수 실행의 부수 효과(Side Effect)로 동작이 발생

(함수에서 입력을 받고 Unit을 반환하면 input을 어떤 방식으로든 소비하고 있을 가능성이 높다)

@Composable (Input) -> Unit

 

Composable 함수는

1. 실행될 때 구현 정보를 방출하며 Composition 과정 중에 발생

Composition은 Composable 함수를 실행하고 UI를 나타내는 트리 구조를 출력하는 행위
트리의 인메모리 표현을 만들거나 업데이트

Composable 함수 방출 과정

 

2. 읽은 데이터가 변경될 시 다시 실행되므로 메모리 구조를 항상 최신 상태로 유지

3. 트리로부터 상태(state)를 읽거나 쓸 수도 있다

 

 

Composable 함수의 속성

Composable 어노테이션은 함수나 표현식의 타입을 효과적으로 변경하며, 해당 타입에 일부 제약 사항이나 특성을 부여한다

 

Compose Runtime은 Composable 함수가 사전에 정의된 특성을 준수하도록 가정하기에 다양한 런타임 최적화 기법을 포함한다

(병렬 Composition, 우선순위에 따른 임의의 Composition 정렬, Smart Recomposition, 위치 기억법 등등)

런타임 최적화는 실행해야 하는 코드에 '확실성'을 가질 수 있을 때 가능하다
'확실성'을 활용해 다양한 실행 전략이나 평가 기법에 따라 함수를 실행하거나 "소비"할 수 있다

 

Composable 함수의 속성은 대부분 Compose Compiler에 의해 활성화된다

Compose Compiler
Kotlin 컴파일러 플러그인
코드를 Compose Runtime이 효율적으로 처리할 수 있도록 IR(Intermediate Representation)을 변환
(IR은 Kotlin 컴파일러가 Kotlin 코드를 분석하고 생성하는 중간 표현)

 

Compose Compiler에 의해 Composable 함수에 추가적인 정보를 부여할 수 있다

 

1. 호출 컨텍스트 (Calling Context)

Compose에는 Calling Context라는 개념이 존재한다

Compiler는 Composable 함수에 엄격한 규칙을 부과한다

트리가 Composable 함수로만 구성되고 Composer가 트리를 따라 전달될 수 있도록 Composable 함수는 다른 Composable 함수에서만 호출될 수 있다 (Composable Context에서만 호출 가능)

 

Runtime에 Compiler는 Composable 함수의 매개변수 목록의 끝에 Composer 인스턴스를 추가한다

Composer는 모든 하위 Composable 호출로 전달되어 트리의 모든 수준에서 접근 가능

Composer
Composable 코드와 Compose Runtime 중재자 역할
런타임 시에 트리의 형태를 빌드, 업데이트하기 위해 사용됨

Runtime 중 composer가 추가된 과정

 

예시)

@Composable
fun NamePlate(name: String, lastname: String) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(text = name)
        Text(text = lastname, style = MaterialTheme.typography.subtitle1)
    }
}

 

위 코드는 Compose Compiler에 의해 아래와 같이 변환된다

(트리 내 모든 Composable 호출로 전달되는 composer)

 

 

2. 멱등성 (Idempotent)

Composable 함수는 생성하는 노드 트리에 대해 멱등성을 가져야 한다

동일한 데이터에 대해 동일한 트리가 생성되어야 한다

멱등성이란?
연산을 여러 번 적용하더라도 결과가 달라지지 않는 성질

 

Compose Runtime은 Recomposition과 같은 작업을 위해 멱등성이 제공하는 가정에 의존한다

Recomposition
데이터(입력값)가 변경될 때 마다 Composable 함수를 다시 실행하여 업데이트 된 정보를 방출시키고 트리를 업데이트하는 작업

 

Recomposition은 트리를 아래로 순회하며 어떤 노드를 재구성할지 확인한다

데이터가 변경된 노드만 재구성을 수행하고 나머지는 생략하는데, 생략하기 위해서는 Composable 함수가 멱등성의 성질을 가져야 한다

(Runtime은 동일한 입력값에 대해 동일한 결과를 생성한다고 가정하고 이 결과는 이미 메모리에 적재되어 있어 생략할 수 있다)


3. 통제되지 않은 사이드 이펙트 방지 (Free of uncontrolled side effects)

Side Effect
호출된 함수의 제어를 벗어나서 발생할 수 있는 예상치 못한 모든 동작

 

로컬 캐시에서 데이터 읽기, 네트워크 요청, 전역 변수 설정 등의 작업

함수가 결과를 생성하기 위해 입력값에만 의존하는 것이 아니라 외부 요인에도 의존하게 되는 것

 

Compose Runtime은 Composable 함수가 예측 가능하도록 기대하지만, Side Effect가 포함되면 예측이 어려워져 멱등성이 보장되지 않는다. 함수는 Compose Runtime에 의해 짧은 시간 내 여러 번 다시 실행될 수 있기 때문이다

 

Side Effect를 생각하지 못한 코드 

예시 1) 네트워크 요청이 여러번 수행될 수 있는 위험한 코드

@Composable
fun EventsFeed(networkService: EventsNetworkService) {
    val events = networkService.loadAllEvents()
    
    LazyColumn { 
        items(events) { event ->
            Text(text = event.name)
        }
    }
}

 

예시 2) Composable 함수를 다른 Composable 함수의 결과에 의존하도록 만들거나 특정 실행 순서를 가정하는 코드

var name: String = ""

@Composable
fun MainScreen() {
    Header()
    Detail()
    EventList()
}

 

Compose Compiler에 의해 3개의 함수는 임의의 순서, 병렬로도 실행될 수 있다

만약, Detail 함수에서 name을 읽고 Header에서 name을 업데이트 한다면 원치 않는 결과가 나올 수 있다

 

모든 Composable 함수를 Stateless하게 만들도록 노력해야 한다

매개변수로 입력값을 전달하고 주어진 입력값만으로 결과를 만들 수 있어야 한다

Stateful한 함수가 필요할 경우 Compose에서 제공하는 Effect Handler를 통해 SideEffect를 실행해야 한다

 

 

4. 재시작 가능 (Restartable)

 

일반적인 함수의 콜 스택에서는 함수는 한 번씩 호출되며, 하나 또는 여러 다른 함수들을 호출할 수 있다

일반적인 함수의 콜 스택

 

Composable 함수는 Recomposition으로 인해 여러 번 다시 실행될 수 있다

Compose Runtime은 함수가 재실행될 수 있도록 함수들에 대한 참조를 유지

(4, 5함수는 입력값이 변경되면 재실행)

Composable 호출 과정

 

 

5. 빠른 실행 (Fast Execution)

Composable 함수와 트리는 빠르고 선언적이고 가벼운 방법으로 설명을 만드는 것으로 생각할 수 있다

이 설명은 메모리에 보존되어 나중에 Compose Runtime에 의해 해석되거나 구체화된다

 

Composable 함수는 UI를 구축하거나 반환하지 않는다

단순히 인메모리 구조를 구축하고 업데이트하기 위해 데이터를 방출한다

이로 인해 Composable을 더 빠르게 만들고 Runtime이 문제없이 해당 함수를 여러 번 실행할 수 있다

 

빠르게 실행되기 위해 개발자는 비용이 큰 작업은 코루틴으로 처리하고 Effect Handler에서 처리되어야 한다

 

 

6. 위치 기억법 (Positional Memoization)

함수 메모이제이션의 한 형태

입력값에 기반하여 결과를 캐싱하는 기법

함수 메모이제이션
캐싱의 한 유형으로, 이전에 계산한 값을 메모리에 저장해 동일한 계산의 반복 수행을 줄이는 기술

순수함수에 대해서만 가능하다 (동일한 입력값에 대해 항상 동일한 결과를 반환할 것이라는 확실성을 가지기 때문)

 

메모이제이션에서 함수 호출은 이름, 타입, 매개변수 값의 조합을 통해 식별되며 이를 사용해 고유한 키를 생성한다

키를 통해 캐싱된 결과를 저장하고 검색하고 읽을 수 있다

 

Compose에서는 추가적인 요소로, Composable 함수는 코드 내 호출 위치에 대한 불변의 정보를 가진다

Runtime은 동일한 함수가 동일한 매개변수 값으로 다른 위치에서 호출될 때, 동일한 Composable 부모 트리 내에서 고유한 다른 ID 생성

 

예시)

@Composable
fun MyComposable() {
    Text("Hello") // id 1
    Text("Hello") // id 2
    Text("Hello") // id 3
}

 

위 함수들을 각각 다른 인스턴스로 저장하고 고유한 정체성을 가짐

 

 

하지만 Compose Runtime이 고유한 정체성을 할당하기 어려운 경우가 있다

 

예시) 반복문에서 생성된 Composable

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            MovieOverview(movie)
        }
    }
}

 

Movie는 매번 같은 위치에서 호출되지만 각 movie는 트리에서 서로 다른 노드로 구성된다

동일한 call site에서 함수를 여러 번 호출하면 컴파일러가 함수들을 식별할 수 있는 정보가 없어, Compose Runtime은 이를 구분하기 위해 실행 순서에 의존

 

리스트 하단에 새로운 요소를 추가할 경우

 

 

하지만, 리스트 상단이나 중간에 요소를 추가할 경우 추가된 지점 아래의 모든 Movie Composable 함수에 대해 Recomposition이 발생

 

이를 방지하기 위해 Compose는 Key Composable 제공

(특정 부분(key)을 식별하는 데 사용할 값을 지정)

Compose Runtime이 함수의 위치에 상관없이 리스트에 속한 모든 항목의 정체성을 유지할 수 있다

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            // unique key
            key(movie.id) {
                MovieOverview(movie)
            }
        }
    }
}

 

 

Compose에서 위치 기억법을 활용해 remember를 사용할 수 있다

remember
트리의 상태를 유지하는 인메모리 구조에서 값을 메모리에 읽고 쓰는 역할을 수행하는 Composable 함수

 

Compose에서 메모이제이션은 이를 호출하는 Composable의 Context 내에서만 수행된다

 

 

7. Suspend 함수와의 유사성 (Similarities with suspend functions)

Kotlin의 suspend 함수는 다른 suspend 함수에서만 호출될 수 있어, suspend 함수도 호출 컨텍스트를 필요로 한다

suspend 함수로만 묶이게 보장하고 Kotlin Compiler가 모든 계산 과정에 걸쳐 런타임 환경을 주입하고 전달할 기회를 제공

 

매개변수 목록 끝에 Continuation 타입의 매개변수가 suspend 함수에 추가된다

Continuation
Kotlin 런타임이 다양한 중단점에서 실행을 일시 중단하고 재개하는 데 필요한 모든 정보를 담고 있다

 

 

Composable vs suspend

2개 모두 패턴은 비슷하지만 언어 수준에서 다른 기능을 제공

 

Continuation은 실행을 중단하고 재개하는 콜백 인터페이스로 모델링

Compose는 런타임에서 다양한 방법으로 최적화될 수 있는 함수 호출 그래프에서 인메모리 표현을 생성하도록 모델링

 

 

8. Composable 함수의 색깔 (The color of Composable functions)

함수 컬러링(Function Coloring) 개념은 처음에 동기(sync)와 비동기(async) 함수 간의 호출 관계에서 발생한 제약사항이자 복잡성

 

동기 함수에서 비동기 함수를 호출할 수 없고 동기 함수를 비동기 함수로 변경하거나,

다른 라이브러리(Promise, async/await)를 사용해야 한다

 

이 과정이 전체 코드로 퍼지면, 많은 함수를 비동기 함수로 변경해야 할 수 있다

이러한 현상이 함수를 색칠하는 것과 비슷하다고 하여 함수 컬러링이라 한다

 

Kotlin suspend는 비동기 함수와 동기 함수의 결합 문제를 해결하려고 한다.

하지만 suspend 또한 suspend 함수에서만 호출 가능해 colored된 것으로 간주된다

 

Compose에서도 비슷하다

Composable 함수는 Composable 함수에서만 호출 가능하다

Composable 함수는 표준 함수와 완전히 다른 목표를 가진다

 

서로 다른 Coloring인 표준 함수에서 Composable 함수를 호출해야 하는 경우가 있다

(forEach 람다에서 Composable 함수가 호출되는 경우)

@Composable
fun SpeakerList(speakers: List<Speaker>) {
    Column {
        speakers.forEach { 
            Speaker(it)
        }
    }
}

 

위 코드가 가능한 이유는 인라인(inline) 때문이다

컬렉션 연산자가 인라인으로 선언되어 있어 호출한 부분에 람다식을 inline시켜 간접 호출이 없는 것처럼 효과적으로 만든다

Speaker 함수의 호출은 SpeakerList 본문 내 인라인 되며 둘다 Composable 함수이기에 허용된다

 

 

9. Composable 함수 타입 (Composable funciton types)

Composable 어노테이션은 컴파일 시점에 함수의 타입을 효과적으로 변경한다

 

Composable 함수

@Composable (T) -> A

A는 Unit일 수도 있고 함수가 값을 반환하는 경우 다른 타입일 수도 있다

 

또한 Composable 람다를 일반적인 람다를 선언하는 것처럼 선언할 수 있다

val textComposable: @Composable (String) -> Unit = {
    Text(
        text = it,
        style = MaterialTheme.typography.titleSmall
    )
}

@Composable
fun NamePlate(name: String, lastname: String) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = lastname,
            style = MaterialTheme.typography.titleLarge
        )
        textComposable(lastname)
    }
}

 

또한 Composable 함수는 아래와 같은 형태의 타입을 가질 수 있다 (특정 Composable로만 범위 지정)

@Composable Scope.() -> A
@Composable
inline fun Box(
    ...
    content: @Composable BoxScope.() -> Unit
) {
    ...
    Layout(
        content = { BoxScopeInstance.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}

 

일반적으로 타입은 컴파일러에 정보를 제공하여 빠른 정적 검증을 수행하고, 편리한 코드를 생성하며 런타임에 의해 활용되는 데이터 사용 방식을 제한하고 정제하기 위해 존재한다

하지만 Composable 어노테이션은 런타임 시 Composable 함수의 유효성을 검사하고 사용하는 방법을 변경하기에 Composable 함수가 Kotlin 표준 함수와 다른 타입으로 간주되는 이유이다