AOSP 에서 개발중인 retain API 에 대해 알아보고자 한다. retain API가 왜 등장했는지, 어떤 특징을 가지고 있는지, 그리고 Circuit에서 지원하는 rememberRetained과 어떤 차이점이 있는지 확인해보도록 하겠다.
이 글은 retain API 를 가볍게 소개하는 글이기 때문에, retain 의 내부 구현 등의 더 깊은 내용을 알고 싶다면 아래의 skydoves 님이 작성하신 글을 참고하면 도움이 될 듯 하다.
Previewing retain{} API: A New Way to Persist State in Jetpack Compose
Previewing RetainedEffect: A New Side Effect to Bridge Between Composition and Retention Lifecycles
Add retain { ... } API PR이 merge 되어 AOSP 내에서 전체 코드를 확인 할 수 있다!

본론에 사용된 RetainScope 라는 네이밍이 RetainedValuesStore 라고 바뀌었다고 한다! 내부 코드 확인시 참고...
isKeepingExitedValues -> isRetainingExitedValues
remember를 통해 상태를 관리할 경우 Recomposition 으로 부터 상태를 유지할 수 있지만, Compose Navigation 을 통한 이동으로 화면이 Composition 에서 제거되면 상태를 유지할 수 없는 문제가 있다.
Composition은 Compose가 Composable 함수들을 실행하여 생성한 UI의
구조 정보를 의미한다.
예시
위 상황들에서도 상태를 유지/복원하기 위해 retain API 가 등장하게 되었다.
여기서 눈치가 빠른 사람들은 retain의 사용 목적이 AAC ViewModel의 사용 목적과 매우 흡사하다는 것을 눈치챌 수 있을 것이다.
점점 Compose 생태계에서 ViewModel을 사용할 필요가 없어지고 있음을 체감하게 되는,,

