Django 16. Django & django-redis

jiffydev·2020년 11월 16일
3

1. Redis란?

Redis (Remote Dictionary Server) is an in-memory data structure project implementing a distributed, in-memory key–value database with optional durability. Redis supports different kinds of abstract data structures, such as strings, lists, maps, sets, sorted sets, HyperLogLogs, bitmaps, streams, and spatial indexes.
-wikipedia-

간단히 말해 메모리 안에 키-값 형태의 데이터베이스를 만들 수 있게 해 주는 친구이다. 일반적으로 우리가 데이터베이스에서 데이터를 가져온다는 것은 하드디스크에서 가져온다는 뜻인데, 하드디스크는 속도로 따지면 최하위권이다. 그래서 가져올 데이터의 용량이 크다면 그만큼 로딩에 시간이 걸리게 되고, 이는 웹서비스에서 마이너스 요소가 된다.
그래서 자주 변경되지 않는 데이터는 캐시 메모리에 넣어놓고 쓰면 용량이 큰 데이터도 매우 빠른 속도로 로딩이 가능해진다. 캐시 메모리에 저장하게 해 주는 도구는 여럿 있지만 이번에는 redis를 사용하도록 한다.

2. 사용하게 된 경위

프로젝트로 클래스101 사이트를 클론 코딩하게 되었는데, 썸네일용 이밎 데이터를 너무 큰 것들을 골라서, 메인페이지 로딩이 매우 느렸다.(거의 5초?) 이미지 데이터의 용량이 큰거라 ORM으로 효율적으로 가져온다고 해도 별 효과가 없고, 아니면 이미지 데이터를 전부 바꿔야 하는데 그럴 시간이 없었다.
그래서 알아본 것이 캐시데이터로 저장해 놓고 가져다 쓰는 방법이었는데, 장고에는 이미 django-redis라는 훌륭한 라이브러리가 존재했다. 단점으로는 캐시에 저장된 데이터는 자동으로 갱신되지 않으므로, 클래스가 추가되면 캐시를 삭제해야 하지만, 메인페이지는 자주 변하는 내용은 아니기 때문에 캐시에 저장해도 괜찮을 것이라 판단했다.

3. 사용법

3-1. redis-server

django-redis를 사용하기 전에 서버를 먼저 설치하고 실행해야 한다. 여기서는 ubuntu 18.4(WSL)를 기준으로 한다.
sudo apt-get install redis-server

설치가 됐으면 설정파일을 다음과 같이 열고 설정해준다.

sudo vim /etc/redis/redis.conf  
  
  
# 주석처리 된 것을 해제하고 써주면 된다
maxmemory 1g  
maxmemory-policy allkeys-lru

redis-server를 실행하면 아래와 같이 서버가 열릴 것이다.

이 상태에서 다른 터미널 창을 열어 redis-cli ping을 입력하고 다음과 같이 나오면 잘 연결된 것이다.

3-2. django-redis

redis 서버를 설치했다면 장고에서도 이를 사용할 수 있게 설정해야 한다.
pip install django-redis로 설치한다.
설치가 됐으면 settings.py에 다음과 같이 설정해 준다.

CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        }
    }
}

상세한 설정은 documentation에서 확인할 수 있다.

기본적으로 django-redis는 장고에 탑재된 cache 모듈을 통해 캐싱할 수 있다.
그래서 redis를 쓰기 위해서는 views.py에 cache 모듈을 import해야 한다.
필자의 비루한 예제를 통해 어떤식으로 사용하는지 알아보자

from django.core.cache import cache

class ProductsView(View):
    def get(self, request):
        OFFSET     = int(request.GET.get('offset'))
        LIMIT      = int(request.GET.get('limit'))
        filter_set = {}
        if request.GET.get('category'):
            filter_set['category__name'] = request.GET['category']
            cache.delete('products')

        if not cache.get('products'):
            products         = Product.objects.select_related(
                'sub_category', 
                'creator', 
                ).prefetch_related(
                    'image_set',
                    'productlike_set',
                    'titlecover_set',
                    'cheered_set', 
                    'review_set').all()
            
            top_products     = products.annotate(count=Count('productlike__product_id')).filter(is_open=True, **filter_set).order_by('-count')
            planned_products = products.filter(is_open=False, **filter_set)
            updated_products = products.filter(is_open=True, **filter_set).order_by('-updated_at')

            if OFFSET >= len(updated_products):
                return JsonResponse({'message':'PAGE_NOT_FOUND'}, status=404)
            updated_products=updated_products[OFFSET:OFFSET+LIMIT]

            data={
                'top_10_data':[
                    {
                        'product_id'  : product.id,
                        'image_url'   : product.image_set.first().image_url,
                        'sub_category': product.sub_category.name,
                        'mentor'      : product.creator.nickname,
                        'title'       : product.name,
                        'like_count'  : product.productlike_set.all().count(),
                        'thumbs_up'   : product.review_set.filter(good_bad=True).count()/product.review_set.all().count(),
                        'price'       : product.price,
                        'discount'    : product.discount,
                        'coupon'      : product.coupon.name
                    }
                if product.review_set.all().count()!=0 else {
                        'product_id'  : product.id,
                        'image_url'   : product.image_set.first().image_url,
                        'sub_category': product.sub_category.name,
                        'mentor'      : product.creator.nickname,
                        'title'       : product.name,
                        'like_count'  : product.productlike_set.all().count(),
                        'thumbs_up'   : 0,
                        'price'       : product.price,
                        'discount'    : product.discount,
                        'coupon'      : product.coupon.name
                    } for product in top_products[:TOP_TEN]],

                'planned_data':[
                    {
                        'product_id'  : product.id,
                        'image_url'   : product.titlecover_set.first().thumbnail_image_url,
                        'is_open'     : product.is_open,
                        'sub_category': product.sub_category.name,
                        'mentor'      : product.creator.nickname,
                        'title'       : product.titlecover_set.first().title,
                        'like_count'  : product.productlike_set.all().count(),
                        'cheered'     : product.cheered_set.count(),
                    } for product in planned_products if product.titlecover_set.exists()],

                'updated_data':[
                    {
                        'product_id'  : product.id,
                        'image_url'   : product.image_set.first().image_url,
                        'sub_category': product.sub_category.name,
                        'mentor'      : product.creator.nickname,
                        'title'       : product.name,
                        'like_count'  : product.productlike_set.all().count(),
                        'thumbs_up'   : product.review_set.filter(good_bad=True).count()/product.review_set.all().count(),
                        'coupon'      : product.coupon.name,
                        'updated_at'  : product.updated_at
                    }
                if product.review_set.all().count()!=0 else {
                        'product_id'  : product.id,
                        'image_url'   : product.image_set.first().image_url,
                        'sub_category': product.sub_category.name,
                        'mentor'      : product.creator.nickname,
                        'title'       : product.name,
                        'like_count'  : product.productlike_set.all().count(),
                        'thumbs_up'   : 0,
                        'coupon'      : product.coupon.name,
                        'updated_at'  : product.updated_at
                    } for product in updated_products]
                }
            data=cache.set('products', data)
        data=cache.get('products')
        return JsonResponse(data, status=200)

