본문 바로가기

Android(Kotlin) Study A부터 Z까지 등반

Room의 모든 것.

Room DB

Room의 모든 것.

SQL, No-SQL 차이는 알 것이라고 생각하고 따로 설명은 안 하겠다.

💡 SQL은 정부나 은행 등에서 아주 많이 사용한다고 한다. 어떤 풀스택 개발자는 단 1줄의 SQL 코드도 써보지 않았다고 한다. 이유가 뭘까?

 

SQL이란?

  • Structured Query Language: 데이터베이스와 대화하기 위한 언어
  • 영어랑 비슷해서 이해하기 쉽다.

왜 사람들이 SQL을 모르는 경우가 있을까?

ORM 때문이다.

ORM이란?

💡 간단하게 말하면 코틀린으로 적은 코드를 SQL문으로 변경해주는 것, 통역사 같은 존재 ex: Room(kotlin), Django(python), Sequelize, type(nodejs) ORM 등이 있다.

 

  • Object-relational mappers
  • ROOM은 SQLite의 추상레이어 위에 제공하고 있으며, SQLite의 모든 기능을 제공하면서 편한 데이터베이스의 접근을 허용
  • 컴파일 시점에서 유효성 검사 가능
  • 스키마 변경 유연성
  • LiveData, Flowable 반환 쿼리 가능(Observation 가능)
  • Maybe, Single 타입도 가능
    • Single 타입은 1개가 오면 success 아무것도 안오면 error를 타게됩니다.
    • Maybe 타입은 행이 1개 or 0개가 오거나 Update시 success -> oncomplete를 타게됩니다.
    • Flowable 타입은 어떤 행도 존재하지 않을경우 onNext나 onError을 방출하지 않습니다

 

이런 복잡한 언어 때문에 SQL Language를 따로 배워야할까?

내가 사용하는 언어의 수를 줄이는 것이 가장 이상적이다.

문제점 너무 ORM에 의존한다.

💡 시간은 아낄 수 있지만, 뭔가 안될 때, 더 빠르게 작동해야할 때 어떻게 대처해야할 지를 모른다.

 

💡 인덱스 설정하는 것만 조금 볼 줄 알아도 쿼리 응답시간의 질이 달라진다.

 

💡 따라서 SQL 자체를 알고 있어야 제대로 사용할 수 있다는 것이다. (안좋을게 없다는 뜻)

 

Room이란?

💡 Room은 스마트폰 내장 DB에 데이터를 저장하기 위해 사용하는 라이브러리 이다. 어노테이션으로 대부분을 명시하는 것 같다.

 

내장 DB는 SQLite가 있지 않나?

💡 그렇다. 그냥 한마디로 사용하기 까다로워서 Room을 권장하고 있다.

 

하지만 선택에도 살짝 고민을 해야한다.

💡 자동 로그인 여부를 저장하고 싶은데 고작 이 true/false 값을 저장하려고 Room을 사용하는 건 닭 잡는데 소 잡는 칼 쓰는 격이다. 별것도 아닌 거에 큰 노력을 들여야 한다는 것이다. 이럴 때는 Room이 아니라 sharedpreferences를 사용하면 된다.

 

💡 반대로 대량의 데이터를 처리하게 될 경우는 Room보다 Realm을 사용하면 좋다. 속도도 빠르고 안정적이고 비동기 지원이 된다는 장점이 있으나 앱 용량이 커진다는 단점이 있어 상황에 맞게 사용하면 된다. 이외에는 Room이 좋다.

 

SQLite와 차이(SQLiteOpenHelper)

  • 쿼리의 컴파일 타임 검증.
  • 상용구 코드를 줄입니다.
  • 이해하고 사용하기 쉽습니다.
  • RxJava, LiveData 및 Kotlin Coroutine과 쉽게 통합됩니다.

Rest of The App은 앱의 나머지 부분이라고 생각하면 된다.

 

Room의 사용 시나리오

응용 프로그램은 Room 데이터베이스 를 사용하여 데이터베이스와 연결된  DAO 를 가져옵니다 . 그런 다음 앱은 각 DAO를 사용 하여 데이터베이스에서 엔터티를 가져 오고 해당 엔터티에 대한 변경 사항을 데이터베이스 에 다시 저장합니다 . 마지막으로 앱은 엔터티를 사용하여 데이터베이스 내의 테이블 열에 해당하는 값을 가져오고 설정합니다 .

