발표자 : 송시은
발표일자 : 2018-10-30
발표주제 : 룸 라이브러리
[TOC]
- https://developer.android.com/topic/libraries/architecture/room
- https://developer.android.com/training/data-storage/room/
- 차세대 안드로이드 개발자를 위한 커니의 코틀린[김태호 지음]
안드로이드 애플리케이션은 SQLite 데이터베이스를 사용하여 복잡하거나 양이 많은 자료를 체계적으로 관리할 수 있다. 하지만 프레임워크에서 제공하는 API만 사용하여 개발하면 여러 종류의 문제가 발생한다.
- SQL 쿼리문을 수행하는 코드를 작성하는 경우, 쿼리문 중간에 데이터를 끼워넣는 과정에서 오타 등의 실수가 발생해도 컴파일 시점에서는 발견할 수 없고 쿼리문을 실제로 실행해야만 정상적으로 동작하는지 확인할 수 있다. 따라서 오류를 발견하고 수정하기까지 시간이 많이 걸릴 수 있으며, 운이 안 좋으면 애플리케이션이 출시되기 전까지 발견하지 못하기도 한다.
- SQL 쿼리로 얻은 결과를 코드에서 쉽게 사용할 수 있도록 객체 형태로 변환하기 위해 작성해야 하는 코드는 양이 많고 복잡하다.
- 메인 스레드에서 별다른 제약 없이 데이터베이스의 자료를 수정하거나 저장된 자료를 불러올 수 있다. 이 때문에, 메인 스레드에서 데이터베이스 관련 작업을 오랫동안 수행하면 UI 업데이트가 지연되어 사용자 경험에 좋지 않은 영향을 주며, 최악의 경우 ANR(Application Not Responding) 상태에 빠질 수 있다.
이러한 문제들을 해결하기 위해, 안드로이드 아키텍처 컴포넌트를 룸(Room) 라이브러리를 제공한다.
룸 라이브러리는 안드로이드 애플리케이션에서 SQLite 데이터베이스를 쉽고 편리하게 사용할 수 있도록 하는 기능을 제공한다. 특히, 데이터베이스 내에서 다루는 자료를 객체 형채로 변환하는 수고를 하지 않아도 된다.
안드로이드 프레임워크에서 제공하는 데이터베이스 관련 API는 단순히 SQLite 데이터베이스를 조작할 수 있는 인터페이스만 제공한다. 하지만 룸 라이브러리는 데이터베이스를 더 체계적으로 사용할 수 있도록 관련 기능을 룸 데이터베이스(Room Database), 데이터 접근 객체(Data Access Object), 엔티티(Entity) 총 세 개의 구성요소로 나누어 제공한다.
룸 데이터베이스는 데이터베이스를 생성하거나 버전을 관리하는 등 실제 데이터베이스 파일과 밀접한 작업을 담당한다. 또한, 어노테이션을 통해 데이터베이스 파일을 사용할 데이터 접근 객체를 정의하여 데이터 접근 객체와 데이터베이스 파일을 연결하는 역할도 수행한다. 필요에 따라 여러 개의 룸 데이터베이스를 정의할 수 있으므로, 각각 다른 데이터베이스 파일에 연결된 데이터 접근 객체들을 선언하는 것도 가능하다.
룸 데이터베이스를 정의하는 클래스는 반드시 RoomDatabase를 상속한 추상 클래스여야 하며, 클래스의 멤버 함수 형태로 데이터베이스와 연결할 데이터 접근 객체를 선언한다. 또한, @Database 어노테이션을 사용하여 데이터베이스에서 사용할 엔티티와 데이터베이스의 버전을 지정한다.
다음은 룸 데이터베이스 클래스를 정의한 예이다.
// RoomDatabase를 상속하는 추상 클래스로 룸 데이터베이스 클래스를 선언한다.
// @Database 어노테이션으로 룸 데이터베이스의 속성을 지정한다.
// entities에 데이터베이스에서 사용할 엔티티의 클래스를 배열 형태로 넣어주며,
// version에 데이터베이스의 버전을 넣어준다.
@Database(entities = arrayOf(User::class, Address::class), version = 1)
abstract class MemberDatabase : RoomDatabase() {
// 사용자의 정보에 접근하는 UserDao 데이터 접근 객체를
// 룸 데이터베이스인 MemberDatabase와 연결한다.
abstract fun UserDao(): UserDao
// 주소 정보에 접근하는 AddressDao 데이터 접근 객체를
// 룸 데이터베이스인 MemberDatabase와 연결한다.
abstract fun addressDao(): AddressDao
}
데이터를 실제로 다루는 역할을 하는 데이터 접근 객체를 얻으려면 룸 데이터베이스의 인스턴스가 필요하다.
룸 데이터베이스는 추상 클래스로 정의되므로, 룸 데이터베이스 구현체의 인스턴스를 얻으려면 Room.databaseBuilder() 함수를 사용해야 한다.
Room.databaseBuilder() 함수를 사용하여 앞의 예시에서 소개한 MemberDatabase의 인스턴스를 생성하는 예는 다음과 같다.
// MemberDatabase의 인스턴스를 생성한다.
// 컨텍스트와 생성할 룸 데이터베이스의 클래스, 그리고 생성될 데이터베이스 파일의 이름을 지정한다.
val database : MemberDatabase = Room.databaseBuilder(
context, MemberDatabase::class.java, "database.db").build()
// 생성된 인스턴스를 사용하여 이 데이터베이스에 연결된 데이터 접근 객체를 얻을 수 있다.
val addressDao = database.addressDao()
룸 데이터베이스의 인스턴스를 한번 생성한 후에는 다른 곳에서도 계속 사용할 수 있도록 생성한 인스턴스를 계속 유지하는 것이 좋습니다. 그러므로 싱글톤 패턴 혹은 유사한 방법을 사용하여 앱 내에서 인스턴스를 공유하도록 구현하는 것이 좋다.
Room.inMemoryDatabaseBuilder()를 사용하면 파일 형태로 저장되는 데이터베이스 대신 메모리에 데이터베이스를 저장하는 룸 데이터베이스 인스턴스를 생성할 수 있습니다. 여기에 저장되는 데이터베이스는 애플리케이션 프로세스가 종료되는 즉시 사라진다.
데이터 접근 객체(Data Access Object; DAO)는 데이터베이스를 통해 수행할 작업을 정의한 클래스이다.
데이터 삽입, 수정, 삭제 작업이나 저장된 데이터를 불러오는 작업 등을 함수 형태로 정의하며, 애플리케이션의 비즈니스 로직에 맞는 형태로 작업을 정의할 수 있다.
데이터 접근 객체는 인터페이스나 추상 클래스로 정의할 수 있으며, 반드시 @Dao 어노테이션을 붙여 주어야 한다.
다음은 @Dao 어노테이션을 사용하여 데이터 접근 객체를 정의한 코틀린 코드이다.
// UserDao 인터페이스를 룸 데이터베이스의 데이터 접근 객체로 표시한다.
@Dao
interface UserDao {
...
}
@Query 어노테이션을 사용하면 데이터 접근 객체 내에 정의된 함수를 호출했을 때 수행할 SQL 쿼리문을 작성할 수 있다. @Query 어노테이션을 사용한 함수는 어노테이션에 작성된 SQL 쿼리문에 함수의 매개변수를 결합할 수 있다. 검색 결과를 반환하는 쿼리문은 @Query 어노테이션을 사용한 함수의 반환 타입에 맞게 결과가 변환되어 출력된다.
@Query 어노테이션 내에서 사용할 수 있는 SQL문은 INSERT, UPDATE, DELETE로 제한된다.
다음 코드는 @Query 어노테이션을 사용하여 데이터 접근 객체에 몇몇 작업을 정의한 예이다.
@Dao
interface UserDao {
// User 테이블의 모든 데이터를 반환한다.
@Query("SELECT * from users")
fun getUsers() : List<User>
// UserId와 일치하는 id를 가진 데이터를 반환한다.
@Query("SELECT * from users WHERE id = :userId")
fun getUSer(userId: Long) : User
// userIds 목록에 포함되는 id를 가진 데이터를 모두 반환한다.
@Query("SELECT * from users WHERE id IN (:userIds)")
fun getUsersIn(userIds: Array<Long) : List<User>
// users 테이블의 모든 데이터를 삭제한다.
@Query("DELETE from users")
fun clearUsers()
}
@Query 어노테이션을 사용하여 지정한 쿼리문은 컴파일 시점에 쿼리문의 오류를 확인한다.
다음과 같이 앞의 예제 코드에서 getUsersIn() 함수의 반환 타입을 실수로 빠뜨렸다고 가정해 보자.
// List<User>를 반환해야 하지만 실수로 함수의 반환 타입을 지정하지 않았다.
@Query("SELECT * from users WHERE id IN (:userIds)")
fun getUsersIn(userIds: Array<Long>)
이 코드를 빌드하면 에러 메시지가 표시되며 컴파일에 실패한다. 따라서 문제가 되는 부분을 사전에 파악하고 수정할 수 있다.
데이터베이스에서 가져온 데이터는 쿼리문을 실행한 시점의 데이터로, 데이터베이스의 내용이 나중에 변경되어도 동기화되지 않는다. 데이터를 반환하는 함수의 타입을 안드로이드 아키텍처 컴포넌트에서 제공하는 LiveData나 RxJava의 Flowable과 같은 타입으로 변경하면 데이터베이스에 저장된 데이터와 동기화가 이뤄지므로 항상 최신 데이터를 참조할 수 있다.
LiveData로 반환 타입을 변경하려면 별도의 라이브러리를 추가하지 않아도 되지만, RxJava에서 제공하는 타입으로 반환 타입을 변경하려면 android.arch.persistence.room:rxjava2 라이브러리를 의존성에 추가해야 한다.
데이터를 조작하는 일부 SQL문은 @Query 어노테이션 대신 특화된 어노테이션을 사용할 수 있다. @Insert, @Update, @Delete 어노테이션을 사용할 수 있으며, 각 어노테이션의 사용 예는 다음과 같다.
@Dao
interface UserDao {
// 새로운 사용자를 추가한다.
// 주요 키(Primary Key)를 기준으로 중복 여부를 확인하며,
// 여기에서는 중복된 데이터가 있는 경우 기존 데이터를 덮어쓴다.
@Insert(onConflict = onConflictStrategy.REPLACE)
fun addUser(user: User)
// 인자로 받은 사용자 정보의 주요 키를 사용하여 데이터를 검색한 후,
// 저장되어 있는 정보를 인자로 받은 정보로 갱신한다.
@Update
fun updateUser(newUser: User)
// 인자로 받은 사용자 정보를 삭제한다.
// 인자로 받은 사용자 정보의 주요 키를 사용하여 삭제할 데이터를 찾는다.
@Delete
fun deleteUser(user: User)
}
엔티티(Entity)는 데이터베이스에 저장할 데이터의 형식을 정의하며, 각 엔티티가 하나의 테이블을 구성한다. 이 이때문에 룸 데이터베이스를 정의할 때 해당 데이터베이스에서 사용하는 엔티티를 @Database 어노테이션 내에 반드시 지정해 주어야 한다. 룸 데이터베이스에서 지정하지 않은 엔티티를 사용하면 컴파일 에러가 발생한다.
엔티티는 @Entity 어노테이션을 사용하여 정의한다. 데이터베이스에 저장할 정보는 필드로 표현하며, public 수준의 가시성을 갖거나 Getter/Setter를 사용하여 필드에 접근할 수 있어야 한다. 코틀린은 필드와 Getter/Setter 대신 프로퍼티를 사용하여 데이터 베이스에 저장할 정보를 정의할 수 있다.
각 엔티티는 최소한 하나의 주요(Primary Key)를 지정해야 한다. @PrimaryKey 어노테이션을 사용하면 엔티티에서 사용할 주요 키를 지정할 수 있다. 만약 여러 필드를 주요 키로 사용하고 싶다면 @Entity 어노테이션에서 주요 키로 사용할 필드의 이름을 지정할 수 있다. 클래스에 포함된 필드 중 데이터베이스에 저장하고 싶지 않은 필드가 있을 경우 @Ignore 어노테이션을 필드에 추가하면 된다.
다음은 엔티티를 정의한 클래스의 예이다.
// 사용자 정보를 표현하는 엔티티를 정의한다.
// 엔티티 이름과 동일한 User 테이블이 생성된다.
@Entity
class User(
// id를 주요 키로 사용한다.
@PrimaryKey val id: Long,
val name: String,
var address: String,
// memo 필드는 데이터베이스에 저장하지 않습니다.
@Ignore var memo: String)
// 주요 키로 id와 name을 사용한다.
@Entity(primaryKeys = arrayOf("id", "name"))
class User(
val id: Long,
var name: String,
var address: String,
@Ignore var memo: String)
각 엔티티별로 생성되는 테이블 이름과 테이블 내의 열(column) 이름이 엔티티 클래스 및 필드 이름과 동일하다. @Entity 어노테이션 내에서 tableName 속성을 사용하면 생성되는 테이블 이름을 변경할 수 있고, @ColumnInfo 어노테이션의 name 속성을 사용하면 각 필드의 데이터를 저장할 열 이름을 지정할 수 있다.
// User 엔티티의 정보를 저장할 테이블 이름을 users로 지정한다.
@Entity(tableName = "users")
class User(
@PrimaryKey val id: Long,
// name 필드를 저장할 열 이름을 user_name으로 지정한다.
@ColumnInfo(name = "user_name") var name: String,
// address 필드를 저장할 열 이름을 user_address로 지정한다.
@ColumnInfo(name = "user_address") var address: String,
@Ignore val memo: String)
@Embedded 어노테이션을 사용하면 여러 필드가 조합된 클래스를 타입으로 갖는 엔티티의 필드를 엔티티 테이블레서 별도의 열로 저장할 수 있다.
다음은 @Embedded 어노테이션의 사용 예이다. User 엔티티의 필드에 포함된 BillingInfo 타입의 필드에 @Embedded 어노테이션을 적용했다.
// 사용자의 결제 정보를 저장하는 클래스이다.
class BillingInfo(
@ColumnInfo(name = "billing_method") val method: String,
@ColumnInfo(name = "billing_data") val data: String)
@Entity(tableName = "users")
class User(
@PrimaryKey val id: Long,
val name: String,
// @Embedded 어노테이션을 사용하여
// BillingInfo 클래스의 필드를 User 엔티티 테이블의 열에 저장한다.
@Embedded val billingInfo: BillingInfo)
@Embedded 어노테이션이 BillingInfo 클래스에 포함된 필드를 User 엔티티에서 생성하는 테이블의 열에 저장하므로, User 엔티티에서 생성한 테이블은 id, name, billingInfo 대신 id, name, billing_method, billing_data 열을 갖게 된다.