안드로이드

[Android] Room DB에 대해 알아보자.

코딩하는후운 2024. 6. 26. 17:38
반응형

Room이란?

Room은 스마트폰 내장 DB에 데이터를 저장하기 위해 사용하는 라이브러리
Room은 완전히 새로운 개념은 아니고, SQLite를 활용해서 객체 매핑을 해주는 역할을 한다.

Room 구조

Entity

  • 개체
  • 관련 있는 속성들이 모여 하나의 정보 단위
@Entity
data class User (
    var name: String,
    var age: String,
    var phone: String
){
    @PrimaryKey(autoGenerate = true) var id: Int = 0
}

DAO

데이터에 접근할 수 있는 메서드를 정의 해놓은 인터페이스

@Dao
interface UserDao {
    @Insert
    fun insert(user: User)
 
    @Update
    fun update(user: User)
 
    @Delete
    fun delete(user: User)
}

@Dao
interface UserDao {
    @Query("SELECT * FROM User") // 테이블의 모든 값을 가져와라
    fun getAll(): List<User>
 
    @Query("DELETE FROM User WHERE name = :name") // 'name'에 해당하는 유저를 삭제해라
    fun deleteUserByName(name: String)
}

Room Database

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

@Database(entities = arrayOf(User::class, Student::class), version = 1)
abstract class UserDatabase: RoomDatabase() {
    abstract fun userDao(): UserDao
}

데이터베이스를 생성하고 관리하는 데이터베이스 객체 만들기 위해서 위와 같은 추상 클래스를 만들어 줘야 한다.

  • RoomDatabase 클래스를 상속받고, @Database 어노테이션으로 데이터베이스임을 표시

version은 앱을 업데이트하다가 entity의 구조를 변경해야 하는 일이 생겼을 때 이전 구조와 현재 구조를 구분해주는 역할
여러 개의 entity를 가져야 한다면 arrayOf() 안에 콤마로 구분해서 entity를 넣어주면 된다.
데이터베이스 객체를 인스턴스 할 때 싱글톤으로 구현하기를 권장하고 있다.

@Database(entities = [User::class], version = 1)
abstract class UserDatabase: RoomDatabase() {
    abstract fun userDao(): UserDao
 
    companion object {
        private var instance: UserDatabase? = null
 
        @Synchronized
        fun getInstance(context: Context): UserDatabase? {
            if (instance == null) {
                synchronized(UserDatabase::class){
                    instance = Room.databaseBuilder(
                        context.applicationContext,
                        UserDatabase::class.java,
                        "user-database"
                    ).build()
                }
            }
            return instance
        }
    }
}

 

실제 사용

var newUser = User("김똥깨", "20", "010-1111-5555")

// 싱글톤 패턴을 사용하지 않은 경우
val db = Room.databaseBuilder(
        applicationContext,
        AppDatabase::class.java,
        "user-database"
).build()
db.UserDao().insert(newUser)

// 싱글톤 패턴을 사용한 경우
val db = UserDatabase.getInstance(applicationContext)
db!!.userDao().insert(newUser)

저대로 실행하면 "Cannot access database on the main thread since it may potentially lock the UI for a long period of time" 에러가 뜬다.

  • 비동기 실행하면 된다고 함.

해결 법

  • allowMainThreadQueries()를 사용해 강제로 실행
  • 비동기 실행을 하면 된다.

allowMainThreadQueries는 안드로이드 Room DB 설정 시 메인 스레드에서 데이터베이스 쿼리를 허용하도록 하는 옵션

장점:

  • 간편한 사용: 메인 스레드에서 직접 데이터베이스 작업을 수행할 수 있어 코드 작성이 간편해집니다.
  • 빠른 프로토타이핑: 초기 개발 단계에서 빠르게 기능을 구현하고 테스트하는 데 유용할 수 있습니다.

단점:

  • 앱 성능 저하: 메인 스레드는 UI 스레드이므로, 이곳에서 긴 데이터베이스 작업을 수행하면 앱이 느려지거나 응답하지 않게 될 수 있습니다.
  • 안정성 문제: 메인 스레드에서 무거운 작업을 하면 ANR(Application Not Responding) 오류가 발생할 위험이 있습니다.

권장 사항:

  • 비동기 작업 사용: 메인 스레드 대신 AsyncTask, Executor, LiveData, RxJava, Kotlin Coroutines 등을 사용하여 백그라운드 스레드에서 데이터베이스 작업을 수행하는 것이 좋습니다.
  • 테스트 목적 사용: allowMainThreadQueries는 테스트나 프로토타이핑 단계에서만 사용하고, 실제 프로덕션 코드에서는 비동기 작업으로 대체해야 합니다.
import android.content.Context
import androidx.room.Room

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

 

지피티에서 사용법

1. 데이터베이스 설정

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

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

2. DAO 인터페이스

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query

@Dao
interface EmoticonDao {
    @Query("SELECT * FROM EmoticonList")
    suspend fun getAllEmoticons(): List<Emoticon>

