[Soil] Soil Query 적용해보기

easyhooon·2025년 9월 30일
2

Soil

목록 보기
3/3
post-thumbnail

서두

기존에 만들어놓은 Clean Architecture 기반의 토이 프로젝트에 Soil Query를 도입하면서, 마이그레이션 전후의 코드 변화를 비교하고 그 차이를 분석해보고자 한다.

https://github.com/easyhooon/BookSearch
레포지토리는 해당 링크를 통해 확인할 수 있으며, 기존 내가 생각하는 Clean Architecture 기반의 프로젝트는 clean-architecture 브랜치에 백업해두었다.

Soil Query는 프론트 진영에서 사용하는 Tanstack Query, SWR과 같은 비동기 상태 관리를 위한 라이브러리의 Kotlin/Compose 버전으로, 간략한 설명은 해당 글에서 확인할 수 있다.

앱의 대한 기능를 빠르게 설명하면, 도서를 검색하고 즐겨찾기에 추가할 수 있는 앱이며 상세화면을 통해 도서의 상세 정보를 확인할 수 있고, 도서 목록에 필터/정렬 기능들을 지원한다.
즐겨찾기에 추가된 도서인 경우, 검색화면에서도 좋아요 표시가 활성화 표시된다.

검색즐겨찾기상세

본론

적용하기 위한 사전 작업

  • Retrofit -> Ktor
  • UseCase 제거
  • 그외에 DroidKaigi 스타일을 참고

Server DTO의 속성들을 그대로 사용하기 때문에, Mapper를 통해 Domain Model로 변환해주는 작업은 마이그레이션 이후 생략하고 바로 UiModel로 변환 하였다.

짚고 넘어가고 싶은 부분으로는 Rin 기반 Presenter + EventEffect 를 사용하는 부분인데, supervisorScope을 사용하여 각 이벤트 처리 로직을 격리하고, safeLaunchedEffect 함수를 통해 처리되지 않는 예외에 의한 앱 크래시를 방지하고 로그를 통해 원인을 파악할 수 있도록 설계하였다.

또한 ensureActive 함수를 통해 Coroutine CancellationException은 다시 던져 코루틴의 취소가 정상적으로 동작하도록 보장한다.

// EventEffect.kt
typealias EventFlow<T> = MutableSharedFlow<T>

@Composable
fun <T> rememberEventFlow(): EventFlow<T> {
    return remember {
        MutableSharedFlow(extraBufferCapacity = 20)
    }
}

@Composable
fun <EVENT> EventEffect(
    eventFlow: EventFlow<EVENT>,
    block: suspend CoroutineScope.(event: EVENT) -> Unit,
) {
    SafeLaunchedEffect(eventFlow) {
        supervisorScope {
            eventFlow.collect { event ->
                launch {
                    block(event)
                }
            }
        }
    }
}

// safeLaunchedEffect.kt
interface ComposeEffectErrorHandler {
    suspend fun emit(throwable: Throwable)
}

@Suppress("CompositionLocalAllowlist")
val LocalComposeEffectErrorHandler = staticCompositionLocalOf<ComposeEffectErrorHandler> {
    object : ComposeEffectErrorHandler {
        override suspend fun emit(throwable: Throwable) {
            throwable.printStackTrace()
        }
    }
}

@Suppress("TooGenericExceptionCaught")
@Composable
fun SafeLaunchedEffect(key: Any?, block: suspend CoroutineScope.() -> Unit) {
    val composeEffectErrorHandler = LocalComposeEffectErrorHandler.current
    LaunchedEffect(key) {
        try {
            block()
        } catch (e: Exception) {
            ensureActive()
            e.printStackTrace()
            composeEffectErrorHandler.emit(e)
        }
    }
}

Rin 기반의 Presenter는 Circuit의 Presenter와 마찬가지로 UI 상태를 변경 가능하나, 새로운 이벤트를 방출할 수는 없는 구조이다.

Toast를 처리하려면 Activity로 부터 Context를 받아와, Presenter내에서 직접 호출해야 하는데, State Holder가 플랫폼 종속성을 갖는 것은 지양해야하는 방식이다.

이러한 딜레마를 해결 및 여러 화면의 반복적인 ScreenEvent 처리를 간소화 하기 위한 EventBus 형식의 ToastMessageEffect를 DroidKaigi 2024의 UserMessage를 참고해서 개발해보았다.

// ToastMessageEffect.kt
@Composable
fun ToastMessageEffect(
    userMessageStateHolder: UserMessageStateHolder,
) {
    val context = LocalContext.current
    val userMessage = userMessageStateHolder.userMessage

    LaunchedEffect(userMessage) {
        userMessage?.let { message ->
            Toast.makeText(context, message.message, Toast.LENGTH_SHORT).show()
        }
    }
}

// UserMessageStateHolder.kt
@Stable
interface UserMessageStateHolder {
    val userMessage: UserMessage?
    fun showMessage(message: String)
}

@Stable
class UserMessageStateHolderImpl : UserMessageStateHolder {
    override var userMessage: UserMessage? by mutableStateOf(null)
        private set

    override fun showMessage(message: String) {
        userMessage = UserMessage(message = message)
    }
}

data class UserMessage(
    val id: Long = System.currentTimeMillis(),
    val message: String,
)

