구현 목적

앱을 제작하면서 온라인에서 가져온 데이터를 메모리에 캐싱해야 할 필요가 있었다. 그 이유는 해당 데이터의 서버 상에서 업데이트 주기가 수 십분 ~ 수 시간 단위로 긴데, 이 데이터를 짧은 간격으로 다시 중복으로 가져오는 것은 비효율적이기 때문이다. 그래서 메모리에 캐싱하는 기능을 만들었고 효율성을 개선할 수 있었다.

비효율적인 기존 방식

총 세 가지의 지역(A, B, C)에 대해서 대기질 데이터를 가져오는 기능을 구현했다고 가정하였다.

비효율적인 기존 방식

  • 흐름
    1. A의 대기질 데이터를 로드
    2. B 정보 화면으로 전환 -> B의 대기질 데이터를 로드
    3. A 정보 화면으로 재 전환 -> A의 대기질 데이터를 로드
    4. C 정보 화면으로 전환 -> C의 대기질 데이터를 로드
    5. A 정보 화면으로 재 전환 -> A의 대기질 데이터를 로드

20초간 A의 데이터를 3번 로드하였고, 이 시간동안 서버 상에서 데이터가 갱신되지 않았다면 중복된 데이터를 2번 불러오게 된다. 만약 업데이트 주기가 길고, 화면 전환이 잦다면 위와 같은 상황은 빈번하게 발생할 것이다.

최근에 날씨 앱을 제작하고 있는데, 여러 부분에서 이런 상황이 발생하였다.

  • 화면을 전환하였다가 다시 돌아왔을때 데이터를 다시 불러옴(짧은 주기)
    • 날씨 정보 화면 -> 앱 설정 화면 -> 날씨 정보 화면
  • 같은 지역의 데이터를 비슷한 시점에 다른 기능 구현을 위해 불러옴
    • 위젯, 알림, 앱 날씨 화면이 거의 동시에 업데이트 되는 경우
    • 계속 최신 데이터만 받아온다면, 최악의 경우 같은 데이터를 수십번 동시다발적으로 불러올 수도 있다.
  • 예보 비교 화면(시간별, 일별)
    • 예를 들어, A지역의 시간별 예보를 불러온지 몇초 밖에 지나지 않은 상태에서 여러 날씨 제공사의 예보를 비교하려고 한다면, 같은 제공사의 데이터를 다시 불러올 수도 있다.

다수의 문제점이 발생하였고, 데이터를 좀더 효율적으로 관리할 필요가 있었다. 그래서 코틀린 코루틴을 활용하여 데이터를 관리하는 기능을 구현하였다.

!이러한 상황이 무조건 비효율적이라고 정답을 내릴 수는 없다. 만약 업데이트 주기가 아주 짧으면서, 최신 데이터만을 UI에 보여주려고 한다면 위와 같은 방식을 사용하는 것이 더 나을 수도 있다.

구현 방식

코루틴

앱에서 코루틴을 사용하여 비동기 로직을 처리하고 있기 때문에, 코루틴으로 구현하였다.

캐시 관리

  • 캐시 시간 제한
  • Lru Cache

캐시 시간 제한

캐시 기능이 필요한 이유이기 때문에, 가장 우선적으로 구현하였다.

서버에서 데이터를 가져와서 캐시에 저장할 때, 데이터의 유효 시간을 함께 저장한다. 이후 캐시를 불러올 때 현재 시간과 캐시의 유효 시간을 비교하여 캐시가 유효한지 확인한다. 만약 유효하지 않다면, 다시 서버에서 데이터를 가져와서 캐시에 저장한다.

Lru Cache

설계시 미처 고려하지 못한 것으로, 한창 구현하다가 저장된 캐시의 크기가 너무 커지는 문제가 발생할 수 있음을 깨달았다.

이를 해결하기 위해 Lru Cache를 사용하였다. Lru Cache는 가장 오랫동안 사용되지 않은 데이터를 삭제하는 캐시 관리 기법이며, 저장할 캐시의 최대 개수(크기)를 지정할 수 있다.

  • 예를 들어, 최대 크기를 3으로 지정하였을 때, 4번째로 캐시가 추가될 때 1번째로 추가된 캐시는 삭제된다.

