2021년 10월 16일 토요일

[안드로이드] Jetpack의 DataStore를 이용하여 데이터 저장하기

 

Jetpack의 DataStore를 이용하여 데이터 저장하기

SharedPreference를 대체하기 위해, 데이터 저장을 개선하여 새롭게 DataStore라는 모듈이 Jetpack에서 alpha로 출시되었습니다. DataStore는 코틀린의 코루틴과 Flow으로 빌드되었으며, 2가지의 다른 구현체를 제공합니다.

  • Proto DataStore : Protocal buffers로 작성된 Object를 저장하는 구현체
  • Preferences DataStore : key-value쌍의 데이터를 저장하는 구현체

DataStore는 SharedPreference의 단점들을 개선하기 위해서, 데이터를 비동기적이고 일관성있고, 묶어서(transactionally) 저장하도록 구현되어 있습니다.

SharedPreferences vs DataStore



  • SharedPreferenece는 동기적인 API이기 때문에 UI스레드에서의 호출도 안전하긴 하지만, 실제로는 디스크 I/O 작업을 하기때문에 문제가 있습니다. 게다가 apply() 메서드는 fsync() 의 UI스레드를 블락합니다. 즉, 애플리케이션 내에서 서비스나 엑티비티가 start/stop하는 매 순간마다 fsync() 호출을 지연시키게 될 것이고, UI스레드가 블락되어 ANR이 발생할 여지가 높아지게 됩니다.
  • SharedPreference는 파싱에러를 런타임 예외로 던집니다.

특별한 경우가 아니면 DataStore의 2가지 구현 모두 설정값을 파일에 저장하고, 모든 연산을 Dispatchers.IO 에서 수행합니다. 그렇지만, 2가지 구현이 각각 다른 방법으로 데이터를 저장합니다.

  • Preference DataStore : SharedPreference와 같이 데이터 스키마를 정의하는 방법이 없고, Key가 안전한 데이터타입에 접근하는지를 보장하지 않습니다.
  • Proto DataStore : Protocal buffers를 이용하여 스키마를 정의할 수 있습니다. Protocal buffers는 데이터의 타입도 강하게 지정하는 것이 가능합니다. XML이나 다른 데이터포멧보다도 빠르고, 작고, 모호하지 않습니다. 하지만, Proto DataStore를 다루려면 새로운 직렬화 메커니즘을 익혀야합니다.

Room vs DataStore

부분업데이트가 필요하거나, 참조무결성 / 복잡하거나 큰 데이터의 지원이 필요한 경우라면, DataStore대신에 Room을 사용하는것이 낫습니다. DataStore는 작고 심플한 데이터에 가장 이상적으로 동작하며, 부분업데이트나 참조무결성은 지원하지 않습니다.

DataStore 사용

DataStore 디펜턴시를 추가해주어야 합니다. (Proto DataStore를 사용하려면 추가 설정을 해주어야 합니다. - 링크)

// Preferences DataStore
implementation "androidx.datastore:datastore-preferences:1.0.0-alpha01"


// Proto DataStore
implementation  "androidx.datastore:datastore-core:1.0.0-alpha01"

Proto DataStore로 작업할 때, app/src/main/proto/ 디렉터리의 proto파일 내에 스키마를 정의해주어야 합니다. 스키마를 정의하는 방법은 이 문서를 참고해주세요.

syntax = "proto3";

option java_package = "<your package name here>";
option java_multiple_files = true;

message Settings {
  int my_counter = 1;
}

DataStore 생성

Context.createDataStore() 확장함수를 이용하여 DataStore를 생성할 수 있습니다.

// with Preferences DataStore
val dataStore: DataStore<Preferences> = context.createDataStore(
    name = "settings"
)

Proto DataStore를 사용하려면 Serializer 인터페이스도 구한하여 DataStore에게 넘겨주어야 합니다.

