Seize the day

SEARCH RESAULT : 글 검색 결과 - 전체 글 (총 506개)

POST : 독서 노트

오쇼 라즈니쉬의 『쉼』

오쇼 라즈니쉬의 `쉼`이라는 책의 첫 장에는 `비폭력과 무소유` 라는 개념이 나오는데. 여기에 심오한 철학이 있는 것 같아. 최대한 풀어서 설명해줘. 

 

제미나이4 아르곤의 정리가 제일 깔끔하네.
 
 

 

오쇼 라즈니쉬의 『쉼』(파탄잘리의 『요가 수트라』 해설서 제2권) 첫 장에 등장하는 비폭력(Ahimsa, 아힘사)과 무소유(Aparigraha, 아파리그라하)는 단순히 "남을 해치지 마라", "물건을 욕심내지 마라"는 도덕적 계율이 아닙니다. 오쇼는 이를 '인간의 에고(Ego, 거짓 자아)가 만들어내는 두려움에서 벗어나 진정한 내면의 평화(쉼)에 이르는 존재론적 상태'로 해석합니다.

이 두 개념에 담긴 핵심 철학을 네 가지 차원으로 나누어 풀어보겠습니다.

1. 도덕적 억압이 아닌 '의식의 자연스러운 결과'

일반적인 종교나 도덕은 폭력적인 사람에게 "비폭력을 실천하라"고 가르치고, 탐욕스러운 사람에게 "무소유를 행하라"고 강요합니다. 하지만 오쇼는 이러한 억압적 노력이 오히려 내면의 위선과 긴장만 키운다고 지적합니다.

  • 억지로 화를 참는 사람 안에는 여전히 폭력이 끓고 있고, 억지로 재물을 버린 사람 안에는 '나는 무소유를 실천하는 훌륭한 사람이다'라는 또 다른 소유욕(영적 에고)이 자리 잡습니다.
  • 오쇼가 말하는 비폭력과 무소유는 훈련해서 얻는 기술이 아니라, 자신이 누구인지 깊이 깨닫고 내면이 고요해졌을 때 저절로 피어나는 향기와 같습니다.

2. 비폭력(Ahimsa): 두려움의 소멸과 '타자'의 사라짐

파탄잘리의 수트라는 *"비폭력이 내면에 확고히 자리 잡으면, 그 사람의 주변에서는 모든 적대감이 사라진다"*고 말합니다. 오쇼는 이를 심리학적·영적 차원으로 깊이 파고듭니다.

  • 폭력의 진짜 뿌리는 '두려움'입니다: 인간이 타인을 공격하거나 방어 태세를 갖추는 이유는 내면이 불안하고 상처받을까 봐 두렵기 때문입니다. 겉으로는 강해 보이려 폭력을 쓰지만, 그 밑바닥에는 연약한 에고의 공포가 깔려 있습니다.
  • 자기 자신에 대한 비폭력: 오쇼는 남을 해치지 않는 것보다 '자기 자신을 학대하지 않는 것'이 먼저라고 강조합니다. 자신을 끊임없이 자책하고, 세상의 기준에 맞추려 억압하는 내면의 폭력이 멈춰야 외부로 향하는 폭력도 멈춥니다.
  • 분리감의 해체: 내면의 깊은 명상(쉼)에 들어가면 '나'와 '너'를 가르는 에고의 경계선이 무너집니다. 세상의 모든 존재가 나와 연결된 하나의 생명임을 체험하면, 남을 해치는 것이 곧 내 손을 자르는 것과 같아지므로 폭력은 불가능해집니다. 이처럼 내면의 공격성과 두려움이 완전히 사라진 사람 앞에서는 타인이나 동물조차 위협을 느끼지 않아 적대감을 내려놓게 됩니다.

3. 무소유(Aparigraha): 빈곤의 찬양이 아닌 '집착으로부터의 자유'

