[Android] AAC 파헤치기 - 6) Room Database

문승연·2023년 8월 12일
0

Android-AAC

목록 보기
6/7

Room이란?

안드로이드 앱을 사용하다보면 네트워크 연결이 끊어져도 새로운 사진만 로딩 중이고 이전에 봤던 사진들은 계속 보이는 것을 경험할 수 있다.
이는 캐싱된 데이터가 로컬 데이터베이스에 남아서 보여지고있기 때문인데, 이처럼 Android에서는 SQLite이라는 가벼운 내부 DB를 이용할 수 있다.

Room Database는 SQLite를 완벽히 활용하면서 원활한 데이터베이스 액세스가 가능하도록 SQLite의 추상화 계층을 제공하는 AAC 라이브러리이다.

Room을 사용하면 얻을 수 있는 이점으로는 다음과 같다.
1. SQL 쿼리의 컴파일 시간 확인
2. 반복적이고 오류가 발생하기 쉬운 상용 코드를 최소화하는 주석
3. 간소화된 데이터베이스 이전 경로

이러한 장점들로 인해 Android 공식 문서에서는 SQLite API를 직접 사용하는 것보다 Room 데이터베이스를 활용하는 것을 권장하고 있다.

Room 기본 구성요소

Room 은 크게 3가지 주요 구성요소로 이루어져있다.

  • 데이터베이스 : 데이터베이스를 보유하고 앱의 영구 데이터와의 기본 연결을 위한 기본 액세스 포인트 역할을 한다.
  • Entity : 앱 데이터베이스의 테이블을 나타낸다.
  • DAO(Data Access Objects) : 앱이 데이터베이스의 데이터를 쿼리, 업데이트, 삽입, 삭제하는 데 사용할 수 있는 메소드를 제공한다.

데이터베이스 클래스는 데이터베이스와 연관된 DAO 인스턴스를 앱에 제공합니다. 이후 앱은 DAO 를 통해서 테이블을 가리키는 Entity 인스턴스를 가져올 수 있습니다. 그리고 앱에서 Entity 클래스를 이용하여 테이블 내에 데이터를 추가하거나, 데이터를 수정합니다.

실습 (Codelab)

여기부터는 안드로이드 Codelab 뷰를 사용한 Android Room - Kotlin 내용을 바탕으로 작성하였습니다.

먼저 아직 Coroutine 에 대한 공부가 부족한 상태인데다가 Room 의 활용법을 익히는데 우선을 두고 있기 때문에 Room 이 아닌 다른 기능의 설명은 생략한다.

항목(Entity) 만들기

Word 라는 데이터 클래스를 생성한다.

data class Word(val word: String)

Word 클래스를 Room 데이터베이스에서 사용하려면 Kotlin 주석을 사용하여 연결해야한다.

@Entity(tableName = "word_table")
class Word(@PrimaryKey @ColumnInfo(name = "word") val word: String)

위에 사용된 주석의 기능은 아래와 같다.

  • @Entity(tableName = "word_table")@Entity 클래스는 SQLite 테이블을 나타냅니다. 클래스 선언에 주석을 달아 항목임을 나타냅니다. 클래스 이름과 다르게 하려면 테이블 이름을 지정하면 됩니다. 여기서는 테이블의 이름을 'word_table'로 지정합니다.
  • @PrimaryKey 모든 항목에는 기본 키 가 필요합니다. 작업을 간단하게 하기 위해 각 단어는 자체 기본 키 역할을 합니다.
  • @ColumnInfo(name = "word") 멤버 변수 이름과 다르게 하려는 경우 테이블의 열 이름을 지정합니다. 여기서는 열 이름을 'word'로 지정합니다.

DAO 만들기

DAO란?
SQL 쿼리를 지정하여 메소드 호출과 연결하는 객체를 DAO라고 한다. DAO는 항상 인터페이스 or 추상 클래스이어야 한다.

다음과 같은 쿼리를 제공하는 DAO를 작성해보자.

  • 모든 단어를 알파벳순으로 정렬
  • 단어 삽입
  • 모든 단어 삭제
@Dao
interface WordDao {

    @Query("SELECT * FROM word_table ORDER BY word ASC")
    fun getAlphabetizedWords(): List<Word>

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(word: Word)

    @Query("DELETE FROM word_table")
    suspend fun deleteAll()
}

