[Android] Compose 4년 후, 다시 정리하는 생명주기/상태/렌더링

2022년에 정리했던 Compose 가이드를 4년 만에 다시 씁니다. 무엇이 그대로고 무엇이 바뀌었는지, 주니어가 중급으로 넘어갈 때 꼭 알아야 할 변화를 회고와 함께 정리했어요. 관련 키워드: Jetpack Compose, Compose 생명주기, Compose 상태, Compose 렌더링, Strong Skipping Mode, Composable 최적화, recomposition

8분 소요

Compose 1.0 stable이 나온 게 2021년 7월이었습니다. 저는 2022년 초에 사내 신규 프로젝트에 처음 도입해봤고, 그때 시리즈로 정리했던 글이 있어요. 생명주기, 상태, 렌더링 3단계까지. 4년이 지난 지금 다시 그 글을 들춰보니, 절반은 여전히 유효하고 절반은 시대가 바뀐 부분이 있더라고요. 이번에 한 번 다시 정리해보려고 합니다. 처음 입문 단계는 지났고 중급으로 넘어가려는 주니어 분들께 도움이 될 것 같아요.

RETRO + UPDATE
Compose, 4년 사이에 무엇이 그대로고 무엇이 바뀌었나
생명주기와 렌더링 3단계 같은 핵심 개념은 그대로지만, Strong Skipping Mode와 stability 추론 변화로 코드 작성 습관이 꽤 달라졌습니다.

여전히 유효한 핵심 개념들

먼저 4년이 지나도 흔들리지 않은 부분부터 정리할게요. 이 부분은 입문 단계에서 다시 확인하고 가시면 됩니다.

컴포지션 → 리컴포지션 → 컴포지션 해제

Composable 함수의 생명주기는 여전히 세 단계입니다. 함수가 처음 호출되면 컴포지션이 일어나고, 트리 형태의 인스턴스가 메모리에 쌓여요. 이후 입력값이 바뀌거나 내부 State가 변하면 리컴포지션이 일어나는데, 핵심은 트리의 모든 인스턴스를 다시 만드는 게 아니라 변경이 있는 노드만 새로 만든다는 점입니다. 화면에서 사라지면 컴포지션이 해제되고요.

이걸 처음 접했을 때 헷갈렸던 게, "그럼 Composable 함수가 도대체 몇 번 실행되는 건가?"였어요. 저도 그랬고요. 답은 "예측하지 마세요, 그냥 멱등하게 짜세요"입니다. 같은 입력에 대해 같은 결과가 나오도록만 작성하면, Compose가 알아서 최소한으로 호출해줍니다. 함수 안에 부수효과(side effect)를 넣지 않는다는 게 그래서 중요해요.

State와 State Hoisting

State 패턴도 그대로입니다. mutableStateOf()로 상태를 만들고, remember로 컴포지션 사이에 값을 유지하고, 구성 변경(화면 회전 등)에도 살아남으려면 rememberSaveable을 쓰는 구조 그대로예요.

State Hoisting도 변함없는 핵심 패턴입니다. 자식 Composable은 상태를 직접 갖지 말고, 부모로부터 값과 콜백을 받는 stateless한 형태로 만든다는 원칙. 4년 전에 처음 보고 "이게 왜 좋지?" 싶었는데, 지금은 거의 종교처럼 지킵니다. 테스트도 쉽고 재사용성도 압도적으로 높거든요.

STATE HOISTING 패턴
// Stateful (외부에서 호출)
@Composable
fun HelloScreen() {
    var name by remember { mutableStateOf("") }
    HelloContent(name = name, onNameChange = { name = it })
}
 
// Stateless (내부 - 재사용 가능)
@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    OutlinedTextField(
        value = name,
        onValueChange = onNameChange,
        label = { Text("Name") }
    )
}

렌더링 3단계: Composition, Layout, Drawing

이게 Compose 중급으로 넘어가는 핵심 개념인데, 4년이 지나도 똑같습니다. 한 프레임이 그려지는 과정은 세 단계예요.

1
Composition (컴포지션)
무엇을 보여줄지 결정. Composable 함수를 실행해서 UI 트리를 구성하는 단계.
2
Layout (레이아웃)
어디에 어떤 크기로 둘지 결정. measurement(측정)와 placement(배치) 두 하위 단계로 나뉨.
3
Drawing (드로잉)
Canvas에 실제로 그림. 가장 마지막 단계라 다른 단계에 영향을 주지 않음.

이 단계들이 중요한 이유는, "어느 단계에서 상태를 읽느냐"에 따라 리컴포지션 비용이 완전히 달라지기 때문입니다. 제가 신입 시절에 가장 많이 했던 실수가, 스크롤 오프셋을 Composable 본문 안에서 읽어서 매번 전체 단계를 다시 돌리는 거였어요. 이게 LazyColumn에서 60fps가 30fps로 떨어지는 주범이거든요.

