본문으로 바로가기

Android Architecture Components #6 - Room

category 개발이야기/Android 2017. 10. 18. 01:00
반응형

Room Persistence Library

Room은 SQLite의 추상화 layer로 SQLite의 모든 기능을 활용하여 유연한 database 접근을 가능하게 한다.

core Frameworkd이 raw SQL을 사용할수 있도록 지원하지만 low level로 접근 가능하고 다음과 같은 항목으로 인해 많은 공수가 들어간다

raw SQL은 complie time에 확인할 수 없기 때문에 데이터의 구조가 변경되는 경우 영향을 받은 SQL을 수동으로 수정해야하며, 이는 공수가 많이 든다.

SQL 결과를 java object로 만들기위해 많은 상용구(상투적인) 코드들이 들어간다.


Room의 3가지 component

Database: database의 holder를 만든다. annotation으로 entities(Table의 구조와 mapping되는 class)를 정의하고, 클래스 내부에 dao를 정의한다.

이 클래스는 RoomDatabase를 상속받은 abstract class가 되며 Room.databaseBuilder() or Room.inMemoryDatabaseBuilder() 를 이용하여 얻을 수 있다.

// User and Book are classes annotated with @Entity.
 @Database(version = 1, entities = {User.class, Book.class})
 abstract class AppDatabase extends RoomDatabase() {
     // BookDao is a class annotated with @Dao.
     abstract public BookDao bookDao();
     // UserDao is a class annotated with @Dao.
     abstract public UserDao userDao();
     // UserBookDao is a class annotated with @Dao.
     abstract public UserBookDao userBookDao();
 }

위 예제는 User, Book 두개의 table을 갖고, 3의 dao class를 갖는다 entity와 Dao의 개수는 제한이 없으나, 이름은 달라야 한다.

Dao는 cursor를 자동으로 class로 converting 해준다. Room은 DAO에 정의된 query 확인을 compile time에 즉각 해준다.


Entity: database의 row와 mapping되는 class, 즉 table의 구조를 나타내는데  Database에서 entities함수를 통해 접근할 수 있다.

@Ignore를 붙이지 않는한 DB에 지속적으로 유지된다.

entity는 empty 생성자나, 부분 field값만 같는 생성자, full field에 대한 생성자 모두를 가질 수 잇다.

DAO: database를 접근하는 함수들이 정의되는 class or interface.  @Database로 정의된 class는 내부에 인자가 없고 @Dao annotation이 되어있는 class를 return하는 abstract 함수를 포함하고 있다. 

 

아래 예제는 하나의 entity와 하나의 Dao를 갖는 형태이다.

// User.java
@Entity
public class User {
    @PrimaryKey
    private int uid;
 
    @ColumnInfo(name = "first_name")
    private String firstName;
 
    @ColumnInfo(name = "last_name")
    private String lastName;
 
    // Getters and setters are ignored for brevity,
    // but they're required for Room to work.
}
// UserDao.java
@Dao
public interface UserDao {
    @Query("SELECT * FROM user")
    List<User> getAll();
 
    @Query("SELECT * FROM user WHERE uid IN (:userIds)")
    List<User> loadAllByIds(int[] userIds);
 
    @Query("SELECT * FROM user WHERE first_name LIKE :first AND "
           + "last_name LIKE :last LIMIT 1")
    User findByName(String first, String last);
 
    @Insert
    void insertAll(User... users);
 
    @Delete
    void delete(User user);
}
// AppDatabase.java
@Database(entities = {User.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}

위 세개의 class가 완성되면 아래와 같이 DB의 instance를 얻을 수 있다.

AppDatabase db = Room.databaseBuilder(getApplicationContext(),
        AppDatabase.class, "database-name").build();

AppDatabase object를 생성하는 코드는 비용이 많이 들기 때문에 singleton으로 구현해야 한다.


Entities

@Database의 annotation에 속성으로 포함된 entitiy는 실제 @Entity annotation을 붙인 class로 만들어야 한다. 이는 각 table로 생성되며, 실제 column으로 만들고 싶지 않은 field가 있다면 @Ignore 를 붙인다.
@Entity
class User {
    @PrimaryKey
    public int id;
 
    public String firstName;
    public String lastName;
 
    @Ignore
    Bitmap picture;
}

Field는 public 형태이어야 하면 private으로 할경우 getter와 setter를 java beans conventions에 따라 만들어야 한다.


Primary key

column이 하나라도 PK는 지정되어야 한다. @PrimaryKey annotation을 이용하거나, 복합 PK의 경우 @Entity 속성으로 primaryKeys를 이용한다.
또한 Id를 autogenerate 하려면 @PrimaryKey 속성에 autoGenerate  속성(true)을 넣는다.
추가적으로 class 이름은 table 명이된다. table 명을 바꾸고 싶다면 @Entity 속성으로  tableName을 넣으면 된다.
만약 field 이름을 column으로 쓰고 싶지 않다면 @ColumnInfo  로 표기해야 한다
// 복합 PK 사용시
@Entity(primaryKeys = {"firstName", "lastName"})
class User {
    public String firstName;
    public String lastName;
 
    @Ignore
    Bitmap picture;
}
 
// 테이블 이름을 직접 지정할 때
@Entity(tableName = "users")
class User {
    ...
}
 
// Column을 직접 지정할때
@Entity(tableName = "users")
class User {
    @PrimaryKey
    public int id;
 
    @ColumnInfo(name = "first_name")
    public String firstName;
 
    @ColumnInfo(name = "last_name")
    public String lastName;
 
    @Ignore
    Bitmap picture;
}


Indices and uniqueness

Index는 아래와 같이 만들수 있다. (결합 index도 생성 가능)
@Entity(indices = {@Index("name"),
        @Index(value = {"last_name", "address"})})
class User {
    @PrimaryKey
    public int id;
 
    public String firstName;
    public String address;
 
    @ColumnInfo(name = "last_name")
    public String lastName;
 
    @Ignore
    Bitmap picture;
}

Unique 제약조건은 아래와 같이 표기할 수 있다 예제)결합조건에 대한 unique index