여기서 데이터베이스 변경사항을 관찰하여 대응할 수 있도록 코루틴(Coroutines)을 활용한다. 코루틴에 대해서는 나중에 자세히 알아보기로 하고 여기서는 코루틴의 Flow 라는 비동기 시퀀스를 사용한다는 것만 알아두자

   @Query("SELECT * FROM word_table ORDER BY word ASC")
   fun getAlphabetizedWords(): Flow<List<Word>>

Room 데이터베이스 추가

Room 데이터베이스 클래스는 추상 클래스이고 RoomDatabase 를 확장해야 합니다. 일반적으로 전체 앱에 Room 데이터베이스 인스턴스가 하나만 있으면 됩니다.

// Annotates class to be a Room Database with a table (entity) of the Word class
@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
public abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   companion object {
        // Singleton prevents multiple instances of database opening at the
        // same time.
        @Volatile
        private var INSTANCE: WordRoomDatabase? = null

        fun getDatabase(context: Context): WordRoomDatabase {
            // if the INSTANCE is not null, then return it,
            // if it is, then create the database
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java,
                        "word_database"
                    ).build()
                INSTANCE = instance
                // return instance
                instance
            }
        }
   }
}

Repository 만들기

Repository란?

Repository 클래스는 여러 데이터 소스 액세스를 추상화합니다. Repository는 아키텍처 구성요소 라이브러리의 일부는 아니지만 코드 분리와 아키텍처를 위한 권장사항입니다. Repository 클래스는 나머지 애플리케이션의 데이터에 액세스를 위한 깔끔한 API를 제공합니다.

// Declares the DAO as a private property in the constructor. Pass in the DAO
// instead of the whole database, because you only need access to the DAO
class WordRepository(private val wordDao: WordDao) {

    // Room executes all queries on a separate thread.
    // Observed Flow will notify the observer when the data has changed.
    val allWords: Flow<List<Word>> = wordDao.getAlphabetizedWords()

    // By default Room runs suspend queries off the main thread, therefore, we don't need to
    // implement anything else to ensure we're not doing long running database work
    // off the main thread.
    @Suppress("RedundantSuspendModifier")
    @WorkerThread
    suspend fun insert(word: Word) {
        wordDao.insert(word)
    }
}

ViewModel 구현

ViewModel 을 사용하여 Repository 와 UI 간의 통신 센터 역할을 하게 만든다.

class WordViewModel(private val repository: WordRepository) : ViewModel() {

    // Using LiveData and caching what allWords returns has several benefits:
    // - We can put an observer on the data (instead of polling for changes) and only update the
    //   the UI when the data actually changes.
    // - Repository is completely separated from the UI through the ViewModel.
    val allWords: LiveData<List<Word>> = repository.allWords.asLiveData()

    /**
     * Launching a new coroutine to insert the data in a non-blocking way
     */
    fun insert(word: Word) = viewModelScope.launch {
        repository.insert(word)
    }
}

