피플펀드가 계속 발전함에 따라 피플펀드를 이용하는 사람들이 많아지면서 서버가 힘겨워 하는 상황을 최근에 겪고 있습니다. 이를 해결하기 위해 여러가지 최적화를 진행하고 있는 중 DB 최적화의 일원으로 Django에서의 DB 효율적인 사용법에 대해서 알아보기 위해 참조한 공식 문서1에 대해서 정리하려 합니다.

DB 최적화

Django에서 DB를 최적화 하는 것은 언제나 trade off가 존재합니다. 특히 DB를 최적화와 반대 선상에 있는 것중 한가지가 코드의 가독성입니다. 따라서 맹목적으로 아래에 명시되는 최적화를 적용하지 말고 상황에 맞춰 잘 사용하는 것이 중요합니다.

기본적인 DB 최적화 먼저

Django단에서 하는 최적화를 진행하기 전에 기본적인 ‘DB 최적화를’ 진행해야 합니다. 가장 기본적인 예로 ‘Database index’나 field의 타입들을 알맞게 정의하는 방법들이 선행되어야 합니다.

QuerySet의 특징

Django에서 DB를 최적하 하기 위해서는 일단 QuerySet이 어떤 특징들을 갖고 있는지 아는 것이 중요합니다.

QuerySet은 게으르다 (lazy)

코드상에서 QuerySet을 생성하는 작업은 DB에 아무런 작업을 진행하지 않습니다. DB의 query는 실제로 QuerySet이 결과값이 연산되기 전까지 실행되지 않습니다. 예를 들어 QuerySet 인스턴스에 filter를 계속 적용하더라도 DB query는 일어나지 않습니다.

QuerySet의 결과값이 연산되는 순간들

QuerySet은 아래의 순간들에 실제 연산이 진행되며 DB query가 발생합니다:

  • Iteration (예: for loop)
  • Slicing
    • 연산되지 않은 QuerySet에 대한 작업은 다시 연산되지 않은 QuerySet을 반환하지만, slicing의 step 파라메터를 사용하게 되면 QuerySet은 연산됨
  • Pickling/Caching
    • Pickling은 캐싱의 타이밍을 임의로 정의하는 기법
  • repr()
  • len()
  • list()
  • bool()

QuerySet Caching

QuerySet이 연산되면 그 결과값은 메모리에 캐싱됩니다. 따라서 그 후에 같은 QuerySet을 연산하려고하면 캐시된 값이 다시 불려옵니다.

# DB query가 두번 발생한다
print([e.headline for e in Entry.objects.all()])
print([e.pub_date for e in Entry.objects.all()])

# DB query가 한번 발생한다
queryset = Entry.objects.all()
print([p.headline for p in queryset]) # QuerySet이 연산됨
print([p.pub_date for p in queryset]) # 캐시되어 있는 결과값을 사용한다

Caching이 사용되는 순간

callable하지 않은 속성들의 값은 캐시가 됩니다 (* callable: Python에서 괄호가 없이 불려지는 method들 - 객체의 속성들).

# callable하지 않은 속성들
entry = Entry.objects.get(id=1)
entry.blog   # Blog 객체가 DB에서 불려옴
entry.blog   # Blog 객체가 DB가 아닌 캐시에서 불려옴

# callable한 method들
entry = Entry.objects.get(id=1)
entry.authors.all()   # DB query가 진행됨
entry.authors.all()   # DB query가 다시 한번 진행됨
  • 커스텀으로 생성한 property들은 cached_property 데코레이터를 사용해서 캐시되게 할 수 있음
  • iterator()를 사용함으로써 캐시가 QuerySet단에서 진행되는 것을 방지할 수 있음 (결과값이 캐시가 안되는 것은 아님)
    • 만약 QuerySet이 많은 데이터를 갖고 있다면 효율적인 퍼포먼스와 메모리 사용량을 iterator를 통해 확보할 수 있음

Caching이 사용되지 않는 순간

만약 QuerySet의 일부만 연산된다면 (예를 들어 QuerySet의 slicing이나 indexing), 캐시된 값이 있는지 확인은 하지만 결과값을 새로 캐시하지 않습니다 (만약 이전에 QuerySet의 전체가 연산된 경우가 있다면 그 때 생성된 캐시값을 사용합니다).

queryset = Entry.objects.all()
print(queryset[5]) # DB query가 진행됨
print(queryset[5]) # DB query가 다시 한번 진행됨

queryset = Entry.objects.all()
[entry for entry in queryset] # DB query가 진행됨
print(queryset[5]) # 캐시값을 사용
print(queryset[5]) # 캐시값을 사용

DB에서 가능한 작업은 DB에서 직접 진행

DB에서 직접 가능한 작업은 Python상에서 하기 보다 직접 DB에서 하는 것이 더 효율적입니다. 아래의 방식들을 통해 DB에서 직접 작업할 수 있습니다:

  • filterexclude들을 이용해 DB에서 직접 원하는 값을 필터링
  • F expression을 사용하면 DB에서 Python 메모리에 값을 가져오지 않고 바로 DB 연산을 진행할 수 있음
    • F expression에 update를 사용 가능함
    • F expression은 race condition (두개의 프로세스가 경쟁해서 한 프로세스의 값을 덮어 씌우는 현상)을 방지함
# 일반적인 연산
reporter = Reporters.objects.get(name='Tintin')
reporter.stories_filed += 1		# stories_filed 값을 DB에서 가져옴
reporter.save()

# F expression
from django.db.models import F

reporter = Reporters.objects.get(name='Tintin')
reporter.stories_filed = F('stories_filed') + 1		# DB에 stories_filed를 직접 연산시킴
reporter.save()
# 새로 연산된 값을 가져오려면
reporter = Reporters.objects.get(pk=reporter.pk)
# 혹은 더욱 간결하게
reporter.refresh_from_db()
  • aggregation을 사용 (예: Sum, Avg, Max, …)
  • SQL 구문을 직접 사용 2