retain은 key 기반으로 값을 식별하며, key는 크게 두가지 구성된다.
// 위치 정보만 사용
val data = retain { loadData() }
// 위치 정보 + 명시적 key 조합
val data = retain(userId, screenId) { loadData() }
이 두 정보를 조합하여 저장된 값을 찾아내며, 아래는 retain의 동작 흐름을 나타낸 다이어그램이다.
/**
* ┌──────────────────────┐
* │ │
* │ retain(keys) { ... } │
* │ ┌────────────┐│
* └────────┤ value: T ├┘
* └──┬─────────┘
* │ ▲
* Exit│ │Enter
* composition│ │composition
* or change│ │
* keys│ │ ┌──────────────────────────┐
* │ ├───No retained value─────┤ calculation: () -> T │
* │ │ or different keys └──────────────────────────┘
* │ │ ┌──────────────────────────┐
* │ └───Re-enter composition──┤ Local RetainScope │
* │ with the same keys └─────────────────┬────────┘
* │ ▲ │
* │ ┌─Yes────────────────┘ │ value not
* │ │ │ restored and
* │ .──────────────────┴──────────────────. │ scope stops
* └─▶( RetainScope.isKeepingExitedValues ) │ keeping exited
* `──────────────────┬──────────────────' │ values
* │ ▼
* │ ┌──────────────────────────┐
* └─No──▶│ value is retired │
* └──────────────────────────┘
*
* @param calculation A computation to invoke to create a new value, which will be used when a
* previous one is not available to return because it was neither remembered nor retained.
* @return The result of [calculation]
* @throws IllegalArgumentException if the return result of [calculation] both implements
* [RememberObserver] and does not also implement [RetainObserver]
* @see remember
*/
@Composable
public inline fun <reified T> retain(noinline calculation: @DisallowComposableCalls () -> T): T {
return retain(
positionalKey = currentCompositeKeyHashCode,
typeHash = classHash<T>(),
calculation = calculation,
)
}
Navigation 백스택에서 화면이 일시적으로 제거될 때, RetainScope는 값을 메모리에 유지(isKeepingExitedValues)하고, 같은 position과 matching keys로 Composition 에 재진입하면 유지된 값을 그대로 사용한다.
Retain 을 통해 상태가 메모리에 유지되는 기간/범위를 원문에서는 Rentention Lifecycle 이라 표현하는데, 기존의 익숙한 Composition Lifecycle과 Retention Lifecycle을 비교해보도록 하겠다.
// 네트워크 데이터 캐싱
// remember 사용시
@Composable
fun ArticleScreen(articleId: String) {
val article = remember { fetchArticleFromNetwork(articleId) }
// -> 다른 화면 갔다가 돌아오면 다시 fetch
}
// retain 사용시
@Composable
fun ArticleScreen(articleId: String) {
val article = retain(articleId) { fetchArticleFromNetwork(articleId) }
// -> 백스택에 남아있다가 돌아오면 캐시된 데이터 사용
}
| 시점 | Composition Lifecycle | Retention Lifecycle |
|---|---|---|
| A 화면 진입 | 시작 ✅ | 시작 ✅ (onRetained) |
| A → B 이동 | 종료 ❌ | 계속 유지 ✅ |
| B → A 복귀 | 시작 ✅ (새로운 Composition 시작) | 계속 유지 ✅ |
| A pop (제거) | 종료 ❌ | 종료 ❌ (onRetired) |
retain은 RetainScope를 통해 상태를 관리하며, 기본적으로 ForgetfulRetainScope 가
사용된다. 이 경우 retain은 remember와 동일하게 동작한다.
Android에서는 Activity나 Navigation graph의 lifecycle에 연결된 lifecycle-aware RetainScope 구현체로 교체하여 사용할 수 있으며, 이 경우 config change나 navigation 이벤트를 넘어 상태를 유지할 수 있다.
둘의 차이를 표로 정리하면 다음과 같다.
| 특징 | remember | rememberSaveable | retain |
|---|---|---|---|
| 저장 방식 | Composition 메모리 | Bundle/SavedState | 메모리 (RetainScope) |
| Recomposition | ✅ 유지 | ✅ 유지 | ✅ 유지 |
| Configuration Change | ❌ | ✅ 복원 | ✅ 유지 |
| Process Death | ❌ | ✅ 복원 | ❌ |
| 백스택 Navigation | ❌ | ✅ 복원 | ✅ 유지 |
| 타입 제약 | 제약 없음 | Parcelable/Serializable | 제약 없음 |
| 복잡한 객체 | ✅ | ❌ (커스텀 Saver 필요) | ✅ |
| 주요 사용처 | 일시적 UI 상태 | Process Death 대응 (폼 입력, 중요 데이터) | Navigation/Config Change 시 객체 재생성 방지 |
// 상품 목록 → 상품 상세 → 리뷰 화면 → 뒤로가기 → 상품 상세
// rememberSaveable 사용시
@Composable
fun ProductDetailScreen(productId: String) {
var scrollPosition by rememberSaveable { mutableStateOf(0) }
// -> 리뷰 화면 갔다가 돌아오면 스크롤 위치 복원
// (SavedState의 Bundle 직렬화/역직렬화)
}
// retain 사용시
@Composable
fun ProductDetailScreen(productId: String) {
var scrollPosition by retain { mutableStateOf(0) }
// -> 백스택에 남아있다가 돌아오면 스크롤 위치 유지!
// (RetainScope가 메모리에 보관)
}
retain API 와 함께 새로 등장할 예정인 SideEffect 처리를 위한 API 로, RetainedEffect는 DisposableEffect와 유사하지만, Composition Lifecycle 이 아닌 Retention Lifecycle 에 연결되어 SideEffect 를 처리한다.
@Composable
@NonRestartableComposable
public fun RetainedEffect(key1: Any?, effect: RetainedEffectScope.() -> RetainedEffectResult) {
retain(key1) { RetainedEffectImpl(effect) }
}
// RetainEffectImpl
private class RetainedEffectImpl(
private val effect: RetainedEffectScope.() -> RetainedEffectResult
) : RetainObserver {
private var onRetire: RetainedEffectResult? = null
override fun onRetained() {
onRetire = InternalRetainedEffectScope.effect()
}
override fun onRetired() {
onRetire?.retire()
onRetire = null
}
override fun onEnteredComposition() {
// Do nothing.
}
override fun onExitedComposition() {
// Do nothing.
}
}
Setup 블록(onRetained)은 객체가 처음 생성될 때만 실행되고, cleanup 블록(onRetired)은 영구적으로 폐기될 때만 실행된다.
RetainedEffect(key) {
// onRetained()
println("Setup - onRetained 시점에 실행됨")
val resource = acquire()
onRetired {
println("Cleanup")
resource.release()
}
}
Composition의 일시적 출입(진입/이탈) 시에는 setup/cleanup을 실행하지 않고, config change나 navigation으로 인한 Composition 재생성 시에도 effect가 유지되도록 설계되었다.
DisposableEffect와 유사하다고 언급한 추가적인 이유로 내부에 LaunchedEffect처럼 별도의 CoroutineScope가 존재하지 않는 부분도 있을 듯하다.
그렇다.
key 기반으로 이미 Composition에서 제거된 화면내 상태를 메모리에 저장해둔다는 측면에서 동작은 유사할 수 있으나, 구현 방식에서 차이가 있다.

