Computer >> 컴퓨터 >  >> 시스템 >> Android

초보자를 위한 Jetpack Compose 튜토리얼: 컴포저블과 재구성(Recomposition) 제대로 이해하기

이 튜토리얼에서는 안드로이드 Jetpack Compose UI 라이브러리와 관련된 핵심 개념과 용어들을 배우게 됩니다.

Compose 입문 가이드이긴 하지만, 안드로이드 개발 자체의 초보 가이드는 아닙니다. 따라서 최소 한두 개 정도의 애플리케이션을 만들어 본 경험이 있다고 가정합니다(반드시 Compose로 만든 앱일 필요는 없습니다).

본격적으로 시작하기 전에 한 가지 말씀드리자면, 처음에는 숙련된 개발자를 대상으로 한 후속 글을 작성할 계획이었지만, Leland Richardson의 2부작 시리즈를 접하고 계획을 바꾸게 되었습니다. 그는 Jetpack Compose 팀에서 일하는 소프트웨어 엔지니어일 뿐만 아니라, 훌륭한 작가이기도 합니다.

이 글만으로도 Jetpack Compose의 기초를 익히기에 충분하지만, Compose로 실무 경험을 어느 정도 쌓은 후에는 반드시 그의 글을 읽어보시기를 강력히 권장합니다.(처음부터 함께 읽는 것도 좋은 학습 방법입니다.)

이 글에서 다룰 핵심 용어와 개념

  • 기존 뷰(View) 시스템과 뷰 계층 구조에 대한 간략한 복습
  • 컴포저블(Composable)과 뷰(View)의 관계
  • 재구성(Recomposition)의 이해와 흔히 저지르는 실수 피하는 방법

컴포저블(Composable)이란 무엇인가?

이 섹션에서는 Jetpack Compose 라이브러리의 가장 근본적인 요소를 살펴봅니다. 숙련된 안드로이드 개발자라면 "컴포저블은 뷰인가?" 하위 섹션으로 바로 넘어가셔도 좋습니다.

반면 뷰(View) 시스템에 익숙하지 않다면 다음 섹션을 먼저 읽어야 합니다. 컴포저블이 무엇인지 이해하려면 기존 시스템에 대한 배경지식이 필수적이기 때문입니다.

뷰 계층 구조(View Hierarchy)

안드로이드 SDK(이 플랫폼에서 UI를 만들 때 사용하는 라이브러리)의 관점에서 뷰(View)란 애플리케이션에 구조와 스타일을 부여하는 데 사용하는 요소입니다.

뷰는 주어진 사용자 인터페이스(UI)의 가장 기본적인 빌딩 블록이며, 각 뷰는 다음과 같은 정보를 담고 있습니다:

  • 화면의 어디에 뷰를 그릴지 알려주는 X, Y 시작 및 끝 좌표
  • 색상(Color)과 알파(Alpha, 투명도) 값
  • 폰트 정보, 텍스트, 심볼, 이미지
  • 사용자 상호작용(클릭 등)이나 애플리케이션 데이터 변화 같은 이벤트에 기반한 동작

여기서 중요한 점은, 뷰가 버튼 같은 위젯(widget)일 수도 있지만, 화면 전체나 일부, 혹은 다른 자식 뷰들을 담는 컨테이너일 수도 있다는 사실입니다.

이런 컨테이너는 문맥에 따라 레이아웃(Layout) 또는 뷰그룹(ViewGroup)이라고 불립니다. 위젯과 대부분의 정보를 공유하면서도, 내부에 중첩(nested)된 다른 뷰들을 어떻게 배치하고 표시할지에 대한 정보를 추가로 가지고 있습니다.

이를 바탕으로 뷰 시스템 복습의 핵심인 뷰 계층 구조(View Hierarchy)로 넘어가겠습니다. 웹 개발자라면 뷰 계층 구조가 사실상 안드로이드판 DOM(Document Object Model)이라고 생각하면 됩니다.

