| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- scope function
- coroutine
- Kotlin
- Android ViewPager2
- Android Interceptor
- 프로그래머스 알고리즘
- 안드로이드
- 영어공부
- OkHttp Interceptor
- MVP Architecture
- Android Navigation
- Android
- 카카오 알고리즘
- WebView
- Android 12 대응
- Android 12
- 66챌린지
- 코틀린 코루틴
- Android Jetpack
- DataBinding
- 안드로이드 카카오 로그인
- 안드로이드 갤러리 접근
- Android WebView
- 알고리즘 자바
- Android ProgressBar
- 영어독립365
- Kotlin FCM
- Java
- 습관만들기
- android recyclerview
- Today
- Total
주맨의 개발노트
[Compose] Screen과 Content를 나누는 이유 본문
Compose로 화면을 설계하다 보면 한 번쯤 마주치는 고민이 있습니다. 화면 하나를 만들 때 Composable 함수를 어떻게 쪼갤 것인가 하는 문제입니다.
보통 두 가지 선택지를 두고 고민하게 됩니다. 하나는 *Screen() 내부에서 *Content()를 호출하고, *Content()가 UI 렌더링을 담당하는 구조입니다. 다른 하나는 *Screen() 내부에서 곧바로 여러 Component 함수들을 조합해서 UI 렌더링까지 책임지는 구조입니다. 후자는 결국 *Content()의 역할을 *Screen()이 그대로 떠안는 셈입니다.
두 경우 모두 전제는 같습니다. 개별 UI 조각(Component)은 이미 따로 분리되어 있다고 가정합니다. 그러니 여기서 다루고 싶은 것은 “Component를 어떻게 나눌 것인가”가 아니라, Screen과 Content라는 레이어를 하나 더 둘 것인가입니다.
화면이 단순하면 Content 없이도 충분해 보이고, 함수 하나를 더 두는 게 보일러플레이트처럼 느껴질 수도 있습니다. 하지만 몇 가지 근거를 따져본 결과 Screen과 Content를 나누는 쪽이 낫다고 판단했고, 그 이유를 정리해보려 합니다.
Screen과 Content, 역할은 다르다
먼저 이 글에서 쓰는 용어부터 정의하겠습니다.
Screen
ViewModel을 구독하고(collectAsState), SideEffect를 처리하고(collectSideEffect), UiState에 속하지 않는 화면 로컬 상태(예: 바텀시트 노출 여부)를 관리합니다. 이펙트풀한 경계를 담당합니다.
Content
UiState를 받아 UI 트리로 변환합니다. 화면이 지금 어떻게 보이는지에 대한 답은 UiState 하나로 고정됩니다.
@Composable
fun HomeContent(
uiState: HomeUiState,
onIntent: (HomeIntent) -> Unit,
)
검증 루프가 짧아진다
Content가 (UiState, Intent)만 받는 순수한 형태라면, fake UiState 몇 개만 만들어서 여러 @Preview를 찍을 수 있습니다. 앱을 다시 빌드하고 실행하지 않아도 화면이 어떻게 보이는지 바로 확인할 수 있습니다.
UI 테스트도 마찬가지입니다. ViewModel이나 SideEffect 채널을 목킹하지 않고 렌더링 로직만 독립적으로 검증할 수 있습니다.
composeTestRule.setContent {
HomeContent(fakeUiState, onIntent = {})
}
Screen과 Content를 나누지 않으면 이 두 검증 모두를 위해 결국 Content와 거의 같은 함수를 뒤늦게 따로 빼게 됩니다. Screen이 collectAsState, collectSideEffect에 묶여 있어서 Preview나 테스트에서 그대로 인스턴스화하기가 번거롭기 때문입니다.
변경과 이해의 범위가 Content 안으로 좁혀진다
디자인 변경으로 “리스트 요약 카드를 상단으로 옮겨달라”는 요청이 들어왔다고 가정해보겠습니다.
레이아웃 코드가 collectSideEffect, 로컬 state 선언, navigation 콜백 연결 코드와 한 함수 안에 뒤섞여 있습니다. 순서만 바꾸면 되는 단순 작업인데도, 이 컴포저블 호출을 옮기는 것이 SideEffect 처리 순서나 상태 구독 타이밍에 영향을 주지 않는지 Screen 전체를 다시 훑어야 합니다.
변경은 HomeContent 안에서 컴포저블 호출 순서만 바꾸면 끝납니다. HomeScreen은 건드릴 필요가 전혀 없고, 변경 범위가 렌더링 관심사 안에 갇혀 있다는 것이 보장됩니다.
이 경계는 코드를 쓸 때만이 아니라 읽을 때도 똑같이 작동합니다. Screen 하나만 있어도 로컬 상태를 렌더링 분기에 섞지 않도록 조심하면 이론적으로는 똑같이 깔끔한 결과를 낼 수 있습니다. 하지만 그 경우 “정말 로컬 상태가 섞여 있지 않은지”를 확인하려면 결국 함수 전체를 처음부터 끝까지 읽어야 합니다.
Content로 분리되어 있으면 이야기가 다릅니다. 파라미터가 (UiState, Intent) 두 개라는 시그니처 자체가, 함수 내부를 한 줄도 읽지 않고도 “여기 있는 모든 분기는 UiState에서 나온 것”이라는 보장을 줍니다.
이 경계가 저절로 지켜지는 것은 아닙니다. Content가 Preview와 테스트에서 이미 여러 번 호출되고 있다는 사실이 이 경계를 실질적으로 지탱해줍니다. 누군가 이 경계를 깨고 로컬 상태를 Content의 새 파라미터로 추가하려 하면, 그 순간 이미 존재하는 모든 @Preview 함수와 테스트 코드가 컴파일 에러로 드러나기 때문입니다.
그래서 실제로는 어떻게 나눌까
지금까지는 왜 나누는지에 대한 이야기였고, 이제 실제로 어떤 값을 Screen에 두고 어떤 값을 Content에 둘지 결정하는 문제가 남습니다.
Content의 계약은 단순합니다. 파라미터는 UiState와 Intent 람다, 이 두 개뿐입니다. MVI 패턴에서는 화면의 모든 상태를 UiState 하나로, 모든 사용자 행동을 Intent 하나로 표현하므로, 파라미터가 두 개뿐이라는 건 정보가 부족해서가 아니라 이미 필요한 정보를 전부 받고 있다는 뜻에 가깝습니다. 이 계약이 지켜지는 한 “화면이 지금 왜 이렇게 보이는가”에 대한 답은 언제나 UiState 하나입니다.
그래서 저는 어떤 값을 Screen에 둘지 Content에 둘지 애매할 때 스스로에게 이런 질문을 던져봅니다.
이 값은 UiState만으로 결정되는가?
이 질문을 실제 사례에 적용해보겠습니다. 바텀시트, 다이얼로그, 스낵바처럼 SideEffect로 트리거되는 값들이 좋은 예입니다. 이런 값도 결국 화면에 그려지는 UI인 이상, 언뜻 보면 Content가 맡아야 할 것처럼 보일 수 있습니다.
하지만 저는 이 경우엔 Content가 아니라고 봅니다. 렌더링이냐 아니냐가 기준이 아니라고 보기 때문입니다. 기준으로 삼는 건 그 값이 UiState의 함수인가, 아니면 화면의 “이벤트 발생 이력”에만 의존하는가입니다. 바텀시트, 다이얼로그, 스낵바처럼 한 번 트리거되고 소비되는 값은 서버 상태나 지속적으로 유지해야 할 도메인 상태가 아니라 “한 번 발생한 이벤트”에 의존합니다. 계속 유지·구독해야 할 상태가 아니라면 애초에 UiState로 승격시킬 이유가 없는 값이라는 뜻입니다.
그렇다면 이 값은 처음부터 Content가 다룰 수 있는 대상이 아닙니다. Content의 계약(UiState만으로 결정되는 값)을 벗어나기 때문입니다. 남는 선택지는 Screen에서 로컬 상태로 관리하는 것뿐입니다. 즉 “왜 Content가 아니라 Screen인가”라는 질문에는, UiState로 관리하지 않는 값은 애초에 Content가 다룰 수 있는 대상이 아니기 때문이라는 소거법으로 답할 수 있습니다. 이걸 억지로 Content에 넣으려면 showBottomSheet: Boolean 같은 파라미터를 추가해야 하고, 그 순간 Content는 더 이상 UiState만의 함수가 아니게 됩니다.
같은 이유로 dismiss 처리도 Intent로 올리지 않습니다.
// dismiss — Screen의 로컬 state를 직접 처리
if (showGreetingBottomSheet) {
GreetingBottomSheet(
onSkipClick = { showGreetingBottomSheet = false },
onDismissRequest = { showGreetingBottomSheet = false },
)
}
dismiss는 화면 내부에서 발생하고 끝나는 일회성 UI 동작입니다. 이를 Intent로 올려 ViewModel까지 왕복시키면, ViewModel은 UI 표현 상태를 관리하는 본연의 역할과 무관하게 이후 어떤 목적으로도 쓰이지 않을 값을 상태로 떠안게 되고, UiState는 그만큼 불필요하게 커집니다.
여기서 “그래도 Unidirectional Data Flow(UDF)를 지키려면 dismiss도 Intent로 올려서 ViewModel을 거쳐야 하는 것 아닌가?”라는 질문이 나올 수 있습니다. 하지만 UDF는 “모든 이벤트가 반드시 ViewModel을 거쳐야 한다”는 규칙이 아니라, “상태를 소유한 주체로부터 상태가 아래로 흐르고, 그 상태를 바꾸는 이벤트는 같은 주체에게 위로 흐른다”는 원칙입니다. 바텀시트 노출 여부라는 상태는 애초에 ViewModel이 아니라 Screen이 소유하고 있으므로, 이 상태에 대한 이벤트는 Screen 안에서 위아래로 닫힌 루프를 이루고, 이 루프 자체로 이미 유효한 UDF입니다.
오히려 dismiss를 Intent로 올려 ViewModel까지 보내더라도, ViewModel이 UiState를 바꾸지 않는다면(바텀시트 노출 여부는 UiState에 속하지 않으므로) 실제로 화면을 닫는 동작은 여전히 Screen의 로컬 state를 직접 바꿔야만 일어납니다. 즉 이벤트는 위로 올라갔는데 그에 대응하는 상태 변화는 내려오지 않는 반쪽짜리 흐름이 되어, 형식만 UDF를 흉내 낼 뿐 실질적인 이득인 “상태 변화의 예측 가능성” 없이 왕복 비용만 추가하는 셈입니다. 물론 “이 바텀시트를 다시 보여주지 않는다”처럼 서버에 반영해야 할 상태가 있다면 그 부분은 별도의 Intent로 ViewModel에 전달하는 것이 맞습니다. 하지만 그것은 “지금 화면에서 닫는다”는 동작과는 다른, 영속적으로 관리해야 할 상태를 다루는 것이므로 별개의 문제입니다.
정리하며
이것이 유일한 정답은 아니라고 생각합니다. 화면이 아주 단순하고 SideEffect도 없다면 Content를 따로 두는 것이 파일 하나 늘리는 보일러플레이트로 느껴질 수 있고, 그 반론도 나름 타당합니다.
그럼에도 예외를 허용하기 시작하면 “이 화면은 Content가 있고 저 화면은 없고”가 되어 판단 기준 자체가 흐려지는 것이 더 큰 비용이라고 판단했고, 그래서 컨벤션으로는 예외 없이 이 구조를 적용하는 쪽을 택했습니다.
- Screen은 ViewModel 구독, SideEffect 처리, 화면 로컬 상태를 관리하는 이펙트풀한 경계입니다.
- Content는
UiState를 UI로 변환하는 순수한 렌더러이며, 파라미터는UiState와Intent람다뿐입니다. - 이 분리는 검증 루프를 짧게 만들고(Preview, UI 테스트), 변경과 이해의 범위를 Content 안으로 좁혀줍니다.
- 애매한 값은 “UiState만으로 결정되는가?”라는 질문 하나로 대부분 판단할 수 있습니다. 아니라면 Screen의 로컬 상태로 남깁니다.
- 단순한 화면에서는 과해 보일 수 있지만, 판단 기준의 일관성을 위해 예외 없이 적용하는 쪽을 택했습니다.
'안드로이드 > Jetpack Compose' 카테고리의 다른 글
| [Jetpack Compose] 테마 간단하게 설정하기 (0) | 2026.01.07 |
|---|---|
| [Jetpack Compose] Compose에서 Navigation 사용 (0) | 2025.11.26 |
| [Jetpack Compose] Navigation 개요 (0) | 2025.11.25 |
| [Jetpack Compose] LaunchedEffect, 언제 왜 사용할까? (0) | 2025.11.23 |
| [Jetpack Compose] Composable의 생명주기 (0) | 2025.11.21 |