Django에서 Redis를 이용해 Caching하기

프로젝트 생성하기

Model 1개와 View 1개를 가지고 있는 아주 기본적인 Django 프로젝트를 만들어보았다.(보다 빠르게 Django 프로젝트를 생성하고 싶다면 django-quickstarter 를 이용하자.)

# models.py

from django.db import models


class Post(models.Model):  
    text = models.TextField()

    def __str__(self):
        return self.text
# views.py

from django.http import JsonResponse

from .models import Post


def my_view(request):  
    posts = Post.objects.all().values('id', 'text')
    return JsonResponse(list(posts), safe=False)
# urls.py

from django.urls import path

from .views import my_view


urlpatterns = [  
    path('', my_view),
]

그리고 다수의 Post 모델의 객체들을 한 번에 DB에 추가하기 하기 위해 아래와 같이 custom django-admin commands 를 구현하였다.

# management/commands/addpost.py

from django.core.management.base import BaseCommand, CommandError

from mydjangoproject.models import Post 


class Command(BaseCommand):  
    help = 'Add as many posts as you want'

    def add_arguments(self, parser):
        parser.add_argument('post_cnt', type=int)

    def handle(self, *args, **options):
        post_cnt = options['post_cnt']
        if post_cnt > 0:
            Post.objects.bulk_create(
                [Post(text="Sample Text #{}".format(i)) for i in range(post_cnt)]
            )
            self.stdout.write(self.style.SUCCESS('Successfully add {} posts'.format(post_cnt)))

테스트를 위해서 아래와 같이 command를 실행해 100000개의 Post 객체들을 PostgreSQL에 넣었다.

$ python manage.py addpost 100000

이제 테스트를 위한 기본적인 세팅은 완료되었다.

loadtest 라는 라이브러리를 이용하여 100번의 요청을 날려보자.

$ loadtest -n 100 http://localhost:8000

이미지

약 28초의 시간이 걸렸다.
이 부분을 이제 Redis로 Caching하여 성능을 개선해보고자 한다.

Redis로 Caching 구현하기

우선 django-redis 를 설치한 후,

$ pip install django-redis 

아래와 같이 settings.py에 추가해주면 Django에서 Redis를 Cache로 사용할 수 있게 된다.

# settings.py

"""language-python
# Cache

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

Caching을 적용하기 위해 View 부분을 변경해보자.

# views.py

from django.http import JsonResponse  
from django.core.cache import cache

from .models import Post


def my_view(request):  
    posts = cache.get_or_set('posts', Post.objects.all().values('id', 'text'))
    return JsonResponse(list(posts), safe=False)

Redis에 Caching 되는 것을 확인하기 위해 한번 요청한 후,

$ curl http://localhost:8000

redis-cli로 접속하면

이미지

":1:posts" 라는 key 값으로 잘 들어갔음을 확인할 수 있다.

이젠 얼마나 성능이 개선될까하는 부분 기대감을 안고 loadtest를 다시 돌려보면,

이미지

약 10초의 시간이 더 줄어들었음을 확인할 수 있었다.

하지만 아직 Caching의 구현이 끝난 건 아니다. 이렇게만 해버리면 Redis의 TTL이 만료될 때까지 사용자는 영원히 똑같은 데이터만 전달받게 될 것이다. 따라서 DB의 데이터가 변경될 때마다 Cache를 초기화하여야 한다.(이러한 오버헤드가 존재하기 때문에, 읽기 작업보다 쓰기 작업보다 빈번한 경우에는 Caching이 오히려 성능을 저해할 수 있다.)

아래와 같이 Post 모델의 save(), delete() 함수를 Override하여 구현하였다.

# models.py

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


class Post(models.Model):  
    ... # 생략
    def save(self, *args, **kwargs):
        cache.delete('posts')
        super().save(*args, **kwargs)

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

이제 Post 모델의 인스턴스를 생성, 변경 또는 삭제하면 Redis에 저장됐던 키 값이 제거될 것이다.

Issue

Redis를 Cache로 사용하였을 때의 성능을 확인하기 위해 여러가지 테스트를 해보다가 이상한 점을 한가지 발견했다.

위의 views.py에서 QuerySet 객체가 아닌 list 함수가 적용된 list 객체로 한번 Caching 해보았다.

# views.py

from django.http import JsonResponse  
from django.core.cache import cache

from .models import Post


def my_view(request):  
    posts = cache.get_or_set('posts', list(Post.objects.all().values('id', 'text')))
    return JsonResponse(posts, safe=False)

이렇게 하면 Caching된 값 자체가 이미 list 함수가 적용된 상태이기 때문에 조금 더 성능이 좋아질 것이라 기대했었다.

이미지

하지만 기대와 달리 loadtest를 돌려보면 앞서 PostgreSQL에서 데이터를 가져오는 경우보다도 더 시간이 오래 걸림을 볼 수 있다. 그리고 한참의 삽질 끝에 그 원인이 Django에서 QuerySets을 처리하는 방식 에 있음을 알게 되었다.

Internally, a QuerySet can be constructed, filtered, sliced, and generally passed around without actually hitting the database. No database activity actually occurs until you do something to evaluate the queryset.

Django에서는 QuerySet을 생성하였다고 하더라도 그 QuerySet이 evaluate 되기 전까지는 실제 DB에 쿼리를 날리지 않는다. 여기서 evaluate 란 QuerySet에 len(), repr() 등을 적용하였을 때를 말하고 여기서 우리가 사용한 list() 함수 역시 여기에 해당된다.

이전의 cache.get_or_set('posts', Post.objects...) 경우에서 Post.objects... QuerySet은 아직 실제 DB에 퀴리를 날리지 않는다. 만약 'posts' 키 값이 없다면, cache.get_or_set() 내부적으로 QuerySet을 Pickling하는 코드가 실행되고 그 때 최초로 한 번 DB에 쿼리를 날려 값을 가져올 것이다. 그 이후부터는 'posts'의 키 값이 존재하므로 DB에 쿼리를 날리지 않아도 된다.
반면 cache.get_or_set('posts', list(Post.objects...)) 경우에는 2번째 인자의 QuerySet에 list() 함수가 적용돼있으므로 'posts' 키 값이 있든 없든 항상 DB에 쿼리를 발생시킨다. 심지어 'posts' 키 값이 있어 Redis에서 Caching된 값을 가져올 때에도 DB로의 쿼리는 발생한다. 이 때문에 Caching을 구현하지 않았을 때보다 오히려 더 긴 응답시간이 발생하였을 것이라 추측한다.

이러한 문제를 해결하기 위해선 cache 에 'posts' 키 값이 있을 때에는 list(Posts.objects...) 코드가 아예 실행되지 않도록 조금 변경해주어야 한다.

# views.py

from django.http import JsonResponse  
from django.core.cache import cache

from .models import Post


def my_view(request):  
    posts =  cache.get('posts')
    if not posts:
        posts = list(Post.objects.all().values('id', 'text'))
        cache.set('posts', posts)
    return JsonResponse(posts, safe=False)

이미지

이제서야 앞서 QuerySet 객체를 Caching 하였을 때와 거의 동일한 시간이 출력됨을 볼 수 있다.

참고