[안드로이드] Room

hee09·2021년 12월 5일
0
post-thumbnail

Room

Room은 SQLite 추상화 라이브러리입니다. 이전에 작성한 글의 SQLite를 이용하는 프로그램을 추상화해서 여러 가지 도움을 주기 위한 라이브러리입니다. SQLite를 사용하지 않도록 권고하며 구글에서 Room을 만든 이유는 Save data using SQLite에 나와 있습니다.

우선 룸의 구조에 대해 알아보겠습니다.


Room 구성요소

Room은 다음과 같은 3가지 요소로 구성되어 있습니다.

  1. Entity
    • 데이터 구조를 표현하기 위한 클래스입니다. DBMS에 이용되기 위한 데이터를 위한 클래스로 테이블이라고 생각하면 됩니다.
    • @Entity 어노테이션으로 표현되는 클래스
    • 클래스 내에 @PrimaryKey, @ColumnInfo 등의 어노테이션으로 변수 선언
  1. DAO(Data access objects)
    • 실제 DBMS를 위해 호출되는 함수를 선언하는 인터페이스입니다. 단지 인터페이스나 추상 클래스를 선언하고 추상 함수를 선언하면 이를 구현해 DBMS를 수행하는 코드는 자동으로 만들어집니다(Retrofit의 Service와 똑같음)
    • @DAO 어노테이션으로 선언
    • @Query, @Update, @Insert, @Delete 등의 어노테이션으로 함수 선언
  1. Database
    • 데이터베이스 이용을 위한 DAO 객체 획득 함수를 제공하는 클래스입니다. DAO 획득 함수는 추상 함수로 정의하며, 데이터베이스를 이용하기 위해 가장 먼저 호출됩니다.
    • @Database 어노테이션으로 만드는 클래스
    • 추상 클래스로 작성
    • Entity를 어노테이션 매개변수로 지정

위의 그림은 Room내 주요 구성요소의 흐름을 보여줍니다. 순서를 설명하자면 아래와 같습니다.

  1. DBMS를 사용하기 위해 우선 Database 클래스의 함수를 호출하여 DAO 객체를 획득합니다.
  2. DAO에 선언된 함수들을 이용하여 DBMS 작업을 수행합니다.
  3. 작업을 수행하면서 데이터베이스를 획득하거나 삽입하거나 업데이트하는 등의 작업을 수행할 때 사용되는 데이터가 Entity입니다. Entity 타입의 데이터를 Select의 결과로 받거나 Entity 타입의 데이터에 값을 셋팅하고 Insert를 수행하는 등의 DBMS 작업을 합니다.

각 클래스 선언 및 사용

우선 Room을 사용하기 위해 module 수준의 build.gradle 파일에 다음과 같이 의존성을 설정합니다.

// 작성일 기준 최신버전
def room_version = "2.3.0"

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

Entity 클래스 정의

아래 코드는 User라는 Entity 클래스(Table)를 정의하는 코드입니다.

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

어노테이션에 대한 내용은 아래에서 살펴보고 단지 Entity 클래스로 모델 클래스를 작성하였다는 것이 중요합니다. 이 하나의 Entity는 데이터베이스내 하나의 테이블에 해당합니다.


DAO(Data access object)

Entity를 정의하였다면 Entity(테이블)과 상호작용하는 메서드들(select, insert 등..)을 제공하는 DAO를 작성합니다. 이 DAO는 인터페이스로 완전한 메서드의 구현을 작성하는 것이 아니라 단지 함수의 선언만 작성하면 됩니다.

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

    @Insert
    fun insertAll(vararg user: User)
}

마찬가지로 어노테이션은 아래에서 살펴보겠습니다. DAO는 실제 DBMS의 작업에 필요한 메서드들을 작성하는 곳으로 DBMS 작업이 필요할 때 getAll(), insertAll()등의 메서드가 호출되는 구조입니다.


Database

