오픈소스 Music Player를 코드를 보니 SharedPreferences에 키를 너무 많이 사용하기에 이게 맞나 싶을 정도 였는데, 궁금해서 Key, value 처리하는 라이브러리로 Datastore 등등 Chatgpt랑 대화하다가 MMKV라는 라이브러리를 알게됬다. UI 쓰레드에서 호출해도 문제가 없고, 멀티 쓰레드, 멀티 프로세스도 지원하고, key-value메모리 캐시도 하고, 키 하나 변경시 하나만 업데이트하고, 값 변경시 Flow로 전달할 수도 있고.. 등등 상당히 흥미가 당겨서 여러 테스트를 해 보았다.
# build.gralde
implementation 'com.tencent:mmkv:2.4.0'
# MyApp.kt onCreate에서
MMKV.initialize(this);
# proguard-rules.pro
-keep class com.tencent.mmkv.** { *; }
1. 가장 간단한 사용법..
object AppSettings {
private val mmkv by lazy { MMKV.defaultMMKV() }
var name
get() = mmkv.decodeString("name") ?: "noname"
set(newValue) {
mmkv.encode("name", newValue)
}
}
ToastUtils.show(AppConfig.name)
AppConfig.name += "a"
이게 가장 간단하지만 설정 값을 추가하려면 5 line을 추가해야해서 번거롭다. 귀찮다.
2. 두번째 방법
abstract class BaseConfig {
abstract val mmkv: MMKV
inner class LongConfig(val key: String, val defaultValue: Long) {
var value
get() = mmkv.decodeLong(key, defaultValue)
set(value) {
mmkv.encode(key, value)
}
}
}
object AppConfig : BaseConfig() {
override val mmkv: MMKV by lazy { MMKV.defaultMMKV() }
val timestamp = LongConfig("timestamp", 0)
}
ToastUtils.show(AppConfig.timestamp.value.toString())
AppConfig.timestamp.value = System.currentTimeMillis()
이 방법은 설정 추가시 1줄이면 된다. 키 와 기본값을 한 곳에서 설정할 수 있다. Config 파일을 여러개 사용하고 싶은다 다른 object를 만들면 된다. 그런데 사용할 때 .value를 항상 붙여야 한다. 이게 좀 거시기 한데.. 이것도 Chatgpt가 세번째 방법을 알려줬다.
3. 세번째 방법
abstract class BaseConfig {
abstract val mmkv: MMKV
protected fun long(key: String, defaultValue: Long) = object : ReadWriteProperty<Any, Long> {
override fun getValue(thisRef: Any, property: KProperty<*>): Long {
return mmkv.decodeLong(key, defaultValue)
}
override fun setValue(thisRef: Any, property: KProperty<*>, value: Long) {
mmkv.encode(key, value)
}
}
protected fun string(key: String, defaultValue: String) = object : ReadWriteProperty<Any, String> {
override fun getValue(thisRef: Any, property: KProperty<*>): String {
return mmkv.decodeString(key, defaultValue) ?: defaultValue
}
override fun setValue(thisRef: Any, property: KProperty<*>, value: String) {
mmkv.encode(key, value)
}
}
}
object AppConfig : BaseConfig() {
override val mmkv: MMKV by lazy { MMKV.defaultMMKV() }
var favoriteTime by long("favoriteTime", 0)
var name by string("name", "noname")
}
ToastUtils.show(AppConfig.favoriteTime.toString())
AppConfig.favoriteTime = System.currentTimeMillis()
훨씬 간결해졌다.
4. Flow로 변경 받기 test
java.lang.UnsupportedOperationException: Intentionally Not implement in MMKV 에러가 난다... 뭔가 별도 구현체가 필요해 보인다. 귀찮다..
protected fun longFlow(key: String, default: Long): Flow<Long> = callbackFlow {
val kv = mmkv
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey ->
if (changedKey == key) {
trySend(kv.decodeLong(key, default))
}
}
// MMKV does not support registerOnSharedPreferenceChangeListener
kv.registerOnSharedPreferenceChangeListener(listener)
trySend(kv.decodeLong(key, default))
awaitClose {
kv.unregisterOnSharedPreferenceChangeListener(listener)
}
}
// Test code
findViewById<View>(R.id.test).setOnClickListener {
AppConfig.count += 1L
}
lifecycleScope.launch {
AppConfig.countFlow.collect { count ->
findViewById<TextView>(R.id.value).text = count.toString()
}
}
5. Flow를 간단히 대충 만들자.
abstract class BaseConfig {
abstract val mmkv: MMKV
inner class LongConfig(val key: String, val defaultValue: Long) {
var value
get() = mmkv.decodeLong(key, defaultValue)
set(value) {
mmkv.encode(key, value)
_flow.value = value // emit
}
private val _flow by lazy { MutableStateFlow<Long>(mmkv.decodeLong(key, defaultValue)) }
val flow: StateFlow<Long> get() = _flow
}
}
object AppConfig : BaseConfig() {
override val mmkv: MMKV by lazy { MMKV.defaultMMKV() }
val count = LongConfig("count", 0)
}
// test code
findViewById<View>(R.id.test).setOnClickListener {
AppConfig.count.value += 1L
}
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
AppConfig.count.flow.collect { count ->
findViewById<TextView>(R.id.value).text = count.toString()
}
}
}
값을 저장하면 emit를 해야한다. 때문에 LongConfig에 코드를 모으는게 좋겠다. 때문에 .value를 다시 사용한다. AppConfig에서만 값을 변경하고 다른 프로세스에서 변경하는 코드가 없다면 이게 구현이 간단하고 기대하는 동작에는 문제가 없다. Flow가 필요한 경우만 이것을 쓰고, 그 외는 by long(... 으로 하는게 나을 듯 하다.)
6. Flow를 다르게 만들자.
abstract class BaseConfig {
protected abstract val mmkv: MMKV
protected val changeNotifier = MutableSharedFlow<String>(replay = 0, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
inner class LongConfig(val key: String, val defaultValue: Long, val hasFlow: Boolean = true) {
var value
get() = mmkv.decodeLong(key, defaultValue)
set(value) {
mmkv.encode(key, value)
if (hasFlow) changeNotifier.tryEmit(key)
}
val flow: Flow<Long>
get() = changeNotifier
.filter { it == key }
.conflate()
.map { value }
.onStart { emit(value) }
.distinctUntilChanged()
}
}
object AppConfig : BaseConfig() {
override val mmkv: MMKV by lazy { MMKV.defaultMMKV() }
var count = LongConfig("count", 0, hasFlow = true)
var name1: String
get() = mmkv.decodeString("name1") ?: "noname"
set(newValue) {
mmkv.encode("name1", newValue)
changeNotifier.tryEmit("name1")
}
val name1Flow: Flow<String>
get() = changeNotifier
.filter { it == "name1" }
.conflate()
.map { name1 }
.onStart { emit(name1) }
.distinctUntilChanged()
}
// test
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
AppConfig.count.flow.collect { count -> findViewById<TextView>(R.id.value).text = count.toString() }
}
launch {
AppConfig.name1Flow.collect { name -> findViewById<TextView>(R.id.test).text = name }
}
}
}
텐센트 MMKV 2.x는 64 bit arm cpu 만을 지원한다.
Caused by com.tencent.mmkv.UnsupportedArchitectureException
MMKV 2.0+ requires 64-bit App, use 1.3.x instead.
따라서 Google play에서 32 설치를 제한하려면 abi 필터를 해야 한다.
아니면 아니면 1.3.x를 사용할 수 있다. 32bit arm cpu와 x86 계열의 안드로이드는 1% 미만이라서 제외하는게 좋겠다. https://github.com/Goooler/MMKV 도 참고
android {
defaultConfig {
// ... 기존 설정 ...
ndk {
// 빌드 결과물에 64비트 아키텍처만 포함시킵니다. armeabi-v7a를 제외
abiFilters "arm64-v8a"
}
}
}
클로드와 대화
MMKV 라이브러리의 장점
MMKV는 WeChat 팀이 개발한 고성능 키-값 저장 라이브러리입니다. 주요 장점을 정리하면 다음과 같습니다.
⚡ 1. 뛰어난 성능
- mmap 기반 메모리 매핑으로 디스크 I/O 없이 데이터를 읽고 씀
- SharedPreferences 대비 수백 배 빠른 읽기/쓰기 속도
- 쓰기 작업이 동기적이지 않아 UI 스레드 블로킹 없음
🔒 2. 데이터 안정성
- mmap 특성상 앱이 비정상 종료되어도 데이터 손실 없음
- 내부적으로 CRC 체크섬으로 데이터 무결성 검증
- 파일 손상 시 자동 복구 메커니즘 내장
🗜️ 3. 효율적인 저장 공간 관리
- Protobuf(Protocol Buffers) 기반 인코딩으로 직렬화 오버헤드 최소화
- 자동 공간 정리(trim): 삭제된 키의 공간을 자동으로 회수
- 파일 크기가 필요 이상으로 커지지 않도록 최적화
🔄 4. 멀티 프로세스 지원
- MMKV.mmkvWithID("name", MMKV.MULTI_PROCESS_MODE)로 프로세스 간 데이터 공유 가능
- SharedPreferences는 멀티 프로세스에서 불안정하지만, MMKV는 안전하게 지원
🌐 5. 크로스 플랫폼
- Android, iOS, macOS, Windows, POSIX 모두 지원
- React Native, Flutter 공식 바인딩 제공
- 플랫폼 간 동일한 API 인터페이스 유지
🛡️ 6. 암호화 지원
- AES-128 기반 데이터 암호화 옵션 제공
- 민감한 정보(토큰, 설정값)를 안전하게 저장 가능
🧩 7. 간편한 API
val mmkv = MMKV.defaultMMKV()
// 쓰기
mmkv.encode("key", "value")
mmkv.encode("count", 42)
// 읽기
val value = mmkv.decodeString("key")
val count = mmkv.decodeInt("count")
SharedPreferences와 유사한 API라 마이그레이션이 쉬움
📊 SharedPreferences vs MMKV 비교
| 쓰기 속도 | 느림 | 매우 빠름 |
| 멀티 프로세스 | ❌ 불안정 | ✅ 안전 |
| 암호화 | ❌ | ✅ |
| 앱 종료 시 안전성 | 낮음 | 높음 |
| 데이터 압축 | ❌ | ✅ Protobuf |
성능이 중요한 앱이나, 멀티 프로세스·암호화가 필요한 환경에서 특히 강력한 선택지입니다.
MMKV에 암호화지원 방법은?
MMKV 암호화 사용 방법
MMKV는 AES-128 CFB 모드로 암호화를 지원합니다.
1️⃣ 암호화된 인스턴스 생성
// Android (Kotlin)
val cryptKey = "your-16byte-key!" // 최대 16바이트
val mmkv = MMKV.mmkvWithID("encryptedStorage", MMKV.SINGLE_PROCESS_MODE, cryptKey)
// iOS (Swift)
let mmkv = MMKV(mmapID: "encryptedStorage", cryptKey: "your-16byte-key!".data(using: .utf8))
2️⃣ 기본 인스턴스에 암호화 적용
// 기존 defaultMMKV에 암호화 키 적용
val mmkv = MMKV.defaultMMKV(MMKV.SINGLE_PROCESS_MODE, "your-16byte-key!")
3️⃣ 암호화 키 변경 (reKey)
// 기존 데이터를 유지하면서 새 키로 재암호화
mmkv.reKey("new-16byte-key!!")
// 암호화 해제 (평문으로 변환)
mmkv.reKey(null)
4️⃣ 암호화 키 확인
val currentKey = mmkv.cryptKey()
println("현재 암호화 키: $currentKey")
🔐 암호화 키 안전하게 관리하는 방법
하드코딩은 위험하므로 아래 방법을 권장합니다.
✅ Android Keystore 활용 (권장)
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import android.util.Base64
// 1. Android Keystore에 키 생성
fun generateKeyStoreKey(alias: String) {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
keyGenerator.init(
KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_CFB)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(128)
.build()
)
keyGenerator.generateKey()
}
// 2. Keystore에서 키를 꺼내 MMKV에 적용
fun getEncryptedMMKV(): MMKV {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val secretKey = keyStore.getKey("mmkv_key_alias", null) as SecretKey
val keyBytes = secretKey.encoded?.let {
Base64.encodeToString(it, Base64.NO_WRAP).take(16)
} ?: "fallback-key-16b"
return MMKV.mmkvWithID("secureStorage", MMKV.SINGLE_PROCESS_MODE, keyBytes)
}
✅ iOS Keychain 활용 (권장)
import Security
func saveKeyToKeychain(key: String) {
let data = key.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "mmkv_crypt_key",
kSecValueData as String: data
]
SecItemAdd(query as CFDictionary, nil)
}
func loadKeyFromKeychain() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "mmkv_crypt_key",
kSecReturnData as String: true
]
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result)
return (result as? Data).flatMap { String(data: $0, encoding: .utf8) }
}
// 사용
let cryptKey = loadKeyFromKeychain() ?? "default-key-16b!"
let mmkv = MMKV(mmapID: "secureStorage", cryptKey: cryptKey.data(using: .utf8))
```
---
### ⚠️ 주의사항
| 항목 | 내용 |
|------|------|
| 키 길이 | **최대 16바이트** (초과 시 잘림) |
| 알고리즘 | AES-128 CFB 모드 고정 |
| 키 분실 | 키를 잃으면 **데이터 복구 불가** |
| 하드코딩 금지 | 소스코드에 키를 직접 넣으면 역공학에 취약 |
| reKey 주의 | 키 변경 중 앱 종료 시 데이터 손상 가능성 있음 |
---
### 💡 정리
```
암호화 키 생성 → Keystore/Keychain에 저장 → MMKV 인스턴스 생성 시 키 주입
가장 안전한 방법은 OS의 보안 저장소(Keystore/Keychain) 에 암호화 키를 보관하고, 앱 실행 시마다 꺼내서 MMKV에 주입하는 패턴입니다.
AndroidKeyStore는 어디에 저장되지?
Android Keystore 저장 위치
📍 물리적 저장 위치
Android Keystore의 키는 앱이 직접 접근할 수 없는 곳에 저장됩니다.
1️⃣ 하드웨어 기반 저장 (최우선)
TEE (Trusted Execution Environment)
또는
StrongBox (독립 보안 칩)
┌─────────────────────────────────┐
│ Android OS │
│ │
│ ┌─────────────────────────┐ │
│ │ 일반 앱 영역 (APK) │ │ ← 키에 직접 접근 불가
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ │
│ │ Android Keystore API │ │ ← 앱은 여기까지만 접근
│ └────────────┬────────────┘ │
└────────────────┼────────────────┘
↓
┌─────────────────────────────────┐
│ TEE / StrongBox (격리) │ ← 실제 키 보관
│ - 별도 CPU/메모리에서 실행 │
│ - OS도 접근 불가 │
└─────────────────────────────────┘
2️⃣ 소프트웨어 기반 저장 (TEE 없을 때 폴백)
TEE를 지원하지 않는 구형 기기에서는:
/data/misc/keystore/ ← 시스템 프로세스 소유
/data/misc/keystore/user_0/ ← 사용자별 키 파일
단, 이 경로는 root 권한 없이는 앱에서 접근 불가
3️⃣ 하드웨어 vs 소프트웨어 저장 확인 방법
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val entry = keyStore.getEntry("mmkv_key_alias", null) as? KeyStore.PrivateKeyEntry
// API 31+
val keyInfo = keyFactory.getKeySpec(secretKey, KeyInfo::class.java)
when {
keyInfo.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX ->
println("StrongBox 하드웨어 칩에 저장")
keyInfo.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT ->
println("TEE에 저장")
else ->
println("소프트웨어(OS)에 저장")
}
🔐 보안 수준 비교
| StrongBox | 독립 보안 칩 | ⭐⭐⭐ 최상 | Pixel 3+ 등 일부 |
| TEE | 격리된 CPU 영역 | ⭐⭐ 높음 | Android 6.0+ 대부분 |
| 소프트웨어 | /data/misc/keystore | ⭐ 보통 | 구형 기기 폴백 |
🔑 핵심 특징
- 앱이 키의 raw bytes를 꺼낼 수 없음 → API를 통해 암/복호화 연산만 요청
- 기기를 바꾸면 키 이전 불가 → 기기에 완전히 종속
- 앱 삭제 시 키도 함께 삭제
- OS조차 TEE 내부 키에 직접 접근 불가
💡 요약
Android Keystore의 키는 앱도, OS도 직접 볼 수 없는 하드웨어 격리 영역(TEE/StrongBox) 에 저장되며, 암호화 연산은 그 안에서만 수행됩니다. 키의 실체가 앱 메모리에 노출되지 않는 것이 핵심 보안 원칙입니다.
하나의 mmkv에 키가 한 50개 있다면 하나 업데이트할때마다 50개 value를 전부 저장하니
MMKV의 업데이트 방식
아닙니다. MMKV는 변경된 키-값 하나만 저장합니다.
📝 MMKV의 쓰기 방식: Append-Only Log
┌─────────────────────────────────────┐
│ MMKV 파일 │
├─────────────────────────────────────┤
│ [key1 = "철수"] ← 최초 저장 │
│ [key2 = 100] │
│ [key3 = true] │
│ ... │
│ [key50 = "data"] │
│ │
│ [key1 = "영희"] ← 업데이트 추가 │
│ (나머지 안건드림)│
└─────────────────────────────────────┘
key1을 업데이트하면 파일 끝에 새 값만 append 합니다. 읽을 때는 가장 마지막 key1 값을 사용합니다.
🆚 SharedPreferences와 비교
SharedPreferences 업데이트 시:
→ XML 파일 전체를 다시 직렬화 후 저장
→ 키가 50개면 50개 전부 다시 씀 ❌
MMKV 업데이트 시:
→ 변경된 키-값 하나만 파일 끝에 추가
→ 나머지 49개 전혀 건드리지 않음 ✅
🗜️ 그럼 파일이 계속 커지나요?
맞습니다. 그래서 MMKV는 자동 정리(trim/compact) 를 합니다.
파일 크기가 임계점 도달
↓
중복된 키 제거 (각 키의 최신 값만 유지)
↓
파일 재작성 (compact)
↓
파일 크기 최적화
// 수동으로 정리하고 싶을 때
mmkv.trim()
💡 요약
| 업데이트 방식 | 변경된 키만 파일 끝에 append |
| 나머지 키 | 전혀 영향 없음 |
| 파일 증가 | 업데이트마다 조금씩 커짐 |
| 자동 정리 | 임계점 도달 시 자동 compact |
핵심: MMKV가 빠른 이유 중 하나가 바로 이 Append-Only 방식 때문입니다. 전체를 다시 쓰지 않고 끝에만 추가하므로 I/O 비용이 매우 적습니다.
Int 값이면 value 사이즈가 늘어나지는 않을텐데 이것도 append하니?
좋은 질문입니다!
결론부터 말하면 Int도 동일하게 Append 방식으로 동작합니다.
왜 제자리 수정(in-place update)을 안 하나요?
얼핏 보면 Int는 크기가 고정(4바이트)이니 제자리에서 덮어쓰면 될 것 같지만:
┌──────────────────────────────────────┐
│ [key1 = 42] ← 여기를 덮어쓰면? │
│ [key2 = 100] │
│ ... │
└──────────────────────────────────────┘
문제점:
- 파일에서 key1의 정확한 오프셋을 찾는 탐색 비용 발생
- 쓰는 도중 앱 종료 시 해당 위치 데이터 손상 가능
- mmap 특성상 랜덤 쓰기보다 순차 쓰기가 훨씬 빠름
MMKV가 Append를 고집하는 이유
Append 방식
→ 항상 파일 끝 주소만 알면 됨
→ 탐색(seek) 없음
→ 손상 가능성 없음 (기존 데이터 안건드림)
→ 속도 최우선
단, Protobuf 인코딩으로 Int 크기는 최소화
MMKV는 Protobuf의 varint 인코딩을 사용해서 작은 숫자일수록 적은 바이트를 씁니다.
값 1 → 1바이트
값 127 → 1바이트
값 128 → 2바이트
값 16383 → 2바이트
Int.MAX → 5바이트 (최대)
그래서 append를 하더라도 실제 추가되는 데이터 크기는 매우 작습니다.
💡 요약
Int든 String이든 무조건 Append. 대신 추가되는 크기가 매우 작고, 파일이 커지면 자동 compact로 정리합니다.
앞에 Int value는 무효화하는 비용은?
좋은 질문입니다!
무효화 비용: 거의 0
MMKV는 이전 값을 명시적으로 무효화하지 않습니다.
┌─────────────────────────────────────┐
│ [key1 = 42] ← 그냥 방치 (무시) │
│ [key2 = 100] │
│ ... │
│ [key1 = 99] ← 새로 append │
└─────────────────────────────────────┘
이전 값을 지우거나 마킹하는 작업이 전혀 없습니다.
그럼 읽을 때는?
앱 최초 로드 시 딱 한 번 전체 파일을 스캔해서 메모리에 올립니다.
앱 시작
↓
파일 전체 스캔 (1회)
↓
중복 키는 마지막 값으로 덮어쓰며 HashMap 구성
↓
이후 읽기/쓰기는 전부 메모리(mmap) 기반
key1=42 → key1=99 → HashMap에 key1:99 만 남음
비용 발생 시점 정리
| 쓰기(업데이트) | Append만 → 거의 0 |
| 이전 값 무효화 | 없음 → 완전히 0 |
| 읽기(런타임) | 메모리 HashMap 조회 → O(1) |
| 앱 시작 시 | 파일 전체 스캔 1회 → 키 많을수록 증가 |
| compact 시 | 파일 재작성 → 가끔 발생 |
💡 핵심 트레이드오프
쓰기 속도 최우선
↓
이전 값 방치 (무효화 비용 0)
↓
파일에 중복 데이터 누적
↓
임계점 도달 시 compact로 한 번에 정리
결국 "쓰기 비용을 0에 가깝게 만들고, 정리 비용은 가끔 몰아서 낸다" 는 전략입니다.
실제로 느리냐?
키 50개 기준으로는 체감 불가 수준입니다.
키 50개 / Int 값
→ 실제 파일 크기 수 KB 수준
→ mmap이라 OS가 메모리에 올려둠
→ 스캔 = 메모리 읽기 수준의 속도
💡 핵심
무효화 skip이 별도로 있는 게 아니라, 순서대로 덮어쓰다 보면 자연스럽게 최신 값만 남는 구조입니다. 단순하지만 매우 효율적인 설계입니다.
글 검색 결과 - 전체 글 (총 502개)