    @Insert
    suspend fun insertEmoticon(emoticon: Emoticon)
}

3. Repository 클래스

class EmoticonRepository(private val emoticonDao: EmoticonDao) {

    val allEmoticons: LiveData<List<Emoticon>> = emoticonDao.getAllEmoticons()

    suspend fun insert(emoticon: Emoticon) {
        emoticonDao.insertEmoticon(emoticon)
    }
}

4. ViewModel 클래스

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

class EmoticonViewModel(private val repository: EmoticonRepository) : ViewModel() {

    val allEmoticons: LiveData<List<Emoticon>> = repository.allEmoticons

    fun insert(emoticon: Emoticon) = viewModelScope.launch {
        repository.insert(emoticon)
    }
}

 

Room 마이그레이션

1. 데이터 베이스 버전 관리

Room 데이터베이스의 버전을 관리하고, 마이그레이션을 정의해야 합니다. 데이터베이스의 버전은 AppDatabase 클래스에 지정

import androidx.room.Database
import androidx.room.RoomDatabase

@Database(entities = [Emoticon::class], version = 2)
abstract class AppDatabase : RoomDatabase() {
    abstract fun emoticonDao(): EmoticonDao
}

2. 마이그레이션 정의

새로운 버전으로의 마이그레이션 로직을 작성해야 합니다.

import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // 여기서 데이터베이스 스키마 변경 작업을 수행합니다.
        // 예시: 새로운 컬럼 추가
        database.execSQL("ALTER TABLE EmoticonList ADD COLUMN new_column TEXT")
    }
}

3. Room 데이터베이스 빌더에 마이그레이션 추가

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

4. 마이그레이션 테이블 추가 작업

import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // EmoticonDetails 테이블 생성
        database.execSQL("""
            CREATE TABLE IF NOT EXISTS EmoticonDetails (
                id INTEGER PRIMARY KEY NOT NULL,
                emoticonId INTEGER NOT NULL,
                description TEXT NOT NULL,
                imageUrl TEXT NOT NULL
            )
        """.trimIndent())
    }
}

실제 마이그레이션시 오류 날 경우

  • 실제 로그가 찍히지는 않는것 같다.
  • RoomOpenHelper.java 파일에 마이그레이션 하는쪽 onUpgrade 디버깅을 하여 메시지를 본다.
  • 그 다음 지피티에게 물어봐서 어떤게 잘못 되었는지 확인하기
    • 나는 version필드명이 서로 다르게 입력 되어 있었음.

 

DB 활용법 (Select, Insert, Update)

Select

Dao에 관련 쿼리 함수들을 넣는다.

@Query("SELECT * FROM emoticon_list WHERE user_id = :userId")
fun getEmoticonList(userId: String): EmoticonListEntity?

Insert

@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertEmoticons(emoticons: List<EmoticonListEntity>): List<Long>
  • OnConflictStrategy.REPLACE : 삽입(insert)이나 업데이트(update) 시 발생할 수 있는 충돌 상황에서 사용할 수 있는 전략

OnConflictStrategy.REPLACE 동작 방식

  • 삽입하려는 데이터의 키(Primary Key)가 기존 데이터와 충돌하는 경우: 기존 행을 삭제하고 새로운 데이터를 삽입합니다.
  • 충돌하지 않는 경우: 데이터를 그냥 삽입합니다.

다른 충돌 처리 전략

  • IGNORE: 충돌이 발생한 경우 삽입을 무시합니다.
  • ABORT: 충돌이 발생한 경우 트랜잭션을 중단하고 롤백합니다.
  • FAIL: 충돌이 발생한 경우 트랜잭션을 중단하지만 롤백하지 않습니다.
  • ROLLBACK: 충돌이 발생한 경우 트랜잭션을 롤백합니다.

결과 List<Long> : 삽입된 각 엔티티의 행 ID(row ID)

ex)

val emoticons = listOf(
    EmoticonListEntity(id = 1, name = "Smile"),
    EmoticonListEntity(id = 2, name = "Sad"),
    EmoticonListEntity(id = 1, name = "Happy")  // This will replace the first one
)

val ids = emoticonDao.insertEmoticons(emoticons)
println(ids)  // Output will be [1, 2, 1]

Update

@Update
fun updateEmoticon(emoticon: EmoticonListEntity): Int
  • Int는 update된 개수라고 한다.
  • primary key가 같은게 없어서 업데이트가 되지 않으면 0

Join

쿼리 관련 테이블 Join을 해야하는 경우가 생긴다.

@Query("SELECT * FROM emoticon_detail INNER JOIN emoticon_detail_image ON emoticon_detail.id = emoticon_detail_image.emoticon_id WHERE emoticon_detail.id = :emoticonId")
  • INNER JOIN은 두 테이블 간의 일치하는 행만 가져오는 조인 방식입니다.
  • ON 절은 두 테이블 간의 조인 조건을 지정합니다. 이 경우 emoticon_detail 테이블의 id 필드와 emoticon_detail_image 테이블의 emoticon_id 필드가 동일할 때 조인을 수행합니다.
  • WHERE 절은 조인된 결과에서 특정 조건을 만족하는 데이터를 선택

