Tech & Programming/모바일(Android, Flutter)

[안드로이드]Room 의 기본

소스코드 요리사 2022. 3. 2. 00:11

안드로이드의 감을 잊지 않고 꾸준하게 공부를 동기부여를 위해 NextStep의 안드로이드 아키텍처 강의를 듣고 있다.
최근 mvvm 과제를 하면서 Room 을 사용할 일이 생겨서 기본적인 내용을 정리해 보았다.

원래 원노트에 정리해서 블로그에 옮길 때 스타일이 많이 깨져서 블로그에 공부한 내용을 올리기가 어려웠는데, 이제 VS CODE 의 플러그인으로 Markdown 으로 작성 후 원노트에 다가 배포하는 형태로 노트들을 적고 있어서 복사-붙여넣기만 하면되기에 블로그에도 한번 올려본다.

Room 을 처음 사용하는 사람들에게 도움이 되었으면 합니다.


Room 의 기본

Room?

  • SQLite 를 손쉽게 사용할 수 있도록 추상화 계층을 제공
  • ORM 라이브러리의 일종으로 JPA 와 유사한 것으로 보임

사용방법

  • 안드로이드 모듈에서 동작
  • build.gradle 파일에 아래 종속성 추가
dependencies {
    def room_version = "2.4.1"

    implementation "androidx.room:room-runtime:$room_version"
    annotationProcessor "androidx.room:room-compiler:$room_version"

    // optional - RxJava2 support for Room
    implementation "androidx.room:room-rxjava2:$room_version"

    // optional - RxJava3 support for Room
    implementation "androidx.room:room-rxjava3:$room_version"

    // optional - Guava support for Room, including Optional and ListenableFuture
    implementation "androidx.room:room-guava:$room_version"

    // optional - Test helpers
    testImplementation "androidx.room:room-testing:$room_version"

    // optional - Paging 3 Integration
    implementation "androidx.room:room-paging:2.4.1"
}

ROOM 을 사용하며 만난 빌드 및 테스트 진행 시 발생오류

kapt 미사용 시 오류 발생

위처럼 하고 Build 를 하면 java.lang.RuntimeException: cannot find implementation for hbs.com.timetablescreen.Utils.AppDataBase. AppDataBase_Impl does not exist 와 같은 오류가 발생한다.
이 때는 gradle 파일 상단에 apply plugin: 'kotlin-kapt' 를 삽입하고, dependencies 에 kapt 'android.arch.persistence.room:compiler:1.1.1' 추가한다.

테스트 시 META 오류 발생

Execution failed for task ':data:mergeDebugAndroidTestJavaResource'.
> A failure occurred while executing com.android.build.gradle.internal.tasks.MergeJavaResWorkAction
   > 2 files found with path 'META-INF/AL2.0' from inputs:
      - /Users/parkjunpil/.gradle/caches/transforms-3/ee1541494a8030d5cbb3f3cb5379c401/transformed/jetified-jna-platform-5.5.0.jar
      - /Users/parkjunpil/.gradle/caches/transforms-3/0725732782c7283c5a3e2fbe9a126f9d/transformed/jetified-jna-5.5.0.jar
     Adding a packagingOptions block may help, please refer to
     https://google.github.io/android-gradle-dsl/current/com.android.build.gradle.internal.dsl.PackagingOptions.html
     for more information

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

테스트 실행 시 위와 같이 오류가 발생하는 경우 build.gradle 에 아래를 추가한다.

android {
     packagingOptions {
        pickFirst 'META-INF/AL2.0'
        pickFirst 'META-INF/LGPL2.1'
    }
}

Room 의 기본요소?

1. 데이터베이스 클래스
데이터베이스를 보유하고, 앱의 영구데이터와의 기본 연결을 위한 기본 액세스 포인트 역할. 즉, 데이터베이스 클래스는 데이터베이스와 연결된 DAO 인스턴스를 앱에 제공

아래 코드는 내가 NextStep MVVM 과제를 하면서 작성한 코드로 데이터베이스 클래스 의 역할을 하는 코드이다.

private const val DATABASE_NAME = "calculator.db"

@Database(version = 1, entities = [History::class])
abstract class CalculatorDatabase : RoomDatabase() {
    abstract fun getHistoryDao(): HistoryDao

    companion object {
        @Volatile
        private var instance: CalculatorDatabase? = null

        fun getInstance(context: Context): CalculatorDatabase {
            return instance ?: synchronized(this) {
                instance ?: buildDatabase(context).also { instance = it }
            }
        }

        private fun buildDatabase(context: Context): CalculatorDatabase {
            return Room.databaseBuilder(
                context,
                CalculatorDatabase::class.java,
                DATABASE_NAME
            ).build()
        }
    }
}

2. 데이터 항목
앱 데이터 베이스의 테이블을 나타내며 엔티티의 정의

