Jetpack Compose에서의 UI 이벤트 처리 구조 개선기

Gio·2025년 10월 11일

서론

Jetpack Compose로 프로젝트를 진행하며 UI 이벤트 처리 구조에 대한 고민이 생겼다.

여기서 UI 이벤트란 UI 계층에서 다뤄져야 할 동작들을 의미한다.

이 글에서는 이벤트 처리 구조를 고민하고 점진적으로 개선한 과정을 공유한다.

Composable 내부에서 이벤트 처리

초기에는 Composable 내부에서 이벤트를 직접 처리했다.

아래 예시는 그때 작성했던 코드로, 화면 이동을 포함한 모든 이벤트를 Screen 내부에서 처리하고 있다.

@Composable
fun CardsScreen() {
    val addLauncher = rememberLauncherForActivityResult(...)
    val editLauncher = rememberLauncherForActivityResult(...)
    
    Scaffold() {
        CardsContent(
            navigateToCardAdditionActivity = { addLauncher.launch(Intent(...)) },
            navigateToCardEditingActivity = { card -> editLauncher.launch(Intent(...)) }
        )
    }
}

위 코드는 다음과 같은 문제점을 갖는다.

  • 화면 이동의 책임이 Composable 안에 존재
    • 화면을 그리는 코드와 이벤트를 처리하는 코드가 함께 존재해 가독성이 떨어진다.
    • 나중에 네비게이션 방식이 바뀌면 Composable 내부를 수정해야 해 유연성이 낮다.
  • 테스트가 어려움
    • 화면 이동 로직이 ActivityResultLauncher에 직접 의존해 단위 테스트가 불가능했다.
    • 계측 테스트로만 검증할 수 있었고, 테스트 속도가 떨어진다.

Activity로 이벤트 처리 책임 이동

Activity는 이벤트 처리, Screen은 UI 렌더링만을 담당하기로 책임을 구분해보았다.

@Composable
fun CardsScreen(
    onAddCard: () -> Unit,
    onEditCard: (CardUiModel) -> Unit
) {
    Scaffold {
        CardsContent(
            navigateToCardAdditionActivity = onAddCard,
            navigateToCardEditingActivity = onEditCard
        )
    }
}
class CardsActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
        
		    val addLauncher = rememberLauncherForActivityResult(...)
            val editLauncher = rememberLauncherForActivityResult(...)

            CardsScreen(
                onAddCard = { addLauncher.launch(Intent(...)) },
                onEditCard = { card -> editLauncher.launch(Intent(...)) }
            )
        }
    }
}

이 구조를 통해 Screen은 화면을 그리는 역할에 집중하고,
Activity에서 실제 이벤트 처리를 담당하게 되어 책임이 명확히 분리되었다.

단위 테스트에서는 버튼 클릭 시 콜백이 호출되는지만 검증하면 되므로, 테스트도 훨씬 단순해졌다.

@Test
fun onAddCardButtonClick_callsOnAddCard() {
	// given
    var called = false
    
    composeTestRule.setContent {
        CardsScreen(onAddCard = { called = true }, onEditCard = {})
    }
    
    // when
    composeTestRule.onNodeWithText("카드 추가").performClick()
    
    // then
    assertTrue(called)
}

하지만 아직 문제가 남아있었다. 현재는 Activity에서 다루는 이벤트가 2가지 뿐이지만 추후 몇 개가 될지 모른다. 다루는 이벤트가 늘어날수록 파라미터의 개수가 늘어날 것이다.

@Composable
fun CardsScreen(
    onAddCard: () -> Unit,
    onEditCard: (CardUiModel) -> Unit,
	onDoSomething: () -> Unit,
	...
) { ... }

이렇게 되면 추후 새로운 이벤트를 추가할 때마다 CardsScreen의 모든 호출부가 수정되어야 한다.

또한 다음과 같이 테스트나 프리뷰를 위한 보일러 플레이트 코드도 늘어날 것이다.

CardsScreen(
    onAddCard = {},
    onEditCard = {},
    onDoSomething = {},
    ...
)

onXXX 콜백 통합 — onUiEvent

