1. 서비스 요구사항 및 프로세스


https://github.com/Gseungmin/playlist/pull/6

인기 플레이리스트와 비인기 플레이리스트로 나뉜다.


<aside>

플레이리스트의 수정은 작성자만 가능하다.

하지만 플레이리스트 조회는 모든 사용자가 가능하다.

즉, 다른 사람이 생성한 플레이리스트라도 내가 조회할 수 있는 것이다.

이럴떠 어떻게 가지고 올 것인가? 매번 데이터베이스를 사용하는 것은 좋지않다.

그렇다고 플레이리스트를 계속 캐시해두는 것은 메모리 효율에 좋지 않다.

그럼 캐시를 사용하면서도 대규모 플레이리스트를 어떻게 효율적으로 관리할까?

1️⃣ 해시를 통해 플레이리스트를 관리한다.


플레이리스트가 캐시될때 페이지 단위로 캐시 된다.

플레이리스트는 최대 1000개의 데이터를 가지므로 모든 데이터를 캐시에 로드하는 것은 문제가 있다.

따라서 페이지 네이션을 통해 조회된 페이지 단위로 캐시된다.

하지만 플레이리스트가 업데이트 되면 해당 모든 페이지에 대한 관리가 필요하다.

물론 PREFIX를 통해 조회할 수 있지만 이는 모든 레디스 키에 대해 비교가 들어가므로 성능상 좋지 않다.

따라서 플레이리스트당 해시 구조를 만들고 관리한다. 또한 커서 정보를 통해 해시의 필드 키를 구분하는 방식을 사용한다.

이렇게 하면 플레이리스트가 업데이트 될때 모든 캐시를 쉽게 조회하여 초기화시킬 수 있다.

2️⃣ 참조 카운팅을 통해 인기 플레이리스트를 관리한다.


인기 플레이리스트와 비인기 플레이리스트를 구분하기 위해 참조 카운팅을 도입한다.

즉, 참조횟수가 많을 수록 인기 플레이리스트인것이다.

아래 루아 스크립트를 보자

public static final String GET_PLAY_LIST_LUA = """
  -- KEYS[1] : 원본 해시 키
  -- ARGV[1] : 필드 키
  -- ARGV[2] : 스냅샷 TTL
  -- ARGV[3] : 임계치(THRESHOLD)
  
  local hashKey   = KEYS[1]
  local fieldKey  = ARGV[1]
  local ttl       = tonumber(ARGV[2])
  local threshold = tonumber(ARGV[3])
  
  -- 1) 페이지 데이터 조회
  local data = redis.call('HGET', hashKey, fieldKey)
  if not data then
      return { 'false' }
  end
  
  -- 2) 참조 카운팅 증가
  local cnt = redis.call('HINCRBY', hashKey, 'cnt', 1)
  
  -- 3) 임계치 도달 시 TTL 연장 & 참조 카운팅 초기화
  if cnt >= threshold then
      redis.call('HSET', hashKey, 'cnt', 0)
      redis.call('EXPIRE', hashKey, ttl)
  end
  
  -- 4) HIT 결과 반환
  return { 'true', data }
 """;

즉 이와 같이 참조가 발생할때마다 참조 카운팅을 추가하는 방식이다.

이를 통해 특정 참조 카운팅 이상이 되면 TTL을 초기화 시켜주는 방안이 있다.

</aside>

분산락을 통해 캐시 스템피드를 방지한다


<aside>

대규모 트래픽을 관리한다고 했을 때 인기 플레이리스트의 경우 갑자기 많은 트래픽이 생길 수 있다.

이는 자연스럽데 데이터베이스의 문제로 이어진다.

따라서 이런 부분을 고려했을때 단일 스레드만 데이터베이스로 접근되도록 해야하고 이를 위해 분산락을 활용한다.

1️⃣ 먼저 캐시를 조회한다.


만약 캐시에 데이터가 있다면 분산락 정보를 가지고 있지 않아도 된다.

따라서 먼저 캐시 정보를 확인한다. 그리고 캐시가 값이 있다면 캐시를 반환한다.

