본문 바로가기

안드로이드 Android/Android Study Jams

7. Databases and RecyclerView - Create a Room database

github.com/google-developer-training/android-kotlin-fundamentals-starter-apps/tree/master/TrackMySleepQuality-Starter

 

google-developer-training/android-kotlin-fundamentals-starter-apps

android-kotlin-fundamentals-starter-apps. Contribute to google-developer-training/android-kotlin-fundamentals-starter-apps development by creating an account on GitHub.

github.com

 

Create the SleepNight entity

Android에서 데이터는 데이터 클래스에서 보여진다. 이 데이터는 함수 호출을 사용해 접근되고 수정될 수 있다. 그러나 데이터베이스 세계에서는 데이터에 접근하고 수정하기 위해 엔터티와 쿼리가 필요하다.

  • 엔터티는 데이터베이스에 저장하기 위해 속성값과 함께 객체나 개념을 나타낸다. 이 어플리케이션 코드에서는 테이블을 정의하는 엔터티 클래스가 필요하고 그 클래스의 각 인스턴스는 그 테이블에서 행을 나타낸다. 엔터티 클래스는 Room에게 데이터베이스에서 어떻게 정보를 제공하고 정보와 상호작용할 지 알려주는 mapping을 가진다. 앱에서 엔터티는 밤잠에 대한 정보를 갖고 있으려고 한다.
  • 쿼리는 데이터베이스 테이블이나 테이블의 조합으로부터 데이터나 정보를 요청하는 것 혹은 데이터에 액션을 취하도록 요청하는 것이다. 일반적인 쿼리는 생성, 열람, 수정 그리고 삭제(CRUD)하는 엔터티를 위한 것이다.
더보기

attribute: 속성

property: 속성값

앱의 사용자 경험(UX)은 (다른 일반적인 유스케이스와 유사하게) 어떤 데이터를 로컬에서 지속시킴으로써 대단히 이익을 얻을 수 있다. 관련된 데이터 조각들을 캐싱하는 것은 오프라인에서도 사용자가 앱을 사용할 수 있도록 한다. 앱이 서버에 의존한다면 캐싱은 사용자가 오프라인인 동안 로컬에서 계속되는 컨텐트를 수정할 수 있게 한다. 앱이 다시 인터넷에 연결되면 그 캐싱된 변화들은 백그라운드에서 끊김 없이 서버와 동기화된다.

Room은 Kotlin 데이터 클래스에서 SQLite 테이블에 저장될 수 있는 엔터티로, 그리고 함수 선언에서 SQL 쿼리로 만들기 위한 모든 힘든 일을 처리한다.

각 엔터티를 annotated data class로, 그리고 그 엔터티와의 상호작용을 annotated interface로 정의해야 한다. 이런 클래스를 data access object(DAO)라고 한다. Room은 데이터베이스에서 테이블을 생성하고 데이터베이스에서 동작하는 쿼리를 생성하기 위해 annotated class를 사용한다.

 

Step 1: SleepyNight 엔터티 생성하기

1. database 패키지에 있는 SleepNight.kt 파일을 연다. SleepNight 데이터 클래스를 생성한다.

2. 클래스 선언 앞에 @Entity로 데이터 클래스 주석을 단다. 이 주석은 인자가 몇 개 있을 수 있다. default로(@Entity에 인자가 없으면) 테이블 이름은 클래스와 같아진다. 이 tableName 인자는 optional이지만 있는 것이 좋다. @Entity의 다른 인자가 몇 가지 더 있으니 문서를 참고할 것.

3. nightId를 기본키로 인식하려면 @PrimaryKey 주석을 달면 된다. Room이 각 엔터티에 대해 ID를 생성하도록 autoGenerate를 true로 설정한다. 이는 각 밤이 유일하다는 것을 보장한다.

4. 나머지 프라퍼티에 @ColumnInfo 주석을 단다. 프라퍼티 이름을 매개변수를 사용해 커스터마이징 한다.

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "daily_sleep_quality_table")
data class SleepNight(
       @PrimaryKey(autoGenerate = true)
       var nightId: Long = 0L,

       @ColumnInfo(name = "start_time_milli")
       val startTimeMilli: Long = System.currentTimeMillis(),

       @ColumnInfo(name = "end_time_milli")
       var endTimeMilli: Long = startTimeMilli,

       @ColumnInfo(name = "quality_rating")
       var sleepQuality: Int = -1
)

 

Create the DAO

