[Compose] Datastore-Tink 을 통해 로컬암호화하여 저장하는 방법

오규성·2026년 4월 9일
post-thumbnail

Codex 의 도움을 받아 리팩토링을 진행하던 도중, Codex 가 현재 내 안드로이드 프로젝트의 다음 보안 문제점을 지적했다.

이를 해결하기 위한 방법으로는

  1. EncryptedSharedPreferences
  2. AndroidKeyStore 방식 사용
  3. DataStore-tink 라이브러리 활용

위의 3종류가 존재하는데, 1번인 EncryptedSharedPreferences 방식은 현재 androidx.security.crypto 계열이 Derprecated 되었기에 추천하지 않는다. (https://developer.android.com/privacy-and-security/cryptography?hl=ko#jetpack_security_crypto_library, https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences 참고)

이제 남은 방식은 2가지인데, 나는 이 중에서 DataStore-tink 에 대해 게시글을 작성하려 한다.
-> DataStore-tink 는 3월에 출시된, 안정화되지 않은 단계이므로 사용을 추천하지는 않는다.


DataStore-tink 방식 구현 진행

이 방식은 크게 아래 흐름으로 이해하면 된다.

  1. TokenBundle 같은 객체를 Serializer 가 바이트로 직렬화한다.
  2. AeadSerializer 가 그 바이트를 AEAD 로 암호화한다.
  3. 최종 저장과 갱신은 DataStore 가 담당한다.

이번 프로젝트에서는 공식 1.3.0-alpha07 릴리스 노트 흐름에 맞춰 다음 구조로 구성했다.

  • AndroidKeysetManager.Builder() 로 Tink keyset 생성
  • withMasterKeyUri("android-keystore://...") 로 AndroidKeyStore master key 연결
  • keysetHandle.getPrimitive(...)Aead primitive 생성
  • AeadSerializer 에 기존 TokenBundleSerializer 를 감싸서 연결
  • DataStore.Builder<T> 로 최종 DataStore 생성

이제 구현을 시작해보자.

우선 시작하기에 앞서 libs.versions.toml 에 다음 라이브러리르 추가하고 module dependencies 에 등록해주자.

androidxDataStoreTink = "1.3.0-alpha09"
androidx-datastore-tink = { module = "androidx.datastore:datastore-tink", version.ref = "androidxDataStoreTink" }
implementation(libs.androidx.datastore.tink)

0. TokenBundle 클래스 생성

DataStore-tink 구현하기 전 토큰 데이터를 담을 TokenBundle 클래스를 구현하고 넘어가겠다.

@Serializable
data class TokenBundle(
    val accessToken: String,
    val refreshToken: String,
    val accessTokenExpiredAt: String,
    val refreshTokenExpiredAt: String,
) {
    companion object {
        val Empty = TokenBundle(
            accessToken = "",
            refreshToken = "",
            accessTokenExpiredAt = "",
            refreshTokenExpiredAt = "",
        )
    }
}

1. DataStore Wrapper 클래스 구현

우선 DataStoreTinkTokenStore 라는 커스텀 저장소를 구현하자.

class DataStoreTinkTokenStore<T>(
    private val dataStore: DataStore<T>,
    private val emptyValue: T,
) {
    suspend fun save(value: T) {
        dataStore.updateData { value }
    }

    suspend fun read(): T? {
        return dataStore.data.first().takeUnless { it == emptyValue }
    }

    fun readFlow(): Flow<T> {
        return dataStore.data
    }
    
    suspend fun clear() {
        dataStore.updateData { emptyValue }
    }
}

2. 이후 이 DataStoreTinkTokenStore 클래스를 생성할 함수를 만들어준다.

/** 암호화된 TokenBundle 본문이 DataStore 파일로 저장될 때 사용할 파일명 */
private const val DefaultDataStoreTinkFileName = "token_bundle_tink.pb"

/** Tink keyset 을 식별할 때 사용할 key */
private const val DefaultDataStoreTinkKeysetName = "token_bundle_tink_keyset"

/** Tink keyset 메타데이터를 저장할 SharedPreferences 파일명 */
private const val DefaultDataStoreTinkPrefsFileName = "token_bundle_tink_keyset_prefs"

/** Tink keyset 을 보호할 Android Keystore master key alias URI*/
private const val DefaultDataStoreTinkMasterKeyUri = "android-keystore://blog_datastore_tink_master_key"

fun <T> createTypedDataStoreTinkStore(
    context: Context,
    fileName: String,
    keysetName: String,
    keysetPrefsFileName: String,
    masterKeyUri: String,
    serializer: Serializer<T>,
    emptyValue: T,
): DataStoreTinkTokenStore<T> {
	// 저장소가 UI 에 묶이지 않게 ApplicationContext 사용 !
    val appContext = context.applicationContext
    val storageFile = appContext.dataStoreFile(fileName)
    val aeadSerializer = AeadSerializer(
        aead = createDataStoreTinkAead(appContext, keysetName, keysetPrefsFileName, masterKeyUri),
        wrappedSerializer = serializer,
        // 파일 이름을 바이트어레이로 변경
        associatedData = fileName.encodeToByteArray(),
    )
    ...
}

이제 AeadSerializer 라는 것을 구현해야하는데, 이를 위해 AeadSerializer 를 위한 Aead 생성 함수를 만들자.

private fun createDataStoreTinkAead(
    context: Context,
    keysetName: String,
    keysetPrefsFileName: String,
    masterKeyUri: String,
): Aead {
	AeadConfig.register()
    
    val keysetHandle = AndroidKeysetManager.Builder()
        // SharedPreferences 위치 결정
        .withSharedPref(context, keysetName, keysetPrefsFileName)
        // 키 알고리즘 결정. 현재는 AES256_GCM 사용
        .withKeyTemplate(KeyTemplate.createFrom(PredefinedAeadParameters.AES256_GCM))
        // Android Keystore 보호키 결정
        // 주소값은 android-keystore:// 이후 자유. 하지만 고유의 것으로 작성할 것
        .withMasterKeyUri(masterKeyUri)
        .build()
        .keysetHandle

    // 최종적으로 keyset 으로부터 Aead 추출
    return keysetHandle.getPrimitive(
        RegistryConfiguration.get(),
        Aead::class.java,
    )
}

참고로 위에서 AeadConfig.register() 는 하지 않는 경우 에러가 발생할 수도 있다.

안하니까 java.security.GeneralSecurityException: Cannot create a new key for parameters AesGcm Parameters 에러가 발생하였는데, 원인이 Tink 내부 registry에 AEAD 관련 구현이 먼저 등록되어야해서 이것을 먼저 해야한다고 한다.

함수 구현이 완료되었으면, createTypedDataStoreTinkStore 작성을 마무리하자.

fun <T> createTypedDataStoreTinkStore(
    context: Context,
    fileName: String,
    keysetName: String,
    keysetPrefsFileName: String,
    masterKeyUri: String,
    serializer: Serializer<T>,
    emptyValue: T,
): DataStoreTinkTokenStore<T> {
    val appContext = context.applicationContext
    val storageFile = appContext.dataStoreFile(fileName)
    val aeadSerializer = AeadSerializer(
        aead = createDataStoreTinkAead(appContext, keysetName, keysetPrefsFileName, masterKeyUri),
        wrappedSerializer = serializer,
        associatedData = fileName.encodeToByteArray(),
    )
    val dataStore: DataStore<T> = DataStore.Builder(
        FileStorage(
            serializer = aeadSerializer,
            produceFile = { storageFile },
        ),
        Dispatchers.IO + SupervisorJob(),
    )
    	// 역직렬화 실패 시 빈 값 (기본 값) 으로 갈아끼우기.
        .setCorruptionHandler(ReplaceFileCorruptionHandler { emptyValue })
        .build()

    return DataStoreTinkTokenStore(
        dataStore = dataStore,
        emptyValue = emptyValue,
    )
}

이제 기본적인 준비가 끝났다.
실제 코드에서 사용할 수 있는 함수를 만들어보자.

fun <T> createDataStoreTinkTokenStore(
    context: Context,
    json: Json,
    defaultValue: T,
    emptyValue: T,
    serializer: KSerializer<T>,
    fileName: String = DefaultDataStoreTinkFileName,
    keysetName: String = DefaultDataStoreTinkKeysetName,
    keysetPrefsFileName: String = DefaultDataStoreTinkPrefsFileName,
    masterKeyUri: String = DefaultDataStoreTinkMasterKeyUri,
): DataStoreTinkTokenStore<T> {
    return createTypedDataStoreTinkStore(
        context = context,
        fileName = fileName,
        keysetName = keysetName,
        keysetPrefsFileName = keysetPrefsFileName,
        masterKeyUri = masterKeyUri,
        serializer = JsonDataStoreSerializer(
            json = json,
            defaultValue = defaultValue,
            serializer = serializer
        ),
        emptyValue = emptyValue,
    )
}

class JsonDataStoreSerializer<T>(
    private val json: Json,
    override val defaultValue: T,
    private val serializer: KSerializer<T>,
) : Serializer<T> {

    override suspend fun readFrom(input: InputStream): T {
        val bytes = input.readBytes()
        if (bytes.isEmpty()) return defaultValue
        return json.decodeFromString(serializer, bytes.decodeToString())
    }

    override suspend fun writeTo(t: T, output: OutputStream) {
        withContext(Dispatchers.IO) {
            output.write(json.encodeToString(serializer, t).encodeToByteArray())
        }
    }
}

여러 곳에서 쉽게 꺼내쓸 수 있게 GenericType 으로 구현하였고, 이를 위해 KSerializer 파라미터를 받도록 하였다.

테스트

MainActivity 에 다음과 같이 코드를 놓아보자.

class MainActivity : ComponentActivity() {
    private val dataStoreTinkStore by lazy {
        createDataStoreTinkTokenStore(
            context = applicationContext,
            json = Json {
                ignoreUnknownKeys = true
                encodeDefaults = true
            },
            serializer = TokenBundle.serializer(),
            emptyValue = TokenBundle.Empty,
            defaultValue = TokenBundle.Empty,
        )
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MaterialTheme {
                Surface(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
                    BlogScreen(dataStoreTinkStore = dataStoreTinkStore)
                }
            }
        }
    }
}

