Django에서 쿼리셋 효과적으로 사용하기

Django의 모델 쿼리를 좀 잘 활용하고 싶어서 아래 글을 읽다가 번역까지 해보았습니다.

원문 - Using Django QuerySets Effectively

번역을 허락해 준 Dave Hall 님께 고마움을 전합니다.


ORM 시스템을 사용하면 SQL 데이터베이스를 손쉽게 다룰 수 있다. 하지만 직접 작성한 SQL에 비해, 느리고 비효율적이라는 단점도 존재한다.

ORM을 효과적으로 사용한다는 말은, ORM이 쿼리를 어떻게 작성하는지 이해한다는 뜻이다. 이 글에서는 중대형 데이터베이스에서 Django의 ORM 시스템을 효과적으로 사용하는 방법에 집중하려 한다.

Django의 쿼리는 마지막까지 지연(lazy)된다

Django의 쿼리셋(queryset)은 데이터베이스의 여러 레코드(row)를 나타낸다. 필터링도 가능하다. 예를 들어 다음 코드는 DB에 저장된 사람 중 이름이 Dave인 모든 사람을 나타낸다.

person_set = Person.objects.filter(first_name="Dave")  

이 코드는 DB에 어떤 쿼리도 전달하지 않는다. 여러분이 person_set에 필터를 추가하거나 person_set을 함수에 전달한다 해도, 이는 DB에 아무런 메시지도 전달하지 않는다. 이는 장점인데, 왜냐하면 DB에 쿼리를 전달하는 일이 웹 애플리케이션을 느려지게 하는 주범 중 하나이기 때문이다.

DB의 레코드를 진짜로 가져오려면(fetch) 다음과 같이 쿼리셋을 순회(iterate)해야 한다.

for person in person_set:  
    print(person.last_name)

Django의 쿼리셋은 캐시된다

쿼리셋을 순회하는 시점에, 쿼리셋에 해당하는 DB의 레코드들을 실제로 가져오며(fetch), 이는 모두 Django 모델로 변환된다. 이를 가리켜 평가(evaluation)라고 한다. 평가된 모델들은 쿼리셋의 내장 캐시에 저장되며, 덕분에 여러분이 쿼리셋을 다시 순회하더라도 똑같은 쿼리를 DB에 다시 전달하지는 않는다.

예를 들어, 다음 코드에서 DB 쿼리는 단 한 번만 발생한다.

pet_set = Pet.objects.filter(species="Dog")

# 쿼리가 '평가'되고, 결과가 캐시된다.
for pet in pet_set:  
    print(pet.first_name)

# 똑같은 순회를 반복할 경우 이미 캐시된 쿼리셋이 사용된다.
for pet in pet_set:  
    print(pet.last_name)

if 문에서는 쿼리셋 평가가 발생난다

쿼리셋의 캐시 기능은 특히, 일단 쿼리셋에 레코드가 존재하는지를 검사한 후 하나라도 발견되었을 때만 이를 순회하려 할 때 유용하다.

restaurant_set = Restaurant.objects.filter(cuisine="Indian")

#  if 문은 쿼리셋을 '평가'한다
if restaurant_set:  
    # 순회할 때는 캐시된 쿼리셋이 사용된다
    for restaurant in restaurant_set:
        print(restaurant.name)

결과 전체가 필요하지 않은 경우 쿼리셋 캐시가 문제가 된다

결과 전체를 순회하지 않고 단지 레코드가 최소한 하나는 존재하는지 알아보고 싶을 수도 있다. 이 경우, 쿼리 셋에 if 문을 사용하면 if 문 때문에 쿼리셋이 ‘평가’되고, 이에 따라 쿼리셋 캐시에도 전체 레코드가 저장된다.

city_set = City.objects.filter(name="Cambridge")

# if 문은 쿼리셋을 평가한다
if city_set:  
    # 쿼리셋의 전체 결과가 필요하지는 않은 상황임에도
    # ORM은 전체 결과를 가져온다(fetch)
    print("At least one city called Cambridge still stands!")

이를 피하려면 exists() 메서드를 사용하자. 이 메서드는 최소한 하나의 레코드가 존재하는지 여부를 확인하여 알려준다.

