[ Android Essential ] Jetpack Compose, 최적화의 법칙

malcongmalcom·2025년 8월 21일

Android Essential

목록 보기
4/5
post-thumbnail

XML VS Compose

Jetpack Compose가 선언형 UI라는 얘기, 다들 진짜 귀에 못이 박히게 들었을 거다. 근데 “선언형”이라는 단어가 정확히 뭘 의미하는지, 그게 왜 중요한지, 그리고 기존 XML 방식이 왜 선언형이 아니라고 하는지까지 깊게 파본 사람은 의외로 별로 없다. 나도 한동안은 그냥 “XML은 구식, Compose는 요즘 스타일” 정도로만 생각했었다. 근데 이건 문법 차이가 아니라, 아예 사고방식 자체를 갈아엎는 얘기다. 말 그대로 UI를 다루는 뇌 회로를 통째로 재배선하는 수준이다.

Compose 공식 문서에 보면 이런 말이 있다.

뷰를 수동으로 조작하면 오류가 발생할 가능성이 커집니다. 데이터를 여러 위치에서 렌더링한다면 데이터를 표시하는 뷰 중 하나를 업데이트하는 것을 잊기 쉽습니다. 또한 두 업데이트가 예기치 않은 방식으로 충돌할 경우 잘못된 상태를 야기하기도 쉽습니다. 예를 들어 업데이트가 UI에서 방금 삭제된 노드의 값을 설정하려고 할 수 있습니다. 일반적으로 업데이트가 필요한 뷰의 수가 많을수록 소프트웨어 유지관리 복잡성이 증가합니다.

짧지만 강력한 이 문장에, 명령형 UI에서 우리가 맞닥뜨리는 모든 지옥이 압축돼 있다. 동기화 깨짐, 데이터 불일치, 잘못된 참조, 유지보수 폭탄… 전부 들어있다.

레거시 프로젝트를 까보면 항상 똑같은 패턴이 튀어나온다.

button.setText("말콩말콤")
container.removeView(view)

XML 기반 명령형 UI에선 이런 직접 명령이 기본이다. “지금 이걸 이렇게 바꿔.” 시스템은 상태랑 UI가 어떻게 연결되는지 전혀 모른다. 모든 연결선을 개발자가 직접 그리고, 직접 기억해야 한다. 화면이 단순할 땐 그럭저럭 버티지만, 규모가 커지면 그냥 폭탄이다.

예를 들어, 사용자 이름이 화면 세 군데에 뿌려진다고 치자.

textViewProfile.text = user.name
textViewComment.text = user.name
textViewSidebar.text = user.name

세 줄 중 하나라도 빼먹으면 바로 불일치 상태다. 상단과 댓글은 새 이름인데, 사이드바는 옛날 이름 그대로.

여기서 끝이 아니다. 생명주기 문제가 있다.

container.removeView(textView)
textView.text = "갱신된 텍스트" // 여기서 바로 크래시

이건 더 짜증난다. 개발 환경에선 안 터지고, QA에선 또 멀쩡하다가, 실제 사용자 폰에서만 “가끔” 터진다. 기종도 다르고, 메모리 상황도 제각각이다. 스크롤을 내렸다가 올리면 갑자기 NullPointerException at setText()가 뜨고, 재현은 또 안 된다. 결국 로그만 파다가 드는 생각은 하나 — 갈아엎자.

명령형 사고의 진짜 큰 비용은 “순서 의존성”과 “숨은 공유 상태”에서 터진다. 화면이 복잡해질수록 “A 먼저 업데이트 → 그다음 B 로딩 → 끝나면 C 표시” 같은 암묵적인 순서 계약이 코드 곳곳에 흩어진다. 한 군데라도 순서가 어그러지면, 겉보기엔 랜덤하게 버그가 터진다. Hotfix로 한 군데 막으면, 다른 데서 또 터진다. 테스트하기도 힘들다. “이 타이밍에 저 뷰가 살아있나?” 같은 걸 시뮬레이션으로 보장하기가 거의 불가능하다.

그래서 선언형을 이해할 때 제일 먼저 잡아야 할 이미지는 이거다. UI를 “명령의 나열”이 아니라 “상태를 UI로 바꾸는 규칙”으로 본다. 한 줄로 요약하면:

UI = f(State)

즉, 특정 시점의 상태(State)를 주면, 그에 대응하는 UI를 만들어내는 함수 f를 정의하는 거다. 포인트는 순서가 아니라 규칙. 같은 상태면 같은 UI가 나와야 한다. 이 규칙을 작은 단위로 쪼개고, 다시 합쳐서 더 큰 규칙을 만들 수 있어야 한다. 그리고 그 합치기가 안전하려면, 각 조각이 가능한 한 입력만 보고 출력을 결정해야 한다. 숨겨진 전역 수정이나 타이밍 의존성은 합성을 박살낸다.

명령형 XML 코드는 이 관점에서 구조적으로 불리하다. 상태와 뷰가 분리돼 있고, 둘을 연결하는 다리는 개발자가 setText, setVisibility, notifyDataSetChanged 같은 호출로 직접 깐다. 시스템은 이 연결선을 모른다. 어디가 어디랑 연결돼 있는지 모르면, 상태가 바뀌었을 때 어느 UI를 다시 그려야 하는지도 알 수 없다. 그러니 “범위 결정”과 “동기화 책임”이 전부 개발자한테 떨어지고, 규모가 커질수록 사람 기억력과 주의력이 병목이 된다.

여기서 흔히 나오는 반론이 있다. “명령형이 더 빠른 거 아니냐? 필요한 부분만 바꾸면 전체를 다시 안 그려도 되잖아.” 겉보기엔 맞는 말이다. 근데 그 “필요한 부분”을 정확히, 항상, 절대 빼먹지 않고, 절대 잘못된 순서로 호출하지 않는다는 전제가 붙는다. 작은 화면에선 가능하다. 근데 화면이 커지고, 팀원이 늘고, 이력이 꼬이고, 비동기 호출이 여러 갈래에서 들어오면? 그 전제는 바로 무너진다.

정리하면, 명령형은 작은 화면에선 빠르고 직관적이지만, 시스템이 상태↔UI 연결을 모르는 구조라서 일관성을 자동으로 유지하는 게 불가능하다. 반대로 선언형은 “규칙”을 중심으로 사고하기 때문에, 상태만 바꾸면 일관된 UI를 만들 수 있다. 핵심은 순서가 아니라 매핑이다. “이 상태면 이렇게 보인다”는 규칙.

수학적 의미의 Composition

이제 이 규칙 얘기를 좀 더 깊게 들어가 보자. 수학에서의 Composition(함수 합성) 개념을 빌려오면 훨씬 선명해진다. 합성이라는 건 한 함수의 출력이 다른 함수의 입력으로 들어가는 거다.

val add3: (Int) -> Int = { it + 3 }
val multiply2: (Int) -> Int = { it * 2 }
val composed: (Int) -> Int = { x -> multiply2(add3(x)) } // multiply2 ∘ add3

여기서 중요한 건 “먼저 add3 하고, 그다음 multiply2 한다”라는 순서를 줄줄 설명하는 게 아니다. 이 두 단계를 하나의 규칙으로 당겨서 볼 수 있다는 게 핵심이다. 절차가 아니라 매핑.

UI도 똑같다. 작은 규칙(컴포저블)을 붙여서 큰 규칙(화면)을 만든다. 그리고 그 합성이 안전하려면, 각 조각이 같은 입력에 같은 출력을 내야 한다. 그래야 결합법칙 같은 성질이 깨지지 않고, 재사용이나 교체가 편해진다.

명령형 UI는 이 “규칙의 합성”이 구조적으로 어렵다. 이유는 단순하다. 규칙이 코드 흐름 속에 박혀 있고, 시스템이 그 연결 관계를 모른다. 예를 들어 user.name이 바뀌었을 때 갱신해야 할 모든 위치를 시스템이 알 수 없으니, 합성을 자동화할 토대가 아예 없다. 반대로 선언형은 “입력→출력” 규칙이 코드 구조에 그대로 드러나니까, 그 규칙을 붙이고 바꾸고 교체하는 일이 훨씬 안전해진다.

이 관점으로 선언형을 보면, “왜 이렇게 해야 하는지”가 그냥 추상적인 유행이 아니라 구조적인 필연으로 느껴진다.

여기까지가 왜 선언형을 해야 하는지, 그리고 명령형이 왜 스케일에서 버거워지는지에 대한 큰 그림이다. 이제 이걸 실제 개발 장면에 꽂아보자. 첫 단계는 “명령형 사고에서 선언형 사고로 머릿속 스위치를 어떻게 바꾸는지”다.

명령형에선 이벤트가 발생하면 “저 버튼 텍스트 바꿔, 로딩 스피너 보여, 응답 오면 텍스트 돌려놓고 스피너 감춰” 같은 명령이 주르륵 나온다. 선언형에선 이벤트가 발생하면 “상태를 Loading으로 바꿔”로 끝난다. 화면에서 스피너가 보이는지, 버튼 텍스트가 뭔지는 상태→UI 규칙이 알아서 계산한다. 이벤트 처리 코드는 상태만 바꾸고, UI는 상태를 그릴 뿐이다. 이게 바로 단방향 데이터 흐름이다. 이벤트 → 상태 변경 → UI는 상태의 함수. 순서 대신 규칙이다.

이걸 코드로 보면 더 선명해진다.

명령형 Activity는 보통 이렇게 생겼다.

// 의도: 로그인 버튼 클릭 시 네트워크 호출하고 결과에 따라 뷰 직접 업데이트
loginButton.setOnClickListener {
    progressBar.visibility = View.VISIBLE
    loginButton.isEnabled = false

    api.login(id, pw, onSuccess = {
        progressBar.visibility = View.GONE
        loginButton.isEnabled = true
        welcomeText.text = "어서와요, ${it.name}님"
        errorText.visibility = View.GONE
    }, onError = {
        progressBar.visibility = View.GONE
        loginButton.isEnabled = true
        errorText.visibility = View.VISIBLE
        errorText.text = it.message
    })
}