적용 이후 작업

  • Repository 제거 -> QuerKey, MutationKey, SubscriptionKey 로 대체 -> MutationKey, SubscriptionKey 는 다시 Repository로 롤백

  • Application 클래스내에 Soil Query 전역 설정 적용(SwrCachePlus 초기화)

@HiltAndroidApp
class App : Application(), SwrClientFactory {

    @OptIn(ExperimentalSoilQueryApi::class)
    override val queryClient: SwrClientPlus by lazy {
        // Soil Query의 캐시 관리자
        SwrCachePlus(
            // 캐시 정책 설정
            policy = SwrCachePlusPolicy(
                // 코루틴 스코프
                coroutineScope = SwrCacheScope(),
                // 쿼리 옵션 (로거 등)
                queryOptions = QueryOptions(
                    staleTime = 30.seconds,  // 30초간 fresh 상태 유지 (API 호출 안함)
                    gcTime = 10.minutes,      // 10분간 메모리 캐시 유지
                    logger = { println(it) },
                ),
                // 메모리 압박 감지 (Android)
                memoryPressure = AndroidMemoryPressure(this),
                // 네트워크 연결 상태 감지 (Android)
                networkConnectivity = AndroidNetworkConnectivity(this),
                // 윈도우 가시성 감지
                windowVisibility = AndroidWindowVisibility(),
            ),
        )
    }
}

SwrCachePlusPolicy 내에선 현재 설정한 옵션외에 다양하고 정교한 캐싱 관련 옵션들을 설정할 수 있다.

Tanstack Query에서 처럼 네트워크 연결 상태를 확인하여, 무의미한 API 호출을 사전에 방지하는 기능을 제공한다.
뿐만 아니라 Android 특성을 반영한 TRIM_MEMORY 관련 대응이 가능한 MemoryPressure, ProcessLifecycleOwner 기반의 WindowVisibility 감지 기능같은 고급 옵션들도 지원한다.
QueryOption 내에 staleTimegcTime 관련 설명은 후술하도록 하겠다.

Repository 패턴 대체

Repository를 대신하여 사용하는 InfiniteQueryKey의 코드는 다음과 같다.

data class SearchBooksPageParam(
    val page: Int,
)

@Singleton
class DefaultSearchBooksQueryKey @Inject constructor(
    private val ktorClient: BookSearchKtorClient,
) {
    fun create(
        query: String,
        sort: String = "accuracy",
        size: Int = 20,
    ): InfiniteQueryKey<List<BookUiModel>, SearchBooksPageParam> = object : InfiniteQueryKey<List<BookUiModel>, SearchBooksPageParam> {
        override val id = InfiniteQueryId<List<BookUiModel>, SearchBooksPageParam>("search_books_${query}_${sort}_$size")
        override val initialParam = { SearchBooksPageParam(1) }
        // 네트워크 통신(API 호출)
        override val fetch: suspend QueryReceiver.(param: SearchBooksPageParam) -> List<BookUiModel> = { pageParam ->
            if (query.isBlank()) {
                emptyList()
            } else {
                val response = ktorClient.searchBook(
                    query = query,
                    sort = sort,
                    page = pageParam.page,
                    size = size,
                )
                response.documents.map { bookResponse ->
                    BookUiModel(
                        isbn = bookResponse.isbn,
                        title = bookResponse.title,
                        // ...
                        isFavorites = null,
                    )
                }
            }
        }
        // 스크롤 하여 추가 API 호출 
        override val loadMoreParam = { chunks: QueryChunks<List<BookUiModel>, SearchBooksPageParam> ->
            val lastChunk = chunks.lastOrNull()
            val lastPageData = lastChunk?.data
            if (lastPageData != null && lastPageData.size == size) {
                SearchBooksPageParam(chunks.size + 1)
            } else {
                null
            }
        }
    }
}

해당 QueryKey를 ScreenContext에 주입하고, 그 ScreenContext를 Hilt EntryPont로 생성한다.
이를 ScreenRoot에 Kotlin Context Parameter로 주입하여, ScreenRoot 에서 Data Fetching을 수행한다.

Hilt의 EntryPoint는 Hilt 의존성 그래프에 접근할 수 있는 진입점이다. 보통은 @Inject@HiltViewModel 등으로 자동 주입받지만, Presenter가 일반 클래스가 아닌 Composable 함수이기 때문에, 이 어노테이션들을 직접 사용할 수 없어 EntryPoint를 통해 수동 주입받았다.

ScreenContext는 단순히 여러 의존성을 묶어서 전달하는 컨테이너 역할을 수행한다.

import com.easyhooon.booksearch.core.data.query.DefaultSearchBooksQueryKey
import com.easyhooon.booksearch.core.domain.repository.BookRepository
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Inject

// SearchScreenContext.kt
class SearchScreenContext @Inject constructor(
    val searchBooksQueryKey: DefaultSearchBooksQueryKey,
    val bookRepository: BookRepository,
)

@EntryPoint
@InstallIn(SingletonComponent::class)
interface SearchScreenContextEntryPoint {
    fun searchScreenContext(): SearchScreenContext
}