여기서 떠오른 것은 기존 XML 기반 뷰 시스템에서 이벤트들을 추상화하고 처리하던 패턴이었다.

viewModel.uiEvent.observe(this) { event ->
    when (event) {
        is CardUiEvent.NavigateToConfirmation -> startActivity(Intent(this, ConfirmActivity::class.java))
        is CardUiEvent.ShowToast -> Toast.makeText(this, event.message, Toast.LENGTH_SHORT).show()
    }
}

Compose에서도 유사하게, 여러 UI 이벤트를 하나의 XXXUiEvent로 추상화하여 Activity에서 처리하도록 적용해보기로 했다.

sealed interface CardEditingUiEvent {
    data class OnCardEdited(val editedCard: CardUiModel) : CardEditingUiEvent
    data object OnSubmit : CardEditingUiEvent
    data object OnCancel : CardEditingUiEvent
}
class CardEditingActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CardEditingScreen(
                state = viewModel.uiState,
                onEvent = ::handleUiEvent
            )
        }
    }

    private fun handleUiEvent(event: CardEditingUiEvent) {
        when (event) {
            is CardEditingUiEvent.OnCardEdited -> viewModel.updateCard(event.editedCard)
            CardEditingUiEvent.OnSubmit -> navigateToConfirmation()
            CardEditingUiEvent.OnCancel -> finish()
        }
    }
}

이를 통해 개별 onXXX 콜백들을 하나의 onUiEvent로 통합할 수 있었다.
덕분에 프리뷰 작성이나 테스트 환경에서도 코드량이 크게 줄었다.

StateHolder에서 발생하는 이벤트도 한 곳에서 처리

UI Event는 Composable 뿐 아니라 StateHolder에서 트리거되기도 한다.
서버 통신 결과를 나타내는 등의 이벤트가 그렇다.

StateHolder에서 발생하는 이벤트는 LaunchedEffect를 사용해 변화를 감지하고,
Composable 이벤트와 동일한 방식으로 onUiEvent를 실행하도록 변경했다.

class CardAdditionActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val stateHolder = rememberCardAdditionStateHolder()
            val state = stateHolder.uiState
            val event = stateHolder.uiEvent

            val onUiEvent: (CardAdditionUiEvent) -> Unit = { event ->
                when (event) {
                    CardAdditionUiEvent.AddCardSuccess -> showToast("Success")
                    CardAdditionUiEvent.AddCardFailure -> showToast("Failure")
                    CardAdditionUiEvent.NavigateBack -> finish()
                    is CardAdditionUiEvent.UpdateCardNumber -> stateHolder.updateCardNumber(evt.cardNumber)
                    is CardAdditionUiEvent.UpdateBankType -> stateHolder.updateBankType(evt.bankType)
                }
            }

            LaunchedEffect(event) {
                event?.let {
                    onUiEvent(it)
                    stateHolder.clearEvent()
                }
            }

            CardAdditionScreen(state = state, onUiEvent = onUiEvent)
        }
    }
}

이제 모든 UI Event를 Activity 내부에서 일괄 처리하게 되어 유지보수가 편리해질 것이다.

결론

처음에는 Composable 내부에서 모든 이벤트를 처리했지만, 이 방식은 책임이 뒤섞이고 테스트가 어려운 구조였다.

이후 Activity로 이벤트 처리를 옮겨 UI와 로직을 분리했으나, 이벤트가 늘어날수록 콜백 파라미터가 증가하는 문제가 생겼다.

이벤트를 추상화하고 onUiEvent로 이벤트 처리를 한 곳으로 통합하며 이 문제를 해결할 수 있었다.

이제 새로운 이벤트가 추가되어도 Composable 시그니처는 그대로 유지되고, Activitywhen 블록만 수정하면 된다.

또한 StateHolder에서 발생하는 이벤트까지 한 곳에서 처리해 일관된 이벤트 처리 흐름을 구축할 수 있었다.

UI 이벤트를 어떻게 처리할지에 따라 코드의 구조와 유지보수성이 크게 달라질 수 있다.

profile
틀린 부분을 지적받기 위해 업로드합니다.

0개의 댓글