상태 읽기 위치별 영향 범위
  • Composable 본문에서 읽기 → Composition + Layout + Drawing 모두 다시 실행
  • Modifier.offset { } 람다 안에서 읽기 → Layout + Drawing만 다시 실행
  • Canvas, drawBehind 람다 안에서 읽기 → Drawing만 다시 실행
❌ 비효율적
Image(
    Modifier.offset(
        // 본문에서 읽음 → 매번 Composition부터 다시
        with(LocalDensity.current) {
            (listState.firstVisibleItemScrollOffset / 2).toDp()
        }
    )
)
✅ 최적화
Image(
    Modifier.offset {
        // Layout 단계에서 읽음 → Composition 스킵
        IntOffset(0, listState.firstVisibleItemScrollOffset / 2)
    }
)

이 차이가 60fps 유지에 결정적이에요. 4년 전에는 이걸 강의 듣고 머리로만 이해했는데, 실제 프로덕션에서 LazyColumn이 버벅거리는 걸 직접 잡고 나니까 비로소 체화되더라고요.

4년 사이에 크게 바뀐 것: Strong Skipping Mode

이게 이번 글에서 가장 강조하고 싶은 변화예요. 2024년 초 Compose Compiler 1.5.4부터 실험적으로 도입됐고, Compose 1.7부터 사실상 기본 활성화 상태로 자리잡은 기능입니다. Android 공식 문서 기준으로 Strong Skipping Mode는 "unstable한 파라미터를 가진 Composable도 스킵 가능하게 만드는" 컴파일러 모드예요.

무엇이 달라졌나

이전 모델에서는 stable과 unstable의 구분이 컴파일러의 모든 판단 기준이었어요. 자료형이 stable로 인정받지 못하면 (예: var 프로퍼티가 있는 data class, mutable list 등) 그 Composable은 무조건 리컴포지션됐습니다. 그래서 4년 전에는 @Stable이나 @Immutable 어노테이션을 곳곳에 발라야 했어요.

Strong Skipping이 켜지면 규칙이 바뀝니다. unstable한 파라미터도 인스턴스 동등성(===)으로 비교해서 같은 인스턴스면 스킵해요. 그리고 모든 람다가 자동으로 메모이제이션됩니다. 이전엔 remember { { } }로 감싸야 했던 것들이 이제 컴파일러가 알아서 처리해줘요.

Strong Skipping 켜짐 vs 꺼짐 비교
파라미터 유형
이전 동작
Strong Skipping
unstable 파라미터
항상 리컴포즈
동일 인스턴스면 스킵
unstable 람다
항상 리컴포즈
자동 메모이제이션
@Stable 어노테이션
equals()로 비교
equals()로 비교 (변화 없음)

오해하기 쉬운 부분

Strong Skipping이 켜졌다고 해서 stability를 더 이상 신경 안 써도 된다는 뜻은 아니에요. 이게 가장 흔한 오해입니다. unstable 타입은 여전히 unstable이고, 단지 런타임이 인스턴스 동등성(===)으로 비교해서 같은 인스턴스일 때만 스킵해주는 거예요. 매 프레임 새 인스턴스가 만들어지는 패턴이라면 여전히 리컴포지션됩니다.

예를 들어 부모 Composable에서 listOf("a", "b")를 리터럴로 자식에 넘기면, 매번 새 인스턴스가 만들어져서 Strong Skipping도 못 도와줍니다. 이런 경우엔 여전히 remember@Immutable이 필요해요. 다만 일반적인 ViewModel → Screen → 자식 Composable 흐름에서는 같은 인스턴스가 유지되는 경우가 많아서 Strong Skipping의 혜택을 자연스럽게 받습니다.

현장 일화: @Stable 어노테이션 대청소

저희 팀은 작년에 Compose 1.7로 업그레이드하면서 사내 코드베이스의 @Stable, @Immutable 어노테이션을 한 번 정리했어요. 200개 가까이 붙어 있던 게 70개 정도로 줄었습니다. 다만 다 떼면 안 돼요. 매 프레임 새로 생성되는 객체를 파라미터로 받는 컴포넌트는 여전히 어노테이션이 필요했고, 이걸 모르고 무차별로 떼다가 LazyColumn 성능이 더 나빠진 사례가 있었습니다. 변경 전후로 Layout Inspector의 Recomposition Count 꼭 비교하세요.

또 하나의 변화: 람다 메모이제이션이 자동화됐다

Strong Skipping과 함께 따라온 변화가 람다 자동 메모이제이션입니다. 4년 전 코드를 보면 콜백을 remember로 감싸는 패턴이 정말 많았어요.