// SearchNavigation.kt
fun NavGraphBuilder.searchGraph(
    innerPadding: PaddingValues,
    navigateToDetail: (BookUiModel) -> Unit,
) {
    composable<MainTabRoute.Search> {
        val context = LocalContext.current
        val searchScreenContext = remember {
            EntryPointAccessors.fromApplication(
                context,
                SearchScreenContextEntryPoint::class.java
            ).searchScreenContext()
        }
        
        with(searchScreenContext) {
            SearchScreenRoot(
                innerPadding = innerPadding,
                onBookClick = navigateToDetail,
            )
        }
    }
}

Presenter에서는 직접적인 Data Fetching을 수행하지 않으며, 이미 Fetching된 데이터를 받아 UI 상태로 변환 및 사용자 이벤트 처리 로직을 담당한다.

// SearchPresenter
data class SearchUiState(
    val currentQuery: String = "",
    val sortType: SortType = SortType.ACCURACY,
    val searchResults: ImmutableList<BookUiModel> = persistentListOf(),
    val hasNextPage: Boolean = false,
)

sealed interface SearchScreenEvent {
    data class Search(val query: String) : SearchScreenEvent
    data object ClearSearch : SearchScreenEvent
    data object ToggleSort : SearchScreenEvent
}

context(context: SearchScreenContext)
@Composable
fun SearchPresenter(
    eventFlow: EventFlow<SearchScreenEvent>,
    queryState: TextFieldState,
    currentQuery: String,
    sortType: SortType,
    searchResults: ImmutableList<BookUiModel>,
    hasNextPage: Boolean,
    onQueryChange: (String) -> Unit,
    onSortChange: (SortType) -> Unit,
): SearchUiState = providePresenterDefaults {
    EventEffect(eventFlow) { event ->
        when (event) {
            is SearchScreenEvent.Search -> {
                if (event.query.isNotBlank()) {
                    onQueryChange(event.query)
                } else {
                    Logger.d("SearchPresenter: Query is blank, ignoring")
                }
            }
            is SearchScreenEvent.ClearSearch -> {
                queryState.clearText()
                onQueryChange("")
            }
            is SearchScreenEvent.ToggleSort -> {
                onSortChange(sortType.toggle())
            }
        }
    }

    SearchUiState(
        currentQuery = currentQuery,
        sortType = sortType,
        searchResults = searchResults,
        hasNextPage = hasNextPage,
    )
}

UseCase는 옵셔널하기 때문에 논외로 치고, 현재 방식에선 Soil을 적용한다고 해서 구현 방식의 depth가 줄어들진 않았다. Repository가 QueryKey로 대체되었을 뿐, 해당 Layer가 아예 제거된건 아니기 때문이다.

또한 별도의 설정이 필요 없다면 QueryKey의 fetch 블록에 바로 HTTP 요청을 작성할 수 있지만, Header에 API Key 지정, Logging 옵션 설정 등이 필요했기 때문에 KtorClient를 별도로 정의하고 생성자 주입했다. 따라서 Service Layer 역시 제거되지 않았다.

rememberMutation, rememberSubscription을 적용하지 않은 이유

사실 적용했었고, soil 브랜치에서 전체 적용 코드를 확인할 수 있다.
다만, Subscription 동작 관련해서 실시간 동기화가 제대로 동작하지 않는 문제가 존재한다...이유는 후술

Soil Query는 Tanstack Query와 같은 비동기 상태 관리 라이브러리(Async State Management Library)로,
비동기 작업이 필요한 데이터의 Fetching, Caching, 동기화를 위해 설계되었다.

Tanstack Query의 주요 UseCase는 서버 API 호출이지만, WebSocket, IndexedDB 같은 다른 비동기 데이터 소스에도 사용할 수 있다.

하지만 DataStore나 Room과 같은 로컬 DB와 같이 사용하는 것은 적절하지 않다고 생각했다.

이유 1. 중복된 구독 관리

DataStore와 Room은 이미 Flow를 통해 실시간 데이터 변경을 자동으로 구독하고 이를 통해 UI를 업데이트할 수 있다. 실제로 DataStore, Room을 통해 데이터를 get 하는 경우 suspend 함수를 사용하지 않고, 일반 함수를 사용하거나, 변수로 지정한다.

Soil의 Mutation/Subscription을 추가로 사용하면 같은 데이터를 두 개의 서로 다른 구독 메커니즘으로 관리하게 되어 동기화 문제가 발생할 수 있다.

실제로 rememberSubscriptionKey에 Room Dao를 주입하여 구현할 경우, 데이터 변경 업데이트가 다소 느리게 적용되는 문제를 확인할 수 있었는데, 현재까지는 정확한 이유를 파악하진 못했고, Subscription 함수의 내부적인 캐싱/무효화 전략과 Room Flow Emit이 꼬여서 발생하는 것으로 추측하고 있다...어렵

이유 2. 불필요한 캐싱

Soil Query는 Tanstack Query에서 처럼 네트워크 요청의 비용을 줄이기 위해 정교한 캐싱 전략을 제공한다.
하지만 로컬 DB는 이미 충분히 빠르므로 이러한 캐싱이 불필요하며, 오히려 복잡성만 증가시킨다.

이유 3. 서버 중심 기능의 부적합성