안드로이드 개발자에게 뷰 계층 구조는 XML 파일이나 Java/Kotlin 코드로 정의한 모든 뷰의 가상 표현(virtual representation)입니다.

이해를 돕기 위해 XML 파일 하나를 살펴보겠습니다(자세히 분석할 필요는 없고 이름만 눈여겨보세요). 그다음 디버거·스태퍼 도구를 사용해 이 파일을 인플레이트하는 Fragment의 메모리 공간에서 어떻게 보이는지 확인해 보겠습니다.

fragment_hour_view.xml:

<?xml version=”1.0" encoding=”utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android=”https://schemas.android.com/apk/res/android"
android:layout_width=”match_parent"
android:layout_height=”match_parent"
android:id=”@+id/root_hour_view_fragment"
xmlns:app=”https://schemas.android.com/apk/res-auto"
>
<androidx.compose.ui.platform.ComposeView
android:id=”@+id/tlb_hour_view”
//...
 />
<com.wiseassblog.samsaradayplanner.ui.managehourview.HourToggleView
android:id=”@+id/vqht_one”
//...
/>
<com.wiseassblog.samsaradayplanner.ui.managehourview.HourToggleView
android:id=”@+id/vqht_two”
//...
/>
<com.wiseassblog.samsaradayplanner.ui.managehourview.HourToggleView
android:id=”@+id/vqht_three”
//...
/>
<com.wiseassblog.samsaradayplanner.ui.managehourview.HourToggleView
android:id=”@+id/vqht_four”
//...
/>
</androidx.constraintlayout.widget.ConstraintLayout>

(Fragment)HourView.kt의 메모리 공간:

초보자를 위한 Jetpack Compose 튜토리얼: 컴포저블과 재구성(Recomposition) 제대로 이해하기
뷰 계층 구조 이미지

디버거와 스테퍼 도구는 여러 라이브러리에서 가져다 쓰는 코드의 내부 동작을 파악하는 저의 최애 학습 방법입니다. 여러분도 한번 시도해 보세요!

XML 파일과 이것이 프로세스(process, 즉 기기에서 실행 중인 프로그램) 안에서 어떤 형태로 변하는지 보여드린 이유는, XML에 중첩된 뷰들이 런타임에는 어떻게 중첩된 뷰 계층 구조로 변환되는지 보여주기 위함입니다.

기존 시스템의 동작 방식을 단순하지만 구체적인 모델로 이해했다면, 이제 새로운 시스템과 비교해 볼 준비가 된 것입니다.

컴포저블은 뷰(View)인가?

이것은 제가 Compose를 처음 다루기 시작했을 때 던졌던 첫 번째 질문 중 하나였는데, 결론적으로 답은 예스이자 노입니다.

라고 할 수 있는 이유는, 컴포저블이 기존 시스템에서 뷰와 동일한 개념적 역할을 수행하기 때문입니다. 컴포저블은 버튼 같은 위젯일 수도 있고, ConstraintLayout 같은 컨테이너일 수도 있습니다(참고로 ConstraintLayout의 Composable 구현체도 공식적으로 제공됩니다).

아니오라고 할 수 있는 이유는, UI가 더 이상 뷰 계층 구조에 가상으로 표현되지 않기 때문입니다(상호 운영성(interoperability)이 필요한 상황은 예외). 그렇다고 해서 Compose가 마법으로 UI를 추적하는 것은 아닙니다. 즉, 뷰 계층 구조와 개념적으로 유사한 자체적인 무언가를 반드시 갖고 있어야 합니다.

그 '무언가'를 아주 간략히 살펴보겠습니다. 아래는 setContent {…} 함수를 사용해 컴포저블을 Activity에 바인딩하는 예시입니다:

ActiveGameActivity.kt:

class ActiveGameActivity : AppCompatActivity(), ActiveGameContainer {
private lateinit var logic: ActiveGameLogic
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val viewModel = ActiveGameViewModel()
    setContent {
        ActiveGameScreen(
            onEventHandler = {
                logic.onEvent(it)
            },
            viewModel
        )
    }
    logic = buildActiveGameLogic(this, viewModel, applicationContext)
}
//…
}