@Entity(indices = {@Index(value = {"first_name", "last_name"},
        unique = true)})
class User {
    @PrimaryKey
    public int id;
 
    @ColumnInfo(name = "first_name")
    public String firstName;
 
    @ColumnInfo(name = "last_name")
    public String lastName;
 
    @Ignore
    Bitmap picture;
}

foreignKey도 설정할 수 있다.

@Entity(foreignKeys = @ForeignKey(entity = User.class,
                                  parentColumns = "id",
                                  childColumns = "user_id"))
class Book {
    @PrimaryKey
    public int bookId;
 
    public String title;
 
    @ColumnInfo(name = "user_id")
    public int userId;
}


Nested objects

Entity 클래스가 field로 object를 갖는경우 @Embeded를 사용한다.
단 해당 table에는 Embeded된 클래스의 column도 똑같은 하나의 column으로 취급된다.
class Address {
    public String street;
    public String state;
    public String city;
 
    @ColumnInfo(name = "post_code")
    public int postCode;
}
 
@Entity
class User {
    @PrimaryKey
    public int id;
 
    public String firstName;
 
    @Embedded
    public Address address;
}

즉 위 예제에서 User table에는 id, firstName, street, state, city, post_code 컬럼이 존재한다.

Embeded 안에서 embeded를 가지수 있으며, 만약 column이름이 중복되는 경우  prefix를 사용하여 unique하게 column이름을 설정한다

 

Data Access Objects (DAOs)

Dao는 abstract class나 interface가 될수 있다. 

RoomDatabase를 인자로 받는 생성자를 만드는 경우에만 abstract class가 될 수 있다.

Room은 절대로 main thread에서 query 작업을 하지 않는다. allowMainThreadQueries() 를 호출하더라도 불가능 하다.

LiveData를 return 하는 비동기 query의 경우에는 가능하다. (어차피 background에서 수행되므로)


Insert

@Insert으로 표기하며, single transaction으로 처리된다.

@Dao
public interface MyDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    public void insertUsers(User... users);
 
    @Insert
    public void insertBothUsers(User user1, User user2);
 
    @Insert
    public void insertUsersAndFriends(User user, List<User> friends);
}

@Entity 로 정의된 class만 인자로 받거나, 그 class의 collection 또는 array만 인자로 받을 수 있다.

또한 인자가 하나인 경우 long type의 return값(insert 된 값의 rowId)을 받을 수 있고, 여러개인 경우 long[], List<Long>을 받을 수 있다.


Update

Update를 사용하여 Entity set을 update한다. return값으로 변경된 rows 수를 받을수 있다.

update는 PK를 기준으로 한다

@Dao
public interface MyDao {
    @Update
    public void updateUsers(User... users);
}


Delete

Delete를 사용하여 Entity set을 update한다. return값으로 변경된 rows 수를 받을수 있다.

삭제 key는 PK를 기준으로 한다.

@Dao
public interface MyDao {
    @Delete
    public void deleteUsers(User... users);
}


Query

@Query를 사용하여 DB를 조회할 수 있다. compile time에 return되는 object의 field와 sql 결과로 나오는 column의 이름이 맞는지 확인하여 일부가 match되면 warning, match되는게 없다면 error를 보낸다.

@Dao
public interface MyDao {
    @Query("SELECT * FROM user")
    public User[] loadAllUsers();
}

select문에 parameter가 들어가야 하는경우 아래와 같이 넣을 수 있다

@Dao
public interface MyDao {
    @Query("SELECT * FROM user WHERE age > :minAge")
    public User[] loadAllUsersOlderThan(int minAge);
}

아래와 같이 여러개의 parameter도 사용할 수 있다.

@Dao
public interface MyDao {
    @Query("SELECT * FROM user WHERE age BETWEEN :minAge AND :maxAge")
    public User[] loadAllUsersBetweenAges(int minAge, int maxAge);
 
