안드로이드 개발자 로드맵 Part 5: Jetpack Compose

skydoves·2024년 7월 27일
12

원문은 Jetpack Compose: The Android Developer Roadmap – Part 5에서 확인하실 수 있습니다.

저자가 운영 중인 구독형 리파지토리인 Dove Letter를 통해 Android와 Kotlin에 대한 다양한 소식과 학습자료를 접하실 수 있습니다. 관심 있으신 분들은 Dove Letter를 방문해 주시길 바랍니다.

Android developer roadmap은 총 5부로 연재될 예정이며, 각 회차는 안드로이드 생태계의 다양한 측면을 다룹니다. 지난 포스트에서는 Design Patterns, Architecture, Asynchronous, Network, Image Loading, Local Storage 등과 관련한 내용들을 살펴보았습니다.

이번 5부에서는 Android 로드맵의 다음 6가지 섹션을 살펴봅니다.

  1. Jetpack Compose
  2. Compose UI
  3. State
  4. Side-Effects
  5. CompositionLocal
  6. Migrate to Compose

Jetpack Compose

Android 팀이 Jetpack Compose stable 1.0을 발표한 후, 프로덕션에서 Jetpack Compose를 채택하는 속도가 빠르게 빨라졌습니다. Google에서 발표한 자료에 따르면, 현재 Jetpack Compose로 빌드되어 Google Play 스토어에 성공적으로 게시된 앱이 125,000개를 넘어섰습니다.

Jetpack Compose에 대해 이야기할 때, 우리는 주로 Compose UI에 집중하는 경향이 있습니다. 그러나 Jetpack Compose는 기술적으로 세 가지 주요 구조로 구성되어 있다는 점에 유의하는 것이 중요합니다.

  • Compose Compiler (컴포즈 컴파일러): Compose Compiler는 Kotlin Multiplatform을 타깃으로 Kotlin으로 작성된 Jetpack Compose의 핵심 구성 요소입니다. KAPTKSP와 같은 기존의 어노테이션 프로세서와는 달리 Compose 컴파일러 플러그인은 FIR(Frontend Intermediate Representation)과 직접 상호 작용합니다. 이 독특한 접근 방식을 통해 플러그인은 컴파일 타임에 정적 코드를 더 많은 정보로 분석하고, Kotlin 소스 코드를 동적으로 수정하고, 궁극적으로 Java 바이트코드를 생성할 수 있습니다. 기본적으로 @Composable과 같은 Compose 라이브러리의 어노테이션은 Compose Compiler가 내부적으로 수행하는 다양한 작업과 복잡하게 관련있습니다.

  • Compose Runtime (컴포즈 런타임): Compose Runtime은 Compose의 모델 및 상태 관리의 초석 역할을 합니다. 이 라이브러리는 자료구조의 한 개념인 슬롯 테이블을 사용하여 컴포지션의 상태를 기억하는 형태로 작동하는데, 슬롯 테이블은 갭 버퍼 (gap buffer) 데이터 구조에서 파생된 개념입니다. 이 런타임은 내부적으로 다양한 중요한 작업을 수행합니다. 프로젝트 개발에서 일반적으로 사용하는 필수 기능을 처리하는데, 여기에는 사이드 이펙트 관리, remember를 이용한 값 보존, recomposition 트리거, CompositionLocal 저장, Compose Layout 노드 구성 등이 포함됩니다.

  • Compose UI (컴포즈 UI): Jetpack Compose의 필수 구성 요소인 Compose UI는 개발자가 Composable 함수를 통해 UI를 소비하여 레이아웃 형태로 만들 수 있도록 하는 라이브러리의 집합체입니다. Compose UI 라이브러리는 Compose 레이아웃 트리의 구성을 용이하게 하는 다양한 구성 요소를 제공합니다. 이러한 레이아웃 트리는 생성되면 Compose Runtime에 의하여 소비됩니다. Kotlin Multiplatform의 기능을 활용하여 JetBrains는 Compose Multiplatform의 stable 버전을 출시했으며 이제 Android, iOS, 데스크톱 및 웹 어셈블리와 같은 각기 다른 플랫폼에 대해 동일한 Compose UI 라이브러리로 동일한 레이아웃을 빌드할 수 있습니다.

이 글에서는 Compose UI, State, Side-effects, CompositionLocal, XML에서 Jetpack Compose로의 마이그레이션 전략에 대하여 살펴봅니다. 하지만, Compose 컴파일러와 런타임의 복잡한 내용은 다루지 않는데, Compose에는 상당한 양의 내부 워크플로가 포함되기 때문입니다. Compose의 내부 작동 방식에 대하여 추가적인 학습을 원하시는 분들께서는 아래의 자료를 참조하시길 바랍니다.

Compose UI

이전 섹션에서 논의했듯이 Compose UI는 Compose Multiplatform 호환성에 초점을 맞춰 Compose 레이아웃의 트리 생성을 간소화하도록 설계된 포괄적인 구성 요소 모음인 Jetpack Compose의 필수 구성 요소입니다.

