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

Jetpack Compose UI 이벤트 처리 완벽 가이드: Sealed Class부터 함수 참조까지

이번 글에서는 Jetpack Compose에서 UI 이벤트를 효과적으로 처리하는 방법을 짧고 실용적으로 살펴보겠습니다.

기존 뷰 시스템에서는 OnClickListener와 같은 인터페이스를 사용해 이벤트를 처리했습니다. 반면 Compose에서는 Kotlin의 Sealed Class(봉인된 클래스), 함수 타입(Function Type), 람다 표현식(Lambda Expression)을 최대한 활용할 수 있습니다.

컴포저블(Composable)이 무엇인지 잘 모르신다면, 기본 개념을 설명하는 글을 먼저 읽어보시는 것을 추천합니다.

이 글의 내용은 아래 영상에서 3분 안에 요약해 볼 수 있습니다.

Sealed Class로 UI 이벤트 모델링하기

먼저 UI 이벤트가 무엇을 의미하는지, 그리고 이를 Sealed Class로 어떻게 모델링하는지 알아보겠습니다.

이전에 Java와 Kotlin(기존 뷰 시스템 기반)으로 동일한 과정을 설명한 적이 있으므로, 여기서는 간략하게만 다루겠습니다.

처리 과정

UI의 각 화면 또는 하위 화면마다 스스로에게 다음 질문을 던져보세요. "사용자가 이 화면과 상호작용할 수 있는 모든 방법은 무엇일까?"

Compose만으로 처음부터 끝까지 만든 제 앱인 Graph Sudoku를 예시로 들어보겠습니다.

Jetpack Compose UI 이벤트 처리 완벽 가이드: Sealed Class부터 함수 참조까지
스도쿠 안드로이드 앱 스크린샷

이 화면의 UI 상호작용을 표현하기 위해 사용한 Sealed Class는 다음과 같습니다.

sealed class ActiveGameEvent {
    data class OnInput(val input: Int) : ActiveGameEvent()
    data class OnTileFocused(val x: Int, 
    val y: Int) : ActiveGameEvent()
    object OnNewGameClicked : ActiveGameEvent()
    object OnStart : ActiveGameEvent()
    object OnStop : ActiveGameEvent()
}

각각을 간단히 설명하면 다음과 같습니다.

  • OnInput: 사용자가 입력 버튼(0, 1, 2, 3, 4 등)을 터치하는 이벤트
  • OnTileFocused: 사용자가 특정 타일(주황색으로 강조된 칸 등)을 선택하는 이벤트
  • OnNewGameClicked: 이름 그대로 새 게임 버튼 클릭 이벤트
  • OnStart / OnStop: 컴포저블 자체에서는 신경 쓰지 않지만, 컴포저블의 컨테이너 역할을 하는 Activity에서 사용되는 라이프사이클 이벤트

Sealed Class를 구성하고 나면, 단 하나의 이벤트 핸들러 함수만으로 다양한 이벤트를 처리할 수 있습니다. 물론 경우에 따라 여러 개의 핸들러 함수로 나누는 편이 더 합리적일 수도 있습니다. 따라서 이 접근 방식은 반드시 프로젝트의 구체적인 요구사항에 맞게 조정해야 한다는 점을 기억하세요.

소프트웨어 아키텍처와 연결하기

이러한 이벤트를 실제로 처리하는 주체는 전적으로 여러분의 선택에 달려 있습니다. 일부 사람들은 MVVM이 소프트웨어 아키텍처의 황금 표준이라고 믿지만, 점점 더 많은 개발자들이 모든 상황에 최적인 단일 아키텍처란 존재하지 않는다는 사실을 깨닫고 있습니다.

Compose를 사용하는 Android에서 제가 현재 채택한 방식은 서드파티 라이브러리 의존 없이 매우 미니멀한 접근으로, 각 기능(화면)마다 보통 다음 요소들을 포함합니다.

  • 이벤트 핸들러 역할을 하는 (프레젠테이션) 로직 클래스
  • 뷰를 렌더링하는 데 필요한 데이터를 저장하는 ViewModel (이름 그대로)
  • 컨테이너 역할을 하는 Activity (갓 오브젝트가 아니어야 함)
  • 뷰를 구성하는 컴포저블
Jetpack Compose UI 이벤트 처리 완벽 가이드: Sealed Class부터 함수 참조까지
Model-View-Whatever

관심사의 분리(Separation of Concerns) 원칙만 지킨다면 무엇을 사용하든 상관없습니다. 저는 단순히 "무엇을 같은 클래스에 함께 두어야 하고, 두지 말아야 하는가?"라는 질문을 던지며 이 아키텍처에 도달했습니다.

ViewModel, Fragment, Activity 중 무엇을 이벤트 핸들러로 삼든, 설정 방법은 모두 동일합니다. 바로 함수 타입!입니다.

선택한 클래스 내에서 Sealed Class를 인자로 받는 이벤트 핸들러 함수를 정의합니다.

