Room을 알아보자!

Hanseul Lee·2022년 8월 30일
0

Jetpack을 공부하자!

목록 보기
1/5

Room은 무엇일까?

안드로이드에서 앱이 종료되어도 데이터를 관리하는 방법에는 다음 것들이 있다.

  • Shared Preferences → 앱의 간단한 설정값을 저장할 수 있다.
  • SQLite → 다량의 데이터를 저장할 수 있다.
  • Room

왜 사용할까?

그런데 SQLite는 사용하기 어렵고 속도 등에서 까다롭다. 이 SQLite을 안전하고 편하고 또 쉽게 사용할 수 있게 해주는 라이브러리가 바로 Jetpack에 포함된 Room이다! 왜냐하면 Room은 SQLite에 추상화된 레이아웃을 씌워서 제공하는 라이브러리기 때문에 SQLite의 모든 기능을 활용하면서 데이터베이스에 능숙한 접근이 가능하다.

공식 문서는 SQLite보다 Room을 쓰는 게 좋다고 권장하는데, 그 이유를 세 가지로 말한다.

  1. SQL 쿼리 컴파일 타임 검증
  2. 반복적이고 오류가 발생하기 쉬운 상용구 코드를 최소화하는 주석
  3. 간소화된 데이터베이스 마이크레이션 경로

Room의 컴포넌트(구성 요소)

Entities

데이터베이스 테이블

@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)

참고로 id를 생성할 때, 아래와 같이 autoGenerate를 사용하면 자동으로 입력해줘서 매우 편하다.

@PrimaryKey(autoGenerate = true) val id: Int = 0

이렇게 테이블 이름을 따로 지정할 수도 있다.

@Entity(tableName = "user_table")
data class User(
    @PrimaryKey(autoGenerate = true)
    val id: Int,
    val firstName: String,
    val lastName: String,
    val age: Int
)

Data Access Object (DAO)

데이터를 삽입(Insert), 삭제(Delete), 업데이트(Update), 조회(Query 활용)할 수 있도록 메서드를 제공하는 객체

@Dao
interface UserDao {
    @Query("SELECT * FROM user")
    fun getAll(): List<User>

    @Query("SELECT * FROM user WHERE uid IN (:userIds)")
    fun loadAllByIds(userIds: IntArray): List<User>

    @Query("SELECT * FROM user WHERE first_name LIKE :first AND " +
           "last_name LIKE :last LIMIT 1")
    fun findByName(first: String, last: String): User

    @Insert
    fun insertAll(vararg users: User)

    @Delete
    fun delete(user: User)
}

추가로 Insert를 할 때 다음처럼 OnConflictStrategy를 주석으로 넣어서 처리하면 데이터 삽입 시 충돌을 방지할 수 있다.

@Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun addUser(user: User)

Room Database

데이터베이스를 가지고 있고, 연결을 위해 액세스 지점 역할을 하는 데이터베이스 객체