Index가 된 유니크한 객체를 사용

index가 된 유티크한 객체를 불러오게 되면 query가 더욱 빠르게 이루어집니다.

사용할 모든 값들을 미리 모두 Query

만약 가능하다면 나중에 사용할 모든 값을 한번에 query해서 DB에 접근하는 횟수를 줄이는게 좋습니다.

select_related는 모든 foreign key(외래키) 관계에 있는 객체들을 한번에 다 가져옵니다. select_related는 어느 QuerySet에서든 사용할 수 있고 (예: filter) filterselect_related를 사용하는 순서에 관계없이 같은 방식으로 처리합니다.

# DB에 접속
e = Entry.objects.get(id=5)

# 관련된 Blog 객체를 가져오기 위해 DB에 한번 더 접속
b = e.blog

# select_related을 사용
# DB에 접속
e = Entry.objects.select_related('blog').get(id=5)

# 이미 위에서 관련된 Blog객체들을 가져왔기 때문에 DB에 접속하지 않음
b = e.blog

prefetch_relatedselect_related와 비슷하지만 select_related가 ‘1 대 1’ 관계 (외래키나 다른 ‘1 대 1’ 관계)에서만 사용 가능하다면 prefetch_related는 ‘1 대 다’ 혹은 ‘다 대 다’에도 사용 가능합니다.

필요없는 것은 가져오지 않는다

QuerySet.values()values_list()

만약 ORM 모델의 객체가 필요 없고 값들만 필요하다면 QuerySet.values()values_list()를 사용하는게 좋습니다.

QuerySet.defer()only()

만약 table에 필요없는 column이 있다면 이 두 기능을 사용할 수 있습니다. 하지만 이 기능들은 오버헤드가 존재하기 때문에 정말 필요할 때만 사용하는 것이 좋습니다 (만약 사용하지 않지만 DB 값을 Python의 값으로 변환할 때 많은 프로세싱이 필요하다면 사용하는 것이 좋습니다).

defer는 Django에게 DB의 특정 필드들을 가져오지 마라고 시킵니다. 단 여러개의 필드를 가져오지 않았을 경우 그 필드의 값을 가져올 때 다른 필드의 값을 가져오지 않기 때문에 각각의 필드 값을 사용할 때 마다 DB에 접속합니다.

Entry.objects.defer("headline", "body")
# 각각 따로 제외시킬 수도 있음
Entry.objects.defer("body").filter(rating=5).defer("headline")
# select_related에서도 사용 가능함
Blog.objects.select_related().defer("entry__headline", "entry__body")
# 모든 필드를 한번에 불러옴
my_queryset.defer(None)

onlydefer의 반대 기능입니다.

QuerySet.count()exists()

len(query_set)를 사용하기 보다 count를, if query_set보다는 exists를 사용하면 좋습니다. 하지만 이 둘을 너무 과하게 쓰지 않도록 조심해야 합니다. 예를 들어 이후에 언젠간 데이터가 필요하다면 그냥 QuerySet을 연산하는 것이 좋습니다.

if right_condition:
	# QuerySet의 laziness덕분에 right_condition이 참이 아니면 DB에 접속하지 않음
	this_query = user.filter(some_condition)
	if this_query:
		# 만약 모델의 값이 존재한다면 어짜피 사용해야함으로 `exists`보다 그냥 DB query를 실행시키는 것이 좋음
		print("you have {} queries".format(len(this_query)))
		for cur_query in this_query:
			do_something()
	else:
		print("You have no query")

QuerySet.update()delete()

여러개의 데이터를 하나씩 저장/삭제하는 것보다 벌크로 저장/삭제를 진행하는 것이 좋습니다. 단 각각의 데이터를 다르게 저장/삭제해야 하는 경우 벌크로 진행할 수 없으니 하나씩 저장/삭제하는게 좋습니다.

Foreign Key 값은 직접 사용

만약 단지 외래키의 값만 필요할 경우, 관계된 객체를 통째로 가져오기 보다는 이미 존재하는 외래키 값을 사용합니다.

# Good
entry.blog_id

# Bad
entry.blog.id

만약 필요없다면 정렬하지 말기

정렬하는 것은 공짜 작업이 아닙니다.

Bulk로 데이터를 생성

만약 가능하다면 여러개의 create()보다는 bulk_create()를 사용합니다. 단 이 기능에는 신경써야할 문제가 있습니다.

  • 이 기능은 save()를 부르지 않음
  • 만약 모델의 primary key가 AutoField면 이 기능은 primary key를 자동으로 생성하지 않음
  • ‘다 대 다’ 관계에서는 작동하지 않음

ManyToManyFields (다 대 다)의 경우에도 가능하다면 여러개의 작업을 한번에 처리하도록 합니다.

# Good
my_band.members.add(me, my_friend)

# Bad
my_band.members.add(me)
my_band.members.add(my_friend)

마치며

웹서비스의 퍼포먼스에서 DB의 병목현상은 매우 중요한 요소입니다. 이를 해결하기 위해 Django에서 여러가지 방법을 제공하고 있지만, 이 글의 서두에서 이야기 했듯이 무분별할 사용은 오히려 DB의 효율성을 떨어트릴 수도 있습니다. 하지만 무엇보다 이 방법들은 프로그래밍의 생산성을 떨어트릴 수 있는 요소들이 존재함으로 (예: raw SQL이나 values는 IDE의 가장 큰 혜택인 code completion을 불가능 하게 합니다) 상황을 잘 파악해서 적절히 사용하는 것이 가장 중요합니다.

References