@Composable
private fun BlogScreen(dataStoreTinkStore: DataStoreTinkTokenStore<TokenBundle>) {
    val tokenBundle by dataStoreTinkStore.readFlow()
        .collectAsStateWithLifecycle(TokenBundle.Empty)
    val scope = rememberCoroutineScope()

    Column {
        Text(
            text = """
                액세스 토큰 : ${ tokenBundle.accessToken }
                리프레쉬 토큰 : ${ tokenBundle.refreshToken }
                액세스 만료 : ${ tokenBundle.accessTokenExpiredAt }
                리프레쉬 만료 : ${ tokenBundle.refreshTokenExpiredAt }
            """.trimIndent(),
            style = MaterialTheme.typography.headlineMedium,
        )

        Button(
            onClick = { scope.launch {
                val newTokenBundle = TokenBundle(
                    accessToken = UUID.randomUUID().toString(),
                    refreshToken = UUID.randomUUID().toString(),
                    accessTokenExpiredAt = "2023-01-01 00:00:00",
                    refreshTokenExpiredAt = "2023-01-01 00:00:00",
                )
                dataStoreTinkStore.save(newTokenBundle)
            }}
        ) { Text(text = "토큰 변경" )}
    }
}

에뮬레이터를 실행하여 테스트 해보자.

잘되는 것을 확인할 수 있었다.


참고

profile
안드로이드 개발자 Gyu 의 개발 블로그 !

0개의 댓글