Lru Cache에 대해선 이전에 작성한 포스팅 ArrayMap, SparseArray, LruCache에 대해서 알아보기 을 참고하면 도움이 될 것이다.

데이터 동기화

여러 스레드에서 동시에 캐시에 접근할 수 있기 때문에, 데이터 동기화를 해주어야 한다.

가장 중요한 부분, 섬세한 로직이 요구되었다.

  • 적용해본 동기화 방식
    • Mutex, Synchronized
    • ConcurrentHashMap
    • ReentrantReadWriteLock
    • Actor

여러 방식으로 테스트 해보면서 Actor로 최종 구현하였다. 이 과정에서 코루틴의 동작 방식에 대해 더욱 깊게 이해할 수 있었다.

1. Mutex, Synchronized

제일 먼저 적용한 기법이다.

Mutex는 Mutual Exclusion의 약자로 상호 배제를 의미한다. 임계 구역을 만들고 그 구역을 통해서 스레드가 데이터에 접근한 뒤 구역을 잠궈서, 다른 스레드는 접근할 수 없도록 막는 기법이다.

스레드 A, B가 동시에 어떤 로직을 시작하려고 할 때, A가 먼저 시작하면 B는 A가 끝낼 때 까지 그 로직 수행을 못하고, A가 작업을 끝내야만 B가 수행할 수 있다. Synchrnoized도 이와 유사하다.

예를 들어, Map을 Mutex 또는 Synchronized로 동기화하면, 그 Map은 동시에 하나의 스레드만 다룰수 있기 때문에, 데이터 동기화가 보장되는 것이다.

Mutex, Synchronized의 차이

  Mutex Synchronized
동작 기반 프로그램 구동 플랫폼, 코루틴에 최적화 JVM
소유권 접근하는 스레드가 잠그고,해제하는 소유권을 가짐 소유 개념이 없음
잠금해제 대기 방식 코루틴 동작을 일시중지 스레드 동작을 일시중지
재진입 불가능, 스레드 A가 Mutex를 잠그고 다시 Mutex를 접근하면 그대로 무한 대기에 빠질 수 있음 가능, 한 스레드가 Synchronized에 접근한 상태에서 다시 Synchronized하더라도 문제없이 그대로 접근가능

잠금해제를 대기하는 방식에서 확실한 차이가 있다.

  • Mutex : 대기하는 동안 다른 작업 가능, 코루틴에 최적화되어 있어 대기하는 동안 다른 코루틴으로 작업을 넘길 수 있다.
  • Synchronized : 대기하는 동안 다른 작업 불가능

하나의 칸만 있는 화장실이 있는 상황으로 비교해보자면 다음과 같다.

  • Mutex : 다른 사람이 나올때 까지 기다리는 동안, 휴대폰을 보는 등 다른 작업을 할 수 있다.
  • Synchronized : 기다리는 동안 아무것도 못한다. 휴대폰도 없고 뭐도 못한다. 그냥 기다린다.

2. ConcurrentHashMap

우리가 직접 따로 임계 구역을 만들어 동기화 할 필요없이 자체적으로 동기화를 지원하는 Map이다.

Map을 예로 들어, Mutex, Synchronized로 임계 구역을 통해 동기화를 하게 되면 Map전체에 대해서 다른 스레드는 접근할 수 없게 된다. 하지만 ConcurrentHashMap은 Map의 일부분만 잠그고, 나머지 부분은 다른 스레드가 접근할 수 있게 해준다.

Map의 key로 1, 2가 있을 때

  • Mutex, Synchronized 를 쓴다면, 1에 대한 작업을 하고 있는 동안 2에 대한 작업을 할 수 없다.
  • ConcurrentHashMap을 쓴다면, 1에 대한 작업을 하고 있는 동안 2에 대한 작업을 할 수 있다.

3. ReentrantReadWriteLock

ReentrantReadWriteLock은 Mutex와 Synchronized와 다르게, 읽기와 쓰기 각각에 대해 잠금을 걸 수 있다. 즉, 읽기 잠금과 쓰기 잠금을 따로 걸 수 있다.

  • 쓰기 잠금을 걸었다면 다른 스레드는 읽기와 쓰기 모두 불가능하다.
  • 읽기 잠금을 걸었더라도 다른 키로 접근한다면 읽을 수 있다.