1. ROOM 의 3가지 개념

Database (데이터베이스)

저장하는 데이터의 집합 단위를 말한다. 데이터베이스 홀더도 포함하고 있다고 함. 데이터베이스의 엑세스 지점,

조건들

  • Be an abstract class that extends [RoomDatabase] → 추상 클래스로 만들어라
  • Include the list of entities associated with the database within the annotation. → 어노테이션 내부에 엔티티를 포함해라.
  • Contain an abstract method that has 0 arguments and returns the class that is annotated with @Dao → 인수 없이 Dao를 반환하는 메서드를 포함해라.
  • At runtime, you can acquire an instance of [Database] by calling Room.databaseBuilder() or Room.inMemoryDatabaseBuilder() → 런타임 시에 해당 메소드를 사용하여 인스턴스를 받아라.

공식문서에서는 데이터베이스 객체를 인스턴스 할 때 싱글톤으로 구현하기를 권장하고 있다.

일단 여러 인스턴스에 액세스를 꼭 해야 하는 일이 거의 없고, 객체 생성에 비용이 많이 들기 때문이다.

싱글톤으로 사용하는 예제

@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
        }
    }
}

Entity (항목)

데이터베이스 내의 테이블을 의미한다—> 한국말로 하면 '개체'인 Entity는 관련이 있는 속성들이 모여 하나의 정보 단위를 이룬 것이다.

@Entity
data class User(
  @PrimaryKey val uid: Int,
  @ColumnInfo(name = "first_name") val firstName: String?,
  @ColumnInfo(name = "last_name") val lastName: String?
)
//대충 요로코롬 생김

조건들

  • All the fields in an entity must either be public or have getter & setter methods. → 데이터 클래스 써라.
  • Entity class should have an empty constructor (if all fields are accessible) or a parameterized constructor which takes all the fields. Room can also use partial constructors. → default 생성자 등의 생성자를 넣어라.
  • Each entity class must have atleast one primary key. You can use either @PrimaryKey annotation to define single field primary key or [primaryKeys] attribute of @Entity annotation for multiple fields. You can also use [autoGenerate] property of @PrimaryKey annotation to automatically assign primary keys. → 엔티티 내부에 하나의 Primary Key를 넣어야 한다. 여러 필드를 묶어 primaryKey로 설정 할 수 있음,
@Entity(primaryKeys = arrayOf("firstName", "lastName"))

 

  • By default, Room uses the class name as the database table name. If you want the table to have a different name, set the [tableName] property of the [@Entity] annotation. Similarly, you can use the name property of the [@ColumnInfo] annotation for defining the name of columns. → Room의 클래스 이름은 Database 이름으로 사용한다고 한다. 겹치면 동기화 문제가 생긴다고 들었음
  • If you don’t want to persist any field, you can annotate them using [@Ignore] → 필드를 persist 하지 않으려면 Ignore를 사용
  • You can use the indices property of @Entity annotation to add indices to an entity. Also, you can create unique indices by setting the [unique] property of an [@Index] annotation to true. → indices 속성으로 인덱스 추가도 할 수 있고, unique속성으로 고유 인덱스를 만들 수 있다.

DAO (다오) Data Access Object

데이터베이스에 접근하는 함수(insert,update,delete, query...)를 제공한다. @Dao 사용

@Dao
interface UserDao {
    @Insert
    fun insert(user: User)
 
    @Update
    fun update(user: User)
 
    @Delete
    fun delete(user: User)
//삽입,수정,삭제 외에 다른 기능을 하는 메서드를 만드는 방법
		@Query("SELECT * FROM User") // 테이블의 모든 값을 가져와라
    fun getAll(): List<User>
 
    @Query("DELETE FROM User WHERE name = :name") // 'name'에 해당하는 유저를 삭제해라
    fun deleteUserByName(name: String)
}
**//대충 요로코롬 생겼다. interface임을 유의하자. 어노테이션이 중요하다.**

💡 UserDao를 사용하는 모든 쿼리는 호출자 스레드에서 수행됩니다. 따라서 UI(main) 스레드에서 메서드가 호출되지 않도록 주의해야 합니다. → MainThread를 Block하여 UI가 끊기면 안되기 때문(인용)

 

