본문 바로가기
Android 일지/리팩토링

6. SharedPreference에서 DataStore로 변경하여 데이터 일관성 문제 해결하기

by 쎄오SseO 2023. 5. 30.

(RunWithMe 리팩토링) 6. SharedPreference에서 DataStore로 변경

 

 

안녕하세요

RunWithMe 리팩토링 프로젝트에서 팀장과 안드로이드 개발을 맡은 서경원입니다.

RunWithMe 프로젝트를 리팩토링하면서 공부한 내용에 대해서 설명해보려합니다.

이해가 안되는 내용이나 제가 잘못 적은 부분이 있다면 꼭 댓글 남겨주세요.

 

 

이번 프로젝트에서 기존에 사용하던 SharedPreference를 DataStore로 변경하고자 했습니다.

SharedPreference가 가진 문제점 때문이었습니다.

여러분들은 SharedPreference의 문제점에 대해 알고 계신가요????

SharedPreference의 문제점에 대해서 ChatGpt에 물어보겠습니다.

 

gpt 답변:

안드로이드에서 SharedPreference를 사용하는 것은 간단하고 편리한 방법입니다. 그러나 SharedPreference에는 몇 가지 문제점이 있을 수 있습니다:

  1. 보안적인 취약성: SharedPreference는 암호화되지 않은 상태로 파일 시스템에 저장되기 때문에, 민감한 데이터 (예: 사용자 인증 정보, API 키 등)를 저장할 때 보안적인 취약성을 가질 수 있습니다. 악의적인 사용자가 기기에 물리적으로 접근한다면 해당 데이터를 읽을 수 있습니다.
  2. 데이터의 일관성 관리 어려움: SharedPreference는 원자성을 보장하지 않습니다. 여러 스레드에서 동시에 SharedPreference를 수정하면 데이터의 일관성 문제가 발생할 수 있습니다.
  3. 복잡한 데이터 구조 처리의 제한성: SharedPreference는 주로 기본 데이터 유형 (문자열, 정수, 불린 등)을 처리하는 데 적합하지만, 복잡한 데이터 구조 (객체, 리스트 등)를 저장하고 관리하기에는 제한적입니다. 이러한 경우에는 별도의 직렬화/역직렬화 로직을 추가해야 할 수 있습니다.
  4. 데이터 용량 제한: SharedPreference는 각 앱에 대해 데이터 용량 제한이 있습니다. 따라서 대량의 데이터를 저장해야 하는 경우에는 다른 데이터 저장 방식을 고려해야 할 수 있습니다.
  5. 변경 감지의 부족: SharedPreference는 데이터 변경을 자동으로 감지하고 알리는 기능을 제공하지 않습니다. 데이터 변경 시 수동으로 해당 변경을 알리고 적절한 동작을 수행해야 합니다.

이러한 문제점을 고려할 때, 민감한 데이터의 경우에는 안전한 데이터 저장 방식 (예: 안드로이드 Keystore, 안드로이드 Room 등)을 사용하는 것이 좋습니다. 또한, 데이터의 일관성, 복잡한 데이터 구조 처리, 용량 제한 등의 요구사항에 따라 적합한 데이터 저장 방식을 선택해야 합니다.

 

 

몇 가지 문제점이 있지만 저희는 2번 문제에 집중해야합니다.

여러 스레드에서 동시에 SharedPreference를 수정하면 데이터의 일관성 문제가 발생할 수 있습니다.”

쓰레드 동기화가 되지 않기 때문에 여러 쓰레드에서 동시에 SharedPreference에 접근할 수 있고 이 때문에 데이터가 원하는 대로 나오지 않을 가능성이 높아집니다.

저희는 이 데이터 일관성 문제를 해결하고자 DataStore를 도입하게 되었습니다.

 

 

DataStore

DataStore는 SharedPreference보다 더 유연하고 성능적으로 개선되었습니다.

DataStore는 쓰레드 간의 동기화를 자동으로 처리하기 때문에 여러 쓰레드에서 안전하게 데이터를 액세스할 수 있을 뿐만 아니라, 비동기 API를 사용하여 IO 작업을 처리하기 때문에 메인 쓰레드와 관련된 제약이 적습니다.

그렇기 때문에 ANR이 발생활 확률이 적어 안전하다고 할 수 있습니다.

Flow를 도입하였기 때문에 비동기 동작을 효과적으로 처리할 수 있습니다.

 

DataStore에는 2가지가 존재합니다.

PreferenceDataStoreProtoDataStore인데 아래 사진을 통해 특징을 확인할 수 있습니다.

 