4. Actor

Actor Model 패턴으로 동기화를 구현하는 방법이다. Concurrency with Actor Model(행위자 모델) 페이지에 상세히 정리되어 있어 추가로 참조하면 도움이 될 것이다.

제목 없는 다이어그램-페이지-3

특정 기능을 전문적으로 수행하는 기능을 하는 것이 Actor이다. 외부에서 Actor에게 메시지를 보내면, Actor는 메시지를 처리하고 결과를 다시 보내준다.

하나의 Actor는 동시에 작업을 수행하지 않고 동기적으로 하나씩 처리하기 때문에, 데이터 동기화가 보장된다.

액터 모델 패턴을 실생활로 비유하자면, 액터는 어떤 특정한 기능을 전문적으로 수행하는 사람이다. 다른 사람은 이 사람과 메시지 채팅으로 소통을 할 수 있다.

  • 특징
    • 작업을 메시지를 받는 순서대로 처리한다. (메시지 큐)
    • 순서대로 처리하므로 별도의 동기화를 해줄 필요가 없다.
    • 메시지 별로 분기하여 독립적인 작업을 수행하기 때문에, 다른 방식 대비 코드가 더 직관적이고 간결하다.

최종 구현 방식

Actor, 여러 방식을 적용해보면서, 개인적으로 코드가 가장 직관적이면서 관리하기 편했고 코루틴에 최적화 되어 있어 이를 선택했다.

! 가장 좋은 방식을 뽑는다면 정답이 없다고 할 수 있다.

구현 코드

CacheManager

캐시 관리 기능을 담당하는 클래스이다. 캐시를 저장하고, 불러오고, 삭제하는 기능을 제공한다.

  • defaultCacheExpiryTime : 캐시의 기본 유효 시간
  • cleaningInterval : 캐시 정리 주기
  • cacheMaxSize : 캐시 최대 개수
abstract class CacheManager<K, V>(
    protected val defaultCacheExpiryTime: Long,
    protected val cleaningInterval: Long,
    protected val cacheMaxSize: Int
) {
    abstract suspend fun get(key: K): CacheState<V>

    abstract suspend fun remove(key: K): Boolean

    abstract suspend fun put(key: K, value: V, cacheExpiryTime: Long = defaultCacheExpiryTime)

    abstract suspend fun entries(): List<Pair<K, Cache<V>>>

    sealed interface CacheState<out V> {
        data class Hit<V>(val value: V) : CacheState<V>
        data object Miss : CacheState<Nothing>
    }
}

CacheCleaner

코루틴 작업으로 에약되어 설정한 주기마다 자동으로 만료된 캐시를 정리하는 기능을 on/off 할 수 있는 인터페이스이다.

interface CacheCleaner {
    fun start()
    fun stop()
}

Cache

Map으로 키와 함께 저장되는 캐시 값이다.

  • value : 캐시 값
  • cacheExpiryTime : 캐시 유효 시간
  • addedTime : 캐시 추가 시간
data class Cache<V>(
    val value: V, val cacheExpiryTime: Long, val addedTime: Long = System.currentTimeMillis()
) {
    fun isExpired(now: Long = System.currentTimeMillis()): Boolean = now - addedTime > cacheExpiryTime
}

CacheManagerImpl(실제 구현체)

위 추상클래스와 인터페이스를 구현한 클래스이다.

  • cacheExpiryTime : 캐시의 기본 유효 시간
  • cleaningInterval : 캐시 정리 주기
  • cacheMaxSize : 캐시 최대 개수
  • dispatcher : 코루틴 디스패처
  • cacheActor : 캐시 관리 기능을 담당하는 Actor
  • isCacheCleanerRunning : 캐시 정리 작업이 실행중인지 여부
  • waitTimeForCacheCleaning : 캐시 정리 작업이 실행중일 때, 대기할 시간
  • cacheCleanerJob : 캐시 정리 코루틴 작업
