[Django SEO] 더 빠르게! - Cache와 압축



Django X Redis = Cache



1. 발단

Redis는 메모리에 데이터를 가지게 하여, 껏다 켜면 사라지는 휘발성이지만, 매우 빠르게 대응할 수 있기때문에 Cache 서버로 자주 거론됩니다. 물론 사용도 쉽고, 편리합니다. 이전 회사에 있을 때, Redis, Sentinel 조합으로 데이터를 레디스에 바로 쓰고 절대 죽지 못하게 5개의 서버를 Master, Slave, Replica로 구분하여 돌리고, 들어오는 데이터를 그냥 바로 Redis에 넣고 주기적으로 Redis에 있는 데이터베이스를 SQL로 옮겨오는 기능이었습니다.


위 같은 경우는 물론 엄청 빠르지만, 데이터가 완전무결해야 하는 경우일 때에는 사용할 수 없습니다. 데이터 손실이 생길 수 있거든요.



2. 왜 Redis?

사실 Redis 뿐 아니라 Memcached 등 캐싱을 하기 위한 옵션이 있었습니다. 다른 포스트에서 각 캐싱 시스템을 다뤄보기로 하고 여기서는 왜 제가 글로벌 캐시 방식인 Redis를 택했는지 설명해 보겠습니다.

프로세스는 CPU로 부터 Code, Heap, Stack, Data를 받아옵니다. 로컬 캐시방식을 선택하면 Heap영역에서 조회가 되고 글로벌 캐시방식보다 성능이 더 좋을 수 있습니다. 하지만, Redis가 독립적으로 구동 되고 있기때문에 웹앱 사이즈나 그 갯수가 커지면 커질 수록 글로벌 캐시방식이 유리합니다. 딩그르르에 여러 서비스를 붙이고 싶기도 하고.. (뭐.. 대체적으로 제가 스스로 사용하기 위한 서비스지만!!) 그리고.. 결정적으로 Celery를 위한 Message Queue로도 사용하고 싶었습니다.

또한, 멤캐쉬는 1MB 레디스는 512MB 의 스트링을 저장할 수 있습니다. 이것도 선택의 이유가 되었습니다.

글로벌 캐시이긴 하지만, 저는 외부 네트워크를 타지 않게 웹서버 안에다가 Redis를 설치했고(필요할때 분리할 수 있게..) localhost로 접속하여 Memcached에 비해 성능이 떨어지는 것을 몸으로 느끼실 수는 없을 겁니다. 200ms 와 400ms 는 2배 차이지만, 유저는 그닥 느낄수가 없거든요..


>> Redis Docker를 이용하여 설치하기 (딩그르르의 다른 포스트)


3.Django SEO


파이썬과 장고는 느립니다.
공개 디스를 하는 건 아니고요.
사실입니다.
그 사실을 인지해야 어디서 더 보완을 할지, 어디서 더 시간을 많이 투자할지 선택하여 집중할 수 있으니까요. 그래서 Django는 이미 멋진 Cache Framework를 제공합니다.

Django's Cache Framework 디자인 철학은 아래와 같습니다.

  • 코드를 더 적게
  • 더 안정적으로
  • 필요시 언제든 확장할 수 있도록



대체적으로 Django에서는 5가지 캐시 방법을 지원합니다.

  • Per-Site 캐시
  • Per-View 캐시
  • Template 캐시
  • Query 캐시
  • 내맘대로 캐시


하지만, 저는 Per-site캐시와 Per-view 캐시는 잘 쓰지 않습니다. 페이지나, 뷰를 통째로 캐시를 해버리면, 접속할때마다 매번 변경되는 DB값이 제대로 반영이 안되거든요. 그때 그때 마다 계속 캐시를 플러시 해줘야 합니다. 그래서 저는 좀 더 복잡하더라도 캐시를 조각조각 나누어 저장하고 관리합니다.