더 쉽게 해결할 수 있는 방법이 있다.

@Relation

테이블을 조인한 결과를 담을 새로운 데이터 클래스를 정의.

  • @Relation 어노테이션은 Room에서 사용되어 관계형 데이터베이스의 테이블 간 관계를 표현
  • 부모 엔티티와 자식 엔티티(또는 연결된 엔티티) 사이의 관계를 정의할 때 사용됩니다. 주로 한 엔티티에 대해 여러 개의 관련된 엔티티를 포함하는 경우에 사용됩니다.
  • @Relation 어노테이션이 사용된 메서드에서는 @Transaction 어노테이션을 함께 사용하는 것이 권장된다고 한다.

@Transaction

  • @Transaction 어노테이션은 Room에서 트랜잭션 단위로 데이터베이스 작업을 처리할 때 사용됩니다.

@Transaction 어노테이션의 역할

트랜잭션 단위로 묶음:
@Transaction 어노테이션은 여러 개의 데이터베이스 작업을 원자적(atomic)으로 묶어주는 역할을 합니다.
이는 모든 작업이 성공적으로 완료될 때만 변경 사항을 커밋하고, 하나라도 실패하면 롤백하여 이전 상태로 되돌리는 기능을 제공합니다.

 

@Relation 을 이용한 ‘결과 Entity’

data class EmoticonDetailResultEntity(
    @Embedded val emoticonDetail: EmoticonDetailEntity,
    @Relation(
        parentColumn = "id",
        entityColumn = "emoticon_id"
    )
    val images: List<EmoticonDetailImageEntity>
)


// 실제 쿼리 한번으로 두 테이블 데이터를 가져올 수 있다. 
@Transaction
@Query("SELECT * FROM emoticon_detail WHERE id = :emoticonId")
fun getEmoticonDetailWithImage(emoticonId: Int): EmoticonDetailResultEntity?

 

작업 하면서 궁금 했던 것

Select할 때 @Relation으로 묶어서 Select했는데 Delete할 때에도 id로 두 테이블 데이터 삭제가 가능한지?

Room에서는 관계를 맺은 두 테이블 데이터 삭제할 때 외래 키 제약 조건과 CASCADE 옵션을 사용하여 하나의 테이블에서 삭제할 때 자동으로 관련된 다른 테이블 데이터도 삭제 가능

  • 외래키를 설정하고 CASCADE 삭제를 활성화 해야 한다.

Room에서는 기본적으로 비활성화

  • 데이터베이스 빌더에서 setForeignKeyConstraintsEnabled(true)를 호출 해야 한다.
@Entity(
    tableName = "emoticon_detail_image",
    foreignKeys = [ForeignKey(
        entity = EmoticonDetailEntity::class,
        parentColumns = ["id"],
        childColumns = ["emoticon_id"],
        onDelete = ForeignKey.CASCADE
    )]
)
data class EmoticonDetailImageEntity(
    @PrimaryKey @ColumnInfo(name = "id") val imageId: Int,
    @ColumnInfo(name = "emoticon_id") val emoticonId: Int,
    val name: String? = null,
    val ordering: Int,
    val url: String? = null,
    val thumbnail: String? = null,
    val version: String? = null
)

데이터 베이스 빌더 설정

Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "MyDatabase.db")
      .addCallback(object : RoomDatabase.Callback() {
          override fun onCreate(db: SupportSQLiteDatabase) {
              super.onCreate(db)
              db.execSQL("PRAGMA foreign_keys=ON")
          }

          override fun onOpen(db: SupportSQLiteDatabase) {
              super.onOpen(db)
              db.execSQL("PRAGMA foreign_keys=ON")
          }
      })
      .build()

 

DAO 삭제 메서드 추가

emoticonId로 삭제하는 DAO 메서드를 작성합니다.
CASCADE 옵션을 사용했으므로 EmoticonDetailEntity를 삭제할 때 자동으로 관련된 EmoticonDetailImageEntity도 삭제됩니다.

@Dao
interface EmoticonDao {
    @Query("DELETE FROM emoticon_detail WHERE id = :emoticonId")
    suspend fun deleteEmoticonById(emoticonId: Int)
}

 

Room에서는 한 번에 여러 테이블에 데이터를 삽입하는 기능을 제공하지 않음.
하나의 트랜잭션 내에서 여러 테이블에 데이터를 삽입할 수 있습니다.

@Transaction
suspend fun insertEmoticonDetailWithImages(
    emoticonDetail: EmoticonDetailEntity,
    emoticonDetailImages: List<EmoticonDetailImageEntity>
) {
    insertEmoticonDetail(emoticonDetail)
    insertEmoticonDetailImages(emoticonDetailImages)
}

 

 

출처 :
https://todaycode.tistory.com/39
https://android-dev.tistory.com/entry/AndroidKotlin-안드로이드-Room-Database-사용하기3-Migration

 

반응형