선언적 방식으로 UI 레이아웃을 만들 수 있습니다. 즉, Kotlin에서 레이아웃을 논리적으로 작성하여 프런트엔드 뷰를 명령적으로 조작할 필요 없이 UI 레이아웃을 유지할 수 있습니다. 이 접근 방식은 재사용성이 높고 직관적인 UI 레이아웃을 개발하는 데 도움이 됩니다.

이번 섹션에서는 테마(Theming), Modifier, 레이아웃 생성, 목록 구현 및 애니메이션을 포함하여 Compose UI 라이브러리를 활용하는 데 필요한 필수 개념을 자세히 살펴봅니다.

Theming

Jetpack Compose를 사용하면 디자인 시스템을 쉽게 구현할 수 있으며, source-of-truth 방식으로 미리 정의된 테마를 제공하여 앱이 일관된 모양을 유지하도록 할 수 있습니다.

Compose UI는 Material Design 2Material Design 3의 구현이라는 두 가지 주요 테마 라이브러리를 제공합니다. 이러한 테마 라이브러리는 버튼(Button), 카드(Card), 스위치(Switch), 바텀 시트(Bottom Sheet) 등 다양한 필수 디자인 구성 요소를 제공하며, 모두 내부적으로 해당 테마 스타일을 반영합니다.

아래 샘플에서 보여지는 것처럼 MaterialTheme Composable 내에서 이러한 UI 구성 요소를 활용하여 애플리케이션의 일관된 모양을 유지할 수 있습니다.

MaterialTheme(
    colors = // ...
    typography = // ...
    shapes = // ...
) {
    // content details
}

애플리케이션의 UI 사양이 Google의 Material Design 가이드라인과 다른 경우 MaterialTheme 컴포저블과 유사한 사용자 정의 디자인 시스템을 구현할 수 있는 유연성이 있습니다. 이러한 경우 Stream Video SDK의 사용자 정의 테마 시스템을 참조할 수 있습니다. 이 접근 방식을 사용하면 제공된 각 UI 구성 요소에 대해 일관된 스타일을 유지하는 동시에 사용자 관점에서 구성 요소 스타일을 쉽게 사용자 정의할 수 있습니다.

Modifier

Jetpack Compose의 Modifier는 UI 개발에 있어 중요한 구성 요소입니다. 개발자에게 Composable 요소의 속성을 변경하고 개선할 수 있는 유연성을 제공합니다. 예를 들어 너비/높이 조정, 패딩 크기, 배경색, 클릭 콜백 추가 등이 있습니다. Compose Modifier 함수의 전체 목록은 Compose Modifiers 목록에서 확인할 수 있습니다.

또한 Modifier는 레이아웃 트리 계층을 통해 효과적으로 전달되어 루트 Composable의 속성을 유지하고 확장할 수 있습니다. 이 기능은 UI 요소 전체에 걸쳐 레이아웃 구성과 스타일을 일관되고 효율적으로 전파할 수 있도록 합니다.

Modifier는 표준 Kotlin 객체이며 상태가 없으므로 아래와 같이 빌더 클래스를 체인으로 연결하여 Modifier를 간단히 만들 수 있습니다.

@Composable
private fun Greeting(name: String) {
    Row(modifier = Modifier.padding(12.dp)) {
        Text(text = "Hello,")
        Text(text = name)
    }
}

대부분의 Compose UI 구성 요소에는 modifier 매개변수가 포함되어 있어 사용자가 속성을 사용자 지정하고 스타일을 제어할 수 있습니다. 마찬가지로, 자체 Composable 함수를 만들 때는 modifier 매개변수를 노출하는 것이 좋습니다. 이 접근 방식은 호출자 쪽에서 직접 스타일을 수정할 수 있게 만들어, UI 디자인에서 더 큰 유연성과 커스텀을 제공할 수 있습니다.

@Composable
public fun Header(modifier: Modifier = Modifier) {
    Row(modifier = modifier.fillMaxWidth()) {
        ..
    }
}

제공된 예에서 Header Composable 함수에 modifier를 전달할 수 있다는 것이 분명합니다. 이 접근 방식은 구성 요소의 스타일을 외부에서 제어할 수 있게 하여 다양한 요구 사항에 따라 다른 스타일을 조정하고 적용할 수 있습니다. 함수 외부에서 스타일링하는 이러한 유연성은 UI 구성 요소의 사용자 정의 및 적응성을 향상시킵니다.

Modifier를 사용할 때 또 다른 중요한 측면은 modifier 함수가 적용되는 순서의 중요성입니다. Modifier 함수를 체인화하려면 수정자를 가장 높은 것부터 시작하여 아래로 순차적으로 레이어링해야 합니다.

이 프로세스의 각 단계는 기존 modifier를 효과적으로 래핑하여 새로운 복합적인 modifier를 구축합니다. 체인을 점진적으로 적용하면 각 modifier가 특정하고 제어된 방식으로 구성 요소의 최종 모양과 동작에 영향을 미칩니다. 이 프로세스는 트리를 탐색하는 것과 유사하며, 위에서 아래로 체계적으로 이동합니다.

아래 예제 코드는 modifier 함수의 순서를 조정하여 간단한 온라인 상태 표시기를 구현하는 방법입니다.

