[Soil] Soil 찍먹 해보기

easyhooon·2025년 8월 20일
4

Soil

목록 보기
1/3
post-thumbnail

서두

Compose 개발에서 상태관리를 단순화하고 개발 속도 향상에 도움을 주는 라이브러리인 Soil 에 대해 알아보고자 한다. 이번 글에선 Soil이 뭔지, 왜 만들어지게 되었는지, 그리고 어떤 기능들을 제공하는지 간략히 알아보도록 하겠다.

What is Soil?

공식 문서에 설명이 잘되어있어 이를 인용해보도록 하겠다.

Soil은 일반적인 UseCase에 대한 상태관리를 단순화하고 개발 속도를 높이도록 설계된 Compose Multiplatform 라이브러리이다.
간단히 말해, 비동기 데이터 관리, 폼 데이터 입력 및 검증, 그리고 Compose First 접근 방식에서 각 요소 간 데이터 공유를 더 쉽게 구현할 수 있도록 지원한다.

이를 다시 말하면,

  • 비동기 데이터 관리(TanStack(React) Query)
  • 폼 데이터 입력 검증(React Hook Form)
  • 각 요소간 데이터 공유(전역 상태 관리 라이브러리(ex. Redux, Zustand, Recoil, Jotal..)

와 같은 React 개발 환경에서 주로 사용하는 개발 편의를 위한 라이브러리들을 Compose 환경에서 사용할 수 있도록 지원하는 도구 모음이다.

Motivation

갑자기 분위기 React?

메인테이너이신 ogaclejapan님께서 Soil 라이브러리를 만드신 계기를 읽어보면 그 이유를 알 수 있다.
아래는 그 요약이다.

연도별 Android 개발의 발전 과정

  1. 2016년 Google I/O에서 AAC(Android Architecture Components)를 발표
  2. 그 이후, AAC ViewModel이 Android 앱 개발에서 필수가 됨(뷰모델 최고!)
  3. 2021년 구글의 Native UI 개발을 위한 modern tookit 인 Jetpack Compose 등장!(React, SwiftUI와 유사하게 선언형 UI 패러다임)
  4. 2024년 AAC ViewModel은 Jetpack Compose를 도입한 개발에서 여전히 널리 사용됨

메인테이너님의 문제 인식
전통적으로 remote 데이터를 가져오는 화면에는 항상 ViewModel이 포함됨
-> 하지만 대부분의 화면(70-80%)은 API에서 데이터를 가져와 간단한 작업을 수행하는 단순한 요소들
-> 이런 단순한 화면들을 Compose-first 방식으로 더 간소화할 수 있지 않을까?(매우 공감)

React 커뮤니티에서 영감
Jetpack Compose보다 몇 년 앞서 선언형 UI로 SPA를 구축해온 React 커뮤니티의 모범 사례에 주목
Server State 개념과 SWR(Stale-While-Revalidate) Data Fetching 및 캐싱 방식을 Compose에 도입하고자 함

메인테이너님은 "ViewModel이 불필요하다"는 것이 아니라, 모든 UI 상태를 ViewModel로 관리하는 방식이 선언형 UI 철학과 다소 맞지 않는다고 보고, Compose의 진정한 강점은 Composable에 있다고 강조하신다.

SWR?

간단하게 정의하면 오래된 데이터를 먼저 보여주고, 백그라운드에서 새 데이터를 가져와 업데이트하는 전략이다.

동작 순서로

  1. Stale(오래된) 데이터를 즉시 표시
  2. 백그라운드에서 새 데이터를 요청
  3. 새 데이터가 오면 업데이트

여기서 Stale(오래된) 데이터에는 캐싱된 데이터의 의미를 포함한다.

어떤 화면에 진입 하여 이미 Initial Load API를 호출한 시점에서 굳이 화면에 재 진입할 때마다 API를 호출하여 새로고침을 할 필요는 없을 때, 위의 전략을 사용한다면,

사용자는 즉시 캐싱된 데이터를 볼 수 있어 빠른 화면 로딩을 경험하고, 백그라운드에서 최신 데이터를 가져와 자동으로 업데이트되므로 성능과 사용자 경험을 모두 향상시킬 수 있다.

어쨌든 매번 API를 호출하는거 아닌가?

이 최신 데이터를 가져와 업데이트하는 시점(API 호출 시점)은 Key 혹은 시간을 설정하는 방식으로 개발자가 직접 관리할 수 있다.

Key 방식은 React Hook과 Compose의 SideEffect 관련 API의 방식처럼 의존성 변경 상황, 시간 기반은 주기적인 화면 갱신이 필요할 때 적합할 듯 하다.

// SWR
const { data } = useSWR('/api/posts', fetcher, {
  refreshInterval: 60000,      // 60초마다 자동 갱신
  revalidateOnFocus: true,     // 포커스 시 재검증
  revalidateOnReconnect: true, // 네트워크 재연결 시
});

// Tanstack Query
const { data } = useQuery(['posts'], fetchPosts, {
  staleTime: 300000,           // 5분 후 stale(오래된, 상한 데이터)로 간주
  refetchInterval: 60000,      // 60초마다 재요청
  refetchOnWindowFocus: true,  // 창 포커스 시
});

자동으로 업데이트? 업데이트의 판단 기준이 뭐임? 기존 데이터와 새로 받아온 데이터가 같으면?

물론 백그라운드에서 받아온 데이터가 기존의 캐싱된 데이터와 같을 경우, 굳이 화면 업데이트를 해줄 필요가 없기 때문에, 이 경우엔 불 필요한 리렌더링을 방지하기 위해 업데이트 되지 않는다.

이러한 데이터 비교 연산이 라이브러리 내부에서 진행되기 때문에, 자동이라는 표현을 사용하였다.

더 자세한 내용은 해당 발표 자료를 참고하면 좋을 것 같다.

Why Soil?

갑자기 왜 이런 초기 단계의 라이브러리를 분석하려고 하는가?

올해 초 부터, 회사에서 인력을 돕기 위해 프론트 공부를 조금 했었는데, 다행히 Compose를 익숙하게 사용하고 있었기 때문에, React의 선언형 UI 패러다임, 각종 hook들에 대해서도 이해하기 수월하였다.

Compose가 정말 React를 많이 참고해서 만들어졌다는 것을 확실히 알 수 있었다. 또한 프론트의 발전 방향을 모바일이 그대로 따라가고 있어, 프론트를 공부하면 모바일의 미래를 준비할 수 있다고 생각한다.

공부를 오래한 것은 아니지만, 둘의 차이점으로는 확실히 React가 더 나온지도 오래되었고, 생태계도 성숙하기에 개발자들의 편의를 위한 라이브러리가 정말 많이 있다는 것이었다.

MVVM 의 아키텍처 패턴을 따르는 것은 React도 마찬가지이나, MVVM ViewModel을 Android에서 처럼 AAC ViewModel과 같은 클래스로 구현하지 않고, 함수형 컴포넌트와 다양한 Hook들의 조합을 통해 구현하여, 상태 관리와 비즈니스 로직을 처리한다는 점이 큰 차이였다.

전역 상태관리 라이브러리의 경우, 그 사용에 대해 찬반이 오가는 것을 확인한 적이 있었는데, 뷰모델 쓰듯이 데이터를 저장하고, 이를 간편하게 꺼내와서 사용할 수 있는 장점이 있는 것은 확실하였다. 브라우저 스토리지 등에 영구 저장도 가능하고...

특히, Data Fetching 관련해서는 TanStack Query(구 React Query), SWR 같은 라이브러리들을 통해 서버 상태 관리를 유려하게 처리하고 있기에, Compose 생태계에도 이런 접근 방식이 도입되면 정말 좋겠다는 생각이 들었다.

React Native 의 가장 큰 장점 역시, 이러한 React 생태계의 라이브러리들을 그대로 사용할 수 있다는 것이라고 개인적으로 생각한다.

내가 직접 만들긴 어려울 것 같고, 누가 안 만들어주나? 그런 아쉬운 마음을 가지고 있던 중에, DroidKaigi 2025 conference app 에서 Repository 클래스를 대체하기 위한 솔루션으로 Soil을 채택한 것을 보고, 궁금해서 라이브러리에 대한 설명을 읽어봤는데, 읽자마자 '아 이게 내가 찾던 솔루션이구나..!' 라는걸 바로 알 수 있었다.

React Hook Form, 전역 상태 관리는 덤이다 +_+

이제 Soil에서 제공하는 기능들이 어떤 것들이 있는지 Soil github 의 sample들을 하나씩 확인해보며 간략하게 설명 해보도록 하겠다.

Soil-Query

https://docs.soil-kt.com/guide/query/hello-query.html

Tanstack Query와 대응되는 라이브러리로, 데이터를 조회하는 Query, 데이터를 변경(생성/수정/삭제)하는 Mutation으로 구성된다.

예제에는 Query 함수만을 사용하므로, 이를 집중적으로 확인해보겠다.

@Composable
fun HelloQueryScreen() {
    HelloQueryScreenTemplate {
        HelloQueryContent(modifier = Modifier.fillMaxSize())
    }
}

@Composable
private fun HelloQueryScreenTemplate(
    content: @Composable () -> Unit
) {
    ErrorBoundary(
        modifier = Modifier.fillMaxSize(),
        fallback = { // 에러 발생시 보여줄 UI
            ContentUnavailable(
                error = it.err, // 에러 정보
                reset = it.reset, // 재시도 함수 
                modifier = Modifier.matchParentSize()
            )
        },
        onError = { e -> println(e.toString()) }, // 에러 로깅
        onReset = rememberQueriesErrorReset() // 리셋 로직(전략)
    ) {
        Suspense( 
            fallback = { ContentLoading(modifier = Modifier.matchParentSize()) }, // 로딩 중 UI
            modifier = Modifier.fillMaxSize(),
            content = content // 실제 콘텐츠 
        )
    }
}

코드를 여기까지만 확인해봐도 React 냄세가 진동을 한다.

예제에 대한 이해를 위해 빠르게 Soil-Experimetal에서 지원하는 ErrorBoundary 컴포저블과 Suspense 컴포저블에 대해 먼저 설명하도록 하겠다.

Soil-Experimental

1. ErrorBoundary

react-error-boundary 라이브러리와 대응되며,
선언적으로 에러를 처리하는 ErrorBoundary 컴포저블은 에러가 발생할 경우 보여줄 UI를 fallback 슬롯 내부에 정의할 수 있다. 또한 에러가 발생할 경우 수행할 동작과, reset(재시도) 함수를 정의할 수 있다.

2. suspense

react의 Suspense 컴포넌트와 대응되며,
ErrorBoundary와 유사하게 fallback 슬롯에 로딩 상태일때의 UI를 정의할 수있다. content 슬롯엔 로딩이 끝난 후 보여줄 컨텐츠 컴포저블을 정의해주면 된다.

다음으로 실제 컨텐츠 영역을 확인해보자.

@Composable
private fun HelloQueryContent(
    modifier: Modifier = Modifier
) = withAppTheme {
    ListSectionContainer { state ->
        val lazyListState = rememberLazyListState()
        LazyColumn(
            modifier = modifier,
            state = lazyListState,
            contentPadding = PaddingValues(horizontal = 16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            items(state.posts, key = { it.id }) { post ->
                NavLink(to = NavScreen.HelloQueryDetail(post.id)) {
                    PostListItem(
                        onClick = it,
                        post = post,
                        modifier = Modifier.fillMaxWidth()
                    )
                }
            }
            val pageParam = state.loadMoreParam
            if (state.posts.isNotEmpty() && pageParam != null) {
                item(true, contentType = "loading") {
                    ContentLoading(
                        modifier = Modifier.fillMaxWidth(),
                        size = 20.dp
                    )
                }
            }
        }
        LazyLoad(
            state = lazyListState,
            loadMore = state.loadMore,
            loadMoreParam = state.loadMoreParam
        )
    }
}

유심히 봐야할 것은 이 중에 ListSectionContainer와 LazyLoad 컴포넌트이다.
우선 React-Experimental에 LazyLoad를 이어서 설명하겠다.

3. LazyLoad

reac-lazyload 라이브러리와 대응되며, 코드를 보면 알 수 있듯, 무한 스크롤을 간편하게 구현하기 위한 컴포넌트이다.

@Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
@OptIn(FlowPreview::class)
@Composable
inline fun <T : Any, S> LazyLoad(
    state: S,
    loadMore: LazyLoadMore<T>,
    loadMoreParam: T?,
    strategy: LazyLoadStrategy<S>,
    debounceTimeout: Duration = 200.milliseconds
) {
    LaunchedEffect(state, loadMoreParam) {
        if (loadMoreParam == null) {
            return@LaunchedEffect
        }
        snapshotFlow { strategy.shouldLoadMore(state) }
            .debounce(debounceTimeout)
            .filter { it }
            .take(1)
            .collect {
                loadMore(loadMoreParam)
            }
    }
}

어떤 시점에 데이터를 추가 로드할지 전략을 수립하여, 간단하게 무한 스크롤을 구현할 수 있게 한다.

이제 본론인 Soil-Query를 확인해보도록 하자.

// TIPS: Adopting the Presentational-Container Pattern allows for separation of control and presentation.
// https://www.patterns.dev/react/presentational-container-pattern
@Composable
private fun ListSectionContainer(
    content: @Composable (ListSectionState) -> Unit
) {
    val query = rememberGetPostsQuery()
    Await(query) { posts ->
        val state by remember(posts, query.loadMoreParam, query.loadMore) {
            derivedStateOf { ListSectionState(posts, query.loadMoreParam, query.loadMore) }
        }
        content(state)
    }
}

private data class ListSectionState(
    val posts: Posts,
    val loadMoreParam: PageParam?,
    val loadMore: suspend (PageParam) -> Unit
)
@Composable
fun HelloQueryDetailScreen(postId: Int) {
    HelloQueryScreenDetailTemplate {
        PostDetailContent(
            postId = postId,
            modifier = Modifier.fillMaxSize()
        )
    }
}

@Composable
private fun HelloQueryScreenDetailTemplate(
    content: @Composable () -> Unit
) {
    ErrorBoundary(
        modifier = Modifier.fillMaxSize(),
        fallback = {
            ContentUnavailable(
                error = it.err,
                reset = it.reset,
                modifier = Modifier.matchParentSize()
            )
        },
        onError = { e -> println(e.toString()) },
        onReset = rememberQueriesErrorReset()
    ) {
        Suspense(
            fallback = { ContentLoading(modifier = Modifier.matchParentSize()) },
            modifier = Modifier.fillMaxSize(),
            content = content
        )
    }
}

@Composable
private fun PostDetailContent(
    postId: Int,
    modifier: Modifier = Modifier
) {
    val foo = rememberExampleSubscription()
    PostDetailContainer(postId) { post ->
        Column(
            modifier = modifier.verticalScroll(rememberScrollState()),
            verticalArrangement = Arrangement.spacedBy(24.dp)
        ) {
            Text(text = foo.reply.getOrElse { "" })
            PostDetailItem(post, modifier = Modifier.fillMaxWidth())
            PostUserDetailContainer(userId = post.userId) { user, posts ->
                PostUserDetailItem(user = user, posts = posts)
            }
        }
    }
}

@Composable
private fun PostDetailContainer(
    postId: Int,
    content: @Composable (Post) -> Unit
) {
    val query = rememberGetPostQuery(postId)
    Await(query) { post ->
        content(post)
    }
}

@Composable
private fun PostUserDetailContainer(
    userId: Int,
    content: @Composable (User, Posts) -> Unit
) {
    val userQuery = rememberGetUserQuery(userId)
    val postsQuery = rememberGetUserPostsQuery(userId)
    Suspense(
        fallback = { ContentLoading(modifier = Modifier.matchParentSize()) },
        modifier = Modifier.fillMaxWidth()
    ) {
        Await(userQuery, postsQuery) { user, posts ->
            content(user, posts)
        }
    }
}

Await?

Await 은 Soil-Query의 핵심 컴포넌트로, 비동기 Query의 성공 상태만을 처리하는 역할을 담당한다.(나머지 상태는 ErrorBoundary, Suspense 컴포넌트로 처리)

주요 특징은 다음과 같다.

  1. 성공 데이터만 전달
Await(query) { post ->  // Query가 성공했을 때만 실행
    content(post)       // 로딩/에러 상태는 처리하지 않음
}
  1. 다중 Query 병렬 처리
Await(userQuery, postsQuery) { user, posts ->  // **두 Query 모두 성공 시**
    content(user, posts)
}
  1. Suspense와 조합
Suspense(fallback = { /* 로딩 UI */ }) {
    Await(query) { data ->      // Suspense가 로딩 처리
        /* 성공 시 UI */         // Await는 데이터만 전달
    }
}

그래서 API는 어디서 호출?

@Composable
fun rememberGetPostsQuery(
    userId: Int? = null,
    builderBlock: InfiniteQueryConfig.Builder.() -> Unit = {}
): GetPostsQueryObject {
    return rememberInfiniteQuery(
        key = GetPostsKey(userId), // <- 여기!
        select = { it.chunkedData }, // API 를 통해 받은 데이터중 chunckedData만 추출해서 반환(데이터 변환 함수)
        config = InfiniteQueryConfig(builderBlock)
    )
}

일반적인 Query의 경우 rememberQuery 함수를 사용하지만, Sample 에선 무한 스크롤을 지원하기 위해 Soil-Query에서 지원하는 rememberInfiniteQuery 를 사용한 것을 확인할 수 있다.

import androidx.compose.runtime.Stable
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import soil.playground.query.data.PageParam
import soil.playground.query.data.Posts
import soil.query.InfiniteQueryId
import soil.query.InfiniteQueryKey
import soil.query.core.KeyEquals
import soil.query.receivers.ktor.buildKtorInfiniteQueryKey

// NOTE: userId
// Filtering resources
// ref. https://jsonplaceholder.typicode.com/guide/
@Stable
class GetPostsKey(
    val userId: Int? = null
) : KeyEquals(), InfiniteQueryKey<Posts, PageParam> by buildKtorInfiniteQueryKey(
    // 고유 ID 생성 로직
    id = InfiniteQueryId.forGetPosts(userId),
    // Ktor 를 통한 API 호출 로직
    fetch = { param ->
        get("https://jsonplaceholder.typicode.com/posts") {
            parameter("_start", param.offset)
            parameter("_limit", param.limit)
            if (userId != null) {
                parameter("userId", userId)
            }
        }.body()
    },
    // 무한 스크롤 Pagination 로직
    initialParam = { PageParam(limit = 20) },
    loadMoreParam = { chunks ->
        chunks.lastOrNull()
            ?.takeIf { it.data.isNotEmpty() }
            ?.run { param.copy(offset = param.offset + param.limit) }
    }
)

fun InfiniteQueryId.Companion.forGetPosts(userId: Int? = null) = InfiniteQueryId<Posts, PageParam>(
    namespace = "posts/*",
    "userId" to userId
)

API 호출 로직이 Key 내부에 캡슐화가 되어있고, 별도의 외부 의존성(Repository, Service 클래스)을 주입할 필요 없다.

외부 의존성을 최소화하고, 모든 로직(Pagination, API 호출, 데이터 변환 등)이 하나의 Key 단위로 독립적으로 구성 되어있어, 여러 레이어 뎁스를 거치지 않고, 다른 곳에서 그대로 재사용할 수도 있을 것으로 보인다.

그럼 Ktor, Retrofit과 어떻게 함께 사용하는가?

Soil-Query-Receiver를 통해 HTTP 클라이언트 라이브러리와 통합하여 사용 가능하다.

Soil은 Compose Multiplatform을 위한 라이브러리이기 때문에 현재는 Ktor Recevier만을 지원한다. Retrofit ㅠ

그래서 SWR은 어디에 적용?

@Composable
private fun ListSectionContainer(
    content: @Composable (ListSectionState) -> Unit
) {
    val query = rememberGetPostsQuery()
    Await(query) { posts ->
        val state by remember(posts, query.loadMoreParam, query.loadMore) {
            derivedStateOf { ListSectionState(posts, query.loadMoreParam, query.loadMore) }
        }
        content(state)
    }
}

@Composable
fun rememberGetPostsQuery(
    userId: Int? = null,
    builderBlock: InfiniteQueryConfig.Builder.() -> Unit = {}
): GetPostsQueryObject {
    return rememberInfiniteQuery(
        key = GetPostsKey(userId),
        select = { it.chunkedData },
        config = InfiniteQueryConfig(builderBlock) // <- 여기!
    )
}

현재 rememberGetPostsQuery 호출시 argument를 자정하지 않았기 때문에 builderBlock엔 빈 값이 들어가고,

@Composable
fun <T, S, U> rememberInfiniteQuery(
    key: InfiniteQueryKey<T, S>,
    select: (chunks: QueryChunks<T, S>) -> U,
    config: InfiniteQueryConfig = InfiniteQueryConfig.Default,
    client: QueryClient = LocalQueryClient.current
): InfiniteQueryObject<U, S> {
    val scope = rememberCoroutineScope()
    val query = remember(key.id) { newInfiniteQuery(key, config, client, scope) }
    query.Effect()
    return with(config.mapper) {
        config.strategy.collectAsState(query).toObject(query = query, select = select)
    }
}

/**
 * Configuration for the infinite query.
 *
 * @property mapper The mapper for converting query data.
 * @property optimizer The optimizer for recomposing the query data.
 * @property strategy The strategy for caching query data.
 * @property marker The marker with additional information based on the caller of a query.
 */
@Immutable
data class InfiniteQueryConfig internal constructor(
    val mapper: InfiniteQueryObjectMapper,
    val optimizer: InfiniteQueryRecompositionOptimizer,
    val strategy: InfiniteQueryStrategy,
    val marker: Marker
) {

    /**
     * Creates a new [InfiniteQueryConfig] with the provided [block].
     */
    fun builder(block: Builder.() -> Unit) = Builder(this).apply(block).build()

    class Builder(config: InfiniteQueryConfig = Default) {
        var mapper: InfiniteQueryObjectMapper = config.mapper
        var optimizer: InfiniteQueryRecompositionOptimizer = config.optimizer
        var strategy: InfiniteQueryStrategy = config.strategy
        var marker: Marker = config.marker

        fun build() = InfiniteQueryConfig(
            strategy = strategy,
            optimizer = optimizer,
            mapper = mapper,
            marker = marker
        )
    }

    companion object {
        val Default = InfiniteQueryConfig(
            mapper = InfiniteQueryObjectMapper.Default,
            optimizer = InfiniteQueryRecompositionOptimizer.Enabled,
            strategy = InfiniteQueryStrategy.Default,
            marker = Marker.None
        )
    }
}

빈 값으로 정의할 경우 default 값인 InfiniteQueryConfig.Default 전략으로 SWR이 실행된다.(위에서 설명한 그대로의 방식)

Soil에서 지원하는 기본적인 캐싱 전략은 3가지가 있는데 각각 간단하게 정리하면 다음과 같다.

  1. Default(Stale-While-Revalidate)
private object DefaultInfiniteQueryStrategy : InfiniteQueryStrategy {
    override fun <T, S> collectAsState(query: InfiniteQueryRef<T, S>): QueryState<QueryChunks<T, S>> {
        val state by query.state.collectAsState()
        LaunchedEffect(query.id) {
            query.resume()  // 항상 백그라운드에서 재검증
        }
        return state
    }
}
  1. Cache-First
    유효한 캐시가 있으면 네트워크 요청을 하지 않고 캐시만 사용
LaunchedEffect(key) {
    val currentValue = flow.value
    if (currentValue.reply.isNone || currentValue.isInvalidated) {
        resume()  // 캐시가 없거나 무효화된 경우만 요청
    }
}
  1. Network-First
    외부에서 업데이트된 데이터가 화면에 정확히 반영되지 않으면 문제가 되는 경우에 사용
val initialValue = if (resumed) flow.value else QueryState.initial()
val state = produceState(initialValue, key) {
    resume()  // 항상 네트워크 요청 먼저
    resumed = true
    flow.collect { value = it }
}

아직 예제엔 언급되지 않은, 파악해야할 부분들이 한참 많이 남아있어서 천천히 음미해봐야겠다.

https://github.com/soil-kt/soil/tree/main/soil-query-core/src/commonMain/kotlin/soil/query

Tanstack Query의 일반적인 코드 패턴과 살짝 차이가 있는데

// Tanstack Query 예시 코드
function Example() {
  const { data, isLoading, isError, refetch } = useQuery('index', fetchData, { stateTime: 5000}
                                                         
  if (isLoading) return <Text>Loading...</Text>
  if (isError) return <View><ErrorImage /></View>
  
  const invalidate = () => { queryClient.invalidateQueries('index') }
  
  return <View>{data}<View onClick={invalidate}>Invalidate</View></View>
}

뷰의 뎁스가 깊어서 Suspense 컴포넌트가 중첩되는 경우가 살짝 우려되는 것 빼고는 상당히 만족스러운 구현체라고 생각한다.

개인적으로 ViewModel + UiState 방식에서 가장 번거로웠던 부분이 isLoading, isError와 같은 화면의 상태에 대한 true/false Flag 값들을 API 호출할 때마다 직접 설정해줘야 부분이었는데, Soil-Query(with ErrorBoundary, Suspense)를 사용하면 이러한 번거로운 작업들을 자동화할 수 있어 매우 편리하다. 이 장점 하나만으로도 충분히 Soil-Query를 도입할 충분한 이유가 된다고 생각한다.

기존의 ViewModel + UiState 방식 
// UiState
data class PostListUiState(
    val isLoading: Boolean = false,
    val posts: List<Post> = emptyList(),
    val isError: Boolean = false,
    val errorMessage: String? = null,
    val isLoadingMore: Boolean = false
)

// ViewModel
class PostListViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(PostListUiState())
    val uiState = _uiState.asStateFlow()
    
    fun loadPosts() {
        viewModelScope.launch {
            // 수동으로 로딩 상태 관리
            _uiState.update { it.copy(isLoading = true, isError = false) }
            
            runCatching {
                repository.getPosts()
            }.onSuccess { posts ->
                // 수동으로 로딩, 에러 상태 관리
                _uiState.update { 
                    it.copy(isLoading = false, posts = posts, isError = false) 
                }
            }.onFailure { exception ->
                // 수동으로 로딩, 에러 상태 관리
                _uiState.update { 
                    it.copy(isLoading = false, isError = true, errorMessage = exception.message) 
                }
            }
        }
    }
    
    fun loadMorePosts() {
        // 비슷한 패턴 반복
        viewModelScope.launch {
            _uiState.update { it.copy(isLoadingMore = true) }
            
            runCatching {
                val currentPosts = _uiState.value.posts
                val morePosts = repository.getMorePosts(currentPosts.size)
                currentPosts + morePosts
            }.onSuccess { allPosts ->
                _uiState.update { 
                    it.copy(isLoadingMore = false, posts = allPosts) 
                }
            }.onFailure { exception ->
                _uiState.update { 
                    it.copy(isLoadingMore = false, isError = true, errorMessage = exception.message) 
                }
            }
        }
    }
}

// UI
@Composable
fun PostListScreen(viewModel: PostListViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsState()
    
    when {
        uiState.isLoading -> LoadingIndicator()
        uiState.isError -> ErrorMessage(uiState.errorMessage)
        else -> PostList(uiState.posts)
    }
}

다음은 Soil-Form 에 대해 설명하도록 하겠다.

Soil-Form

https://docs.soil-kt.com/guide/form/hello-form.html

React Hook Form 과 대응되는 라이브러리로, 폼 입력 상태 관리 및 텍스트필드 데이터 validation을 통합적으로 처리할 때 사용한다.

Android 개발자들 입장에서는 '굳이 이런 부분까지 라이브러리를 사용 해야하나?' 생각이 들 수도 있는데, 아무래도 앱에 비해 웹이 역사가 더 길고, 화면이 큰 만큼, 한 화면내 다양한 입력을 수행하는 화면 플로우가 상대적으로 더 많았기에, 복잡한 form validation과 상태 관리에 대한 니즈가 먼저 생겨, 발전했을 것으로 추측한다.

@OptIn(ExperimentalSerializationApi::class)
@Composable
fun HelloFormScreen() {
    val feedback = LocalFeedbackHost.current
    val coroutineScope = rememberCoroutineScope()
    val focusManager = LocalFocusManager.current
    val formState = rememberFormState(initialValue = FormData(), saver = serializationSaver())
    val form = rememberForm(state = formState) {
        coroutineScope.launch {
            feedback.showAlert("Form submitted successfully")
            focusManager.clearFocus()
            formState.reset(FormData())
        }
    }
    HelloFormContent(
        form = form,
        modifier = Modifier.fillMaxSize()
    )
}

// The form input fields are based on the Live Demo used in React Hook Form.
// You can reference it here: https://react-hook-form.com/
@Composable
private fun HelloFormContent(
    form: Form<FormData>,
    modifier: Modifier = Modifier
) = withAppTheme {
    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier
            .padding(16.dp)
            .verticalScroll(rememberScrollState())
    ) {
        val (f1, f2, f3, f4, f5, f6, f7) = FocusRequester.createRefs()
        form.FirstName { field ->
            field.WithLayout {
                InputField(
                    modifier = Modifier.fillMaxWidth().focusRequester(f1),
                    label = { Text("First name") }
                )
            }
        }
        form.LastName { field ->
            field.WithLayout {
                InputField(
                    modifier = Modifier.fillMaxWidth().focusRequester(f2),
                    label = { Text("Last name") }
                )
            }
        }
        form.Email { field ->
            field.WithLayout {
                InputField(
                    modifier = Modifier.fillMaxWidth().focusRequester(f3),
                    label = { Text("Email") },
                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
                )
            }
        }
        form.MobileNumber { field ->
            field.WithLayout {
                InputField(
                    modifier = Modifier.fillMaxWidth().focusRequester(f4),
                    label = { Text("Mobile number") },
                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
                )
            }
        }
        form.Title { field ->
            field.WithLayout {
                SelectField(
                    transform = { it?.name ?: "" },
                    modifier = Modifier.fillMaxWidth().focusRequester(f5),
                    label = { Text("Title") },
                ) {
                    Title.entries.forEach { value ->
                        Option(value) {
                            Text(text = value.name)
                        }
                    }
                }
            }
        }
        form.Developer { field ->
            field.WithLayout {
                RadioField(
                    modifier = Modifier.fillMaxWidth().focusRequester(f6)
                ) {
                    Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
                        listOf(true, false).forEach { value ->
                            Option(value) {
                                Text(if (value) "Yes" else "No")
                            }
                        }
                    }
                }
            }
        }
        form.Submit {
            Text(
                text = "Submit",
                modifier = Modifier.fillMaxWidth().focusRequester(f7),
                textAlign = TextAlign.Center
            )
        }
    }
}
@Composable
private fun Form<FormData>.FirstName(
    content: @Composable (FormField<String>) -> Unit
) {
    Field(
        selector = { it.firstName },
        updater = { copy(firstName = it) },
        validator = FieldValidator {
            notBlank { "must be not blank" }
        },
        render = content
    )
}