파탄잘리는 *"무소유가 확립되면 탄생의 비밀(과거와 현재, 미래의 삶이 어떻게 돌아가는지)을 꿰뚫어 보게 된다"*고 설명합니다. 오쇼는 이 구절에서 인간이 왜 끊임없이 무언가를 소유하려 하는지 그 심리를 해부합니다.

  • 소유욕은 '내면의 공허함'을 메우려는 발버둥입니다: 우리는 내면이 텅 비어 있고 존재 자체가 불안하기 때문에 돈, 권력, 지식, 심지어 사랑하는 사람까지 '내 것'으로 만들어 그 빈자리를 채우려 합니다. 즉, 소유물과 나를 동일시하여 "내가 가진 것이 곧 나"라고 착각하는 것입니다.
  • 소유가 우리를 소유한다: 무언가를 움켜쥐는 순간, 그것을 잃어버릴까 봐 전전긍긍하게 됩니다. 결국 내가 물건을 소유하는 것이 아니라 물건과 집착이 나의 영혼을 소유하고 구속하는 주객전도가 일어납니다.
  • 사용하되 집착하지 않는 태도: 오쇼의 무소유는 거지처럼 아무것도 없이 살라는 금욕주의가 아닙니다. 부와 사물을 충분히 누리고 사용하되, 그것에 내 존재의 가치를 묶어두지 않는 '내면의 독립'을 뜻합니다.
  • 삶의 비밀을 보는 눈: 과거의 기억, 현재의 재물, 미래의 안전보장에 대한 집착(소유욕)은 우리의 눈을 가리는 두꺼운 먼지와 같습니다. 움켜쥐었던 손을 펴는 순간, 마음을 짓누르던 탐욕과 망각의 커튼이 걷히면서 내가 왜 이 삶을 살아가고 있는지, 어떤 욕망의 수레바퀴 속에서 끝없이 흔들리고 있었는지 본래의 생명 자리가 투명하게 드러납니다.

4. 비폭력과 무소유가 만나는 지점: 진정한 '쉼'

오쇼의 철학에서 폭력과 소유는 사실 같은 뿌리(에고)에서 나온 두 갈래의 그림자입니다. 무언가를 내 것으로 소유하려다 보니 남과 경쟁하고 빼앗는 폭력이 생겨나고, 내 소유물을 지키려다 보니 세상을 경계하고 공격하게 됩니다.

구분에고(Ego)의 상태본성(깨달음)의 상태
근본 심리 내면의 결핍, 고립감, 죽음에 대한 두려움 내면의 충만함, 우주와의 연결감, 깊은 신뢰
외부로 나타나는 모습 폭력 (방어와 공격) & 소유 (집착과 통제) 비폭력 (사랑과 수용) & 무소유 (자유와 나눔)
삶의 에너지 끊임없는 긴장과 투쟁 (피로) 완전한 이완과 평화 (쉼)
 

결국 이 책의 제목인 '쉼'은 아무 일도 하지 않고 누워 있는 육체적 휴식이 아닙니다. 나를 방어하기 위해 날을 세우던 긴장(폭력)을 내려놓고, 나를 증명하기 위해 움켜쥐고 있던 손(소유)을 가만히 펴는 순간 찾아오는 영혼의 깊은 안식과 자유를 의미합니다.

top

posted at

2026. 10. 18. 01:34


POST : 개발한 애플리케이션

리채2 가계부 안드로이드 버전 출시

옛날에 PC가계부 리채2를 만들었다. PC에서 가계부를 사용하는 건 너무 번거로워서 안드로이드 가계부를 다시 만들었다. 내가 원하는 기능이 명확했기때문에 직접 개발할 수 밖에 없었다. 개발은 2016년에 시작했고, 올 해까지 출시 계획없이 와이프와 나만 사용해 왔다. 서버 없이 동작하는 오프라인 가계부이니 출시한다고 비용이 더 들거나 하지는 않는다. 출시 가능한 수준으로 몇 달간 정리했고 최근에 플레이스토어에 출시했다. 가계부 이름은 `제주 가계부`이다. 

https://play.google.com/store/apps/details?id=com.mdiwebma.leetzsche

 

https://play.google.com/store/apps/details?id=com.mdiwebma.leetzsche

 

play.google.com

 

top

posted at

2026. 10. 10. 00:31


POST : Android Dev Study

안드로이드 앱에서 NoSQL을.. ObjectBox

