본문 바로가기

Computer Engineering/Django

Django Orm Query 최적화하기 1편.





WAS (Web Application Server)의 가장 큰 역할은 DB에서 데이터를 효율적으로 가져와서 Client에게 전달하고, Client에게 받은 데이터를 저장하는 것이라고 생각합니다.

개인적으로 농구에 관심이 많아서 아마추어 선수들도 프로선수들 처럼 자신의 대회기록을 제공하는 사이트를 사이드 프로젝트로 진행하고 있습니다. 아직 개발 중이라서 홍보는 하고 있지 않습니다.

아무래도 기록과 관련된 서비스이다보니 Query를 많이 사용하는 서비스입니다.

Team 의 상세페이지를 구현하면서 query 응답속도를 줄인 경험을 공유하려고 합니다. 

아직 사용자가 많지 않은 서비스이다 보니 많은 시간을 할애 하지는 않았습니다. 그래서 혹시 부족한 부분이 있고, 조언해주실 부분이 있으면 정말 환영합니다.(사실 이 부분이 포스팅하는 이유 중에 가장 큽니다..ㅎㅎ)


그럼 이제 먼저 어떤 페이지인지 부터 소개드리면 아래와 같은 페이지입니다.
 
수정 전

SQL Time -> 11queries,  9.62 ms



위와 같은 페이지이고 실제 Django debug toolbar 라이브러리를 이용해서 살펴보면 다음과 같은 SQL 들이 실행됩니다.

실제 요청하는 SQL 리스트




어떤 코드가 위와 같은 Sql 을 실행시켰는지 살펴보면 아래와 같습니다.

수정 전 코드

views.py 코드

def team_detail(request, pk):
    # team 상세
    team = get_object_or_404(Team, id=pk)

    return render(request, 'team/team_detail.html', {
        'team': team, 
        'team_home_score': team.home_average_score(), //팀의 홈 평균 득점
        'team_away_score': team.away_average_score(), // 팀의 어웨이 평균 득점
        'home_match': team.home_match_qs, // 홈 최근 3경기 기록
        'away_match': team.away_match_qs, // 어웨이 최근 3경기 기록
        'record': team.match_count(), // 경기결과 총 경기수 / 승수 /패배 수
        'join_seasons': team.join_season()
    })

이번에 query 최적화 작업에서 Raw SQL 은 최대한 사용하지 않을 것 입니다.


실제 SQL을 실행시키는 orm query code

홈 경기평균 득점
home_average_score = Match.objects.filter(team1_id=self.id).\
    aggregate(team1_score=Avg('team1_score'))['team1_score']

어웨이 경기 평균 득점
away_average_score = Match.objects.filter(team2_id=self.id).\
    aggregate(team2_score=Avg('team2_score'))['team2_score']

홈 최근 3경기
home_match_qs = self.team1_match.select_related('team1').\
                    select_related('team2').all()[0:3]

어웨이 최근 3경기
away_match_qs = self.team2_match.select_related('team1').select_related('team2').\
                    all()[0:3]


팀 경기수

전체 경기수
total_match_count = Match.objects.filter(Q(team1_id=self.id) | Q(team2_id=self.id)).count()

승리 경기수
win_match_count = Match.objects.filter(winner_id=self.id).count()

패배 경기수
lose_match_count = total_match_count - win_match_count
record = {
    "win_rate": (win_match_count / total_match_count) * 100,
    "win": win_match_count,
    "lose": lose_match_count
}


참여한 대회 목록

join_season = self.jointeam_set.all()




이제 어떤 페이지이고 어떤 SQL들이 실행되는지 살펴봤으니 어떻게 응답시간을 줄이건지 살펴보겠습니다.

이번에 ORM Query 최적화해서 집중할 부분은 다음과 같은 부분이었습니다.


1. 실제 실행되는 Sql 수를 줄이자. (통신 비용이 가장 큼)

2. 실제 필요한 데이터만 가져오자. (필요없는 것들 까지 가져오지 말자)

3. 가독성은 포기하지 말자.

4. 너무 많은 시간을 할애하지는 말자. (아직 많은 분들이 이용하는 서비스는 아니어서ㅠ)



1. 필요한 필드만 가져와보자 ( only, values, values_list 메서드 이용)

-기존 query 코드

홈 최근 3경기

장고 Query
home_match_qs = self.team1_match.select_related('team1').\
                    select_related('team2').all()[0:3]

실제 실행되는 sql



어웨이 최근 3경기

장고 Query
away_match_qs = self.team2_match.select_related('team1').select_related('team2').\
                    all()[0:3]

실제 실행되는 sql




-변경한 query 코드


홈 최근 3경기

장고 query
def home_match_qs(self):
    home_match_qs = \
        self.team1_match.select_related('team1', 'team2').\
        only("team1__name", "team2__name",
             "team1_score", "team2_score", "date")[0:3]
    return home_match_qs 

