본문 바로가기
Python/Django

[Django] ORM N+1 문제 해결 : 지연로딩

by Byeong 2024. 12. 1.

문제 

 

채팅방 리스트를 조회할 때, 채팅방 수에 비례해 쿼리 수도 함께 증가하는 문제가 발생했다. 이로 인해 요청 처리시간도 증가했다.

채팅방 증가에 따른 쿼리 + 시간 증가

 


원인 : N+1 문제 

#views.py 
...
chat_rooms = ChatRoom.objects.all()

rooms=[]
for room in chat_rooms:
    data ={
        "name": room.name,
        "id":room.id,
        "users": room.users.count(),
        "add_date" : room.add_date,
    }
    rooms.append(data)
    ...

 채팅방 목록을 조회하는 코드이다. 여기서 모든 채팅방을 조회하고 반복문을 통해 데이터를 저장하는데 채팅방에 접속한 유저를 참조하하며 N개의 추가 쿼리를 발생하는게 문제였다.

 

N+1 문제 

 N+1 문제는 관계형 데이터베이스에서 지연로딩(Lazy Loading) 방식에서 발생하는 문제이다. Django ORM에서 객체를 가져올 때, 관련된 데이터가 필요해질 때마다 별도의 쿼리가 실행되며, 결과적으로 N개의 추가 쿼리가 발생한다. 

 이로 인해 데이터베이스에 많은 부하가 걸리고 응답시간이 느려지는 등 성능 문제가 발생한다.

그러나 지연로딩은 문제만 있는 것은 아니다. 다른 ORM에서도 지연로딩을 사용하는 이유는 그만큼 장점이 많기 때문입니다.

 

지연로딩 장점

  1. 불필요한 데이터베이스 쿼리를 방지하여 필요한 데이터만 쿼리하여 성능을 보장한다.
  2. 모든 관련된 데이터를 한 번에 로드하지 않고 필요한 경우에만 쿼리하므로 메모리 사용을 줄인다.
  3. 데이터베이스의 부담을 줄일 수 있다.

N+1 문제 확인

django-silk을 통해 어디서 쿼리가 발생하는지 확인해 봤다.   

 

 

발생 하는 곳

 

chat_rooms = ChatRoom.objects.all() → 쿼리하지 않음 
for room in chat_rooms: →쿼리 발생

"name": room.name, → 쿼리하지 않음(이미 가져온 데이터)

"users": room.users.count() → 해당 ChatRoom의 usrs 조회 쿼리 발생 (N번)


해결 방법 : 즉시로딩 

N+1 문제는 즉시로딩(Eager Loading) 방식을 활용해 해결할 수 있다.

 즉시로딩은 데이터 요청시 연관 된 데이터를 미리 가져오는 방식으로 이를 통해 지연로딩에서 발생하는 문제를 방지한다.

 

1. select_related

  - one-to-many 또는 one-to-one 관계에서 사용

  - SQL의 JOIN을 이용해서 관련된 객체들을 한 번 가져옴

 

2. prefetch_related

  - many-to-many 또는 역참조 관계에서 주로 사용

  - 내부적으로 두번의 쿼리를 사용해 데이터를 가져옴

 

#정방향 참조 
Post.objects.select_related('author')

#영방향 참조 
Author.objects.prefetch_related('posts')

 


적용하기 

#views.py
...
chat_rooms = ChatRoom.objects.prefetch_related('users') #즉시로딩 방식

rooms=[]
if chat_rooms.exists():
    for room in chat_rooms:
        data ={
            "name": room.name,
            "id":room.id,
            "users": room.users.count(),
            "add_date" : room.add_date,
        }
        rooms.append(data)

...

- prefetch_related를 사용해 Many-to-Many 관계의 users 데이터를 한 번 가져옴

- 반복문 내에서 users.count()를 호출해도 추가 쿼리가 발생하지 않음

 


한 걸음 더 : annoteate 활용

...
# 사용자 숫자 데이터베이에서 처리
ChatRoom.objects.prefetch_related('users').annotate(user_count=models.Count('users'))

...

"users": room.users_count,
...

annotate를 사용해 데이터베이스에서 사용자 수를 미리 계산하도록 구현

 


결과

 

 ORM 최적화를 통해 기존에 발생하던 N+1개의 쿼리 발생에서 3개의 쿼리로 줄었고 처리속도도 34ms으로 단축됐다.


느낀 점

섣부른 최적화는 만악의 근원이다”- '도널드 크누스'의 말을 떠올리며, 이번 경험을 통해 최적화는 문제를 명확히 이해하고 올바른 해법을 찾은 후에 실행해야 한다는 것을 다시금 깨달았습니다.