class CacheManagerImpl<K, V>(
    cacheExpiryTime: Duration = Duration.ofMinutes(5),
    cleaningInterval: Duration = Duration.ofMinutes(5),
    cacheMaxSize: Int = 10,
    dispatcher: CoroutineDispatcher
) : CacheManager<K, V>(cacheExpiryTime.toMillis(), cleaningInterval.toMillis(), cacheMaxSize), CacheCleaner,
    CoroutineScope by CoroutineScope(dispatcher) {

    private val cacheActor = cacheManagerActor()
    private val isCacheCleanerRunning = AtomicBoolean(false)
    private val waitTimeForCacheCleaning = 20L
    private var cacheCleanerJob: Job? = null

    init {
        start()
    }

    override fun start() {
        if (cacheCleanerJob?.isActive == true) {
            return
        }

        cacheCleanerJob = launch(SupervisorJob()) {
            while (true) {
                delay(cleaningInterval)

                isCacheCleanerRunning.getAndSet(true)

                val response = CompletableDeferred<Int>()
                cacheActor.send(CacheMessage.Clear(response))
                response.await()

                isCacheCleanerRunning.getAndSet(false)
            }
        }
    }

    override fun stop() {
        launch {
            while (isCacheCleanerRunning.get()) {
                delay(waitTimeForCacheCleaning)
            }
            cacheCleanerJob?.cancel()
            cacheCleanerJob = null
        }
    }

    override suspend fun get(key: K): CacheState<V> {
        val response = CompletableDeferred<CacheState<V>>()
        cacheActor.send(CacheMessage.Get(key, response))
        return response.await()
    }


    override suspend fun put(key: K, value: V, cacheExpiryTime: Long) {
        cacheActor.send(CacheMessage.Put(key, value, cacheExpiryTime))
    }

    override suspend fun remove(key: K): Boolean {
        val response = CompletableDeferred<Boolean>()
        cacheActor.send(CacheMessage.Remove(key, response))
        return response.await()
    }

    override suspend fun entries(): List<Pair<K, Cache<V>>> {
        val response = CompletableDeferred<List<Pair<K, Cache<V>>>>()
        cacheActor.send(CacheMessage.Entries(response))
        return response.await()
    }

    @OptIn(ObsoleteCoroutinesApi::class)
    private fun CoroutineScope.cacheManagerActor(
    ) = actor<CacheMessage<K, V>>(start = CoroutineStart.LAZY) {
        val cacheMap = LruCache<K, Cache<V>>(cacheMaxSize)
        for (msg in channel) {
            msg.process(cacheMap)
        }
    }

    private sealed interface CacheMessage<K, V> {
        fun process(cacheMap: LruCache<K, Cache<V>>)

        data class Put<K, V>(val key: K, val value: V, val expiryTime: Long) : CacheMessage<K, V> {
            override fun process(cacheMap: LruCache<K, Cache<V>>) {
                cacheMap.put(key, Cache(value, expiryTime))
            }
        }

        data class Remove<K, V>(val key: K, val response: CompletableDeferred<Boolean>) : CacheMessage<K, V> {
            override fun process(cacheMap: LruCache<K, Cache<V>>) {
                response.complete(cacheMap.remove(key) != null)
            }
        }

        data class Clear<K, V>(val response: CompletableDeferred<Int>) : CacheMessage<K, V> {
            override fun process(cacheMap: LruCache<K, Cache<V>>) {
                val now = System.currentTimeMillis()
                var removedCount = 0
                cacheMap.snapshot().forEach { (key, cache) ->
                    if (cache.isExpired(now)) {
                        cacheMap.remove(key)
                        removedCount++
                    }
                }
                response.complete(removedCount)
            }
        }

        data class Get<K, V>(
            val key: K, val response: CompletableDeferred<CacheState<V>>
        ) : CacheMessage<K, V> {
            override fun process(cacheMap: LruCache<K, Cache<V>>) {
                val cacheState = cacheMap[key]?.run {
                    if (isExpired()) {
                        CacheState.Miss
                    } else {
                        CacheState.Hit(value)
                    }
                } ?: run {
                    CacheState.Miss
                }
                response.complete(cacheState)
            }
        }

        data class Entries<K, V>(
            val response: CompletableDeferred<List<Pair<K, Cache<V>>>>
        ) : CacheMessage<K, V> {
            override fun process(cacheMap: LruCache<K, Cache<V>>) {
                response.complete(cacheMap.snapshot().map { it.key to it.value })
            }
        }
    }

}

Actor

코루틴 스코프 내에서 Actor를 생성하였다.