@Composable
fun OnlineIndicator(modifier: Modifier = Modifier) {
    Box(
        modifier = modifier
            .size(60.dp)
            .background(VideoTheme.colors.appBackground, CircleShape)
            .padding(4.dp)
            .background(VideoTheme.colors.infoAccent, CircleShape),
    )
}

Composable 함수를 구성하고 Android Studio에서 미리보기를 설정하면 아래와 같이 결과를 볼 수 있습니다.

Modifier는 Jetpack Compose에서 핵심적인 역할을 하며, 이 포스트에서는 Modifier의 필수 요소에 대해 다루지만 모든 측면을 포괄하지는 않습니다. Modifier에 대한 보다 포괄적인 이해를 위해 Compose Modifier에 대한 Android 공식 문서를 살펴볼 수 있습니다

Lists and Grids

사용자에게 나열 된 항목들을 효과적으로 표시하기 위해 목록이나 그리드를 활용하지 않는 사례는 극히 드물정도로 자주 사용되는 개념입니다. 기존의 XML 기반 Android 개발에서 성능이 뛰어나고 효율적인 목록을 만드는 데는 늘 복잡함이 수반되었으며, 특히 RecyclerView의 경우 더욱 그렇습니다. 이 접근 방식에는 복잡한 사용성 및 사용자 정의가 따라왔습니다.

반대로 Jetpack Compose는 목록과 그리드의 구현을 간소화합니다. 더 간단한 접근 방식을 제공하여 각 항목에 대해 재사용 가능한 구성 요소를 사용할 수 있습니다. Jetpack Compose의 Composable 함수는 본질적으로 독립적이게 동작하기 때문에 높은 수준의 재사용성을 제공하고 일반적으로 목록 및 그리드 구현과 관련된 복잡성을 줄입니다.

아래에 제공된 예에서 세로로 된 목록을 구현하는 것은 Jetpack Compose의 Column Composable 함수를 사용하여 간단히 해결할 수 있습니다. 이는 구조화된 레이아웃을 만드는 것이 얼마나 간단하고 효율적인지 보여줍니다.

@Composable
fun MessageList(messages: List<Message>) {
    Column(modifier = Modifier.verticalScroll()) {
        messages.forEach { message ->
            MessageItem(message)
        }
    }
}

@Composable
fun MessageItem(message: Message) {
  .. 
}

Row Composable 함수를 사용하여 가로로 된 목록을 구현할 수도 있습니다.

Jetpack Compose의 ColumnRow Composable은 모든 항목을 동시에 렌더링하기 때문에 많은 수의 항목을 표시하는 성능문제가 발생할 수 있어 많은 항목을 처리해야하는 사례에서는 적합하지 않을 수 있습니다. 이러한 시나리오의 경우 Compose UI는 목록에 LazyColumnLazyRow를 사용하거나 그리드에 LazyVerticalGridLazyHorizontalGrid를 사용하는 것이 좋습니다. 이러한 구성 요소는 화면에 보여지는 항목만 렌더링하여 대용량 데이터 세트를 효율적으로 처리하고 성능과 리소스 사용을 최적화합니다.

Jetpack Compose의 Lazy 목록과 그리드(LazyColumn 등)는 기존 RecyclerView와 유사한 목적을 제공하며 표준 Column 또는 Row Composables에 비해 향상된 성능을 제공합니다. 다음은 LazyColumn 사용을 보여주는 예입니다.

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        items(messages) { message ->
            MessageItem(message)
        }
    }
}

@Composable
fun MessageItem(message: Message) {
  .. 
}

위에 표시된 예시에서 Jetpack Compose의 지연 목록과 그리드는 도메인 특정 언어(DSL) 스타일로 고유한 스코프를 제공합니다. 이 스코프 내에서 제공된 항목 함수(itemsitem)를 사용하여 Composable 함수를 렌더링할 수 있습니다. 이 기능은 보다 체계적이고 직관적인 가독성을 제공하며 기존 RecyclerView 보다 훨씬 간단합니다.

RecyclerView.AdapterRecyclerView.ViewHolder의 복잡성 없이 Jetpack Compose에서 목록을 성공적으로 구현할 수 있으며, 위의 예시에서 살펴볼 수 있듯이 놀랍게도 15줄 미만의 코드가 사용되었습니다! 이러한 단순성과 효율성은 Android UI 개발에 Jetpack Compose를 사용하는 가장 매력적인 측면 중 하나입니다.

Animation

애니메이션은 UI 디자인의 하이라이트 역할을 하며 풍부한 사용자 경험을 제공합니다. 전통적으로 XML 기반 레이아웃에서 정교한 애니메이션을 만드는 것은 상당한 어려움을 안겨주었습니다. 이러한 복잡성은 복잡한 Android 뷰 시스템을 탐색하고 측정(measurement)을 처리해야 하는 필요성과 ValueAnimatorObjectAnimator와 같은 복잡한 애니메이션 API를 사용해야 하는 데서 비롯되었습니다.

