Notice
Recent Posts
Recent Comments
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

성장기록지

Room을 활용한 쇼핑 상품 북마크 기능 (기능 구현 과정) 본문

개인 프로젝트

Room을 활용한 쇼핑 상품 북마크 기능 (기능 구현 과정)

pengcon 2025. 2. 9. 20:54

이전 글에서 Room 아키텍처에 따라 Entity, Dao, Database를 구성하는 과정을 서술하였다.

이번 글에선 어떻게 기능들을 구현하였는지 과정을 작성하도록 하겠다.

저번 글은 아래 링크에서 참고 가능하다.

https://codinghun.tistory.com/76

 

Room을 활용한 쇼핑 상품 북마크 기능(Room 구조 구현)

쇼핑앱을 구현하며 Room을 통해 로컬 데이터베이스에 상품을 저장하는 기능을 구현하고자 하였다.이전에 공부한 Room의 구조들을 활용하며, MVVM 구조를 깨지 않도록 구현한 과정을 작성하고자 한

codinghun.tistory.com

 

북마크 된 상품 리사이클러뷰에 표시하기

 

viewmodel에 StateFlow<Set<String>>으로 bookmarkedItems를 구성해둔다.

private val _bookMarkedItems = MutableStateFlow<Set<String>>(emptySet())
val bookmarkItems = _bookMarkedItems.asStateFlow()

 

이후 뷰모델 init 시에 getBookmarkShoppingItems()를 활용해 ShoppingItem의 id만 set에 넣어준다.

set으로 넣어주는 이유는 아래에서 contain 연산을 하는데, 시간복잡도를 최적화 하기 위해서다.

init {
    viewModelScope.launch {
        searchRepository.getBookmarkShoppingItems().collect { items ->
            _bookMarkedItems.value = items.map { it.id }.toSet()
        }
    }
}


items는 검색어에 debounce를 적용해 가져온 상품 목록이다.

이것을 bookmarkItems와 combine 하여서 활용하였다.

람다에서 bookmarkedIds로 구성하여 items에 포함된 item들이 contain인지 여부를 통해

최종적으로 ShoppingImtem의 isBookmarked를 결정해주었다.

 @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
    val items: Flow<PagingData<Item>> =
        _searchText
            .debounce(500L)
            .filter { it.isNotEmpty() }
            .flatMapLatest { query ->
                searchRepository.getShoppingItems(query)
            }
            .cachedIn(viewModelScope)

val shoppingItems: Flow<PagingData<ShoppingItem>> = items
    .combine(bookmarkItems) { pagingData, bookmarkedIds ->
        pagingData.map { item ->
            val isBookmarked = bookmarkedIds.contains(item.productId)
            ShoppingItem(
                id = item.productId,
                item = item,
                isBookmarked = isBookmarked
            )
        }
    }
    .cachedIn(viewModelScope)

 

UI에는 아래와 같이 xml에 dataBinding을 활용해 Item의 isBookmarked에 따라 아이콘을 결정해주도록 하였다.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

<data>

    <variable
        name="shoppingItem"
        type="com.example.ishopping.data.model.ShoppingItem" />
    <variable
        name="listener"
        type="com.example.ishopping.util.BookmarkClickListener" />

</data>

 <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:paddingHorizontal="8dp"
        android:paddingVertical="12dp">
// 중략...

    <androidx.appcompat.widget.AppCompatImageButton
            android:id="@+id/btn_shopping_item_buttons"
            isBookmarked="@{shoppingItem.isBookmarked}" //바인딩 어댑터 활용
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="16dp"
            android:background="@drawable/selector_item_like_icon"
            android:onClick="@{()-> listener.onBookmarkButtonClick(shoppingItem)}"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

// 이하 생략..

 

바인딩 어댑터는 다음과 같이 작성하였다.

@BindingAdapter("isBookmarked")
fun AppCompatImageButton.setBookmarkState(isBookmarked: Boolean) {
    isSelected = isBookmarked
}

 

