Android/Android Compose

Compound Component 방식을 사용하여 복잡한 Compose 대응하기

최데브 2025. 12. 12. 08:44

 

Compose로 화면을 만들기 시작하면 처음엔 깔끔한데, 어느 순간부터 이게 슬슬 이상해진다.

  • Scaffold 안에
  • 상단 툴바 들어가고
  • 탭 들어가고
  • 상태바 패딩도 넣어야 하고
  • 플로팅 버튼에, 스낵바에, 로딩 레이어에…

"아 이거 한 파일에 계속 써도 되나…?" 싶어지면 보통

여러개의 컴포저블로 나눠서 그려보려고 한다. 그러다보면 재활용도 하고 싶고

쉽게 시도해보는게 Slot API 인데 이게 또 복잡하면 문제가 발생한다.

 

Compose 화면이 망가지는 과정

보통 화면이 이렇게 커진다.

  1. 처음엔 ScreenA() 같은 함수 하나에서 시작
  2. 거기에 TopBar, TabRow, 리스트, 다이얼로그, 로딩, 에러 뷰 등등이 한데 모이기 시작
  3. 어느 순간 @Composable fun ScreenA() 하나가 300줄이 넘어감
  4. 이제서야 아 좀 나눠볼까 하고 나누기 시작함
    • 파라미터로 넘겨야 하는 값이 10개가 넘어가고
    • 콜백도 5~6개씩 달리기 시작
  5. 결국 관리하기 힘든 화면 완성

이때 필요한 게 Compound Component다.

물론 멋쟁이 개발자들은 처음부터 잘 나눠서 만들거라고 믿는다.

 

 

Compound Component 패턴이 뭐냐면

말만 거창하지 개념은 간단하다.

여러 개의 작은 컴포넌트를 하나의 덩어리처럼 다루면서

내부 슬롯을 적당히 열어둬서 커스터마이징은 가능하게 만드는 패턴

조금 더 안드로이드스럽게 말하면

  • CustomDialog 하나를 만들어두고
  • 그 안에 제목 영역, 본문 영역, 버튼 영역을
  • 필요한 만큼만 열어준 상태로 하나의 컴포넌트처럼 쓰는 것

Compose에서는 이걸 람다 + Scope (람다 리시버) 로 예쁘게 만들 수 있다.

 

한번 찍먹해보자

예를 들어 이런 컴포넌트가 있다고 해보자.

@Composable
fun MyBox(
    title: String?,
    content: String?,
    mainButtonText: String?,
    boxContent: @Composable MyBoxScope.() -> Unit,
) {
    val scope = remember(title, content, mainButtonText) {
        MyBoxScopeImpl(
            title = title,
            content = content,
            mainButtonText = mainButtonText,
        )
    }

    Column {
        scope.boxContent()
    }
}

 

처음 보면 약간 어색한 부분이 있다.

  • scope라는 걸 만든 다음
  • scope.boxContent()만 호출했는데
  • 그 안에서 뭔가가 그려진다?

이게 바로 람다 리시버 + Scope 덕분이다.

 

Scope + 람다 리시버가 실제로 하는 일

먼저 MyBoxScope를 보자.

interface MyBoxScope {
    val title: String?
    val content: String?
    val mainButtonText: String?

    @Composable
    fun Title()
    
    @Composable
    fun Content()
    
    @Composable
    fun MainButton(onClick: () -> Unit)
}

 

다음은 구현체

class MyBoxScopeImpl(
    override val title: String?,
    override val content: String?,
    override val mainButtonText: String?,
) : MyBoxScope {

    @Composable
    override fun Title() {
        if (title != null) {
            Text(text = title)
        }
    }

    @Composable
    override fun Content() {
        if (content != null) {
            Text(text = content)
        }
    }

    @Composable
    override fun MainButton(onClick: () -> Unit) {
        if (mainButtonText != null) {
            Button(onClick = onClick) {
                Text(mainButtonText)
            }
        }
    }
}

 

이제 사용 코드를 보면 감이 온다.

 

@Composable
fun SampleScreen() {
    MyBox(
        title = "타이틀",
        content = "내용입니다",
        mainButtonText = "확인",
    ) {
        // 여기 this == MyBoxScope
        Title()

        Spacer(Modifier.height(8.dp))

        Content()

        Spacer(Modifier.height(16.dp))

        MainButton(onClick = { /* 클릭 처리 */ })
    }
}

 

 

MyBox {   } 블록 안에서

  • Title(), Content(), MainButton()은 전부 MyBoxScope의 함수
  • 이 블록의 this는 MyBoxScope이기 때문에
  • scope.boxContent()를 호출하면, 그 안의 Title() / Content() / MainButton()이
  • 실제로는 MyBoxScopeImpl의 구현을 타고 렌더링된다.

결론적으로 이 구조 때문에  한 번 scope만 만들어놓고 그걸 기반으로 작은 DSL처럼 쓰는게 가능해진다.

 

굳이 람다 리시버를 써야 해? 라는 의문

 

사실 Slot API 만으로도 대부분의 화면은 충분히 커버할 수 있다.

그런데 화면이 점점 커지고 커스터마이징 포인트가 늘어나면 이렇게 된다

  • 변경사항이 생길떄마다 파라미터를 계속 추가해야함
  • '이 슬롯은 이 조합에서만 의미 있다' 같은 제약을 표현하기 어려움
  • title, content, buttonText 등을 넘겨놓고도 실제 그리는 책임이 여기저기 흩어진다

이럴 때 Scope 기반 Compound Component가 빛을 발한다.

 