Room 데이터베이스를 사용할 때, 코드에서 Kotlin 함수를 정의하고 호출함으로써 데이터베이스에 질의한다. 이 Kotlin 함수는 SQL 질의에 대응된다. 어노테이션을 사용하여 이 대응을 정의하고 Room이 필요한 코드를 생성한다.

DAO를 데이터베이스에 접근하기 위한 커스텀 인터페이스라고 생각하면 된다.

 

Step 1: SleepDatabase DAO 생성하기

SleepDatabaseDao.kt

...
@Dao
interface SleepDatabaseDao {
    @Insert
    fun insert(night: SleepNight)

    @Update
    fun update(night: SleepNight)

    @Query("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
    fun get(key: Long): SleepNight?

    @Query("DELETE FROM daily_sleep_quality_table")
    fun clear()

    @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC LIMIT 1")
    fun getTonight(): SleepNight?

    @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC")
    fun getAllNights(): LiveData<List<SleepNight>>
}

쿼리문의 :key는 함수의 인자인 key를 참조한다. (colon notation)

DELETE 문은 테이블 자체를 삭제하는 것이 아니라 특정 아이템을 삭제한다. 단점은 테이블에 있는 것을 가져오거나 알아야 한다는 것이다. @Delete 어노테이션은 특정 엔트리를 다룰 때는 편하지만 테이블에서 모든 엔트리를 삭제하기에는 비효율적이다.

getAllNights()는 SleepNight 엔터티의 리스트를 LiveData로 리턴한다. Room은 이 LiveData를 업데이트된 상태로 유지한다. 이는 데이터를 명시적으로 한 번만 얻으면 된다는 의미이다.

 

Create and test a Room database