작을 땐 이게 잘 보이는데, 조건이 늘어나면 가지가 무섭게 불어난다. 반면 선언형 사고로 바꾸면 “상태를 바꾸고, UI는 상태를 그린다”로 정리된다.

// 의도: 상태만 바꾼다
onEvent(LoginClicked(id, pw)) {
    state = state.copy(phase = Loading)
    api.login(id, pw,
        onSuccess = { user -> state = state.copy(phase = Success(user)) },
        onError   = { err  -> state = state.copy(phase = Error(err))   }
    )
}

// UI는 상태만 바라본다
when (state.phase) {
    Idle       -> showIdle()
    Loading    -> showSpinner()
    is Error   -> showError(state.phase.message)
    is Success -> showWelcome(state.phase.user)
}

여기서 핵심은 “UI 업데이트”라는 개념을 머릿속에서 없애는 거다. 우리는 상태를 바꾸고, 화면은 그 상태를 표현할 뿐이다. 그러면 “어디를 다시 그릴지, 뭘 언제 바꿀지” 같은 세부 로직은 전부 규칙이 알아서 처리한다. 사람이 하나하나 관리하던 연결선이 규칙으로 넘어간다. 이게 선언형의 뼈대다.

XML에서 Compose로, 안전한 마이그레이션 전략

합성 얘기를 이어가자. 합성의 본질은 “규칙을 규칙과 결합하는 것”이다. 수학에서 함수 합성은 결합법칙이 성립한다. (h ∘ g) ∘ f = h ∘ (g ∘ f). 이게 왜 중요하냐면, UI 조각을 붙여서 큰 화면을 만들 때 조립 순서가 결과를 바꾸지 않아야 재사용성이 생기기 때문이다. 반대로 사이드이펙트가 뒤섞이면 순서가 결과를 뒤집어버린다. 그러면 합성이 깨지고, 조각 재사용이 위험해진다. 테스트도 빡세진다. “이 컴포넌트를 저쪽으로 옮겼더니 갑자기 토스트 두 번 뜸” 같은 황당한 일이 터진다.

UI 합성을 박살내는 대표적인 패턴을 대놓고 적어보자.

  • 가변 전역 싱글톤에 몰래 쓰기: 어느 카드 컴포넌트가 전역 Session.currentUser를 “잠깐” 바꾼다. 옆 카드가 같은 프레임에서 그 값을 읽는다. 순서에 따라 결과가 달라진다. 합성 붕괴.
  • 숨은 타이머/딜레이: 내부에서 Handler.postDelayed로 상태를 바꾼다. 조각을 옮기거나 중첩을 바꾸면 타이밍이 달라져 다른 결과가 나온다.
  • UI 이벤트 안에서 직접 뷰 조작: 자식이 부모의 뷰를 직접 만지거나, 클릭되면 형제 visibility를 바꿔버린다. 순서 의존. 합성 불가.
  • 여러 출처에서 같은 상태를 따로 관리: 상단, 목록, 상세 화면이 각자 isLoading을 들고 있고 서로 모른다. 결과? 한쪽만 로딩 중인 괴상한 화면.

이런 걸 다 걷어내면 문장이 하나로 정리된다. “상태는 단일 진실(SSOT)로 모으고, UI는 오로지 상태의 함수로 렌더링하자.” 그러면 합성은 그냥 된다. 작은 규칙을 쌓아 큰 규칙 만드는 게 안전해진다.

그럼 실전에서 이 전환을 어떻게 하느냐. 한 번에 다 바꾸려 하면 망한다. 안전하게 하려면, 명령형 화면을 render(state) 하나로 감싸는 중간 단계부터 만든다. 지금 있는 XML + Activity/Fragment는 그대로 두고, “상태 → 뷰 변경”을 한 함수에 몰아넣는다. 이벤트 핸들러에서는 뷰를 직접 안 건드리고, 오직 상태만 바꾼다. 그리고 그 상태를 render(state)가 적용한다.

data class LoginUiState(
    val phase: Phase = Phase.Idle,
    val message: String? = null
) {
    sealed interface Phase {
        data object Idle: Phase
        data object Loading: Phase
        data class Success(val userName: String): Phase
        data class Error(val reason: String): Phase
    }
}

var state = LoginUiState()

fun render(state: LoginUiState) {
    when (val p = state.phase) {
        LoginUiState.Phase.Idle -> {
            progressBar.visibility = View.GONE
            loginButton.isEnabled = true
            errorText.visibility = View.GONE
            welcomeText.visibility = View.GONE
        }
        LoginUiState.Phase.Loading -> {
            progressBar.visibility = View.VISIBLE
            loginButton.isEnabled = false
            errorText.visibility = View.GONE
            welcomeText.visibility = View.GONE
        }
        is LoginUiState.Phase.Success -> {
            progressBar.visibility = View.GONE
            loginButton.isEnabled = true
            errorText.visibility = View.GONE
            welcomeText.visibility = View.VISIBLE
            welcomeText.text = "어서와요, ${p.userName}님"
        }
        is LoginUiState.Phase.Error -> {
            progressBar.visibility = View.GONE
            loginButton.isEnabled = true
            errorText.visibility = View.VISIBLE
            welcomeText.visibility = View.GONE
            errorText.text = p.reason
        }
    }
}

핸들러는 이렇게 단순해진다.

loginButton.setOnClickListener {
    state = state.copy(phase = LoginUiState.Phase.Loading)
    render(state)

    api.login(id, pw,
        onSuccess = { user ->
            state = state.copy(phase = LoginUiState.Phase.Success(user.name))
            render(state)
        },
        onError = { err ->
            state = state.copy(phase = LoginUiState.Phase.Error(err.message ?: "알 수 없는 오류"))
            render(state)
        }
    )
}

이렇게만 해도 명령형 화면을 선언형 사고로 감싸기 시작할 수 있다. “뷰 직접 만지기”에서 “상태 바꾸고 → 렌더”로 패러다임이 바뀐다. 합성은 여기서부터 가능해진다. 나중에 Compose로 옮길 때도, render(state)는 그대로 @Composable Screen(state)로 바뀐다. 로직 손실 없이 넘어간다. 안전한 마이그레이션 첫 발이다.

합성 품질을 점검하는 체크리스트도 있다. 코드 리뷰에서 바로 쓸 수 있다.

  • 같은 입력 상태에서 같은 UI가 나오는가? (결정성)
  • 전역 수정이나 타이밍 의존 사이드이펙트가 없는가? (참조 투명성 근접)
  • UI 변경이 “상태 변경”으로만 표현돼 있는가? (명령 제거)
  • 상태는 단일 출처(SSOT)로 모여 있는가? (중복 플래그 제거)
  • 이벤트 흐름이 “이벤트 → 상태 변경 → 렌더” 한 방향인가? (단방향)
  • 뷰 생명주기와 무관하게 오래된 콜백이 UI에 닿는가? (취소·무시 전략 필요)

이 조건을 통과하면 합성은 단단해진다. 조각을 여기서 저기로 옮겨도 결과가 안 흔들린다.

그리고 자주 나오는 질문. “선언형이 항상 더 빠른 거냐?” → 아니다. 선언형은 일관성과 합성 가능성을 보장하기 좋은 구조일 뿐, 마법처럼 성능을 올려주진 않는다. 대신 중요한 차이가 있다. 명령형은 작을 땐 빠르고, 커지면 사람이 병목이 되고, 선언형은 커져도 규칙만 지키면 시스템이 일관성 유지를 해준다. 스케일 곡선이 다르다. 팀이 크고 화면이 복잡할수록 선언형이 유리하다.

또 하나, 선언형이 “순수 함수”만 의미하는 건 아니다. UI는 본질적으로 사이드이펙트(네트워크, 디스크, 토스트, 네비게이션)가 필요하다. 중요한 건 사이드이펙트를 규칙 밖으로 밀어내고, 상태로 표준화해서 합성 가능한 경계로 만드는 것이다.

예를 들어 “네비게이션 가라” 대신 “state = state.copy(navigateTo = Detail(id))”처럼 신호를 상태에 담고, 실제 전환은 상위에서 한 번 처리한다. 이렇게 하면 테스트도 쉽고, 조각 재사용도 안전하다. 명령을 상태로 끌어올려 규칙의 영역으로 옮기는 셈이다.

마지막으로 점진적 리팩터링 로드맵을 요약하면 이렇다.

  1. 렌더 함수 도입: 기존 XML 화면에 render(state) 만든다.
  2. 명령 제거: 이벤트 핸들러에서 뷰 직접 조작 없애고 상태만 바꾼다.
  3. 상태 통합(SSOT): 중복 플래그 걷어내고 한 모델로 묶는다.
  4. 사이드이펙트 격리: 네비, 토스트, 네트워크 같은 효과는 상태 신호나 외부 레이어에서 한 번에 처리.
  5. 조각 분해: render 내부를 작은 규칙으로 쪼개고, 입력만으로 출력이 결정되게 만든다.
  6. 전환: 준비되면 그대로 @Composable로 교체. 로직 손실 없이 간다.

결국 한 줄로 귀결된다. UI를 명령으로 다루지 말고, 상태→UI 규칙으로 다뤄라. 그래야 합성이 가능해지고, 사람이 하던 동기화 노동이 시스템의 몫이 된다. 작은 화면에선 차이가 안 보여도, 스케일이 커질수록 “왜 선언형이 필요한지”가 뼈저리게 드러난다.

선언형 UI가 최적화된 UI는 아니다

지금까지는 XML과 Compose를 비교하면서 UI를 그리는 방법론 자체를 얘기했다. 이제부터는 본격적으로 Compose를 어떻게 잘 쓸 수 있는지, 즉 Compose 최적화의 법칙에 대해 얘기해보려 한다. 결국 핵심은 단순하다. 사이드이펙트를 규칙 바깥으로 밀어내고, 상태를 표준화해서 합성 가능한 경계로 만드는 것. 앞으로 네 가지 원칙을 차례대로 다루겠지만, 본질은 다 같은 얘기다. 이제 하나씩 규칙을 짚어보면서 이게 무슨 뜻인지 풀어보자.

위치 대신 정체성으로 구분하기

