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