Jetpack Compose는 전용 애니메이션 라이브러리를 제공하여 이 프로세스를 간소화합니다. 이 라이브러리는 다양한 사용자 친화적인 API를 제공하여 기존 XML 기반 레이아웃에 비해 동적 애니메이션을 훨씬 더 간단하게 구현할 수 있습니다.

  • AnimatedVisibility: 이 Composable 함수는 콘텐츠의 등장과 사라짐을 애니메이션으로 표현합니다. 기본적으로 콘텐츠는 페이드인 및 확장 효과와 함께 나타나고 페이드아웃 및 축소 효과와 함께 사라집니다. 그러나 이러한 전환은 사용자 지정 EnterTransitionExitTransition 매개변수를 지정하여 선호도에 맞게 조정할 수 있습니다.

  • Corssfade: Crossfade는 크로스페이드 효과를 사용하여 두 개의 컴포저블 함수 간에 애니메이션 전환을 제공합니다. 이 기능은 특히 서로 다른 화면 간에 부드럽고 시각적으로 매력적인 전환을 만드는 데 유용합니다.

  • AnimatedContent: AnimatedContent 컴포저블은 지정된 애니메이션 스펙 매개변수를 준수하여 주어진 대상 상태의 변경 사항에 따라 콘텐츠를 동적으로 애니메이션화합니다. 이 기능은 특히 두 개의 개별 컴포저블 함수 간에 애니메이션을 만드는 데 유용합니다.

  • animate_AsState: animate_AsState 함수는 단일 값을 애니메이션화하기 위한 Jetpack Compose의 가장 간단한 애니메이션 API입니다. 이를 사용하려면 대상 또는 종료 값을 지정하기만 하면 됩니다. 그러면 API가 자동으로 애니메이션을 시작하여 현재 값에서 제공된 대상 값으로 원활하게 전환합니다. animateDpAsState, animateFloatAsState, animateIntAsState, animateOffsetAsState, animateSizeAsState, animateValueAsState와 같은 animate_AsState 함수를 사용할 수 있으며, 이는 다양한 값 유형을 생성합니다.

  • animateContentSize: animateContentSize modifier는 크기 변경을 애니메이션화하는 간단하고 효율적인 방법을 제공합니다. 이 modifier는 자세한 콜백의 필요성을 없애 애니메이션 프로세스를 간소화합니다. 이 modifier를 컴포저블에 적용하고 미리 정의된 상태에 따라 결정된 다양한 콘텐츠 크기에 의해 크기 전환을 자동으로 처리하도록 할 수 있습니다.

Jetpack Compose에서 사용할 수 있는 다양한 강력한 애니메이션 API를 살펴보았습니다. 기존의 XML 기반 레이아웃 시스템과 비교했을 때 사용 편의성이 뛰어나 애플리케이션 개발에 Jetpack Compose를 도입해야 하는 강력한 이유를 만들어주기도 합니다. Compose 내 애니메이션에 대해 더 자세히 알아보려면 Compose 애니메이션에 대한 포괄적인 가이드를 참조하세요.

State

프로그래밍에서 "상태"라는 용어는 특정 순간의 메모리 내 정보 표현을 의미하며, 이는 프로그램의 동작을 결정합니다. 이 개념은 주로 사용자 상호작용과 관련이 있으며, 사용자가 수행한 다양한 입력과 동작에 따라 프로그램이 어떻게 반응하고 작동하는지에 영향을 미칩니다.

네트워크에서 사용자 정보를 가져오도록 설계된 화면에 버튼이 있는 시나리오를 고려해 보겠습니다. 이 맥락에서 작업 상태는 Idle에서 로딩 중으로, 마지막으로 완료로 진행되는 것으로 표현할 수 있습니다. 이 시퀀스는 Idle(아무 일도 일어나지 않음)에서 시작하여 로딩 중(버튼을 클릭하면 네트워크에서 데이터를 가져오는 중)으로 이동하고 완료(네트워크에서 페이로드를 성공적으로 수신)로 끝납니다.

상태가 변경되면 UI의 역할은 주로 상태 관리자(뷰 모델 또는 유사한 상태 머신이라고도 함)에서 이러한 변경 사항을 관찰하고 이에 따라 레이아웃을 조정하는 것입니다. 본질적으로 상태는 UI와 깊이 얽혀 있으며, 후자는 전자의 변경 사항에 동적으로 응답합니다.

Jetpack Compose에서 Compose Runtime 라이브러리는 특수한 상태 API를 제공하며, 이를 통해 Compose 내에서 UI 상태를 효율적으로 관리할 수 있습니다.

State and Recomposition

Recomposition 개념을 살펴보기 전에 Jetpack Compose의 세 가지 주요 단계를 이해하는 것이 중요합니다.

  1. 구성(Composition): 구성 단계의 이 초기 단계에서는 Composable 함수를 구성에 통합하는 작업이 포함됩니다. 이 단계에서는 Composable 함수에 대한 설명과 메모리 내 슬롯이 여러 개 생성됩니다. 이러한 슬롯은 각 Composable 함수를 기억하는 데 사용되어 런타임 중에 효율적으로 활용할 수 있습니다.

  2. 레이아웃(Layout): 이 단계에서는 Composable 트리 내에서 각 Composable 노드의 배치가 결정됩니다. 기본적으로 레이아웃 프로세스는 각 Composable 노드를 측정하고 적절하게 배치하여 모든 요소가 UI의 전체 구조에 올바르게 배열되도록 하는 작업을 포함합니다.

  3. 그리기(Drawing): 이 단계에서는 Composable 노드를 일반적으로 장치 화면인 캔버스에 렌더링하는 작업이 포함됩니다. 이 단계에서는 Composable 함수의 시각적 표현이 생성되어 표시됩니다.