@Composable
private fun Form<FormData>.LastName(
    content: @Composable (FormField<String>) -> Unit
) {
    Field(
        selector = { it.lastName },
        updater = { copy(lastName = it) },
        validator = FieldValidator {
            notBlank { "must be not blank" }
        },
        render = content
    )
}

@Composable
private fun Form<FormData>.Email(
    content: @Composable (FormField<String>) -> Unit
) {
    Field(
        selector = { it.email },
        updater = { copy(email = it) },
        validator = FieldValidator {
            notBlank { "must be not blank" }
            email { "must be valid email address" }
        },
        render = content
    )
}

@Composable
private fun Form<FormData>.MobileNumber(
    content: @Composable (FormField<String>) -> Unit
) {
    Field(
        selector = { it.mobileNumber },
        updater = { copy(mobileNumber = it) },
        validator = FieldValidator {
            notBlank { "must be not blank" }
        },
        render = content
    )
}

@Composable
private fun Form<FormData>.Title(
    content: @Composable (FormField<Title?>) -> Unit
) {
    Field(
        selector = { it.title },
        updater = { copy(title = it) },
        validator = FieldValidator {
            notNull { "must be selected" }
        },
        render = content
    )
}

@Composable
private fun Form<FormData>.Developer(
    content: @Composable (FormField<Boolean?>) -> Unit
) {
    Field(
        selector = { it.developer },
        updater = { copy(developer = it) },
        validator = FieldValidator {
            notNull { "must be selected" }
        },
        render = content
>     )
}

