선생님, 개발을 잘하고 싶어요.

[룸] Android Room 개발, Coroutine과 Test로 편하게 하기. 본문

개발/android 개발

[룸] Android Room 개발, Coroutine과 Test로 편하게 하기.

알고싶은 승민 2020. 4. 11. 22:57

서론

  요즘 한창, 습관 기록 애플리케이션 사이드 프로젝트를 진행 중입니다.

이번 프로젝트는 서버가 없는 앱을 개발해야 했는데요. 그래서 데이터 관리를 위해 Room을 사용하기로 했습니다.

 

Room개발에 까탈스러운 점은, Database 생성에 context가 필요하다는 건데요.

그래서 적절히 개발 됬는지 확인하기 위해서

  1. Activity에 Room 코드를 작성한다.
  2. 앱을 런치해서 해당 Activity로 이동한다.
  3. 로그를 확인하며 원하는 대로 동작하는지 확인한다.

이런 절차로 진행했습니다만 🤔 귀찮아졌습니다.

대상

  • Room을 처음 써보시는 분
  • Room을 써보긴 했지만 리팩터링에 고민이 있으신 분
  • AndroidTest라는 녀석을 보긴 했지만 평생 써본 적 없으신 분

이 글은 매우 Room초심자의 입장입니다만... coroutine을 세세하게 다루진 않습니다. Room과 AndroidTest에 집중해주세요. 🥺

Gradle 세팅 하기

Room을 사용하기 위해서 Gradle을 세팅해봅시다.

 

앱 수준 build.gradle 최 상단에 kapt를 세팅해 줍니다. 

apply plugin: 'kotlin-kapt'

 

앱 수준 build.gradle의 dependecies에 Room 종속성을 추가해줍니다.

dependencies {
    ...

    // Room components
    implementation "androidx.room:room-runtime:2.2.5"
    implementation "androidx.room:room-ktx:2.2.5"
    kapt "androidx.room:room-compiler:2.2.5"
    androidTestImplementation "androidx.room:room-testing:2.2.5"
    
    ...
}

 

Room 세팅 하기

다음으로 Room 기본 요소를 세팅합시다.

 

처음은 Entity를 설계합니다.

Room에서 Entity는 데이터베이스의 Table이라고 보시면 편합니다.

예를 보기 위해 간단히 이름을 가진 습관 테이블을 설계해 보겠습니다.

private const val TABLE_NAME = "habit"

@Entity(tableName = TABLE_NAME)
data class HabitSchema(
    @PrimaryKey(autoGenerate = true) val id: Long,
    val title: String
)

 

그다음 Dao를 설계합니다.

Dao는 데이터베이스 쿼리를 추상화한 클래스라고 보시면 됩니다.

여기에 우리가 아까 설계한 Table에 CRUD 쿼리를 작성하게 됩니다.

@Dao
interface HabitDao {
    @Insert
    suspend fun create(habitSchema: HabitSchema): Long

    @Query("select * from $TABLE_NAME")
    suspend fun readAll(): List<HabitSchema>

    @Update
    suspend fun update(habitSchema: HabitSchema)

    @Delete
    suspend fun delete(habitSchema: HabitSchema)

    @Query("delete from $TABLE_NAME")
    suspend fun deleteAll()
}

 

Dao는 Room에서 컴파일 타임에 자동으로 적절한 함수를 생성해 줄 겁니다. 

마지막으로 앱에서 실제로 사용할 Database 클래스를 설계합니다.

@Database(entities = [HabitSchema::class], version = 1)
abstract class AppDatabase: RoomDatabase() {
    abstract fun habitDao(): HabitDao
}

 

됐습니다. 이제 우리는 Habit을 넣고, 쓰고, 수정하고, 지우는 데이터베이스를 설계했습니다.

원하는 데로 동작하는지 확인해 볼까요? 🤗

 

기존 방법대로 Room 개발 하기

기존 방법대로 Room개발이 잘 됐는지 확인해 봅시다. 

 

우선 실제로 우리가 설계한 AppDatabase 클래스를 생성해달라고 Room에게 요청해야 합니다.

