데이터 저장 - Room

2024. 7. 31. 02:26[개발]/Kotlin 활용 앱 개발

# Room 개념

- SQLite를 쉽게 사용할 수 있는 DB 객체 매핑 라이브러리

- 쉽게 Query를 사용할 수 있는 API를 제공

- Query 결과를 LiveData로 정해 DB가 변경될 때마다 쉽게 UI 변경 가능

- SQLite보다 Room 사용 권장

 

# Room의 주요 3요소

1) @Database

- 클래스를 DB로 지정하는 annotation(주석)

- RoomDatabase를 상속받은 클래스

- Room.dataabseBuilder를 통해 인스턴스를 생성

 

2) @Entity

- 클래스를 테이블 스키마로 지정하는 annotation

 

3) @Dao

- 클래스를 DAO(Data Access Object)로 지정하는 annotation

- 기본적인 insert, delete, update SQL은 자동 생성 / 복잡한 SQL은 직접 작성

 

# Room 활용 순서

1) gradle 파일 설정

- gradle 파일의 dependencies 안에 아래 내용을 추가

plugins {
		....
    id 'kotlin-kapt'
}
.....

dependencies {

    ......

    def room_version = "2.5.1"
    implementation "androidx.room:room-runtime:$room_version"
    annotationProcessor "androidx.room:room-compiler:$room_version"
    kapt "androidx.room:room-compiler:$room_version"
    // optional - Kotlin Extensions and Coroutines support for Room
    implementation "androidx.room:room-ktx:$room_version"
    // optional - Test helpers
    testImplementation "androidx.room:room-testing:$room_version"
}

 

2) Entity 생성

// 테이블 스키마를 지정하기 위해 @Entity를 작성
@Entity(tableName = "student_table")    // 테이블 이름을 student_table로 지정함
data class Student (
	// @PrimaryKey: 고유 키값 의미
    // 각 Entity는 1개의 Primary Key를 가져야 함(일반적으로 고유 id값으로 설정)
    @PrimaryKey
    // @ColumnInfo: 열의 이름을 변수명과 다르게 할 경우 지정
    @ColumnInfo(name = "student_id") 
    // data class Student의 인자
    val id: Int,
    val name: String
)

 

3) DAO 생성

- DAO는 interface나 abstract class로 정의 필요

- Annotation에 SQL 쿼리를 정의하고 그 쿼리를 위한 메소드를 선언

- 가능한 annotation: @Insert, @Update, #Delete(이하 SQL 쿼리를 작성하지 않아도  컴파일러가 자동 생성), @Query(새로 작성해야 함)

// table의 모든 항목(*)을 불러오는 쿼리를 getAllData() 메서드로 지정, 반환값 = List
@Query("SELECT * from table") fun getAllData() : List<Data>

- @Insert, @Update는 key가 중복될 경우의 오류 처리를 위해 onConflict를 지정 가능

 ① OnConflictStrategy.ABORT: key 충돌 시 종료

 ② OnConflictStrategy.IGNORE: key 충돌 무시

 ③ OnConflictStrategy.REPLACE: key 충돌 시 새로운 데이터로 변경

- @Update, @Delete는 primary key에 해당되는 값을 찾아 변경/삭제 수행

- @Query로 리턴되는 데이터 타입을 LiveData<>로 하면 데이터 업데이트 시 Observer를 통해 즉각 알 수 있음

@Query("SELECT * from table") fun getAllData() : LiveData<List<Data>>

- @Query에 SQL을 정의할 때 메서드의 인자 활용 가능

// student_table에서 name이 sname값인 모든 것을 가져와라
@Query("SELECT * FROM student_table WHERE name = :sname")
// suspend 쓰는 이유: 코루틴을 사용하는 거라서 / 나중에 해당 메서드를 불러올 땐 runBlocking{} 내에서 호출 필요
suspend fun getStudentByName(sname: String): List<Student>
@Dao
// interface나 abstract class 형식으로 지정
interface MyDAO {
    @Insert(onConflict = OnConflictStrategy.REPLACE)  // INSERT, key 충돌이 나면 새 데이터로 교체
    suspend fun insertStudent(student: Student)

    @Query("SELECT * FROM student_table")
    fun getAllStudents(): LiveData<List<Student>>        // LiveData<> 사용