Optimistic Update, 자동 재시도, 백그라운드 refetch, Query 무효화 등의 기능은 네트워크 지연과 실패를 다루기 위해 설계되었다. 하지만 로컬 DB와의 상호 작용에서는 이런 기능들이 딱히 의미가 없다.

그러면 DroidKaigi에서는 왜 DataStore에 rememberMutation/Subscription 씀?

DroidKaigi 앱은 모든 데이터 작업들을 Soil로 통합하여 일관성 있는 Composable-First 아키텍처를 구현했다. 이는 프로젝트의 아키텍처 철학에 따른 의도적인 선택이며, 개발자들에게 새로운 관점을 보여주기 위한 하나의 본보기라고 개인적으로 추측한다.

무튼 이렇게 각 데이터 소스의 특성에 맞게 취사 선택하여 Soil Query를 적용하였다.

이제 각각의 화면 구현의 변경점을 확인해보도록 하겠다.

도서 검색화면

기존 코드(UI)

@Composable
internal fun SearchRoute(
    // ...
    navigateToDetail: (BookUiModel) -> Unit,
    // 뷰모델 사용
    viewModel: SearchViewModel = hiltViewModel(),
) {
    // 상태 구독 
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val searchBooks by viewModel.searchBooks.collectAsStateWithLifecycle()

    // UI 이벤트 처리 
    ObserveAsEvents(flow = viewModel.uiEvent) { event ->
        when (event) {
            is SearchUiEvent.NavigateToDetail -> navigateToDetail(event.book)
        }
    }

    SearchScreen(
        // ...
        uiState = uiState,
        searchBooks = searchBooks,
        onAction = viewModel::onAction,
    )
}

@Composable 
internal fun SearchScreen(...) {...}

@Composable
internal fun SearchContent(
    uiState: SearchUiState,
    searchBooks: ImmutableList<BookUiModel>,
    onAction: (SearchUiAction) -> Unit,
) {
    when (uiState.searchState) {
        is SearchState.Loading -> {
            // LoadingIndicator
        }

        is SearchState.Error -> {
          	// ErrorScreen
        }

        is SearchState.Idle -> {
            // IdleScreen
        }

        is SearchState.Success -> {
            if (uiState.isEmptySearchResult) {
                // EmptyScreen
            } else {
                // Paging 처리를 위한 커스텀 컴포저블 구성 
                InfinityLazyColumn(
                    modifier = Modifier.fillMaxSize(),
                    contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
                    verticalArrangement = Arrangement.spacedBy(8.dp),
                    loadMore = { onAction(SearchUiAction.OnLoadMore) },
                ) {
                    items(
                        items = searchBooks,
                        key = { it.isbn },
                    ) { book ->
                        BookCard(
                            book = book,
                            onBookClick = { onAction(SearchUiAction.OnBookClick(book)) },
                        )
                    }

                    item {
                        LoadStateFooter(
                            footerState = uiState.footerState,
                            onRetryClick = { onAction(SearchUiAction.OnRetryClick) },
                        )
                    }
                }
            }
        }
    }
}

@Immutable
sealed interface SearchState {
    data object Idle : SearchState
    data object Loading : SearchState
    data object Success : SearchState
    data class Error(val message: String) : SearchState
}

data class SearchUiState(
    val searchState: SearchState = SearchState.Idle,
    val footerState: FooterState = FooterState.Idle,
    val queryState: TextFieldState = TextFieldState(),
    val sortType: SortType = SortType.ACCURACY,
    val currentPage: Int = 1,
    val currentQuery: String = "",
    val isLastPage: Boolean = false,
    val totalCount: Int = 0,
) {
    val isEmptySearchResult: Boolean
        get() = searchState is SearchState.Success && totalCount == 0
}

sealed interface SearchUiEvent {
    data class NavigateToDetail(val book: BookUiModel) : SearchUiEvent
}
// InfinityLazyColumn.kt
// 기기에서 평균적으로 한 화면에 보이는 아이템 개수
private const val LIMIT_COUNT = 4

@Composable
fun InfinityLazyColumn(
    modifier: Modifier = Modifier,
    state: LazyListState = rememberLazyListState(),
    contentPadding: PaddingValues = PaddingValues(0.dp),
    reverseLayout: Boolean = false,
    verticalArrangement: Arrangement.Vertical =
        if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
    userScrollEnabled: Boolean = true,
    loadMoreLimitCount: Int = LIMIT_COUNT,
    loadMore: () -> Unit = {},
    content: LazyListScope.() -> Unit,
) {
    state.onLoadMore(limitCount = loadMoreLimitCount, action = loadMore)

    LazyColumn(
        modifier = modifier,
        state = state,
        contentPadding = contentPadding,
        reverseLayout = reverseLayout,
        verticalArrangement = verticalArrangement,
        horizontalAlignment = horizontalAlignment,
        flingBehavior = flingBehavior,
        userScrollEnabled = userScrollEnabled,
        content = content,
    )
}

@SuppressLint("ComposableNaming")
@Composable
private fun LazyListState.onLoadMore(
    limitCount: Int = LIMIT_COUNT,
    loadOnBottom: Boolean = true,
    action: () -> Unit,
) {
    val reached by remember {
        derivedStateOf {
            reachedBottom(limitCount = limitCount, triggerOnEnd = loadOnBottom)
        }
    }

    LaunchedEffect(reached) {
        if (reached && layoutInfo.totalItemsCount > limitCount) action()
    }
}