MongDB 처럼 Entity정의만으로 DB를 사용하는 방법이 없나 ChatGPT랑 라이브러리를 찾아달라고 했더니 ObjectBox를 추천해줬다. 

build.gradle 설정은 생략한다. 귀찮은 건 AI에게 시키자. 
https://github.com/dajkim76/DiffView/commit/e0c9777c0255c34a39d09b40d095bad6bc5c68db

AppBoxStore.kt.  (SQLite의 DatabaseHelper와 같은 )

import android.content.Context
import android.util.Log
import com.mdiwebma.diffviewer.BuildConfig
import io.objectbox.Box
import io.objectbox.BoxStore
import io.objectbox.converter.PropertyConverter

/**
 * Singleton manager for ObjectBox BoxStore in the app.
 * 백업은
 * boxStore.runInTx {
 *   // 이 블록 안에서 data.mdb 파일을 backup 위치로 복사
 *   // adb shell run-as com.mdiwebma.diffviewer ls -la files/objectbox/app-db/
 *   File(dbDir, "data.mdb").copyTo(File(backupDir, "data.mdb"), overwrite = true)
 * }
 */
class AppBoxStore private constructor(applicationContext: Context) {
    val boxStore: BoxStore = MyObjectBox.builder()
        .androidContext(applicationContext)
        .name("app-db")
        .build().also {
            if (BuildConfig.DEBUG) {
                /**
                 *   1. 기기/에뮬레이터가 연결된 상태에서 터미널에 포트 포워딩 명령어를 실행합니다:
                 *     adb forward tcp:8090 tcp:8090
                 *
                 *   2. PC 웹 브라우저(Chrome 등)에서 접속합니다:
                 *   👉 http://localhost:8090/index.html
                 */
                try {
                    // release 모드에서 컴파일 오류를 피하기 위해 리플렉션 사용
                    val adminClass = Class.forName("io.objectbox.android.Admin")
                    val constructor = adminClass.getConstructor(BoxStore::class.java)
                    val adminInstance = constructor.newInstance(it)
                    val startMethod = adminClass.getMethod("start", android.content.Context::class.java)
                    val started = startMethod.invoke(adminInstance, applicationContext) as? Boolean ?: false
                    Log.d(TAG, "ObjectBox Admin (Object Browser) started: $started (port: 8090)")
                } catch (e: Throwable) {
                    Log.w(TAG, "Failed to start ObjectBox Admin: ${e.message}")
                }
            }
        }

    fun isInitialized(): Boolean = !boxStore.isClosed

    @Synchronized
    fun close() {
        if (!boxStore.isClosed) boxStore.close()
    }

    fun <T> getBox(entityClass: Class<T>): Box<T> = boxStore.boxFor(entityClass)

    inline fun <reified T> getBox(): Box<T> = boxStore.boxFor(T::class.java)

    companion object {
        private const val TAG = "AppBoxStore"

        @Volatile
        private var instance: AppBoxStore? = null

        fun getInstance(context: Context): AppBoxStore {
            return instance ?: synchronized(this) {
                instance ?: AppBoxStore(context.applicationContext).also {
                    instance = it
                }
            }
        }

        @Synchronized
        fun close() {
            instance?.close()
            instance = null
        }
    }
}

 

SettingEntity.kt (Table정의와 같은)

import io.objectbox.annotation.ConflictStrategy
import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import io.objectbox.annotation.Unique

@Entity
data class SettingEntry(
    @Id var id: Long = 0,

    @Unique(onConflict = ConflictStrategy.REPLACE) val key: String,

    val value: String,
)

 

AppSettings.kt ( 앱 설정을 추가하는 싱글톤 클래스)

import com.mdiwebma.diffviewer.box.AppBoxStore
import com.mdiwebma.diffviewer.box.SettingEntry
import com.mdiwebma.diffviewer.box.SettingEntry_
import java.util.concurrent.Executors

object AppSettings {
    const val THEME_AUTO = "auto"
    const val THEME_LIGHT = "light"
    const val THEME_DARK = "dark"

    // Declare App settings
    val runCount = SettingLong("runCount", 0)
    val githubApiKey = SettingString("githubApiKey", "")
    val diffGroupId = SettingLong("diffGroupId", 0L)
    val appTheme = SettingString("appTheme", THEME_AUTO)