object SettingsSerializer : Serializer<Settings> {
    override fun readFrom(input: InputStream): Settings {
        try {
            return Settings.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override fun writeTo(t: Settings, output: OutputStream) = t.writeTo(output)
}


// with Proto DataStore
val settingsDataStore: DataStore<Settings> = context.createDataStore(
    fileName = "settings.pb",
    serializer = SettingsSerializer
)

DataStore로부터 데이터 읽기

DataStore는 데이터를 Flow, Preferences, Proto Schema로 가져올 수 있습니다. DataStore는 Dispatchers.IO에서 데이터를 접근하도록 보장하여 UI스레드가 블락되지 않도록 합니다.

  • Preferences DataStore
val MY_COUNTER = preferencesKey<Int>("my_counter")
val myCounterFlow: Flow<Int> = dataStore.data
     .map { currentPreferences ->
        // Unlike Proto DataStore, there's no type safety here.
        currentPreferences[MY_COUNTER] ?: 0   
   }
  • Proto DataStore
val myCounterFlow: Flow<Int> = settingsDataStore.data
    .map { settings ->
        // The myCounter property is generated for you from your proto schema!
        settings.myCounter 
    }

DataStroe에 데이터 쓰기

DataStore는 데이터를 저장하기 위해서 DataStore.updateData() 일시중단 함수를 제공합니다. 이 함수는 현재 저장된 데이터의 상태를 담은 Preferences 또는 proto Schema 객체를 파라미터로 넘겨줍니다. 이 updateData() 함수는 데이터의 읽기/쓰기/수정 연산이 원자적으로 동작하도록 합니다.

Preferences DataStore는 데이터 갱신을 좀 더 쉽게 할 수 있도록 DataStore.edit() 함수를 제공합니다. Preferences 객체를 받는 대신에, 직접 수정가능하도록 MutablePreferences 객체를 받습니다. updateData() 함수와 마찬가지로 변경이 다 완료되어 디스크에 저장된 후에야 변경사항이 적용됩니다.

  • edit() 예시 (Preferences DataStore)
suspend fun incrementCounter() {
    dataStore.edit { settings ->
        // We can safely increment our counter without losing data due to races!
        val currentCounterValue = settings[MY_COUNTER] ?: 0
        settings[MY_COUNTER] = currentCounterValue + 1
    }
}
  • updateData() 예시 (Proto DataStore)
suspend fun incrementCounter() {
    settingsDataStore.updateData { currentSettings ->
        // We can safely increment our counter without losing data due to races!
        currentSettings.toBuilder()
            .setMyCounter(currentSettings.myCounter + 1)
            .build()
    }
}

SharedPreferences를 DataStore로 마이그레이션하기

SharedPreferences에서 DataStore로 데이터를 마이그레이션 하려면, DataStore 빌더에 SharedPreferencesMigration 객체를 전달해 주어야 합니다. 그러면, 자동으로 SharedPreferences의 데이터를 DataStore에 마이그레이션해 줍니다. 마이그레이션 동작은 DataStore에서 데이터 접근을 하기 전에 수행됩니다. 즉, DataStore.data가 어떤 데이터를 반환하거나 DataStore.updateData()가 업데이트를 수행하기 전에 마이그레이션이 성공해야 함을 의미합니다.

Preferences DataStore로 마이그레이션하려면 SharedPreferencesMigration 구현체를 기본으로 사용하고, SharedPreferences의 이름을 생성자에 전달하면 됩니다.

val dataStore: DataStore<Preferences> = context.createDataStore(
    name = "settings",
    migrations = listOf(SharedPreferencesMigration(context, "settings_preferences"))
)

Proto DataStroe로 마이그레이션하려면 SharedPreferences의 데이터를 Proto Schema로 매핑하는 함수를 구현해 주어야 합니다.

val settingsDataStore: DataStore<Settings> = context.createDataStore(
    produceFile = { File(context.filesDir, "settings.preferences_pb") },
    serializer = SettingsSerializer,
    migrations = listOf(
        SharedPreferencesMigration(
            context,
            "settings_preferences"            
        ) { sharedPrefs: SharedPreferencesView, currentData: UserPreferences ->
            // Map your sharedPrefs to your type here
          }
    )
)

원문

[안드로이드/테스트] 테스트의 기본개념 & 안드로이드에서의 테스트

 

테스트

테스트의 단위



유닛(Unit)테스트

  • 코드의 가장 작은부분(보통 메서드)을 테스트하는 것
  • 외부의 리소스(네트워크/데이터베이스)는 배제하고 테스트를 수행할 수 있어야 함
    • 외부환경이 테스트의 결과를 좌우하면 안되므로..
  • 유닛테스트는 테스트목표가 간단하고 명확해야 한다!
    • ex) 메서드의 유닛테스트 - 테스트용 입력값을 주었을 때, 출력값이 정확한가?
  • 유닛테스트 단위의 예시
    • DNote의 객체를 Note의 객체로 잘 변환하는가?
    • (어떤 데이터소스이든 상관없이) 데이터소스로부터 pageId를 가진 데이터를 가져와서 Page 객체로 잘 변환하는가?
    • 레포지토리로부터 가져온 Page를 가공하여 LiveData로 제대로 출력되고 있는가?

통합(Integration)테스트