Type Converters

Sometimes, you might need to persist a custom data type in a single database column. You can use type converters for these type of use cases. → 쉽게 말하면 타입 변환을 해주는 것이라고 생각하면 된다.

class Converters {
  @TypeConverter
  fun fromTimestamp(value: Long?): Date? {
  return value?.let { Date(it) }
  }

  @TypeConverter
  fun dateToTimestamp(date: Date?): Long? {
  return date?.time?.toLong()
  }
}

데이터베이스 선언시 추가

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

더 많은 활용 Dao

insert - 여러 매개변수로도 받아낼 수 있다. Conflict 사용하여 충돌시 대처도 넣을 수 있음

@Dao
interface UserDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertUsers(vararg users: User)    @Insert
    fun insertBothUsers(user1: User, user2: User)    @Insert
    fun insertUsersAndFriends(user: User, friends: List<User>)
}

OnConflictStrategy에 대해서

  • OnConflictStrategy.REPLACE
  • : 이전 데이터를 교체하고 트랜잭션을 계속합니다.
  • OnConflictStrategy.ROLLBACK
  • : 트랜잭션을 롤백합니다.
  • OnConflictStrategy.ABORT
  • : 트랜잭션을 중단합니다. 트랜잭션이 롤백됩니다.
  • OnConflictStrategy.FAIL
  • : 트랜잭션을 실패합니다. 트랜잭션이 롤백됩니다.
  • OnConflictStrategy.NONE
  • : 충돌을 무시합니다.

💡 참고: ROLLBACK 및 FAIL는 더 이상 사용되지 않는다고 한다. 대신 ABORT 를 사용하라고함.

 

update

When you create a DAO method and annotate it with [@Update] Room generates an implementation that modifies a set of entities, given as parameters, in the database. It uses a query that matches against the primary key of each entity. → vararg 형식으로도 가능하다고 한다. 유저 배열을 넘길 수 있다는 뜻, 기본키로 엔티티를 찾는다.

@Dao
interface UserDao {
    @Update(onConflict = OnConflictStrategy.REPLACE)
    fun updateUsers(vararg users: User)    @Update
    fun update(user: User)
}

vararg이란?

  • 가변 인자, 설명을 위해서 간단히 예제만 봐도 이해할 것이다.
    • 일반적인 사용
    fun sum(vararg num: Int) = num.sum()
    
    fun main(args: Array<String>) {
        val n1 = sum(1)
        val n2 = sum(1, 2, 3, 4, 5)
        println(n1) // 1
        println(n2) // 15
    }
    
    • 배열을 가변인자로 넘길 때(*을 붙인다. pointer가 아님 코틀린에선 spread operator라고 부른다)
    fun showAll(vararg s: String) {
        println(s.joinToString())
    }
    
    fun main(args: Array<String>) {
        val test = arrayOf("A", "B")
        showAll(test) // error
        showAll(*test) // OK
    }
    
    • 제네릭 타입의 경우는 *을 생략해도 된다.
    fun <T> asList(vararg ts: T): List<T> {
        val result = ArrayList<T>()
        for (t in ts) // ts is an Array
            result.add(t)
        return result
    }
    
    val a = arrayOf(1, 2, 3)
    val list1 = asList(a) // OK
    val list2 = asList(-1, 0, *a, 4) // OK
    

Delete,

When you create a DAO method and annotate it with @Delete , Room generates an implementation that removes a set of entities, given as parameters, from the database. It uses the primary keys to find the entities to delete. → 얘도 vararg 가능

@Dao
interface UserDao {
    @Delete
    fun deleteUsers(vararg users: User)
}

Query

쿼리에 매개변수 전달 : 기호로 사용

@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE age BETWEEN :minAge AND :maxAge")
    fun loadAllUsersBetweenAges(minAge: Int, maxAge: Int): Array<User>

    @Query("SELECT * FROM users WHERE first_name LIKE :search " +
           "OR last_name LIKE :search")
    fun findUserWithName(search: String): List<User>
}
  • 당연하겠지만 Select로 일부를 받을 때 Data Class 형태로 받을 수 있음.