    //
    // Database operation
    private val dbExecutor = Executors.newSingleThreadExecutor()

    private fun readString(key: String): String? {
        val box = AppBoxStore.getInstance(MyApp.appContext).getBox<SettingEntry>()
        return box.query(SettingEntry_.key.equal(key)).build().use { query ->
            query.findFirst()?.value
        }
    }

    private fun writeString(key: String, value: String) {
        dbExecutor.execute {
            val appBoxStore = AppBoxStore.getInstance(MyApp.appContext)
            appBoxStore.boxStore.runInTx {
                val settingBox = appBoxStore.getBox<SettingEntry>()
                val entity = settingBox.query(SettingEntry_.key.equal(key)).build().use { query ->
                    query.findFirst()
                }
                if (entity != null) {
                    settingBox.put(entity.copy(value = value))
                } else {
                    settingBox.put(SettingEntry(key = key, value = value))
                }
            }
        }
    }

    //
    // Helper class
    abstract class SettingValue<T>(val key: String, val defaultValue: T) {
        @Volatile
        private var cachedValue: T? = null

        protected abstract fun parse(str: String): T?

        var value: T
            get() = cachedValue ?: (readString(key)?.let { parse(it) } ?: defaultValue).also {
                cachedValue = it
            }
            set(newValue) {
                if (cachedValue != newValue) {
                    cachedValue = newValue
                    writeString(key, newValue.toString())
                }
            }

        // 💡 위임 프로퍼티 지원 연산자
        // operator fun getValue(thisRef: Any?, property: KProperty<*>): T = value
        // operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T) { value = newValue }

        init {
            if (BuildConfig.DEBUG) {
                if (!settingInstanceChecker.add(key)) error("SettingValue key is duplicate: $key")
            }
        }
    }

    class SettingInt(key: String, defaultValue: Int) : SettingValue<Int>(key, defaultValue) {
        override fun parse(str: String) = str.toIntOrNull()
    }

    class SettingLong(key: String, defaultValue: Long) : SettingValue<Long>(key, defaultValue) {
        override fun parse(str: String) = str.toLongOrNull()
    }

    class SettingBoolean(key: String, defaultValue: Boolean) : SettingValue<Boolean>(key, defaultValue) {
        override fun parse(str: String) = str.toBooleanStrictOrNull()
    }

    class SettingDouble(key: String, defaultValue: Double) : SettingValue<Double>(key, defaultValue) {
        override fun parse(str: String) = str.toDoubleOrNull()
    }

    class SettingString(key: String, defaultValue: String) : SettingValue<String>(key, defaultValue) {
        override fun parse(str: String) = str
    }
}

private val settingInstanceChecker = mutableSetOf<String>()

 

실제로 DiffViewer앱에 사용해봤다. 

top

posted at

2026. 9. 16. 12:53


POST : 개발한 애플리케이션

DiffViewer: Compare Code,Text 를 개발했다.

BookLife앱의 글쓰기 기능을 개선해볼까하다 글을 저장할 때마다  각 저장된 버전과 현재 글을 비교해서 Diff를 보여주는 기능이 있으면 어떨까 싶었다.  책 쓰기 앱에는 책과 챕터 관리 기능이 더 중요하지만, DiffView가 더 재미가 있어 보여서 ChatGPT에 조사를 맡겼더니,  Github나 Android Studio스타일의 DiffView는 없으니 그냥 새로 만드는게 낫겠다고 했다.  그럼 agy에서 던질 프롬프트를 달라고 해서 제미나이 3.7 Flash에 던져보니 두 세번 질문만에 MVP 버전이 나와버려서 놀랬다.  대부분 제미나이가 생성한 코드이고, 코드리뷰를 꼼꼼히 하지는 않았다. 그래도 개발자 테스트는 충분히 했다. 

DiffView를 별도의 라이브러리로 만들고, 이 라이브러리를 테스트하기위해서 DiffViewer를 간단히 만들었다.  
DiffView 라이브러리/ DiffViwer : https://github.com/dajkim76/DiffView
Download Demo App: https://play.google.com/store/apps/details?id=com.mdiwebma.diffviewer
Demo Video: 

 