위의 예제는 메인페이지에서 상품 목록을 불러오는 view이다. 전체적인 흐름을 먼저 설명하면, if not cache.get('products') 으로 캐시에 products 키로 저장된 데이터가 있는지 확인한다. 없다면 데이터베이스에서 필요한 항목들을 불러와 data라는 변수에 딕셔너리로 저장한다.
그리고 이 data를 data=cache.set('products', data)로 캐시에 저장한다. 만약 캐시에 데이터가 있다면 조건문은 그냥 통과하고 캐시에서 'products' 키의 데이터를 가져와 jsonresponse로 돌려줄 것이다.

4. 주의사항

1. 데이터에 변경이 생겼다면 cache.delete()

캐시에 저장된 데이터는 데이터베이스에 변경사항이 생겨도 적용되지 않는다. 그렇기 때문에 캐시에 TTL(time to live)를 설정해 일정 시간이 지나면 지워지게 하거나(설정하지 않으면 계속 같은 데이터가 존재함) 데이터베이스에 새로운 객체를 생성/삭제할 때 save()/delete()메소드를 사용하고, 이 메소드를 오버라이드 해서 그 안에 캐시를 지운 후 저장하도록 해야 한다.
필자의 경우 클래스가 새로 생성되면 반드시 cache.delete('products')를 통해 지워지도록 했다. 메소드를 오버라이드 하는 방법은 아래와 같다.

# models.py

from django.db import models  
from django.core.cache import cache


class Product(models.Model):  
    ...
    def save(self, *args, **kwargs):
        cache.delete('products')
        super().save(*args, **kwargs)

    def delete(self, *args, **kwargs):
        cache.delete('products')
        super().delete(*args, **kwargs)

2. 캐시에 모든 데이터 타입이 들어갈 수 있는 것은 아니다.

위의 예제에서 필자는 데이터베이스에서 데이터를 필요한 데이터를 딕셔너리 형식으로 만들고 그것을 캐시에 저장했다. 사실 처음 생각했던 것은 Product.objects.all()로 불러온 데이터를 몽땅 캐시에 넣는 방법이었다. 그런데 분명 캐시에는 잘 저장이 됐는데 데이터를 불러올 때 계속해서 쿼리를 날리는 것이 보였다.
구글링을 해도 확실한 답을 찾을 수는 없었지만, 다른 사람들이 올린 django-redis예제를 보면 항상 딕셔너리 또는 리스트 형식으로 캐시에 저장한 것을 확인했다. 그래서 프론트에 전송할 데이터(딕셔너리)를 우선 캐시에 저장하고 테스트를 했더니 쿼리를 날리지 않는 것을 확인할 수 있었다.

결론: queryset은 캐시에 넣을 수 없다(방법이 있다면 댓글을 달아주길..) 따라서 리턴할 데이터를 넣거나 딕셔너리/리스트 등으로 queryset을 감싸서 저장해야 한다.

profile
잘 & 열심히 살고싶은 개발자

1개의 댓글

comment-user-thumbnail
2021년 2월 11일

포스팅 잘읽었습니다.

마지막에 쿼리셋을 캐싱할 수 없다는 말은 잘못되었네요.
캐싱하지 못하는게 아니라 캐싱하였으나, 코드가 목적에 맞지 않게 잘못 짜여있어서 그렇습니다.
(=> 위 코드는 데이터를 변수에 할당해 생성할 때 이미 쿼리를 다 날림)

캐시에 키값이 있는지 검사한 후에 필요한 경우에만 변수를 생성해야 합니다.

답글 달기