[Jetpack Compose] (1) Composable, ComposeView

빙티·2025년 1월 30일

Jetpack Compose 👻

목록 보기
1/1
post-thumbnail

서론

컴포즈에는 Recomposition, State, remember, 상태 호이스팅 등 중요한 개념들이 참 많다.
하지만 단순히 어떤 정보를 맥락 없이 머리에 주입하는 것은 재미도 없고 크게 와 닿지 않는다.

직접 코드를 작성하며 “왜 이렇게 되는 거지?”라는 물음에서 비롯해 나만의 흐름을 갖고 공부할 때 훨씬 기억에 오래 남았다.

그래서 Xml 기반 프로젝트에 Jetpack Compose를 점진적으로 도입하며, 마이그레이션 과정에서 마주치는 컴포즈 개념들을 소개하는 시리즈를 시작해보려고 한다!

이번 글에서는 Composable 함수와 ComposeView를 소개하고, 뷰 기반 프로젝트에서 Composable을 사용하는 방법을 알아보겠다.






Jetpack Compose란?

이런 설명은 좀 뻔하지만, 명색이 첫 컴포즈 포스팅이니 Jetpack Compose의 개요를 알아보고 넘어가자.
Jetpack Compose는 2021년 구글에서 출시한 선언형 UI 프레임워크다.
여기서 집중해야 할 키워드는 선언형(Declarative)이며, 반대 개념으로는 명령형(Imperative)이 있다.


선언형 vs 명령형

명령형은 특정 작업을 수행하기 위한 상세 절차를 모두 명시하는 것이다.
반면 선언형은 구체적인 방법이나 절차보단 어떤 작업인지에 집중한다.
아래 리스트에서 짝수만 선별하는 작업을 각각 명령형과 선언형으로 수행해보자.

val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

우선 명령형이다.

val evenNumbers = mutableListOf<Int>()
for (i in numbers.indices) {
    if (numbers[i] % 2 == 0) {
        evenNumbers.add(numbers[i])
    }
}

어떻게 필터링을 할 것인지 단계별로 절차가 명시되어있다.
아래 과정 중 하나라도 틀리거나 순서가 바뀌면 의도대로 동작하지 않을 것이다.

  1. 기존 리스트의 인덱스를 for문으로 순회하며
  2. 해당 인덱스의 원소 값이 2로 나누어 떨어지면
  3. 결과 리스트에 원소를 추가

다음은 선언형이다.

val evenNumbers = numbers.filter { it % 2 == 0 }

구체적인 필터링 로직은 filter 함수 내부로 숨겨져 있고, 개발자는 무엇을 필터링 할 것인지만 신경쓰면 된다.

선언형의 장점은 코드가 매우 간결하고 읽기 쉽다는 것이다.
그러나 캡슐화 된 내부 동작을 정확히 이해하지 못하고 사용하면 예상치 못한 버그를 만날 수 있다.

나는 처음 명령형과 선언형이란 개념을 접했을 때, 디지털 신호의 0과 1처럼 이분법적으로 나뉘는 것으로 여겼다.

그렇지만 명령형과 선언형은 추상화 수준에 따라 상대적으로 결정되며, 아날로그처럼 연속적인 스펙트럼 상에 존재한다는 것을 알게 되었다.
따라서 코드의 추상화 수준이 높아질수록 선언형에 가까워진다 라고 표현할 수 있겠다.

// 1. 낮은 추상화 (더 명령형에 가까움)
val result = mutableListOf<String>()
for (user in users) {
    if (user.isActive) {
        result.add(user.name.uppercase())
    }
}

// 2. 중간 추상화
val result = users
    .filter { it.isActive }
    .map { it.name.uppercase() }

// 3. 높은 추상화 (더 선언형에 가까움)
val result = users.activeUsers.uppercaseNames

대부분의 프레임워크는 시간에 따라 발전을 거듭하며 추상화 수준이 높아지므로, 자연스럽게 명령형보다는 선언형 방식으로 진화한다.
안드로이드도 Jetpack Compose를 통해 선언형 UI 패러다임을 도입하며 이러한 변화의 물결에 합류했다.

선언형과 명령형에 대해 더 자세히 알고 싶으면 아래 블로그 포스트를 읽어보는 것을 추천한다.
참고 블로그 1, 참고 블로그 2


컴포넌트(Component)