PreferenceDataSotre와 ProtoDataStore의 차이는 유형 안전성(Type Safety)인 것을 확인할 수 있습니다.

ProtoDataStore는 프로토콜 버퍼를 사용하여 스키마를 정의해야할 필요가 있습니다.

프로토콜 버퍼를 사용하여 데이터 직렬화를 지원합니다.

 

 

 

저희 앱에서는 간단한 데이터를 저장하기 때문에 프로토콜 버퍼를 사용하지 않는 PreferenceDataStore를 사용했습니다.

SharedPreference처럼 Key와 Value로 이루어져있습니다.

 

// At the top level of your kotlin file:
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

name 매개변수에 DataStore의 이름을 넣어 생성합니다.

 

같은 프로세스에서 특정 파일의 DataStore 인스턴스를 두 개 이상 만들지 않도록 합니다. 이렇게 하면 모든 DataStore 기능이 중단될 수 있습니다. 동일한 프로세스에서 특정 파일의 DataStore가 여러 개 활성화되어 있다면 데이터를 읽거나 업데이트할 때 DataStore가 IllegalStateException을 발생시킵니다.

 

 

val EXAMPLE_COUNTER = intPreferencesKey("example_counter")
val exampleCounterFlow: Flow<Int> = context.dataStore.data
  .map { preferences ->
    // No type safety.
    preferences[EXAMPLE_COUNTER] ?: 0
}

데이터를 읽을 때에는 위와 같이 사용합니다.

Int값을 저장하기 때문에 키 값을 intPreferencesKey로 생성합니다. String을 사용한다면 StringPreferenceKey를 사용해 생성하면 됩니다.

파일로 저장하기 때문에 데이터가 문자열로 저장되지만 key의 타입을 통해 타입을 검증한 뒤 변환해줍니다.

 

suspend fun incrementCounter() {
  context.dataStore.edit { settings ->
    val currentCounterValue = settings[EXAMPLE_COUNTER] ?: 0
    settings[EXAMPLE_COUNTER] = currentCounterValue + 1
  }
}

데이터를 저장할 때는 위와 같이 사용합니다.

 

 

보안적인 문제

저희 앱에서는 JWT 토큰과 refresh토큰을 DataStore에 저장해주고자 했습니다.

앞서 봤던 SharedPreference의 문제점에 보안적 취약점도 있다는 것을 기억하고 계신가요??

기존에 이를 해결해주기 위해 EncryptSharedPreference를 사용하였습니다.

DataStore는 기존처럼 보안적으로 취약점이 있기 때문에 중요한 데이터를 저장하는 경우에는 추가적인 보안이 필요하다고 생각했습니다.

암호화 키를 생성하여 KeyStore에 저장해주고 이 암호화 키를 사용하여 저장, 읽기 동작을 할 때 암호화와 복호화를 해주도록 구현하였습니다.

 

 

아래는 저희 앱에서 사용한 코드입니다.

/**
 * 안드로이드 앱 별 KeyStore 의 Key를 통해
 * 데이터를 암호화 - 복호화
 * base64 + 앱 별 고유키를 통한 암호화, 복호화
 * */
// 모든 입력값은 2048비트(256바이트) 보다 클 수 없다.
private const val KEY_LENGTH_BIT = 2048

// KeyStore의 유효기간
private const val VALIDITY_YEARS = 25

private const val KEY_PROVIDER_NAME = "AndroidKeyStore"
private const val KEYSOTRE_INSTANCE_TYPE = "AndroidKeyStore"

private const val CIPHER_ALGORITHM =
    "${KeyProperties.KEY_ALGORITHM_RSA}/" +
            "${KeyProperties.BLOCK_MODE_ECB}/" +
            KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1

private lateinit var keyEntry: KeyStore.Entry

// Private only backing field
@Suppress("ObjectPropertyName")
private var _isSupported = false

// 이미 init 진행했는지 체크.
private val isSupported: Boolean
    get() = _isSupported

/**
 * Android Version M (API 23) 이상 사용하는 KeyStore 설정 함수
 * */