CacheManagerImpl 인스턴스의 생명주기(앱 실행 ~ 종료)와 동일하다.

private fun CoroutineScope.cacheManagerActor(
    ) = actor<CacheMessage<K, V>>(start = CoroutineStart.LAZY) { }
  • 가능한 작업
    • Get : 캐시를 불러온다.
    • Put : 캐시를 저장한다.
    • Remove : 캐시를 삭제한다.
    • Clear : 캐시를 모두 삭제한다.
    • Entries : 캐시를 모두 불러온다.

cacheCleanerJob, 캐시 자동 정리기

캐시 매니저를 사용할 떄 수동으로 캐시 정리 코드를 만드는 수고를 덜기 위해, 캐시 자동 정리기를 구현하였다.

cleaningInterval 주기마다 캐시 정리 작업을 Actor에게 요청한다.

cacheCleanerJob = launch(SupervisorJob()) {
    while (true) {
        delay(cleaningInterval)

        isCacheCleanerRunning.getAndSet(true)

        val response = CompletableDeferred<Int>()
        cacheActor.send(CacheMessage.Clear(response))
        response.await()

        isCacheCleanerRunning.getAndSet(false)
    }
}

캐시 클리너 on/off

앱에서 캐시 클리너를 on/off 할 수 있도록 구현하였다.

앱이 닫힌 상태라면 캐시 클리너를 굳이 실행할 필요가 없기 때문에, 앱이 닫힌 상태(액티비티 onStop)가 될 때 코루틴 작업을 취소시키고, 다시 앱이 실행될 때(액티비티 onRestart) 코루틴 작업을 다시 시작한다.

override fun stop() {
    launch {
        while (isCacheCleanerRunning.get()) {
            delay(waitTimeForCacheCleaning)
        }
        cacheCleanerJob?.cancel()
        cacheCleanerJob = null
    }
}

stop()을 호출하면 cacheCleanerJob을 취소시키는데, 만약 캐시 정리를 진행 중이라면 캐시 정리 작업이 끝날 때 까지 대기한다.

대기하다가 캐시 정리가 끝나면 그때 cacheCleanerJob을 취소시킨다.

캐시 정리 작업 진행여부를 확실하게 동기화 시키기 위해 AtomicBoolean을 사용해서 isCacheCleanerRunning을 만들었다.

실제 적용

대기질 정보를 가져오는 기능을 구현하면서 적용해보았다.

구조는 다음과 같다.

제목 없는 다이어그램-페이지-4

  • AirQualityRepository : 대기질 정보를 가져오는 기능을 담당
  • RepositoryCacheManager : AirQualityRepository내 캐시 매니저의 자동 클리너 기능을 on/off 할 수 있도록 함
  • GlobalRepositoryCacheManager : CacheManager를 사용하는 Repository의 캐시 클리너를 일괄적으로 다룸
    • Activity 생명주기에 따라 ActivityViewModel을 통해 호출된다

테스트

LruCache는 android 의존성이 있기 때문에, Robolectric을 사용하여 테스트했고, 모두 통과했다.

@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE)
class CacheManagerImplTest {
    private lateinit var cacheManager: CacheManagerImpl<String, FakeCache>
    private val testDispatcher = UnconfinedTestDispatcher()

    @Test
    fun put_and_get() = runTest {
        cacheManager = CacheManagerImpl(dispatcher = testDispatcher)
        val fakeHitCache = FakeCache(1, "test")
        cacheManager.put(fakeHitCache.key, fakeHitCache)

        // 간단하게 캐시를 저장하고 불러오는 기능을 테스트
        assert(cacheManager.get(fakeHitCache.key) is CacheManager.CacheState.Hit)
        assert(cacheManager.get("missKey") is CacheManager.CacheState.Miss)
    }

    @Test
    fun test_lru_cache_only() = runTest {
        val cacheMaxSize = 3
        cacheManager = CacheManagerImpl(dispatcher = testDispatcher, cacheMaxSize = cacheMaxSize)
        val fakeCaches = List(5) {
            FakeCache(it, "test $it").apply {
                cacheManager.put(key, this)
            }
        }

        // 추가한 목록에서 키 0, 1 은 캐시 miss
        // 키 2, 3, 4는 캐시 hit가 되어야 한다
        val missKeys = (0..<(fakeCaches.size - cacheMaxSize)).toList()
        val hitKeys = (fakeCaches.size - cacheMaxSize until fakeCaches.size).toList()

        missKeys.forEach {
            assert(cacheManager.get(fakeCaches[it].key) is CacheManager.CacheState.Miss)
        }

        hitKeys.forEach {
            assert(cacheManager.get(fakeCaches[it].key) is CacheManager.CacheState.Hit)
        }
    }

