[Soil] DroidKaigi 앱에서는 Soil을 어떻게 쓰고 있을까?

easyhooon·2025년 8월 28일
2

Soil

목록 보기
2/3
post-thumbnail

서두

이번 글에서는 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

Mutation은 데이터를 변경(생성/수정/삭제)할때 사용되고, Soil 에서는 이를 rememberMutation 함수를 통해 구현할 수 있다.

구조와 동작 방식

SettingsScreenPresenter.kt:16

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) },
)

ProfilePresenter.kt:25

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 임엔 틀림 없다.

ScreenContext?

각 Screen 별 의존성 컨테이너 역할(Screen 에 필요한 Query Key, Mutation Key 등을 제공)과, DI와 상태 관리를 분리하는 역할을 수행한다.

Android Context 와는 관계 X

구조와 설계 패턴

SettingsScreenContext.kt:11

// 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
    }
}

다양한 컨텍스트 예시

복합 의존성 관리 - 타임테이블 화면

TimetableScreenContext.kt:12

interface TimetableScreenContext : ScreenContext {
    // 데이터 조회
    val timetableQueryKey: TimetableQueryKey
    // 실시간 구독
    val favoriteTimetableIdsSubscriptionKey: FavoriteTimetableIdsSubscriptionKey 
    // 즐겨찾기 변경
    val favoriteTimetableItemIdMutationKey: FavoriteTimetableItemIdMutationKey   
}

Context Receivers 와 연동

SettingsScreenRoot.kt:13

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에 이런 문법이 있는지 처음 알았다,,,
자세한 내용은 해당 블로그 글을 참고해보면 좋을 듯 하다.

필립 형님께서도 설명 영상을 올려주셨다!

왜 screenContext를 만들어서 사용하는지

Presenter가 Composable 함수이기 때문이다.

ViewModel 또는 Circuit의 Presenter를 State Holder로 사용할 경우, Class이기 때문에 Hilt와 같이 @Binds 또는 @Prodives를 통해 Class 생성자 주입이 가능하지만, Composable 함수의 경우 다른 방식의 주입이 필요하다.

애초에 함수이기 때문에 생성자라는 개념이 없다.

Key가 필요한 이유

  1. 데이터 식별자 역할
// 설정 데이터
val settingsKey = SubscriptionKey("settings")

// 사용자 데이터  
val userKey = SubscriptionKey("user-123")

// 게시글 데이터
val postKey = SubscriptionKey("post-456")
  1. 캐시 관리 -> 중복 방지를 통한 메모리 효율성 증가
// 같은 Key = 같은 캐시 공유
rememberSubscription(settingsKey) // A 화면
rememberSubscription(settingsKey) // B 화면 - 같은 캐시 사용!

// 다른 Key = 별도 캐시
rememberSubscription(userKey) // 독립적인 캐시
  1. 무효화(Invalidation) 제어
    DroidKaigi 에서는 명시적으로 무효화를 적용하진 않고, 변경 후 반응형 업데이트 방식을 취하고 있다.
// 설정 변경 후 반응형 업데이트
settingsMutation.mutate(newSettings)
   ↓
settingsDataStore.save(newSettings) // DataStore에 저장
   ↓ 
settingsDataStore.getSettingsStream().emit(newSettings) // Flow가 자동 emit// 해당 SubscriptionKey를 사용하는 모든 구독자가 새 데이터로 자동 업데이트

따라서 Soil 에서 key 는 어떤 데이터인지, 어디에 캐시할지, 누구와 공유할지 둥을 판단하는 필수적인 역할을 수행한다고 볼 수 있다.

Subscription

Subscription은 데이터의 실시간 변화를 감지하여 UI를 자동으로 업데이트하는 역할을 수행한다.

Soil 에서는 이를 rememberSubscription 함수를 통해 구현할 수 있다.

기본 사용법

SettingsScreenRoot.kt:18

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() },
)

SettingsDataStore.kt#L28

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 결합

FavoritesScreenRoot.kt:19

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) },
    )
}

이미 collectAsState 가 있는데?

rememberSubscription의 collectAsState 와 비교했을 때의 장점에 대해 설명해보도록 하겠다.

  1. 통합된 에러 처리
SoilDataBoundary(
    state = rememberSubscription(screenContext.settingsSubscriptionKey),
    // 에러/로딩 UI 통합
    fallback = SoilFallbackDefaults.appBar(title = "Settings"), 
) { settings ->
    // 성공 시에만 실행
}
  1. 자동 생명주기 관리
  • Compose 생명주기와 자동 동기화
  • 메모리 누수 방지를 위한 자동 구독 해제
  • 백그라운드/포그라운드 전환 시 최적화
  1. 동일한 SubscriptionKey로 여러 화면에서 구독 시 자동 공유
// A 화면에서 설정 데이터 구독
rememberSubscription(screenContext.settingsSubscriptionKey) 

// B 화면에서도 같은 데이터 구독  
rememberSubscription(screenContext.settingsSubscriptionKey) 
  1. Soil 생태계 통합
  • Soil의 DataBoundary와 완벽 통합
  • Suspense, ErrorBoundary 자동 처리
  • Query와 Subscription 간 일관된 API

차이점 비교

기능collectAsStaterememberSubscription
에러 처리수동 구현 필요자동 처리
로딩 상태별도 관리통합 관리
캐싱직접 구현자동 캐싱
생명주기수동 관리(collectAsStateWithLifecycle 사용)자동 관리
재시도수동 구현자동 지원

Soil-Form

사용하고 있는 곳이 ProfileEditScreen뿐이라, 해당 코드 전문을 확인해보면 될 듯하다. 추가적인 설명은 필요 없어보여서 생략...

결론

DroidKaigi 2025 앱의 Soil 기반 아키텍처는 Metro DI + KSP 코드 생성 + Context Receivers + Soil 의 조합으로 다음과 같은 이점을 제공한다.

  1. 타입 안전한 의존성 주입: ScreenContext를 통한 컴파일 타임 검증
  2. 자동화된 보일러플레이트: KSP 기반 코드 자동 생성
  3. 통합된 상태 관리: Query/Subscription/Mutation의 일관된 API
  4. 강력한 에러 처리: SoilDataBoundary를 통한 선언적 에러/로딩 처리

rememberSubscription 은 단순한 상태 구독을 넘어서, 캐싱, 에러 처리, 생명주기 관리를 모두 포함한 완전한 솔루션을 제공한다.

이는 collectAsState의 단순한 대안이 아니라, 더 나은 개발자 경험과 사용자 경험을 위한 진화된 접근법이라고 볼 수 있을 듯 하다.

P.S

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에 대해서도 추가적으로 학습을 진행해봐야겠다.

ProfileRepository.kt#L4

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

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글