ActiveGameScreen.kt:

@Composable
fun ActiveGameScreen(
    onEventHandler: ((ActiveGameEvent) -> Unit),
    viewModel: ActiveGameViewModel
) {
    //...

    GraphSudokuTheme {
        Column(
            Modifier
                .background(MaterialTheme.colors.primary)
                .fillMaxHeight()
        ) {
            ActiveGameToolbar(
                clickHandler = {
                    onEventHandler.invoke(
                        ActiveGameEvent.OnNewGameClicked
                    )
                }
            )

            Box {
              //content
            }
        }
    }
}

Compose에서는 뷰 계층 구조 대신, Activity의 mWindow 필드 깊숙한 곳까지 파고들면 찾을 수 있는 무언가가 그 역할을 대신합니다. 바로 뷰 계층 구조의 개념적 대체물인 Composer와 그 slotTable입니다.

초보자를 위한 Jetpack Compose 튜토리얼: 컴포저블과 재구성(Recomposition) 제대로 이해하기

이 시점에서 ComposerslotTable에 대한 상세한 설명이 필요하다면, 역시나 Leland의 글을 읽어보시길 권합니다(2부에서 깊이 있게 다룹니다). Compose 계층 구조는 Composer와 slotTable 외에도 더 많은 요소로 구성되어 있지만, 시작하기에는 충분합니다.

요약하자면, Jetpack Compose는 우리가 'Compose 계층 구조'라고 부를 수 있는 것을 사용합니다(Composer와 slotTable 같은 요소들이 만들고 관리합니다).

개념적으로는 뷰 계층 구조와 동일합니다. 메모리 공간에 있는 객체들의 집합이 UI를 통틀어 표현한다는 점에서요. 하지만 구현 방식은 완전히 다릅니다.

그런데 여기서 중요한 차이점이 하나 있습니다. 기술적으로 이해하기는 까다롭지만, 원리 자체는 쉽게 이해할 수 있는 부분입니다. 바로 Compose가 Compose 계층 구조의 업데이트를 처리하는 방식, 즉 재구성(Recomposition)입니다.

재구성(Recomposition): Compose UI를 업데이트하는 방법

영어가 모국어가 아닌 독자들을 위해 덧붙이자면, 'Compose'라는 단어는 라틴어 componere(대략 '모아서 만들다')에서 왔습니다. 음악을 만드는 사람을 '작곡가(Composer)'라고 부르듯, 여러 악기에서 나오는 음표를 하나의 곡으로 조합하는 사람을 생각하면 됩니다.

'조합한다'는 것은 개별 조각들이 존재한다는 의미입니다. 거의 모든 훌륭한 소프트웨어 개발자가 코드를 합리적인 최소 단위로 나누려 노력한다는 점을 이해하는 것이 중요합니다.