data class NameTuple(
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)@Dao
interface UserDao {
    @Query("SELECT first_name, last_name FROM users")
    fun loadFullName(): List<NameTuple>
}
  • 커서도 받을 수 있음, 직접 엑세스 하는 경우
@Dao
interface UserDao {
    @Query("SELECT * FROM users")
    fun loadAllUsers(): Cursor
}
  • 조인도 된다.
@Dao
interface BookDao {
    @Query(
        "SELECT * FROM book " +
        "INNER JOIN loan ON loan.book_id = book.id " +
        "INNER JOIN user ON user.id = loan.user_id " +
        "WHERE users.name LIKE :userName"
    )
    fun findBooksBorrowedByNameSync(userName: String): List<Book>
}

Entity Relationship in Room

You can use the [@Embedded]annotation to represent an object that you'd like to decompose into its subfields within a table(entity). You can then query the embedded fields just as you would for other individual columns. → Embedded 태그를 쓰면 data class도 Entity로 넣을 수 있다.(Address 내부 변수들이 열에 포함된다)

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

@Entity
data class User(
  @PrimaryKey val id: Int,
  val firstName: String?,
  @Embedded val address: Address?
)

💡 나머지 내용 이해 아직 못했음. 제대로 못 읽어서 링크만 남길게요.. 엔티티간의 관계 https://blog.mindorks.com/entity-relationship-in-room

 

Room에서 객체 참조를 허용하지 않는 이유

클라이언트 측에서는 이 유형의 지연 로드가 일반적으로 UI 스레드에서 발생하기 때문에실행 가능하지 않으며 UI 스레드에서 디스크에 관한 정보를 쿼리하면 상당한 성능 문제와 메모리 문제가 발생하기 때문입니다.

객체를 엔티티에 넣으면 추가 쿼리가 발생하기 때문

Room의 내부 작업

Room 데이터베이스를 생성한 후 코드를 처음 컴파일할 때 Room은 주석이 달린 클래스의 구현 @Database과 @Dao를 자동 생성합니다. UserDatabase및 UserDao의 구현은 Room의 annotation 프로세서에 의해 자동 생성됩니다. → 이게 ORM의 과정인듯?

💡 참고: 자동 생성 코드는 build/generated/source/kapt/ 폴더에서 찾을 수 있다.

 

실제 처리가 일어나는 클래스들

Database

public final class UserDatabase_Impl extends UserDatabase {
  private volatile UserDao _userDao;

  @Override
  protected SupportSQLiteOpenHelper createOpenHelper(DatabaseConfiguration configuration) {
    //Implementation
  }//Room.databaseBuilder().build()할 때 SupportSQLiteOpenHelper를 반환

  @Override
  protected InvalidationTracker createInvalidationTracker() {
    //Implementation
  }
//수정된 테이블 목록을 유지하고 이러한 테이블에 대해 콜백에 알리는 무효화 추적기를 생성
  @Override
  public void clearAllTables() {
    //Implementation
  }
//모든 테이블에서 데이터를 삭제하는 동작을 구현.
  @Override
  public UserDao userDao() {
    //Implementation
  }
}
//UserDao_Impl 생성해주는것(존재하지 않는 경우에만)

userDao()

@Override
public UserDao userDao() {
  if (_userDao != null) {
    return _userDao;
  } else {
    synchronized(this) {
      if(_userDao == null) {  // 존재하지 않는 경우에만
        _userDao = new UserDao_Impl(this);
      }
      return _userDao;
    }
  } 
}
@Override
public void insertAll(final User... users) {
  __db.assertNotSuspendingTransaction();
  __db.beginTransaction();
  try {
    __insertionAdapterOfUser.insert(users);
    __db.setTransactionSuccessful();
  } finally {
    __db.endTransaction();
  }
}
@Override
public void delete(final User user) {
  __db.assertNotSuspendingTransaction();
  __db.beginTransaction();
  try {
    __deletionAdapterOfUser.handle(user);
    __db.setTransactionSuccessful();
  } finally {
    __db.endTransaction();
  }
}

Dao

public final class UserDao_Impl implements UserDao {
  private final RoomDatabase __db;

  private final EntityInsertionAdapter<User> __insertionAdapterOfUser;

  private final EntityDeletionOrUpdateAdapter<User> __deletionAdapterOfUser;