이제, 세 가지 주요 단계를 거쳐 이미 렌더링된 UI 레이아웃을 다시 업데이트하는 방법에 대해 궁금할 수 있습니다. 이를 해결하기 위해 Jetpack Compose는 첫 번째 단계(기술적으로는 구성, Composable 노드가 UI 변경 사항을 구성에 알림)부터 시작하여 상태 변경에 대한 응답으로 UI 레이아웃을 업데이트하는 다시 그리기 메커니즘을 도입하는데, 이 프로세스를 Recomposition(재구성) 이라고 합니다.

앞서 언급했듯이 대부분의 모바일 애플리케이션은 데이터 모델의 메모리 내 표현인 상태를 유지합니다. 이러한 상태의 변경에 대응하여 UI를 업데이트하는 것은 필수적입니다. 이를 용이하게 하기 위해 Jetpack Compose는 Recomposition을 트리거하는 두 가지 기본 방법을 제공하여 UI가 최신 상태 변경 사항을 반영하도록 합니다.

  • 입력 변경 (Input Changes): 이것은 Composable 함수에서 Recomposition을 트리거하는 가장 기본적인 방법입니다. Compose 런타임은 equals 함수를 사용하여 인수의 변경 사항을 감지합니다. equals가 false를 반환하는 경우 런타임은 이를 입력 데이터의 변경으로 해석합니다. 결과적으로 이러한 데이터 변경 사항에 따라 UI 표현을 업데이트하기 위해 Recomposition을 시작합니다.

  • 상태 변경 관찰 (Observing State Changes): Jetpack Compose는 Compose 런타임 라이브러리에서 제공하는 State API를 사용하여 상태 변경 사항을 모니터링하여 Recomposition을 트리거하는 효과적인 메커니즘을 제공합니다. 일반적으로 이는 remember Composable 함수와 함께 사용됩니다. 이 접근 방식은 메모리에 상태 객체를 보존하고 Recomposition 중에 복원하여 UI가 현재 상태를 반영하도록 하는 데 도움이 됩니다.

Jetpack Compose에서 State에 대한 이해를 심화하려면 공식 Android 문서, State 및 Jetpack ComposeRecomposition을 살펴보시길 바랍니다.

Recomposition은 Jetpack Compose에서 필요한 메커니즘이지만 Compose 단계를 다시 시작하므로 성능 비용이 발생합니다. Jetpack Compose로 개발한 앱의 성능을 최적화하려면 Compose 앱 성능을 개선하기 위한 팁과 모범 사례를 제공하는 Jetpack Compose 성능 최적화를 위한 Stability 이해하기 포스트를 읽어보시는 것을 권장합니다.

Stateful vs Stateless

Jetpack Compose의 영역에서 Composable 함수는 상태를 관리하는 방법에 따라 Stateful 또는 Stateless로 설명되는 경우가 많습니다.

  • Stateful: 앞서 논의했듯이, Composable 함수는 remember 함수를 활용할 때 'Stateful'로 간주됩니다. 이 접근 방식을 사용하면 함수가 객체를 메모리에 저장하여 여러 recomposition에서 상태를 효과적으로 보존할 수 있습니다. remember 함수는 Composable 함수가 반복적으로 호출되더라도 상태가 일관되도록 합니다.

  • Stateless: 반면, Composable 함수는 remember 함수를 사용하지 않고 대신 인수를 통해 상태를 수신할 때 'Stateless'로 불립니다. 이 시나리오에서 함수는 내부 상태를 유지하지 않고 전달된 데이터에 전적으로 의존하므로 특정 시나리오에서 더 예측 가능하고 재사용하기 쉽습니다.

Jetpack Compose에서 효율적이고 효과적인 UI 개발을 위해서는 Stateful 및 Stateless Composables의 차이점을 이해하는 것이 매우 중요하며, 애플리케이션 내에서 상태를 처리하고 유지하는 방법에 영향을 미칩니다.

Stateful 및 Stateless Composable 함수 중에서 선택하는 것은 특정 요구 사항에 따라 달라지지만 일반적으로 Stateless한 Composable 함수를 만드는 것이 가장 좋습니다. 이 권장 사항의 근거는 유지 관리성과 재사용성에서 비롯됩니다.

  • 유지 관리성(Maintainability): Stateful한 Composables는 호출자 입장에서 사용하기 편리해 보일 수 있지만, 내부 상태와 동작에 대한 이해를 흐리게 만들 수 있습니다. 이러한 투명성 부족으로 인해 개발자는 Composable이 다양한 시나리오에서 어떻게 동작할지 예측하기 어려워 유지 관리에 어려움을 겪을 수 있습니다.

  • 재사용성(Reusability): 외부 상태 관리에 의존하는 Stateless Composable 함수는 다양한 애플리케이션 부분에서 더 재사용 가능한 경향이 있습니다. 이는 입력에 따라 출력을 예측할 수 있는 순수 함수처럼 작동하며, 예기치 않게 동작에 영향을 미칠 수 있는 내부 상태를 유지하지 않습니다.