/**
 * @param limitCount: 몇 개의 아이템이 남았을 때 트리거 될 지에 대한 정보
 * @param triggerOnEnd: 바닥에 닿았을 때에도 트리거 할 지 여부
 *
 * @return 바닥에 닿았는지 여부(트리거 조건)
 */
private fun LazyListState.reachedBottom(
    limitCount: Int = LIMIT_COUNT,
    triggerOnEnd: Boolean = false,
): Boolean {
    val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
    return (triggerOnEnd && lastVisibleItem?.index == layoutInfo.totalItemsCount - 1) || lastVisibleItem?.index != 0 && lastVisibleItem?.index == layoutInfo.totalItemsCount - (limitCount + 1)
}

웰노운한 구조를 띄고 있으며 UiState를 통해 화면 내 상태(Idle, Loading, Success, Error)를 수동으로 관리하여, 각 상태에 따른 UI를 화면에 나타내며, InfinityLazyColumn을 직접 구현하여 추가 로드를 위한 조건을 지정하여 무한 스크롤을 구현하고 있다.

Soil Query 적용 후(UI)

data class SearchUiState(
    val currentQuery: String = "",
    val sortType: SortType = SortType.ACCURACY,
    val searchResults: ImmutableList<BookUiModel> = persistentListOf(),
    val hasNextPage: Boolean = false,
)

sealed interface SearchScreenEvent {
    data class Search(val query: String) : SearchScreenEvent
    data object ClearSearch : SearchScreenEvent
    data object ToggleSort : SearchScreenEvent
}

@OptIn(ExperimentalSoilQueryApi::class)
context(context: SearchScreenContext)
@Composable
fun SearchScreenRoot(
    innerPadding: PaddingValues,
    onBookClick: (BookUiModel) -> Unit,
) {
    val queryState = rememberTextFieldState()
    val eventFlow = rememberEventFlow<SearchScreenEvent>()

    var currentQuery by rememberRetained { mutableStateOf("") }
    var sortType by remember { mutableStateOf(SortType.ACCURACY) }

    val favoriteBookIds by context.bookRepository.favoriteBooks
        .map { books -> books.map { it.isbn }.toSet() }
        .collectAsStateWithLifecycle(initialValue = emptySet())

    val searchInfiniteQuery = if (currentQuery.isNotEmpty()) {
        rememberInfiniteQuery(
            key = context.searchBooksQueryKey.create(
                query = currentQuery,
                sort = sortType.value,
                size = 20,
            ),
            select = { it }
        )
    } else null

    if (searchInfiniteQuery != null) {
        // 검색 결과가 있는 경우
        SoilDataBoundary(
            state = searchInfiniteQuery,
            fallback = SoilFallback(
                errorFallback = { ctx ->
                    SearchErrorContent(onRetry = { ctx.errorBoundaryContext.reset?.let { it() } })
                },
                suspenseFallback = {
                    SearchLoadingContent()
                }
            ),
        ) { searchData ->
            // Soil infinite query의 searchData에서 실제 데이터 추출
            val allSearchResults: List<BookUiModel> = searchData.flatMap { chunk -> chunk.data }

            // 검색 결과에 즐겨찾기 상태 반영
            val searchResultsWithFavorites = allSearchResults.map { book: BookUiModel ->
                book.copy(isFavorites = favoriteBookIds.contains(book.isbn))
            }.toImmutableList()

            val uiState = SearchPresenter(
                eventFlow = eventFlow,
                queryState = queryState,
                currentQuery = currentQuery,
                sortType = sortType,
                searchResults = searchResultsWithFavorites,
                hasNextPage = searchInfiniteQuery.loadMoreParam != null,
                onQueryChange = { currentQuery = it },
                onSortChange = { sortType = it },
            )

            SearchScreen(
                innerPadding = innerPadding,
                queryState = queryState,
                uiState = uiState,
                onSearchClick = { query ->
                    if (query.isNotBlank()) {
                        currentQuery = query
                    }
                },
                onClearClick = {
                    queryState.clearText()
                    currentQuery = ""
                },
                onSortClick = {
                    sortType = sortType.toggle()
                },
                onBookClick = onBookClick,
                loadMore = { param ->
                    searchInfiniteQuery.loadMore.let { loadMoreFn ->
                        (param as? SearchBooksPageParam)?.let {
                            loadMoreFn(it)
                        }
                    } ?: Unit
                },
                loadMoreParam = searchInfiniteQuery.loadMoreParam,
            )
        }
    } else {
        // 검색 결과가 없는 경우
        // EmptyScreen
    }
}

@Composable
internal fun SearchContent(
    books: ImmutableList<BookUiModel>,
    hasNextPage: Boolean,
    onBookClick: (BookUiModel) -> Unit,
    loadMore: suspend (Any) -> Unit = {},
    loadMoreParam: Any? = null,
) {
    if (books.isEmpty()) {
        // SearchEmptyContent()
    } else {
        val lazyListState = rememberLazyListState()

        LazyColumn(
            modifier = Modifier.fillMaxSize(),
            state = lazyListState,
            // ...
        ) {
            items(
                items = books,
                key = { it.isbn },
            ) { book ->
                BookCard(
                    book = book,
                    onBookClick = { onBookClick(book) },
                )
            }

            if (books.isNotEmpty() && hasNextPage) {
                // LoadingIndicator
            }
        }

        LazyLoad(
            state = lazyListState,
            loadMore = { param -> loadMore(param) },
            loadMoreParam = loadMoreParam
        )
    }
}