아래 코드는 내가 NextStep MVVM 과제를 하면서 작성한 코드로 Room 항목을 사용하여 '데이터 항목' 의 역할을 하는 코드이다.
테이블의 스키마를 나타내고, DATA 객체 그자체로 쓰이기도 한다.

@Entity(tableName = "history")
data class History(
    @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val historyId: Int,
    @ColumnInfo(name = "formula") val formula: String,
    @ColumnInfo(name = "calculate_result") val calculateResult: String
) {
    constructor(formula: String, calculateResult: String): this(0, formula, calculateResult)
}

3. 데이터 액세스 개체 - DAO
앱이 데이터베이스의 데이터를 쿼리, 업데이트, 삽입, 삭제를 하는데 사용할 수 있는 메소드를 제공

아래 코드는 내가 NextStep MVVM 과제를 하면서 작성한 코드로 Room 항목을 사용하여 '데이터 엑세스' 의 역할을 하는 코드이다.
즉 CRUD 를 하는 SQL 을 작성하고, Object 로 변환하는 로직을 작성하는 역할을 한다.

@Dao
interface HistoryDao {
    @Insert
    fun insert(histories: List<History>)

    @Query("SELECT * FROM history")
    fun getAll(): List<History>
}

Room 라이브러리 아키텍처 다이어그램


Room 테스트 코드

1. data 모듈에서 작성한 테스트 코드

Android 의 구성요소를 사용하기 때문에 AndroidTest 에 작성함.

package edu.nextstep.camp.calculator.data.local

import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class HistoryDaoTest {
    private lateinit var database: CalculatorDatabase
    private lateinit var historyDao: HistoryDao

    private val testHistories = listOf(
        History("1 + 1", "2"),
        History("1 + 1 * 3", "6")
    )

    @Before
    fun setUp()  {
        val appContext = InstrumentationRegistry.getInstrumentation().targetContext
        database = Room.inMemoryDatabaseBuilder(appContext, CalculatorDatabase::class.java).build()
        historyDao = database.getHistoryDao()

        historyDao.insert(testHistories)
    }

    @After
    fun tearDown() {
        database.close()
    }

    @Test
    fun `저장되어_있는_모든_history_목록이_나온다`() {

        val actual = historyDao.getAll()

        assertThat(actual).containsExactly(History(1,"1 + 1", "2"),   History(2,"1 + 1 * 3", "6"))
    }

    @Test
    fun `history가_정상적으로_저장된다`() {
        val insertHistory = History(3, "1 + 4 + 6 - 2", "9")

        historyDao.insert(listOf(insertHistory))

        val actual = historyDao.getAll()

        assertThat(actual).contains(insertHistory)
    }
}

2. 코루틴 테스트

코루틴 테스트를 위해서는 build gradle 에 testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2' 추가가 필요하다.
보통 viewModel 안의 코루틴을 사용하는 부분을 테스트할 때, 안드로이드 X 의 ViewMode 라이브러리를 사용하고 있다면 viewModelScope 를 이용해서 코루틴을 launch 시키고 있을 것이다.
이 때 사용되는 Dispatcher 는 Default 이고, Dispatchers.setMain(testDispatcher) 를 이용해 변경할 수 있다.
Dispatcher 를 TestCoroutineDispatcher로 변경하여 동기적으로 변환하여 테스트를 진행할 수 있다.
상세한 것은 https://medium.com/androiddevelopers/easy-coroutines-in-android-viewmodelscope-25bffb605471 블로그를 참조하는 것이 좋다.

나는 ViewModel 에 ioDispacher 를 외부에서 주입할 수 있게 생성자 주입을 택하고 있기 때문에 아래 코드와 같이 ViewModel 을 생성 시 생성자에 TestCoroutineDispathcer 를 주입해서 테스를 진행했다.

    @ExperimentalCoroutinesApi
    val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()

    @Test
    fun `저장된_계산한_결과들이_정상적으로_조회된다`() {
        val savedHistory = listOf(
            History(1,"1 + 1 - 1 * 8", "8"),
            History(2,"5 - 1 * 12", "48"),
            History(3,"1 + 1 / 1 * 8", "16"),
        )
        val viewModel = CalculatorViewModel(
            calculator = Calculator(),
            calculatorRepository = defaultRepository,
            ioDispatcher = testDispatcher
        )
        coEvery { defaultRepository.getHistoryAll() } returns savedHistory

        viewModel.showCalculateHistory()
        val actual = viewModel.calculateHistory.getOrAwaitValue()

        assertThat(actual).containsExactlyElementsIn(savedHistory.map { getStringForDisplay(it) })
    }

    private fun getStringForDisplay(history: History): String {
        return "${history.formula}\n= ${history.calculateResult}"
    }

레퍼런스