이번 글에서는 DroidKaigi conference-app-2025 에서 Soil을 어떤 방식으로 사용하고 있는지 알아보고자 한다.
Soil의 Sample Code 에서는 데이터를 조회하는 rememberQuery 정도만 사용하고 있어, 이보다 더 다양한 케이스를 다루는(rememberMutation, rememberSubscription...etc) 앱의 코드를 확인하며 그 쓰임을 확인하려고 한다.
아무래도 Mutation 이나, Optimistic Update 관련 예제를 만들려면 POST API 를 지원하는 Open API 를 사용해야하는데, 이를 무료로 제한 없이 사용할 수 있는 Open API가 없어, Sample Code 를 작성하는 것도 쉽지 않을 듯하다. 우선 Soil Discussion 내에 더 다양한 Sample Code 를 추가해달라고 요청을 올려둔 상태이다.
https://github.com/soil-kt/soil/discussions/222
저번 글에서 Query에 대해선 확인해보았으니, 그외 다른 기능들을 실제 코드와 함께 살펴보도록 하겠다.
Mutation은 데이터를 변경(생성/수정/삭제)할때 사용되고, Soil 에서는 이를 rememberMutation 함수를 통해 구현할 수 있다.
val settingsMutation = rememberMutation(screenContext.settingsMutationKey)
// 폰트 설정 변경
settingsMutation.mutate(settings.copy(useKaigiFontFamily = event.kaigiFontFamily))
DefaultSettingsMutationKey.kt:12
@ContributesBinding(DataScope::class)
@Inject
public class DefaultSettingsMutationKey(
private val settingsDataStore: SettingsDataStore,
) : SettingsMutationKey by buildMutationKey(
id = MutationId("settings_mutation_key"),
mutate = { settingsDataStore.save(settings = it) },
)
val profileMutation = rememberMutation(screenContext.profileMutationKey)
var isInEditMode by rememberRetained { mutableStateOf(!isAllowedToShowCard) }
EventEffect(eventFlow) { event ->
when (event) {
is ProfileScreenEvent.EnterEditMode -> {
isInEditMode = true
}
is ProfileScreenEvent.CreateProfile -> {
profileMutation.mutate(event.profile)
isInEditMode = false
}
}
}
이처럼 Mutation 은 비동기 작업의 상태 관리(로딩, 성공, 실패)를 자동으로 처리하며, UI에서 간단한 mutate() 호출만으로 복잡한 데이터 변경 작업을 수행할 수 있다.
DroidKaigi에서도 역시 서버 API 호출 상황이 아닌, 로컬 DB에 접근하여 데이터를 변경하는 작업에서 Mutation을 사용하는 것을 확인 할 수 있었다. 로컬 DB에 접근하는 경우에도 로딩, 실패 상황이 존재하지 않는 것은 아니기 때문에, Mutation 을 사용하는 적절한 UseCase 임엔 틀림 없다.
각 Screen 별 의존성 컨테이너 역할(Screen 에 필요한 Query Key, Mutation Key 등을 제공)과, DI와 상태 관리를 분리하는 역할을 수행한다.
Android Context 와는 관계 X
// Metro DI와의 통합(Metro 의존성 그래프에 등록)
@ContributesGraphExtension(SettingsScope::class)
interface SettingsScreenContext : ScreenContext {
val settingsSubscriptionKey: SettingsSubscriptionKey
val settingsMutationKey: SettingsMutationKey
// Factory 정의
@ContributesGraphExtension.Factory(AppScope::class)
fun interface Factory {
fun createSettingsScreenContext(): SettingsScreenContext
}
}
다양한 컨텍스트 예시
복합 의존성 관리 - 타임테이블 화면
interface TimetableScreenContext : ScreenContext {
// 데이터 조회
val timetableQueryKey: TimetableQueryKey
// 실시간 구독
val favoriteTimetableIdsSubscriptionKey: FavoriteTimetableIdsSubscriptionKey
// 즐겨찾기 변경
val favoriteTimetableItemIdMutationKey: FavoriteTimetableItemIdMutationKey
}
Context Receivers 와 연동
context(screenContext: SettingsScreenContext)
@Composable
fun SettingsScreenRoot(onBackClick: () -> Unit) {
// screenContext의 의존성을 자연스럽게 사용
rememberSubscription(screenContext.settingsSubscriptionKey)
}
Context Receivers?
Context Receiver는 특정 context(타입) 안에서만 호출할 수 있는 함수를 정의하는 기능으로, 코틀린 1.6.2에서 추가되었지만, 2.2.0부터는 Context Parameters로 대체될 예정이라고 한다. 나도 사실 kotlin에 이런 문법이 있는지 처음 알았다,,,
자세한 내용은 해당 블로그 글을 참고해보면 좋을 듯 하다.
필립 형님께서도 설명 영상을 올려주셨다!
Presenter가 Composable 함수이기 때문이다.
ViewModel 또는 Circuit의 Presenter를 State Holder로 사용할 경우, Class이기 때문에 Hilt와 같이 @Binds 또는 @Prodives를 통해 Class 생성자 주입이 가능하지만, Composable 함수의 경우 다른 방식의 주입이 필요하다.
애초에 함수이기 때문에 생성자라는 개념이 없다.
// 설정 데이터
val settingsKey = SubscriptionKey("settings")
// 사용자 데이터
val userKey = SubscriptionKey("user-123")
// 게시글 데이터
val postKey = SubscriptionKey("post-456")
// 같은 Key = 같은 캐시 공유
rememberSubscription(settingsKey) // A 화면
rememberSubscription(settingsKey) // B 화면 - 같은 캐시 사용!
// 다른 Key = 별도 캐시
rememberSubscription(userKey) // 독립적인 캐시
// 설정 변경 후 반응형 업데이트
settingsMutation.mutate(newSettings)
↓
settingsDataStore.save(newSettings) // DataStore에 저장
↓
settingsDataStore.getSettingsStream().emit(newSettings) // Flow가 자동 emit
↓
// 해당 SubscriptionKey를 사용하는 모든 구독자가 새 데이터로 자동 업데이트
따라서 Soil 에서 key 는 어떤 데이터인지, 어디에 캐시할지, 누구와 공유할지 둥을 판단하는 필수적인 역할을 수행한다고 볼 수 있다.
Subscription은 데이터의 실시간 변화를 감지하여 UI를 자동으로 업데이트하는 역할을 수행한다.
Soil 에서는 이를 rememberSubscription 함수를 통해 구현할 수 있다.
SoilDataBoundary(
state = rememberSubscription(screenContext.settingsSubscriptionKey),
) { settings ->
// settings 데이터가 변경될 때마다 자동 리컴포지션
}
DefaultSettingsSubscriptionKey.kt:12
@ContributesBinding(DataScope::class)
@Inject
public class DefaultSettingsSubscriptionKey(
private val settingsDataStore: SettingsDataStore,
) : SettingsSubscriptionKey by buildSubscriptionKey(
id = SubscriptionId("settings"),
// flow 기반 데이터 스트림
subscribe = { settingsDataStore.getSettingsStream() },
)
public fun getSettingsStream(): Flow<Settings> {
return dataStore.data.mapNotNull { preferences ->
val cache = preferences[DATA_STORE_SETTINGS_KEY] ?: json.encodeToString(Settings.Default)
json.decodeFromString<Settings>(cache)
}
}
Query + Subscription 결합
SoilDataBoundary(
// 정적 데이터
state1 = rememberQuery(screenContext.timetableQueryKey),
// 실시간 데이터
state2 = rememberSubscription(screenContext.favoriteTimetableIdsSubscriptionKey),
fallback = SoilFallbackDefaults.appBar(
title = stringResource(FavoritesRes.string.favorite),
),
) { timetable, favoriteTimetableItemIds ->
val eventFlow = rememberEventFlow<FavoritesScreenEvent>()
// 두 데이터 소스를 결합하여 사용
val uiState = favoritesScreenPresenter(
eventFlow = eventFlow,
timetable = timetable.copy(bookmarks = favoriteTimetableItemIds),
)
FavoritesScreen(
uiState = uiState,
onTimetableItemClick = onTimetableItemClick,
onBookmarkClick = { eventFlow.tryEmit(FavoritesScreenEvent.Bookmark(it)) },
onAllFilterChipClick = { eventFlow.tryEmit(FavoritesScreenEvent.FilterAllDays) },
onDay1FilterChipClick = { eventFlow.tryEmit(FavoritesScreenEvent.FilterDay1) },
onDay2FilterChipClick = { eventFlow.tryEmit(FavoritesScreenEvent.FilterDay2) },
)
}
rememberSubscription의 collectAsState 와 비교했을 때의 장점에 대해 설명해보도록 하겠다.
SoilDataBoundary(
state = rememberSubscription(screenContext.settingsSubscriptionKey),
// 에러/로딩 UI 통합
fallback = SoilFallbackDefaults.appBar(title = "Settings"),
) { settings ->
// 성공 시에만 실행
}
// A 화면에서 설정 데이터 구독
rememberSubscription(screenContext.settingsSubscriptionKey)
// B 화면에서도 같은 데이터 구독
rememberSubscription(screenContext.settingsSubscriptionKey)
차이점 비교
| 기능 | collectAsState | rememberSubscription |
|---|---|---|
| 에러 처리 | 수동 구현 필요 | 자동 처리 |
| 로딩 상태 | 별도 관리 | 통합 관리 |
| 캐싱 | 직접 구현 | 자동 캐싱 |
| 생명주기 | 수동 관리(collectAsStateWithLifecycle 사용) | 자동 관리 |
| 재시도 | 수동 구현 | 자동 지원 |
사용하고 있는 곳이 ProfileEditScreen뿐이라, 해당 코드 전문을 확인해보면 될 듯하다. 추가적인 설명은 필요 없어보여서 생략...
DroidKaigi 2025 앱의 Soil 기반 아키텍처는 Metro DI + KSP 코드 생성 + Context Receivers + Soil 의 조합으로 다음과 같은 이점을 제공한다.
rememberSubscription 은 단순한 상태 구독을 넘어서, 캐싱, 에러 처리, 생명주기 관리를 모두 포함한 완전한 솔루션을 제공한다.
이는 collectAsState의 단순한 대안이 아니라, 더 나은 개발자 경험과 사용자 경험을 위한 진화된 접근법이라고 볼 수 있을 듯 하다.
DroidKaigi 에서는 DI 라이브러리로 Metro 를 사용하는데 이것과 기존의 사용하였던 Hilt, Koin과 어떤 차이점이 있는지도 추후 학습해봐야겠다.
DroidKaigi에서 Circuit 과 Metro의 Maintainter 이신 Zac Sweers 님께서 Metro에 대한 발표를 하실 예정이니, 추후 영상이 유튜브에 업로드되면 참고해봐도 도움이 될 듯하다.
또한 Cash App의 molecule 라이브러리도 Soil과 함께 사용하는 것을 확인할 수 있었는데, 리드미를 확인해보니 soil이 제공하는 Compose State를 flow로 변환하는데에 molecule을 사용하구, iOS SwiftUI에서 이 flow를 구독해서 상태 변화에 따라 UI가 자동으로 업데이트되도록 사용한다고 한다.
이 molecule에 대해서도 추가적으로 학습을 진행해봐야겠다.