Entity와 DAO 인터페이스를 작성하였다면 마지막으로 Database 클래스를 생성하면 됩니다. Database 클래스는 위에서 언급하였듯이 DAO 객체를 반환하면 됩니다.

Database 객체는 데이터베이스의 구성요소를 정의하고 지속되는 데이터(데이터베이스)에 대한 주 접근 지점의 역할을 수행합니다. 이 클래스는 반드시 @Database 어노테이션이 필요하고 관련된 entity 배열과 데이터베이스 버전을 어노테이션 안에 포함해야합니다. 그리고 RoomDatabase를 상속받는 추상 클래스로 작성해야하며 안에 작성되는 메서드는 매개변수가 없어야 하고 반환 타입은 위에서 작성한 DAO여야 합니다.

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

만약 앱이 싱글 프로세스로 작동한다면, RoomDatabase 객체를 인스턴스화할 때 싱글톤 디자인 패턴을 따라야합니다. 객체를 인스턴스화하는데 많은 비용이 소모되기 때문입니다.

만약 앱이 여러 프로세스에서 작동한다면 Database builder를 선언할 때 enableMultiInstanceInvalidation()을 포함하면 됩니다. 이 메소드를 사용한다면 각각의 프로세스에서 객체화 된 RoomDatabase 객체가 동기화되어서 같은 데이터베이스 객체를 사용하도록 합니다.

RoomDatabase의 builder에 addCallback() 메소드를 사용하여 RoomDatabase.Callback 추상 클래스를 추가할 수 있습니다. 이 클래스를 추가하여 RoomDatabase가 어떠한 조건을 만족했을 때, RoomDatabase.Callback에 선언된 메소드가 호출되도록 하는 것입니다. Callback 클래스에는 onCreate, onDestructiveMigration, onOpen 세 개의 메소드가 존재합니다. onCreate는 데이터베이스가 처음 만들어질 때 호출되는 메소드이고, onDestructiveMigration는 데이터베이스가 구조적으로 migrate(아래에 데이터베이스 스키마 변경에서 추가적으로 설명하겠습니다)할 때 호출되는 메소드이고, onOpen은 데이터베이스가 오픈되었을 때 호출되는 메소드입니다.


사용 예시(Database, DAO, Entity를 사용해 DBMS를 수행)

// databaseBuilder 메서드의 인자로 context, RoomDatabase를 상속받는 클래스,
// 데이터베이스 파일의 이름을 전달
val db = Room.databaseBuilder(
    applicationContext,
    AppDataBase::class.java, "database-name"
).build()

// DAO 객체 획득
val userDao = db.userDao()
// DAO에 정의된 추상 함수를 사용하여 DBMS 작업을 수행
val users = userDao.getAll()

우선 Room의 databaseBuilder를 사용해 Database 객체를 생성합니다. 그리고 그 객체를 사용해 DAO 객체를 획득하고 그 안에 선언한 메서드를 사용해 데이터베이스와 상호작용하면 됩니다.

액티비티에서 DAO 클래스의 함수 호출은 네트워크 호출과 마찬가지로 메인 스레드에서 호출하면 안되고, 작업 스레드에서 호출해야 합니다.

이제 아래에서 Room 주요 구성요소의 정의와 구조에 대해 더 자세히 알아보겠습니다.


Room 엔티티


Entity 정의

Entity는 DBMS에 이용되는 데이터의 구조를 표현하기 위한 클래스입니다. 이 Entity 클래스에 정의된대로 테이블이 만들어지고 해당 테이블에 담긴 데이터가 Entity 객체에 담겨 전달됩니다.