@Database(entities = [User::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

데이터베이스는 여러 개의 entities를 관리할 수 있기 때문에 Array로 전달된다. 여러 개의 entities를 넣을 거라면 ,로 추가하자.

그리고 version은 앱을 업그레이드해 entity의 구조가 바뀌었을 때 데이터의 형태를 구분하기 위해 사용한다. 데이터 형태가 다를 경우 마이그레이션을 해야하는데, 이 마이그레이션이 필요한지 여부를 확인하기 위해 버전을 사용한다. 앱을 릴리즈해서 많은 사람들이 사용하고 있을 때 쓰면 된다.

exportSchema는 데이터를 폴더로 내보내는지의 여부를 관리하는 변수다. 기본값은 true고 이때 데이터베이스 스키마를 지정된 폴더로 내보낸다. 버전 기록을 유지할 때 활용된다. 때문에 메모리 내 전용데이터베이스와 같이 버전 기록을 유지하지 않는 경우에는 false로 하자.

Room Database는 데이터베이스와 연결된 DAO를 앱에 제공한다. 그래서 앱은 DAO를 써서 테이블(Entities)에 접근해 데이터를 삽입, 조회, 업데이트, 삭제할 수 있다.


위와 같이 기본 컴포넌트를 정의했다면 인스턴스를 만드는 코드를 살펴보자.

val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java, "database-name"
        ).build()

덧붙여 알아두면 좋은 주의점이 있다. 데이터의 양이 많거나 다른 앱을 사용하는 경우에 상당한 시간이 걸릴 수 있다. 대략 5초 정도 넘어가면 ANR이 발생되고 앱이 종료된다. 이런 점을 방지 하기 위해 시간이 많이 걸릴 수 있는 데이터 API나 네트워크 API는 메인 스레드, 그러니까 UI 스레드에서 사용하지 않도록 해야 한다.

하지만 실습 등의 이유로 메인 스레드에서 사용할 수도 있는데, 이런 경우에는 다음과 같이 allowMainThreadQueries를 활용해 빌드하도록 하자. 이렇게 되면 메인 스레드에서도 db 접근을 해도 좋다. 하지만 웬만하면 이렇게 하지 않는 게 좋다는 걸 꼭 알아두자.

val db = Room.databaseBuilder(
            applicationContext,
            UserProfileDatabase::class.java,
            "user_profile"
        ).allowMainThreadQueries().build()

데이터베이스와 상호작용하는 코드는 다음과 같다.

val userDao = db.userDao()
val users: List<User> = userDao.getAll()

클린한 코드를 위한 Repository

주의하자. Repository는 Room의 기본 구성요소는 아니다! 하지만 제목과 같이 클린한 코드를 위해서는 필요한 요소다. 코드 분리와 아키텍쳐를 고려했을 때 권장되는 Repository는 추상 클래스로, multiple data sources에 접근할 수 있게 해준다.
거두절미하고 코드로 보자.

class UserRepository(private val userDao: UserDao) {

    val realAllData: LiveData<List<User>> = userDao.readAllData()

    suspend fun addUser(user: User) { // 코틀린 활용을 위해 suspend 함수를 사용했다
        userDao.addUser(user)
    }
}

dependencies 추가

// 22.08.30 ver.

dependencies {
    val room_version = "2.4.3"

    implementation("androidx.room:room-runtime:$room_version")
    annotationProcessor("androidx.room:room-compiler:$room_version")

    // To use Kotlin annotation processing tool (kapt)
    kapt("androidx.room:room-compiler:$room_version")
    // To use Kotlin Symbol Processing (KSP)
    ksp("androidx.room:room-compiler:$room_version")

    // optional - Kotlin Extensions and Coroutines support for Room
    implementation("androidx.room:room-ktx:$room_version")

    // optional - RxJava2 support for Room
    implementation("androidx.room:room-rxjava2:$room_version")

    // optional - RxJava3 support for Room
    implementation("androidx.room:room-rxjava3:$room_version")

    // optional - Guava support for Room, including Optional and ListenableFuture
    implementation("androidx.room:room-guava:$room_version")

    // optional - Test helpers
    testImplementation("androidx.room:room-testing:$room_version")

    // optional - Paging 3 Integration
    implementation("androidx.room:room-paging:2.5.0-alpha03")
}

cf. kapt 싱크하는 방법

  1. 프로젝트 단 build.gradle의 plugins에 다음을 추가한다.

    // Created 28 July 2022.
    
    plugins {
      id("org.jetbrains.kotlin.kapt") version "1.7.20-Beta"
    }
  2. 모듈 단 build.gradle의 plugins에 다음을 추가한다.

    id "org.jetbrains.kotlin.kapt"
  3. dependencies에 추가한다.

    kapt("androidx.room:room-compiler:2.4.3")

퀴즈로 점검해보자!

Use Room for data persistence | Android Developers (영어버전)
데이터 지속성을 위해 Room 사용 (한국어버전)

퀴즈의 정답을 보자

한국어 버전으로 풀었다..ㅎㅎㅎ

DAO의 인스턴스는 AppDatabase 클래스에서 참조한다! 순간 AppDatabase가 뭔지 헷갈렸는데 RoomDatabse가 바로 AppDatabse 클래스를 정의한다는 걸 기억하자.

synchronized()를 활용한 데이터베이스 생성코드는 다음과 같다.

fun getDatabase(context: Context): UserDatabase {
            val tempInstance = INSTANCE
            if (tempInstance != null) { // 지금 우리 데이터베이스가 준비되었을 때, 즉 존재할 때
                return tempInstance
            }
            synchronized(this) { // 지금 우리 데이터베이스가 null로 준비되지 않았을 때
                // 싱글톤의 중복생성을 방지하기 위해서 synchronized을 사용했다.
                // 여러 스레드에서 동시에 이곳에 안전하게 액세스할 수 있기 때문이다.
                // 여러 개의 인스턴스가 작동하게 되면 퍼포먼스에 있어 굉장히 고비용이다.
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    UserDatabase::class.java,
                    "user_database"
                ).build()
                INSTANCE = instance
                return instance
            }
        }

뷰 모델에 관한 참조를 보유하는 게 아니라 데이터 레이어와 뷰모델 레이어 사이에서 서로를 중재한다.

추가자료

TIL) 230404 -> Room 라이브러리

코드만 보기

Dendencies

// 프로젝트 단
ext {
		room_version = '2.5.0'
}