    @Query("SELECT * FROM student_table WHERE name = :sname")   
    suspend fun getStudentByName(sname: String): List<Student>

    @Delete
    suspend fun deleteStudent(student: Student); // primary key is used to find the student

    // ...
}

 

4) Database 생성

 -RoomDatabase를 상속해 자신의 Room 클래스 생성

- 포함되는 Entity들과 DB 버전을 @Database annotation에 지정

- 버전이 기존 DB보다 높으면 DB 오픈 시 migration(RoomDatabase 객체의 addMigration() 메서드)

- DAO를 가져올 수 있는 getter 메서드 만듦

- Room 클래스 인스턴스는 하나만 있으면 되므로 singleton 패턴 사용(Room.databaseBuilder() 사용)

// @Database 안에 Entity들과 version을 지정
@Database(entities = [Student::class, ClassInfo::class, Enrollment::class, Teacher::class], version = 1)

abstract class MyDatabase : RoomDatabase() {
    abstract fun getMyDao() : MyDAO

    companion object {
        private var INSTANCE: MyDatabase? = null
        // 버전1에 대한 버전2 migration
        private val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) { 생략 }
        }
        // 버전2에 대한 버전3 migration
        private val MIGRATION_2_3 = object : Migration(2, 3) {
            override fun migrate(database: SupportSQLiteDatabase) { 생략 }
        }
        fun getDatabase(context: Context) : MyDatabase {
            if (INSTANCE == null) {
                INSTANCE = Room.databaseBuilder(
                    context, MyDatabase::class.java, "school_database")
                    .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
                    .build()
            }
            return INSTANCE as MyDatabase
        }
    }
}

 

5) Migration

Room.databaseBuilder(...).addMigrations(MIGRATION_1_2, MIGRATION_2_3)

private val MIGRATION_1_2 = object : Migration(1, 2) {   // version 1 -> 2
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("ALTER TABLE student_table ADD COLUMN last_update INTEGER")
    }
}

private val MIGRATION_2_3 = object : Migration(2, 3) {   // version 2 -> 3
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("ALTER TABLE class_table ADD COLUMN last_update INTEGER")
    }
}

 

6-1) UI와 연결 - Room

- Repository와 ViewModel 사용 권장

- RoomDatabase 객체에서 DAO 객체를 받아오고 이 DAO 객체의 메서드를 호출해 DB에 접근

myDao = MyDatabase.getDatabase(this).getMyDao()
runBlocking { // (주의) UI를 블록할 수 있는 DAO 메소드를 UI 스레드에서 바로 호출하면 안됨
    myDao.insertStudent(Student(1, "james"))  // suspend 지정되어 있음
}
val allStudents = myDao.getAllStudents() // LiveData는 Observer를 통해 비동기적으로 데이터를 가져옴

 

6-2) UI와 연결 - LiveData

- observe() 메서드를 통해 Observer 지정

- 데이터 변경 때마다observer의 onChanged()가 호출, 데이터를 업데이트

- LiveData<>를 리턴하는 DAO 메서드를 Observer를 통해 비동기적으로 데이터를 받으므로 UI 스레드에서 직접 호출해도 ㅁ문제가 없음

val allStudents = myDao.getAllStudents()
allStudents.observe(this) {   // Observer::onChanged() 는 SAM 이기 때문에 lambda로 대체
    val str = StringBuilder().apply {
            for ((id, name) in it) {
                append(id)
                append("-")
                append(name)
                append("\n")
            }
        }.toString()
    binding.textStudentList.text = str
}

 