SoilDataBoundary?

@Composable
@Composable
fun <T> SoilDataBoundary(
    state: DataModel<T>,
    modifier: Modifier = Modifier,
    fallback: SoilFallback = SoilFallbackDefaults.default(),
    content: @Composable (T) -> Unit,
) {
    val coroutineScope = rememberCoroutineScope()

    // 에러 상태 처리: 에러 발생 시 fallback UI 표시 및 재시도 지원
    ErrorBoundary(
        fallback = fallback.errorFallback,
        onReset = {
            coroutineScope.launch {
                state.performResetIfNeeded() // Query는 refresh, Subscription은 reset
            }
        },
        modifier = modifier,
    ) {
        로딩 상태 처리: 데이터 로딩 중일 때 fallback UI 표시
        Suspense(fallback = fallback.suspenseFallback) {
            // 데이터 준비 대기: 데이터 로딩 완료되면 content 렌더링
            Await(state = state, content = content)
        }
    }
}

위와 같이 Soil을 사용할때 주로 쓰게 되는 구조를 템플릿화 한 것으로, Loading, Error, Success 등의 화면의 상태를 고차 컴포넌트(HOC) 패턴을 활용하여 표현한다.

따라서 UiState에 화면의 상태를 수동으로 관리할 필요가 없어졌다.

HOC 패턴은 컴포넌트를 감싸서 기능을 추가하는 래퍼를 사용하는 패턴으로, 로딩이나 에러 처리 같은 공통 로직을 여러 화면에서 재사용할 수 있다.

또한 Soil 에서 지원하는 LazyLoad를 사용하여 별다른 공수 없이 무한 스크롤을 간편하게 구현할 수 있었다.

아마도 리액트 진영의 자주 사용하는 라이브러리에 영감을 받아 만들어진 것으로 추측된다.

기존 코드(ViewModel)

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val searchBooksUseCase: SearchBooksUseCase,
    combineBooksWithFavoritesUseCase: CombineBooksWithFavoritesUseCase,
) : ViewModel() {
    // ...

    private val _searchResults = MutableStateFlow<List<Book>>(persistentListOf())

    val searchBooks: StateFlow<ImmutableList<BookUiModel>> = combineBooksWithFavoritesUseCase(_searchResults)
        .map { books ->
            books.map { book ->
                book.toUiModel()
            }.toImmutableList()
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000L),
            initialValue = persistentListOf(),
        )

    // 사용자 액션에 대한 처리를 중앙화 
    fun onAction(action: SearchUiAction) {
        when (action) {
            is SearchUiAction.OnBookClick -> navigateToBookDetail(action.book)
            is SearchUiAction.OnSearchClick -> searchBook(action.query)
            is SearchUiAction.OnClearClick -> clearQuery()
            is SearchUiAction.OnLoadMore -> loadMore()
            is SearchUiAction.OnRetryClick -> retry()
            is SearchUiAction.OnSortClick -> toggleSortType()
        }
    }

    private fun navigateToBookDetail(book: BookUiModel) {
        viewModelScope.launch {
            _uiEvent.send(SearchUiEvent.NavigateToDetail(book))
        }
    }

    private fun searchBook(query: String) {
        if (query.isBlank()) return

        viewModelScope.launch {
            val currentState = _uiState.value
            val isFirstPage = query != currentState.currentQuery

            if (isFirstPage) {
                _searchResults.update { persistentListOf() }
                _uiState.update { state ->
                    state.copy(
                        searchState = SearchState.Loading,
                        currentQuery = query,
                        currentPage = 1,
                        isLastPage = false,
                        totalCount = 0,
                    )
                }
            } else {
                _uiState.update { it.copy(footerState = FooterState.Loading) }
            }

            val page = if (isFirstPage) 1 else currentState.currentPage

            searchBooksUseCase(
                query = query,
                sort = currentState.sortType.value,
                page = page,
                size = PAGE_SIZE,
                currentBooks = _searchResults.value,
            ).onSuccess { result ->
                _searchResults.update { result.books }
                _uiState.update { state ->
                    state.copy(
                        searchState = SearchState.Success,
                        footerState = FooterState.Idle,
                        currentPage = result.nextPage,
                        isLastPage = result.isEnd,
                        totalCount = result.totalCount,
                    )
                }
            }.onFailure { exception ->
                if (isFirstPage) {
                    _uiState.update { state ->
                        state.copy(
                            searchState = SearchState.Error(handleException(exception)),
                            footerState = FooterState.Idle,
                        )
                    }
                } else {
                    _uiState.update { state ->
                        state.copy(
                            footerState = FooterState.Error(handleException(exception)),
                        )
                    }
                }
            }
        }
    }

    private fun clearQuery() {
        _uiState.value.queryState.clearText()
    }

    private fun loadMore() {
        val currentState = _uiState.value
        if (currentState.isLastPage || currentState.footerState is FooterState.Loading) {
            return
        }

        searchBook(currentState.currentQuery)
    }

    private fun retry() {
        val query = _uiState.value.currentQuery
        if (query.isNotBlank()) {
            searchBook(query)
        }
    }

    private fun toggleSortType() {
        //...
    }
}