tree_set = Tree.objects.filter(type="deciduous")

# exists()는 쿼리셋 캐시를 만들지 않으면서 레코드가 존재하는지 검사한다
if tree_set.exists():  
    # DB에서 가져온 레코드가 하나도 없다면
    # 트래픽과 메모리를 절약할 수 있다
    print("There are still hardwood trees in the world!")

쿼리셋이 엄청 큰 경우 쿼리셋 캐시가 문제가 된다

몇 천 개 단위의 레코드를 다뤄야 할 경우, 이 데이터를 한 번에 가져와 메모리에 올리는 행위는 매우 비효율적이다. 더 나쁜 점은, 거대한 쿼리가 서버의 프로세스를 잠궈(lock) 버려서 웹 애플리케이션이 죽을 수도 있다는 사실이다.

쿼리셋 캐시를 방지하면서도 전체 레코드를 순회해야 한다면, iterator()를 사용하자. 이는 데이터를 작은 덩어리로 쪼개어 가져오고, 이미 사용한 레코드는 메모리에서 지운다.

star_set = Star.objects.all()

# iterator() 메서드는 전체 레코드의 일부씩만 DB에서 가져오므로
# 메모리를 절약할 수 있다.
for star in star_set.iterator():  
    print(star.name)

쿼리셋 캐시를 방지하고자 iterator() 메서드를 사용한 경우 당연하게도, 똑같은 쿼리셋을 한 번 더 순회할 때 쿼리를 다시 평가한다. 따라서 iterator() 메서드를 사용하기 전, 여러분의 코드에서 똑같은 거대 쿼리셋을 재사용하려고 하지는 않는지 주의해야 한다.

쿼리셋이 엄청 큰 경우 if 문도 문제가 된다

앞서 보았듯이, 쿼리셋 캐시와 if/for 문을 조합하면 상황에 따라 쿼리셋을 순회하거나 하지 않을 수 있다. 하지만 쿼리셋이 엄청 큰 경우, 쿼리셋 캐시는 고려 사항이 될 수 없다.

간단한 해결책으로 exists()와 iterator()를 함께 사용하면, DB에 두 개의 쿼리를 실행하고 그 결과를 쿼리셋 캐시로 생성하는 일을 방지할 수 있다.

molecule_set = Molecule.objects.all()

# 첫 번째 쿼리로, 쿼리셋에 레코드가 존재하는지 확인한다
if molecule_set.exists():  
    # 또다른 쿼리로 레코드를 조금씩 가져온다
    for molecule in molecule_set.iterator():
        print(molecule.velocity)

좀더 복잡한 해결책은 파이썬의 고급 순회 메서드(advanced iteration methods)를 사용하는 것이다. 이를 통해 순회를 진행할지 결정하기 전에 iterator() 메서드의 첫 번째 레코드를 감지(peek)할 수 있다.

atom_set = Atom.objects.all()

# 첫 번째 쿼리로 레코드를 가져오기 시작한다
atom_iterator = atom_set.iterator()

# 첫 번째 레코드를 감지(peek)한다
try:  
    first_atom = next(atom_iterator)
except StopIteration:  
    # 레코드가 없다면 아무 일도 하지 않는다
    pass
else:  
    # 레코드가 하나라도 존재한다면
    # 모든 레코드를 순회한다(첫 번째 레코드를 포함해서)
    from itertools import chain
    for atom in chain([first_atom], atom_set):
        print(atom.mass)

안일한 최적화에 주의하라

쿼리셋 캐시는 애플리케이션이 만들어 내는 DB 쿼리의 개수를 줄이며, 일반적으로는 정말로 필요한 쿼리만을 DB에 전달하기 위해 존재한다.

exists()와 iterator() 메서드를 사용하면 메모리 사용을 최적화할 수 있다. 그렇지만 쿼리셋 캐시는 생성되지 않기 때문에, DB 쿼리가 중복될 수 있다.

코드를 주의 깊게 작성하고, 뭔가 느려진다 싶을 때 병목 지점을 조사한다면, 약간의 쿼리셋 최적화가 도움이 될 수도 있다.