선언형 UI에서 컴포넌트란, UI를 구성하는 재사용 가능한 단위를 의미한다.
여러 기본 컴포넌트를 조합해 복잡한 컴포넌트를 만들 수 있다.
아래는 텍스트 컴포넌트버튼 컴포넌트조합해 구성한 다이얼로그 컴포넌트이다.

컴포넌트는 아래와 같은 중요한 특징을 가진다.

  • 상태를 기반으로 동작한다.
  • 입력을 받아 그에 맞는 UI를 생성한다.
  • 동일한 입력이 주어졌을 때 동일한 UI를 반환한다.

상태에 따라 UI를 그린다는 말이 아직은 와닿지 않을 수도 있다.
쉽게 말해, 데이터가 바뀌면 화면이 그에 맞게 자동으로 다시 그려진다는 뜻이다.

그렇다면 Jetpack Compose에서는 컴포넌트를 어떻게 구현할 수 있을까?




Composable 함수

컴포넌트는 @Composable 어노테이션이 붙은 컴포저블 함수로 구현할 수 있다.

// 컴포즈 컨벤션에서 @Composable 메서드명은 PascalCase이다.
@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name")
}

어노테이션과 대문자로 시작한다는 것만 제외하면 일반 함수랑 별다를 것 없어 보인다.

하지만 컴포저블 함수는 컴파일 시 Compose 컴파일러에 의해 특별한 형태로 변환된다.
마치 코루틴에서 suspend를 붙이면 파라미터에 Continuation을 추가해 중단 가능한 구조로 변환하던 것처럼 말이다.

위의 Greeting 컴포저블을 디컴파일하면 함수 파라미터의 마지막에 ComposerInt가 추가되는 것을 확인할 수 있다.

fun Greeting(
    name: String,
    composer: Composer?, // 새로 추가된 파라미터
    changed: Int, // 새로 추가된 파라미터
    ) {
    // Recomposition 여부 판단 및 UI emit (생략)
}

Composer는 컴포지션 트리의 상태와 위치 등을 추적하고 관리하는 컨텍스트 객체이다.
Compose 컴파일러는 @Composable 함수에 Composer를 전달하도록 변환하며, Compose 런타임은 이 Composer를 기반으로 UI를 구성한다.



Composable 호출하기

Composable 함수를 만들었으니 이제 어딘가에서 호출해 사용하고 싶다.
우리의 목적은 UI를 그리는 것이니 기존 Activity의 onCreate()에서 컴포저블을 호출해보자.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Greeting("빙티")
    }
}

그러면 이렇게 ‘Composable 함수는 또 다른 Composable 함수 안에서만 호출 가능하다’라는 경고를 만날 수 있다.

코루틴에서 suspend functionsuspend function 안에서만 호출 가능한 것과 비슷하다.

어쨌든 우리는 Composable 함수가 또 다른 Composable 함수 안에서만 호출 가능함을 알게 되었다.
그 이유는 위에서 언급했듯, @Composable은 Compose 컨텍스트를 일반 함수는 Compose 컨텍스트가 없기 때문이다.

그렇다면 최상위 Composable은 어디서 호출해야 할까?
이제 본격적으로 안드로이드 뷰에 컴포즈를 이식하는 방법을 알아보자.






ComposeView

ComposeView란 기존의 안드로이드 뷰 시스템에 컴포즈를 통합하기 위한 래퍼 클래스다.
아래 ComposeView의 내부 코드 중 setContent() 함수가 바로 기존 뷰 시스템에 @Composable을 주입하기 위한 진입점의 역할을 한다.

class ComposeView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {

    private val content = mutableStateOf<(@Composable () -> Unit)?>(null)

    @Suppress("RedundantVisibilityModifier")
    protected override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
        private set

  	// 내부적으로 setContent에서 등록된 컴포저블을 호출
    @Composable
    override fun Content() {
        content.value?.invoke()
    }

  	// 접근성 서비스를 위해 View 이름을 알려주는 함수
    override fun getAccessibilityClassName(): CharSequence {
        return javaClass.name
    }

  	// ❗핵심❗ 외부에서 @Composable 블록을 주입할 수 있게 함
    fun setContent(content: @Composable () -> Unit) {
        shouldCreateCompositionOnAttachedToWindow = true
        this.content.value = content
        if (isAttachedToWindow) {
            createComposition()
        }
    }
}

setContent()를 사용하는 대표적인 방법들을 알아보자.


1. Activity에서 컴포저블 사용하기

가장 먼저 소개할 방법은, ComponentActivity의 확장함수인 setContent()를 사용하는 것이다.