// 검색 리스트와 즐겨찾기 도서를 조합 
class CombineBooksWithFavoritesUseCase @Inject constructor(
    private val repository: BookRepository,
) {
    operator fun invoke(booksFlow: Flow<List<Book>>): Flow<List<Book>> {
        return combine(
            booksFlow,
            repository.favoriteBooks,
        ) { books, favoriteBooks ->
            books.map { book ->
                val isFavorite = favoriteBooks.any { it.isbn == book.isbn }
                book.copy(isFavorite = isFavorite)
            }
        }
    }
}

Soil Query 적용 후

context(context: SearchScreenContext)
@Composable
fun SearchPresenter(
    eventFlow: EventFlow<SearchScreenEvent>,
    queryState: TextFieldState,
    currentQuery: String,
    sortType: SortType,
    searchResults: ImmutableList<BookUiModel>,
    hasNextPage: Boolean,
    onQueryChange: (String) -> Unit,
    onSortChange: (SortType) -> Unit,
): SearchUiState = providePresenterDefaults {
    EventEffect(eventFlow) { event ->
        Logger.d("SearchPresenter: Received event: $event")
        when (event) {
            is SearchScreenEvent.Search -> {
                if (event.query.isNotBlank()) {
                    onQueryChange(event.query)
                } else {
                    Logger.d("SearchPresenter: Query is blank, ignoring")
                }
            }
            is SearchScreenEvent.ClearSearch -> {
                queryState.clearText()
                onQueryChange("")
            }
            is SearchScreenEvent.ToggleSort -> {
                onSortChange(sortType.toggle())
            }
        }
    }

    // 갱신된 UiState를 반환 
    SearchUiState(
        currentQuery = currentQuery,
        sortType = sortType,
        searchResults = searchResults,
        hasNextPage = hasNextPage,
    )
}

그래서 적용해서 뭐가 좋아진건데?

기존 구현에선 불가능한 검색 결과 목록의 효율적인 최신화가 가능하다.

사용자가 많은 커뮤니티 게시판 내에 글 목록을 가정해보도록 하자.

새로운 글이 계속 올라오는 상황에서, 글 목록에 대한 최신화가 필요한 경우
보통 Android 앱 개발할 때 떠오르는 방법은 3가지이다.

  1. Pull to Refresh
  2. 해당 탭에 들어올 때마다 최신화를 위해 API 재호출
  3. 폴링

하지만 세 방법 다 단점이 존재한다.

Pull to Refresh 는 우선 사용자가 인지하기 어려우며(별도의 안내가 필요), 사용자가 수동으로 갱신하는 방식이다.

해당 탭에 들어올 때마다 API 호출을 하는 경우, 무분별하게 API가 호출이 될 수 있고, 경험상 기존 스크롤 위치를 유지하기 어려웠다.

폴링은 고정 주기마다 계속 API를 호출하여 높은 리소스를 담보로 실시간성을 제공하기 위함이므로, 현재 유스케이스랑은 적합하지 않다. 주기가 짧으면 화면이 자주 깜빡거리는 문제도 발생...

Soil Query의 staleTime/gcTime 전략은 이런 문제들을 해결한다.

staleTime을 예를 들어 30초로 설정하면, 탭을 자주 전환해도 30초 이내엔 캐시를 사용해 불필요한 API 호출을 방지한다. 30초가 지나면 자동으로 stale 상태가 되어 다음 화면 진입 시 최신 데이터를 가져온다.

gcTime을 10분으로 설정하면, 탭을 벗어나도 10분간 캐시를 메모리에 보관한다. 사용자가 다시 돌아왔을 때 즉시 캐시를 표시하고 백그라운드에서 최신화하여, 로딩 없이 부드러운 UX를 제공한다.

결과적으로 사용자 행동 기반의 적절한 최신화가 가능해진다. 자주 보는 동안은 캐시로 빠르게 응답하고, 시간이 지나면 자동으로 최신 데이터를 가져오되, 화면 복귀 시엔 즉시 표시된다. Pull to Refresh 처럼 별도 조작 및 안내가 필요하지 않고, 매번 API를 호출하지도 않으며, 폴링처럼 리소스를 낭비하지도 않는다.

이러한 요구사항을 기존 방식에서 직접 커스텀하여 구현하려면...쉽지 않을 것으로 예상된다.

staleTime, gcTime외에 언급하지 않은 다른 QueryOption들에 대한 설명은 주석으로 대체하도록 하겠다.

/**
 * 데이터가 "신선한" 상태로 유지되는 시간
 * 이 시간 내에는 캐시를 사용하고 API를 재호출하지 않음
 */
val staleTime: Duration

/**
 * 구독자가 없을 때 캐시를 메모리에 보관하는 시간
 * 화면 복귀 시 즉시 표시를 위해 사용
 */
val gcTime: Duration