Entity 클래스 정의하는 규칙

  • Entity 클래스는 @Entity 어노테이션으로 선언합니다.
  • Entity 클래스의 데이터를 저장하기 위한 테이블이 자동으로 만들어집니다.
  • 테이블 이름이 곧 클래스명이며 대소문자는 무시됩니다(생성자의 파라미터 중 tableName 파라미터에 인자를 넘겨서 테이블 이름 지정 가능).
  • Entity 클래스 내의 변수에 해당하는 column이 만들어집니다.
  • 기본으로 변수명과 같은 이름의 column과 대응하며 대소문자를 구분합니다.
  • @PrimaryKey 어노테이션으로 기본 키를 지정할 수 있습니다.
  • 변수 중 데이터베이스와 상관없이 이용하려는 변수는 @Ignore 어노테이션을 추가하면 됩니다.
  • 생성자는 자유롭게 추가할 수 있습니다.
  • 데이터베이스에 대응하는 변수는 public이나 private으로 선언하면 게터/세터 함수를 추가해야합니다.

이제 Entity에 선언되는 어노테이션에 대해 알아보겠습니다.


Entity 어노테이션

  • @PrimaryKey

Entity 클래스의 식별자 변수에 선언되는 어노테이션입니다. Entity에 대응하는 테이블의 기본 키 Column을 의미하며 최소 하나 이상의 기본 키 변수를 선언해야 합니다. 자동으로 증가하려는 값을 명시하려면 autoGenerate 속성을 이용합니다.

@Entity
data class User(
    @PrimaryKey(autoGenerate = true)
    val uid: Int,
)

PrimaryKey를 지정할 때 하나의 변수가 아닌 여러 변수를 묶어서 지정할 수도 있습니다. 여러 변수를 묶을 때는 아래와 같이 작성하면 되는데 주의할 점은 primaryKeys에 나열한 변수는 @NonNull로 선언해야 합니다.

@Entity(primaryKeys = ["firstName", "lastName"])
data class User(
    @NonNull
    val firstName: String,
    @NonNull
    val lastName: String,
    @Ignore
    val picture: Bitmap
)
  • @Entity(tableName="users")
    Entity에 대응하는 테이블명은 기본으로 클래스명을 따르지만 @Entity 어노테이션에 tableName 속성으로 테이블명을 지정하면 바꿀 수 있습니다.

  • @ColumnInfo(name="first_name")
    테이블의 Column명은 기본으로 Entity 클래스의 변수명을 따르지만 원한다면 이 어노테이션을 이용해 변수명과 다른 Column명을 지정할 수 있습니다.

  • @Entity(indices={})
    indices 속성으로 @Entity에 인덱스 정보를 설정할 수 있습니다. 인덱스는 데이터베이스 테이블에 있는 데이터를 빨리 찾기 위한 용도의 데이터베이스 객체이며 일종의 색인기술에 해당합니다.

  • @Embedded
    Entity 클래스가 두 개 존재하고 이 두 Entity 클래스의 데이터를 하나의 테이블에 모두 저장되게 하고 싶을 때 사용합니다.

data class Address(
    val street: String?,
    val state: String?,
    val city: String
)

@Entity(primaryKeys = ["firstName", "lastName"])
data class User(
    @NonNull
    val firstName: String,
    @NonNull
    val lastName: String,
    @Embedded val address: Address?
)

Address와 같이 포함되는 클래스는 선언 부분에 @Entity 어노테이션을 추가할 필요가 없습니다. 그리고 Address를 포함하는 User Entity 클래스에서는 @Embeded 어노테이션을 사용하여 Address를 포함하였습니다. 이렇게 되면 User 테이블에 Address 클래스의 데이터가 함께 저장됩니다.

Entity의 어노테이션은 이외에도 더 존재하고 엔티티 어노테이션 - 1, 엔티티 어노테이션 - 2에 더 많은 정보가 있습니다.


DAO


DAO 메서드

Entity 클래스를 정의하여 데이터가 어떻게 테이블에 매핑되는지 명시하였다면 이제 데이터를 저장하거나 획득하기 위해 호출되는 함수를 제공해야 합니다. 이미 언급했듯이 이러한 함수를 가지는 클래스는 DAO입니다. DAO 클래스는 인터페이스나 추상 클래스로 작성하며 Room은 DAO 클래스안에 선언된 추상 함수의 어노테이션 정보를 보고 실제 데이터베이스에 접근하는 클래스를 자동으로 만들어 주는 구조입니다.