첫 번째 규칙은 "위치 대신 정체성으로 구분하기"이다. 처음 들으면 무슨 말인지 잘 와닿지 않을 수도 있다. 이걸 이해하려면 우선 recomposition이 일어날 때 Compose 시스템이 어떻게 개별 컴포저블을 구분하는지부터 알아야 한다. 어떤 기준으로 규칙을 나누는지 확실히 알게 되면, 이 첫 번째 규칙이 자연스럽게 이해될 거다. 그런데 규칙끼리 구분된다는 게 정확히 무슨 의미일까? 얼핏 보면 당연한 얘기 같지만, 실제로는 그렇지 않다. 아래 코드를 한번 보자.

@Composable
fun SignInScreen(showError: Boolean) {
    Column {
        // 이 if 블록은 '분기된 호출 지점'을 만든다.
        // true일 때만 ErrorBanner() 호출 지점이 생성/유지되고,
        // false면 트리에서 제거된다(식별은 분기 위치로 관리).
        if (showError) {
            ErrorBanner()          // 조건부 컴포저블
        }
        SignInForm()               // 항상 존재하는 호출 지점
    }
}

위 코드에서 컴포저블을 나누는 기준은 바로 콜사이트다. 호출 위치를 기준으로 구분하기 때문에 ErrorBanner()와 SignInForm()은 서로 다른 컴포저블로 인식된다. 얼핏 보면 너무 당연한 얘기처럼 들리겠지만, 사실 그렇지만은 않다. 아래 코드를 다시 보자. 콜사이트만으로는 기준이 충분하지 않다는 걸 알 수 있다.

@Composable
fun ChannelsScreen(channels: List<Channel>) {
    Column {
        for (channel in channels) {
            ChannelOverview(channel)
        }
    }
}

어떤가? 호출부가 같으니까 ChannelOverview(channel)은 하나의 컴포저블로 인식될까? 그렇지 않다. Compose 시스템은 단순히 콜사이트만 보는 게 아니라 실행 순서까지 함께 고려해서 컴포저블을 구분한다. 그래서 channels가 변하지 않는 한, 즉 channels 안의 요소 순서가 바뀌지 않는 한 recomposition은 일어나지 않는다. 똑똑하게 필요한 부분만 갱신되는 거다.

그렇다면 만약 어떤 이유로 순서가 뒤섞이면 어떻게 될까? 답은 간단하다. Compose는 그걸 “완전히 다른 요소”라고 판단해서 해당 위치의 UI를 통째로 버리고 새로 만든다.

이런 상황이 특히 자주 생기는 곳이 LazyColumn이다. 리스트에서 아이템 순서가 바뀌거나, 스크롤로 화면 밖으로 나갔다가 다시 들어올 때 Compose는 위치를 기준으로 식별하기 때문에 “아, 이건 새로운 아이템이네?” 하고 기존 UI를 버리고 새로 만든다. 이 과정에서 내부 상태가 다 날아가고, 불필요한 recomposition이 반복된다.

그래서 LazyColumn이나 LazyGrid를 쓸 때는 꼭 key를 지정해주는 게 좋다. 아이템의 고유 ID를 key로 넘겨주면 Compose가 아이템의 “정체성”을 기억하기 때문에 화면 밖으로 나갔다가 돌아와도 기존 UI를 그대로 재사용할 수 있다.

@Composable
fun ChannelListScreen(channels: List<Channel>) {
    LazyColumn {
        // key를 지정하지 않은 경우
        items(channels) { channel ->
            ChannelOverview(channel)
        }
    }
}

이렇게 작성하면 channels 리스트가 그대로더라도 스크롤할 때마다 아이템이 새로 그려질 수 있다. 하지만 아래처럼 key를 지정해주면 Compose는 아이템을 위치가 아니라 ID로 구분해서 처리한다.

@Composable
fun ChannelListScreen(channels: List<Channel>) {
    LazyColumn {
        items(
            items = channels,
            key = { it.id } // 채널의 고유 ID를 key로 지정
        ) { channel ->
            ChannelOverview(channel)
        }
    }
}

이렇게 하면 스크롤을 하거나 순서가 바뀌더라도 같은 ID를 가진 채널은 기존 UI를 그대로 재사용한다. 입력 값이나 애니메이션 진행도 같은 내부 상태도 유지되고, 불필요한 recomposition도 크게 줄어든다. 결국 핵심은 “위치”가 아니라 “정체성”으로 구분하게 만드는 거다. Compose가 아이템을 어떻게 식별하는지만 이해해도 리스트 성능은 훨씬 좋아진다.

여기서 한 가지 더 짚자. 만약 ChannelOverview 안에서 네트워크 이미지를 비동기로 불러오는 사이드이펙트가 있다면, “순서 기반 식별”의 비용은 훨씬 커진다. 리스트 상단이나 중간에 아이템이 추가·삭제·재정렬되면 위치가 바뀐 모든 호출 지점이 다시 재구성되고, 진행 중이던 이미지 로드가 통째로 취소됐다가 재시작된다. 네트워크는 비용이 큰 자원이고, 이런 상황이 스크롤 중에 반복되면 체감 성능 저하로 이어진다.

이 문제를 해결하는 방법은 두 가지다. 첫째, 안정적인 key를 지정해서 위치와 무관하게 정체성으로 식별하게 만들고, 둘째, 사이드이펙트를 아이템의 정체성(id)에 맞춰 스코프를 잡아주는 것이다.

@Composable
fun ChannelListScreen(channels: List<Channel>) {
    LazyColumn {
        // 절대 index를 key로 쓰지 말 것! (순서가 바뀌면 다른 아이템으로 간주)
        items(
            items = channels,
            key = { it.id } // ← 고유 id로 "정체성" 고정
        ) { channel ->
            ChannelOverview(channel)
        }
    }
}

위와 같이 안정적인 key를 지정해준다. 그다음, ChannelOverview 안에서 일어나는 네트워크 로드를 id에 결박한다. remember와 LaunchedEffect의 key를 channel.id로 주면, 같은 채널(id)이 위치만 달라져도 작업이 이어지고, 다른 채널로 바뀔 때만 취소→재시작된다.

@Composable
fun ChannelOverview(channel: Channel) {
    // 상태는 id에 종속되도록 remember 범위를 잡는다
    var image by remember(channel.id) { mutableStateOf<Image?>(null) }
    var loading by remember(channel.id) { mutableStateOf(false) }
    var error by remember(channel.id) { mutableStateOf<Throwable?>(null) }

    // 사이드이펙트도 id를 key로 스코프: 같은 채널이면 위치가 바뀌어도 이어짐
    LaunchedEffect(channel.id) {
        loading = true
        error = null
        try {
            image = loadNetworkImage(channel.imageUrl) // suspend 가정
        } catch (t: Throwable) {
            error = t
        } finally {
            loading = false
        }
    }

    Column {
        when {
            loading -> ChannelHeaderLoading()
            error != null -> ChannelHeaderError(onRetry = { /* 재시도 로직 */ })
            else -> ChannelHeader(image)
        }
        Text(channel.name)
    }
}

핵심은 “위치가 바뀌어도 같은 아이템이면 같은 작업으로 이어지게” 만드는 거다. key = { it.id }로 트리 매칭을 안정화하고, 내부 비동기 작업은 remember(channel.id)와 LaunchedEffect(channel.id)로 정체성 기준으로 스코프를 잡는다. 이렇게 하면 스크롤·정렬 변화·삽입/삭제가 있어도 같은 채널은 재사용되고, 네트워크 로드가 불필요하게 취소·재시작되는 걸 크게 줄일 수 있다.

반대로, 아래처럼 index를 key로 쓰거나(또는 key를 아예 생략) 사이드이펙트를 값 변화와 무관한 remember { ... }에 두면, 순서가 조금만 흔들려도 Compose는 전혀 다른 것으로 판단해서 매번 갈아엎는다.

@Composable
fun BadChannelListScreen(channels: List<Channel>) {
    LazyColumn {
        items(
            items = channels,
            key = { index -> index } // ❌ 나쁜 예: 순서가 바뀌면 전부 다른 것으로 간주
        ) { channel ->
            BadChannelOverview(channel)
        }
    }
}

@Composable
fun BadChannelOverview(channel: Channel) {
    // ❌ 나쁜 예: key 없이 remember → 다른 채널로 바뀌어도 이전 상태가 섞이거나,
    // 스크롤 재등장 시 불필요한 취소/재시작 발생
    var image by remember { mutableStateOf<Image?>(null) }

    // ❌ 나쁜 예: 효과 key 없음 → 사소한 재구성에도 취소/재시작 난사
    LaunchedEffect(Unit) {
        image = loadNetworkImage(channel.imageUrl)
    }

    ChannelHeader(image)
}

정리하면, 리스트 성능과 안정성은 결국 “식별” 문제다. 리스트에는 고유 key, 사이드이펙트에는 고유 id를 key로. 사이드이펙트를 규칙(합성 경계) 밖으로 밀어내고 상태를 id 기준으로 표준화하면, Compose는 똑똑하게 재사용하고 우리는 불필요한 네트워크 비용과 끊김을 피할 수 있다.

다시 계산하지 말고, 기억해서 꺼내쓰기

두 번째 규칙은 단순하다. 연산을 최소화해라. UI 빌드(리컴포지션) 중에 무거운 계산은 remember로 결과를 캐싱해서 덜 돌려야 한다. 한 번 그려진 결과를 재활용하는 게 Smart Recomposition의 핵심이다. 그래서 지금부터 remember가 정확히 뭔지, 컴포지션 과정에서 어떻게 붙어서 언제 무효화되는지까지 깔끔하게 정리해보자. 흐름은 “왜 필요한가 → 어떻게 동작하나 → 실제 코드에선 뭘 쓰나 → 컴파일러가 뒤에서 뭘 하냐” 순서로, 중간중간 실전 예시랑 “개념 모델(가짜 라이브러리 코드)”까지 곁들인다.

UI는 결국 “데이터 → 그리기” 사이클의 반복이다. 문제는 화면이 조금만 바뀌어도 같은 계산을 또 하고 또 한다는 거다. 이미지 디코딩, 리스트 정렬/필터, 복잡한 포맷팅 같은 건 비용이 크다. remember의 역할은 여기서 끝난다. “이 계산 결과, 같은 자리에서 계속 재활용하자.” 근데 이 “같은 자리”라는 말 안에 컴포지션/그룹/슬롯/호출 지점 개념이 들어 있다.