/**
 * Prefetch 처리의 최대 윈도우 시간
 * 이 시간을 초과하면 prefetch CoroutineScope가 종료됨
 */
val prefetchWindowTime: Duration

/**
 * 두 에러가 동일한지 판단하는 함수
 * 동일하면 errorUpdatedAt을 갱신하지 않고 기존 에러 상태 유지
 */
val errorEquals: ((Throwable, Throwable) -> Boolean)?

/**
 * 에러 발생 시 쿼리 처리를 일시 중지할 기간을 결정
 * null 반환 시 중지하지 않음
 */
val pauseDurationAfter: ((Throwable) -> Duration?)?

/**
 * 네트워크 재연결 시 활성 Query 자동 재검증 여부
 * NetworkConnectivity 제공 시에만 작동
 */
val revalidateOnReconnect: Boolean

/**
 * 윈도우(앱) 포커스 복귀 시 활성 Query 자동 재검증 여부
 * WindowVisibility 제공 시에만 작동
 */
val revalidateOnFocus: Boolean

/**
 * Query에서 에러 발생 시 호출되는 콜백
 * 전역 에러 처리/로깅에 사용
 */
val onError: ((ErrorRecord, QueryModel<*>) -> Unit)?

/**
 * ErrorRelay로 에러를 전파할 때 억제할지 결정
 * true 반환 시 해당 에러는 전파되지 않음
 */
val shouldSuppressErrorRelay: ((ErrorRecord, QueryModel<*>) -> Boolean)?

각 Query 마다 다르게 QueryOption 설정이 가능한가?

그렇다.

현재는 API를 하나만 사용해서 Application 클래스에 전역으로 디폴트 설정을 걸어두었는데, QueryKey에 onConfigureOptions 함수를 오버라이드하여 설정하면 된다.

@Singleton
class DefaultSearchBooksQueryKey @Inject constructor(
    private val ktorClient: BookSearchKtorClient,
) {
    fun create(
        query: String,
        sort: String = "accuracy",
        size: Int = 20,
    ): InfiniteQueryKey<List<BookUiModel>, SearchBooksPageParam> = object : InfiniteQueryKey<List<BookUiModel>, SearchBooksPageParam> {
        override val id = InfiniteQueryId<List<BookUiModel>, SearchBooksPageParam>("search_books_${query}_${sort}_$size")
        override val initialParam = { SearchBooksPageParam(1) }
        override val fetch: suspend QueryReceiver.(param: SearchBooksPageParam) -> List<BookUiModel> = { pageParam ->
            // ...
        }
        override fun onConfigureOptions(): QueryOptionsOverride = { options ->
            options.copy(
                staleTime = 30.seconds, // 30초간 fresh 상태 유지 (API 호출 안함)
                gcTime = 10.minutes     // 10분간 메모리 캐시 유지
            )
        }
    }
}

간편한 설정을 통해 매우 강력한 캐싱 전략을 적용할 수 있다.

즐겨찾기search

구조는 달라졌지만, Soil Query를 직접적으로 적용하진 않게 되었으므로 설명 생략.

도서 상세화면

이하 동문.

결론

기존 토이 프로젝트에 Soil을 적용하여, 구현 방식에 차이점을 확인하고, 적용했을 때 얻을 수 있는 장점들을 코드 레벨로 확인해볼 수 있었다.

Kotlin/Compose 기반의 개발 환경에서도 비동기 상태관리 끝판왕인 Tanstack Query의 레플리카를 사용할 수 있게된 부분이 정말 고무적이라 생각한다.

P.S

1.

아직 적용해보지 못한 DroidKaigi 레포의 Core Architecture Components들도 적용해봐야겠다.

Hilt -> Metro 마이그레이션하여 ScreenContext 패턴 외에 Composable 함수인 Presenter에 Repository를 직접 주입해보고자 한다. 이후 KMP 확장까지!

또한 Navigation3를 적용하여 NavBackStack을 직접 조작하여, 더 유연한 화면 전환 처리들을 경험해봐야겠다. 이제는 안정화 되었겠지?

2.

QueryOption을 통해 staleTime 등을 설정하여 API 호출 Term을 결정하는 경우, 아무래도 캐싱과 관련된 부분이라 프론트팀 내에서만이 아닌, 서버 팀과 논의도 필요할 것 같은데, 프론트 분들이 평소에 Tanstack Query를 적용할 때 어떤 식으로 캐싱전략을 적용하고 있는지 궁금하다. 주위에 물어봐야겠다.

댓글로 답변 주시면 감사하겠습니다 ㅎㅎ

3.

Soil Query를 사이드 프로젝트에 적용하여 아직 사용해보지 않은 QueryOptions들을 극한으로 활용 해보며, 비동기 상태 관리를 보다 유려하게 처리해보고 싶다.

reference)
https://github.com/soil-kt/soil/tree/main/sample
https://github.com/DroidKaigi/conference-app-2025
https://tanstack.com/query/latest
https://tanstack.com/query/latest/docs/framework/react/overview
https://velog.io/@dongchyeon/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-EventBus-%ED%99%9C%EC%9A%A9%EB%B2%95
https://patterns-dev-kr.github.io/design-patterns/hoc-pattern/
https://github.com/DroidKaigi/conference-app-2024

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

0개의 댓글