# Room Database의 주요 annotation

  1. @Database
    • 데이터베이스 클래스를 정의할 때 사용합니다.
    • 데이터베이스에 포함될 엔티티와 버전을 명시합니다.
  2. @Entity
    • 데이터베이스 내의 테이블을 정의할 때 사용합니다.
    • 클래스 이름이 테이블 이름으로 사용되며, 필드는 컬럼으로 매핑됩니다.
  3. @PrimaryKey
    • 엔티티의 기본 키(primary key)를 정의할 때 사용합니다.
    • 유니크한 값이어야 하며, 데이터베이스 내에서 각 엔티티를 구분하는 데 사용됩니다.
  4. @ColumnInfo
    • 테이블의 컬럼 정보를 세부적으로 정의할 때 사용합니다.
    • 컬럼의 이름, 타입, 인덱스 등을 설정할 수 있습니다.
  5. @Dao
    • 데이터 접근 객체(Data Access Object)를 정의할 때 사용합니다.
    • 데이터베이스의 CRUD(Create, Read, Update, Delete) 연산을 위한 메소드를 포함합니다.
  6. @Insert
    • 데이터를 삽입하는 메소드에 사용합니다.
    • 해당 메소드는 엔티티를 인자로 받아 데이터베이스에 추가합니다.
  7. @Query
    • 복잡한 SQL 쿼리를 실행하는 메소드에 사용합니다.
    • 메소드에 주어진 SQL 쿼리를 실행하여 결과를 반환합니다.
  8. @Update
    • 데이터를 업데이트하는 메소드에 사용합니다.
    • 인자로 받은 엔티티의 데이터로 기존 레코드를 갱신합니다.
  9. @Delete
    • 데이터를 삭제하는 메소드에 사용합니다.
    • 인자로 받은 엔티티를 데이터베이스에서 제거합니다.
  10. @Transaction
    • 메소드가 하나의 트랜잭션으로 실행되어야 함을 나타냅니다.
    • 여러 연산을 하나의 작업으로 묶어 실행할 때 사용합니다.
  11. @ForeignKey
    • 엔티티 간의 외래 키 관계를 정의할 때 사용합니다.
    • 참조 무결성을 유지하는 데 도움을 줍니다.
  12. @Index
    • 특정 컬럼에 인덱스를 생성할 때 사용합니다.
    • 쿼리 성능을 향상시키는 데 유용합니다.

 

# 예제

- 학생 정보(ID, 이름)을 DB에 저장하고 호출하는 앱

 

1) gradle에 설정 추가

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    // 아래 한 줄 추가
    id 'kotlin-kapt'
}
dependencies {

    implementation 'androidx.core:core-ktx:1.10.0'
    implementation 'androidx.appcompat:appcompat:1.6.1'
    implementation 'com.google.android.material:material:1.9.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

	// dependencies에 아래 줄들 추가
    def room_version = "2.5.1"
    implementation "androidx.room:room-runtime:$room_version"
    annotationProcessor "androidx.room:room-compiler:$room_version"
    kapt "androidx.room:room-compiler:$room_version"
    // optional - Kotlin Extensions and Coroutines support for Room
    implementation "androidx.room:room-ktx:$room_version"
    // optional - Test helpers
    testImplementation "androidx.room:room-testing:$room_version"
}

 

2) activity_main.xml 생성

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="5dp"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/edit_student_id"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:ems="10"
        android:hint="ID"
        android:inputType="number"
        app:layout_constraintEnd_toStartOf="@+id/query_student"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintHorizontal_chainStyle="spread_inside"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <EditText
        android:id="@+id/edit_student_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:ems="10"
        android:hint="student name"
        android:inputType="textPersonName"
        app:layout_constraintEnd_toStartOf="@+id/add_student"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintHorizontal_chainStyle="spread_inside"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/edit_student_id" />

    <Button
        android:id="@+id/add_student"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Add Student"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toEndOf="@+id/edit_student_name"
        app:layout_constraintTop_toBottomOf="@+id/query_student" />

    <Button
        android:id="@+id/query_student"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Query Student"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toEndOf="@+id/edit_student_id"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="Result of Query Student"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/edit_student_name" />

    <TextView
        android:id="@+id/textView2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:text="Student List"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/text_query_student" />

    <TextView
        android:id="@+id/text_query_student"
        android:layout_width="0dp"
        android:layout_height="100sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textView" />

    <TextView
        android:id="@+id/text_student_list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginBottom="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textView2" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

3) MainActivity.kt 생성

package com.android.roomexample

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.android.roomexample.databinding.ActivityMainBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

class MainActivity : AppCompatActivity() {

    private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
    // 전역변수로 myDAO 선언
    lateinit var myDao: MyDAO

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

		// DAO를 통해 ROOM DB 인스턴스를 받아옴
        myDao = MyDatabase.getDatabase(this).getMyDao()

        val allStudents = myDao.getAllStudents()
        // observer를 통해 LiveData의 변경을 즉각 반영
        allStudents.observe(this) {
            val str = StringBuilder().apply {
            	// DB안의 모든 학생들에 대해 append(붙이기) 수행
                for ((id, name) in it) {
                    append(id)
                    append("-")
                    append(name)
                    append("\n")
                }
            }.toString()
            binding.textStudentList.text = str
        }

