본문 바로가기
개발/Compose

(Compose) Stateful vs Stateless

by DinoDev 2025. 8. 7.
728x90
반응형

Jetpack Compose로 UI를 만드는 것은 레고(Lego) 블록으로 멋진 작품을 조립하는 것과 같습니다. Text, Button, Image 같은 기본 블록들을 가지고 우리는 무엇이든 만들 수 있죠.

그런데 만약 여러분이 만든 레고 블록이 특별한 임무를 부여받았다면 어떨까요? 예를 들어, '파란색 자동차의 왼쪽 바퀴'로만 사용될 수 있는 블록처럼 말입니다. 이 블록은 다른 어떤 작품에도 쓸 수 없는 '일회용' 부품이 되고 말 겁니다.

코드의 세계도 마찬가지입니다. 우리가 무심코 만든 컴포넌트가 혹시 '특정 상황에서만 동작하는' 일회용 부품이 되어가고 있지는 않나요? 오늘 우리는 우리의 컴포넌트를 어떤 작품에든 쓸 수 있는 만능 레고 블록처럼 만드는 설계 비법, StatefulStateless의 세계로 떠나보겠습니다.

일회용 부품?

신입 개발자 '제이'는 오늘 팀의 칭찬을 한 몸에 받았습니다. '클릭하면 하트가 채워지는' 좋아요 버튼을 완벽하게 만들어냈기 때문이죠. 스스로 상태를 관리하는 이 똑똑한 버튼 덕분에 코드는 아주 간결했습니다.

며칠 후, 다른 팀원이 제이에게 부탁합니다. "제이님, 그 좋아요 버튼 정말 예쁘네요! 저희 화면에서는 '북마크' 아이콘으로 바꾸고, 클릭하면 서버에 저장 요청을 보내는 기능으로 재사용해도 될까요?"

그 순간 제이는 깨달았습니다. 자신의 '좋아요 버튼'은 하트 아이콘과 너무 단단하게 결합되어 있어, 다른 어떤 용도로도 재사용할 수 없다는 사실을요. 지금까지 제이는 일회용 부품을 만들고 있었습니다.

이처럼 멋지게 동작하던 코드가 재사용성이라는 벽에 부딪히는 순간, 우리는 더 나은 설계에 대해 고민하게 됩니다. StatefulStateless 컴포넌트 설계법을 통해 다시는 이런 곤경에 빠지지 않는 방법을 알아봅시다.

Stateful

제이가 처음 만든 '좋아요 버튼' 컴포넌트는 자신의 상태를 remember를 통해 직접 만들고 관리하는 구조였습니다.

@Composable
fun StatefulLikeButton(modifier: Modifier = Modifier) {
    // '좋아요' 여부(isLiked)를 스스로 기억하고 관리한다.
    var isLiked by remember { mutableStateOf(false) }

    IconButton(
        onClick = { isLiked = !isLiked },
        modifier = modifier
    ) {
        Icon(
            imageVector = if (isLiked) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
            contentDescription = "좋아요"
        )
    }
}

 

이 코드는 그 자체로 완벽하게 동작했습니다. 하지만 '좋아요'가 아닌 '북마크' 기능으로 재사용하는 것은 불가능했죠. 제이는 선임 개발자에게 조언을 구했고, '상태를 분리하라'는 힌트를 얻습니다.

Stateless

제이는 선임 개발자의 조언에 따라 자신의 버튼에서 '상태'와 'UI'를 분리하기로 결심합니다.

바보 컴포넌트 만들기

바보 컴포넌트를 만들라니 조금 이상하죠? 컴포넌트 스스로는 아무것도 할 수 없고 외부의 도움으로 동작하도록 만드는 것입니다.

먼저, 상태를 전혀 갖지 않고 오직 외부에서 시키는 대로 그리기만 하는 Stateless 컴포넌트를 만듭니다. 필요한 모든 데이터는 파라미터로 전달받고, 클릭 이벤트는 람다 함수를 통해 외부에 알리기만 합니다.

@Composable
fun StatelessLikeButton(
    isLiked: Boolean,          // 1. UI를 그리는 데 필요한 데이터를 외부에서 받는다.
    onLikeClick: () -> Unit,   // 2. 클릭 이벤트를 외부로 전달할 통로만 만든다.
    modifier: Modifier = Modifier
) {
    IconButton(
        onClick = onLikeClick,
        modifier = modifier
    ) { // 3. 클릭 시 전달받은 람다를 호출
        Icon(
            imageVector = if (isLiked) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
            contentDescription = "좋아요"
        )
    }
}

상태를 부모 컴포넌트에게 위임하기(State Hoisting)

이제 이 '바보'가 된 버튼에게 상태를 전달하고 제어할 '똑똑한' 부모가 필요합니다. 부모 컴포저블을 만들고, 상태 관리를 그곳으로 끌어올립니다(Hoisting).

@Composable
fun ArticleScreen() {
    // 1. 상태(isUserLiked)를 부모 컴포넌트가 직접 소유하고 관리한다.
    var isUserLiked by remember { mutableStateOf(false) }

    Column {
        Text("Compose 설계의 모든 것")
        // ... (본문 내용)

        // 2. Stateless 자식에게 상태와 이벤트 핸들러를 전달한다.
        StatelessLikeButton(
            isLiked = isUserLiked,          // 상태는 아래로 내려주고 (State Down)
            onLikeClick = {                 // 이벤트는 위로 올려받는다 (Events Up)
                isUserLiked = !isUserLiked
            }
        )
    }
}