    @Query("SELECT * FROM user WHERE first_name LIKE :search "
           + "OR last_name LIKE :search")
    public List<User> findUserWithName(String search);
}

만약 일부 컬럼만 조회하고 싶다면 따로 return class를 만들어서 요청할 수 있다.

// return 받을 class를 정의
public class NameTuple {
    @ColumnInfo(name="first_name")
    public String firstName;
 
    @ColumnInfo(name="last_name")
    public String lastName;
}
 
 @Dao
public interface MyDao {
 @Query("SELECT first_name, last_name FROM user")
 public List<NameTuple> loadFullName();
}

정해지지 않은 개수의 parameter가 넘어가야 하는 경우 아래와 같이 수행 가능하다

@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
    public List<NameTuple> loadUsersFromRegions(List<String> regions);
}

LiveData를 리턴하도록 만들수도 있다. 이 경우 Database가 수정될때 Room이 자동으로 LiveData를 업데이트 하도록 코드를 생성해 준다.

@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
    public LiveData<List<User>> loadUsersFromRegionsSync(List<String> regions);
}

Cursor를 반환하도록 할 수 있으나, 권장하지 않는다.

@Dao
public interface MyDao {
    @Query("SELECT * FROM user WHERE age > :minAge LIMIT 5")
    public Cursor loadRawUsersOlderThan(int minAge);
}

Join을 이용하여 여러 테이블을 access 할 수 도 있다.

또한 return type이 LiveData일 경우 room은 연관된 table을 모두 관찰한다. (???)

@Dao
public interface MyDao {
    @Query("SELECT * FROM book "
           + "INNER JOIN loan ON loan.book_id = book.id "
           + "INNER JOIN user ON user.id = loan.user_id "
           + "WHERE user.name LIKE :userName")
   public List<Book> findBooksBorrowedByNameSync(String userName);
}

POJO 테이블을 return하도록 할수도 있다.

@Dao
public interface MyDao {
   @Query("SELECT user.name AS userName, pet.name AS petName "
          + "FROM user, pet "
          + "WHERE user.id = pet.user_id")
   public LiveData<List<UserPet>> loadUserAndPetNames();
 
   // You can also define this class in a separate file, as long as you add the
   // "public" access modifier.
   static class UserPet {
       public String userName;
       public String petName;
   }
}


Using type converters

Room은 primitive type과 그 wrapping 타입만 지원한다. 하지만 그외에 type을 사용할 경우 TypeConverter를 사용하여 type을 치환해야 한다.

예를들어 DB에서는 timestamp로 되어 있고, java code에서는 Date class로 되어있는경우 우선 아래와 같이 converter를 만든다.

public class Converters {
    @TypeConverter
    public static Date fromTimestamp(Long value) {
        return value == null ? null : new Date(value);
    }
 
    @TypeConverter
    public static Long dateToTimestamp(Date date) {
        return date == null ? null : date.getTime();
    }
}

이 두개의 converting 함수는 서로 converting 해주고 있다.

 @TypeConverters를 이용하여 적용할 곳에 넣는다.
// AppDatabase.java
@Database(entities = {User.java}, version = 1)
@TypeConverters({Converters.class})
public abstract class AppDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}
 
// User.java
@Entity
public class User {
 ...
 private Date birthday;
}
 
// UserDao.java
@Dao
public interface UserDao {
 ...
 @Query("SELECT * FROM user WHERE birthday BETWEEN :from AND :to")
 List<User> findUsersBornBetweenDates(Date from, Date to);
}

@Typeconverter를 지정하는 위치에 따라 scope이 달라진다.

예제처럼 Databse에 넣으면 Dao와 entity모두 영향을 받는다.

Dao 나 Entity, POJO에 넣을수도 있고, Entity의 특정 field, Dao의 특정 method or 특정 parameter에 넣을 수 있다.


Database migration

databse migration이 필요한 경우 entity class에 수정항목을 반영해야 한다. 또한 데이터를 날리지 않기 위해서 migration을 할수있는 방법을 제공한다.

migration을 등록하면, runtime에 migration을 수행하며, 정해놓은 순서대로 migration이 가능하다.

migration을 등록할 때는 시작버전과 끝버전을 넣어야 한다.

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
        .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build();
 
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, "
                + "`name` TEXT, PRIMARY KEY(`id`))");
    }
};
 
static final Migration MIGRATION_2_3 = new Migration(2, 3) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("ALTER TABLE Book "
                + " ADD COLUMN pub_year INTEGER");
    }
};

migration 코드가 없으면 Room은 DB를 그냥 rebuild한다. (기존 데이터는 날아간다.)

또한 migration에 들어가는 query는 상수에 넣지말고, 직접 넣는게 migration 로직을 유지하는데 더 좋다. 

migration이 끝나고 나면, schema에 대한 유효성 확인을 하고, 문제가 있을경우 mismatch된 부분에 정보를 담은 exception을 발생 시킨다.


반응형