2022년 스타일 (여전히 동작은 함)
val onClickHandler = remember(viewModel) {
    { id: String -> viewModel.onItemClick(id) }
}
ItemList(items = items, onItemClick = onClickHandler)
2026년 스타일 (컴파일러가 알아서)
ItemList(
    items = items,
    onItemClick = { id -> viewModel.onItemClick(id) }
)

컴파일러가 람다의 캡처 변수가 같으면 자동으로 같은 인스턴스를 재사용해줍니다. 그래서 자식 Composable이 onItemClick 람다를 비교할 때 매번 새 인스턴스로 인식하지 않아요. 코드가 훨씬 깔끔해졌고, 신입 개발자가 remember 빼먹어서 성능 문제 만드는 일이 거의 없어졌습니다.

2025년 4월 Compose 1.8: 실험 API의 절반이 사라졌다

Android 공식 블로그를 보면 Compose 1.8에서 실험 API가 172개에서 70개로 줄었다고 발표했습니다. UI/Foundation 모듈 기준이고, 거의 절반 가까이 stable로 전환됐다는 뜻이에요. 이게 무슨 의미냐면, 그동안 @OptIn(ExperimentalFoundationApi::class) 같은 어노테이션 줄줄이 붙이고 쓰던 API들이 이제 안심하고 쓸 수 있게 됐다는 거죠.

주니어 분들께 가장 와닿을 변화 두 가지만 짚어볼게요.

TextField 자동 완성이 한 줄로 끝난다

이게 4년간 Compose의 가장 큰 가려움이었어요. 로그인 화면에서 비밀번호 자동 완성을 붙이려면 AutofillNode를 만들고 트리에 등록하고 콜백 받고... 정말 짜증나는 작업이었거든요. 1.8부터는 의미적 contentType만 지정하면 끝납니다.

Compose 1.8 자동 완성
TextField(
    state = rememberTextFieldState(),
    modifier = Modifier.semantics {
        contentType = ContentType.Username
    }
)

animateBounds로 부드러운 크기/위치 애니메이션

LookaheadScope가 stable이 되면서 함께 들어온 Modifier.animateBounds가 굉장히 편해요. 4년 전엔 크기와 위치를 동시에 부드럽게 바꾸려면 animateDpAsState 두세 개 조합하고 isExpanded 상태 추적하고 직접 보간했어야 했거든요. 이제는 modifier 한 줄로 끝납니다. 펼치고 접히는 카드 UI나 디테일 뷰 전환에서 특히 빛을 발해요.

4년 동안 본 흔한 안티패턴들

프로덕션에서 코드 리뷰하면서 자주 보는 패턴들이에요. 주니어 시절 저도 다 했던 것들이라, 한 번쯤 본인 코드에서 확인해보시면 좋겠습니다.

1. Composable 본문에서 ViewModel 메서드 호출
viewModel.fetchData()를 함수 본문에서 그냥 호출하면 리컴포지션마다 호출됩니다. LaunchedEffect로 감싸세요. 이건 4년 전에도 흔한 실수였고 지금도 똑같이 흔해요.
2. State를 본문 변수에 그냥 풀어서 쓰기
val count = viewModel.count.value로 받아서 비교 로직에 쓰는 케이스. State의 추적 메커니즘이 깨져서 리컴포지션이 트리거되지 않을 수 있어요. collectAsStateWithLifecycle() 또는 by 위임을 쓰세요.
3. LazyColumn에 key 안 주기
items(list) { ... }로만 끝내면 리스트 정렬 바뀔 때 모든 항목이 리컴포즈됩니다. items(list, key = { it.id })로 key를 명시하면 변경된 항목만 처리해요. Strong Skipping이 와도 이건 그대로입니다.
4. derivedStateOf 안 쓰고 본문에서 매번 계산
"스크롤 위치가 100px 넘었는가" 같은 파생 상태를 매번 계산하면, 스크롤할 때마다 리컴포지션됩니다. derivedStateOf로 감싸면 boolean 결과가 바뀔 때만 리컴포지션돼요. 효과가 굉장히 큽니다.

디버깅: Layout Inspector를 안 쓰면 손해

4년 전과 비교해 가장 좋아진 게 디버깅 도구예요. Android Studio Hedgehog 이후 Layout Inspector에서 각 Composable의 Recomposition Count와 Skipped Count를 실시간으로 볼 수 있습니다. 어떤 Composable이 의도치 않게 리컴포즈되고 있는지 한눈에 보여요.

저는 PR 리뷰 전에 항상 Layout Inspector로 변경한 화면을 한 번 돌려봅니다. Recomposition Count가 예상보다 높으면 그 부분을 다시 봐요. 직관적으로 "왜 이게 리컴포즈되지?" 싶으면 거의 99% 위에서 말한 안티패턴 중 하나거든요. 더 깊이 파고 싶으시면 Composition Tracing이란 도구도 있는데, 그건 시니어 단계 영역이라 여기서는 패스하겠습니다.