먼저 가벼운 예시부터 보자.

@Composable
fun Scores(raw: List<Int>) {
    val top3 = remember(raw) { raw.sortedDescending().take(3) }
    Text(top3.joinToString())
}

핵심은 두 줄이다. remember(raw) { … }로 raw가 바뀔 때만 정렬을 다시 돌리고, 같으면 캐시에서 꺼낸다. 왜 묶냐면 sortedDescending()이 꽤 무거울 수 있고, 프레임마다 돌리면 스크롤 끊기고 배터리 뜨거워지니까. 즉 연산 최소화용 캐시다.

조금만 더 들어가면 Compose는 UI를 “컴포지션”이라는 트리 구조로 기억한다. 화면을 그릴 때 단순히 바로 픽셀만 찍는 게 아니라, 어떤 컴포저블이 어떤 순서로 호출됐는지, 그 결과 어떤 UI가 만들어졌는지를 전부 기록해둔다. 이 트리 안에서 컴포저블 함수 하나 호출이 그룹이고, 그 그룹 안에는 여러 개의 슬롯이 있다. remember는 바로 이 슬롯 하나를 빌려서 값을 저장한다. 그래서 같은 함수가 다시 호출돼도, 컴포저는 “아, 여기 지난번 그 자리네” 하고 슬롯에서 값을 꺼내서 재사용해준다.

예를 들어 이런 코드가 있다고 해보자.

@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) } // 그룹 안 슬롯에 저장
    Button(onClick = { count.value++ }) {
        Text("count = ${count.value}")
    }
}

여기서 remember는 Counter 그룹의 슬롯에 MutableState를 박아둔다. 다시 그려질 때는 mutableStateOf(0)를 새로 만들지 않고 슬롯에서 꺼내준다.

문제는 이 “자리”라는 게 기본적으로 순서 기반이라는 거다. 예를 들어 if 분기가 있을 때 이렇게 짜면 문제가 생길 수 있다.

@Composable
fun Example(flag: Boolean) {
    if (flag) {
        val a = remember { "A" } // flag=true일 때만 등장
        Text(a)
    }
    val b = remember { "B" }     // flag가 true일 때는 슬롯1, false일 땐 슬롯0
    Text(b)
}

처음에 flag = true라면 a는 슬롯0, b는 슬롯1에 들어간다. 그런데 나중에 flag = false로 바뀌면 a가 사라지면서 b가 슬롯0에 들어간다. 그러면 컴포저는 슬롯0에 있던 "A"를 그대로 꺼내서 "B" 위치에 써버리거나, 새로 다시 계산해버리는 상황이 생긴다. 이게 바로 호출 지점이 흔들린다는 거다.

리스트도 마찬가지다.

@Composable
fun UserList(users: List<User>) {
    LazyColumn {
        items(users) { user ->
            val avatar = remember { decodeAvatar(user.avatarUrl) }
            Text(user.name)
        }
    }
}

여기서 순서를 [A, B, C] → [B, A, C]로 바꾸면, 슬롯 번호가 달라져서 B가 A의 캐시를 잘못 가져가거나 다시 디코딩을 해버린다.

그래서 해결책은 두 가지다. 첫 번째는 그룹 자체에 안정적인 키를 주는 거다.

LazyColumn {
    items(users, key = { it.id }) { user ->
        val avatar = remember { decodeAvatar(user.avatarUrl) }
        Text(user.name)
    }
}

이렇게 하면 순서가 바뀌어도 “이 그룹은 user.id로 식별된다”라는 이름표가 붙어서 캐시가 꼬이지 않는다.

두 번째는 remember에 의존성을 명시하는 거다.

LazyColumn {
    items(users, key = { it.id }) { user ->
        val avatar = remember(user.avatarUrl) { decodeAvatar(user.avatarUrl) }
        Text(user.name)
    }
}

이렇게 하면 같은 호출 지점이어도 user.avatarUrl이 바뀌면 새로 계산하고, 같으면 이전 값을 그대로 재사용한다.

정리하자면 컴포지션은 UI 트리, 그룹은 컴포저블 호출 하나, 슬롯은 그 그룹 안의 저장칸이다. remember는 슬롯에 값을 넣어두고 같은 호출 지점이면 그대로 꺼내 쓴다. 근데 순서가 바뀌면 캐시를 못 찾는다. 그래서 리스트나 분기처럼 순서가 흔들릴 수 있는 상황에선 key를 붙이거나, remember에 키를 줘서 안정적으로 캐시를 유지해야 한다.

여기서 헷갈리기 쉬운 포인트도 있다. remember는 결과를 저장할 뿐이고 “이 값 바뀌었으니 이 부분만 다시 그려” 같은 변화 통지는 하지 않는다. 그건 State의 역할이다. 그래서 실무에서는 보통 이렇게 쓴다.

@Composable
fun Counter() {
    val counter = remember { mutableStateOf(0) } // State 인스턴스를 캐싱
    Button(onClick = { counter.value++ }) { Text("${counter.value}") }
}

여기서 remember는 MutableState를 한 번만 만들게 하고, 다시 그려지는 건 counter.value를 읽은 호출 지점만 무효화(invalidate)되기 때문이다. 결론은 무거운 건 remember, 변화 통지는 State다.

파생값도 최적화해두면 리컴포지션 소음이 준다. derivedStateOf는 다른 State에서 계산되는 파생값을 메모이즈해준다.

@Composable
fun TopBarShadow() {
    val listState = rememberLazyListState()
    val showShadow by remember { 
        derivedStateOf { 
            listState.firstVisibleItemIndex > 0 ||
            listState.firstVisibleItemScrollOffset > 0
        } 
    }
    TopAppBar(tonalElevation = if (showShadow) 4.dp else 0.dp)
    LazyColumn(state = listState) { /* ... */ }
}

정렬이나 필터 캐시도 자주 쓰는 패턴이다.

@Composable
fun SortedChannels(channels: List<Channel>) {
    // channels 참조가 바뀔 때만 재계산(불변 리스트 가정)
    val sorted by remember(channels) {
        mutableStateOf(channels.sortedBy { it.name.lowercase() })
    }
    LazyColumn {
        items(sorted, key = { it.id }) { ch -> ChannelRow(ch) }
    }
}

아이템별 무거운 리소스를 캐시할 땐 키가 있는 remember를 쓴다. 정체성에 스코프를 걸어주는 느낌이다.

@Composable
fun ChannelOverview(channel: Channel) {
    val avatarHandle = remember(channel.id) { heavyDecode(channel.avatarUrl) }
    Avatar(avatarHandle)
}

생성과 해제가 필요한 리소스는 remember만으론 부족하다. 생성은 remember, 수명 관리는 DisposableEffect로 끊어줘야 한다.

@Composable
fun Player(channelId: String) {
    val player = remember(channelId) { Player() } // 생성 캐시
    DisposableEffect(player) {
        player.prepare(channelId)          // 시작
        onDispose { player.release() }     // 종료
    }
    PlayerView(player)
}

코루틴은 유지하면서 최신 람다만 바꾸고 싶을 땐 rememberUpdatedState가 깔끔하다.

저장 수명도 구분해야 한다. remember는 리컴포지션 사이에는 유지되지만, 회전 같은 구성 변경에는 날아가버린다. 그런 값은 rememberSaveable로 잡아야 한다. 큰 객체는 통째로 저장하지 말고 ID 같은 걸 저장해서 복구하는 게 좋다.

@Composable
fun SignInForm() {
    var email by rememberSaveable { mutableStateOf("") } // 회전에도 보존
    var password by remember { mutableStateOf("") }      // 화면 벗어나면 리셋 OK
    /* ... */
}

반대로 이렇게 하면 안 된다. 상위에서 큰 상태를 통째로 읽으면 작은 변화에도 큰 트리가 다시 그려진다.

@Composable
fun BadHome(uiState: HomeUiState) {
    // 이 한 줄이 바뀔 때마다 아래 전부 재구성될 수 있음
    val showFab = uiState.canPost
    Feed(uiState.items)      // 무거운 리스트
    Fab(visible = showFab)
}

이럴 땐 필요한 조각만 읽고, 파생이나 캐시는 안쪽으로 밀어 넣는 게 좋다.

@Composable
fun GoodHome(items: List<Post>, canPost: Boolean) {
    val showFab by remember { derivedStateOf { canPost } }
    Feed(items)
    Fab(visible = showFab)
}

그리고 리스트나 그리드에서는 항상 key를 제공해줘야 한다. 호출 지점과 슬롯을 안정화해야 remember 캐시가 제대로 붙는다. 같은 ID면 같은 슬롯으로 돌아오게 하고, 다른 ID일 때만 새로 계산하게 만들면 된다.

LazyColumn {
    items(posts, key = { it.id }) { post ->
        val preview = remember(post.id) { buildPreview(post) }
        Text(preview)
    }
}

조건부 컴포저블에서도 분기 흔들림을 막으려면 key로 호출 지점을 고정해둘 수 있다.

@Composable
fun MaybeBox(show: Boolean, id: String) {
    key(id) {
        if (show) {
            val heavy = remember(id) { computeHeavy(id) }
            Text(heavy)
        }
    }
}

컴포저블 함수는 그냥 함수처럼 보이지만 사실은 컴파일러가 뒤에서 꽤 많은 장치를 붙여준다. 단순히 UI를 그리는 게 아니라 “이 호출 지점이 어디인지”, “여기서 기억해둬야 할 값이 뭔지”, “이 부분은 다시 실행할지 건너뛸지” 같은 걸 전부 추적한다. 예를 들어서 이렇게 간단한 코드가 있다고 치자.

@Composable
fun Example(raw: List<Int>) {
    val top3 = remember(raw) { raw.sortedDescending().take(3) }
    Text(top3.joinToString())
}

이건 컴파일되면 개념적으로 이런 식으로 풀린다. (실제 코드는 훨씬 복잡하지만 흐름만 보면 된다)