        binding.addStudent.setOnClickListener {
            val id = binding.editStudentId.text.toString().toInt()
            val name = binding.editStudentName.text.toString()
            if (id > 0 && name.isNotEmpty()) {
                CoroutineScope(Dispatchers.IO).launch {
                    myDao.insertStudent(Student(id, name))
                }
            }

            binding.editStudentId.text = null
            binding.editStudentName.text = null
        }

		// Query Studnet 버튼
        binding.queryStudent.setOnClickListener {
            val name = binding.editStudentName.text.toString()
            CoroutineScope(Dispatchers.IO).launch {

                val results = myDao.getStudentByName(name)

                if (results.isNotEmpty()) {
                    val str = StringBuilder().apply {
                        results.forEach { student ->
                            append(student.id)
                            append("-")
                            append(student.name)
                        }
                    }
                    // withContext 쓰는 이유:
                    // DB의 결과에 따라 도는 루틴 자체가 별도의 코루틴 스레드를 통해 동작하는 중이라서
                    // 그것만으로는 UI 업데이트가 불가함. -> withContext를 걸어줘야 UI 업뎃 가능
                    withContext(Dispatchers.Main) {
                        binding.textQueryStudent.text = str
                    }
                } else {
                    withContext(Dispatchers.Main) {
                        binding.textQueryStudent.text = ""
                    }
                }
            }
        }
    }
}

 

4) MyDatabase 생성

package com.android.roomexample

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

@Database(entities = [Student::class],
    exportSchema = false, version = 1)
abstract class MyDatabase : RoomDatabase() {
    abstract fun getMyDao() : MyDAO

    companion object {
        private var INSTANCE: MyDatabase? = null
        private val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
            }
        }

        fun getDatabase(context: Context) : MyDatabase {
            if (INSTANCE == null) {
            	// 싱글톤 패턴으로 Room.databaseBuilder를 통해 Room 클래스의 인스턴스 생성
                INSTANCE = Room.databaseBuilder(
                	// DB 이름 - school_database
                    context, MyDatabase::class.java, "school_database")
                    .addMigrations(MIGRATION_1_2)
                    .build()
                // for in-memory database
                /*INSTANCE = Room.inMemoryDatabaseBuilder(
                    context, MyDatabase::class.java
                ).build()*/
            }
            return INSTANCE as MyDatabase
        }
    }
}

 

5) MyDAO 생성

package com.android.roomexample

import androidx.lifecycle.LiveData
import androidx.room.*

@Dao
interface MyDAO {
    @Insert(onConflict = OnConflictStrategy.REPLACE)  // INSERT, key 충돌이 나면 새 데이터로 교체
    suspend fun insertStudent(student: Student)

    @Query("SELECT * FROM student_table")
    fun getAllStudents(): LiveData<List<Student>>        // LiveData<> 사용

    @Query("SELECT * FROM student_table WHERE name = :sname")   // 메소드 인자를 SQL문에서 :을 붙여 사용
    suspend fun getStudentByName(sname: String): List<Student>

    @Delete
    suspend fun deleteStudent(student: Student); // primary key is used to find the student

}

 

6) MyEntity 생성

package com.android.roomexample

import androidx.room.*

@Entity(tableName = "student_table")    // 테이블 이름을 student_table로 지정함
data class Student (
    @PrimaryKey @ColumnInfo(name = "student_id") val id: Int,
    val name: String
)

 

# DB 어디에 저장되었는지 확인하는 방법

[View] → [Tool Windows] → [Device Explorer] 에서 저장된 위치 확인 가능

 

# DB에 어떻게  저장되었는지 확인하는 방법

[View] → [Tool Windows] → [App Inspection] 에서 저장된 파일 확인 가능 

 

'[개발] > Kotlin 활용 앱 개발' 카테고리의 다른 글

Google Map 가져오기  (0) 2024.07.31
사용자 위치 얻기  (0) 2024.07.31
데이터 저장 - SharePreferences  (0) 2024.07.31
Compose - 함수 / UI 구성  (0) 2024.07.30
Compose - 함수 / 레이아웃 구성  (0) 2024.07.30