실제 실행되는 sql
l

어웨이 최근 3경기

def away_match_qs(self):
    away_match_qs = \
        self.team2_match.select_related('team1', 'team2').\
        only("team1__name", "team2__name",
             "team1_score", "team2_score", "date")[0:3]
    return away_match_qs 

실제 실행되는 sql



-> 필요한 필드만 가져오므로써 시간이 약 50% 정도 줄어든 것을 확인할 수 있었습니다.



2. Sql 을 줄이기.

팀 경기수

전체 경기수
total_match_count = Match.objects.filter(Q(team1_id=self.id) | Q(team2_id=self.id)).count()


승리 경기수
win_match_count = Match.objects.filter(winner_id=self.id).all().count()


패배 경기수
lose_match_count = total_match_count - win_match_count
record = {
    "win_rate": (win_match_count / total_match_count) * 100,
    "win": win_match_count,
    "lose": lose_match_count
}

서로 다른 조건의 row 들을 가져오기 때문에 장고 ORM에서 제공되는  query로 한 번에 가져올 수 있는 방법이 떠오르질 않았습니다. 전체 경기를 가져온 후에 DB 에서가 아닌 파이썬 단에서 처리 할 수 있겠지만 코드 가독성면이나 성능면이나 좋은 방법이 아니라고 생각되어 이부분은 그대로 유지하기로 했습니다. 혹시, 좋은 방법이 있다면 댓글이나 연락 주시면 정말 감사하겠습니다.


참가한 대회 리스트

맨 위의 사진에서는 참가한 대회가 하나여서 몰랐지만 참가한 대회가 여러개인 경우 다음과 같이 참가한 대회수만큼 sql 인 실행되고 있는 것을 확인할 수 있었습니다.

장고 query
join_season = self.jointeam_set.all()

실제 실행되는 sql


중복 query가 실제 수행되는 코드



변경 후 코드

장고 query
def join_season(self):
    join_season = self.jointeam_set.select_related("season").values_list("season")
    return join_season

실제 실행되는 sql



실행되는 sql의 수를 해당 팀이 참여한 대회 수만큼 줄일 수 있었습니다.  한가지 추가적으로 말씀드리면 values_list 메서드와 only 메서드의 차이는 only 메서드의 id 를 따로 명시해주지 않아도 자동으로 가져오고, values_list 메서드의 경우 id 또한 명시적으로 지정해줘야 id column을 가져옵니다.



+ 추가적으로 시도해본 부분.

홈 경기평균 득점
home_average_score = Match.objects.filter(team1_id=self.id).\
    aggregate(team1_score=Avg('team1_score'))['team1_score']

어웨이 경기 평균 득점
away_average_score = Match.objects.filter(team2_id=self.id).\
    aggregate(team2_score=Avg('team2_score'))['team2_score]

위 부분을 team을 가져올 때 
View 단에서 아래와 같이 수정하고 아래와 같이 가져오면 sql 이 2개가 실행 될 줄 알고( prefetch -> select 해서 가져온 후에 다시 select from where in()) 해봤으나 오히려 prefetch_related 실행시 발생하는 추가 select sql 만 추가되어서 2개 sql 이 필요했고, AVG 문은 따로 수행이 되었다. 당연하게 aggregate 부분이 sql 단에서 처리가 되기 때문에 당연한 부분이었는데…
team = Team.objects.filter(id=pk).\
    prefetch_related("team1_match", "team2_match").first()


def home_average_score(self):
    home_average_score = self.team1_match.aggregate(team1_score=Avg('team1_score'))['team1_score']
        # Match.objects.filter(team1_id=self.id).\
        # aggregate(team1_score=Avg('team1_score'))['team1_score']
    return home_average_score




수정 후 결과

실행시간: 6.32 ms, 총 sql수 :10 (컴퓨터 환경에 따라서 달라지지만 확실히 처음보다는 단축된 것을 확인했습니다.)

-> 만족스러운 결과는 아니지만 시간을 단축할 수 있었습니다. 1:1 매치의 특성상 두 번씩 가져오는 부분을 잘 수정해보고 싶은데 쉽지가 않네요. (DB 설계를 할 때 query를 효율적으로 가져올 수 있도록 설계가 필요할 것 같고, 장고 ORM과 SQL공부가 더 필요할 것 같습니다.)

-> 서비스가 발전한다면 Redis 같은 infra를 사용해서 응답속도를 줄여봐야 할 것 습니다.






저 혼자 열심히 해봤지만 한계가 많이 있는 것 같습니다.

많은 분들의 조언이 필요합니다!

혹시, 위과 같은 코드에서 응답시간을 줄일 수 있는 부분을 알고 계시는 분은 꼭 댓글이나 email(qoentlr37@gmail.com) 로 알려주시면 감사할 것 습니다 🙂


부족한 글 읽어주셔서 감사합니다!