  • 각 기능(유닛)들이 상호작용하고 잘 동작하는지를 테스트하는 것
  • 외부의 리소스가 결합된 유닛테스트라고 이해하면 될듯
    • 통합테스트는 외부의 리소스와 결합하여 기능이 동작하는가를 확인하는게 목적이므로
  • 통합테스트 단위의 예시
    • 데이터베이스로/서버로부터 pageId를 가진 데이터를 가져와서 Page 객체로 잘 변환하는가?

기능(Functional / Acceptance)테스트

  • 애플리케이션 / 소프트웨어가 제대로 동작하는지 완전한 기능을 테스트하는 것
  • QA분들이 해주시는 하나의 TC라고 생각하면 될듯

(유닛)테스트코드

  • 일반적으로 테스트코드를 작성한다는건 유닛테스트를 의미함
  • 통합/기능테스트의 코드를 작성하기도 하지만, 들어가는 공수대비 관리포인트가 많기때문에...

테스트코드 뭐가 좋은가?

  • 작성한 비즈니스 코드가 정상적으로 동작하는지를 명확하게 반복적으로 확인할 수 있음
  • 코드를 변경했을 때, 기존에 동작하던게 문제없이 동작하는지를 검증할 수 있음
  • 테스트를 위해서 코드를 바꾸다보면... 결과론적으로 코드의 구조가 개선됨 (단일책임원칙 등..)

테스트코드 단점은?

  • 테스트코드도 결국은 코드이기 때문에 작성에 공수가 들어감
  • 스펙변경 등으로 테스트코드가 변경되어야 하는 상황이 발생.... 관리해주어야 하는 번거로움ㅠㅠ
  • (코드 구조가 꼬여있어서) 테스트를 위해 코드의 전체 구조를 바꿔야하는 아이러니한 상황이 발생하기도...

어떻게 짜면 좋을까?

  • 반드시 이렇게 짜야한다라는 건 없음. 목적/단위에 맞게 테스트를 하도록 짜면 됨
  • 유닛테스트에서는 외부 리소스/환경에 영향을 받으면 안되므로, Mock / Spy / Dummy 객체를 이용
  • 테스트코드 패턴으로 Given - When - Then 패턴이 존재하며, 막막한 테스트코드를 짜는데 가이드가 될 수 있음
    • Given(준비) : 테스트를 위한 준비과정. Mock, 더미 데이터 등 테스트에 사용하는 값들을 초기화하고 준비하는 단계
    • When(실행) : 검증할 코드를 실행하는 단계. 유닛테스트에서는 보통 메서드를 테스트하므로 메서드를 실행하는 한줄만 들어감
    • Then(검증) : 메서드를 수행한 후 받은 출력이 예상과 같은지를 검증하는 단계
@Test
fun given_목레포지토리_when_파트너데이터_조회_then_데이터불러오기_성공() {
    val partnerData = makeDummyData()
    given("목레포지토리에 파트너데이터 지정") {
        Mockito
            .`when`(mockSettingRepository.getPartnerInfo())
            .thenReturn(Single.just(partnerData))
    }

    `when`("뷰모델 fetchPartnerData()") {
        viewModel.fetchPartnerData()
    }

    then("구독한 LiveData로 제대로 된 데이터가 넘어오는지 확인") {
        Assert.assertEquals(partnerData, viewModel.partnerData.getOrAwaitValue())
    }
}

안드로이드에서의 테스트

테스트환경