class ActiveGameLogic(
    private val container: ActiveGameContainer?,
    private val viewModel: ActiveGameViewModel,
    private val gameRepo: IGameRepository,
    private val statsRepo: IStatisticsRepository,
    dispatcher: DispatcherProvider
) : BaseLogic<ActiveGameEvent>(dispatcher),
    CoroutineScope {
    //...
    override fun onEvent(event: ActiveGameEvent) {
        when (event) {
            is ActiveGameEvent.OnInput -> onInput(
                event.input,
                viewModel.timerState
            )
            ActiveGameEvent.OnNewGameClicked -> onNewGameClicked()
            ActiveGameEvent.OnStart -> onStart()
            ActiveGameEvent.OnStop -> onStop()
            is ActiveGameEvent.OnTileFocused -> onTileFocused(event.x, event.y)
        }
    }
    //...
}

이 접근 방식은 매우 체계적이며, 서드파티 라이브러리 없이 순수하게 작성된 이 클래스의 모든 유닛을 단일 진입점(onEvent)을 통해 손쉽게 테스트할 수 있다는 장점이 있습니다.

하지만 아직 끝난 것이 아닙니다. 당연히 이 이벤트 핸들러 함수인 onEvent에 대한 참조를 컴포저블 쪽으로 전달할 방법이 필요합니다. 이때 함수 참조(function reference)를 사용합니다.

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,
                viewModel
            )
        }

        logic = buildActiveGameLogic(this, viewModel, applicationContext)
    }

  	//...
}

여기서 Activity를 굳이 사용하는 이유가 궁금하신 분들이 계실 겁니다. 자세한 답변은 언젠가 라이브 Q&A 시간에 드릴 수 있지만, 간단히 말씀드리면 이렇습니다.

Jetpack Navigation을 사용하지 않는 제 아키텍처 접근 방식에서는 Fragment가 Compose와 함께 쓰이기에 다소 무의미해 보입니다. 반면 기능별 컨테이너로 Activity를 사용하는 것에는 전혀 문제가 없습니다. 단, 갓 액티비티(god activity)를 작성하는 것만은 피하세요.

구체적으로 Kotlin에서 함수를 참조하는 문법은 다음과 같습니다. 클래스/인터페이스 이름(최상위 함수라면 생략 가능) 뒤에 콜론 두 개(::)를 붙이고, 인자나 괄호 없는 함수 이름을 적으면 됩니다.

onEventHandler = logic::onEvent

OnClickListener를 Jetpack Compose의 onClick Modifier로 대체하기

준비가 끝났으니, 이제 컴포저블 내부에서 이 구조가 실제로 어떻게 동작하는지 살펴보겠습니다. 당연히 루트 컴포저블은 이벤트 핸들러 함수를 파라미터로 받아야 합니다.

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

함수 타입 문법을 처음 접하면 다소 낯설 수 있지만, 이것이 실제로 하나의 함수에 대한 참조이며, 클래스 참조와 크게 다르지 않다는 점을 이해하면 어렵지 않습니다.

갓 오브젝트를 만들지 말아야 하듯, 거대한 단일 컴포저블도 만들지 말아야 합니다.

  1. UI를 합리적인 최소 단위로 분해합니다.
  2. 각 부분을 컴포저블 함수로 감쌉니다.
  3. UI 상호작용이 있는 각 컴포저블에는 반드시 이벤트 핸들러 함수에 대한 참조를 전달합니다.

다음은 스도쿠 앱의 입력 버튼을 나타내는 컴포저블로, 이벤트 핸들러를 참조로 전달받는 예시입니다.

@Composable
fun SudokuInputButton(
    onEventHandler: (ActiveGameEvent) -> Unit,
    number: Int
) {
    Button(
        onClick = { onEventHandler.invoke(ActiveGameEvent.OnInput(number)) },
        modifier = Modifier
            .requiredSize(56.dp)
            .padding(2.dp)
    ) {
        Text(
            text = number.toString(),
            style = inputButton.copy(color = MaterialTheme.colors.onPrimary),
            modifier = Modifier.fillMaxSize()
        )
    }
}

이벤트를 로직 클래스로 실제로 전달하려면 invoke 함수를 사용해야 합니다. 이 함수는 함수 타입 정의에 명시된 대로 인자를 받습니다(이 경우 ActiveGameEvent 객체를 받습니다).

이제 여러분은 Kotlin(Compose 사용 여부와 무관하게)이라는 아름답고 현대적인 프로그래밍 언어의 장점을 최대한 활용해 UI 상호작용 이벤트를 처리할 준비가 되었습니다.

이 글이 도움이 되었다면 SNS에 공유해 주시고, 독립 프로그래머이자 콘텐츠 크리에이터를 응원하고 싶으시다면 아래 리소스도 확인해 주세요.

소셜 미디어

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

강좌 및 튜토리얼 모음

https://youtube.com/wiseass     https://www.freecodecamp.org/news/author/ryan-michael-kay/ https://skl.sh/35IdKsj (Android Studio를 활용한 Android 입문 과정)