요약하자면, Stateless Composable 함수를 선택하면 구성 요소를 다양한 맥락에서 테스트, 유지 관리 및 재사용하기 쉬운 더 깔끔하고 모듈화된 코드베이스로 이어질 수 있습니다. Stateful Composable 함수를 Stateless 함수로 변환하는 것은 상태 호이스팅(State Hoisting)이라는 기술을 통해 효과적으로 달성할 수 있습니다.

State Hoisting (상태 호이스팅)

Jetpack Compose의 상태 호이스팅은 상태 관리를 호출자쪽으로 위임하여 Statefull한 Composable 함수를 Stateless 함수로 변환하는 데 사용되는 방법입니다. 기본적으로 상태 호이스팅의 핵심 개념은 일반적으로 remember에서 관리되는 상태 변수를 Composable 함수의 두 매개변수로 대체하는 것을 포함합니다.

이 프로세스는 상태를 유지하는 책임을 Composable 함수에서 호출자로 효과적으로 전환하여 함수를 Stateless한 함수로 만듭니다. 아래 그림에서 볼 수 있듯이 MyTestField Composable 함수는 Stateless한 방식으로 작동합니다. 호출자쪽에서 직접 상태를 수신한 다음 동일한 호출 사이트로 이벤트를 다시 전달합니다.

다음으로, 예시 코드를 사용하여 Stateful 및 Stateless Composable 함수를 대조해 보겠습니다. 더 명확하게 이해하기 위해 위의 그림을 참조합니다. 사용자 입력을 처리하도록 설계된 MyTextField라는 사용자 지정 텍스트 필드를 고려해 보겠습니다. MyTextField를 Stateful Composable로 구현하려는 경우에 대한 예입니다.

@Composable
fun HomeScreen() {
  MyTextField()
}

@Composable
fun MyTextField() {
  val (value, onValueChanged) = remember { mutableStateOf("") }

  TextField(value = value, onValueChange = onValueChanged)
}

위의 코드 예제에서 MyTextFieldremember Composable 함수를 사용하여 상태를 메모리에 저장하고 입력 변경 사항을 추적하여 상태를 유지한다는 것을 알 수 있습니다. 이 구현은 MyTextField를 내부적으로 상태를 관리하므로 Stateful한 Composable을 만듭니다.

여기에는 장단점이 있습니다. 호출 사이트(HomeScreen)는 MyTextField의 상태를 관리할 필요가 없으므로 구현이 간소화됩니다. 그러나 이는 해당 함수 외부에서 MyTextField의 동작을 이해하고 제어하는 것이 더 어려워진다는 것을 의미합니다. 결과적으로 내부적으로 관리되는 상태로 인해 MyTextField를 다른 컨텍스트에서 재사용하기 어려울 수 있습니다.

이제 아래 예제와 같이 동일한 작업을 수행하는 또 다른 사례를 살펴보겠습니다.

@Composable
fun HomeScreen() {
  val (value, onValueChanged) = remember { mutableStateOf("") }

  MyTextField(
    value = value,
    onValueChanged = onValueChanged
  )
}

@Composable
fun MyTextField(
  value: String,
  onValueChanged: (String) -> Unit
) {
  TextField(value = value, onValueChange = onValueChanged)
}

이 예에서 MyTextField Composable은 인수를 통해 값의 변경 사항을 반영하도록 설계되었으며, 호출 사이트(HomeScreen)는 MyTextField의 모든 상태를 관리합니다. 이 접근 방식은 이전의 Statefull의 예시와 비교했을 때 코드가 길어질 수 있지만, 다양한 사용 사례에서 MyTextField Composable의 재사용성이 향상된다는 명확한 이점을 제공합니다.

@Composable
fun HomeScreen() {
  val (value, onValueChanged) = remember { mutableStateOf("") }
  val processedValue by remember(value) { derivedStateOf { value.filter { !it.isDigit() } } }

  MyTextField(
    value = processedValue,
    onValueChanged = onValueChanged
  )
}

@Composable
fun MyTextField(
  value: String,
  onValueChanged: (String) -> Unit
) {
  TextField(value = value, onValueChange = onValueChanged)
}

이 접근 방식은 "State Hoisting (상태 호이스팅)"이라고 하며, 호출자(이 경우 MyTextField)에서 호출자(위의 예시에서 HomeScreen)로 상태 관리를 승격 또는 '호이스팅'하는 관행을 말합니다. 즉, 계층 구조에서 상태가 더 높은 위치에서 관리되므로 보다 유연하고 제어된 상태 관리가 가능합니다.

위의 Stateless 예제를 기반으로 한 가지 사용 사례를 더 살펴보겠습니다. 사용자가 숫자를 입력하지 못하도록 제한하는 텍스트 필드를 만들고 싶다고 가정해 보겠습니다. 이 시나리오는 Stateless 접근 방식의 다양성과 적응성을 보여줍니다. 그러면 아래 예제와 같이 구현할 수 있습니다.