fun Example(raw: List<Int>, composer: Composer, changed: Int) {
    composer.startGroup(key = 12345)
    val top3 = composer.remember(
        anchor = composer.currentAnchor(),
        keys = arrayOf(raw)
    ) {
        raw.sortedDescending().take(3)
    }
    Text(top3.joinToString(), composer, 0)
    composer.endGroup()
}

여기서 중요한 포인트는 두 가지다. 첫째, remember는 키를 비교하고 슬롯 테이블에서 값을 꺼내오는 방식으로 동작한다는 것. 둘째, 입력이 바뀌지 않았다면 이 그룹 자체를 “스킵”해서 내부 호출을 아예 실행하지 않는다. 그러면 remember { … } 블록도 다시 계산하지 않고 이전 값을 그대로 쓴다. 스킵이 잘 적용될수록 캐시가 잘 맞아서 성능이 좋아지고, 반대로 매번 새로운 객체를 파라미터로 만들어서 넘기면 스킵이 깨져서 매번 키 비교나 계산이 일어나게 된다. 그래서 Compose에서 입력을 안정적으로(stable) 유지하는 습관이 중요하다고 말하는 거다.

아주 단순화한 개념 모델을 코드로 보면 이런 느낌이다.

class Composer {
    private val slotTable = mutableMapOf<Anchor, Any?>()

    fun <T> remember(anchor: Anchor, keys: Array<out Any?>, calc: () -> T): T {
        val entry = slotTable[anchor]
        if (entry is RememberEntry && entry.keys.contentEquals(keys)) {
            @Suppress("UNCHECKED_CAST")
            return entry.value as T
        }
        val newValue = calc()
        slotTable[anchor] = RememberEntry(keys, newValue)
        return newValue
    }

    data class RememberEntry(val keys: Array<out Any?>, val value: Any?)
    data class Anchor(val id: Long)
}

여기에 RememberObserver라는 인터페이스도 있는데, 이걸 구현하면 컴포지션 생명주기에 맞춰 자동으로 콜백을 받을 수 있다.

interface RememberObserver {
    fun onRemembered()   // 슬롯에 붙었을 때
    fun onForgotten()    // 분기 빠져서 그룹에서 떨어졌을 때
    fun onAbandoned()    // 컴포지션이 완료되기 전에 버려질 때
}

예를 들어 네이티브 리소스를 붙잡고 있는 객체가 있으면 이렇게 만들 수 있다.

class DecoderCache(private val url: String): RememberObserver {
    private var handle: NativeHandle? = null

    override fun onRemembered() {
        handle = openDecoder(url) // 디코더 열기
    }

    override fun onForgotten() {
        handle?.close()
        handle = null
    }

    override fun onAbandoned() {
        handle?.close()
        handle = null
    }

    fun decode(): Bitmap = handle!!.decode()
}

이제 Composable에서는 그냥 이렇게만 쓰면 된다.

@Composable
fun Avatar(url: String) {
    val cache = remember(url) { DecoderCache(url) }
    Image(cache.decode().asImageBitmap(), null)
}

겉보기에는 단순히 remember(url) 한 줄로 캐시 객체를 만든 것 같지만, 내부적으로는 DecoderCache가 RememberObserver를 구현했기 때문에 컴포지션에 붙을 때 디코더를 열고, 빠지면 닫아주고, 중간에 버려질 때도 알아서 정리된다. 그러니까 Avatar는 그냥 decode()만 호출하면 되고, 생명주기 관리는 자동으로 해결되는 구조다.

정리하면, 컴파일러가 붙여주는 트래킹 코드와 슬롯 테이블, 스킵 최적화 같은 것들이 있어서 remember가 단순한 캐시 이상으로 동작한다는 거고, RememberObserver까지 섞으면 리소스 관리까지도 안전하게 할 수 있다. 이게 Compose 내부가 어떻게 돌아가는지 감 잡게 해주는 핵심 그림이다.

마지막으로 실전에서 바로 써먹는 패턴을 몇 가지 더 보자. 우선 형식 변환이나 포맷터 같은 비싼 객체는 캐시해서 재활용하는 게 좋다.

@Composable
fun FancyDate(ts: Long, pattern: String) {
    val formatter = remember(pattern) { DateTimeFormatter.ofPattern(pattern) }
    Text(Instant.ofEpochMilli(ts).atZone(ZoneId.systemDefault()).format(formatter))
}

날짜 포맷터는 생성 비용이 크니까 매번 만들지 말고 remember로 묶어두는 게 훨씬 효율적이다.

그 다음은 파생 합계 최소화다. 장바구니 총합 같은 값은 리스트가 바뀔 때만 다시 계산하면 된다.

@Composable
fun CartTotal(items: List<Item>) {
    val total by remember(items) { derivedStateOf { items.sumOf { it.price * it.count } } }
    Text("총액: $total원")
}

입력값을 복원해야 할 땐 rememberSaveable을 쓰면 된다. 프로세스가 죽었다 살아나도 상태를 번들로 직렬화해서 복구해준다.

data class Form(val name: String, val age: Int)

val FormSaver = Saver<Form, Bundle>(
    save = { f -> Bundle().apply { putString("n", f.name); putInt("a", f.age) } },
    restore = { b -> Form(b.getString("n") ?: "", b.getInt("a")) }
)

@Composable
fun ProfileForm() {
    var form by rememberSaveable(stateSaver = FormSaver) { mutableStateOf(Form("", 0)) }
    // …
}

여기서 자주 터지는 오해들도 같이 짚고 넘어가야 한다. remember { heavy(list) }처럼 키를 안 주면 list가 바뀌어도 절대 재계산 안 한다. 이건 버그다. 반드시 remember(list) { heavy(list) }처럼 키를 명시해야 한다. 또 remember 안에서 사이드이펙트를 직접 돌리면 수명 관리가 안 된다. 이런 건 LaunchedEffect(key)나 DisposableEffect(key)를 써야 한다. 리스트에서 index를 key로 주는 것도 문제다. 재정렬이나 삽입 시 호출 지점이 흔들려서 캐시가 꼬인다. 안전한 방법은 안정적인 ID를 key로 쓰는 거다. 입력 필드를 remember로만 들고 있으면 프로세스 재생성 시 입력이 날아간다. 이런 값은 rememberSaveable로 잡아야 복원된다. 마지막으로, remember가 알아서 의존성을 추적해줄 거라고 착각하는 경우가 많은데 사실은 키로만 판단한다. 의존성은 직접 명시해야 한다.

정리하면 remember는 전역 캐시가 아니라, 딱 그 호출 지점 전용 메모이제이션이다. 재계산 기준은 두 가지뿐이다. 호출 지점이 유지되는가, 그리고 키가 같은가. 무거운 계산은 remember(키…)로 묶고, 화면 갱신은 State로, 파생은 derivedStateOf, 수명 관리는 LaunchedEffect나 DisposableEffect, 복원은 rememberSaveable, 최신 참조는 rememberUpdatedState로 처리한다. 이 조합이 몸에 붙으면 변화가 실제로 있을 때만 계산하고 나머지는 remember로 버티는 구조가 된다. 그러면 리컴포지션은 가벼워지고 UI는 훨씬 부드러워진다.

넓게 읽지 말고 꼭 필요한 것만 읽고 쓰기

세 번째 규칙은 상태 관리를 항상 최적화하자는 것이다. 좋지 않은 recomposition의 큰 원인 중 하나가 “상태를 너무 넓게 읽기”와 “합성(Composition) 도중 상태를 써버리는 역방향 쓰기”다. 상태는 필요한 범위에서만 읽고, 파생값은 파생값으로 분리해 변화 빈도를 낮추고, 쓰기는 반드시 효과 영역으로 밀어내야 한다. Compose의 상태 모델이 어떻게 동작하는지부터 정리해보자. Compose는 State를 읽는 컴포저블이 그 값이 바뀔 때만 다시 그려지도록 설계되어 있다. 즉 “무엇을 어디서 읽느냐”가 곧 성능이다.

가장 먼저 파생값 최적화다. 스크롤처럼 값은 자주 바뀌는데 UI는 임계값을 넘길 때만 반응하길 원할 수 있다. 이럴 때는 derivedStateOf로 “변화가 필요할 때만 바뀌는” 파생 상태를 만들어서 관찰하면 된다. 예를 들어 상단 그림자를 보일지 말지 결정하는 값은 이렇게 쪼갠다.

@Composable
fun ChannelListWithTopBarShadow() {
    val listState = rememberLazyListState()

    // 스크롤이 0을 넘을 때만 true로 바뀌는 파생 상태
    val showShadow by remember {
        derivedStateOf { 
            listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
        }
    }

    Column {
        TopAppBar(tonalElevation = if (showShadow) 4.dp else 0.dp)
        LazyColumn(state = listState) { /* ... */ }
    }
}

derivedStateOf는 입력이 더 자주 변해도 “파생 결과가 바뀌는 순간”에만 업데이트를 내보내도록 설계돼 있다. 그래서 불필요한 재구성을 피한다. 문서에서도 스크롤·제스처처럼 업데이트가 잦은 입력을 임계값 기반으로 다룰 때 쓰라고 권장한다. 실제로 derivedStateOf는 관찰 가능한 Compose 상태를 만들어 주는 도우미다. 보통 remember { derivedStateOf { ... } } 형태로 생성자 자체를 안정화한다.

다음은 “합성 중 쓰기 금지”다. 합성 단계에서 상태를 바로 바꾸면 그 변경이 다시 합성을 유발하고, 그 합성에서 또 바꾸고… 무한 루프가 난다. 아래 코드는 나쁜 예다.

@Composable
fun BadCounter() {
    var count by remember { mutableStateOf(0) }
    if (count < 10) {           // ❌ 합성 중 쓰기 → 무한 재구성의 씨앗
        count++                  // 여기서 쓴 값이 다시 재구성 유발
    }
    Text("count = $count")
}

이런 초기화·비동기 요청·로깅 같은 “쓰기”는 LaunchedEffect 등 효과(Effect) 영역으로 밀어내야 한다. 효과는 해당 컴포저블의 생명주기에 묶이고, key가 바뀌거나 컴포지션에서 빠질 때 자동으로 취소된다.