    @Test
    fun test_time_out_only() = runBlocking {
        val cacheExpiryTime = Duration.ofSeconds(1)
        cacheManager = CacheManagerImpl(dispatcher = testDispatcher, cacheExpiryTime = cacheExpiryTime)

        val fakeCaches = List(6) {
            FakeCache(it, "test $it")
        }

        // miss 유도
        val missList = fakeCaches.subList(0, 3)
        // hit 유도
        val hitList = fakeCaches.subList(missList.size, fakeCaches.size)

        missList.forEach {
            cacheManager.put(it.key, it)
        }

        delay(cacheExpiryTime.toMillis())

        hitList.forEach {
            cacheManager.put(it.key, it)
        }

        missList.forEach {
            assert(cacheManager.get(it.key) is CacheManager.CacheState.Miss)
        }
        hitList.forEach {
            assert(cacheManager.get(it.key) is CacheManager.CacheState.Hit)
        }
    }

    @Test
    fun cache_cleaner_removes_expired_items() = runBlocking {
        val cacheExpiryTime = Duration.ofMillis(5)
        val cleaningInterval = Duration.ofMillis(10)

        val fakeCaches = List(100) {
            FakeCache(it, "test $it")
        }

        cacheManager = CacheManagerImpl(dispatcher = testDispatcher,
            cacheMaxSize = 100,
            cacheExpiryTime = cacheExpiryTime,
            cleaningInterval = cleaningInterval)

        fakeCaches.forEach {
            cacheManager.put(it.key, it)
        }
        // 자동으로 캐시가 정리되어 캐시맵은 비어있어야 한다.
        delay(cleaningInterval.toMillis() + 10)
        assertTrue(cacheManager.entries().isEmpty())
    }

    @Test
    fun items_should_not_be_deleted_if_cache_cleaner_didnt_work() = runBlocking {
        val cacheExpiryTime = Duration.ofMillis(5)
        val cleaningInterval = Duration.ofSeconds(10)

        val fakeCaches = List(100) {
            FakeCache(it, "test $it")
        }

        cacheManager = CacheManagerImpl(dispatcher = testDispatcher,
            cacheMaxSize = 100,
            cacheExpiryTime = cacheExpiryTime,
            cleaningInterval = cleaningInterval)

        fakeCaches.forEach {
            cacheManager.put(it.key, it)
        }
        // 캐시 클리너가 동작하지 않았으므로 아이템의 개수는 유지되어야 한다
        assertTrue(cacheManager.entries().size == fakeCaches.size)
    }
}

data class FakeCache(val id: Int, val value: String) {
    val key: String = id.toString()
}

실제 테스트 결과

아래 화면은 각 지역을 선택하면 날씨 정보 화면으로 전환되는 기능을 제공한다.

테스트를 위해 캐시의 크기를 4로 설정했고, 다음 순서로 지역을 선택해나갔다.

  1. 청주시
  2. 영동군
  3. 의령군
  4. 김해시 내동
  5. 경산시 내동
  6. 청주시
  7. 의령군

스크린샷 2023-12-12 210558

다음과 같이 의도대로 정확하게 동작한다.

  1. 청주시 : Miss -> 서버에서 데이터를 가져옴
  2. 영동군 : Miss -> 서버에서 데이터를 가져옴
  3. 의령군 : Miss -> 서버에서 데이터를 가져옴
  4. 김해시 내동 : Miss -> 서버에서 데이터를 가져옴
  5. 경산시 내동 : Miss -> 서버에서 데이터를 가져옴
  6. 청주시 : Miss
    • 5번째 경산시를 선택하면서 청주시의 캐시가 삭제되었기 때문에, 다시 서버에서 데이터를 가져옴
  7. 의령군 : Hit
    • 만약 8번째에서 다른 지역을 선택한다면, 그 때 의령군이 캐시에서 삭제된다.

image