ORM의 Lazy Loading과 N+1 문제

Hansu Kim·2021년 11월 19일
0

개발 Must-know

목록 보기
3/9

ORM(Django)에서의 Lazy Loading

ORM에선 DB의 리소스를 정말 필요한 때가 아니면 호출하지 않는다는 특징이 있다.

users = User.objects.all() -> DB 호출 X
user_list = list(users) -> DB 호출 O

위와 같이, 인터프리터에서 처리하는 라인에서 해당 값이 정말로 사용될 때까지 쿼리문은 수행되지 않는다.
해당 특성은 성능 차원에서 DB 액세스를 최소화하기 위한 기능으로 보인다.
하지만, 이 기능으로 인해 비효율적인 쿼리가 발생하게 된다.

Caching을 활용한 쿼리 최소화

django에서는 쿼리셋을 캐시형태로 가지고 있다. 이것을 result cache라고 부른다.
캐싱을 사용하면 불필요한 쿼리 수행을 줄일 수 있다.

users = User.objects.all()
first_user = users[0]
user_list = list(users)

위의 경우, users[0]에서 "SELECT * FROM user LIMIT 1;"이 호출되고,
그 다음 라인에서 다시 list(users)에 의해 "SELECT * FROM user;" 가 수행되게 된다.

만약 아래와 같이 코드의 순서를 바꿔준다면, 캐싱 기능을 통해 불필요한 쿼리 호출을 줄일 수 있다

users = User.objects.all()
user_list = list(users)
first_user = users[0]

ORM Lazy Loading의 문제점

위 서술에 따르면, ORM의 Lazy Loading은 2가지 특징이 있다.

  • 특징1. 필요한 시점에만 SQL문을 호출한다
  • 특징2. 정말 필요한 만큼만 호출한다.
    • ex) SELECT문 내의 LIMIT 1
    • ex) 쿼리셋 캐싱

위 특징들로 인해, Django의 ORM은 사용시 주의할 점이 있다.

문제1 - 1번 특징으로 인해, 여러 개의 쿼리셋이 한번에 합쳐 실행되면 매우 느리게 동작할 수 있다.

여러 개의 쿼리가 합쳐져서 조인(left outer join)이 많아지고 복잡해지면 성능이 저하된다.

해당 이슈는 데이터 구조를 개선하거나 애플리케이션 조인하는 방식으로 해결할 수 있다.

  • 데이터 구조 개선 방식의 예를 들면, 정규화 레벨을 낮추거나, DB를 RDB->NoSQL로 변경하는 방식이 있다.

  • 애플리케이션 조인의 예를 들면, 애플리케이션에서 각각의 테이블 컬렉션을 가져온 후, 테이블 간 관계되는 키값을 통해 따로 처리를 해주는 방식이다.

    -> 하지만 이 방식의 경우, 쿼리문의 Where XX in (1,2,3,...) 방식으로 추가하여 처리를 해주게 되는데 이 때 IN절에서 처리해주는 인덱스는 200개를 넘지 않게 하는게 통상적이며, 200개를 넘어갈 경우 별도의 다른 쿼리문을 통해서 다시 가져오도록 파티셔닝하도록 하여야 성능상 이점이 있다.

문제2 - 2번 특징으로 인해, 이미 알고 있는 값도 다시 한번 호출이 일어날 수 있다(N+1 Problem)

N+1 문제는 연관 관계에서 발생하는 이슈로, 연관 관계가 설정된 엔티티를 조회할 경우 조회된 데이터 갯수(n)만큼 연관관계의 조회 쿼리가 추가로 발생하는 것을 말한다.

  • python 예시 코드
class Place(models.Model):
    name = models.CharField(max_length=50)
    address = models.CharField(max_length=80)

    def __str__(self):
        return self.name


class Restaurant(models.Model):
    place = models.OneToOneField(Place, on_delete=models.CASCADE, related_name='restaurant')
    name = models.CharField(max_length=50)
    severs_pizza = models.BooleanField(default=False)

    def __str__(self):
        return self.name
        