정리하며: 그래서 무엇을 새로 배워야 하나

4년 전 Compose 입문을 마치고 중급으로 넘어가시는 분들이라면, 다음 세 가지를 챙기시면 충분합니다.

2026 CHECKLIST
렌더링 3단계 + 단계별 상태 읽기
여전히 가장 중요한 핵심. 이걸 모르면 LazyColumn이 60fps를 못 냅니다.
Strong Skipping Mode의 동작 원리
@Stable 어노테이션 무차별 적용 시대는 끝. 단, 매 프레임 새 인스턴스 만드는 패턴은 여전히 조심.
Layout Inspector로 Recomposition Count 보기
디버깅 도구 한 번도 안 켜본 분들 의외로 많아요. PR 전에 한 번 돌려보는 습관 강추.

4년 전 글을 다시 읽어보니, 그때는 Compose가 아직 자리잡는 단계라 "어떻게 동작하는지"를 깊이 이해해야 했어요. 지금은 컴파일러가 더 똑똑해져서, 핵심 멘탈 모델만 잡으면 자연스러운 코드를 써도 잘 돌아갑니다. 그래서 입문 장벽이 많이 낮아졌고, 대신 안티패턴을 미리 학습해서 피해 가는 게 더 중요해진 것 같아요.

다음에 4년 후에 또 글을 쓰게 된다면, 아마 Compose Multiplatform이 안드로이드/iOS/데스크톱을 모두 한 번에 커버하는 표준이 되어 있지 않을까 싶어요. 그때는 또 다시 정리해보겠습니다.

* 본문에 인용된 자료: Android Developers 공식 블로그 (Strong Skipping Mode Explained, What's new in Jetpack Compose April '25), Compose Foundation 공식 릴리스 노트, Compose 1.8 Stability inference 관련 기술 자료. 코드 예시는 가독성을 위해 일부 단순화했습니다.

앱 개발이 고민되시나요?

기획부터 출시까지, 모바일파트너스가 함께합니다

다른 아티클 살펴보기

[Android] Compose 4년 후, 다시 정리하는 생명주기/상태/렌더링

2022년에 정리했던 Compose 가이드를 4년 만에 다시 씁니다. 무엇이 그대로고 무엇이 바뀌었는지, 주니어가 중급으로 넘어갈 때 꼭 알아야 할 변화를 회고와 함께 정리했어요. 관련 키워드: Jetpack Compose, Compose 생명주기, Compose 상태, Compose 렌더링, Strong Skipping Mode, Composable 최적화, recomposition

[Android] Compose 4년 후, 다시 정리하는 생명주기/상태/렌더링

2022년에 정리했던 Compose 가이드를 4년 만에 다시 씁니다. 무엇이 그대로고 무엇이 바뀌었는지, 주니어가 중급으로 넘어갈 때 꼭 알아야 할 변화를 회고와 함께 정리했어요. 관련 키워드: Jetpack Compose, Compose 생명주기, Compose 상태, Compose 렌더링, Strong Skipping Mode, Composable 최적화, recomposition

커스텀형 쇼핑몰, AI 개발이면 30일이면 충분합니다

우리 브랜드만의 쇼핑몰을 갖고 싶은데 견적이 5천만 원..." 그 고민, 이제 30일이면 풀립니다. AI 코딩 + 앱박스 패키징, PC와 모바일, 앱까지 동시 출시 가이드

커스텀형 쇼핑몰, AI 개발이면 30일이면 충분합니다

우리 브랜드만의 쇼핑몰을 갖고 싶은데 견적이 5천만 원..." 그 고민, 이제 30일이면 풀립니다. AI 코딩 + 앱박스 패키징, PC와 모바일, 앱까지 동시 출시 가이드

구글플레이 정책 놓치면 앱이 사라집니다

Play Console 메일을 안 본 사이 앱이 사라진 회사를 봤습니다. Target API 35부터 Age Signals API까지 2025-2026 정책을 한 번에 정리했습니다.

구글플레이 정책 놓치면 앱이 사라집니다

Play Console 메일을 안 본 사이 앱이 사라진 회사를 봤습니다. Target API 35부터 Age Signals API까지 2025-2026 정책을 한 번에 정리했습니다.

(주)모바일파트너즈

서울 마포구 월드컵로 196, 대명비첸시티 14층

MobilePartners
모바일파트너스 로고

Contact

develop@mobpa.co.kr

© 2025 MobilePartners. All rights reserved

(주)모바일파트너즈

서울 마포구 월드컵로 196, 대명비첸시티 14층

Contact

develop@mobpa.co.kr

© 2025 MobilePartners. All rights reserved

(주)모바일파트너즈

서울 마포구 월드컵로 196, 대명비첸시티 14층

Contact

develop@mobpa.co.kr

© 2025 MobilePartners. All rights reserved