  • 안드로이드에서는 2가지의 테스트환경이 있음

JUnit Test



  • JVM 기반의 테스트툴인 JUnit을 이용하여 테스트를 수행하는 환경으로 안드로이드 환경과는 분리되어 있음
  • 단순 JVM 기반이기 떄문에 속도가 매우 빠름
  • 안드로이드 종속적인 테스트는 수행할 수 없음
    • ex) Context로부터 데이터를 얻어온다거나... 이런 테스트는 불가능.
    • 단, 테스트 수행시에 안드로이드 기반의 객체들을 기본값으로 셋팅하도록 지정할 수는 있음
    android {
        ...
        testOptions {
            unitTests.returnDefaultValues = true
        }
        ...
    }
    

Instrumented Test



  • 안드로이드 환경(에뮬레이터, 실기기)에서 테스트를 수행
  • JUnit Test에 비해 실제로 앱을 빌드하여 수행하는것과 동일하므로 속도가 느림
  • 안드로이드 종속적인 테스트를 수행할 때 이용.
    • ex) Room을 이용한 테스트(Room은 Context를 필요로 하니까)

테스트하기 어려운 상황들 우회

LiveData

  • LiveData.observeForever() 를 이용하여 라이프사이클 상관없이 값을 구독하도록 함.
// LiveDataTest.kt
/* Copyright 2019 Google LLC.
   SPDX-License-Identifier: Apache-2.0 */
fun <T> LiveData<T>.getOrAwaitValue(
    time: Long = 2,
    timeUnit: TimeUnit = TimeUnit.SECONDS
): T {
    var data: T? = null
    val latch = CountDownLatch(1)
    val observer = object : Observer<T> {
        override fun onChanged(o: T?) {
            data = o
            latch.countDown()
            this@getOrAwaitValue.removeObserver(this)
        }
    }

    this.observeForever(observer)

    // Don't wait indefinitely if the LiveData is not set.
    if (!latch.await(time, timeUnit)) {
        throw TimeoutException("LiveData value was never set.")
    }

    @Suppress("UNCHECKED_CAST")
    return data as T
}

// 실제 사용
@RunWith(JUnit4::class)
abstract class BaseTest {
    // LiveData를 테스트하기 위해 꼭 추가해주어야 하는 Rule
    // androidx.arch.core.executor.testing.InstantTaskExecutorRule
    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()
    
    ...
    @Test
    fun 테스트() {
        then("page load fail") {
            Assert.assertNotNull(pageViewModel.error.getOrAwaitValue())
        }
    }
}

RxJava

  • UnitTest에서는 Rx의 Scheduler가 원활하게 동작하지 않는다. (Schedulers.io), AndroidSchedulers.mainThread())
  • 해당 스케쥴러를 Schedulers.trampoline() 으로 동작하도록 바꿔주어 문제를 해결할 수 있음
// RxSchedulersRules.kt
class RxSchedulersRules : TestRule {
    override fun apply(
        base: Statement,
        description: Description
    ): Statement {
        return object : Statement() {
            @Throws(Throwable::class)
            override fun evaluate() {
                RxJavaPlugins.setIoSchedulerHandler {
                    Schedulers.trampoline()
                }
                RxAndroidPlugins.setInitMainThreadSchedulerHandler {
                    Schedulers.trampoline()
                }
                try {
                    base.evaluate()
                } finally {
                    RxJavaPlugins.reset()
                    RxAndroidPlugins.reset()
                }
            }
        }
    }
}

// 실제 사용
@RunWith(JUnit4::class)
class BaseTest {
    // 이 Rule만 선언해주면 됨.
    @get:Rule
    val testSchedulerRule = RxSchedulersRules()
    ...
}

참고문서