// app 단
    implementation "androidx.room:room-runtime:$room_version"
    kapt "androidx.room:room-compiler:$room_version"
    implementation "androidx.room:room-ktx:$room_version"

Entity

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity
data class Item(
   @PrimaryKey(autoGenerate = true)
   val id: Int = 0,
   @ColumnInfo(name = "name")
   val itemName: String,
   @ColumnInfo(name = "price")
   val itemPrice: Double,
   @ColumnInfo(name = "quantity")
   val quantityInStock: Int
)

Dao

@Dao
interface ItemDao {
    @Insert(onConflict = OnConflictStrategy.IGNORE)     // 기존 item이 존재해 충돌이 났을 때 새 item은 무시한다
    suspend fun insert(item: Item)

    @Update
    suspend fun update(item: Item)

    @Delete
    suspend fun delete(item: Item)

    @Query("SELECT * from item WHERE id = :id")
    fun getItem(id: Int): Flow<Item>

    @Query("SELECT * from item ORDER BY name ASC")
    fun getItems(): Flow<List<Item>>
}

Database

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
           }
       }
   }
}

Application

import android.app.Application
import com.example.inventory.data.ItemRoomDatabase

class InventoryApplication : Application(){
   val database: ItemRoomDatabase by lazy { ItemRoomDatabase.getDatabase(this) }
}

ViewModel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.example.inventory.data.Item
import com.example.inventory.data.ItemDao
import kotlinx.coroutines.launch

class InventoryViewModel(private val itemDao: ItemDao) : ViewModel() {

    private fun insertItem(item: Item) {
        viewModelScope.launch {
            itemDao.insert(item)
        }
    }

    private fun getNewItemEntry(itemName: String, itemPrice: String, itemCount: String): Item {
        return Item(
            itemName = itemName,
            itemPrice = itemPrice.toDouble(),
            quantityInStock = itemCount.toInt()
        )
    }

    fun addNewItem(itemName: String, itemPrice: String, itemCount: String) {
        val newItem = getNewItemEntry(itemName, itemPrice, itemCount)
        insertItem(newItem)
    }

    // text가 올바르게 채워져 있는지 확인
    fun isEntryValid(itemName: String, itemPrice: String, itemCount: String): Boolean {
        if (itemName.isBlank() || itemPrice.isBlank() || itemCount.isBlank()) {
            return false
        }
        return true
    }

}

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

화면(Fragment)에 ViewModel 적용

import android.content.Context.INPUT_METHOD_SERVICE
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.example.inventory.data.Item
import com.example.inventory.databinding.FragmentAddItemBinding

/**
 * Fragment to add or update an item in the Inventory database.
 */
class AddItemFragment : Fragment() {

    private val navigationArgs: ItemDetailFragmentArgs by navArgs()

    private val viewModel: InventoryViewModel by activityViewModels {
        InventoryViewModelFactory(
            (activity?.application as InventoryApplication).database
                .itemDao()
        )
    }

    lateinit var item: Item

    // Binding object instance corresponding to the fragment_add_item.xml layout
    // This property is non-null between the onCreateView() and onDestroyView() lifecycle callbacks,
    // when the view hierarchy is attached to the fragment
    private var _binding: FragmentAddItemBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentAddItemBinding.inflate(inflater, container, false)
        return binding.root
    }

    private fun isEntryValid(): Boolean {
        return viewModel.isEntryValid(
            binding.itemName.text.toString(),
            binding.itemPrice.text.toString(),
            binding.itemCount.text.toString()
        )
    }

    private fun addNewItem() {
        if (isEntryValid()) {
            viewModel.addNewItem(
                binding.itemName.text.toString(),
                binding.itemPrice.text.toString(),
                binding.itemCount.text.toString(),
            )
            val action = AddItemFragmentDirections.actionAddItemFragmentToItemListFragment()
            findNavController().navigate(action)
        }
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.saveAction.setOnClickListener {
            addNewItem()
        }
    }

    /**
     * Called before fragment is destroyed.
     */
    override fun onDestroyView() {
        super.onDestroyView()
        // Hide keyboard.
        val inputMethodManager = requireActivity().getSystemService(INPUT_METHOD_SERVICE) as
                InputMethodManager
        inputMethodManager.hideSoftInputFromWindow(requireActivity().currentFocus?.windowToken, 0)
        _binding = null
    }
}

참고자료

Android 개발자 문서 “Save data in a local database using Room”

유튜브 “모던 안드로이드 아키텍쳐 - Room”

유튜브 “ROOM Database - #1 Create Database Schema”

gradle 공식 문서 “org.jetbrains.kotlin.kapt”

코틀린 공식 문서 “Using kapt”

0개의 댓글