PlayListResponse cached = getFromCache(playListId, cursor);
		if (cached != null) {
		    return cached;
}

2️⃣ 캐시가 없다면 락 획득을 시도한다.


캐시가 없다면 락 획득을 시도한다.

이후 락을 획득한 스레드는 다시 한번 캐시를 조회한다.

그 이유는 그 사이에 다른 스레드가 캐시를 업데이트했을 수 있기 때문이다.

또한 락을 최종적으로 획득하지 못해도 다시 한번 캐시를 조회한다.

이 또한 그 사이에 다른 스레드가 캐시를 업데이트했을 수 있기 때문이다.

String lockKey = PLAY_LIST_ITEM_LOCK_KEY + playListId + ":" + cursor;
RLock lock = redisson.getLock(lockKey);
boolean acquired = false;

try {
    acquired = getRock(lock);

    // 3️⃣락 획득 실패 시 캐시 재조회
    if (!acquired) {
        return getFromCache(playListId, cursor);
    }

    // 4️⃣다시 한번 재조회, 락을 획득한 사이 업데이트 되었을 수 있다.
    cached = getFromCache(playListId, cursor);
    if (cached != null) {
        return cached;
    }
    
    //...
} finally {
    releaseLock(lock, acquired);
}

3️⃣ 만약 캐시가 없다면 데이터 베이스에 접근한다


만약 락을 획득한 스레드에게 캐시가 없을 경우 데이터 베이스에 다시 접근한다.

이를 통해서 받은 데이터를 기반으로 캐시를 업데이트한다.

// 5️⃣페이지네이션을 통해 플레이리스트 아이템 조회
List<PlayListItem> dtoList =
        paginationRepository.getPlayListItemByCursor(cursor, playListId);

// 6️⃣플레이리스트 캐시 업데이트
PlayListResponse response = new PlayListResponse(dtoList);
redisPlayListService.insertPlayListCache(
        PLAY_LIST_ITEM_KEY + playListId,
        String.valueOf(cursor == null ? 0 : cursor),
        response
);

</aside>

2. 개선사항


1️⃣ LFU에 LRU를 더하다


<aside>

LFU 알고리즘의 가장 큰 문제는 참조 수만을 기준으로 한다는 점이다.

물론 위의 방식은 특정 시간까지 LFU를 채우지 못하면 삭제되는 것으로 LRU의 개념이 어느정도 쓰인다고 볼 수 있다.

다만 추가적으로 고려를 해본다면 LRU를 추가 도입해볼 수도 있을 것 같다.

이를 통해 참조가 많아져도 대규모 연속 참조가 아니라면 캐시가 초기화되는 것이다.

다만 이는 해시에 대한 참조 카운트 뿐만 아니라 LRU 정보도 추가로 따로 관리해야 한다.

</aside>

2️⃣ ZSET을 활용하여 인기 플레이리스트를 관리하는건 어떨까?


<aside>

지금은 해시에 참조 카운트를 필드로 구현하고 있다.

다만 이 정보를 ZSET 같은 곳에서 보관하면 실시간 인기 게시글의 정보를 확인할 수 있다.

즉, 참조 카운트를 ZSET에서 관리해서 주기적으로 인기게시글 100개만 남겨줘 라고 할 수 있는 것이다.

다만 이 과정은 크게 두가지 문제로 이어진다.

참조카운트를 기반으로 하므로 이를 감소시키는 로직도 필요하다.


겨울에 참조가 정말 많이 된 크리스마스 플레이리스트가 있다고 하자

이때 이 참조 값이 봄까지 오면 문제가 생긴다.

즉, 시간이 지나면 자동으로 참조 값을 내려줘야 한다.

따라서 ZSET에서 플레이리스트의 순위를 메길때 매긴 직후 참조 정보를 추가로 감소시키는 것도 하나의 방안이다.

ZSET은 꽤 무겁다


ZSET은 스킵 리스트 이외에 해시라는 자료구조를 추가로 가진다.

즉, 메모리가 중요한 레디스에서 메모리 낭비로 이어질 수 있다.

따라서 이런 부분에 대한 충분한 고려후 도입이 필요하다.

</aside>