Circuit 과 Rin 의 rememberRetained 는 저장된 상태를 config change와 같은 상황에서 유지하기 위해 Android 구현체의 내부에서 AAC ViewModel을 사용한다.
이는 곧 암시적으로 ViewModel의 lifecycle에 의존할 수밖에 없는 구조를 의미한다.
항상 느끼는거지만, ViewModel을 사용하는 것에서 벗어나기 위해 만들어진 프레임워크/라이브러리 인데, 내부적으로(암시적으로) ViewModel을 사용한다는게 아이러니한 부분이다.
Circuit의 rememberRetained 가 key 기반으로 상태를 보존하는 방법은 이전에 작성했던 Circuit 분석 글에서 확인할 수 있다.
[Circuit] rememberRetained, produceRetainedState 함수 분석(1)
Circuit의 암시적인 뷰모델 사용 부분
Rin의 암시적인 뷰모델 사용 부분
하지만 retain은 그렇지 않다.
순수 Compose Runtime 레벨에서 Composition Scope를 넘어 상태를 유지할 수 있는 메커니즘을 제공한다.
@Composable
private fun <T> retainImpl(key: RetainKeys, calculation: () -> T): T {
// 현재 Composition의 LocalRetainedValuesStore에서 store 가져오기
val retainedValuesStore = LocalRetainedValuesStore.current
val holder =
remember(key) {
// // 1. 이전에 Composition을 나갔던(exited) 값이 store에 있는지 확인
val retainedValue =
retainedValuesStore.getExitedValueOrElse(key, RetainedValuesStoreMissingValue)
if (retainedValue !== RetainedValuesStoreMissingValue) {
// 2-a. 저장된 값이 있으면 재사용 (calculation 재실행 X)
RetainedValueHolder(
key = key,
value = @Suppress("UNCHECKED_CAST") (retainedValue as T),
owner = retainedValuesStore,
isNewlyRetained = false, // 기존 값 재사용
)
} else {
// 2-b. 저장된 값이 없으면 calculation 실행하여 새로 생성
RetainedValueHolder(
key = key,
value = calculation(),
owner = retainedValuesStore,
isNewlyRetained = true, // 새로 생성된 값
)
}
}
// 3. RetainedValuesStore가 변경되었다면 (예: 다른 scope로 이동)
// -> holder를 새로운 store로 재할당
if (holder.owner !== retainedValuesStore) {
SideEffect { holder.readoptUnder(retainedValuesStore) }
}
return holder.value
}
// 사용 예시
// rememberRetained
@Composable
fun CounterPresenter(): CounterState {
var count by rememberRetained { mutableStateOf(0) }
return CounterState(count) { event ->
when (event) {
is CounterEvent.Increment -> count++
}
}
}
// retain
@Composable
fun CounterScreen() {
val count = retain { mutableStateOf(0) }
// ...
}
그렇다고 보긴 어려울 것 같다.
Circuit의 영감을 받아 만들어진 Rin 라이브러리의 깃허브 이슈를 보면 흥미로운 글이 있는데