// Basic custom validation rule for email addresses
private fun StringRuleBuilder.email(message: () -> String) {
    val pattern = Regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}\$")
    extend(StringRule({ pattern.matches(this) }, message))
}

코드를 확인했을 때, 특징으로는 다음과 같다.

  1. Form 제네릭으로 타입 안정성을 보장
  2. selector 와 updater 로 데이터의 불변성을 유지
  3. Kotlin DSL 스타일의 validation 처리 지원

sample 코드를 보면 React Hook Form 대비해서 살짝, 보일러 플레이트 코드가 많은 느낌은 있는데, Soil을 사용한다고 Soil의 모든 라이브러리를 다 사용해야하는 것은 아니기 때문에, 취사 선택하면 될 것 같다.

Compose의 TextField와 TextFieldState 와 같은 API가 별도의 라이브러리를 사용하지 않아도, 폼에 필요한 거의 모든 기능들을 지원해서 필요를 못 느낀 것도 있긴 하다.

보통 React Hook Form은 검증 로직 라이브러리인 Zod 와 같이 사용하여, 상태 관리와 복잡한 validation도 간단하게 처리할 수 있는데(일반적인 정규식도 직접 작성할 필요 없기도 하고), 이러한 완성도 높은 라이브러리가 Compose 생태계에 부재한 것도 상대적으로 아쉬운 부분이다.