여기서 합리적인이라는 표현을 쓴 이유는, DRY(Don't Repeat Yourself) 같은 원칙도 만들어내는 문제보다 해결하는 문제가 많을 때까지만 따라야 한다고 생각하기 때문입니다.

이 개념은 흔히 모듈화(modularity)라고 불리고, 저는 '관심사의 분리(Separation of Concerns, SOC)'라는 표현을 선호하는데, 적용하면 여러 가지 이점이 있습니다. Leland의 글을 그대로 베낀 것 아니냐고 생각하실 분들도 계시겠지만, 저는 오랫동안 SOC를 소프트웨어 아키텍처의 황금 원칙으로 언급해 왔습니다.

이 원칙이 Compose에서 빛을 발하는 지점은, 인기 JavaScript 라이브러리인 React에서 볼 수 있는 것과 같은 방식입니다. 올바르게 작성하면, Compose는 재구성이 실제로 필요한 컴포저블(UI의 일부/요소)만 다시 그립니다(redraw, re-render, update, 부르는 명칭은 무엇이든).

애플리케이션 성능 측면에서 이것은 엄청나게 중요합니다. 기존 뷰 시스템이든 Compose든, UI를 다시 그리는 작업은 시스템 리소스에 큰 부담을 주기 때문입니다.

참고로 기존 RecyclerView(2016년에 제가 튜토리얼을 처음 만들었던 주제이기도 합니다!)의 존재 이유 자체가 데이터 목록에 ViewHolder 패턴을 적용해, 리스트 항목마다 새로운 뷰를 계속 인플레이트(생성)하지 않아도 되게 하는 것이었습니다.

이 글의 목표는 이론 위주로 설명하는 것이었습니다. 앞으로 몇 달간 실습 중심 콘텐츠를 충분히 작성할 예정이니까요. 다만, 재구성이 실제로 어떻게 동작하는지 그리고 어떻게 하면 최악의 방식으로 재구성을 유발하게 되는지! 이해를 돕기 위해 직접 겪은 사례 하나로 마무리하겠습니다.

스톱워치 예제

첫 번째 완성형 Compose 애플리케이션으로 저는 스도쿠를 만들기로 했습니다. 이유는 여러 가지였지만, 핵심은 미친 듯이 복잡하지 않은 UI를 가진 프로젝트를 원했다는 점입니다. 또한 스도쿠 퍼즐에 딱 알맞은 그래프 자료구조와 알고리즘을 깊이 파볼 기회도 원했습니다.

그중 하나로, 사용자가 퍼즐을 푸는 데 걸린 시간을 측정하는 스톱워치가 필요했습니다:

초보자를 위한 Jetpack Compose 튜토리얼: 컴포저블과 재구성(Recomposition) 제대로 이해하기
Graph Sudoku 퍼즐

흔한 이야기지만, 이 타이머가 실제보다 훨씬 쉬울 거라 기대했습니다. 안드로이드의 Chronometer 클래스와 Java의 Timer 클래스를 만져봤지만, 두 클래스 모두 서로 다른 방식으로 앱을 망가뜨리는 문제를 일으켰습니다.

결국 한 걸음 물러나서 깨달았습니다. '내가 지금 Kotlin으로 코딩하고 있잖아.' 그래서 프레젠테이션 로직 클래스에 코루틴(Coroutine) 기반 타이머를 구현했고(거기 두는 게 가장 합리적이었습니다), 이 타이머가 매초 ViewModel을 갱신하도록 했습니다:

Class ActiveGameLogic(…):…{
//…
inline fun startCoroutineTimer(
    delayMillis: Long = 0,
    repeatMillis: Long = 1000,
    crossinline action: () -> Unit
) = launch {
    delay(delayMillis)
    if (repeatMillis > 0) {
        while (true) {
            action()
            delay(repeatMillis)
        }
    } else {
        action()
    }
}
private fun onStart() =
launch {
    gameRepo.getCurrentGame(
    { puzzle, isComplete ->
        viewModel.initializeBoardState(
            puzzle,
            isComplete
    )
        if (!isComplete) timerTracker = startCoroutineTimer {
            viewModel.updateTimerState()
        }
    },{
        container?.onNewGameClick()
    })
}
//…
}

ViewModel(AAC의 VM이 아닌, 제가 직접 작성한 VM입니다. 다만 Compose는 AAC VM과도 이미 상호 운영성이 좋습니다.)은 콜백 함수에 대한 참조를 노출했고, 저는 이 콜백으로 컴포저블을 갱신했습니다:

class ActiveGameViewModel {
    //…
    internal var subTimerState: ((Long) -> Unit)? = null
    internal var timerState: Long = 0L
    //…
    internal fun updateTimerState(){
        timerState++
        subTimerState?.invoke(timerState)
    }
//…
}

이제 중요한 부분입니다! remember 같은 Compose의 특정 기능을 사용하면 Compose 계층 구조의 재구성을 트리거할 수 있습니다:

var timerState by remember {
    mutableStateOf(“”)
}

궁금하신 분들을 위해 말씀드리면, 이런 기능들은 remember로 기억하는 값의 상태(state)를 slotTable에 저장합니다. 여기서 '상태'란 데이터의 현재 값 정도로 이해하면 되며, 위 코드에서는 빈 문자열로 시작합니다.

바로 여기서 제가 실수를 저질렀습니다. 저는 간단한 타이머 컴포저블을 별도의 함수로 분리하고(SOC 적용!), timerState를 해당 컴포저블의 파라미터로 전달했습니다.

그런데 위 코드 조각들은 타이머의 부모 컴포저블, 즉 UI에서 가장 복잡한 영역(9x9 스도쿠는 상당히 많은 위젯을 필요로 합니다)을 감싸는 컨테이너 안에 있었습니다:

@Composable
fun GameContent(
    onEventHandler: (ActiveGameEvent) -> Unit,
    viewModel: ActiveGameViewModel
) {
    Surface(
        Modifier
            .wrapContentHeight()
            .fillMaxWidth()
    ) {
        BoxWithConstraints(Modifier.background(MaterialTheme.colors.primary)) {
            //…
            ConstraintLayout {
                val (board, timer, diff, inputs) = createRefs()
                var isComplete by remember {
                    mutableStateOf(false)
                }
                var timerState by remember {
                    mutableStateOf("")
                }
                viewModel.subTimerState = {
                    timerState = it.toTime()
                }
                viewModel.subIsCompleteState = { isComplete = it }
            //…Sudoku board
            //Timer
                Box(Modifier
                    .wrapContentSize()
                    .constrainAs(timer) {
                        top.linkTo(board.bottom)
                        start.linkTo(parent.start)
                    }
                    .padding(start = 16.dp))
                {
                    TimerText(timerState)
                }
            //…difficulty display
            //…Input buttons
            }
        }
    }
}
@Composable
fun TimerText(timerState: String) {
    Text(
        text = timerState,
        style = activeGameSubtitle.copy(color = MaterialTheme.colors.secondary)
    )
}

결과는 상당한 랙(lag)과 반응성 저하였습니다. 디버거를 적극 활용한 끝에 원인을 찾았습니다. timerState 변수가 부모 컴포저블 내부에서 생성되고 갱신되면서, 그 부모 컴포저블이 감싸는 UI 전체 영역의 재구성을 매초마다 트리거하고 있었던 것입니다. 매. 초. 마다.

관련 코드를 TimerText 컴포저블 내부로 옮긴 후에는 아주 부드럽게 동작했습니다:

@Composable
fun TimerText(viewModel: ActiveGameViewModel) {
    var timerState by remember {
        mutableStateOf("")
    }

    viewModel.subTimerState = {
        timerState = it.toTime()
    }

    Text(
        text = timerState,
        style = activeGameSubtitle.copy(color = MaterialTheme.colors.secondary)
    )
}

이 글이 재구성의 동작 원리와, 재구성을 최악의 방식으로 유발하는 대표적인 실수 하나를 이해하는 데 도움이 되었기를 바랍니다.

불필요한 재구성을 피하는 것은 성능에 매우 중요합니다. 그리고 지금까지의 경험으로 볼 때, remember 상태를 개별 컴포저블 안에 유지하는 수준까지 SOC를 철저히 적용하는 것이 표준 관행이 되어야 할 것 같습니다.

리소스 및 지원

이 글이 마음에 드셨다면 소셜 미디어에 공유해 주시고, freeCodeCamp에서 저의 다른 글들도 확인해 주세요. 수백 개의 튜토리얼을 담은 유튜브 채널도 운영하고 있으며, 여러 플랫폼에서 활발하게 글을 쓰고 있습니다.

소셜 미디어에서 만나요

Instagram과 Twitter에서도 저를 찾아볼 수 있습니다.

마지막으로, 제가 Jetpack Compose를 시작할 때 활용한 단 하나의 최고 리소스를 소개합니다. 바로 훌륭한 개발자들이 작성한 동작하는 코드 샘플입니다.