@Composable
fun GoodCounter() {
    var count by remember { mutableStateOf(0) }

    LaunchedEffect(Unit) {      // ✅ 합성 밖(효과)에서 한 번만 실행
        while (count < 10) {
            delay(16)
            count++
        }
    }
    Text("count = $count")
}

LaunchedEffect는 “컴포저블 수명에 스코프된 코루틴”을 띄우는 도구다. key가 변하면 이전 작업을 취소하고 새로 시작한다. 반대로 key가 상수면 해당 수명 동안 한 번만 돈다.

상태 저장의 수명도 맞춰야 한다. remember는 “재구성 사이”에는 유지되지만 구성 변경(회전, 다크모드)이나 프로세스 재생성에는 날아간다. 그런 상황까지 살아야 하는 값은 rememberSaveable을 쓴다. 가능한 값은 번들에 자동 저장되고, 복잡한 타입은 Saver로 직렬화 방식을 지정한다.

@Composable
fun SignInForm() {
    var email by rememberSaveable { mutableStateOf("") }   // 회전에도 보존
    var password by remember { mutableStateOf("") }        // 화면 벗어나면 리셋돼도 되는 값
    /* ... */
}

공식 문서도 “재구성 유지 = remember, 구성 변경/프로세스 복구까지 = rememberSaveable”로 정리한다. 단, 번들 크기 한계가 있으니 큰 리스트/이미지는 ID 같은 최소 상태만 저장하고 복구는 다른 저장소를 이용하라고 권장한다.

Compose가 “변했다”고 판단하는 기준도 알아두면 좋다. mutableStateOf는 기본적으로 구조적 동등성(==) 로 값 변화를 판단한다. 즉 같은 값(==)을 다시 넣으면 변경으로 취급되지 않아 재구성이 일어나지 않는다. 이 기본값 덕에 숫자·데이터 클래스처럼 값이 같은 재할당은 소음이 줄어든다.

컬렉션을 상태로 다룰 때는 스냅샷 상태 컨테이너를 쓰자. mutableStateListOf()나 toMutableStateList()를 쓰면 추가/삭제가 곧바로 관찰돼 필요한 부분만 다시 그린다.

@Composable
fun SelectedChannels() {
    val selected = remember { mutableStateListOf<String>() } // channelId 목록
    LazyColumn {
        items(channels) { ch ->
            val checked = ch.id in selected
            ChannelRow(
                channel = ch,
                checked = checked,
                onToggle = {
                    if (checked) selected.remove(ch.id) else selected.add(ch.id)
                }
            )
        }
    }
}

파생값을 “UI 트리의 더 위쪽”에서 읽는 것도 조심하자. 큰 스코프에서 상태를 읽으면 그 스코프 전체가 다시 그려진다. 아래는 나쁜 예다. 화면 루트에서 거대한 uiState를 통째로 읽고, 그 안의 작은 필드 하나 때문에 전체가 재구성된다.

@Composable
fun BadHome(uiState: HomeUiState) {
    Column {
        // 이 한 줄이 바뀔 때마다 아래 전부가 같이 재구성될 수 있음
        val showFab = uiState.canPost
        Feed(uiState.items)               // 데이터도 큼
        FloatingActionButton(visible = showFab) { /* ... */ }
    }
}

필요한 조각만 읽거나, 파생값으로 분리해 영향 범위를 줄인다.

@Composable
fun GoodHome(items: List<Post>, canPost: Boolean) {
    val showFab by remember { derivedStateOf { canPost } } // 변화 빈도/범위 축소
    Feed(items)                                            // 목록은 목록대로
    FloatingActionButton(visible = showFab) { /* ... */ }
}

상태를 Flow로 다뤄야 할 때는 snapshotFlow가 유용하다. Compose 스냅샷 상태 읽기를 차가운 Flow로 변환해 코루틴으로 다룰 수 있다. 예를 들어 리스트의 “맨 위 아님” 상태를 이벤트 스트림으로 바꾸고 싶다면 다음처럼 쓴다.

@Composable
fun RememberScrollEvents() {
    val listState = rememberLazyListState()

    LaunchedEffect(listState) {
        snapshotFlow {
            listState.firstVisibleItemIndex > 0 ||
            listState.firstVisibleItemScrollOffset > 0
        }.collect { notAtTop ->
            analytics.log("notAtTop=$notAtTop")
        }
    }
}

snapshotFlow는 블록에서 읽은 스냅샷 상태가 바뀌면(이전과 값이 다르면) 새 값을 내보낸다. UI는 필요할 때만 이 값에 반응하면 된다.

마지막으로, 리스트·정렬·삽입/삭제처럼 “위치가 흔들리는” 곳에서는 key를 반드시 제공하고, 비동기 작업은 아이템의 id에 스코프하자. 같은 id면 같은 상태·작업으로 이어지고, 다른 id일 때만 취소·재시작된다. 이건 앞선 글에서 다뤘던 리스트 규칙과 같은 맥락이다. 파생값은 derivedStateOf, 저장 수명은 remember/rememberSaveable, 쓰기는 LaunchedEffect로 밖으로. 기본 동등성은 구조적(==)이며, 큰 컬렉션은 스냅샷 컨테이너. 결국 핵심은 하나로 수렴한다. 사이드이펙트를 규칙 밖으로 밀어내고, 상태를 표준화해서 합성 가능한 경계로 만든다. 그러면 Compose는 필요한 만큼만, 그것도 빠르게 다시 그린다.

변화를 놓치지 말고, 추적 가능하게 만들기

Compose에서 말하는 “데이터 안정성(stability)”이라는 건, 이 타입이 바뀌었는지 Compose가 알 수 있냐, 없냐의 문제다. 알 수 있으면 변했을 때만 다시 그리고, 모르면 혹시 모르니까 매번 다시 그려버린다. 결국 성능 차이가 여기서 갈린다.

예를 들어 아래처럼 var와 MutableSet을 쓰면 Compose는 추적이 힘들다.

// ❌ 불안정: var + MutableSet은 Compose가 변화를 감지 못함
data class Channel(
    var name: String,
    val members: MutableSet<String>
)

name이 바뀌거나, members.add("사람")이 실행돼도 Compose는 “이게 진짜 달라진 건가?”를 확실히 알 수 없다. 그러니까 안전하게 가려고 매번 리컴포지션을 트리거한다.

반대로 모든 걸 불변으로 만들면 Compose가 쉽게 판단할 수 있다.

// ✅ 안정적: 모두 val + 불변 리스트
data class Channel(
    val name: String,
    val members: List<String>
)

여기선 Channel 객체 자체가 새로 만들어져야 값이 바뀐 거니까, Compose는 비교하기 쉽다. 값이 같으면 다시 그릴 필요 없고, 다르면 그때만 그리면 된다. 그래서 “불변(immutable) 우선” 패턴이 권장되는 거다.

하지만 현실적으로는 도메인 모델이 가변일 때가 있다. 이럴 때는 가변 프로퍼티를 그냥 두지 말고 Compose의 상태로 래핑해야 한다. 즉, mutableStateOf를 쓰는 거다.

import androidx.compose.runtime.*

@Stable // 이 타입의 프로퍼티는 Compose 상태로 추적된다는 계약
class ChannelState(
    name: String,
    memberCount: Int
) {
    var name by mutableStateOf(name)        // Compose 상태
        private set

    var memberCount by mutableStateOf(memberCount)  // Compose 상태
        private set

    fun rename(newName: String) { name = newName }
    fun setMemberCount(c: Int) { memberCount = c }
}

여기서 @Stable이라는 어노테이션을 붙여주는 이유는 “이 타입은 public 프로퍼티가 바뀌면 Compose가 알 수 있게 되어 있다”라는 걸 컴파일러에 약속하는 거다. 그럼 Compose는 이 타입을 불필요하게 매번 다시 그리지 않고, 실제 상태가 변했을 때만 다시 그린다.

사용하는 쪽은 아주 단순해진다.

@Composable
fun ChannelCard(state: ChannelState) {
    Text(text = state.name)                  // name이 바뀌면 여기만 다시 그림
    Text(text = "${state.memberCount}명")    // memberCount 바뀌면 여기만 다시 그림
}

컬렉션도 마찬가지다. 그냥 MutableList로 쓰면 추가/삭제를 감지하지 못한다. 이럴 땐 Compose 전용의 스냅샷 컨테이너를 써야 한다.

@Composable
fun ChannelList(initial: List<ChannelState>) {
    val channels = remember {
        mutableStateListOf<ChannelState>().apply { addAll(initial) }
    }

    // 원소 자체가 Compose 상태이므로 안전
    fun renameFirst() {
        val first = channels.firstOrNull() ?: return
        first.rename("새 이름")   // ChannelState 안에서 상태로 추적됨
    }

    LazyColumn {
        items(channels, key = { it.name }) { st ->
            ChannelCard(st)
        }
    }
}

mutableStateListOf를 쓰면 add, remove, set 같은 변경이 곧바로 Compose에 감지돼서, 필요한 아이템만 다시 그린다. 원소 자체가 ChannelState처럼 Compose 상태를 노출하는 타입이면 내부 변경도 안전하게 추적된다.

결국 Compose가 “이 값이 바뀌었는지”를 확실히 알 수 있게 만드는 게 핵심이다. 알 수 있으면 skip 최적화가 적용돼서 큰 트리도 건너뛰고 재사용할 수 있다. 알 수 없으면 그냥 매번 다시 그린다.

그래서 규칙은 세 가지로 정리된다.

  1. 가능한 한 불변 데이터 우선으로 설계한다. (val, 불변 리스트)
  2. 불가피하게 가변이면 Compose 상태로 래핑한다. (mutableStateOf, mutableStateListOf)
  3. 컴파일러가 헷갈릴 수 있는 경우엔 @Immutable/@Stable을 붙여서 “이건 안전하다”는 계약을 명시한다.

여기서 중요한 건 어노테이션이 마법이 아니라는 거다. 실제로 불변이거나 실제로 Compose 상태로 변화를 통지할 수 있는 구조여야 한다. 그렇지 않은데 억지로 붙이면 오히려 잘못된 skip이 발생해서 UI가 안 갱신되는 버그가 생긴다.