@Composable
fun HomeScreen() {
  val (value, onValueChanged) = remember { mutableStateOf("") }
  val processedValue by remember(value) { derivedStateOf { value.filter { !it.isDigit() } } }

  MyTextField(
    value = processedValue,
    onValueChanged = onValueChanged
  )
}

@Composable
fun MyTextField(
  value: String,
  onValueChanged: (String) -> Unit
) {
  TextField(value = value, onValueChange = onValueChanged)
}

또한, MyTextField Composable을 다양한 방식으로 계속 사용할 수 있으며, 특정 요구 사항에 맞게 조정할 수 있습니다. 이러한 유연성은 상태 호이스팅이 다양한 컨텍스트에서 구성 요소의 동작에 대한 외부 제어 및 사용자 정의를 허용하기 때문에 Composable 함수의 재사용성을 향상시키는 이유를 명확히 해야 합니다.

Side-Effects (사이드 이펙트)

이제 사이드 이펙트의 개념에 대해 논의해 보겠습니다. 프로그래밍 세계에서 사이드 이펙트는 일반적으로 프로그램의 한 부분에서 변경한 내용이 다른 부분에 영향을 미쳐 발생하는 의도치 않거나 예상치 못한 상황을 말합니다. 이러한 효과는 즉시 나타나지 않는 경우가 많아 예상하고 관리하기 어렵습니다.

Jetpack Compose에서 사이드 이펙트가 발생할 수 있는 가장 일반적인 시나리오 중 하나는 Composable 함수 내부입니다. 표준 함수와 달리 Composable 함수는 Recomposition과 같은 다양한 트리거로 인해 UI를 업데이트하기 위해 다시 호출될 수 있습니다. 따라서 Composable 함수 내에서 도메인 로직을 직접 실행하는 것은 위험할 수 있습니다. 이러한 반복적인 호출로 인해 UI에서 예기치 않은 동작이나 불일치가 발생할 수 있기 때문입니다.

예를 들어 텍스트와 텍스트 필드의 조합을 만들고 처음 나타날 때 기본 메시지가 있는 토스트를 표시하려는 시나리오를 생각해 보세요. 아래와 같이 간단한 구현을 생각해 낼 수 있습니다.

@Composable
fun HomeScreen(defaultValue: String = "Hello, Compose!") {
  Column {
    val (value, onValueChanged) = remember { mutableStateOf(defaultValue) }

    val context = LocalContext.current
    Toast.makeText(context, defaultValue, Toast.LENGTH_SHORT).show()

    Text(text = value)

    TextField(value = value, onValueChange = onValueChanged)
  }
}

하지만 이 코드를 실행하면 문제가 발생합니다. 텍스트 필드의 입력을 수정할 때마다 기본 메시지("Hello, Compose!")가 토스트로 다시 나타납니다. 이는 텍스트 필드의 상태가 변경되면 Recomposition이 트리거되어 Compose UI가 업데이트되고 부수효과로 토스트가 반복적으로 표시되기 때문입니다.

이 현상은 "사이드 이펙트(Side-Effect)"으로 알려져 있으며, 프로그램 내에서 예기치 않은 동작이 발생하는 것을 말합니다. 특히 UI의 동작으로인해 네트워크 데이터 가져오기, 데이터베이스 쿼리, 기타 주요 작업 수행과 같은 도메인 로직과 상호 작용해야 하는 경우가 많습니다. 이러한 상호 작용은 대부분의 애플리케이션에서 UI가 현재 상태와 기본 시스템에서 처리하는 데이터를 반영하도록 하는 데 필수적입니다.

사이드 이펙트의 문제를 해결하기 위해 Jetpack Compose는 "이펙트 핸들러"라는 개념을 도입합니다. 이 접근 방식은 Compose 프레임워크 내에서 사이드 이펙트를 관리하고 제어하도록 특별히 설계되어 아래 API를 사용하여 UI 상호 작용과 업데이트가 제어되고 예측 가능한 방식으로 발생하도록 합니다.

  • SideEffect: 이 함수는 성공적인 Recomposition 후에 Composable이 아닌 표준 함수의 실행을 가능하게 합니다. 하지만 이 실행 결과가 Recomposition 전에 발생할 것이라고 보장하지는 않습니다. 본질적으로 Recomposition 후에 작업을 트리거하도록 설계되었습니다.

  • LaunchedEffect: 이 함수는 초기 구성 단계부터 시작하여 Composable 함수 내에서 suspend 함수를 안전하게 실행할 수 있도록 합니다. 중요한 점은 LaunchedEffect가 구성에서 제거되면 코루틴 스코프가 자동으로 취소된다는 것입니다. 다양한 조건에 따라 다른 실행을 관리하려면 LaunchedEffect에 고유한 key 매개변수를 제공하여 실행 흐름을 제어할 수 있습니다. 이 접근 방식을 사용하면 특정 상황이나 상태 변경에 따라 현재 실행을 취소하고 새 suspend 함수를 시작할 수 있습니다.

  • DisposableEffect: 이 함수는 Composable이 아닌 표준 함수의 실행을 허용하는 동시에 구성을 벗어날 때 진행 중인 작업을 취소하거나 폐기할 수 있는 기능을 제공하여 리소스 누수나 중복 처리와 같은 문제를 효율적으로 방지합니다.

