캐시 적용기 #1(Local Cache 적용하기)

손문기·2023년 12월 13일

시작하며

이전 글에 이어서 Cach 적용기에 대해 적어보도록 하겠습니다.
현재 프로젝트에서 Local Cache로 관리하고자 하는 데이터는 1. 사용자별 트래킹 상품 목록 정보, 2. 트래킹 인기 상품 목록 정보 2가지로, 위의 데이터들을 가지고 직접 Local Cache를 구현하고 처리하는 과정에 대해 적어보도록 하겠습니다.

사용자별 트래킹 상품 목록 정보

사용자는 다양한 상품들을 트래킹하도록 추가 삭제 할 수 있고, 현재 추가되어있는 상품 목록을 홈 화면에서 확인할 수 있습니다.
일반적으로 홈 화면에 대한 사용자의 접근이 많을 것으로 예상되어 해당 데이터를 캐싱하여 사용하기에 적절하다고 판단하였습니다. 다만 사용자가 트래킹 상품을 추가하거나 삭제할 경우, 상품 가격을 수정하거나 알람 활성화 여부를 변경할 경우 캐시에 저장된 데이터를 변경해줘야 합니다.

트래킹 인기 상품 목록 정보

모든 사용자들에 대하여, 상품 별로 트래킹을 추가한 사용자 수가 많은 순서로 인기 순위가 최대 30위 까지 책정된 목록을 인기 상품 화면에서 확인할 수 있습니다.

해당 데이터는 모든 사용자들에 대하여 공통적으로 응답되는 데이터이고, 인기 상품 목록을 확인하기 위한 사용자의 접근 또한 많을 것으로 예상되어 데이터가 지속적이고 반복적으로 참조가 될 것으로 예상됩니다.
또한 인기 순위에 있는 상품의 경우 그만큼 등록한 사용자 수가 많음을 의미하므로, 각 상품에 대한 세부 정보 조회 또한 다른 상품보다 빈번하게 요청될것으로 예상됩니다.
하지만 수 많은 사용자들이 트래킹 상품을 추가 삭제 할 때마다 순위가 변동 될 수 있어 데이터가 자주 변동될 수 있습니다.

Cache 직접 구현하기

학습을 목적으로 Local에서 사용한 Cache에 대해서는 직접 구현하였습니다.
기본적인 구조는 양방향 연결 리스트와 해시 맵을 가지도록 하였습니다.
Cache의 최대 크기는 Cache 생성시에 지정해주고, 각자 데이터 특성에 맞도록 삽입 및 삭제 알고리즘을 구현하였습니다.

공통사항

hashMap

해시맵은 연결리스트의 느린 탐색 속도를 보완하는 역할을 합니다. 해시 맵은 키를 통해 데이터를 빠르게 찾을 수 있어 캐시에서 데이터를 조회하거나 갱신하는데 유용합니다.

양방향 연결 리스트

export class CacheNode<T> {
    key: string;
    value: T;
    prev: CacheNode<T>;
    next: CacheNode<T>;

    constructor(key: string, value: T) {
        this.key = key;
        this.value = value;
    }
}

위와 같은 Node들이 포인터로 양방향 연결되어 있는 양방향 연결 리스트를 직접 구현하였습니다.

export class TrackingProductCache {
    private maxSize: number;
    private count: number;
    head: CacheNode<TrackingProduct[]>;
    tail: CacheNode<TrackingProduct[]>;
    hashMap = new Map<string, CacheNode<TrackingProduct[]>>();
    constructor(size: number) {
        this.maxSize = size;
        this.head = new CacheNode('head', [new TrackingProduct()]);
        this.tail = new CacheNode('tail', [new TrackingProduct()]);
        this.head.next = this.tail;
        this.tail.prev = this.head;
        this.count = 0;
    }
}

위의 코드는 사용자별 트래킹 상품 목록 정보를 담는 Cache의 생성자 부분입니다.
초기 형태는 value가 비어있는 Head와 Tail Node만이 서로 연결되어 있습니다.
캐시에 새로운 데이터를 추가하거나 삭제할 때는 위의 두 Node사이의 포인터만을 조작하여 빠른 삽입 및 삭제가 가능합니다.

	// TrackingProductCache 내부 함수
    put(key: string, value: TrackingProduct[]) {
            this.delete(key);
            const node = new CacheNode(key, value);
            this.add(node);
            if (this.count > this.maxSize) {
                const oldestNode = this.head.next;
                this.remove(oldestNode);
            }
        }

    private add(node: CacheNode<TrackingProduct[]>) {
        const prev = this.tail.prev;
        prev.next = node;
        node.next = this.tail;
        node.prev = prev;
        this.tail.prev = node;
        this.hashMap.set(node.key, node);
        this.count++;
    }

    delete(key: string) {
        const node = this.hashMap.get(key);
        if (node) {
            this.remove(node);
        }
    }

    private remove(node: CacheNode<TrackingProduct[]>) {
        const { prev, next } = node;
        prev.next = next;
        next.prev = prev;
        this.hashMap.delete(node.key);
        this.count--;
    }

또한 LRU와 같은 캐시 전략을 구현하는데 있어 노드를 추적하기가 용이합니다.
위의 TrackingProductCache 처럼 LRU Cache 구현할 때, 캐시 데이터의 삽입 삭제 과정에서 Head와 Tail Node로 양방향 접근이 가능하여 가장 최근에 사용한 데이터를 리스트의 끝으로 이동시키는 작업을 쉽고 빠르게 할 수 있습니다.

캐시 업데이트

