Android Room Database

timothy jeong·2021년 12월 6일
0

Android with Kotlin

목록 보기
62/69

SQLite 를 사용해서 데이터를 저장한다는 사실은 알았다. 이러한 SQLite 를 위한 라이브러리가 Room 이다. Room 은 SQLite 위에서 작동하며, SQLite 의 장점을 이용하는 동시에 코드를 더욱 간결하게 사용할 수 있게된다.

이러한 Room 을 이용하는 앱들은 보통 MVVM 구조를 갖춘 앱들이다. MVVM 이란 Model-View-ViewModel 을 줄인 말로써,

  • View 는 UI 와 관련된 코드만 책임지는 객체이다.
  • ViewModel 은 비즈니스 로직과 뷰에 나타날 데이터를 책임지는 객체이다.
  • Model 은 앱의 기반이 되는 데이터를 조작하는 책임을 지는 객체를 의미한다. 즉, DAO 를 의미한다.

단어 추측 앱의 경우 View 와 ViewModel 이 적용되었지만, Model 의 개념이 적용되지는 않았다. 즉, 데이터베이스에 접근하여 정보를 저장하고 추츨하는 과정이 없었다.

의존성

앱 그레이들 스크립트에 추가

buildscript {
    ...
    ext.lifecycle_version = "2.3.1"
    ext.room_version = "2.4.0-beta01"
    // m1 칩 맥북은 이 2.4.0-alpha03 이상의 버전을 이용해야한다.
}

모듈 그레이들 스크립트에 추가

... 
    buildFeatures {
        dataBinding true
    }
...

dependencies {
    ...
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
    implementation "androidx.room:room-runtime:$room_version"
    implementation "androidx.room:room-ktx:$room_version"
    ...
}

첫번째 버전

데이터 베이스 코드(Model Part)

구성요소

1. 테이블 구성 객체 : 데이터베이스의 테이블 구조를 담는 데이터 클래스
2. Dao 객체 : 데이터 베이스에 접근하는 함수를 정의한 객체
3. 데이터 베이스 객체 : 데이터 베이스 생성을 담당하는 객체

1. 데이터 클래스

데이터 클래스를 정의하여 데이터 베이스에 등록하면 테이블이 하나 생성되는 것과 같다. 그곳에 데이터 클래스를 insert 하면 이는, row 가 하나 생성되는 것과 같다.

// 테이블 이름 특정하기
@Entity(tableName = "task_Table")
data class Task(

    @PrimaryKey(autoGenerate = true) // pk 지정
    @ColumnInfo(name = "id") // column 정보중 이름 지정
    var taskId: Long = 0L,
    @ColumnInfo(name = "task_name")
    var taskName: String = "",
    @ColumnInfo(name = "task_done")
    var taskDone: Boolean = false
)

만약 Room 라이브러리가 특정 데이터 클래스를 저장하는걸 원치 않는다면, @Ignore 에노테이션을 붙이면 된다.

2. DAO (data access object)

@Dao 에노테이션이 붙은 인터페이스를 정의하면 DAO 로서 동작한다.

@Dao
interface TaskDAO {

    // Insert 에노테이션을 설정하면
    // Room 라이브러리는 이 함수가 Task 데이터를 insert 하는데 필요한 모든 기능을 정의해둔다.
    @Insert
    suspend fun insert(task: Task)

    // 여러 Task 를 insert 하는 함수도 가능
    @Insert
    suspend fun insertAll(tasks: List<Task>)

    @Update
    suspend fun update(task: Task)

    @Delete
    suspend fun delete(task: Task)

    // 이외의 것들은 @Query 를 이용하면 된다.
    @Query("SELECT * FROM task_table WHERE id = :taskId")
    fun get(taskId: Long) : LiveData<Task>
    
    @Query("SELECT * FROM task_table ORDER BY id DESC")
    fun getAll(): LiveData<List<Task>>
}

모든 함수에 suspend 키워드를 붙여서 메인 스레드가 데이터베이스에 접근하는것을 방지했다. 코루틴을 이용하는 것으로, 이렇게 함으로써 메인쓰레드는 UI에 집중하도록 할 수 있다.

하지만 Room 라이브러리를 이용할때 라이브데이터를 반환하는 함수는 자동으로 백그라운드 스레드를 이용하게 되므로 suspend 를 명시해줄 필요가 없다.

3. 데이터 베이스 객체

추상 클래스를 이용헤서 데이터 베이스를 정의한다. 이 ㅋㄹ래스는 데이터 베이스의 이름, 버전, 데이터 베이스를 정의하는 클래스와 인터페이스 정보가 담겨야한다.