  public UserDao_Impl(RoomDatabase __db) {
    this.__db = __db;   //트랜잭션이나 쿼리용 인스턴스
    this.__insertionAdapterOfUser = new EntityInsertionAdapter<User>(__db) {
      //Implementation
    }; //insertAll()에 사용
    this.__deletionAdapterOfUser = new EntityDeletionOrUpdateAdapter<User>(__db) {
      //Implementation
    };
  }//delete()에 사용

  @Override
  public void insertAll(final User... users) {
    //Implementation
  }

  @Override
  public void delete(final User user) {
    //Implementation
  }

  @Override
  public List<User> getAll() {
    //Implementation
  }

  @Override
  public List<User> loadAllByIds(final int[] userIds) {
    //Implementation
  }

  @Override
  public User findByName(final String first, final String last) {
    //Implementation
  }
}

Room은 LiveData의 통합을 매우 쉽게 지원한다고 함. DAO 메서드에서 LiveData를 반환하기만 하면 Room이 다른 모든 것을 처리한다.

Observable queries with LiveData

  • 데이터가 변경될 때 앱의 UI가 자동으로 업데이트되기를 원하는 경우가 많으면 LiveData로 반환하면 된다.
@Dao
interface UserDao {
    @Query("SELECT * FROM users")
    fun getAllLiveData(): LiveData<List<User>>
}
  • Room generates all necessary code to update the [LiveData] when the database is updated. → Room은 데이터베이스가 업데이트 될 때 LiveData도 업데이트 해줄 수 있는 코드가 있다.
@Override
public LiveData<List<User>> getAllLiveData() {
  final String _sql = "SELECT * FROM users";
  final RoomSQLiteQuery _statement = RoomSQLiteQuery.acquire(_sql, 0);
  return __db.getInvalidationTracker().createLiveData(new String[]{"users"}, false, new Callable<List<User>>() {
    @Override
    public List<User> call() throws Exception {
      final Cursor _cursor = DBUtil.query(__db, _statement, false, null);
      try {
        final int _cursorIndexOfUid = CursorUtil.getColumnIndexOrThrow(_cursor, "uid");
        final int _cursorIndexOfFirstName = CursorUtil.getColumnIndexOrThrow(_cursor, "first_name");
        final int _cursorIndexOfLastName = CursorUtil.getColumnIndexOrThrow(_cursor, "last_name");
        final List<User> _result = new ArrayList<User>(_cursor.getCount());
        while(_cursor.moveToNext()) {
          final User _item;
          final int _tmpUid;
          _tmpUid = _cursor.getInt(_cursorIndexOfUid);
          final String _tmpFirstName;
          _tmpFirstName = _cursor.getString(_cursorIndexOfFirstName);
          final String _tmpLastName;
          _tmpLastName = _cursor.getString(_cursorIndexOfLastName);
          _item = new User(_tmpUid,_tmpFirstName,_tmpLastName);
          _result.add(_item);
        }
        return _result;
      } finally {
        _cursor.close();
      }
    }

    @Override
    protected void finalize() {
      _statement.release();
    }
  });
}

정말 그만 알아보고 싶지만, 조금만 알아보자.

__db.getInvalidationTracker().createLiveData() 로 아래의 것들을 받아온다.

  • tableNames is used for by RoomTrackingLiveData to observe for changes. → 변경사항 관찰용
  • inTransaction indicates whether the query has to be performed as a transaction or not. → 트랜잭션 수행 여부 Boolean
  • computeFunction is a callable which is called whenever there are any changes in observed tables. → 변경사항 있을 때 호출되는 함수, call()로 실행
  • 대충 정리하면 커서로 DBUtil 실행해서 뭐 State변경된 것만 불러와서 result에 추가하고 마지막에 반환해주는 구조 or 그냥 데이터 전체를 result에 새로 때려박아서 반환해주는 구조인듯하다.

출처

https://medium.com/androiddevelopers/room-rxjava-acb0cd4f3757

https://namget.tistory.com/entry/안드로이드-ROOM-라이브러리-사용하기-코루틴

https://www.youtube.com/watch?v=z9chRlD1tec

https://medium.com/androiddevelopers/7-pro-tips-for-room-fbadea4bfbd1

https://www.youtube.com/watch?v=SKWh4ckvFPM