fun initKeyStore(alias: String): Boolean {
    try {
        with(KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, KEY_PROVIDER_NAME)) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                val spec = KeyGenParameterSpec.Builder(
                    alias,
                    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
                )
                    .setAlgorithmParameterSpec(RSAKeyGenParameterSpec(KEY_LENGTH_BIT, F4))
                    .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
                    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)
                    .setDigests(
                        KeyProperties.DIGEST_SHA512,
                        KeyProperties.DIGEST_SHA384,
                        KeyProperties.DIGEST_SHA256
                    )
                    /*
                     * Setting true only permit the private key to be used if the user authenticated
                     * within the last five minutes.
                     */
                    .setUserAuthenticationRequired(false)
                    .build()

                initialize(spec)
                generateKeyPair()
            } else {
                return false
            }
        }

        return true
    } catch (e: GeneralSecurityException) {
        // 아주 가끔 몇몇 기기들은 오류가 발생하기도 한다.
        return false
    }
}

/**
 * Beware that input must be shorter than 256 bytes. The length limit of plainText could be dramatically
 * shorter than 256 letters in certain character encoding, such as UTF-8.
 */
fun encrypt(plainText: String): String {
    if (!_isSupported) {
        return plainText
    }

    val cipher = Cipher.getInstance(CIPHER_ALGORITHM).apply {
        init(Cipher.ENCRYPT_MODE, (keyEntry as KeyStore.PrivateKeyEntry).certificate.publicKey)
    }
    val bytes = plainText.toByteArray(Charsets.UTF_8)
    val encryptedBytes = cipher.doFinal(bytes)
    val base64EncryptedBytes = Base64.encode(encryptedBytes, Base64.DEFAULT)
    return String(base64EncryptedBytes)
}

fun decrypt(base64EncryptedCipherText: String): String {
    if (!_isSupported) {
        return base64EncryptedCipherText
    }

    val cipher = Cipher.getInstance(CIPHER_ALGORITHM).apply {
        init(Cipher.DECRYPT_MODE, (keyEntry as KeyStore.PrivateKeyEntry).privateKey)
    }
    val base64EncryptedBytes = base64EncryptedCipherText.toByteArray(Charsets.UTF_8)
    val encryptedBytes = Base64.decode(base64EncryptedBytes, Base64.DEFAULT)
    val decryptedBytes = cipher.doFinal(encryptedBytes)

    return String(decryptedBytes)
}

KeyStore 생성, 암호화, 복호화 코드입니다.

 

 

/** DataStore 확장 함수 **/
object preferencesKeys {
    val JWT = stringPreferencesKey("jwt")
    val REFRESH_TOKEN = stringPreferencesKey("refresh-token")
}

suspend fun <T> DataStore<Preferences>.saveValue(key: Preferences.Key<T>, value: T) {
    edit { prefs -> prefs[key] = value }
}

suspend fun DataStore<Preferences>.saveEncryptStringValue(key: Preferences.Key<String>, value: String) {
    edit { prefs -> prefs[key] = encrypt(value) }
}

suspend fun <T> DataStore<Preferences>.getValue(key: Preferences.Key<T>, type: Int): Flow<Any> {
    return data
        .catch { exception ->
            if (exception is IOException) {
                exception.printStackTrace()
                emit(emptyPreferences())
            } else {
                throw exception
            }
        }
        .map { prefs ->
            prefs[key] ?: when (type) {
                KEY_INT -> {
                    0
                }
                KEY_BOOLEAN -> {
                    false
                }
                KEY_STRING -> {
                    ""
                }
                else -> {}
            }
        }
}

suspend fun DataStore<Preferences>.getDecryptStringValue(key: Preferences.Key<String>): Flow<Any> {
    return data
        .catch { exception ->
            if (exception is IOException) {
                exception.printStackTrace()
                emit(emptyPreferences())
            } else {
                throw exception
            }
        }
        .map { prefs ->
            decrypt(prefs[key] ?: "")
        }
}

편하게 사용하기 위해 만든 DataStore의 확장 함수입니다.

암호화, 복호화 하는 코드를 넣은 개별 함수를 만들었습니다.

 

 

@Singleton
@Provides
fun provideDataStore(@ApplicationContext appContext: Context): DataStore<Preferences> =
        PreferenceDataStoreFactory.create(
            produceFile = { appContext.preferencesDataStoreFile(DATASTORE_NAME) })

DataStore 생성 또한 hilt를 통해 주입하여 편하게 사용하고자 했습니다.

 

 

@AndroidEntryPoint
class LoginActivity : BaseActivity<ActivityLoginBinding>(R.layout.activity_login) {

    @Inject
    lateinit var dataStore: DataStore<Preferences>

        ...
}

hilt를 통해 주입받은 dataStore로 확장 함수를 편하게 사용하면 됩니다.

(예시로는 dataStore에 주입받아 사용하도록 적었지만 DataStore는 Data Layer에서 사용하는 것이 좋습니다.)