그리고 내부에서 쓰기위해 Jinja Template으로 설계, 개발, 배포를 한 적은 있으나, 따로 Front-end Framework를 쓰지 않고 이렇게 Django의 기능을 최대한 활용하면서 만인에게 공개되는 페이지(딩그르르 처럼) 없었습니다. 해서, Cache Management 관련한 라이브러리를 찾아보았으나, 제대로 되어 있는것이 없더라구요. 캐시는 넣어 줄때 넣어주고 지워줄때 잘 지워줘야 합니다. 안그러면 사용자 경험이 최악으로 가게 되죠. 안쓰니만 못하게 됩니다. 그래서 이 라이브러리를 좀 만들어 봐야 하겠습니다.

StackoverFlow에 가면 Django Cache 다루기 까다롭다라는 말이 많이 있는데, 왜 일지 궁극적인 이유를 생각해봤습니다. 결론은, 사람들이 안쓰니까.. 별로 개발할 필요도 없고.. 프론트엔드는 프론트엔드 잘하는 다른 자바스크립트 프레임워크가 해야지!

그래도 혼자 만드는 웹페이지의 관리포인트를 늘리게 되면, 아마 얼마 못하고 그만 두게 될겁니다. 그래서 이 라이브러리를 하나 만들어 보고 싶어요 시간이 되면.



4. Per-site Cache

우선 Per-site 캐시와 Per-view캐시는 간단합니다.

Per-site 캐시는 아래 미들웨어를 추가해 주면됩니다.

MIDDLEWARE = [
    ...
'django.middleware.cache.UpdateCacheMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.cache.FetchFromCacheMiddleware',
    ... ]


여기서 주의할 점은 미들웨어의 순서입니다. 

UpdateCacheMiddleware는 아래처럼 헤더를 추가하는 다른 미들웨어보다 항상 위에 있어야합니다.

  • SessionMiddleware adds Cookie
  • GZipMiddleware adds Accept-Encoding
  • LocaleMiddleware adds Accept-Language


캐시를 원치 않는 View가 있다면 아래처럼 설정하세요.

from django.views.decorators.cache import never_cache

@never_cache
def myview(request):
...



5. Per-view Cache

Per-View 캐시는 view 하나하나를 캐시하는 것입니다. view에서 리턴하는 렌더링 데이터를 캐시합니다. 사용법은 아래와 같습니다.

from django.views.decorators.cache import cache_page

@cache_page(600)
def my_view(request):
...

** 캐시되는 시간만 표기(600초 입니다)


@cache_page(600, cache="special_cache")
def my_view(request):
...

**기본 캐시시스템 말고 다른 캐시를 사용하고 싶을때,


@cache_page(600, key_prefix="site1")
def my_view(request):
...

**cache key에 prefix를 넣고 싶을때


from django.views.decorators.cache import cache_page

urlpatterns = [
path('foo/<int:code>/', cache_page(600)(my_view)),
]

** urls.py 안에서도 위 처럼 설정할 수 있습니다.


Redis에서 prefix를 이용하여 키를 확인할 수 있습니다.

$ redis-cli -n 1
127.0.0.1:6379[1]> keys *
1) "example:1:views.decorators.cache.site1.cache_header"
2) "example:1:views.decorators.cache.site1.cache_page"
127.0.0.1:6379[1]> get "example:1:views.decorators.cache.cache_page"


아래처럼 해서 원하는 Prefix만 골라낼 수 있습니다.

$ redis-cli -n 1
127.0.0.1:6379[1]> keys *.site1.*



6. Template Fragement Cache

템플릿 안에서 조각을 내어 캐시하는 방법입니다. base.html을 만들어서 {% block %}{% endblock %}을 사용하여 하고 계시지요? base.html에서 변하지 않는 부분을 아래와 같이 캐시 해버립니다. 저는 base.html에서 Vue.js와 Axios를 이용해서 추가 데이터를 가지고 오기 때문에 그 부분은 캐시하지 않습니다. 