사이드 이펙트에 대해 더 자세히 알아보려면 공식 문서인 Side-effects in Compose 공식 문서를 확인하세요.

CompositionLocal

Jetpack Compose는 선언적 접근 방식을 사용하여 설계되어 재사용성이 높고 직관적인 UI 레이아웃을 만들 수 있습니다. 그러나 레이아웃 루트에 제공된 정보가 노드의 맨 끝에서 필요할 때 문제가 발생합니다. 간단한 UI 구조에는 문제가 없지만 아래 그림과 같이 10단계 이상의 다양한 Composable 함수 호출을 포함하는 매우 복잡한 레이아웃을 구성해야 하는 시나리오를 고려해보시길 바랍니다.

이러한 시나리오에서는 루트에서 끝까지 필요한 정보를 전달해야 하며, 그 사이에 있는 모든 Composable 함수도 실제로 사용하지 않더라도 이 정보를 전달해야 합니다. 이러한 접근 방식은 Composable 함수의 전체 세트를 복잡하게 만들어 더 광범위하고 관련 없는 정보로 부담을 줍니다. 결과적으로 코드의 유지 관리성이 떨어질 수 있습니다.

이 문제를 해결하기 위해 Jetpack Compose는 CompositionLocal 메커니즘을 도입합니다. Compose 트리를 따라 데이터를 암묵적으로 전달할 수 있으므로, 아래 그림과 같이 필요할 때마다 노드의 모든 수준에서 필요한 정보에 액세스할 수 있습니다.

많은 시나리오에서 CompositionLocal은 생성된 후 크게 변경되지 않는 비교적 "정적인" 정보를 전달하는 데 사용됩니다. 친숙한 예로는 MaterialTheme 객체가 있는데, 이는 Compose UI 구성 요소 전체에서 일관성을 보장하는 데 도움이 됩니다. 아래 예시를 통해서 살펴보겠습니다.

@Composable
fun MaterialTheme(
    ..
) {
    ..
    CompositionLocalProvider(
        LocalColors provides rememberedColors,
        LocalContentAlpha provides ContentAlpha.high,
        LocalIndication provides rippleIndication,
        LocalRippleTheme provides MaterialRippleTheme,
        LocalShapes provides shapes,
        LocalTextSelectionColors provides selectionColors,
        LocalTypography provides typography
    ) {
        ..
    }
}

MaterialTheme의 내부 구현에서 색상(color), 투명도(alpha), 표시(indication), 모양(shapes) 및 타이포그래피(typography)를 포함한 테마의 모든 측면을 정의합니다. 결과적으로 MaterialTheme Composable 내에서 사용되는 모든 Jetpack Compose Material UI 구성 요소는 테마의 속성을 일관되게 적용합니다. 개발자의 사용 패턴과 관계없이 이러한 균일성이 유지되는데, 개발자는 CompositionLocal을 통해 테마 정보에 액세스할 수 있기 때문입니다.

반면 CompositionLocal을 과도하게 사용하면 코드를 디버깅하고 유지 관리하는 데 어려움이 발생할 수 있습니다. 따라서 신중하게 사용하고 코드베이스에서 과도하게 의존하지 않는 것이 중요합니다. CompositionLocal에 대해 자세히 알아보려면 Locally scoped data with CompositionLocal 공식 문서를 살펴보시길 바랍니다.

Migrate to Compose

프로젝트가 여전히 XML 기반인 경우 점진적으로 Jetpack Compose로 전환할 수 있습니다. Jetpack Compose는 여러개의 모듈로 구성된 라이브러리로 이루어져 있으므로 쉽게 이식할 수 있으며, 기존 프로젝트에 원활하게 통합할 수 있습니다. 이러한 라이브러리를 프로젝트에 가져오기만 하면 마이그레이션을 시작할 수 있습니다.

또한 Jetpack Compose는 XML에서 Jetpack Compose로의 전환을 용이하게 하는 실용적인 API를 제공합니다. 주목할 만한 예로는 통합 프로세스를 간소화하는 ComposeViewViewCompositionStrategy가 있습니다.

Jetpack Compose로 마이그레이션하는 것에 대한 자세한 내용은 아래에 제공된 리소스를 참조하시길 바랍니다.

Conclusion

이로써 Android Developer Roadmap 5부 포스트를 마무리합니다. 이번 5부에서는 Jetpack Compose, Compose UI, State, Side-Effects, CompositionLocal, Migrate to Compose와 같이 Compose에 기본적인 이해도를 필요로하는 내용에 대하여 살펴보았습니다.

다시 한 번 말씀드리지만, 로드맵의 방대한 양에 절대 당황하지마시고 학습에 필요한 부분만 선택적으로 학습하시는 것을 권장드립니다.

Android 로드맵의 이전 섹션을 놓친 경우 아래의 링크도 읽어보시는 것을 권장드립니다.

즐거운 코딩 되시길 바랍니다!

작성자 엄재웅 (skydoves)

profile
http://github.com/skydoves

1개의 댓글

comment-user-thumbnail
2024년 7월 30일

좋은 글 감사합니다!

답글 달기