
DearShot의 로그인 기능을 구현하면서 access token과 refresh token을 앱에 저장해야 했다. 앱을 다시 실행했을 때 로그인 상태를 복원하고, access token이 만료되면 refresh token으로 새 토큰을 받기 위해서다.
그런데 서버와 통신하는 부분에 HTTPS를 적용하는 것만으로 충분할까? 앱 안에 남아 있는 인증 정보도 보호하는 편이 좋겠다는 생각이 들었다.
그래서 로그인 토큰 저장소는 DataStore + Tink + Android Keystore로 구성했다. DataStore가 저장과 상태 관리를 담당하고, Tink가 암호화를 수행하며, 암호화 키는 Android Keystore에서 관리하는 구조다.
이번 글에서는 각 도구를 선택한 이유와 저장·복원 흐름, 그리고 토큰 자동 갱신과 함께 사용할 때 고려한 부분을 정리해 보려고 한다.
로그인 후 서버에서 받은 access token은 API 요청에 사용한다. refresh token은 access token을 갱신할 때 사용한다. 둘 다 다른 곳으로 유출되면 문제가 될 수 있는 인증 정보다.
HTTPS는 앱과 서버 사이에서 이동하는 데이터를 보호한다. 반면 앱이 토큰을 파일에 저장한 뒤에는 별도의 저장 정책이 필요하다. 아래는 내가 정리한 HTTPS 관련 글이다.
HTTPS 정리
여기서 고려한 범위는 디스크에 저장된 토큰 파일이 그대로 읽히는 상황을 줄이는 것이다. 앱이 정상적으로 API를 호출할 때에는 토큰을 복호화해서 메모리에서 사용해야 한다. 따라서 저장 암호화가 메모리에 있는 토큰, 로그에 출력한 토큰, 실행 중인 앱을 장악한 공격까지 모두 해결하는 것은 아니다.
Preferences DataStore는 작은 데이터를 비동기적으로 저장하고, Flow로 읽고, 트랜잭션으로 갱신할 수 있는 저장소다. 하지만 기본 Preferences 직렬화 자체가 토큰 암호화 기능을 제공하는 것은 아니다. DataStore 공식 문서
파일을 일반 텍스트 편집기로 열었을 때 JSON처럼 보이지 않더라도, 그것만으로 암호화됐다고 판단할 수는 없다. 직렬화와 암호화는 목적이 다르다.
직렬화: 데이터를 저장하거나 전달할 수 있는 바이트 형식으로 바꾸는 작업
암호화: 키 없이 원래 내용을 읽기 어렵도록 바꾸는 작업
이번 저장소에서는 Preferences를 직렬화한 다음, 그 결과를 암호화해서 파일에 기록한다.
도구 | DearShot에서 맡는 역할 |
|---|---|
DataStore | 토큰 저장·조회, 변경 알림, 트랜잭션 처리 |
Tink | 인증 암호화 API 제공 |
Android Keystore | 암호화 키 생성·보관·사용 |
AeadSerializer | DataStore의 직렬화와 Tink 암호화를 연결 |
AeadSerializer는 AndroidX의 datastore-tink에 들어 있는 클래스다. 기존 Serializer를 감싸서 읽을 때 복호화하고, 쓸 때 암호화한다. AeadSerializer API
저장 흐름은 다음과 같다.
로그인 API에서 받은 토큰
↓
TokenStore.save()
↓
Preferences 데이터 구성
↓
PreferencesFileSerializer로 직렬화
↓
AeadSerializer + Tink로 암호화
↓
앱 내부 파일에 저장읽는 때는 이 순서가 반대로 진행된다. Repository는 평소처럼 TokenStore.getTokens()를 호출하면 된다.
프로젝트에서는 버전 카탈로그를 사용한다.
[versions]
datastore = "1.3.0-rc01"
tink = "1.20.0"
[libraries]
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-datastore-tink = { module = "androidx.datastore:datastore-tink", version.ref = "datastore" }
tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink" }앱 모듈에서 다음 의존성을 추가한다.
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.datastore.tink)
implementation(libs.tink.android)Tink API를 코드에서 직접 사용하므로 tink-android도 명시적으로 선언했다. 버전은 이번에 사용하는 datastore-tink의 Android 의존성과 맞췄다.
키를 코드에 문자열로 작성하거나 토큰 파일 옆에 그대로 보관하면, 파일을 암호화한 의미가 줄어든다.
이번에는 Android Keystore에 AES-256-GCM 키를 생성하고, Tink의 AndroidKeystore API를 통해 사용한다. Keystore의 키는 일반적인 키 바이트를 꺼내서 파일에 저장하는 방식으로 다루지 않는다. 실제 하드웨어 보호 여부는 기기에 따라 달라진다.
여기서 AES-256-GCM은 암호화 알고리즘, 키의 길이, 동작 방식을 함께 나타내는 이름이다.
AES : 같은 키로 암호화와 복호화를 수행하는 대칭 키 암호화 알고리즘
256 : 사용하는 키의 길이가 256비트, 즉 32 바이트라는 뜻이다.
GCM : 데이터를 암호화하면서 변조 여부도 검증하는 동작 방식이다. 암호화할 때 생성한 인증 태그를 복호화 과정에서 검증하며 검증에 실패하면 데이터를 반환하지 않는다.
DearShot에서는 토큰을 AES-256-GCM으로 암호화해 파일에 저장하고, 다시 읽을 때 검증과 복호화를 거쳐 원래 토큰을 얻는다. 암호화에 필요한 IV 생성과 인증 태그 처리는 Tink 구현에 맡긴다.
핵심 코드는 다음과 같다.
private val keyAlias = "dearshot_auth_datastore_v1"
if (!AndroidKeystore.hasKey(keyAlias)) {
AndroidKeystore.generateNewAes256GcmKey(keyAlias)
}
val aead = AndroidKeystore.getAead(keyAlias)여기서 AndroidKeystore는 com.google.crypto.tink.integration.android.AndroidKeystore다.
keyAlias는 키를 찾기 위한 이름이다. 암호화 키 값이나 비밀번호가 아니다.
generateNewAes256GcmKey()는 같은 alias의 키를 덮어쓸 수 있으므로, 기존 키가 없는 경우에만 호출한다. 초기화가 동시에 실행되지 않도록 만드는 것도 필요하다. 이 API의 동작은 Tink 소스에서 확인할 수 있다. Tink AndroidKeystore 구현
이 구성에서는 별도 SharedPreferences에 Tink 키셋을 저장하지 않는다. Keystore를 사용하는 AEAD 구현을 Serializer에 바로 전달한다. Keystore 사용 실패 시 키를 평문 파일에 저장하는 대체 경로도 두지 않았다.
AEAD는 Authenticated Encryption with Associated Data의 약자다. 암호화와 함께 데이터의 인증 검증을 수행하며, 추가 문맥 정보인 associated data를 사용할 수 있다. Tink AEAD 문서
DearShot에서는 다음 문자열을 사용한다.
val associatedData =
"dearshot.auth.datastore:v1".encodeToByteArray()이 문자열은 비밀 키가 아니다. 이 암호문이 DearShot의 인증 저장소에 속한다는 문맥을 연결하는 값이다.
암호화할 때와 복호화할 때 같은 값을 사용해야 한다. 저장된 파일에 대해 다른 associated data를 사용하면 인증 검증에 실패한다.
이 값이나 키 alias를 무작정 변경해서는 안 된다. 변경이 필요하면 기존 파일을 어떻게 이전하거나 폐기할지 함께 결정해야 한다.
IV 생성과 암호문 처리 같은 세부 작업은 Tink 구현에 맡긴다. 앱 코드에서는 토큰마다 IV를 직접 만들거나 암호문 문자열을 조합하지 않는다.
암호화 저장소를 만드는 핵심 설정은 다음과 같다.
val serializer = AeadSerializer(
aead = AndroidKeystore.getAead(keyAlias),
wrappedSerializer = PreferencesFileSerializer,
associatedData = associatedData,
)
val dataStore = DataStore.Builder(
storage = FileStorage(
serializer = serializer,
produceFile = {
context.preferencesDataStoreFile("auth_tokens_secure")
},
),
context = Dispatchers.IO + SupervisorJob(),
).build()각 인자는 다음 역할을 한다.
aead: 실제 암호화·복호화를 수행하는 Tink 객체
wrappedSerializer: Preferences를 바이트로 바꾸고 다시 읽는 Serializer
associatedData: 암호화에 연결할 문맥 정보
FileStorage: Serializer를 사용해 파일을 읽고 쓰는 저장 방식
produceFile: 저장할 파일 위치
context: 저장소의 작업을 실행할 코루틴 문맥
토큰을 Preferences에 넣을 때에는 평문 문자열을 사용한다. 하지만 파일에 쓰는 단계에서 Serializer가 전체 내용을 암호화한다. 따라서 메모리의 Preferences 값과 디스크에 기록된 바이트는 다르다.
이 예시는 암호화 연결 부분을 보여 주는 발췌 코드다. 실제 프로젝트에는 초기화 직렬화, 오류 복구 정책, 앱 업데이트를 위한 저장 데이터 이전 처리도 추가했다.
같은 파일을 사용하는 DataStore 인스턴스를 여러 개 만들지 않도록 TokenStore를 Hilt의 @Singleton으로 관리했다.
Keystore 초기화가 처음 실행될 때에는 다음 형태를 사용한다.
private val mutex = Mutex()
private var instance: DataStore<Preferences>? = null
suspend fun get(): DataStore<Preferences> = mutex.withLock {
instance ?: withContext(Dispatchers.IO) {
create()
}.also {
instance = it
}
}처음 접근한 호출이 저장소를 만들고, 이후 호출은 같은 인스턴스를 받는다. 동시에 접근하더라도 Mutex가 초기화를 직렬화한다. 초기화가 실패하면 인스턴스를 저장하지 않아서 다음 접근 때 다시 시도할 수 있다.
withContext(Dispatchers.IO)는 키 조회·생성과 저장소 초기화 작업을 메인 스레드에서 수행하지 않기 위해 사용한다.
또한 저장소 자체의 작업 문맥은 앱 수명을 기준으로 잡았다. 화면의 viewModelScope를 DataStore 수명에 연결하면 그 화면이 사라질 때 저장소 작업도 취소될 수 있기 때문이다.
토큰 저장소의 공개 기능은 네 가지다.
suspend fun getTokens(): AuthTokens?
suspend fun save(newTokens: AuthTokens)
suspend fun clear()
suspend fun replaceIfCurrent(
expected: AuthTokens?,
replacement: AuthTokens?,
): Boolean화면이나 Repository에 암호화 코드를 흩어 놓지 않고, 저장소 내부에서 공통으로 적용한다.
토큰 모델은 다음과 같다.
data class AuthTokens(
val accessToken: String,
val refreshToken: String,
) {
init {
require(accessToken.isNotBlank())
require(refreshToken.isNotBlank())
}
override fun toString(): String =
"AuthTokens(accessToken=[REDACTED], refreshToken=[REDACTED])"
}toString()을 덮어쓴 이유는 로그에서 객체를 출력했을 때 토큰이 그대로 노출되는 일을 줄이기 위해서다. 물론 accessToken 필드를 직접 로그로 출력하면 이 보호를 우회하게 되므로, 인증 헤더와 응답 본문을 기록하는 로깅 정책도 별도로 신경 써야 한다.
조회는 DataStore의 Flow를 토큰 모델로 변환한다.
val tokens: Flow<AuthTokens?> = flow {
emitAll(loadDataStore().data.map(::readTokens))
}.distinctUntilChanged()
suspend fun getTokens(): AuthTokens? = tokens.first()readTokens()는 access token과 refresh token이 둘 다 유효한 문자열일 때만 AuthTokens를 만든다. 한쪽이 없으면 로그인 세션이 없는 상태인 null을 반환한다.
distinctUntilChanged()는 토큰 쌍이 실제로 바뀌었을 때만 새 값을 전달한다. first()는 현재 토큰 값 한 번만 필요한 API 요청이나 시작 화면의 세션 복원에서 사용한다.
저장은 updateData()로 처리한다.
suspend fun save(newTokens: AuthTokens) {
loadDataStore().updateData {
writeTokens(it, newTokens)
}
}
private fun writeTokens(
current: Preferences,
tokens: AuthTokens?,
): Preferences = current.toMutablePreferences().apply {
if (tokens == null) {
remove(ACCESS_TOKEN)
remove(REFRESH_TOKEN)
} else {
this[ACCESS_TOKEN] = tokens.accessToken
this[REFRESH_TOKEN] = tokens.refreshToken
}
}.toPreferences()access token만 저장된 중간 상태를 만들지 않도록 두 값을 같은 트랜잭션에서 변경한다. toPreferences()로 새 불변 스냅샷을 반환하며, 파일 암호화는 Serializer가 처리한다.
save()가 성공적으로 끝나면 DataStore의 저장 작업도 완료된 상태다. 쓰기가 실패하면 예외가 전달되므로, 로그인 처리에서는 저장 성공을 확인한 뒤 다음 화면으로 이동해야 한다. DataStore updateData 계약
로그아웃도 같은 함수를 활용한다.
suspend fun clear() {
loadDataStore().updateData {
writeTokens(it, null)
}
}이 함수는 저장된 토큰 쌍을 제거한다. 파일 자체를 물리적으로 지우거나 서버의 refresh token을 폐기하는 기능은 아니다. 서버 로그아웃이 필요하면 해당 API도 별도로 호출해야 한다.
자동 갱신에는 저장 암호화 외에도 신경 써야 할 문제가 있다.
① 기존 토큰으로 API 요청
② 토큰이 만료되어 갱신 요청 시작
③ 사용자가 로그아웃
④ 갱신 응답이 늦게 도착
⑤ 새 토큰을 무조건 저장하면 로그아웃이 취소되는 결과이를 막기 위해 현재 토큰이 갱신을 시작할 때의 토큰과 같은 경우에만 교체한다.
suspend fun replaceIfCurrent(
expected: AuthTokens?,
replacement: AuthTokens?,
): Boolean {
var replaced = false
loadDataStore().updateData { current ->
if (readTokens(current) != expected) {
current
} else {
replaced = true
writeTokens(current, replacement)
}
}
return replaced
}현재 값 확인과 새 값 저장이 하나의 트랜잭션 안에 있다. 확인을 먼저 하고 나중에 save()를 따로 호출하면, 그 사이에 다른 로그인이나 로그아웃이 실행될 수 있다.
access token과 refresh token을 한 쌍으로 비교하며, 상태가 달라졌다면 교체하지 않고 false를 반환한다. 저장 자체가 실패한 경우에는 성공을 반환하지 않고 예외가 전달된다.
토큰 읽기가 실패했다고 해서 항상 저장 파일을 비우면 안 된다. 일시적인 디스크 오류나 Keystore 오류라면 재시도가 가능한데, 파일을 먼저 지워 버리면 정상적인 세션까지 잃을 수 있다.
DearShot에서는 다음 정책을 사용했다.
상황 | 처리 |
|---|---|
토큰이 없는 정상 저장소 | 로그인 필요 상태 |
암호문 인증 검증 실패 또는 Preferences 파싱 손상 | 토큰을 비우고 로그인 필요 상태 |
키가 사라진 뒤 앱을 재시작 | 새 키로 기존 파일을 읽을 수 없으므로 재로그인 처리 |
그 외 암호화 제공자·Keystore 오류, 파일 읽기·쓰기 오류 | 예외 전달, 무조건 파일을 비우지 않음 |
오류 분류는 암호화 알고리즘만의 문제가 아니라 앱의 로그인 정책이다. 기기별 Keystore 동작은 다를 수 있으므로 실제 기기에서도 검증할 계획이다.
이번 구현에서 기준으로 삼은 것은 저장과 암호화, 로그인 정책의 책임을 나누는 것이었다.
DataStore는 데이터를 일관되게 저장하고, Tink는 암호화를 수행하고, Android Keystore는 키를 관리한다. 앱 코드에서는 토큰 쌍을 어떻게 저장하고 교체할지, 읽기 실패 시 어떤 화면으로 보낼지를 결정한다.
암호화 API를 사용한다고 로그인 기능이 자동으로 안전해지는 것은 아니다. 늦게 도착한 갱신 응답, 저장 실패, 키 유실, 로그 출력도 각각 다뤄야 한다는 점을 확인했다.
DearShot에서는 이 저장소를 게스트·소셜 로그인과 자동 갱신 흐름에서 공통으로 사용하도록 구성했다.
원문: Velog에서 읽기