이 구조를 통해 데이터의 흐름은 '위에서 아래로(State Down)', 이벤트의 흐름은 **'아래에서 위로(Events Up)'**라는 명확한 단방향으로 정리되었습니다. 이것이 바로 상태 호이스팅 패턴의 핵심입니다.

더 고도화 하면?

이전까지는 좋아요 여부와 클릭했을 때 동작만 파라미터로 전달 받았는데 아이콘 이미지도 받는다면 재사용성이 극대화 됩니다.

@Composable
fun IconToggleButton(
    toggled: Boolean,
    onToggle: () -> Unit,
    toggledIcon: ImageVector,
    untoggledIcon: ImageVector,
    modifier: Modifier = Modifier,
    contentDescription: String? = null,
) {
    IconButton(
        onClick = onToggle,
        modifier = modifier
    ) {
        Icon(
            imageVector = if (toggled) toggledIcon else untoggledIcon,
            contentDescription = contentDescription
        )
    }
}

이렇게 되면 더이상 토글 관련된 기능을 가진 버튼 UI를 매번 만들지 않고 재사용해서 사용할 수 있습니다.

// 북마크 기능을 위한 부모 컴포저블
@Composable
fun BookmarkFeature() {
    // '북마크' 상태를 관리한다
    var isBookmarked by remember { mutableStateOf(false) }

    // 똑같은 IconToggleButton을 '북마크' 기능으로 재사용!
    IconToggleButton(
        toggled = isBookmarked,
        onToggle = {
            isBookmarked = !isBookmarked
            // server.saveToBookmark(...)  <-- 북마크 관련 로직 호출
        },
        toggledIcon = Icons.Filled.Bookmark,      // '북마크 On' 아이콘 전달
        untoggledIcon = Icons.Filled.BookmarkBorder, // '북마크 Off' 아이콘 전달
        contentDescription = "북마크"
    )
}

쉬운 테스트

이제 IconToggleButton을 아주 쉽게 테스트할 수 있습니다. 이제 IconToggleButton이 정말 재사용 가능한지, 그리고 올바르게 동작하는지 검증하고 싶습니다. 그는 단순히 모양만 테스트하는 것을 넘어, "버튼을 클릭했을 때, 우리가 전달한 onToggle 람수 함수가 정말로 실행되는가?"를 확인하는 테스트를 작성합니다.

@Test
fun `IconToggleButton을_클릭하면_onToggle_람다가_호출된다`() {
    // 1. Given (준비): 람다가 호출되었는지 확인할 추적용 Boolean 변수를 만든다.
    var isToggled = false

    composeTestRule.setContent {
        IconToggleButton(
            toggled = false,
            // 2. When (행동): 버튼이 클릭되면, 추적용 변수의 값을 true로 변경하는 람다를 전달한다.
            onToggle = { isToggled = true },
            toggledIcon = Icons.Filled.Favorite,
            untoggledIcon = Icons.Filled.FavoriteBorder,
            contentDescription = "좋아요 버튼"
        )
    }

    // `contentDescription`으로 버튼을 찾아 클릭 동작을 수행한다.
    composeTestRule.onNodeWithContentDescription("좋아요 버튼").performClick()

    // 3. Then (검증): 람다가 호출되어 isToggled 변수의 값이 true로 바뀌었는지 확인한다.
    assertTrue("onToggle 람다가 호출되지 않았습니다.", isToggled)
}

마치며

우리는 하나의 상태를 가진 컴포넌트가 어떻게 의도치 않게 '일회용 부품'이 될 수 있는지, 그리고 간단한 설계 원칙의 적용만으로 어떻게 재사용 가능한 컴포넌트로 진화할 수 있는지 구체적인 여정을 함께했습니다.

핵심은 Stateful 컴포넌트와 Stateless 컴포넌트가 어떻게 다른지 명확히 이해하고, '상태 호이스팅'이라는 다리를 통해 이 둘을 의도적으로 분리하는 것이었습니다.

  • Stateful 컴포넌트는 스스로 모든 것을 처리해 편리해 보이지만, 특정 기능과 단단히 결합되어 재사용이 어렵다는 명확한 한계를 가집니다.
  • Stateless 컴포넌트는 상태를 직접 관리하지 않아 '바보'처럼 보일지라도, 바로 그 특징 때문에 어떤 상황에서든 재사용할 수 있는 놀라운 유연성을 갖게 됩니다.

잘 설계된 Stateless 컴포넌트는 여러분의 가장 든든한 자산이 되어, 개발 속도를 높이고 버그는 줄여줄 것입니다.

 


블로그 글에 대해 궁금한 점이 있다면 아래 카카오톡 오픈채팅에 들어와서 질문해주세요

 

Android Kotlin Compose QnA

 

open.kakao.com

 

728x90
반응형

'개발 > Compose' 카테고리의 다른 글

(Compose) Lifecycle - Composition의 탄생부터 소멸까지  (2) 2025.08.08
(Compose) Compose UI Test  (4) 2025.08.05
(Compose) LazyColumn  (3) 2025.08.05
(Compose) Modifier  (2) 2025.08.04
(Compose) 상태(state)  (2) 2025.08.04