속보) Kotlin 생태계에도 Zod 가 나타났다!

Soil-Space

https://docs.soil-kt.com/guide/space/hello-space.html

Atom 이라는 키워드가 포함되는 것으로 보아, redux/zustand보단 작은 단위의 상태를 조합하는 bottom-up 구조인, recoil/ jotai와 대응되는 전역 상태관리에 대응되는 라이브러리로, 필요한 부분만 선택적으로 구독하여 상태를 공유하고 관리하는 기능을 제공한다.

@Composable
private fun HelloSpaceContent(
    modifier: Modifier = Modifier
) = withAppTheme {
    Column(
        modifier = modifier,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        CounterSection(
            modifier = Modifier
                .fillMaxWidth()
                .fillMaxHeight()
                .weight(1f)
                .background(colorScheme.surface)
        )
        CounterSection(
            modifier = Modifier
                .fillMaxWidth()
                .fillMaxHeight()
                .weight(1f)
                .background(colorScheme.surfaceVariant)
        )
    }
}

@Composable
private fun CounterSection(
    modifier: Modifier
) {
    Box(
        modifier = modifier,
        contentAlignment = Alignment.Center
    ) {
        CounterContainer { state ->
            CounterView(
                count = state.count,
                updateCount = state.updateCount,
                counterText = state.counterText
            )
        }
    }
}