기존에는 액티비티의 onCreate()에서 setContentView()를 호출해 루트 뷰를 설정했다.
Compose에서는 setContent() 함수로 이를 대체하여, 액티비티가 컴포즈 기반의 UI 트리를 루트 뷰로 가지게 한다.

아래처럼 setContent에 원하는 Composable 함수를 넣어주면 액티비티 안에 UI가 그려진다. (setContent를 사용하려면 의존성이 필요하다.)

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Greeting("빙티")
        }
    }
}

어떻게 이런 일이 가능한지 setContent 내부를 살펴보자.

public fun ComponentActivity.setContent(
    parent: CompositionContext? = null,
    content: @Composable () -> Unit
) {
		// 액티비티의 루트에 ComposeView가 존재하는지 확인
    val existingComposeView = window.decorView
        .findViewById<ViewGroup>(android.R.id.content)
        .getChildAt(0) as? ComposeView

		// 기존 ComposeView가 있는 경우 context와 content만 업데이트
    if (existingComposeView != null) with(existingComposeView) {
        setParentCompositionContext(parent)
        setContent(content)
    } else ComposeView(this).apply {
		// 기존 ComposeView가 없는 경우 새로운 ComposeView를 생성하고 루트 뷰로 설정
        setParentCompositionContext(parent)
        setContent(content)
        setOwners()
        setContentView(this, DefaultActivityContentLayoutParams)
    }
}

else 부분이 핵심이다.
setContent()가 최초로 호출되어 ComposeView가 존재하지 않는 경우, 새로운 ComposeView를 생성해 액티비티의 루트 뷰로 설정하고 있다.


2. 뷰에서 컴포저블 사용하기

- XML에 선언하기

첫번째는 XML 레이아웃 파일에서 ComposeView를 선언하고 정적으로 컴포저블을 주입하는 방법이다.
앱이 실행될 때 XML이 파싱되므로 뷰 계층의 고정된 위치에 ComposeView가 미리 들어간다.
이 방법은 기존 XML 구조를 유지하면서 최소한의 Compose 도입만 필요할 때 유용하다.

<androidx.compose.ui.platform.ComposeView
    android:id="@+id/compose_view"
    android:layout_height="wrap_content" />

코드에서 idbinding으로 접근해 setContent 함수로 Compose UI를 주입할 수 있다.

  // 1. findViewById 사용
  val composeView = findViewById<ComposeView>(R.id.compose_view)
  composeView.setContent {
		Text(text = "Hello Compose")
  }

  // 2. Binding 사용
  binding.composeView.setContent {
  		Text(text = "Hello Compose")
  }

- 코드에서 뷰 그룹에 추가하기

두번째는 XML의 수정 없이, ComposeView를 직접 생성해 기존 뷰 그룹에 addView()로 추가하는 방법이다.
전체 View의 위치나 크기는 View 시스템의 layoutParams로 설정할 수 있다.

이 방법은 앱 실행 중에 UI 구성을 로직에 따라 동적으로 제어할 수 있다.
아래 코드처럼 특정 조건shouldShowComposeView에 따라 뷰를 추가하거나 제거할 수 있고, 여러 개의 ComposeView를 반복문에서 만드는 것도 가능하다.

if (shouldShowComposeView) {
    val composeView = ComposeView(context).apply {
        layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
        setContent { Text("Hello Compose") }
    }
    myLinearLayout.addView(composeView)
}

기존 프로젝트에서 xml로 작성된 화면에 신규 기능을 ComposeView로 추가하기 위해, 데이터 바인딩을 활용한 방법 1을 택했다.

이 외에도 Fragment에서 사용하는 방법과 RecyclerView ViewHolder에서 사용하는 방법 등이 있다.






마무리

이번 글에서는 XML에서 컴포즈로 마이그레이션할 때 가장 먼저 만나는 개념들을 정리하고, 어떤 방법들이 있는지 알아보았다.
데이터 바인딩과 바인딩 어댑터를 사용하는 현재 프로젝트는 UI 로직이 분산되어 있어 디버깅과 유지보수가 까다롭다.
얼른 컴포즈를 도입해 UI 상태와 이벤트 흐름을 더욱 명확하게 관리해보고 싶다.
다음 글에서는 컴포즈의 상태 관리 기법을 다뤄볼 예정이다.

profile
할머니에게 설명할 수 없다면 제대로 이해한 게 아니다

0개의 댓글