캐시에 저장되어 있는 데이터가 변경될 경우 캐시의 데이터를 항상 최신 상태로 유지하여 데이터 일관성이 보장되도록 하였습니다.
이는 비록 데이터가 변경 될 때마다 캐시를 업데이트하는 비용이 들지만, 캐시가 Local 영역에 있기 때문에 업데이트 비용이 많이 들지 않을 것으로 예상하여 손실보다 이득이 크다고 생각하였습니다.

TrackingProductCache

TrackingProductCache는 key: 사용자ID, value: 사용자별 트래킹 상품 목록 정보를 저장하고 있습니다.
최근의 사용자일 수록 데이터를 요청할 확률이 높다고 판단하여 LRU전략을 이용하여 Cache를 구성하고 있습니다.
또한 읽기 전략으로는 Look Aside를 이용하고 있어 새로운 사용자가 최초 트래킹 목록을 조회할 경우에 DB에서 데이터를 읽어와 Cache에 저장하고, 반복되는 다음 요청부터는 현재 Cache의 데이터를 통해 빠르게 응답합니다.
Look Aside의 초기 Cache Miss를 보완하기 위해서 Cache Warming 작업을 할 수 있지만, 자주 데이터를 요청하는 사용자가 아닌 사용자로 Cache를 채우게 될 경우 오히려 warming 과정에서의 작업 비용과 저장 공간이 낭비 될 수 있어 적용하지 않았습니다.

ProductRankCache

ProductRankCache는 key: 상품Id, value: 상품 정보를 저장하고 있습니다.
해당 Cache는 인기 순위 목록의 상품들만을 저장하고 있으며 상품별 트래킹 인기 순위를 반영하여 Cache 구성 순서를 유지하고 있습니다.
해당 Cache는 인기 순위 목록 자체를 Cache로 유지하고 있는 특이성이 있습니다.
따라서 Cache의 자료구조 자체에 인기 순위를 반영하기 위해 자체적인 알고리즘을 적용하여 Cache를 관리하고 있으며 전체적인 코드는 다음과 같습니다.

export class ProductRankCache {
    maxSize: number;
    count: number;
    head: CacheNode<ProductRankCacheDto>;
    tail: CacheNode<ProductRankCacheDto>;
    hashMap = new Map<string, CacheNode<ProductRankCacheDto>>();
    constructor(size: number) {
        this.maxSize = size;
        this.head = new CacheNode('head', new ProductRankCacheDto());
        this.tail = new CacheNode('tail', new ProductRankCacheDto());
        this.head.next = this.tail;
        this.tail.prev = this.head;
        this.count = 0;
    }

    put(key: string, value: ProductRankCacheDto) {
        const node = new CacheNode(key, value);
        this.add(node);
        if (this.count > this.maxSize) {
            const lowestNode = this.getLowestNode();
            this.remove(lowestNode);
        }
    }

    private add(node: CacheNode<ProductRankCacheDto>) {
        let prev = this.tail.prev;
        while (prev.value.userCount <= node.value.userCount) {
            if (prev.value.userCount === node.value.userCount && prev.value.id > node.value.id) {
                break;
            }
            if (prev === this.head) {
                break;
            }
            prev = prev.prev;
        }
        const next = prev.next;
        prev.next = node;
        node.next = next;
        node.prev = prev;
        next.prev = node;
        this.hashMap.set(node.key, node);
        this.count++;
    }

    private getLowestNode() {
        let node = this.tail.prev;
        while (node.value.userCount > node.prev.value.userCount) {
            node = node.prev;
        }
        return node;
    }

    delete(key: string) {
        const node = this.hashMap.get(key);
        if (node) {
            this.remove(node);
        }
    }

    private remove(node: CacheNode<ProductRankCacheDto>) {
        const { prev, next } = node;
        prev.next = next;
        next.prev = prev;
        this.hashMap.delete(node.key);
        this.count--;
    }

    findIndex(key: string) {
        let node = this.head.next;
        let idx = 0;
        while (node.key !== key) {
            idx++;
            if (this.count <= idx) {
                idx = -1;
                break;
            }
            node = node.next;
        }
        return idx;
    }

    update(product: ProductRankCacheDto, newProduct?: ProductRankCacheDto) {
        const node = this.hashMap.get(product.id);
        if (node) {
            this.remove(node);
        }
        if (newProduct) {
            this.put(newProduct.id, newProduct);
            return;
        }
        this.put(product.id, product);
    }

    get(key: string): ProductRankCacheDto | null {
        const node = this.hashMap.get(key);
        return node ? node.value : null;
    }

    getAll() {
        const nodeList = [];
        let node = this.head.next;
        while (node !== this.tail) {
            nodeList.push(node.value);
            node = node.next;
        }
        return nodeList;
    }
}

위의 Cache는 인기 순위라는 명확한 기준이 있고 한번 Cache에 loading 되면 지속적으로 조회가 발생할 것을 기대할 수 있어, 처음 서버 실행시에 Cache Warming 작업을 하고 있습니다.

async initCache() {
        const rankList = await this.productRepository.getTotalInfoRankingList();
        rankList.forEach((product) => {
            this.productRankCache.put(product.id, { ...product, userCount: parseInt(product.userCount) });
        });
    }

마치며

이번 글에서는 Local Cache를 직접 구현하고 프로젝트에 적용해보는 과정에 대해 다루어보았습니다.
Cache를 직접 구현하는 과정을 가지면서 Cache 구조에 대해 보다 깊은 이해를 할 수 있었습니다.
또한 데이터를 Cache에 저장하는 과정에서 해당 데이터의 특성에 맞게 커스텀한 Rank Cache를 구성하여 운영하는 경험을 하면서 적절한 Cache구조 설계와 운영 전략을 선택하는 것이 매우 중요함을 깨달았습니다.

0개의 댓글