결과적으로 Room에 있는 상품은 아래와 같이 하트 표시가 채워져서 표시되게 하였다.

 


버튼을 클릭하여 상품 북마크 추가/해제 하기

우선 커스텀 클릭 리스너를 구성해주었다.

코드를 처음 보는 사람도 어댑터에서의 동작을 추론할 수 있게 fun interface를 통한 상위 클릭 리스너로 구성하였다.

fun interface BookmarkClickListener {
    fun onBookmarkButtonClick(shoppingItem: ShoppingItem)
}

 

그다음 Activity와 Fragment에서 Adapter에 람다를 통해 클릭리스너를 전달하였다. 

(참고: fun interface는 람다로 전달할 수 있다.)

@AndroidEntryPoint
class SearchActivity : AppCompatActivity() {
    private lateinit var binding: ActivitySearchBinding
    private val viewModel by viewModels<SearchViewmodel>()
    private val pagingAdapter =
        SearchShoppingItemAdapter(ShoppingItemComparator){
            viewModel.onBookmarkButtonClick(it)
        }
 //생략..

 

listener의 onBookmarkButtonClick()은 람다를 통해 viewmodel의 onBookmarkButtonClick()을 불러주었다.

isBookmarked에 따라서 버튼을 클릭 시 insertBookmarkItem()을 호출하거나

deleteBookmarkItem()을 호출하도록 해주었다.

fun onBookmarkButtonClick(shoppingItem: ShoppingItem)  {
    if (shoppingItem.isBookmarked) {
        removeBookmark(shoppingItem)
    } else {
        addBookmark(shoppingItem)
    }
}

fun addBookmark(shoppingItem: ShoppingItem) {
    viewModelScope.launch(Dispatchers.IO) {
        searchRepository.insertBookmarkItem(shoppingItem.copy(isBookmarked = true))
    }
}

fun removeBookmark(shoppingItem: ShoppingItem) {
    viewModelScope.launch(Dispatchers.IO) {
        searchRepository.deleteBookmarkItem(shoppingItem)
    }
}

 

 

이후 Adapter에서는 리스너를 DataBinding에 전달해주었다.

class SearchShoppingItemAdapter(
    diffCallback: DiffUtil.ItemCallback<ShoppingItem>,
    private val listener: BookmarkClickListener // 람다로 가져온 리스너
) :
    PagingDataAdapter<ShoppingItem, SearchShoppingItemViewHolder>(diffCallback) {
    // 중략..

    class SearchShoppingItemViewHolder(
        private val binding: ItemSearchShoppingItemBinding,
        private val listener: BookmarkClickListener
    ) :
        RecyclerView.ViewHolder(binding.root) {
        fun bind(shoppingItem: ShoppingItem) {
            binding.shoppingItem = shoppingItem
            binding.listener = listener
        }
  // 생략..

 

xml에선 onClick 시 Listener의 함수를 호출하도록 하였다.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

<data>

    <variable
        name="shoppingItem"
        type="com.example.ishopping.data.model.ShoppingItem" />
    <variable
        name="listener"
        type="com.example.ishopping.util.BookmarkClickListener" />

</data>

 <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:paddingHorizontal="8dp"
        android:paddingVertical="12dp">
// 중략...

    <androidx.appcompat.widget.AppCompatImageButton
            android:id="@+id/btn_shopping_item_buttons"
            isBookmarked="@{shoppingItem.isBookmarked}" 
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="16dp"
            android:background="@drawable/selector_item_like_icon"
            android:onClick="@{()-> listener.onBookmarkButtonClick(shoppingItem)}" // 리스너의 함수 활용
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

// 이하 생략..

 

실행 예시

 

북마크 추가

 

 

북마크 제거

 

 

구현 후기

기능 구현 자체는 어렵지 않았으나, MVVM에 맞게 적용하고

처음봐도 어느정도 이해가 가는 코드를 구현하느라 꽤나 많은 시간이 걸렸다.

그래도 고민한 덕에 어느정도 성장을 한 것 같다.