class WordViewModelFactory(private val repository: WordRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(WordViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return WordViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

Repository 및 데이터베이스 인스턴스화

앱에 데이터베이스 인스턴스Repository 인스턴스를 싱글톤으로 사용한다. 이 작업을 위해 인스턴스를 Application 클래스의 멤버로 생성한다. 그러면 매번 구성하지 않고 필요할 때마다 Application 에서 가져올 수 있다.

class WordsApplication : Application() {
    // Using by lazy so the database and the repository are only created when they're needed
    // rather than when the application starts
    val database by lazy { WordRoomDatabase.getDatabase(this) }
    val repository by lazy { WordRepository(database.wordDao()) }
}

데이터베이스 채우기

현재 데이터베이스에 데이터가 없는 상태이다.

데이터를 추가하는 2가지 방법은

  1. 데이터베이스를 생성할때 데이터를 추가한다.
  2. 단어(데이터)를 추가하는 Activity 를 추가한다.

앱을 생성할때마다 모든 컨텐츠를 삭제하고 데이터베이스를 다시 채우려면 RoomDatabase.Callback 을 만들고 onCreate() 를 재정의한다.

Room 데이터베이스 작업은 UI 스레드에서 할 수 없으므로 onCreate() 는 IO Dispatcher에서 코루틴을 실행한다.

코루틴 관련 동작을 WordApplication 클래스와 WordRoomDatabase 클래스에 추가해주고 WordRoomDatabase 에 콜백을 만든다.

private class WordDatabaseCallback(
    private val scope: CoroutineScope
) : RoomDatabase.Callback() {

    override fun onCreate(db: SupportSQLiteDatabase) {
        super.onCreate(db)
        INSTANCE?.let { database ->
            scope.launch {
                populateDatabase(database.wordDao())
            }
        }
    }

    suspend fun populateDatabase(wordDao: WordDao) {
        // Delete all content here.
        wordDao.deleteAll()

        // Add sample words.
        var word = Word("Hello")
        wordDao.insert(word)
        word = Word("World!")
        wordDao.insert(word)

        // TODO: Add your own words!
    }
}

마지막으로 Room.databaseBuilder()에서 .build()를 호출하기 직전에 데이터베이스 빌드 시퀀스에 콜백을 추가한다.

.addCallback(WordDatabaseCallback(scope))

데이터와 연결

사용자가 입력하는 새 단어를 저장하고 RecyclerView 에 단어 데이터베이스의 현재 콘텐츠를 표시하여 UI를 데이터베이스에 연결한다.

MainActivityViewModel을 생성한다.

private val wordViewModel: WordViewModel by viewModels {
    WordViewModelFactory((application as WordsApplication).repository)
}

WordViewModel 의 allWords LiveDataObserver 를 추가하여 데이터가 변경되면 UI를 변경할 수 있도록 한다.

wordViewModel.allWords.observe(this) { words ->
            // Update the cached copy of the words in the adapter.
            words?.let { adapter.submitList(it) }
        }

NewWordActivity 에서 단어를 추가하고 오면 MainActivity 에서 적용할 수 있도록 onActivityResult() 코드를 추가한다.

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
        data?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let {
            val word = Word(it)
            wordViewModel.insert(word)
        }
    } else {
        Toast.makeText(
            applicationContext,
            R.string.empty_not_saved,
            Toast.LENGTH_LONG).show()
    }
}

MainActivity 구성을 완료하면 앱 구성이 마무리된다.

이 앱의 구조를 요약하면 아래 이미지와 같다.

앱의 구성요소는 다음과 같다.

MainActivity : RecyclerViewWordListAdapter 를 사용하여 목록에 단어를 표시합니다. MainActivity 에는 데이터베이스의 단어를 관찰하고 변경될 때 알림을 받는 Observer 가 있습니다.
NewWordActivity : 새 단어를 목록에 추가합니다.
WordViewModel : 데이터 영역에 액세스하는 메서드를 제공하고 MainActivity 에서 관찰자 관계를 설정할 수 있도록 LiveData 를 반환합니다.
LiveData<List<Word>> : UI 구성요소에서 자동 업데이트를 가능하게 합니다. flow.toLiveData() 를 호출하여 Flow 에서 LiveData 로 변환할 수 있습니다.
Repository : 하나 이상의 데이터 소스를 관리합니다. RepositoryViewModel 이 기본 데이터 제공자와 상호작용하는 메서드를 노출합니다. 이 앱에서는 백엔드가 Room 데이터베이스입니다.
Room: SQLite 데이터베이스의 래퍼이고 이를 구현합니다. Room은 개발자가 직접 해야 했던 많은 작업을 처리합니다.
DAO: 메서드 호출을 데이터베이스 쿼리에 매핑하므로 저장소가 getAlphabetizedWords() 와 같은 메서드를 호출할 때 Room에서 `SELECT
FROM word_table ORDER BY word ASC를 실행할 수 있습니다**.** DAO는 데이터베이스의 변경사항에 관한 알림을 받으려고 할 때 일회성 요청의suspend쿼리와Flow쿼리를 노출할 수 있습니다.Word: 단일 단어가 포함되는 항목 클래스입니다. Views, Activities, FragmentsViewModel`을 통해서만 데이터와 상호작용합니다. 따라서 데이터의 출처는 중요하지 않습니다.

레퍼런스
1. Room을 사용하여 로컬 데이터베이스에 데이터 저장
2. [Android] Room 이해 및 활용
3. [Kotlin] MVVM Room Database 사용법 및 사용예제: Entity, RoomDatabase, DAO, repository, ViewModel, coroutine, MVVM, 구성하기
4. 뷰를 사용한 Android Room - Kotlin

profile
"비몽(Bemong)"이라는 앱을 개발 및 운영 중인 안드로이드 개발자입니다.

0개의 댓글