top

posted at

2026. 9. 16. 00:29


POST : Android Dev Study

SharedPreferences의 대체재 MMKV

오픈소스 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 라이브러리의 장점
오후 4:32
 

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

 
kotlin
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 비교

항목SharedPreferencesMMKV
쓰기 속도 느림 매우 빠름
멀티 프로세스 ❌ 불안정 ✅ 안전
암호화 ❌ ✅
앱 종료 시 안전성 낮음 높음
데이터 압축 ❌ ✅ Protobuf

성능이 중요한 앱이나, 멀티 프로세스·암호화가 필요한 환경에서 특히 강력한 선택지입니다.

 

MMKV에 암호화지원 방법은?

오후 4:34
 

MMKV 암호화 사용 방법

MMKV는 AES-128 CFB 모드로 암호화를 지원합니다.


1️⃣ 암호화된 인스턴스 생성

 
kotlin
// Android (Kotlin)
val cryptKey = "your-16byte-key!" // 최대 16바이트
val mmkv = MMKV.mmkvWithID("encryptedStorage", MMKV.SINGLE_PROCESS_MODE, cryptKey)
 
swift
// iOS (Swift)
let mmkv = MMKV(mmapID: "encryptedStorage", cryptKey: "your-16byte-key!".data(using: .utf8))

2️⃣ 기본 인스턴스에 암호화 적용

 
kotlin
// 기존 defaultMMKV에 암호화 키 적용
val mmkv = MMKV.defaultMMKV(MMKV.SINGLE_PROCESS_MODE, "your-16byte-key!")

3️⃣ 암호화 키 변경 (reKey)

 
kotlin
// 기존 데이터를 유지하면서 새 키로 재암호화
mmkv.reKey("new-16byte-key!!")

// 암호화 해제 (평문으로 변환)
mmkv.reKey(null)

4️⃣ 암호화 키 확인

 
kotlin
val currentKey = mmkv.cryptKey()
println("현재 암호화 키: $currentKey")

🔐 암호화 키 안전하게 관리하는 방법

하드코딩은 위험하므로 아래 방법을 권장합니다.

✅ Android Keystore 활용 (권장)

 
kotlin
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 활용 (권장)

 
swift
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는 어디에 저장되지?

오후 4:36
 

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 소프트웨어 저장 확인 방법

 
kotlin
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를 전부 저장하니

오후 4:39
 

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)
        ↓
파일 크기 최적화
 
kotlin
// 수동으로 정리하고 싶을 때
mmkv.trim()

💡 요약

항목내용
업데이트 방식 변경된 키만 파일 끝에 append
나머지 키 전혀 영향 없음
파일 증가 업데이트마다 조금씩 커짐
자동 정리 임계점 도달 시 자동 compact

핵심: MMKV가 빠른 이유 중 하나가 바로 이 Append-Only 방식 때문입니다. 전체를 다시 쓰지 않고 끝에만 추가하므로 I/O 비용이 매우 적습니다.

 

Int 값이면 value 사이즈가 늘어나지는 않을텐데 이것도 append하니?

오후 4:40
 

좋은 질문입니다!

결론부터 말하면 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는 무효화하는 비용은?

오후 4:41
 

좋은 질문입니다!


무효화 비용: 거의 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이 별도로 있는 게 아니라, 순서대로 덮어쓰다 보면 자연스럽게 최신 값만 남는 구조입니다. 단순하지만 매우 효율적인 설계입니다.

top

posted at

2026. 3. 31. 17:40


POST : 개발한 애플리케이션

Offline music player supporting timestamp-based cue lists and playback resume

유튜브 프리미엄을 오래 사용하면서, 음악 감상은 주로 유튜브 앱과 유튜브 뮤직 앱을 이용해왔다. 특히 클래식 연주 영상을 많이 듣다 보니 타임스탬프 텍스트를 자주 활용하게 되는데, 두 앱 모두 이를 어느 정도 지원하긴 하지만 일반 음악 앱처럼 Next, Prev 버튼으로 타임스탬프 단위 이동이 되지는 않는다.