// TIPS: Adopting the Presentational-Container Pattern allows for separation of control and presentation.
// https://www.patterns.dev/react/presentational-container-pattern
@Composable
private fun CounterContainer(
    content: @Composable (CounterState) -> Unit
) {
    var counter by rememberAtomState(counterAtom)
    val counterSelector by rememberAtomValue(counterSelectorAtom)
    val state by remember { derivedStateOf { CounterState(counter, { counter = it }, counterSelector) } }
    content(state)
}

private data class CounterState(
    val count: Int,
    val updateCount: (Int) -> Unit, 
    val counterText: String,
)

@Composable
private fun CounterView(
    count: Int,
    updateCount: (Int) -> Unit,
    counterText: String
) = withAppTheme {
    Column(
        verticalArrangement = Arrangement.spacedBy(4.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Counter(
            value = count,
            onDecrement = { updateCount(count - 1) },
            onIncrement = { updateCount(count + 1) }
        )
        Text(
            text = counterText,
            style = typography.bodySmall
        )
    }
}

private val navScreen = atomScope()
private val currentScreen = atomScope()

// Specifying saverKey is optional. It is necessary for restoration on the Android platform.
private val counterAtom = atom(0, saverKey = "counter")
private val counterSelectorAtom = atom {
    val value = get(counterAtom)
    when {
        0 < value -> "positive number"
        0 > value -> "negative number"
        else -> "zero"
    }
}

코드만 확인 했을 때, 주요한 부분으로는

  1. Container와 View를 분리(Container/Presentational 패턴, State Hoisting과 유사)
  2. State에 행위, 이벤트를 포함(Circuit의 UiState, Redux/Zustand의 Reducer 와 비슷)
  3. atom을 내에 Saver를 통해 Android platform 에서 config change 로 부터 데이터를 보호

정도인데, AAC ViewModel을 대체해서 사용할 수 있는지는 직접 적용해본 뒤에 알 수 있을 것 같다.

DroidKaigi 앱에서도 Soil-Query만 Data Layer에서 사용하고, State Holder로는 Circuit 스타일의 Presenter 를 사용하는 것을 확인할 수 있었다. 이것 역시 취사 선택하면 될듯

https://docs.soil-kt.com/guide/faq.html

Soil-Space의 경우 아직 Jetpack Navigation과 연동이 되지않아, 사용에 한계가 있을 듯 하여, 조금 더 묵혀놨다가 맛봐야겠다.

결론

Compose 개발의 편의를 위한 라이브러리인 Soil이 어떤 기능들을 제공하는지 알아보았다.

다음 글에서는 기존의 ViewModel(UiState) + Repository + Service 기반의 프로젝트를 Soil을 사용하는 방식으로 리팩토링하여, 어떤 차이점이 있는지, Soil을 사용하였을 때, 어떤 장점이 있는지 알아보고 Soil의 효용성에 대해 알아보도록 하겠다.

또한, sample 코드엔 사용되지 않았지만, React의 useOptimistic Hook, Tanstack Query를 통해 구현할 수 있는 Optimistic Update 기능 또한 Soil-Experimental에서 지원하고 있어, 이를 적용해봐야겠다. 매우 기대중

글을 쓰면서 참고했던 레퍼런스들 중에 Container-Presentational 패턴, fine-grained reactivity 등의 키워드들이 많이 보였는데, 이것들이 뭔지도 추가적으로 학습해봐야겠다. 이미 아는 개념인데, 용어만 모르는 것일 수도 있다.

빨리 기존 코드에 Soil이 지원하는 SWR 전략을 적용해보고 싶다.

reference)
https://github.com/soil-kt/soil
https://docs.soil-kt.com/
https://zenn.dev/tbsten/articles/fb84f8edf55490
https://api.soil-kt.com/