그리고 딩그르르 첫 페이지에 유저가 들어오게 되면 포스트가 바뀌거나 추가되지 않는 이상 일정하게 스태틱에 가까운 상태로 렌더링합니다. 그래서 Index는 전 페이지를 캐싱하였습니다. 글이 삽입되거나 삭제될때 또는 변경될때마다 이 캐시를 플러시 해주면 됩니다.

{% load cache %}
{% cache 500 sidebar %}
.. sidebar ..
{% endcache %}

** 500초 동안 유지되는 sidebar의 html을 캐시합니다.


{% cache 500 sidebar request.user.username %}
.. sidebar for logged in user ..
{% endcache %}

** 유저명 별로 사이드바가 변하면 위처럼 캐시할 수 있습니다. 유저가 많아지면 캐시되는 양이 폭발합니다!


{% load i18n %}
{% load cache %}

{% get_current_language as LANGUAGE_CODE %}

{% cache 600 welcome LANGUAGE_CODE %}
{% trans "Welcome to example.com" %}
{% endcache %}

** 국제화(i18)도 지원합니다. 언어별 캐시를 합니다.


{% cache 600 sidebar %} ... {% endcache %}
{% cache my_timeout sidebar %} ... {% endcache %}

** 변수 사용이 가능합니다.


{% cache 300 local-thing ...  using="localcache" %}

** 다른 캐시 백엔드 사용이 가능합니다.


주의할 점.

템플릿 내에서 쓴다면 반드시 {% block %}과 {% endblock %} 사이에서 쓰셔야 해요. 블럭 밖을 벗어나면 장고는 쳐다보지도 않고 무시합니다.


위 처럼 캐시 프리픽스와 각종 변수를 사용하시면,
캐시이름이 아래처럼 나올거에요.

example:1:template.cache.index.d41d8cd98f00b204e9800998ecf8427e

index라는 변수로 탬플릿을 캐시한 것입니다. 우리가 이것을 지워줄땐 저 이름을 전부 다 넣어야 해요. 그래서 아래와 같은 함수가 있습니다. utils.py 같은데에다가 작성해 두시고 key를 가지고 오면 편리하실거에요.

from django.core.cache import cache
from django.core.cache.utils import make_template_fragment_key

def key_manager(key, variable=None, delete=False):
# key = make_template_fragment_key('sidebar', [username])
key = make_template_fragment_key(key, variable)
if delete:
cache.delete(key)
return key

** key는 String이고 variable은 List 입니다.





7. Query 캐시

데이터 베이스를 치는 일은 상당히 비싼일입니다. 뭐 돈이 들어가는 건 아니지만, 리소스가 사용되니까요. A API를 호출했는데 AA 라는 답변이 왔습니다. 이 답변이 바뀌려면 Update나 Delete, 또는 Delete 후 다시 Insert 등이 있어야 다른 값이 옵니다. 그래서 각 모델 클래스에 메소드를 만들어 두고 캐시가 플러시 될 수 있도록 작업해 줍니다. 이걸 안해주면 내가 뭘 캐시 해놨는지 뭘 Flush 해야하는지 알길이 없어요. (제가 라이브러리를 만들고 싶은 이유.. 이거 .. 너무 비생산적이잖아?)

그래서 한번 변경되면, 다음 변경될때까지는 쿼리를 하지마라! 라는 의미에서 Query Cache를 사용합니다. 지금 읽고 계신 이 글도 제가 수정하기 전까지 변경되지 않습니다. DB에서 온게 아니라 Redis에서 온 글입니다.

제가 조금 많이 Query 캐시를 좋아합니다. 아니, DB를 최소한으로 접근하는 것을 좋아해요. 데스크탑으로 딩그르르를 보고 계신다면 상단에 방문자 수와 방문자 그래프가 있습니다. 이 그래프는 DB를 치지 않아요. 방문자가 들어올때마다 DB에 기록하고 또는 딩그르르가 쿠키를 발급해서 처리하는 구조가 아닙니다.