  • 아래 코드에서 SleepDatabaseDao를 리턴하는 sleepDatabaseDao의 body는 Room이 companion object를 바탕으로 자동으로 생성한다.
  • 앱 전체에서 Room 데이터베이스의 인스턴스는 하나만 필요하므로 RoomDatabase를 싱글톤으로 만든다.

 

Tip: 어떤 Room 데이터베이스에서든 코드는 크게 차이 없으므로 이 코드를 템플릿으로 사용할 수 있다.

 

Step 1: Database 생성하기

SleepDatabase.kt

...
@Database(entities = [SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase: RoomDatabase() {
    abstract val sleepDatabaseDao: SleepDatabaseDao

    companion object {
        @Volatile
        private var INSTANCE: SleepDatabase? = null

        fun getInstance(context: Context): SleepDatabase {
            synchronized(this) {
                var instance = INSTANCE

                if (instance == null) {
                    instance = Room.databaseBuilder(
                            context.applicationContext,
                            SleepDatabase::class.java,
                            "sleep_history_database"  // name for the database
                    ).fallbackToDestructiveMigration().build()
                    
                    INSTANCE = instance  // smart cast
                }

                return instance
            }
        }
    }
}

(SleepNight::class 같은 문법은 무엇을 의미하는지 잘 모르겠다.)

@Database에 인자로 엔터티의 리스트로 된 아이템으로 SleepNight을 넘겨 준다.

companior object는 클라이언트가 클래스의 인스턴스를 생성하지 않고 데이터베이스를 생성하거나 얻는 메서드에 접근할 수 있게 한다. 데이터베이스를 제공하는 것이 이 클래스의 유일한 목적이므로 인스턴스를 생성할 이유가 전혀 없다.

INSTANCE 변수는 SleepDatabase 인스턴스가 하나 생성될 때마다 데이터베이스에 대한 참조를 유지한다. 이것은 계산적으로 낭비인 반복적으로 데이터베이스에 연결이 되는 것을 피하게 해준다.

@Volatile 어노테이션을 달면 변수가 절대 캐싱되지 않고 모든 쓰기와 읽기가 메인메모리를 통해 수행된다. 이는 INSTANCE의 값이 항상 최신이고 모든 실행 스레드와 같도록 해준다. 이는 한 스레드에 의한 INSTANCE의 변화는 다른 모든 스레드에 즉각적으로 보여지고 두 스레드가 각자 캐시에서 같은 엔터티를 업데이트하는 문제를 일으키는 상황이 생기지 않는 것을 의미한다.

다중 스레드는 동시에 데이터베이스 인스턴스를 필요로할 수 있어서 한 개가 아닌 두 개의 데이터베이스를 만들어낼 수 있다. 데이터베이스를 얻기 위해 코드를 synchronized 안에 넣는 것은 한 번에 하나의 실행 스레드만 이 코드 블럭에 들어갈 수 있다는 것을 의미한다. 이는 데이터베이스가 한 번만 초기화되도록 한다.

Kotlin의 smart cast는 로컬 변수에만 가능하다.

instance의 type mismatch error를 해결하기 위해서 migration strategy와 build()를 추가해야 한다.

일반적으로, 스키마가 변할 때를 위해 migration strategy를 적용한 migration object를 제공해야 한다. migration object는 이전 스키마로 된 모든 행을 가져오고 새로운 스키마로 된 행으로 변경해 데이터가 소실되지 않는 방법을 정의하는 객체이다. 간단한 솔루션은 데이터베이스를 destroy하고 rebuild하는 것이다. 이는 데이터가 소실된다는 뜻이다.

 

Step 2: SleepDatabase 테스트하기

스타터 앱의 androidTest 폴더는 Android 기기 장치를 수반하는 유닛 테스트를 포함(테스트는 Android 프레임워크를 필요로 한다는 뜻)하므로 실제 혹은 가상 기기에서 테스트를 실행해야 한다. 물론 Android 프레임워크를 수반하지 않는 순수한 유닛 테스트를 실행할 수도 있다.

androidTest 폴더의 SleepDatabaseTest 파일을 열고 주석을 해제한다.

...
@RunWith(AndroidJUnit4::class)
class SleepDatabaseTest {

    private lateinit var sleepDao: SleepDatabaseDao
    private lateinit var db: SleepDatabase

    @Before
    fun createDb() {
        val context = InstrumentationRegistry.getInstrumentation().targetContext
        // Using an in-memory database because the information stored here disappears when the
        // process is killed.
        db = Room.inMemoryDatabaseBuilder(context, SleepDatabase::class.java)
                // Allowing main thread queries, just for testing.
                .allowMainThreadQueries()
                .build()
        sleepDao = db.sleepDatabaseDao
    }

    @After
    @Throws(IOException::class)
    fun closeDb() {
        db.close()
    }

    @Test
    @Throws(Exception::class)
    fun insertAndGetNight() {
        val night = SleepNight()
        sleepDao.insert(night)
        val tonight = sleepDao.getTonight()
        assertEquals(tonight?.sleepQuality, -1)
    }
}

 

테스팅 코드의 예행연습(재사용할 수 있는 코드이다.)

  • @RunWith는 테스트를 설정하고 실행하는 프로그램인 test runner를 인식한다.
  • 설정하는 동안 @Before이 달린 함수가 실행되고 SleepDatabaseDao와 함께 in-memory SleepDatabase를 생성한다. "in-memory"는 이 데이터베이스가 파일 시스템에 저장되지 않고 테스트가 실행된 후에 삭제된다는 것을 의미한다.
  • 또한, in-memory 데이터베이스를 빌드할 때, 코드는 테스트에 특화된 메서드인 allowMainThreadQueries를 호출한다. 기본적으로, 메인 스레드에서 쿼리를 실행하려고 하면 에러가 발생한다. 이 메서드는 테스트 동안에만 해야 하는 메인 스레드에서 테스트 실행하기를 가능하도록 해준다.
  • @Test 어노테이션이 달린 테스트 메서드에서, SleepNight을 생성, 삽입 그리고 검색하고 그들이 같다고 단언한다. 무언가가 잘못 되면 예외 처리를 한다. 실제 테스트에서, 여러 개의 @Test 메서드를 가질 수 있다.
  • 테스팅이 끝나면 @After 어노테이션이 달린 함수가 데이터베이스 닫기를 수행한다.

Project 페인에서 테스트 파일을 우클릭하고 Run 'SleepDatabaseTest'를 선택한다.

테스트가 수행되고 나면 모든 테스트가 통과되었는지 확인한다.

 

Coding challenge

나머지 DAO 메서드를 실행하는 테스트 추가하고 실행하기

update()와 getAllNight()는 어떻게 테스트해야 할지 모르겠다.

github.com/Hyesung82/android-kotlin-fundamentals-starter-apps/blob/master/TrackMySleepQuality-Starter/app/src/androidTest/java/com/example/android/trackmysleepquality/SleepDatabaseTest.kt

 

Hyesung82/android-kotlin-fundamentals-starter-apps

android-kotlin-fundamentals-starter-apps. Contribute to Hyesung82/android-kotlin-fundamentals-starter-apps development by creating an account on GitHub.

github.com

 

Homework

Answer these questions

Q1. 2 / Q2. 3 / Q3. 1, 2, 4 / Q4. 2, 3, 4 / Q5. 1, 2