Scope 기반 Compound Component가 좋은 지점

 

Scope를 쓰면 다음이 가능해진다.

내부 API 를 노출할 수 있다

예를 들어 다이얼로그 컴포넌트를 만든다고 하면

interface DialogScope {
    @Composable fun Title(text: String = defaultTitle)
    @Composable fun Description()
    @Composable fun PrimaryButton(onClick: () -> Unit)
    @Composable fun SecondaryButton(onClick: () -> Unit)
    fun dismiss()
}

 

dismiss() 같은 함수까지 Scope에 넣어두면

CustomDialog(visible = isVisible) {
    Title()
    Description()
    PrimaryButton { 
        // 저장 로직
        dismiss()
    }
}

이렇게 이 컴포넌트 안에서만 의미 있는 동작을 자연스럽게 녹여 넣을 수 있다.

Scope를 쓰면 UI + 행동을 함께 캡슐화한 미니 DSL을 하나 만든 느낌이 된다.

 

복잡한 제약을 Scope 안으로 숨길 수 있다

예를 들어 메인 버튼은 항상 하단 고정 + 로딩일 때는 비활성 + 특정 상태에서만 활성 같은 로직이 있다고 하자.

class DialogScopeImpl(
    private val uiState: DialogUiState,
    private val onConfirm: () -> Unit,
) : DialogScope {

    @Composable
    override fun PrimaryButton(onClick: () -> Unit) {
        Button(
            onClick = { if (!uiState.isLoading) onClick() },
            enabled = uiState.canConfirm && !uiState.isLoading,
        ) {
            if (uiState.isLoading) {
                CircularProgressIndicator()
            } else {
                Text("확인")
            }
        }
    }
}
 

그러면 사용하는 쪽에서는 그냥

CustomDialog(uiState = uiState) {
    Title()
    Description()
    PrimaryButton {
        onConfirm() // isLoading, enabled 같은 건 신경 안 써도 됨
    }
}
 

화면 설계하는 사람 입장에서는

  • 로딩이면 버튼 눌리면 안된다
  • 특정 플래그가 false면 버튼 비활성한다

같은 걸 매번 생각할 필요가 없다.
Scope 안으로 제약을 밀어 넣고, 외부에서는 DSL만 쓰게 만드는 것이 핵심이다.

 

복잡한 화면 하나 = Compound Component 하나로 다루기

예를 들어 구독 관리 화면이 있다고 해보자.

  • 상단에 요약 정보 카드
  • 탭으로 구독 중 / 만료 / 추천 플랜
  • 하단에 구독 해지 버튼
  • 중간중간 토스트, 스낵바, 로딩, 에러 레이어

이걸 하나의 SubscriptionScreenScaffold 같은 Compound Component로 묶을 수 있다.

 

@Composable
fun SubscriptionScreenScaffold(
    uiState: SubscriptionUiState,
    onEvent: (SubscriptionEvent) -> Unit,
    content: @Composable SubscriptionScope.() -> Unit,
) {
    val scope = remember(uiState) {
        SubscriptionScopeImpl(uiState = uiState, onEvent = onEvent)
    }

    Scaffold(
        topBar = { scope.TopBar() },
        bottomBar = { scope.BottomBar() },
        snackbarHost = { scope.SnackbarHost() },
    ) { innerPadding ->
        Box(Modifier.padding(innerPadding)) {
            scope.content()

            scope.LoadingLayer()
            scope.ErrorLayer()
        }
    }
}

 

사용은 이렇게

 

@Composable
fun SubscriptionScreen(viewModel: SubscriptionViewModel = koinViewModel()) {
    val uiState by viewModel.uiState.collectAsState()

    SubscriptionScreenScaffold(
        uiState = uiState,
        onEvent = viewModel::onEvent,
    ) {
        // 여기 this == SubscriptionScope
        SummaryCard()
        TabContent()
    }
}

 

이렇게 되면

  • Scaffold 구조, 스낵바, 로딩, 에러, 이벤트 디스패치 같은 건 전부 SubscriptionScope가 들고 있고
  • 실제 콘텐츠는  SummaryCard(), TabContent()만 호출해서 채워 넣으면 된다.

결국 화면 설계할 때 관점이 바뀐다.

Composable 여러 개를 배치하는 느낌에서 이 화면만의 DSL로 스크린을 선언하는 느낌으로

 

 

언제 Scope까지 쓰고, 언제 그냥 Slot으로 끝낼까

실제로 써보면서 느낀 기준은 대충 이렇다.

그냥 Slot API로 충분한 경우

  • 단순 재사용 컴포넌트
  • 슬롯이 2~3개 이내어가 제약 로직도 별로 없는 경우
  • 이 컴포넌트만의 행동이라는게 거의 없는 경우

Scope + Compound Component가 좋은 경우

  • 화면 단위, 혹은 화면의 큰 섹션 단위
  • 내부에 들어가는 요소들이 많고 각각 서로 상태/제약을 공유해야 하는 경우
  • 이 화면에서만 의미 있는 행동/로직을 UI와 함께 묶어서 노출하고 싶은 경우
  • 도메인 단위로 캡슐화하고 싶은 경우

한 줄로 요약하면

그냥 예쁜 View 조각이면 Slot,  이 도메인의 규칙이 잔뜩 묻어 있는 덩어리라면 Scope 기반 Compound Component

 

Compose 자체는 선언형이라 처음엔 단순하다. 그러나
도메인 규칙이 쌓이기 시작하면 UI 코드가 진짜 금방 더러워진다.

이럴 때 적절하게 써주자