Dao 쿼리에서 Kotlin 값 참조하기

@Entity
data class Schedule(
    @PrimaryKey val id: Int,
    @NonNull @ColumnInfo(name = "stop_name") val stopName: String,
    @NonNull @ColumnInfo(name = "arrival_time") val arrivalTime: Int
)

위 데이터 클래스에서 컬럼이 스네이크 케이스로 명명되어있지만, 코틀린에서는 카멜 케이스로 명명하기 때문에 @ColumnInfo 주석을 활용해 이름을 지정했다.

@Dao
interface ScheduleDao {
    @Query("SELECT * FROM schedule WHERE stop_name = :stopName ORDER BY arrival_time ASC")
    fun getByStopName(stopName: String): List<Schedule>
}

그리고 Dao 쿼리에서 값을 참조하기 위해 :를 사용한다.

ViewModelFactory

ViewModelFactoryViewModel 인스턴스를 생성하는 데 사용되는 클래스다. ViewModelProvider를 사용하여 ViewModel 인스턴스를 만들고 관리한다.

ViewModelFactoryViewModel의 인스턴스화를 위해 필요한 데이터 또는 객체를 전달할 수 있도록 도와준다. 그래서 ViewModel이 생성될 때마다 인스턴스화해야 하는 클래스 또는 데이터가 있을 때 유용하다. 예를 들어 ViewModelFactory를 사용하여 데이터베이스 인스턴스를 ViewModel에 전달하면 ViewModel은 데이터베이스를 사용하여 데이터를 검색하거나 업데이트 할 수 있다.

일반적으로 앱의 Application 클래스에서 인스턴스화되며, 필요한 경우에는 액티비티나 프래그먼트에서 사용되기 때문에 이를 통해 ViewModel의 생명주기와 관련된 문제를 해결할 수 있다.

  • ViewModelFactory의 장점은 다음과 같이 정리할 수 있다.
    • ViewModel의 생성 및 관리 단순화
      ViewModel 인스턴스를 생성하고 필요에 따라 관리하는 작업이 단순화된다. ViewModel의 생명주기와 관련된 문제를 처리할 필요가 없으므로 코드를 더 간결하고 읽기 쉽게 짤 수 있다.
    • 쉬운 의존성 주입
      ViewModel 인스턴스에 필요한 의존성을 주입할 수 있다. 이를 통해 ViewModel은 더 모듈화되고 유지보수가 용이해진다.
    • 테스트 용이성 향상
      테스트에서 ViewModel 인스턴스에 필요한 의존성을 제공할 수 있어 테스트를 더 쉽게 할 수 있다.
    • 코드의 재사용성 상승
      ViewModel 인스턴스 생성과 관리에 대한 코드를 중앙에서 관리할 수 있기 때문에 코드의 재사용성이 높아지며, 유지보수가 더 쉬워진다.
  • 따라서 ViewModelFactory를 사용하지 않을 때는 다음과 같은 문제점들이 생긴다.
    • ViewModel 생명주기 문제
    • 코드 중복
    • 테스트 용이성 저하
    • 어려운 의존성 주입

ViewModelFactory를 사용하는 법을 코드로 알아보자.