class MainActivity : AppCompatActivity() {
    ...

    private fun iWantToKnowTheDatabaseIsFind() {
        // 테스트 용으로 메모리상 생성
        val database = Room.inMemoryDatabaseBuilder(
            this,
            AppDatabase::class.java
        ).build()
    }
    
    ...
}

우리는 실제 제품에 들어가기 전에 개발 확인용 이기 때문에 Room.inMemoryDatabaseBuilder로 만들어줍니다. 

실제 제품에 들어갈 코드에선 Room.databaseBuilder를 사용해주세요.😋 그래야 디바이스에 잘 저장된답니다.

 

그리고 우리가 원하는 데로 동작하는지 로그를 찍어서 확인해 봐야겠죠?

class MainActivity : AppCompatActivity() {
    ...

    private fun iWantToKnowTheDatabaseIsFind() {
        // 테스트 용으로 메모리상 생성
        val database = Room.inMemoryDatabaseBuilder(
            this,
            AppDatabase::class.java
        ).build()
        
        // id를 0 으로 설정해주어서 id가 autoGeneration 되게 한다.
        val habitSchema = HabitSchema(id = 0, title = "아아 마이크 테스트")
        runBlocking {
            // 습관을 씁니다.
            database.habitDao().create(habitSchema)

            // 실제 DB에 써진 것을 확인합니다.
            var dbHabitSchema = database.habitDao().readAll()[0]
            Log.d("승민", "방금 넣은 것 $dbHabitSchema")

            // DB를 업데이트 해볼까요?
            database.habitDao().update(dbHabitSchema.copy(title = "들립니다."))

            // 방금 수정이 잘 적용됬는지 다시 확인해봅니다.
            dbHabitSchema = database.habitDao().readAll()[0]
            Log.d("승민", "방금 수정한 것 $dbHabitSchema")

            // 지우는 것도 잘 동작하는지 확인해 봅니다.
            database.habitDao().delete(dbHabitSchema)

            Log.d("승민", "방금 지워서 아무것도 없음. ${database.habitDao().readAll()}")
        }
    }
    
    ...
}

 

로그캣을 한 번 확인해 봅시다.

com.example.roomtestsample D/승민: 방금 넣은 것 HabitSchema(id=1, title=아아 마이크 테스트)
com.example.roomtestsample D/승민: 방금 수정한 것 HabitSchema(id=1, title=들립니다.)
com.example.roomtestsample D/승민: 방금 지워서 아무것도 없음. []

 

잘 동작하는군요! 

하지만 테스트하기 위해 앱을 켜고 MainActivity로 이동하고 Logcat을 확인하는 번거로움 어떻게 못 할까요?😇

테스트 코드를 활용해서 Room 개발 쉽게 하기

우리가 Room을 왜 Activity에서 확인했을까요? 

그 이유는 바로, Room이 Database 생성하는데 context가 필요하기 때문입니다.

 

반대로 말하면 context만 있으면, 앱을 실제로 구동하지 않아도 Room코드를 검증해 볼 수 있겠죠?

 

이러한 테스트를 위해서 안드로이드는 androidTest, InstrumentedTest 테스트를 제공합니다.

프로젝트 만들면 생기는 이거!

 

ExampleInstrumentedTest를 켜 보시면 이런 코드가 있는데

val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.roomtestsample", appContext.packageName)

androidTest에서, 화면을 그리지 않고도 appContext를 받아올 수 있는 마법의 구문입니다. 

그러면 준비는 끝났습니다. androidTest와 Room을 섞어 볼까요?🤝

 

@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
    private lateinit var appDatabase: AppDatabase

    @Before
    fun setup() {
        val appContext = InstrumentationRegistry.getInstrumentation().targetContext
        assertEquals("com.example.roomtestsample", appContext.packageName)

        // 테스트용으로 받아온 context 로 appDatabase 를 생성합니다.
        appDatabase = Room.inMemoryDatabaseBuilder(
            appContext,
            AppDatabase::class.java
        ).build()
    }

    // suspend 함수를 호출하기 위해서, 함수 자체를 runBlocking 으로 만들어주면 깔끔합니다.
    @After
    fun cleanup() = runBlocking {
        appDatabase.habitDao().deleteAll()
    }
}