// 만약 데이터 베이스 스키마를 변경한다면 version 숫자를 변경해야한다.
// exportSchema 데이터베이스 스키마를 폴더로 저정해서 히스토리를 볼 수 있게 할 것인지.
@Database(entities = [Task::class], version = 1, exportSchema = false)
abstract class TaskDatabase: RoomDatabase() {
    // DAO 인터페이스를 지정
    abstract val taskDao: TaskDAO
}

그리고 데이터베이스 객체를 인스턴스화 하여 반환해야 한다.

@Database(entities = [Task::class], version = 1, exportSchema = false)
abstract class TaskDatabase: RoomDatabase() {
    abstract val taskDao: TaskDAO

    companion object{
        @Volatile
        private var INSTANCE : TaskDatabase? = null

        fun getInstance(context: Context): TaskDatabase {
            synchronized(this) {
                var instance = INSTANCE
                if (instance == null) {
                    instance = Room.databaseBuilder(
                        context.applicationContext,
                        TaskDatabase::class.java,
                        "task_database"
                    ).build()
                    INSTANCE = instance
                }
                return instance
            }
        }
    }

companion object 를 이용하여 TaskDatabase 클래스를 인스턴스화 하지 않아도 getInstance() 함수를 호출할 수 있도록 하였다.

레이아웃

메인 액티비티

<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView 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"
    android:id="@+id/fragmentContainerView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="16dp"
    android:name="com.example.room.TasksFragment"
    tools:context=".MainActivity">

</androidx.fragment.app.FragmentContainerView>

태스크 프레그먼트

<?xml version="1.0" encoding="utf-8"?>
<layout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <data>

    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <EditText
            android:id="@+id/task_name"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="text"
            android:hint="Enter a task name"/>

        <Button
            android:id="@+id/save_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:text="Save Task" />

        <TextView
            android:id="@+id/task"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>
    </LinearLayout>

</layout>

뷰 모델

class TaskViewModel(val dao: TaskDAO): ViewModel() {
    var newTaskName = ""

    // 뷰 모델에서 코루틴을 이용할때는  viewModelScope.launch {..} 를 이용한다.
    fun addTask() {
        viewModelScope.launch {
            val task = Task()
            task.taskName = newTaskName
            dao.insert(task)
        }
    }
}

클래스 생성자가 인자를 받기 때문에 디폴트 뷰모델 프로바이더로는 생성할 수 없다. 뷰모델 팩토리를 만들어야 한다.

class TaskViewModelFactory(private val dao: TaskDAO) 
    : ViewModelProvider.Factory{

    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(TaskViewModel::class.java)) {
            return TaskViewModel(dao) as T
        }
        throw IllegalArgumentException("Unknown ViewModel")
    }
}

프레그먼트 코드

태스크 프레그먼트 코드

class TasksFragment: Fragment() {
    private var _binding: FragmentTaskBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentTaskBinding.inflate(inflater, container, false)
        val view = binding.root

        val application = requireNotNull(this.activity).application
        val dao = TaskDatabase.getInstance(application).taskDao
        val viewModelFactory = TaskViewModelFactory(dao)
        val viewModel = ViewModelProvider(this, viewModelFactory)
            .get(TaskViewModel::class.java)

        binding.viewModel = viewModel

        return view
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

레이아웃 xml 업데이트

<?xml version="1.0" encoding="utf-8"?>
<layout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <data>
        <variable
            name="viewModel"
            type="com.exmaple.room.TaskViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <EditText
            android:id="@+id/task_name"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="text"
            android:hint="Enter a task name"
            android:text="@={viewModel.newTaskName}"/>
<!--        @={} 는 레이아웃에서 viewModel 을 업데이트 할 수 있음을 의미한다. -->
<!--        단순히 코드상에서 변경되는 값을 레이아웃에 반영하는 것은 @{viewModel.newTaskName} 을 이용한다.-->

        <Button
            android:id="@+id/save_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:text="Save Task"
            android:onClick="@{() -> viewModel.addTask()}"/>

        <TextView
            android:id="@+id/task"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>
    </LinearLayout>

</layout>

두번째 버전

DAO 와 라이브 데이터

Dao 언터페이스에서 리턴타입으로 라이브 데이터를 썼다.

@Dao
interface TaskDao { 
    ...
    @Query("SELECT * FROM task_table WHERE taskId = :key")
    fun get(key: Long): LiveData<Tasks>