정리하면, Compose에서 안정성은 곧 “변화를 추적할 수 있느냐”의 문제다. 추적 가능하게 만들면 Compose가 똑똑하게 건너뛰고, 추적 불가능하게 두면 매번 다시 그려야 한다. 이 차이가 성능과 구조의 핵심이다.

실무에서의 Compose 활용 전략

디자인 시스템 기반 UI 최적화

대규모 앱에서 성능 최적화는 “디자인 시스템으로 재사용성을 확보하는 것”부터 시작한다. 버튼, 리스트, 다이얼로그를 컴포넌트로 묶고, 색상·타이포그래피·Shape 같은 디자인 토큰을 Theme과 CompositionLocal로 흘려보내면, 화면이 바뀌어도 코드가 불어나지 않고 재구성이 필요한 최소 지점만 건드리게 된다. 핵심은 두 가지다. 첫째, 스타일과 레이아웃 규칙은 “위”에서 통일해서 내려보낸다. 둘째, 화면은 “데이터 모델 → 컴포넌트”의 규칙으로 그린다. 이렇게 해두면 다크 모드, 동적 컬러(Material You), 폰트 스케일, 접근성 옵션이 바뀌어도 하위 컴포저블을 뜯어고치지 않는다.

토큰은 CompositionLocal로 주입한다. 불변(또는 Compose가 추적 가능한) 데이터 클래스로 정의하고, 테마 루트에서 제공하면 된다. 이렇게 하면 하위 트리가 동일한 토큰 값을 공유하므로, 테마 변경 시 필요한 곳만 깔끔하게 리컴포지션된다.

import androidx.compose.runtime.*
import androidx.compose.material3.*

@Immutable
data class AppPalette(
    val brand: Color,
    val onBrand: Color,
    val success: Color,
    val warning: Color,
)

@Immutable
data class AppDimens(
    val xxs: Dp, val xs: Dp, val sm: Dp, val md: Dp, val lg: Dp
)

val LocalPalette = staticCompositionLocalOf {
    AppPalette(brand = Color(0xFF3B82F6), onBrand = Color.White, success = Color(0xFF10B981), warning = Color(0xFFF59E0B))
}
val LocalDimens  = staticCompositionLocalOf {
    AppDimens(xxs = 2.dp, xs = 4.dp, sm = 8.dp, md = 12.dp, lg = 16.dp)
}

@Composable
fun TebahTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
	val scheme = when {
    	dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
        	if (darkTheme) dynamicDarkColorScheme(LocalContext.current)
        	else dynamicLightColorScheme(LocalContext.current)
    	}
    	darkTheme -> darkColorScheme()
    	else -> lightColorScheme()
	}


    val palette = if (darkTheme)
        AppPalette(brand = scheme.primary, onBrand = scheme.onPrimary, success = Color(0xFF22C55E), warning = Color(0xFFF59E0B))
    else
        AppPalette(brand = scheme.primary, onBrand = scheme.onPrimary, success = Color(0xFF16A34A), warning = Color(0xFFEAB308))

    val dimens = AppDimens(xxs = 2.dp, xs = 4.dp, sm = 8.dp, md = 12.dp, lg = 16.dp)

    CompositionLocalProvider(
        LocalPalette provides palette,
        LocalDimens  provides dimens
    ) {
        MaterialTheme(
            colorScheme = scheme,
            typography = Typography(), // 프로젝트 타이포그래피
            shapes = Shapes(),         // 프로젝트 Shape
            content = content
        )
    }
}

컴포넌트는 토큰을 “읽기만” 하도록 만든다. 색상 값을 매번 직접 넣기보다, 로컬에서 꺼내 쓰면 변동이 생겨도 호출부를 바꿀 필요가 없다. 또한 재사용 컴포넌트에는 “확장 지점(slot)”만 남기고 나머지는 내부에서 토큰으로 처리하면, 호출자 쪽 리컴포지션 범위도 줄어든다.

enum class TButtonVariant { Primary, Tinted, Danger }

@Composable
fun TButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    variant: TButtonVariant = TButtonVariant.Primary,
    leading: (@Composable (() -> Unit))? = null
) {
    val palette = LocalPalette.current
    val color = when (variant) {
        TButtonVariant.Primary -> ButtonDefaults.buttonColors(containerColor = palette.brand, contentColor = palette.onBrand)
        TButtonVariant.Tinted  -> ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant, contentColor = MaterialTheme.colorScheme.onSurfaceVariant)
        TButtonVariant.Danger  -> ButtonDefaults.buttonColors(containerColor = LocalPalette.current.warning, contentColor = Color.Black)
    }
    Button(onClick = onClick, colors = color, shape = MaterialTheme.shapes.medium, modifier = modifier) {
        leading?.invoke()
        Text(text, style = MaterialTheme.typography.labelLarge)
    }
}

데이터 주도 UI는 리스트나 반복되는 패턴에서 힘을 발휘한다. 화면을 “그릴 방법” 대신 “그릴 스펙”으로 모델링하면, 서버 응답이나 A/B 실험에 따라 UI 모양을 바꾸는 것도 쉽다. 중요한 건 스펙을 불변 데이터 클래스로 정의하고, 각 스펙을 안정적으로 구분할 수 있는 id를 갖게 만드는 것이다. 그래야 LazyColumn에서 key = { it.id }로 안정적 매칭이 되고, 불필요한 리컴포지션을 피할 수 있다.

@Immutable
data class UiBlock(
    val id: String,
    val type: UiType,
    val title: String? = null,
    val body: String? = null,
    val imageUrl: String? = null,
    val actions: List<UiAction> = emptyList()
)

enum class UiType { Header, Paragraph, Image, Actions }

@Composable
fun RenderBlocks(blocks: List<UiBlock>) {
    LazyColumn {
        items(
            items = blocks,
            key = { it.id } // 안정적 식별자
        ) { block ->
            when (block.type) {
                UiType.Header    -> Text(block.title.orEmpty(), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(LocalDimens.current.lg))
                UiType.Paragraph -> Text(block.body.orEmpty(), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(LocalDimens.current.lg))
                UiType.Image     -> RemoteImage(url = block.imageUrl)
                UiType.Actions   -> Row(Modifier.padding(LocalDimens.current.md)) {
                    block.actions.forEach { act ->
                        TButton(text = act.label, onClick = act.onClick, modifier = Modifier.padding(end = LocalDimens.current.sm))
                    }
                }
            }
        }
    }
}

@Stable
data class UiAction(val label: String, val onClick: () -> Unit)

컴포넌트는 “입력(상태) → 출력(UI)”의 순수 함수를 지키고, 스타일·간격·모양은 테마/토큰에서 읽는다. 이렇게 작성하면 접근성(폰트 스케일)이나 동적 컬러가 바뀌어도, 변경 신호는 토큰 경로를 따라 내려오고 필요한 부분만 다시 그려진다. 반대로, 각 컴포넌트가 제각각 색상 상수를 들고 있거나 MutableSet 같은 불안정 데이터를 직접 들고 있으면, Compose가 변화 범위를 좁혀서 스킵할 수 없다. 토큰과 데이터 모델은 가급적 @Immutable(완전 불변) 또는 @Stable(상태로 변경을 통지)로 계약을 명시해 두자. 실제로 불변이거나 변경이 Compose 상태로 표면화될 때에만 붙이는 게 안전하다.

리소스/이미지처럼 비용이 큰 요소는 remember와 토큰을 함께 쓰면 효율이 좋다. 예를 들어 썸네일은 remember(url)로 디코딩 핸들을 캐시하고, 패딩/코너는 LocalDimens와 MaterialTheme.shapes에서 읽는다. 스크롤이 많은 화면은 이 조합 하나로도 프레임 드롭을 확 줄인다.

@Composable
fun RemoteImage(url: String?, modifier: Modifier = Modifier) {
    if (url.isNullOrEmpty()) {
        Box(modifier.size(80.dp).background(MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.small))
        return
    }
    val painter = remember(url) { /* 이미지 로더에서 painter 생성 */ }
    Image(
        painter = painter,
        contentDescription = null,
        modifier = modifier
            .size(80.dp)
            .clip(MaterialTheme.shapes.small)
    )
}

테마 전환, 다국어, 브랜드 스킨을 바꿔야 하는 시점에도 디자인 시스템은 그대로 쓴다. 루트의 TebahTheme에서 팔레트·타이포·Shape·Dimens를 교체해 주면, 하위는 그대로 재사용된다. 프리뷰에서도 동일하게 이 테마만 감싸면 실제 빌드와 동일한 룰로 랜더링되니, 디자인-개발 핸드셰이크가 안정된다.

결국 디자인 시스템 기반 최적화는 “코드를 적게 쓰자”가 아니라 “변화를 예측 가능하게 만들자”에 가깝다. 스타일은 위에서 흘리고(Theme/CompositionLocal), 화면은 스펙으로 그리고(데이터 클래스), 리스트는 안정 키로 묶고, 컴포넌트는 순수함수로 유지한다. 그러면 재사용성이 올라가고, 리컴포지션의 범위가 자연스럽게 좁아진다. 이게 대규모 앱에서 눈에 보이는 부드러움을 만드는 가장 경제적인 방법이다.

멀티모듈 아키텍처와 빌드 최적화

앱이 커질수록 빌드 시간은 체감으로 느껴질 만큼 커진다. Compose를 쓰는 프로젝트라면 더더욱 “컴파일 전파”를 어떻게 끊을지부터 고민해야 한다. 나는 모듈을 기능 단위로 쪼개되, 호출자는 화면의 구현을 모르고 진입점 계약만 보게 만든다. Composable을 직접 의존시키면 호출 모듈까지 재컴파일이 번지기 쉬우니까, Compose UI는 항상 구현 모듈에 숨기고, 계약만 얇게 노출한다. 경계는 인터페이스로 고정하고, 구현은 DI로 주입한다. 그러면 기능 모듈 내부에서 Composable이 얼마나 바뀌든, 위쪽(app, 다른 feature)까지는 빌드 충격이 덜 간다.