@Before으로 지정한 함수에서, 각 테스트가 시작되기 전 코드를 지정할 수 있습니다.

여기서 우리는 androidTest가 제공하는 appContext를 사용해서 Room Database 객체를 만들 수 있습니다.

Activity 코드 기억나시나요? 😋

참고로.
@After로 지정한 함수는, 각 테스트가 끝난 후 코드를 지정할 수 있습니다.

지금은 Room.inMemoryDatabaseBuilder를 사용해서 상관없지만,
실제로 기기상 데이터베이스를 저장시키는 Room.databaseBuilder로 생성하셔도 됩니다.

그러면 각 테스트마다 실제 기기에 데이터를 쓰게 되고, 각 테스트마다 다른 상태의 DB에 접근하게 됩니다.
따라서 각 테스트 끝날 때마다, 모든 데이터를 초기 상태로 만들어주는 코드를 작성해 주는 것이 좋습니다.

 

그러면 빨리, Room코드가 잘 작동하는지 확인해봅시다!

@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
    ...
    
    @Test
    fun iWantToKnowTheDatabaseIsFind() = runBlocking{
        val habitSchema = HabitSchema(id = 0, title = "아아 마이크 테스트")

        appDatabase.habitDao().create(habitSchema)
        var dbHabitSchema = appDatabase.habitDao().readAll()[0]
        
        // 우리가 추가 시도한 객체가, DB에서 읽어온 객체와 같은지 확인합니다.
        assertHabitEquals(habitSchema, dbHabitSchema)

        appDatabase.habitDao().update(dbHabitSchema.copy(title = "들립니다."))

        // 우리가 업데이트 시도한 객체가, DB에서 읽어온 객체와 같은지 확인합니다.
        dbHabitSchema = appDatabase.habitDao().readAll()[0]
        assertHabitEquals(habitSchema.copy(title = "들립니다."), dbHabitSchema)

        // 우리는 하나의 습관만 기록했으니, 그 습관을 제거하면, 비어있겠죠? 비어있는지 확인합시다.
        appDatabase.habitDao().delete(dbHabitSchema)
        assertEquals(0, appDatabase.habitDao().readAll().size)
    }

    private fun assertHabitEquals(expected: HabitSchema, actual: HabitSchema) {
        // id 는 자동생성되므로, 검증을 위해서 id의 동일성은 무시하자.
        assertEquals(expected.copy(id = 0), actual.copy(id = 0))
    }
    
    ...
}

이제 이 코드를 실행해 봅시다! 실행은 간단합니다. 해당 @Test함수 좌측에 실행 버튼이 있습니다. 가벼운 마음으로 눌러주세요 😘

@Test 왼쪽엔 해당 테스트를 실행 할 수있는 버튼이 있습니다.

 

잠깐의 빌드 시간이 지나고, 에뮬레이터가 켜지고 테스트가 시작될 겁니다. 테스트가 통과했나요? 

초록 버튼이 영롱합니다.

 

모든 테스트가 성공했다는 결과가 나왔습니다. 

우리가 assert 했던 구문이 전부 통과했다는 의미이고, 원하는 대로 개발이 완료되었다는 의미입니다. 만세!

 

마치며

  실제 애플리케이션은 무수히 만은 Entity(Table)를 설계해야 하고, Entity 여러 개를 사용하는 Dao를 설계하며, Dao들을 여러 개 가지는 Database를 만들어서 사용하게 됩니다. 복잡하죠? 🙁

 

  그래도 괜찮습니다. 이런 복잡한 Room코드 개발을 도와주는 androidTest를 알게 됐잖아요. androidTest를 활용해서 방금 작성하신 Room 코드를 검증하고 새로운 Room코드를 추가하세요.

  그리고 Activity에선 검증된 Room을 사용해서 개발하세요. 그러면 스트레스 덜 받고 즐거운 안드로이드 개발이 되실 거라고 믿습니다. 😊

Comments