import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import dev.zacsweers.metro.Inject
import io.github.droidkaigi.confsched.data.profile.ProfileDataStore
import io.github.droidkaigi.confsched.model.profile.Profile
import io.github.droidkaigi.confsched.model.profile.ProfileSubscriptionKey
import io.github.droidkaigi.confsched.model.profile.ProfileWithImages
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import soil.query.annotation.ExperimentalSoilQueryApi
import soil.query.compose.rememberSubscription
@Inject
class ProfileRepository(
private val profileSubscriptionKey: ProfileSubscriptionKey,
private val profileDataStore: ProfileDataStore,
) {
@OptIn(ExperimentalSoilQueryApi::class)
fun profileFlow(): Flow<ProfileWithImages> = moleculeFlow(RecompositionMode.Immediate) {
soilDataBoundary(state = rememberSubscription(profileSubscriptionKey))
}
.filterNotNull()
.distinctUntilChanged()
.catch {
// Errors thrown inside flow can't be caught on iOS side, so we catch it here.
emit(ProfileWithImages())
}
@Throws(Throwable::class)
suspend fun save(profile: Profile) {
profileDataStore.saveProfile(profile)
}
}

학습을 위한 양질의 컨텐츠가 나와서 행복+_+
reference)
https://github.com/DroidKaigi/conference-app-2025
https://github.com/soil-kt/soil