본문 바로가기

소프트웨어-이야기/프로그래밍 언어와 프레임워크

(Django) CacheOps - ORM에 Redis Cache 쉽게 적용하기

django-cacheops는 Django에 Redis Cache를 쉽게 적용하고, 관리할 수 있도록 도와주는 라이브러리이다.

Cacheops의 가장 큰 장점은 ORM에 캐시를 간편하게 적용할 수 있단 점이다. 

이번 포스팅에서는 Cacheops의 특징과 주의할점에 대해서 정리해보고자 한다.

 

Cacheops의 특징

A. ORM Cache

A-1. 사용방법

모델에서 캐시를 바라보도록 변경하고 싶으면, 설정 파일에 아래와 같은 옵션을 추가해주면 된다.

CACHEOPS = {
    'auth.user': {'ops': 'get', 'timeout': 60},
}

위와 같이 추가하면, User 모델을 get으로 조회하는 경우, DB보다 캐시를 먼저 바라보게 된다.

User.objects.get(user_id=1)

 

특정 로직에서만 캐시를 바라보게 하고 싶은 경우에는 다음과 같이 사용하면 된다.

CACHEOPS = {
    'auth.user': {'ops': (), 'timeout': 60},
}
User.objects.get(user_id=1).cache()

 

A-2. ORM Cache의 장점

장점 1. 적은 코드로 ORM에 캐시를 적용할 수 있다.

CacheOps에서 ORM Cache 생성 / 조회 기능을 제공하면서, 얻는 이점은 다음과 같다.

  1. Cache Hit에 실패한 경우, DB에서 값을 조회한 후, 이를 다시 캐싱하는 과정을 신경쓰지 않아도 된다.
  2. ORM 결과를 Redis에 저장하기 위해 직렬화한 후, 데이터를 가져오기 위해 역직렬화하는 과정을 신경쓰지 않아도 된다.
  3. 캐시키 생성 패턴에 관여하지 않아도 된다.
    • 캐시옵스는 모델 이름과 필터 조건을 활용하여, 쿼리 별로 유니크한 캐시키를 만들어낸다. 
    • 때문에 개발자가 캐시키 생성 패턴을 신경쓰지 않아도 된다.
모델 조회 캐시키를 만드는 방식이 궁금하다면, cacheopse/query.py의 _cache_key 함수를 참고하면 된다. 

 

장점 2. ORM 캐시와 데이터베이스의 값 싱크를 알아서 관리해준다.

Model.save(), .delete() 함수를 호출하는 방식으로 데이터가 변경되거나, 삭제되는 경우, 캐시옵스는 해당 모델 객체가 속한 캐시를 삭제해준다. 

단, QuerySet update 함수가 호출되는 경우에는, 캐시옵스에서 캐시를 지워주지 못한다. 

때문에 캐시옵스를 적용한 모델을 일괄 업데이트를 하는 경우에는 invalidated_update 함수를 사용해야한다.

User.objects.filter(created_at__gte=datetime(2019,5,11)).invalidated_update(level=1)

 

A-2. 주의사항

1. 검색 조건에 모델 조인이 포함되는 경우, 과도하게 캐시 삭제 요청이 발생할 수 있다.

< 안좋은 예시 : "발행된 콘텐츠를 북마크한 수"를 회원별로 캐싱하기 >

Bookmark.objects.filter(user_id=1, content__is_publish=True).count().cache()

위의 샘플 코드는 위험한 코드다! 

왜냐하면 북마크 수를 가져오기 위해 콘텐츠 테이블을 조인하고 있고, 검색 조건 범위가 광범위하기 때문이다. 

Cacheops는 역으로 참조된 모델의 값이 변경되는 경우에도, 관계된 캐시를 삭제해준다.

즉, 콘텐츠 is_publish 컬럼값이 변경될 때마다, 위의 조건으로 만들어진 모든 북마크 캐시가 삭제된다는거다.

CacheOps가 연관된 데이터 싱크를 알아서 해주기 때문에 유용하지만, 위와 같은 조건은 위험하다.

때문에 다른 테이블을 조인해야하는 쿼리를 분리한 후, Cachops를 적용해야한다. 

