시냅스

Java 로 구현하는 In-Memory Cache 본문

Java, Spring

Java 로 구현하는 In-Memory Cache

ted k 2023. 3. 26. 17:25

https://www.upguard.com/blog/cache

 

Java 로 구현하는 In-Memory Cache

캐시는 성능을 위해 매우 중요한 장치입니다.

디스크보다 빠른 접근시간을 가지며, 일시적으로 어떤 값을 저장/접근하기에 용이합니다.

 

자바에서 연산을 하는 것 또한 메모리 위에서 이뤄지는 것이므로, 

애플리케이션에 사용할 캐시를 직접 만들어 볼 수 있겠습니다.

(이를테면 Caffeine 같은 라이브러리를 저희가 만드는 것입니다!)

 

이를 위해 자바의 참조 유형과 캐시에 대해서 알고 계시면 진행이 수월할 것입니다.

 

https://liltdevs.tistory.com/182

 

Java 참조 유형 과 GC (strong, soft, weak, phantom reference)

참조 유형 자바에서는 명시적으로 메모리를 해제할 수 없습니다. 그러한 일들은 GC 가 대리 수행하고 있는데요. https://liltdevs.tistory.com/161 Java 가비지 컬렉션 Garbate Collection 정리 GC stop the world GC를

liltdevs.tistory.com

https://liltdevs.tistory.com/126

 

컴퓨터구조 기억장치의 분류와 특성

기억장치 컴퓨터에서 프로그램과 데이터를 저장하기 위한 장치 전자적 수단에 의해 기억 및 기록 능력을 실현시키는 장치/소자 2진 정보의 쓰기/읽기/검색이 가능한 다수의 메모리 셀로 구성된

liltdevs.tistory.com

 

구현 조건

저희가 만들 자료구조는 캐싱을 위해 사용하므로,

각 요소가 Out Of Memory Error 가 발생하기 이전까지만 유지하고

메모리가 부족하다면 gc로 비워지게 하고 싶습니다.

 

또한 참조되고 있는 객체는 gc 이후에도 사용하고,

비교적 참조가 적은 객체들은 메모리가 해제되게 하고 싶습니다.

(참조 지역성의 원리를 이용하고 싶습니다!)

 

이때 저희는 자바의 참조 유형인 Soft Reference 를 사용할 수 있을 것입니다.

 

 

구현

 

구현의 편의를 위해 vm option 으로 힙사이즈를 꽤 작게 설정하였습니다.

 

우선 차이점을 보기 위해서 강참조를 통한 OOME 를 유도해보겠습니다.

public static void main(String[] args) {
    List<Object> cache = new ArrayList<>();
    Object o = new Object();
    while (true) {
        cache.add(o);
    }
}

위의 코드는 저희가 잘 알고 있는 list 에 그저 Object 를 넣는 코드입니다.

Object 를 Strong Reference 하고 있습니다.

실제로 실행해본다면 꽤 짧은 시간 안에 OOME 가 발생하는 것을 확인하실 수 있을 것입니다.

 

 

다시 돌아와,

저희의 목적에 맞는 Cache 를 구현해보겠습니다.

public class Cache<K, V> {

    private final HashMap<K, CacheSoftReference<K, V>> map = new HashMap<>();

    // 참조 대상 객체들의 생명 주기를 추척하고 gc 에 의해 수집되는 객체들을 모으는 데 사용
    // 참조가 약한 객체들은 여기에 담긴다.
    private final ReferenceQueue<V> queue = new ReferenceQueue<>();

    public void put(K key, V value) {
        cleanUp(); // 참조가 약한 객체들을 먼저 제거하고 넣는다.
        CacheSoftReference<K, V> ref = new CacheSoftReference<>(key, value, queue);
        map.put(key, ref);
    }

    public V get(K key) {
        CacheSoftReference<K, V> ref = map.get(key);
        if (ref == null) { // cache miss
            return null;
        }
        return ref.get(); // cache hit
    }

