SharedPreference는 쓰레드 안전할까?

최혜성·2024년 8월 30일
0
post-thumbnail

네?

요새 기존 프로젝트의 코드를 보다보니 SharedPreference (이하 SP라 명명)를 사용하는 코드가 많았다. 실제 중요한 데이터는 백엔드에 저장하고 간단한 설정값이나 경로등은 키-값 저장소인 SP가 적절해 잘 사용하고는 했다

MVVM패턴에서도 dao의 역할로서 LocalStorage(Dao Interface)의 구현체인 SharedPreferenceStorage를 생성해 값을 저장하고 불러오는데 유용하게 사용했다.

https://github.com/choi-hyeseong/CDP2_WEMADE/blob/main/APP/app/src/main/java/com/home/cdp2app/common/memory/SharedPreferencesStorage.kt

안전한거죠?

댓즈노노 그렇지않다

기존 SP는 안드로이드 SDK 초창기때부터 존재했어서 비동기 API와 일관성 부분에 대해 약하거나 지원하지 않는다. 당장 코루틴이 나왔음에도 관련 메소드를 지원하지 않는다.
그나마 apply 메소드를 쓰면 비동기적으로 처리한다고는 하는데 음..

그래서 최대한 Coroutine의 IO Scope를 사용. 메인쓰레드와 분리해서 처리할려고 하니 이번에는 동시성 제어가 문제였다.

어흑흑

만약 IO 스코프의 작업을 한다고 하자. 이때 async블럭으로 parallel하게 실행한다면 어떻게 될까

CoroutineScope(Dispatcher.IO).launch {
    async {
        SP.edit().putString().commit()
    }
    
    async {
        SP.getString()
    }
}

이렇게 실행하면 밑의 코루틴의 getString은 값을 가져올수도, 가져오지 않을수도 있다. 아무리 코루틴이라 하더라도 병렬적으로 실행하면 어떤게 먼저실행될지, 동시에 접근할지 모르는일이였다.
차라리 어떤게 먼저실행될지 모르는 race-condition이라면 그나마 낫다. 하지만 Dispatcher에 따라 worker가 다르게 배정되는데 이 경우 다른 스코프에서 SP에 접근한다면?

어김없이 Concurrent Issue가 발생한다

 CoroutineScope(Diapatcher.IO).launch {
     SP.getString~
 }
 
 CoroutineScope(Dispatcher.Main).launch {
     SP.putString ~
 }

그럼 어캐해오..

채신기술인 DataStore를 쓰면된다.
SP기반의 Preference DataStore가 있고, 제일 채신인 Proto DataStore가 있다.

프로토는 코틀린, 자바 이외에 C나 다른 언어를 지원하는 포괄성을 갖고 있는데다가 커스텀 스키마, 타입 명시가능등등이 있다.

Preference방식은 프로토 방식과는 달리 기존 SP를 쓰다보니 타입 안정성이 떨어진다. (json방식으로 저장됨.) 있을수도 있고 없을수도 있다식의 불안정성?
그래도 일관성 보증에 값을 저장할때는 코루틴 suspend 블록을 받다보니 어느 디스패처에서 호출하더라도 IO 디스패처에서 수행되므로 동시성 문제는 발생하지 않을것으로 사료된다. 내부적으로도 핸들링 하기도 하고

값을 가져올때도 Flow 형태로 반환하므로 좀더 안전하게 가져올수 있고..

	val key = stringPreferenceKey("DATA")
	suspend fun getValue() : Flow<Int> {
    	context.dataStore.data.map { pref -> pref[key] }
    }
    
    suspend fun writeValue(value : Int) {
    	context.dataStore.edit { pref ->
        	pref[key] = value
        }
    }

지금 쓰는거랑 안맞아오

지금 값을 가져오는 방식은 suspend내에서 동기적으로 수행되곤 한다.


suspend fun getAmount() : Int {
	SP.getInteger("KEY")
}

메인쓰레드 블로킹 방식도 아니고, 현재 모든 유스케이스간 로직이 비동기로 처리되는게 아니라서, 예를 들어 급식표를 조회하는 일련의 과정을 구현한다면 다음과 같을것이다.

  1. SP에서 저장해놓은 학교 코드를 가져온다
  2. 학교 코드를 토대로 파싱한다
  3. 본다

1번의 과정을 현재 suspend 내에서 SP에 접근하고 동기적으로 가져오는데, 이 과정이 끝나야 2번을 호출할 수 있다.

그런데 이를 flow로 변경한다면 좀 복잡해진다. 파싱 하는 함수의 파라미터를 String대신 Flow<String>으로 변경하고, map이든 collect든 LiveData처럼 값이 들어왔을때 호출하는 형식의 비동기 콜백 형태로 구성해야 한다.

//before
suspend fun findSheet(code : String) {
	blah blah..
}

//after
suspend fun findSheet(code : Flow<String>) {
	code.collect { findCode -> 
    	blah blah..
    }
}

리턴값까지 있는 경우에는 더 심각하다.. 블로킹까지 고려해봐야 하니..

https://medium.com/@joongwon/android-datastore%EB%A1%9C-%EC%9D%B4%EC%A0%84%ED%95%98%EA%B8%B0-273329bb2569
위 게시글을 보고 해답을 얻었다.

이 글을 보니 어처피 suspend에서 호출될거, 그냥 값을 가져오는것은 조금 손해를 보더라도 블로킹을 하는것도 나쁘지 않을것 같았다.
MainThread도 아니기도 하고.. 애초에 suspend 함수가 중단함수 이므로 해당 함수를 수행종료까지 블록되는것은 당연했고, 값을 가져오는 부분까지 합치는것도 괜찮지 않을까 싶었다. 백엔드나 어디 다른곳에서 가져오는것도 아니고 블록된다고 1초이상 걸리는것도 아니기 때문에 (내부적인 SP 사용.)

inline - 인라인시킴. 컴파일시 해당 함수의 호출부로 그대로 복붙. static보다 메모리 절감. static이 아닌경우 new Function등으로 객체 생성되는데 이를 방지하므로 상당한 성능/메모리 확보
reified - 제네릭 타입은 컴파일타임에 사라지는데, 이를 보완하기 위한 방법.

그래서 위 링크에 따르면


suspend inline fun <T : Any> DataStore<Preferences>.readValue(key: Preferences.Key<T>, defaultValue: T): T {
    return data.catch { recoverOrThrow(it) }.map { it[key] }.firstOrNull() ?: defaultValue
}

Flow로 받은 리턴값을 catch함수로 예외처리하고, firstOrNull을 이용해 blocking해서 값을 가져온다.

만약 비동기적으로 모든 로직을 짰다면 그냥 Flow를 그대로 써도 될것 같다.

profile
KRW 채굴기

0개의 댓글

관련 채용 정보