https://github.com/takahirom/Rin/issues
Resaca 라는 라이브러리와 Rin이 매우 유사한데 차이점이 뭔지를 질문하는 글이다.

실제로 이 Resaca 라는 라이브러리는 Circuit의 정식 릴리즈보다 대략 11개월 정도 이른 2021년 12월에 처음 릴리즈 되었으며, remember/rememberSaveable 의 한계점을 인식하고 RememeberScoped 라는 API를 이미 지원하였다.

내부적으로(암시적으로) ViewModel을 사용하는 Circuit, Rin과는 다르게 Resaca를 적용할 경우엔 명시적으로 ViewModel을 사용하는 것을 확인할 수 있었다. 각 라이브러리의 철학이 상이함을 어림 짐작할 수 있는 부분
Resaca 관련 아티클 참고
결론적으로, Compose가 세상에 등장한지 얼마되지 않은 시점부터, Composition 에서 제거된 상황으로 부터 기존의 상태를 유지하려는 니즈는 꾸준히 있어왔고, 그에 따라 여러 라이브러리들이 개발되었으며, 현 시점에선 이를 Compose 에서 공식으로 지원하게 되었다고 보면 될듯하다.
더 찾아보진 못했지만, Resaca 보다도 더 먼저 등장한 라이브러리가 존재할 수도 있고~
AOSP에서 개발중인 retain API 에 대해 알아보았다.
개인적인 의견으론 이후, retain API을 사용할 수 있게 되어 공식 표준이 된다면, rememberRetained을 사용하기 위해 만들어진 Rin과 같은 라이브러리는 자연스레 deprecated 될 것으로 보여진다.
Circuit은 rememberRetain 외에도 여러 도구들(Presenter, Navigation, SideEffect API...etc)을 제공하는 프레임워크이기 때문에, 여전히 Circuit의 장점에 공감하는 사람들은 그대로 사용할 듯하다.
retain API가 상용화 된다면 기존 Compose Navigaton 사용시 화면에 다시 돌아왔을때 화면 내 Initial Load Data API 가 재실행 되지않도록 막는다던지,
Analytics 로그를 심을 때, A -> B -> B pop -> A 상황에서, A 화면에 대한 진입 로그를 2번이 아닌 1번만 호출되도록 막는 등의 문제 상황들을 별도의 라이브러리 도입 없이 쉽게 해결할 수 있을 것 같아 기대가 된다.
Rin 라이브러리 깃허브에 앞으로의 향후 방향성 관련 질문을 이슈에 남겨두었고, 답변을 받을 수 있었다.
https://github.com/takahirom/Rin/issues/27

reference)
https://proandroiddev.com/exploring-retain-api-a-new-way-to-persist-state-in-jetpack-compose-bfb2fe2eae43
https://proandroiddev.com/previewing-retainedeffect-a-new-side-effect-to-bridge-between-composition-and-retention-lifecycles-685b9e543de7
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/runtime/runtime-retain/src/commonMain/kotlin/androidx/compose/runtime/retain/Retain.kt?q=retain&ss=androidx%2Fplatform%2Fframeworks%2Fsupport
https://github.com/slackhq/circuit
https://github.com/takahirom/Rin
https://github.com/sebaslogen/resaca