나는 보통 core / feature / app으로 나누고, feature는 다시 api / impl로 쪼갠다. api에는 라우트와 등록 메서드처럼 “컴파일 안정적인 면”만 남긴다. 화면 Composable, ViewModel, 리포지토리 구현은 전부 impl로 보낸다. 앱은 오직 api만 본다. 구현은 런타임에 주입돼서 붙는다.

// :feature:channel:api  — 호출자가 보는 건 오직 이 계약
package channel.api

interface ChannelEntry {
    val route: String
    fun register(builder: NavGraphBuilder, navController: NavController)
}
// :feature:channel:impl — Compose 화면과 네비 등록은 구현 모듈에 숨긴다
package channel.impl

@Composable
private fun ChannelScreen(
    viewModel: ChannelViewModel = hiltViewModel(),
    onBack: () -> Unit
) {
    val state by viewModel.state.collectAsState()
    ChannelContent(state = state, onIntent = viewModel::onIntent, onBack = onBack)
}

class ChannelEntryImpl @Inject constructor() : ChannelEntry {
    override val route: String = "channel/{id}"
    override fun register(builder: NavGraphBuilder, navController: NavController) {
        builder.composable(route) {
            ChannelScreen(onBack = { navController.popBackStack() })
        }
    }
}

@Module
// 네비게이션 엔트리 바인딩을 어디에 두느냐에 따라 범위가 달라진다.
// SingletonComponent에 두면 앱 전역에서 살아남지만,
// 화면 단위라면 ActivityRetainedComponent나 ViewModelComponent처럼
// 더 좁은 범위를 고려할 수도 있다. (전역이 꼭 필요한 게 아니라면)
@InstallIn(SingletonComponent::class) // 전역이 꼭 필요한 경우에만 사용
abstract class ChannelEntryBindModule {
    @Binds abstract fun bindEntry(impl: ChannelEntryImpl): ChannelEntry
}
// :app — 앱은 api만 의존하고, 멀티바인딩으로 모아 조립만 한다
@Composable
fun AppNavGraph(
    navController: NavController,
    entries: Set<@JvmSuppressWildcards ChannelEntry> // Hilt Multibindings
) {
    NavHost(navController, startDestination = "home") {
        entries.forEach { it.register(this, navController) }
    }
}

이 구조의 포인트가 Compose 쪽에선 더 크다. Composable 시그니처가 바뀌면 그걸 부르는 모듈까지 전파되는데, 아예 Composable을 바깥에 노출하지 않으면 전파 자체가 없다. 호출자는 라우트만 알고, 내부에서 어떤 Composable을 몇 개로 나눴는지, 파라미터가 어떻게 생겼는지 신경 쓰지 않는다. 덕분에 기능 모듈 안에서 디자인 개편을 하든, 화면을 쪼개든, 상위 모듈은 재컴파일을 피해간다.

디자인 시스템도 같은 원리로 분리한다. 색·간격·타이포 같은 토큰과 공용 컴포넌트는 core:designsystem 같은 모듈에 모으고, 이 모듈을 api 면으로만 바라보게 한다. 호출부는 색 값을 직접 들고 다니지 말고 Theme/CompositionLocal에서 읽기만 한다. 토큰이 바뀌면 토큰을 읽는 지점만 리컴포지션되고, 빌드도 디자인 시스템 모듈 안에서 수렴한다.

// :core:designsystem — 토큰과 공용 버튼
@Immutable data class AppPalette(val brand: Color, val onBrand: Color)
val LocalPalette = staticCompositionLocalOf { AppPalette(Color(0xFF3B82F6), Color.White) }

@Composable
fun TebahTheme(content: @Composable () -> Unit) {
    val palette = AppPalette(brand = MaterialTheme.colorScheme.primary, onBrand = MaterialTheme.colorScheme.onPrimary)
    CompositionLocalProvider(LocalPalette provides palette) { content() }
}

@Composable
fun TButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
    val p = LocalPalette.current
    Button(
        onClick = onClick,
        colors = ButtonDefaults.buttonColors(containerColor = p.brand, contentColor = p.onBrand),
        modifier = modifier
    ) { Text(text) }
}

데이터 모델은 core:model에서 불변(@Immutable) 으로 잡아두는 게 좋다. Compose의 strong skipping이 잘 먹히고, api 면이 안정화되니 모듈 간 컴파일 전파도 줄어든다. 바뀌어야 하는 건 모델을 mutable로 만들기보다, 화면 내부에서 State로 표면화해서 다룬다.

// :core:model — 불변 데이터, 안정성 보장
@Immutable
data class Channel(
    val id: String,
    val name: String,
    val avatarUrl: String
)

DI는 Hilt로 가되, 필요한 모듈에만 적용하고 KAPT 대신 KSP로 돌린다. 바인딩은 항상 api 인터페이스 기준으로 잡는다. 그래야 구현 교체가 쉽고, 구현 모듈을 갈아엎어도 위로 새지 않는다. Compose ViewModel 주입도 구현 모듈 내부에 가둬두면 app은 ViewModel 타입조차 알 필요가 없다.

Gradle 쪽은 스위치를 켜서 병렬·회피·캐시를 적극적으로 쓰면 된다. org.gradle.parallel, org.gradle.caching은 기본으로 켜두면 되고, org.gradle.configuration-cache는 아직 모든 Gradle 플러그인에서 완전 안정적이지 않다. 특히 Hilt와 KSP를 같이 쓰는 프로젝트에서는 캐시가 깨져서 오히려 빌드가 느려질 수 있으니, 무조건 켜기보다는 시도해보고 문제가 없을 때만 활성화하는 게 현실적이다. Kotlin incremental 빌드와 classpath snapshot, KSP incremental도 붙여두면 효과가 크지만, 일부 라이브러리는 incremental 처리가 완전하게 지원되지 않을 수 있으니 주기적으로 확인하는 게 좋다. 라이브러리 모듈에서 사용하지 않는 빌드 기능(BuildConfig, viewBinding 등)은 꺼서 작업 그래프를 줄이고, api/implementation 의존 구분은 말할 것도 없다. 앱은 feature의 api만 의존하도록 습관을 들여야 한다. Compose 컴파일러의 metrics/report는 프로파일할 때만 잠깐 켜고 평소에는 꺼두는 게 빌드 부담을 덜 수 있다.

런타임 관점에서의 Compose 최적화 원칙들도 이 아키텍처에 자연스럽게 녹아든다. 리스트에는 항상 key = { it.id }로 정체성을 고정하고, 무거운 연산은 remember로 캐시하고, 비동기는 아이템 id에 스코프를 건다. 이런 내부 최적화는 화면 구현 모듈(impl) 안에서 마음껏 바꿔도, 위쪽 모듈은 계약만 바라보니 빌드에 영향이 거의 없다. 결국 방향은 하나다. Composable은 구현 모듈에 숨기고, 호출자는 계약만 본다. 테마와 모델은 안정적으로 공유하고, DI로 느슨하게 잇는다. 그러면 Compose 기반 대규모 앱에서도 빌드는 병렬·캐시로 빠르게 돌고, 화면 변경은 구현 모듈 안에서 흡수되며, 런타임 리컴포지션은 필요한 만큼만 일어난다.

결론

결국 요점은 하나였다. UI를 명령의 나열로 다루지 말고, 상태→UI라는 규칙으로 다루자는 것. 그렇게 사고를 바꾸면 나머지는 전부 기술적인 후속 작업들로 정리된다. 리스트에선 “위치”가 아니라 “정체성”으로 식별하고, 무거운 계산은 remember와 derivedStateOf로 캐시해 실제로 바뀔 때만 돌리고, 쓰기는 합성 바깥의 효과 영역으로 밀어낸다. 데이터는 불변을 기본으로 하고, 바뀌는 조각만 Compose 상태로 표면화해서 안정성을 보장한다. 이 네 가지가 맞아 떨어질 때 비로소 Compose의 스킵이 자연스럽게 터지고, 스크롤해도 프레임이 흔들리지 않는다.

프로젝트 스케일로 올라가면 구조가 성능을 결정한다. 디자인 토큰은 Theme/CompositionLocal로 위에서 흘리고, 화면은 데이터 스펙으로 그리며, 리스트는 항상 안정 키를 준다. 컴포넌트는 입력만 보고 출력을 내는 순수 함수로 유지한다. 빌드 측면에선 Composable을 구현 모듈에 숨기고 계약만 바깥에 노출해 컴파일 전파를 끊는다. feature를 api/impl로 나누고, Hilt로 구현을 주입하며, KSP·병렬·캐시를 켜서 “빨리 돌릴 수 있는” 작업 그래프를 만든다. 그러면 화면 변경은 구현 모듈 안에서 흡수되고, 상위 모듈은 조립만 담당하면서 빌드·런타임 모두 안정적으로 빨라진다.

중요한 건 이것들이 각각의 요술봉이 아니라 한 가지 원리의 다른 표현이라는 점이다. 사이드이펙트를 규칙 밖으로 밀어내고, 상태를 표준화해 합성 가능한 경계로 만든다. 리스트에선 그 경계가 key, 연산에선 remember, 상태에선 State/Effect, 데이터에선 @Immutable/@Stable, 시스템에선 디자인 토큰과 멀티모듈 계약으로 나타난다. 이 원리를 지키면 “안 그려도 되는 건 안 그리는” 구조가 자연스럽게 만들어지고, 팀 규모와 화면 복잡도가 늘어나도 유지보수성과 성능이 같이 올라간다.

이제 남은 건 실무에서 습관을 굳히는 일뿐이다. 이벤트가 오면 뷰를 직접 만지지 말고 상태를 바꾸고, 화면은 그 상태를 그리게 한다. 리스트엔 키를 주고, 비동기는 id에 스코프를 건다. 무거운 건 캐시하고, 불변을 지키고, 변화는 상태로 흘린다. 그리고 모듈 경계는 계약으로 고정하고 구현은 뒤로 숨긴다. 그렇게 한 줄씩, 화면 하나씩 적립하다 보면 어느 순간 “Compose가 빠르다”가 아니라 “우리가 빠르다”는 말을 하게 된다.

profile
안녕하세요. 날씨가 참 덥네요.

0개의 댓글