    public V remove(K key) {
        cleanUp();
        CacheSoftReference<K, V> ref = map.remove(key);
        if (ref == null) {
            return null;
        }
        return ref.get();
    }
    private void cleanUp() {
        CacheSoftReference<K, V> ref;
        // queue 에는 참조가 약한 객체들이 담겨있고 OOM 을 방지하기 위해 제거한다.
        // 참조 카운트가 0이 된 객체들을 jvm 이 queue 에 담는다
        while ((ref = (CacheSoftReference<K, V>) queue.poll()) != null) {
            map.remove(ref.key);
        }
    }

    private static class CacheSoftReference<K, V> extends SoftReference<V> {

        private final K key;

        public CacheSoftReference(K key, V referent, ReferenceQueue<V> queue) {
            super(referent, queue);
            this.key = key;
        }

        @Override
        public boolean equals(Object obj) {
            if (obj == this) {
                return true;
            }
            if (!(obj instanceof CacheSoftReference)) {
                return false;
            }
            CacheSoftReference<?, ?> ref = (CacheSoftReference<?, ?>) obj;
            return Objects.equals(this.key, ref.key);
        }

        @Override
        public int hashCode() {
            return Objects.hashCode(key);
        }
    }
}

내부적으로는 map 을 사용하여 key 에 대한 접근을 빠르게 하게 되고

Reference queue 는 참조가 약한 객체를 담아두어 필요에 의해

참조가 약한 객체들을 삭제함으로써 캐시의 조건에 부합하게 됩니다.

 

객체를 넣거나 삭제할 때에는 cleanUp 을 통해 우선 참조가 약한 객체들을 해제합니다.

이는 위에서 설명했듯 참조지역성의 원리나, OOM을 방지하기 위함입니다.

 

 

 

캐시를 구현하였으니, 실제로 실행해보겠습니다.

먼저 일반적인 HashMap 에서 OOM 이 일어날 수 있는 코드를 살펴보겠습니다.

HashMap<Integer, TestClass> cache = new HashMap<>();
int i = 0;
while (true) {
    cache.put(i++, new TestClass(1));
}

그저 객체를 단순히 집어넣기만 하고 있으니 당연히 OOM 이 발생할 것으로 예상할 수 있습니다.

 

Cache<Integer, TestClass> cache = new Cache<>();
int i = 0;
while (true) {
    cache.put(i++, new TestClass(1));
}

저희가 생성한 Cache 는 위에서 봤듯, 약한 참조에 대해서는 객체의 참조를 해제하게 됩니다.

그렇게 가용한 공간을 만든 뒤 객체를 새로 생성하여 넣음으로써 OOM 을 방지할 수 있게 됩니다.

 

 

실행하며 애플리케이션의 상태를 VisualVM 으로 확인해보겠습니다.

 

힙이 일정 수준 이상 올라가지 않는 것으로 미루어 보아,

gc 가 적절히 잘 되고 있다는 것으로 생각해볼 수 있겠습니다.

 

 

또한 gc 의 현황을 보게되면, Eden Space 에서 대부분 생성되었다가 사라지는 것을 볼 수 있습니다.

Old 영역으로 이동되는 객체 또한 거의 없는 것을 확인할 수 있습니다.

저희의 구현 조건을 만족하는 것을 확인하였습니다.

 

코드 상으로 구현이 단순하지만, 유용한 자바로 구현하는 Cache 에 대해서 알아보았습니다.

캐시를 구현함으로써 gc 의 작동 방식과 java 의 참조 유형에 대해서 알 수 있게 됐고,

실제로 사용하며 성능상의 이점을 노려볼 수도 있겠습니다.

 

완성도 있는 제품을 사용하려면 카페인, 구아바, 레디스 등을 고려해볼 수도 있겠습니다.

 

끝!

Comments