class BusScheduleViewModelFactory(
    private val scheduleDao: ScheduleDao
) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(BusScheduleViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return BusScheduleViewModel(scheduleDao) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

이 코드는 BusScheduleViewModelFactory 클래스에 전달된 클래스가 BusScheduleViewModel 클래스의 하위 클래스인 경우, ScheduleDao 인스턴스를 사용하여 BusScheduleViewModel 인스턴스를 생성한다. 그렇지 않은 경우 IllegalArgumentException을 발생시킨다.

이를 통해 BusScheduleViewModelFactory 클래스가 BusScheduleViewModel 인스턴스 생성을 담당하고, 다른 ViewModel 클래스에 대해서는 예외를 발생시키는 역할을 하게 된다.

즉, ViewModel 인스턴스 생성에 대한 책임을 ViewModelProvider.Factory에서 BusScheduleViewModelFactory로 이전하여, ViewModel 인스턴스 생성과 의존성 주입을 효율적으로 처리할 수 있게 됐다.

  • create() ViewModelProvider가 ViewModel을 요청할 때 호출. 요청된 ViewModel 클래스와 ScheduleDao 인스턴스를 사용하여 ViewModel 인스턴스를 생성하고 반환함.
  • isAssignableFrom() 인자로 전달된 클래스가 다른 클래스의 하위 클래스인지 여부를 반환함. 이 코드에서는 전달된 클래스가 BusScheduleViewModel 클래스의 하위 클래스인지 검사.

그렇다면 항상 ViewModelFactory를 쓰는 게 좋을까? 이 질문의 답은 No다.

ViewModelFactory를 사용하면 성능에 영향을 줄 수도 있다. ViewModelFactory를 사용하면 ViewModel 인스턴스를 생성할 때 추가 오버헤드가 발생하기 때문이다. ViewModel 인스턴스를 자주 생성하거나 생성하는 데 많은 시간이 걸리는 경우에는 성능 저하가 발생할 수 있다는 말이다.

그러니까 정리하면 ViewModel Factory를 사용하는 것이 항상 좋은 것은 아니다. ViewModel 인스턴스를 자주 생성하거나 생성하는 데 많은 시간이 걸리는 경우에는 성능을 저하시킬 수 있기 때문에, 앱의 요구사항을 고려하여 ViewModel Factory를 사용할지 여부를 결정하자.

Room 라이브러리의 Database

Room을 알아보자!

kotlin_app_programming_tutorial/UserDatabase.kt at main · HS0204/kotlin_app_programming_tutorial

위 포스트와 코드에서도 공부를 했지만 하나하나씩 뜯어보며 다시 살펴보자. 우선 전체적인 코드다.

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(entities = [Item::class], version = 1, exportSchema = false)
abstract class ItemRoomDatabase : RoomDatabase() {

   abstract fun itemDao(): ItemDao

   companion object {
       @Volatile
       private var INSTANCE: ItemRoomDatabase? = null
       fun getDatabase(context: Context): ItemRoomDatabase {
           return INSTANCE ?: synchronized(this) {
               val instance = Room.databaseBuilder(
                   context.applicationContext,
                   ItemRoomDatabase::class.java,
                   "item_database"
               )
                   .fallbackToDestructiveMigration()
                   .build()
               INSTANCE = instance
               return instance
           }
       }
   }
}
  • abstract class AppDatabase: RoomDatabase() room 라이브러리가 따로 구현하기 때문에 추상 클래스로 만든다.
  • abstract fun scheduleDao(): ScheduleDao
    Dao에 액세스하는 추상 함수. 데이터베이스는 Dao를 알아야 하기에 이를 반환하는 추상 함수를 선언했다. 하나의 데이터베이스에 Dao 여러 개가 있을 수 있다.
  • companion object 경합 상태나 기타 잠재적 문제를 방지하려고 데이터베이스 인스턴스가 하나만 있는지 확인해야 한다. 이를 위해 인스턴스를 companion object에 저장하고, 기존 인스턴스를 반환하거나 새 인스턴스를 만드는 메서드가 필요하다.
    1. 우선 인스턴스를 만든다.

      @Volatile
      private var INSTANCE: ItemRoomDatabase? = null

      @Volatile 주석을 사용하면 해당 변수의 모든 읽기와 쓰기는 메인 메모리에서 실행하게 된다. 그래서 변수의 값이 최신으로 유지될 수 있고, 다른 스레드에서 활용되고 있더라도 이 변수를 즉각적으로 표시하기 때문에 선언한다.

    2. 인스턴스를 반환하는 메서드를 만든다. 하나만 있으면 되니까 싱글톤이어야 한다.

      fun getDatabase(context: Context): ItemRoomDatabase {
                  return INSTANCE ?: synchronized(this) {
                      val instance = Room.databaseBuilder(
                          context.applicationContext,
                          ItemRoomDatabase::class.java,
                          "item_database"
                      ).fallbackToDestructiveMigration().build()
                      INSTANCE = instance
      
                      return instance
                  }
              }

      엘비스 연산자 ?:를 사용해 인스턴스가 원래 기존에 있는지 아닌지 확인한다. 이미 있다면 기존 인스턴스를 반환하고, 아니라면 데이터베이스 인스턴스를 만들게 된다. 중복 생성을 방지하기 위해 여러 스레드에서 요청되더라도 한 번에 하나만 안전하게 액세스할 수 있는 synchronized()를 사용했고, createFromAsset()를 호출해 기존 데이터를 로드한다.

      fallbackToDestructiveMigration()는 데이터가 손실되지 않도록 이전 스키마의 모든 행을 가져와 새 스키마의 행으로 변환하는 방법을 정의한다.

  • @Datebase 데이터베이스임을 알리는 주석. 엔티티는 여러 개일 수 있고, 배열 형태로 나열된다. 버전은 스키마가 변경될 때마다 증가하도록 하자.

ListAdapter

RecyclerView를 만들 때 기존에는 RecyclerView.Adapter를 사용했지만, item 하나에 변경사항이 일어나면 실시간으로 처리할 때 전체 recyclerView를 새로고침하기 때문에 좋지 않다. 동적으로 list가 변경될 때 성능이 좋지 않다는 말이다.

이런 단점의 대안이 **ListAdapter**다. AsyncListDiffer를 활용해서 이전 데이터 item과 바뀐 데이터 item을 확인해 차이를 알고, 이 차이를 통해 바뀐 item만 업데이트를 하기에 효율적이다. 그러니까 자주 업데이트 되는 데이터를 처리하기 매우 좋다.

kotlin_app_programming_tutorial/PhotoGridAdapter.kt at main · HS0204/kotlin_app_programming_tutorial

위 코드에서 이미 적용을 했었지만 조금 다른 코드를 예시로 다시 보자.

class BusStopAdapter(private val onItemClicked: (Schedule) -> Unit) : ListAdapter<Schedule, BusStopAdapter.BusStopViewHolder>(DiffCallback) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BusStopViewHolder {
        val viewHolder = BusStopViewHolder(
            BusStopItemBinding.inflate(
                LayoutInflater.from( parent.context),
                parent,
                false
            )
        )
        viewHolder.itemView.setOnClickListener {
            val position = viewHolder.adapterPosition
            onItemClicked(getItem(position))
        }
        return viewHolder
    }

    override fun onBindViewHolder(holder: BusStopViewHolder, position: Int) {
        holder.bind(getItem(position))
    }

    class BusStopViewHolder(private var binding: BusStopItemBinding): RecyclerView.ViewHolder(binding.root) {
        @SuppressLint("SimpleDateFormat")
        fun bind(schedule: Schedule) {
            binding.stopNameTextView.text = schedule.stopName
            binding.arrivalTimeTextView.text = SimpleDateFormat(
                "h:mm a").format(
                Date(schedule.arrivalTime.toLong() * 1000)
            )
        }
    }

    companion object {
        private val DiffCallback = object : DiffUtil.ItemCallback<Schedule>() {
            override fun areItemsTheSame(oldItem: Schedule, newItem: Schedule): Boolean {
                return oldItem.id == newItem.id
            }

            override fun areContentsTheSame(oldItem: Schedule, newItem: Schedule): Boolean {
                return oldItem == newItem
            }
        }
    }
}
  • 매개변수 onItemClicked() : setOnClickListener()와 함께 아이템을 클릭했을 때 그 아이템의 위치를 알려준다.
  • onBindViewHolder() : 지정 위치에 뷰를 바인딩한다.
  • ViewHolder() : 뷰에 액세스한다.
  • DiffCallback : ListAdapter가 list를 업데이트할 때 어떤 item이 달라졌는지 확인한다. 이 코드에는 id만 확인하는 메서드(areItemsTheSame), item 자체를 확인하는 메서드(areContetnsTheSame) 두 가지가 있다.