일반적으로 DAO의 메서드가 호출되며 데이터베이스에 접근이 발생하는데 이는 메인 스레드에서는 허용하지 않습니다. 따라서 Coroutine, Rx, Thread 등을 사용하여 메인 스레드가 아닌 작업 스레드에서 데이터베이스를 처리해야 합니다.

Room은 SQL 쿼리문을 컴파일 시간에 검증하기 때문에 query에 문제가 있을 시 컴파일 에러가 발생하여 쉽게 오류를 찾을 수 있습니다.


삽입(Insert)

데이터베이스에 데이터를 저장하는 메서드는 @Insert 어노테이션으로 정의합니다.

@Dao
interface UserDAO {
    @Insert
    fun insertAll(vararg user: User)

    @Insert
    fun insertBothUsers(user1: User, user2: User)

    @Insert
    fun insertUser(user: User)
}

@Insert 메서드의 매개변수는 삽입되는 Entity 객체 하나일 수도 있고 여러 개일 수도 있습니다. 반환 값을 안 받아도 되지만 받을 수도 있습니다. 만약 하나의 파라미터만 넘긴다면 새롭게 삽입된 행의 Long 타입의 rowId를 받습니다. 만약 파라미터가 array나 collection이라면 메서드는 Long 타입 rowId의 array나 collection을 반환합니다.


갱신(Update)

데이터베이스에 데이터를 갱신하는 함수는 @Update 어노테이션으로 정의합니다.

@Dao
interface UserDAO {
    @Update
    fun updateUsers(vararg user: User)
}

매개변수로 대입된 객체의 데이터를 모두 갱신합니다. 갱신 조건은 매개변수로 대입된 객체의 @PrimaryKey 어노테이션으로 선언된 변숫값과 테이블의 기본 키 Column을 조건으로 갱신합니다. 만약 매칭되는 @PrimaryKey가 없다면 아무 변화도 없습니다. 반환값은 Unit이나 갱신한 행의 개수를 int 타입으로 받을 수 있습니다.


삭제(delete)

데이터베이스에 데이터를 삭제하는 함수는 @Delete 어노테이션으로 정의합니다.

@Dao
interface UserDAO {
    @Delete
    fun deleteUsers(vararg user: User)
}

Update와 마찬가지로 매개변수에 대입된 객체의 @PrimaryKey 변수와 테이블의 기본 키를 조건으로 행을 삭제합니다. 만약 매칭되는 @PrimaryKey가 없다면 아무 변화도 없습니다. 반환값은 Unit이나 삭제한 행의 개수를 int 타입으로 받을 수 있습니다.


질의문을 이용한 데이터 조회

조회는 기본 키가 아닌 다양한 Column을 대상으로 정렬하는 등 몇 가지 조건이 추가될 수 있습니다. 따라서 복잡한 처리를 위해 @Query라는 어노테이션을 제공합니다. 이 어노테이션에는 질의문을 직접 담을 수가 있습니다. 주로 SELECT문을 담기 위해 사용되지만 insert, update 등의 다른 질의문을 담을 수도 있습니다.

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

    @Query("SELECT * FROM user WHERE last_name IN (:whereArgs)")
    fun getUser(whereArgs: List<String>): List<User>

    @Query("UPDATE user SET first_name = :value WHERE uid = :id")
    fun updateName(value: String, id: Int)
}

위의 예제처럼 각 함수에 @Query 어노테이션을 추가하고 질의문을 직접 지정할 수 있습니다.

만약 질의문에 동적 데이터가 추가되어야 한다면 두 번째 @Query 어노테이션처럼 선언하면 됩니다. 동적으로 데이터가 추가되어야 하는 부분을 콜론(:)으로 명시하면 되는데 위의 코드에서 :whereArgs 또는 :value와 :id가 추가되는 부분입니다. 이 위치에 대입되는 데이터는 어노테이션이 추가된 메서드의 매개변수입니다. 결국 :whereArgs, :value, :id는 매개변수의 이름입니다.