Cachops는 관계되어있는 캐시를 일괄 삭제할 때, 1000개씩 나눠서 캐시 삭제 요청을 보낸다. 상세한 구현은 cacheops/lua/invalidate.lua 코드에서 확인할 수 있다. 

 

2. select_related 함수로 캐싱된 데이터는 캐시 삭제 대상에 포함되지 않는다.

< 안좋은 예시 : select_related으로 모델을 조인한 경우 >

Bookmark.objects.select_related('user').filter(content_id=1).cache()

위와 같이 작성한 경우, 회원정보가 변경되어도, 북마크 캐시가 삭제되지 않는다.

Bookmark.objects.prefetch_related('user').filter(content_id=1).cache()

이 경우, select_related를 prefetch_related으로 변경해야한다.

 

B. Function / View / Template Cache

CacheOps를 활용하면 function / view / template에도 캐시를 간편하게 적용할 수 있다.

이 중, function에 캐시를 적용하는 방식에 대해서 설명하고자 한다.

B-1. 사용 방법

@cached 데코레이터를 활용하면, 함수의 결과값을 캐싱해둘 수 있다.

@cached(timeout=60*60*24)
def get_exchange_rate(target_date):
    """
    환율 정보 조회
    """
    return ExternalApi.get_exchange_rate(target_date)

그리고 캐시를 퍼지할 때는, 아래와 같이 호출하면 된다. 

get_exchange_rate.invalidate(target_date)

 

Cacheops 데이터 구조

CachOps는 데이터베이스의 값이 변경되었을 때, 의존성이 있는 ORM 캐시를 삭제하기 위하여, 아래와 같은 데이터 구조로 구성되어 있다.

cacheops data structure

CacheOps는 크게 3 depth으로 데이터를 관리한다. 

가장 로우 레벨은 실 데이터가 저장되는 String Type의 데이터이다. 

그리고 의존성 있는 캐시키들을 묶어서 관리하기 위하여, conj 캐시와  schema 캐시가 존재한다. 

conj 캐시는 의존성 있는 데이터가 변경되었을 때, 함께 갱신되어야하는 캐시들의 키 목록이 저장되는 영역이다. 

schema 캐시는 최상위 레벨의 캐시 데이터 영역이다. schema 캐시에는 conj 캐시키 목록이 저장된다.

schema 캐시는 테이블에 속한 검색 조건 목록을 관리하고, conj 캐시는 검색 조건에 속하는 실 캐시 데이터를 관리한다. 

이 관계를 위하여 schema 캐시와 conj 캐시는 각각 SET 데이터 타입으로 구성되어 있다.

 

cacheops는 위의 데이터구조를 활용해서 역으로 삭제해야하는 캐시들을 찾아낸다.

 

CACHEOPS_LRU 옵션을 사용하는 경우, 의존성 있는 캐시키의 관계를 저장하는 SET Data에는 TTL이 없다.
그래서 실 데이터 캐시가 TTL이 소멸되어 삭제되는 되더라도, 해당 캐시키가 속해있던 conj:XXX는 삭제되지 않고 남아있다.

conj:XXX 캐시키는 검색 조건 종류별로 생성된다. 그래서 conj:XXX 키도 많아질 수 있다. ( 만약 쿼리셋 검색조건에 회원번호가 속한다면, 회원수만큼 conj가 생성된다. )

캐시 삭제 정책을 volatile-lru으로 설정하더라도, max-memory가 찰 때부터 캐시가 삭제되기 때문에,
conj:XXX 캐시가 소멸되지 않아서 메모리를 많이 잡아먹는 현상이 발생할 수 있다.

 

Cacheops 제약사항

처음에 언급했듯이, cacheops는 Redis에 특화된 라이브러리이다.

의존관계에 있는 캐시키 목록을 관리하기 위하여 Redis에 특화된 데이터 타입과 함수들을 사용하고 있기 때문이다. 

( SET Daty Type, Lua Script, Redis DEL / sadd 등 전용 함수.. )

때문에 memcached 같은 다른 캐시 솔루션을 사용한다면, cacheops를 사용할 수 없다. 

 

<끝>