Flow를 사용해 데이터 변경사항 업데이트하기

Flow는 데이터 스트림을 반환하는 기능으로, 이를 이용해서 데이터베이스 INSERT와 같은 실시간 업데이트에 대응해보자.

  • Dao
    @Dao
    interface ScheduleDao {
    
        @Query("SELECT * FROM schedule ORDER BY arrival_time ASC")
        fun getAll(): List<Schedule>
    
        @Query("SELECT * FROM schedule WHERE stop_name = :stopName ORDER BY arrival_time ASC")
        fun getByStopName(stopName: String): List<Schedule>
    }
    이 Dao를 사용하면 데이터가 변경되어도 동적 업데이트를 할 수 없다. 왜냐하면 반환값이 List로, Dao에서 한 번만 반환이 되기 때문이다. 따라서 다음과 같이 Flow를 활용해 동적으로 업데이트할 수 있도록 바꿔보자.
    @Dao
    interface ScheduleDao {
    
        @Query("SELECT * FROM schedule ORDER BY arrival_time ASC")
        fun getAll(): Flow<List<Schedule>>
    
        @Query("SELECT * FROM schedule WHERE stop_name = :stopName ORDER BY arrival_time ASC")
        fun getByStopName(stopName: String): Flow<List<Schedule>>
    }
    반환 유형이 flow기 때문에 따로 suspend를 명시할 필요는 없다.
  • ViewModel Dao에 액세스하는 부분도 바꿔준다.
    /*
    class BusScheduleViewModel(private val scheduleDao: ScheduleDao): ViewModel() {
    
        fun fullSchedule(): List<Schedule> = scheduleDao.getAll()
    
        fun scheduleForStopName(name: String): List<Schedule> = scheduleDao.getByStopName(name)
    
    }
    */
    class BusScheduleViewModel(private val scheduleDao: ScheduleDao): ViewModel() {
    
        fun fullSchedule(): Flow<List<Schedule>> = scheduleDao.getAll()
    
        fun scheduleForStopName(name: String): Flow<List<Schedule>> = scheduleDao.getByStopName(name)
    
    }
  • 화면 (Fragment)
    GlobalScope.launch(Dispatchers.IO) {
                // submitList() is a call that accesses the database. To prevent the
                // call from potentially locking the UI, you should use a
                // coroutine scope to launch the function. Using GlobalScope is not
                // best practice, and in the next step we'll see how to improve this.
                busStopAdapter.submitList(viewModel.fullSchedule())
            }
    주석에서와 같은 문제가 있기 때문에 다음과 같이 바꿔준다.
    lifecycle.coroutineScope.launch {
                viewModel.fullSchedule().collect() {
                    busStopAdapter.submitList(it)
                }
            }

0개의 댓글