그래서 타임스탬프 텍스트로 cue 파일을 생성하는 프로그램을 만들었고, cue 파일을 잘 지원하는 AIMP 앱과 함께 몇 달간 사용해왔다.
https://dajkim76.tistory.com/603 참고

하지만 cue 파일을 만들고 이를 폰으로 옮기는 과정이 번거롭다는 문제가 있었다. 한 번 생성한 cue 파일을 수정하는 것도 쉽지 않았다. 이때도 느꼈지만, 뮤직 앱에서 타임스탬프 텍스트를 직접 입력할 수 있다면 훨씬 편리할 것이라고 생각했다. 관련 기능을 가진 앱을 찾아봤지만 마땅한 것이 없어, 오픈소스 뮤직 앱에 해당 기능을 직접 추가하기로 했고 약 한 달 정도 개발을 진행했다.

핵심 기능 자체는 하루 정도면 구현할 수 있었지만, 기존 앱에 버그가 많고 필요한 기능도 부족해 이를 보완하는 데 시간이 더 걸렸다.

현재 구현한 주요 기능은 다음과 같다. 긴 트랙에 타임스탬프 텍스트를 붙여넣어 Cue 리스트를 생성할 수 있으며, 블루투스 환경에서도 이전/다음 버튼으로 Cue 단위 이동이 가능하다. 또한 마지막 재생 위치를 기억하도록 했고, Cue 리스트에서 특정 구간을 스킵할 수 있어 원하는 곡만 골라 재생할 수도 있다.



Demo Video
https://youtu.be/Fw4VasM7hdQ

 

앱은 https://play.google.com/store/apps/details?id=com.mdiwebma.musicplayer 에서 다운로드 할 수 있다. 

 

Music Player - Google Play 앱

타임스탬프 기반 큐 리스트 및 재생 재개를 지원하는 오프라인 음악 플레이어

play.google.com

 

It is specialized with features to assist with the playback of very long tracks.

You can create a cue list over the cover image by entering timestamps on very long tracks.

For music files downloaded from YouTube, you can easily create a cue list by pasting the YouTube timestamp text.

The Previous and Next buttons move the playback position based on the cue list.

It remembers the track's playback position so you can resume from that point at any time.

App Download:

: https://play.google.com/store/apps/details?id=com.mdiwebma.musicplayer

Downloading in OPUS format with YTDLnis works best.

: https://ytdlnis.org/

Improved:

  • Display Cue playlists on the cover by manually entering YouTube timestamps as text
  • If a Cue playlist exists, add Previous and Next buttons to navigate within the Cue list (Playback Page, Notifications, Widgets)
  • Long press on the Cue playlist to display the menu (Stop Playing, Favorites, Edit Timestamp, Repeat 1 Song)
  • Display YouTube metadata information, create playlists from metadata, and open YouTube if a link exists
  • Add Previous 10 Seconds and Next 10 Seconds buttons to the Playback Page
  • Add gestures to the Playback Page: Up (Open Queue), Left (Next Song), Right (Previous Song). Add an Up animation when opening the queue list and a Down animation when closing the Playback Page
  • Like a track (add to favorites list), navigate to artist, navigate to album, add to playlist, and view properties on the Playback Screen
  • Load and display embedded cover images from track files
  • Improved folder exclusion feature. Direct addition via folder picker; if the parent folder is excluded, subfolders are also excluded.
  • Smart Playlist (Recently added tracks, Recently played tracks, Most played tracks)
  • Improved track back button behavior: If playback has lasted 3 seconds or longer, restart from the beginning instead of the previous track.
  • Added next track button to the current playback bar.
  • Disc number support, displayed alongside track numbers.
  • Added favorites feature to Folder, Artist, and Album tabs.
  • New option: Autoplay upon Bluetooth connection (may not work depending on the device or conditions).
  • New option: Move to the corresponding track's playback screen when a track is played.
  • New option: Start from the last playback position when the user plays a track.

 

top

posted at

2026. 3. 30. 14:42


CONTENTS

Seize the day
BLOG main image
김대정의 앱 개발 노트와 사는 이야기
RSS 2.0Tattertools
공지
아카이브
최근 글 최근 댓글
카테고리 태그 구름사이트 링크