    @Query("SELECT * FROM task_table ORDER BY taskId DESC")
    fun getAll(): LiveData<List<Tasks>>
}

리턴 타입으로 라이브 데이털르 쓴 함수는 한번 호출되고 나면 항상 데이터베이스를 최신으로 유지하려고 한다. 위의 예제를 예시로 한면, 새로운 Task 가 추가될 때마다 getAll() 은 항상 추가된 값을 가져온다는 것이다.

그런데 그동안 써온 라이브데이터는 LiveData<Data> 식이었는데, 이번에는 라이브 데이터가 List<Data> 를 감싸고 있다. 이러한 데이터 타입을 바로 뷰에 결합시킬 수 있을까? 그건 아무래도 힘들 것이다.

이럴때는 LiveData<List<Task>> 형 데이터를 LiveData<String> 형 데이터로 변경해 주면 뷰에 매핑하기 좋게된다.

이러한 LiveData 내부의 데이터 타입을 바꾸는 작업은 Transformations 클래스의 map 객체를 이용할 수 있다.

map 함수는 LiveData 를 source 파라미터로 받고, source에 mapFuntion 이라는 임의의 함수를 적용시킨 뒤, 그 결과값을 LiveData 형태로 반환하는 함수이다.

@MainThread 
@NonNull 
open static fun <X : Any!, Y : Any!> map(
    @NonNull source: LiveData<X>, 
    @NonNull mapFunction: Function<X, Y>
): LiveData<Y>

mapFunction 이 파라미터에 List<Task> 의 요소를 하나씩 빼내서 원하는 String 으로 바꿔서 반환하는 함수를 넘겨주면 된다. (리사이클러 뷰를 사용하지 않고 TextView 에서 하나의 String 으로 처리할 것이기 때문이다.)

List 에 있는 값들을 하나씩 빼서 조작하는 방법은 여러가지가 있겠지만, 별도의 파라미터 설정 없이 람다 안에서 처리하기에는 fold 와 reduce 를 이용하는 것이 적절하다.

public inline fun <T, R> Iterable<T>.fold(initial: R, operation: (acc: R, T) -> R): R {
    var accumulator = initial
    for (element in this) accumulator = operation(accumulator, element)
    return accumulator
}

fold 와 reduce 함수는 모두 acc 라는 누적 파라미터에 T 를 처리해서 더하고 누적된 파라미터를 리턴하는 형태를 갖고있다. 둘의 차이는 fold 는 최초 누적 파라미터에 값을 초기화하고 시작한다는 것이고, reduce 는 이러한 initial 파라미터의 역할을 list[0] 요소가 대신한다는 데 있다.
(내부적으로 iterator 를 쓰느냐 쓰지 않느냐 등의 추가적인 차이가 있지만, 피상적으로 함수만 사용한다면 다 알필요는 없을 것 같다.)

class TasksViewModel(val dao: TaskDao): ViewModel() {
    ...
    private val tasks = dao.getAll()
    
    val tasksString = Transformations.map(tasks) {
            tasks -> formatTasks(tasks)
    }
    
    private fun formatTasks(tasks: List<Tasks>): String {
        return tasks.fold("") {
                str, item -> str + '\n' + formatTask(item)
        }
    }
    
    private fun formatTask(tasks: Tasks): String {
        var str = "ID: ${tasks.taskId}"
        str += '\n' + "Name: ${tasks.taskName}"
        str += '\n' + "Complete: ${tasks.taskDone}" + '\n'
        return str
    }

라이브데이터 매핑

레이아웃과

...
        <TextView
            android:id="@+id/tasks"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@{viewModel.tasksString}" />
    </LinearLayout>

프레그먼트에 코드 추가

binding.lifecycleOwner = viewLifecycleOwner

데이터베이스 확인하기 (Inspector)

안드로이드 스튜디오(버전 4.1 이상)는 데이터베이스에 어떤 테이블이 있는지, 어떤 리코드가 있는지 확인할 수 있는 기능을 제공한다.

이를 이용하기 위해서는 API 버전 26이상으로 돌아가는 에뮬레이터에서 앱을 실행시키고, view -> ToolWindows -> App Inspection 을 키고, 실행중인 앱 프로세스를 드롭다운 메뉴에서 선택한다. 그러면 실행중인 앱의 데이터베이스가 창에 나타난다.

데이터 수정하기

이렇게 나타난 창에 특정 셀을 더블 클릭하고 새로운 값을 입력, Enter 를 누르면 데이터를 수정할 수 있다. 만약 이때 라이브데이터를 사용중이라면 실행중인 앱에 바로 적용된다.

맞춤 쿼리 실행하기

Database Inspector를 사용하여 앱이 실행되는 동안 앱의 데이터베이스에서 맞춤 SQL 쿼리를 실행할 수도 있다.

(1) open new query 탭을 클릭하고

(2) 쿼리를 입력하고 Run 을 누르면 실핼 결과가 나온다.

profile
개발자

0개의 댓글