DroidKaigi 2025 컨퍼런스 앱에 기여하면서 처음 Soil을 실제 프로젝트 구조 안에서 살펴볼 기회가 있었다.
당시에도 구조는 꽤 인상적이었다. ScreenRoot가 query와 subscription을 구독하고, Composable Presenter가 mutation과 화면 로컬 상태를 다뤘다. 비동기 데이터의 loading/error/content 전환은 SoilDataBoundary가 맡았다.
최근 DroidKaigi 2026 앱에서 Navigation3와 Retain을 활용한 구조를 살펴보다가 문득 궁금해졌다.
2025 앱과 2026 앱은 모두 Soil을 사용하는데, 실제 활용법은 무엇이 달라졌을까?
두 저장소를 같은 기능을 중심으로 비교해 보니, 2026 앱은 Soil 사용법을 완전히 새로 만든 것이 아니었다. 2025에서 시작한 방향을 더 명시적인 생명주기, 역할 분리, 오류 처리, 테스트, 컴파일 타임 규칙으로 발전시킨 모습에 가까웠다.
이 글에서는 시간표와 즐겨찾기 화면을 중심으로 그 차이를 정리한다.
비교 기준은 2025 앱의
07b46e6커밋과 2026 앱의000edaa커밋이다. 두 프로젝트의 개발 단계와 구현된 기능 범위가 다르므로 단순한 코드량 비교보다는 아키텍처의 방향에 초점을 맞췄다.
두 앱의 공통된 뼈대는 다음과 같다.
QueryKey와 SubscriptionKey로 표현한다.MutationKey로 표현한다.ScreenRoot가 rememberQuery()와 rememberSubscription()을 호출한다.SoilDataBoundary가 loading/error/content 경계를 만든다.UiState를 반환한다.차이는 이 구조를 얼마나 명확하게 분리하고 강제하는지에 있다.
| 비교 지점 | DroidKaigi 2025 | DroidKaigi 2026 |
|---|---|---|
| Soil 버전 | 1.0.0-alpha12 | 1.0.0-alpha15 |
| 화면 상태 유지 | Rin의 rememberRetained | Compose Runtime의 retain |
| 화면 DI 수명 | KSP가 생성한 remember*ScreenContextRetained() | Navigation3 엔트리 안에서 screen graph를 retain |
| 의존성 Context | 하나의 ScreenContext가 query·subscription·mutation key를 모두 노출 | ScreenContext와 PresenterContext를 역할별로 분리 |
| 사용자 입력 | MutableSharedFlow 기반 EventFlow | 양방향 ScreenChannel<Action, ActionResult> |
| mutation 실행 | mutate() | mutateAsync() + success/error effect |
| 오류 재시도 | 앱에서 query/subscription 타입별 reset 구현 | Soil의 rememberQueriesErrorReset() 사용 |
| 캐시 영속화 | 시간표 중심의 개별 구현 | 재사용 가능한 buildPersistedQueryKey() |
| 상세 데이터 조회 | ID별 TimetableItemQueryKey 사용 | 공유 시간표 query에서 select로 항목 선택 |
| 규칙 준수 | 코드 리뷰와 관례 중심 | 커스텀 FIR checker로 컴파일 타임 강제 |
| Presenter 테스트 | 화면 Robot 테스트 중심 | Molecule·Turbine 기반 Presenter harness 추가 |
한 문장으로 요약하면 이렇다.
2025 앱이 “Soil을 화면 구조에 잘 연결하는 법”을 보여줬다면, 2026 앱은 “Soil의 각 기능이 아키텍처의 어디에 있어야 하는지 명시하고 강제하는 법”을 보여준다.
ScreenRoot에서 데이터를 연다먼저 두 프로젝트가 공유하는 구조부터 살펴보자.
2025 시간표 화면의 ScreenRoot는 query와 subscription을 열고 SoilDataBoundary의 content 람다 안에서 Presenter를 호출한다.
SoilDataBoundary(
state1 = rememberQuery(screenContext.timetableQueryKey),
state2 = rememberSubscription(
screenContext.favoriteTimetableIdsSubscriptionKey,
),
) { timetable, favoriteIds ->
val eventFlow = rememberEventFlow<TimetableScreenEvent>()
val uiState = timetableScreenPresenter(
eventFlow = eventFlow,
timetable = timetable.copy(bookmarks = favoriteIds),
)
TimetableScreen(uiState = uiState, /* ... */)
}
2026 시간표 화면도 데이터 경계 자체는 거의 같은 위치에 둔다.
SoilDataBoundary(
state1 = rememberQuery(screenContext.timetableQueryKey),
state2 = rememberSubscription(
screenContext.favoriteTimetableIdsSubscriptionKey,
),
) { timetable, favoriteIds ->
val screenChannel =
retainScreenChannel<TimetableScreenAction, TimetableScreenActionResult>()
val uiState = context(screenContext.presenterContext) {
timetableScreenPresenter(
screenChannel = screenChannel,
timetable = timetable.copy(bookmarks = favoriteIds),
)
}
TimetableScreen(uiState = uiState, /* ... */)
}
즉 두 앱 모두 Presenter가 loading 상태를 직접 조립하거나 repository Flow를 장기간 수집하지 않는다. ScreenRoot가 데이터를 구독하고, Presenter는 SoilDataBoundary를 통과한 준비된 값을 받는다.
2026에서 달라진 것은 이 중심축이 아니라 주변의 수명 관리와 책임 경계다.
rememberRetained에서 Compose 공식 retain으로2025 앱은 아직 Compose Runtime의 Retain API가 자리 잡기 전이었다. 대신 Rin 0.3.0의 rememberRetained를 사용했다.
Presenter의 화면 로컬 상태도 rememberRetained로 유지했다.
var uiType by rememberRetained {
mutableStateOf(TimetableUiType.List)
}
var selectedDay by rememberRetained {
mutableStateOf(DroidKaigi2025Day.ConferenceDay1)
}
화면 단위 DI context에도 같은 방식을 적용했다. 2025 앱의 KSP processor는 다음과 같은 함수를 생성한다.
fun TimetableScreenContext.Factory
.rememberTimetableScreenContextRetained(): TimetableScreenContext {
return rememberRetained {
createTimetableScreenContext()
}
}
NavEntry에서는 이 생성 함수를 호출해 화면 context를 구성 변경 동안 유지했다.
entry<TimetableNavKey> {
with(rememberTimetableScreenContextRetained()) {
TimetableScreenRoot(/* ... */)
}
}
2026 앱은 이 역할을 Compose Runtime의 retain으로 옮겼다. 앱의 NavDisplay에는 각 엔트리별 retained value store를 제공하는 retainNavEntryDecorator()가 들어간다.
NavDisplay(
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
retainNavEntryDecorator(),
rememberSnackbarNavEntryDecorator(),
),
)
각 NavEntryProvider는 화면 context 하나가 아니라 화면 graph 전체를 유지한다.
entry<TimetableItemDetailNavKey> { key ->
val graph = retain(key) {
screenGraphFactory.createTimetableItemDetailScreenGraph(key.id)
}
context(graph.screenContext) {
TimetableItemDetailScreenRoot(/* ... */)
}
}
Presenter의 로컬 상태도 공식 retain으로 바뀌었다.
var selectedDay by retain {
mutableStateOf(DroidKaigi2026Day.Day1)
}
이 변화는 단순한 함수 이름 교체가 아니다.
2025에서는 외부 라이브러리와 KSP 생성 코드가 화면 context의 수명을 만들어 줬다. 2026에서는 Navigation3 엔트리가 retained store의 소유자가 되고, screen graph와 Presenter 상태가 같은 엔트리 수명 안에 놓인다. 내비게이션과 DI, 화면 상태의 수명 관계가 코드에 더 직접적으로 드러난다.
ScreenContext에서 두 역할 Context로2025 시간표의 ScreenContext는 화면이 사용하는 Soil key를 모두 제공한다.
interface TimetableScreenContext : ScreenContext {
val timetableQueryKey: TimetableQueryKey
val favoriteTimetableIdsSubscriptionKey: FavoriteTimetableIdsSubscriptionKey
val favoriteTimetableItemIdMutationKey: FavoriteTimetableItemIdMutationKey
}
ScreenRoot는 이 context로 query와 subscription을 읽고, Presenter 역시 같은 TimetableScreenContext를 받는다. 실제 코드에서는 역할을 나눠 사용하지만 타입만 보면 Presenter가 화면 읽기용 key까지 모두 볼 수 있다.
2026 앱은 이를 두 개의 context로 나눈다.
@Inject
class TimetablePresenterContext(
val favoriteTimetableItemIdMutationKey: FavoriteTimetableItemIdMutationKey,
val logger: KaigiLogger,
) : PresenterContext
@Inject
@SingleIn(TimetableScreenScope::class)
class TimetableScreenContext(
val timetableQueryKey: TimetableQueryKey,
val favoriteTimetableIdsSubscriptionKey: FavoriteTimetableIdsSubscriptionKey,
val presenterContext: TimetablePresenterContext,
) : ScreenContext
역할은 명확하다.
ScreenContext: query와 subscription처럼 화면의 데이터를 읽는 데 필요한 의존성PresenterContext: mutation처럼 사용자 action을 처리하는 데 필요한 의존성ScreenContext가 PresenterContext를 상속하지 않고 프로퍼티로 보유한다는 점도 중요하다. Root는 Presenter를 호출하는 짧은 구간에서만 PresenterContext를 연다.
val uiState = context(screenContext.presenterContext) {
timetableScreenPresenter(/* ... */)
}
이렇게 하면 Presenter가 Root의 모든 의존성을 볼 필요가 없고, 화면 UI 깊숙한 곳에서 query를 임의로 구독하기도 어려워진다.
Context를 나누는 것만으로는 개발자가 항상 같은 규칙을 따른다고 보장할 수 없다. 2026 앱은 한 단계 더 나아가 커스텀 Kotlin FIR checker로 Soil 호출 위치를 검사한다.
검사 규칙의 핵심은 다음과 같다.
rememberQuery()와 rememberSubscription()은 ScreenContext가 있는 Root 역할에서만 호출한다.rememberMutation()은 PresenterContext가 있는 Presenter 역할에서만 호출한다.mutate()와 mutateAsync() 역시 Presenter 역할 안에서만 호출한다.ScreenContext를 context parameter로 선언할 수 없다.ScreenContext와 PresenterContext를 동시에 구현할 수 없다.예를 들어 feature UI 내부에서 context 없이 rememberQuery()를 호출하면 다음 취지의 컴파일 오류가 발생한다.
rememberQuery/rememberSubscriptionis a Root-role read. Read Soil at the screen root, not deep in feature UI.
또한 2026 앱은 MutationObject.mutate 프로퍼티에 직접 접근하는 것도 금지한다. action을 ScreenChannel로 전달한 뒤 Presenter의 ActionEffect 안에서 mutateAsync()를 호출하도록 유도한다.
2025의 구조가 코드 리뷰와 팀의 관례로 책임을 분리했다면, 2026의 구조는 잘못된 Soil 사용 자체가 컴파일되지 않도록 만든다. 규모가 커질수록 아키텍처 문서보다 컴파일 오류가 더 강한 가드레일이 될 수 있다는 판단으로 보인다.
물론 트레이드오프도 있다. 커스텀 컴파일러 플러그인은 Kotlin 버전 변화에 맞춰 계속 관리해야 한다. 규칙이 프로젝트에 너무 강하게 결합되면 작은 실험도 번거로워질 수 있다. 2026 앱은 이 비용을 감수하고 일관성을 택한 사례다.
EventFlow에서 retained ScreenChannel로2025 앱은 화면에서 발생한 이벤트를 MutableSharedFlow 기반의 EventFlow로 Presenter에 전달한다.
val eventFlow = rememberEventFlow<TimetableScreenEvent>()
TimetableScreen(
onBookmarkClick = { id ->
eventFlow.tryEmit(TimetableScreenEvent.Bookmark(id))
},
)
Presenter는 EventEffect로 이벤트를 수집한다.
EventEffect(eventFlow) { event ->
when (event) {
is TimetableScreenEvent.Bookmark -> {
favoriteMutation.mutate(event.id)
}
// ...
}
}
2026 앱은 action과 action result를 모두 담는 ScreenChannel을 사용한다.
class ScreenChannel<Action, ActionResult>(
internal val actions: Channel<Action> = Channel(Channel.BUFFERED),
internal val results: Channel<ActionResult> = Channel(Channel.BUFFERED),
)
@Composable
fun <A, R> retainScreenChannel(): ScreenChannel<A, R> =
retain { ScreenChannel() }
흐름은 양방향이다.
send(action)으로 사용자 입력을 보낸다.ActionEffect에서 action을 처리한다.emit(result)로 일회성 결과를 보낸다.ActionResultEffect에서 Snackbar나 화면 이동 같은 UI 작업을 수행한다.역할 분리는 채널에도 이어진다. send()와 ActionResultEffect에는 ScreenContext가, ActionEffect와 emit()에는 PresenterContext가 필요하다. 타입이 각 계층에서 호출할 수 있는 API의 방향을 제한한다.
Channel을 직접 쓰지 않고 ScreenChannel로 감쌌을까?ScreenChannel은 Channel의 전송 기능을 확장하는 래퍼가 아니다. Screen과 Presenter 사이의 통신 규약을 하나의 타입에 담는다. Action과 ActionResult를 나눠 입력과 출력의 방향을 드러내고, context parameter로 각 계층의 사용 권한을 제한한다.
채널 수집은 ActionEffect와 ActionResultEffect가 LaunchedEffect에 연결한다. 덕분에 화면마다 수집 코루틴의 수명을 다시 구현할 필요가 없다. 일반 UI callback에서는 trySend()로 action을 전달하고, Presenter에서는 suspend 함수인 emit()으로 result를 보낸다.
현재 사용 방식에서는 action과 result마다 소비자가 하나이므로 Channel의 일대일 큐가 잘 맞는다. 소비한 이벤트를 다시 전달하지 않으면서, 아직 처리하지 못한 이벤트는 버퍼에 남길 수 있다. Presenter 테스트도 같은 action/result 경계를 사용한다.
두 Channel을 화면에 그대로 노출해도 메시지는 전달할 수 있다. 다만 어느 계층이 어떤 채널을 읽고 써야 하는지, 수집을 어떤 수명에 연결해야 하는지가 호출부의 관례로 남는다. ScreenChannel은 이 규칙을 이름과 타입으로 고정한다.
ScreenChannel은 Retain 때문에 필요한가?ScreenChannel이라는 추상화와 Retain은 별개의 선택이다. remember로 생성해도 양방향 프로토콜과 역할 제한은 그대로 유효하다. Retain이 결정하는 것은 채널의 정체성과 버퍼를 얼마 동안 유지할지다.
화면이 컴포지션에서 일시적으로 빠지면 ActionEffect와 ActionResultEffect의 LaunchedEffect는 취소된다. remember로 만든 채널이라면 화면이 다시 구성될 때 새 인스턴스가 만들어져, 이전 채널의 버퍼에 남아 있던 action이나 result에 더는 접근할 수 없다. 반면 retainScreenChannel()은 해당 Navigation3 엔트리의 Retain scope가 살아 있는 동안 같은 ScreenChannel을 돌려준다. 수집 effect가 다시 시작되면 기존 버퍼에 아직 남아 있는 메시지를 이어서 받을 수 있다.
Retain이 보존하는 것은 수집 코루틴이 아니라 ScreenChannel 인스턴스와 내부 버퍼다. 수집 코루틴은 컴포지션에 맞춰 취소되고 다시 시작된다. 또한 이 채널은 프로세스 종료 후에도 복원되는 영속 큐가 아니다. 백 스택에서 엔트리가 제거되어 Retain scope가 끝나면 함께 폐기된다.
2025 Presenter는 EventEffect 안에서 Soil의 mutate()를 호출한다. EventEffect를 감싸는 SafeLaunchedEffect가 예외를 ComposeEffectErrorHandler로 전달하지만, 각 화면이 mutation 실패를 UI 결과로 변환하는 공통 구조는 두드러지지 않는다.
2026 Presenter는 mutateAsync()를 호출하고 mutation state를 별도의 effect로 관찰한다.
ActionEffect(screenChannel) { action ->
when (action) {
is TimetableScreenAction.Bookmark ->
favoriteMutation.mutateAsync(action.id)
// ...
}
}
MutationErrorEffect(favoriteMutation) { error ->
screenChannel.emit(
TimetableScreenActionResult.ShowMessage(error.toUserMessage()),
)
favoriteMutation.reset()
}
Screen은 결과를 받아 Snackbar를 표시한다.
ActionResultEffect(screenChannel) { result ->
when (result) {
is TimetableScreenActionResult.ShowMessage ->
snackbarHostState.showSnackbar(result.message.text)
}
}
MutationErrorEffect와 MutationSuccessEffect는 timestamp를 기준으로 이미 소비한 결과를 기억한다. 구성 변경이나 화면 복원 뒤 같은 success/error가 다시 실행되는 일을 막기 위한 장치다. 결과를 처리한 뒤 mutation을 reset()하는 규칙도 컴파일러 checker가 확인한다.
2026 앱에는 디버그 빌드에서 Soil의 query·subscription·mutation 오류를 모아 보여주는 DebugSoilErrorMonitor도 있다. 오류가 도착하면 key와 stack trace를 bottom sheet로 확인하고 복사할 수 있다. Soil이 단순한 데이터 호출 도구를 넘어 앱 차원의 관측 대상이 된 셈이다.
2025의 즐겨찾기 mutation은 하나의 고정 ID를 사용한다.
buildMutationKey(
id = MutationId("favorite_timetable_item_id_mutation_key"),
mutate = { userDataStore.toggleFavorite(it) },
)
같은 즐겨찾기 mutation은 시간표, 세션 상세, 즐겨찾기 화면에서 모두 쓰인다. Soil client 안에서 mutation state가 ID를 기준으로 관리된다면 서로 다른 화면이 같은 state를 보게 될 여지가 있다.
2026 앱은 각 screen graph가 MutationTag를 제공하고, mutation ID에 이 tag를 포함한다.
@Provides
private fun provideMutationTag(): MutationTag =
MutationTag("TimetableScreen")
buildMutationKey(
id = SoilIds.favoriteTimetableItemIdMutation(extraTag),
mutate = { id -> store.toggle(id) },
)
같은 동작을 수행하는 mutation이라도 화면마다 별도의 캐시 identity를 갖는다. 한 화면에서 발생한 success/error state가 다른 화면의 mutation effect에 섞이지 않도록 경계를 세운 것이다.
이 규칙 역시 FIR checker가 확인한다. MutationKey 구현은 MutationTag를 생성자 인자로 받아야 하며, 해당 tag가 실제 MutationId에 전달되어야 한다.
SoilDataBoundary의 재시도 로직이 단순해졌다2025의 SoilDataBoundary는 오류를 reset할 때 DataModel의 실제 타입을 직접 구분했다.
private suspend fun <T> DataModel<T>.performResetIfNeeded() {
when (this) {
is QueryObject<T> -> error?.let { refresh() }
is SubscriptionObject<T> -> error?.let { reset() }
}
}
DataModel을 한 개, 두 개, 세 개 받는 overload도 각각 제공했다. 화면의 app bar까지 유지하는 전용 fallback도 있어 당시 앱의 UI 요구사항을 세밀하게 처리했다.
2026 앱은 Soil이 제공하는 rememberQueriesErrorReset()을 사용한다.
ErrorBoundary(
fallback = fallback.errorFallback,
onReset = rememberQueriesErrorReset(),
) {
Suspense(fallback = fallback.suspenseFallback) {
Await(state1 = state1, state2 = state2, content = content)
}
}
앱이 query와 subscription의 reset 차이를 직접 알 필요가 없어졌다. Soil이 alpha12에서 alpha15로 발전하면서 앱 쪽의 보조 코드가 라이브러리 API로 흡수된 사례로 볼 수 있다.
다만 2026 스냅샷의 SoilDataBoundary는 한 개와 두 개의 state만 지원하고 fallback도 더 단순하다. 이를 무조건적인 개선으로 보기는 어렵다. 2025 앱은 더 많은 화면과 완성된 UI 요구사항을 반영한 상태였고, 2026 앱은 아직 개발 중인 스냅샷이기 때문이다.
2025 앱도 네트워크 데이터의 preload를 지원했다. 시간표 query는 응답을 SessionCacheDataStore에 저장하고 다음 실행에서 캐시를 읽는다.
class DefaultTimetableQueryKey(
private val sessionsApiClient: SessionsApiClient,
private val dataStore: SessionCacheDataStore,
) : TimetableQueryKey by buildQueryKey(
id = QueryId("timetable"),
fetch = {
val response = sessionsApiClient.sessionsAllResponse()
dataStore.save(response)
response.toTimetable()
},
) {
override fun onPreloadData(): QueryPreloadData<Timetable>? = {
dataStore.getCache()?.toTimetable()
}
}
이 방식은 시간표에 최적화되어 있지만, 다른 query에서 같은 전략을 사용하려면 저장과 복원 코드를 다시 작성해야 한다.
2026 앱은 이를 buildPersistedQueryKey()로 일반화했다.
class DefaultTimetableQueryKey(
private val api: TimetableApi,
private val fileStorage: ServerEnvironmentScopedFileStorage,
) : TimetableQueryKey by buildPersistedQueryKey(
id = SoilIds.timetableQuery,
persistKey = "timetable",
fileStorage = fileStorage,
fetchResponse = { api.getTimetable() },
transformToDomainModel = { response ->
Timetable(response.toTimetableItems().toPersistentList())
},
)
이 helper의 특징은 domain model이 아니라 서버 응답을 직렬화해 저장한다는 점이다.
transformToDomainModel을 다시 실행한다.domain model 리팩터링 때문에 저장된 캐시 형식이 바로 깨지는 일을 줄일 수 있고, 변환 로직도 항상 한곳을 통과한다. 이 helper는 시간표뿐 아니라 staff, sponsors, contributors query에도 재사용된다.
저장소도 Android·iOS·JVM·Wasm별 FileStorage 구현으로 추상화했다. 개발 서버와 운영 서버의 캐시가 섞이지 않도록 ServerEnvironmentScopedFileStorage가 key에 서버 환경을 포함한다.
2025의 “시간표를 빠르게 보여주기 위한 캐시”가 2026에서는 “여러 서버 query에 적용하는 공통 영속화 정책”으로 확장된 셈이다.
select를 쓴다상세 화면에서 같은 데이터를 어떻게 가져오는지도 달라졌다.
2025 앱은 시간표 목록과 별도로, 세션 ID를 받는 TimetableItemQueryKey를 만들었다. 전용 상세 API는 없으므로 저장된 전체 시간표 응답이나 서버 응답에서 해당 항목을 찾는다.
class DefaultTimetableItemQueryKey(
timetableItemId: TimetableItemId,
private val sessionsApiClient: SessionsApiClient,
private val dataStore: SessionCacheDataStore,
) : TimetableItemQueryKey by buildQueryKey(
id = QueryId(timetableItemId.value),
fetch = {
val response =
dataStore.getCache() ?: sessionsApiClient.sessionsAllResponse()
response.toTimetable().timetableItems.first {
it.id == timetableItemId
}
},
)
2026 앱은 상세 화면도 목록과 같은 TimetableQueryKey를 사용한다. 대신 rememberQuery()의 select에서 필요한 세션만 고른다.
rememberQuery(
key = screenContext.timetableQueryKey,
select = { timetable ->
timetable.items.first {
it.id == screenContext.timetableItemId
}
},
)
목록과 상세 화면이 같은 cache identity를 공유하므로 별도의 query 구현과 저장 전략이 필요하지 않다. 전체 시간표가 이미 캐시에 있다면 상세 화면도 그 결과를 그대로 활용할 수 있다.
이 선택이 항상 정답인 것은 아니다. 상세 전용 API가 있거나 목록과 상세의 갱신 주기·오류 정책이 달라야 한다면 별도 QueryKey가 더 자연스럽다. 2026 앱처럼 같은 원본 데이터에서 한 항목을 보여주는 경우에는 공유 query와 select의 조합이 중복을 줄여준다.
2025 KaigiApp은 Composable 안에서 Soil client를 만들어 SwrClientProvider에 전달한다.
SwrClientProvider(SwrCachePlus(SwrCacheScope())) {
KaigiTheme(/* ... */) {
KaigiAppUi()
}
}
2026 앱은 UiGraph가 소유한 client를 받는다. UiGraph 자체도 retain으로 유지한다.
val uiGraph = retain { appGraph.uiGraph }
SwrClientProvider(client = uiGraph.swrClient) {
// app content
}
Soil client의 생성과 수명이 UI 코드 바깥의 DI graph에 놓이면서 앱 전체 cache의 소유자가 더 명확해졌다. Navigation3 엔트리별 screen graph는 유지되지만 Soil client는 그보다 바깥의 UI graph가 공유한다.
정리하면 2026 앱에는 두 층의 수명이 있다.
UiGraph와 SwrClientScreenChannelSoil의 cache는 앱 수준에서 공유하면서 화면 전용 의존성과 이벤트는 엔트리 수명에 맞춘 구조다.
2025 앱도 Soil의 loading과 error 상태를 화면 테스트에서 검증했다. fake server 상태를 바꾸고 Robot으로 loading indicator와 error fallback을 확인하는 방식이다. 이는 ScreenRoot + SoilDataBoundary + Screen 전체를 검증하는 데 적합하다.
2026 앱은 이 방식에 더해 Composable Presenter 전용 테스트 도구를 만들었다.
runPresenterTest()는 다음 구성요소를 한곳에 묶는다.
moleculeFlowSwrCachePlusPresenterContextScreenChannelUiState와 action result 검증덕분에 Presenter 테스트는 UI 없이도 다음 내용을 확인할 수 있다.
runPresenterTest(
presenterContext = graph.presenterContext,
presenter = { channel ->
favoritesScreenPresenter(
screenChannel = channel,
timetable = sampleTimetable,
)
},
) {
val initial = uiStates.awaitItem()
send(FavoritesScreenAction.SelectDayFilter(DroidKaigi2026Day.Day2))
val filtered = uiStates.awaitItem()
send(FavoritesScreenAction.Bookmark(TimetableItemId("d1a")))
assertEquals(
TimetableItemId("d1a"),
graph.favoriteMutationKey.invocations.receive(),
)
}
mutation 실패가 ActionResult.ShowMessage로 변환되는지, rememberQuery()가 loading에서 content로 전환되는지도 별도로 검증한다.
Root는 데이터 로딩과 오류 fallback을 테스트하고, Presenter는 순수한 state 전이와 mutation 연결을 테스트한다. 앞서 나눈 역할 경계가 테스트 경계에도 그대로 반영되어 있다.
지금까지의 변화만 보면 2026 구조가 모든 면에서 더 좋아 보일 수 있다. 하지만 실제 선택에는 비용이 따른다.
첫째, 2026 앱의 screen 하나를 구성하는 파일과 개념이 많아졌다.
ScreenContextPresenterContextScreenGraphScreenChannelActionActionResultNavEntryProviderMutationTag작은 화면에는 과하게 느껴질 수 있다.
둘째, 커스텀 FIR plugin은 강력하지만 유지 비용이 있다. Kotlin compiler API가 바뀔 때 대응해야 하며 팀원이 규칙의 의도를 이해하지 못하면 컴파일 오류가 답답하게 느껴질 수 있다.
셋째, 두 스냅샷의 기능 범위가 다르다. 2025 앱은 profile에 Soil Form까지 사용하고 세 개의 DataModel을 받는 boundary와 app bar 전용 fallback도 제공한다. 반면 비교 시점의 2026 앱에는 Soil Form 의존성이 없고 일부 화면 기능은 아직 구현 중이다.
따라서 이 글은 “2026이 2025보다 우월하다”는 비교가 아니다. 같은 팀이 Soil을 사용하며 어떤 문제를 발견했고, 다음 프로젝트에서 어떤 규칙을 명시적으로 만들었는지 살펴보는 데 의미가 있다.
DroidKaigi 2025 앱은 Soil을 Compose Multiplatform 화면에 자연스럽게 연결한 사례였다.
SoilDataBoundary가 loading과 error를 처리한다.DroidKaigi 2026 앱은 이 구조를 더 강한 프로젝트 아키텍처로 발전시켰다.
retain과 Navigation3 엔트리 수명을 사용한다.ScreenContext와 PresenterContext를 역할별로 나눈다.가장 흥미로웠던 점은 Soil API 자체보다 그 주변이 바뀌었다는 것이다. rememberQuery(), rememberSubscription(), rememberMutation()이라는 중심 API는 두 해 모두 비슷하다. 달라진 것은 누가 호출하고, 어떤 수명에 속하며, 실패를 어디로 보내고, 그 규칙을 어떻게 검증하는지다.
라이브러리를 도입하는 것과 그 라이브러리를 프로젝트의 언어로 만드는 것은 다르다. 2025 앱이 Soil을 잘 활용한 사례라면, 2026 앱은 Soil을 중심으로 팀의 아키텍처 규칙과 개발 도구까지 설계한 사례라고 느꼈다.