여러분이 방문하시면, Google Analytics에 등록되고, 딩그르르는 Celery를 이용하여 몇분에 한번씩 Google Analytics에 호출하여 지금까지 오늘 방문자가 몇명인지 구해옵니다. 그리고 그걸 DB에 넣고 매번 사용자가 호출할 쿼리 결과를 캐싱해 둡니다. 매번 방문자가 늘때마다 실시간으로 바뀌진 않지만(그럴 필요도 없고..) 안전하고, 부하도 덜주고, DB에 의미 1도 없는 데이터 안 쌓아도 되고요.

post = cache.get('post')
if not post:
post = BlogPost.objects.all()
cache.set('post', post, 300)

** 캐시에 post가 있다면, 캐시에서 가지고 오고 없다면 쿼리를 날리고 300초의 생명을 가진 캐시를 생성합니다.

set, add, get, get_or_set 처럼 많은 기능이 있습니다. 아래 공식문서에서 확인하세요!

Django Cache Framework Low-Level API 공식 문서 보기:

>>공식문서



8. 캐시말고 다른 팁.. 없음? 있음!


- Cached Template Loader 사용하기

TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')]
,
# 'APP_DIRS': True,
'OPTIONS': {
'loaders': [
('django.template.loaders.cached.Loader', [
'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',
]),
],
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]

Django는 템플릿을 만들어 전송합니다. 여기저기서 조립하는 시간이 많이 걸리죠. 장고가 느리다고 욕먹는 이유입니다. 위처럼 사용하면, 반응속도를 절반정도로 줄일수 있고 CPU 사용량도 적어집니다. 장고 1.10에서 나오고 버전업하면서 더 좋아졌습니다.

아참, DEBUG 상태에서는 작동하지 않습니다.


- select_related와 prefetch_related를 사용하세요.

쿼리 수를 줄이세요. INNER JOIN하는 Select Related와 각 쿼리를 따로 해서 조인하는 Prefetch_Related를 잘 사용하세요. 관련 포스트는 따로 올리겠습니다.



- 작업이 오래걸리는 것들은 논블락킹 비동기로 처리하세요.

Django는 View가 할일이 다 끝나야 렌더링이 시작됩니다. 너무 오래 걸리는일은 비동기로 처리하세요.(우리에겐 Celery가 있습니다.)



- 커스텀 Template Tag를 사용하신다면 Thread-safe로 돌려야 하고 Tag를 처리하는 함수 안에서 API 호출등을 하지마세요.

Tag안에서 그게 필터던 뭐던 그 안에서 다른 API 호출을 하지마세요. Tag 작업이 안끝나면 장고는 렌더링을 시작하지 않습니다.



- 일단 필수적인 엘레멘트들 가지고 렌더링을 시작하고 나머지는 프론트엔드에게 맞기세요.

우선 렌더링을 시작하게 하세요. 그리고 추가적인 내용은 렌더링 후 API 호출을 이용하여 삽입하세요.



- Django Compressor 를 사용하여 Static을 압축하기

>> Django Compressor 라이브러리 공식문서 읽기




TTFB가 1초가 넘는다면, 템플릿이나 태그사용의 문제일 가능성이 큽니다. 1초 이하인데 DOMContentLoaded가 2초가 넘는다면 로드하는 파일 사이즈를 줄이거나 압축하세요.




  • [[a.original_name]] ([[a.file_size | fileSizer]])
좋아요[[ postLike | likePlus ]]
공유
라이언

“Lead Python Engineer”

댓글 [[totalCommentCount]]
[[ comment.author__nick_name ]] [[ comment.datetime_updated | formatDate]] (수정됨)

[블라인드 처리된 글 입니다.]

답장
[[ sub.author__nick_name ]] [[ sub.datetime_created | formatDate ]] (수정됨)

취소
댓글을 남겨주세요.
'Django' 관련 최신 포스트
[[ post.title ]]
[[ post.datetime_published_from | DateOnly ]]