if __name__ == '__main__':
  for place in Place.objects.all():
     print(place.restaurant.name)
  • 수행된 Query
SELECT `photo_place`.`id`, `photo_place`.`name`, `photo_place`.`address` FROM `photo_place`
SELECT `photo_restaurant`.`id`, `photo_restaurant`.`place_id`, `photo_restaurant`.`name`, `photo_restaurant`.`severs_pizza` FROM `photo_restaurant` WHERE `photo_restaurant`.`place_id` = 1 LIMIT 21
SELECT `photo_restaurant`.`id`, `photo_restaurant`.`place_id`, `photo_restaurant`.`name`, `photo_restaurant`.`severs_pizza` FROM `photo_restaurant` WHERE `photo_restaurant`.`place_id` = 2 LIMIT 21
SELECT `photo_restaurant`.`id`, `photo_restaurant`.`place_id`, `photo_restaurant`.`name`, `photo_restaurant`.`severs_pizza` FROM `photo_restaurant` WHERE `photo_restaurant`.`place_id` = 3 LIMIT 21
SELECT `photo_restaurant`.`id`, `photo_restaurant`.`place_id`, `photo_restaurant`.`name`, `photo_restaurant`.`severs_pizza` FROM `photo_restaurant` WHERE `photo_restaurant`.`place_id` = 4 LIMIT 21
...

해당 문제 해결을 위해 Django에서는 Eager Loading을 사용할 수 있다.

Eager-Loading

Eager Loading은 사전에 쓸 데이터를 포함해 쿼리를 날리기에, 비효율적으로 쿼리가 늘어나는 것을 방지할 수 있다.
Eager-Loading을 위해 Django는 select_related와 prefetch_related를 지원하고 있다.

참조 대상이 중간 테이블이 아닐 시, 쿼리문에서 Join을 이용해 쿼리를 수행한다.

  • python 예시 코드
dogs2 = Dog.objects.select_related('owner').filter(id__gt=65540)
for dog in dogs2:
	dog.owner.name
  • 수행된 Query
 SELECT `dogs`.`id`, `dogs`.`owner_id`, `dogs`.`name`, `dogs`.`age`, `owners`.`id`, `owners`.`name`, `owners`.`email`, `owners`.`age` FROM `dogs` INNER JOIN `owners` ON (`dogs`.`owner_id` = `owners`.`id`) WHERE `dogs`.`id` > 65540; args=(65540,)

2개의 테이블을 각각 읽어와서 Django에서 합친다.
2번째 테이블을 읽어올 때, IN절을 통해 필요한 만큼의 쿼리만 수행한다.
select_related를 사용할 수 없는 many-to-many 모델에서 사용한다.

1:1, 1:N 관계에서도 사용 가능

  • python 예시 코드
dogs2 = Dog.objects.prefetch_related('owner').filter(id__gt=65540)
for dog in dogs2:
	dog.owner.name
  • 수행된 Query
SELECT `dogs`.`id`, `dogs`.`owner_id`, `dogs`.`name`, `dogs`.`age` FROM `dogs` WHERE `dogs`.`id` > 65540; args=(65540,)
SELECT `owners`.`id`, `owners`.`name`, `owners`.`email`, `owners`.`age` FROM `owners` WHERE `owners`.`id` IN (17); args=(17,)

참고- JPA에서의 N+1 문제

Django에서 prefetch_related를 사용할 경우, 2번째 테이블을 읽어오는 IN절을 알아서 최적화해주지만 JPA에서는 해당 기능이 수행되지 않는 것으로 보인다.
그에 따라 JPA에서는 Fetchtype이 EAGER든 LAZY든 똑같이 N+1 문제가 발생하게 되며, 문제 해결을 위해 Fetch join이나 EntityGraph 방식을 사용한다.

참고 URL
https://velog.io/@kim6515516/npuls
https://velog.io/@burnkim61/Django-ORM-N1-%EB%AC%B8%EC%A0%9C
https://leffept.tistory.com/311
https://incheol-jung.gitbook.io/docs/q-and-a/spring/n+1

0개의 댓글