https://github.com/alan2207/bulletproof-react/blob/master/docs/state-management.md
https://tanstack.com/query/latest
https://ko.react.dev/reference/react/useOptimistic
https://ko.react.dev/reference/react/Suspense
https://www.npmjs.com/package/react-lazyload
https://www.npmjs.com/package/react-error-boundary
https://react-hook-form.com/
https://github.com/pmndrs/jotai
https://recoiljs.org/ko/docs/basic-tutorial/atoms/
https://www.patterns.dev/react/presentational-container-pattern
https://junghan92.medium.com/%EB%B2%88%EC%97%AD-%EC%84%B8%EB%B6%84%ED%99%94%EB%90%9C-%EB%B0%98%EC%9D%91%ED%98%95-fine-grained-reactivity-%EC%97%90-%EB%8C%80%ED%95%9C-%ED%95%B8%EC%A6%88%EC%98%A8-%EC%86%8C%EA%B0%9C-57242ec61e08
https://www.elancer.co.kr/blog/detail/214
https://speakerdeck.com/l2hyunwoo/seoneonhyeong-uireul-hagseubhal-ddae-aladweoyahaneun-kiweodeudeul?slide=53
https://youtu.be/txaf9F0d-aE?si=3AjQecJ-5m3OHZQc

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

4개의 댓글

comment-user-thumbnail
2025년 8월 27일

좋은 글 감사합니다~!

1개의 답글
comment-user-thumbnail
2025년 9월 13일

Soil이라는 이름부터 익숙하고 멋지다고 생각했는데, 글 덕분에 간접적으로 경험할 수 있어 좋았습니다. 다음 편도 기대하고 있습니다. 좋은 글 감사합니다.

1개의 답글