또한, 컬렉션 타입의 매개변수를 질의문에 적용할 수도 있습니다. 두 번째 질의문에 해당하는데 매개변수가 List 타입입니다. 위의 코드에서는 SELECT의 WHERE 조건인 IN 부분에 매개변수 문자열들이 자동으로 나열됩니다.


조회 결과

SELECT의 결과는 Entity 객체로 받습니다. 그런데 만약 모든 속성 중 몇몇 속성만 필요하다면 Entity 자체를 사용하는 대신에 관련된 속성만 담기 위한 클래스를 생성하면 됩니다.

관련된 속성만 담기 위한 클래스

data class NameTuple (
    @ColumnInfo(name = "first_name")
    val firstName: String,
    @ColumnInfo(name="last_name")
    val lastName: String
)

UserDAO 메서드

@Query("SELECT first_name, last_name FROM user")
fun getUserName(): NameTuple
  • 조회 결과를 LiveData로 받을 수도 있습니다. Observer 등으로 결과를 쉽게 이용할 수 있습니다.

  • 조회 결과를 RxJava의 Flowable로 받을 수도 있습니다.

  • 조회 결과를 SQLite의 Cursor로도 받을 수도 있습니다.


데이터베이스 스키마 변경

이미 이용되고 있는 데이터베이스에 스키마를 변경해야 할 때도 있습니다. 이미 존재하는 테이블에 Column을 추가한다든지 제거하는 것처럼 데이터베이스의 스키마를 변경하는 작업은 Database 클래스의 버전 정보를 이용합니다.

Entity

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

Database 클래스

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

위와 같이 Entity와 Database가 선언되어 있습니다. 이 user 테이블에 Column을 하나 추가한다고 가정하겠습니다. 우선 Column을 추가하기 위해 Entity 클래스를 수정합니다.

Column이 추가된 Entity

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

    // 추가되는 속성
    val address: String
)

그리고 Database의 어노테이션에 추가되어 있는 version 정보가 변경되지 않으면 user 테이블의 스키마는 절대 변경되지 않습니다. 이에 맞게 Database 클래스의 version 값을 변경해야 합니다. 그런데 숫자만 변경했다고 적용되지 않습니다. 원래 버전인 1에서 바뀔 버전인 2로 변경될 때 무엇을 할지를 개발자가 정의해 주어야 합니다. 이를 위해 Migration 클래스를 상속받아 Migrate 메서드를 재정의한 클래스를 준비해야 합니다. 그리고 이 함수 내에 스키마 변경과 관련된 코드를 작성합니다.

Migration 생성

@Database(entities = [User::class], version = 2)
abstract class AppDataBase: RoomDatabase() {
    abstract fun userDao(): UserDAO

    companion object {
        // 싱글턴으로 생성(object 키워드를 사용)
        val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("ALTER TABLE user ADD COLUMN address TEXT")
            }
        }
    }
}

이제 이렇게 준비한 Migration 객체를 Room의 DatabaseBuilder를 이용할 때 addMigrations() 메서드로 알려주면 Database 객체 생성 시 등록한 Migration 객체의 migrate() 메서드가 자동으로 호출됩니다.

addMigration을 사용하여 Migration 적용

// databaseBuilder 메서드의 인자로 context, RoomDatabase를 상속받는 클래스
// 데이터베이스 파일의 이름을 전달
val db = Room.databaseBuilder(
    applicationContext,
    AppDataBase::class.java, "database-name"
).addMigrations(AppDataBase.MIGRATION_1_2).build()

참조
깡쌤의 안드로이드 프로그래밍
안드로이드 developer - Save data in local database

틀린 부분을 댓글로 남겨주시면 수정하겠습니다..!!

profile
되새기기 위해 기록

0개의 댓글