현재 다노에서 다노샵 서버 개발을 하고 있는 대구올빼미입니다.(올빼미지만 밤 10시에 자는 건 비밀)

그리고 현재 다노샵 서버는 python을 베이스로 django 프레임워크를 활용하여 구성되어 있습니다.
많은 분들이 아시다시피 Django는 정말 좋은 기능들을 많이 제공해줍니다.

그중에서 초보 개발자에게 정말 좋은 것은 바로 ORM...!!
ORM은 객체(Object)와 관계(Relation)을 연결(Mapping)해주는 개념입니다(이라고 합니다).
객체와 관계형데이터베이스를 변형 및 연결해주는 작업이라고 말할 수 있습니다. 즉 개발하는 데 있어서
관계형 데이터베이스의 제약을 최대한 받지 않으면서, 객체를 클래스로 표현하는 것과 같이 관계형 데이터베이스를
객체처럼 쉽게 사용할 수 있도록 해줍니다.

# 30세 이상 멤버 정보 가져오기 in sql
select * from member where age >= 30;

# 30세 이상 멤버 정보 가져오기 in django orm
Member.objects.filter(age__gte=30)


여러 프레임워크에서도 이 ORM을 제공해 줍니다.

그렇다 보니, 실제 서비스에서도 보통 ORM을 통해서 개발을 많이 진행합니다.
하지만 이 ORM의 가장 무서운 점은 실제 안에서 어떤 쿼리문을 발생시켜서 저렇게 쉽게 쓸 수 있도록 해주는지
알 수 없다는 것입니다.

그동안 제가 했던 작업선에서는 평상시에는 전혀 성능적으로 이슈가 크게 되지 않다 보니
너무나도 편하게 ORM을 사용해왔습니다. (사실 ORM이 아직도 너무 좋습니다 흑....)

실제 ORM 쿼리가 어떻게 요청이 되는지, 사수분이 여러 번 이야기해주셨지만
직접적으로 와 닿지가 않아 그렇게 넘어갔습니다. (죄송합니다... 반성합니다ㅠㅠ)

그리고 그 문제는 생각보다 크리티컬 하게 일어났습니다.

최근에 다노샵에서는 옵션별 할인 프로모션과,
기존에 있었던 대용량 할인 정보를 함께 표시해주도록 하는 업무를 진행하게 되었습니다.
그리고 그동안에 잘 사용하던 ORM에서 드디어 문제를 일으키고 말았습니다.

기존에는 각각의 제품 단위로 프로모션 할인이 들어가게 되었고, 제품 단위로 할인율을 구해주면 되었지만,
이제는 제품 하위의 옵션 단위로, 그리고 해당 옵션이 대용량 할인을 하고 있는지 일일이 계산하여,
옵션별로 할인율을 표기해주어야 하였습니다.(그만큼 기존보다 더 많은 로직을 타야 했고, 제품에 대한 할인 정보이다 보니 
굉장히 많은 곳에서 해당 부분을 사용하고 있었습니다.)

해당 작업을 진행하며, DUE도 짧았지만 나름 정말 몰입하여(심지어 아침에 운동할 때도 이 생각만 하고), 구조를 짜고
코드를 구성하였습니다. (물론 ORM을 그동안도 잘 사용해왔기에, 성능은 신경 쓰지 않았습니다.)

그리고 그 결과는 아주 처참하게 나타났습니다. 

테스팅을 할 때는 잘 작동하는 것에 만족하면서 테스팅을 진행하였고(현재는 QA시스템이 도입되었습니다.),
빠듯하게 실제 live가 되었을 때 곧 웹사이트가 느려진 것을 알게 되었습니다. 그리고 다급하게 New Relic(New Relic은 SaaS 기반의 APM(Application Performance Management) 서비스를 제공하는 회사)을 살폈습니다.

Newrelic의 web transaction time( 사용자가 웹 페이지를 클릭했을 때, 그 페이지에 대한 응답을 받는데까지의 시간)을 보았을 때 기존보다 적게는 2배에서 많게는 5배까지 느려진 지표를 보여주었다.

신입 개발자를 등을 땀으로 범벅이게 만들었던 그날의 지표들...


이때서야 저는 ORM이 얼마나 무서운지 깨닫게 되었습니다. 그동안 ORM을 아무 생각 없이 사용하였던 나 자신에 대한 깊은 후회와,
반성이 밀려들어왔습니다. 다급하게 전체 캐시를 적용해주어 성능적인 이슈는 막고 있었지만, 이번 프로모션 기간이 끝나기 전에 해당 부분을 개선해주어야 하는 2번째 미션이 주어졌습니다. (즉 일을 2번 하게 되었습니다.)

그렇게 반성을 하며 해당 부분에 대한 리팩토링을 시작하였습니다.
크게 저는 4가지의 방법을 통해 성능을 개선시켰습니다. 지금부터 그 이야기를 해보려고 합니다.

먼저 ORM에서 실제 발생시키는 쿼리를 보지 못하면 절대 개선을 할 수 없습니다. 따라서 ORM에 대해서 잘 모를 때는 무조건 실행 쿼리문을 볼 수 있는 창을 따로 띄어놓고 개발하시기를 추천드립니다.(네... 물론 저는 그렇지 않았습니다. ㅠㅠ)

별첨 : ORM에서 실제 발생시키는 쿼리문 보기 (Django + mysql)

# mysql client에서
show processlist; # 현재 mysql에서 실행 중인 process 보기

show variables like 'general_log%'; # 현재 general_logs의 상태를 볼 수 있습니다.

set GLOBAL general_log='ON'; #general_log를 켤 때

set GLOBAL general_log='OFF'; #general_log를 끌 때

set GLOBAL gnenral_logs = 'ON'으로 하고 나면 어디에 로그를 쌓는지 보이게 됩니다.

general_log_file의 경로를 볼 수 있습니다.

이제 docker(아마 docker 환경에서 개발할 것으로 생각됩니다.)에서 해당 경로로 가서 해당 파일의 log를 tail로 보게 되면
실행되는 쿼리들을 볼 수 있습니다. 

# docker 환경이 아니시거나 혹시 다른 방법으로는 

django-logging : docs.djangoproject.com/en/3.0/topics/logging/
print문으로 바로 찍어 보기 : stackoverflow.com/questions/3748295/getting-the-sql-from-a-django-queryset

와 같은 방법을 이용해보시면 좋을 것 같습니다.

첫 째 : 그 동안 크게 생각하고 있지 않던 select_related에 대해 다시 공부하여 적용해주었습니다. (1+N쿼리문제 해결)

관계형 데이터베이스의 장점은 Foreign Key를 묶을 수 있고 해당 Foreign key로 다른 테이블에도 접근할 수 있다는 것입니다.
하지만 이렇게 Foregin Key로 묶인 데이터를 ORM에서 가지고 올 때 주의해야 할 부분이 있습니다.

간단한 예시를 들어보겠습니다.

class Member(models.Model):
	name = models.CharField(max_length=32)
    age = models.SmallIntegerField()
    detail = models.ForeignKey('MemberDetail', on_delete=SET_NULL)
    
	class Meta:
    	db_table = "member"
        
class MemberDetail(models.Model):
	address = models.CharField(max_length=32)
    
    class Meta:
    	db_table = "member_detail"

가장 쉽게 볼 수 있는 DB 모델을 하나 만들었습니다.
고객의 중요한 정보를 Member 모델에 넣고, 추가 정보들을 MemberDetail로 따로 저장하도록 모델을 구성하였습니다.

나이가 30살 이상인 고객들의 address를 가지고 오고 싶으면 어떻게 해야 할까요?

def get_member_address_list():
	result = []

    member_list = Member.objects.filter(age__gte=30)

    for member in member_list:
        member_address = member.detail.address
        result.append(member_address)

    return result

간단한 함수를 하나 만들고 실행시켜보면, 굉장히 놀랄 수도 있습니다. (물론 위의 ORM에 의해 발생하는 로그들을 보고 있을 때입니다.)
왜냐하면 member.detail.address 이 부분에서 Foregin key에 묶인 MemberDetail 정보를 가지고 오기 위해 새로운 쿼리를 발생시키는데, 
해당 for문을 돌 때마다 1번씩 발생시키기 때문입니다. (해당하는 고객이 1만 명이라면, 1만 번 발생하게 됩니다.)

숫자가 적을 때는 괜찮지만 숫자가 많아지게 되면 어마어마한 쿼리를 발생시키게 됩니다. (물론 컴퓨터는 굉장히 빠르기 때문에, 평소에는 잘 느끼지 못합니다.)

이것을 1+N 쿼리 문제라고 합니다.

이 부분을 개선해주기 위해서는 어떻게 해야 할까요? 바로 select_related를 써주어 처음 member_list를 query 해올 때, 함께 Foregin Key로 묶인 MemberDetail 정보를 함께 쿼리 해오는 것입니다. (filter를 적었다고 쿼리문이 발생하지 않습니다. 위에서는 for문을 돌아야 하는 시점에 해당 filter의 query가 실행되게 됩니다.)

해당 코드를 select_related를 통해 개선해보겠습니다.

def get_member_address_list():
	result = []

    member_list = Member.objects.select_related('detail').filter(age>30)

    for member in member_list:
        member_address = member.detail.address
        result.append(member_address)

    return result


ORM에서 filter를 해올 때 select_related를 통해 Foreign key 묶인 것을 한 번에 query를 해오면 해당 Foregin key가 필요할 때 굳이 또 query문이 생성되지 않아도 된다는 것입니다. 해당 함수를 실행시키면, 딱 1회 query 문이 실행되는 것을 볼 수 있습니다. Member에서 age가 30 초과인 사람들을 filter 해올 때 해당하는 사람들의 detail에 묶인 정보도 함께 query 해와 메모리에 저장해놓고 이후에 지속적으로 사용하게 됩니다. (select_related 이외에서 prefetch_related도 있으나 여기서는 다루지 않겠습니다.)

둘 째 : 매개변수를 넘겨줄 때 객체 형태로 넘겨주면 또 한 번의 쿼리를 줄일 수 있습니다.

보통 우리는 매개변수에 값을 전달해줄 때, 정수를 넘겨주는 경우가 많다. 예를 들면 member_id를 넘겨주는 것이다.
이것도 예시를 먼저 들어보면 좋을 것 같다.

이번에는 특정 멤버의 address를 가지고 오는 함수를 생성하였다.

class GetMemberInfo:
	
    # 특정 나이보다 큰 멤버들의 주소 정보를 가지고 오는 함수
    def get_member_address_list_from_age(age):
        result = []
        member_list = Member.objects.filter(age>30)

        for member in member_list:
            member_address = self.get_member_address(member.id)
            result.append(member_address)
	
    # 특정 멤버의 주소 정보를 가지고 오는 함수
    @staticmethod
    def get_member_address(member_id):
        member = Member.objects.get(id=member_id)
        member_address = member.detail.address
        return member_address

위의 get_member_address 함수를 보면 member_id라는 argument를 받아서, 그 안에서 member_id를 통해 member 정보를 다시 가지고 오고, 그리고 묶인 Foreign Key를 통해 그 멤버의 address를 뽑아내고 있습니다.

이렇게 되면 get_member_address 함수를 실행할 때마다 member에 대한 쿼리 1번과 member의 Foreign key로 묶인 detail(Member_Detail)에 대한 쿼리 1번 총 2번의 쿼리가 실행되게 됩니다. 이것을 1번으로 줄이는 것은 위의 select_related를 사용해주면 1번으로 줄일 수 있습니다. (member = Member.objects.select_related('detail').get(id=member_id)

그럼 1번은 꼭 어쩔 수 없이 실행되어야 하는 걸까요?

이 쿼리 1번도 아예 줄여 버릴 수 있습니다. 그 방법은 바로 argument로 member_id를 넘겨주는 대신 객체 자체를 넘겨주는 방법입니다.
위의 함수를 좀 수정해보도록 하겠습니다.

class GetMemberInfo:
	
    # 특정 나이보다 큰 멤버들의 주소 정보를 가지고 오는 함수
    def get_member_address_list_from_age(age):
        result = []
        member_list = Member.objects.select_related('detail').filter(age>30)

        for member in member_list:
            member_address = self.get_member_address(member)
            result.append(member_address)
	
    # 특정 멤버의 주소 정보를 가지고 오는 함수
    @staticmethod
    def get_member_address(member):
        member_address = member.detail.address
        return member_address

member_id를 argument로 넘겨주어서 다시 한번 get_member_address 함수에서 쿼리를 실행시키는 게 아니라,
애초에 argument로 쿼리 되어 뽑힌 객체 자체를 넘겨주는 것입니다.
그리고 추가적으로 member_list를 filter 해오는 단계에서 select_related를 통해 detail 정보를 함께 쿼리 해오게 되면, 객체 정보를 넘겨줄 때 Foreign key로 묶인 detail에 대한 정보도 함께 넘겨지게 되고, 놀랍게도 get_member_address 함수에서는 아예 쿼리가 발생하지 않게 됩니다. 그럼 쿼리는 member_list에 대한 쿼리 딱 1번으로 마무리할 수 있습니다. 

ORM을 사용하여 아무 생각 없이 코드를 작성하다 보면 쿼리의 효율이 굉장히 나빠질 수 있습니다. (또 한 번 반성합니다..)

셋 째 : 필요한 부분에는 캐시를 적용해 줍니다.

현재 다노에서는 Redis를 캐시 DB로 활용하고 있습니다.
필요한 적재적소의 부분에 캐시를 적용해주면 성능을 굉장히 높일 수 있습니다. 위의 과정들을 통해 성능을 개선하였다면, 더 비약적으로 
성능 개선을 위해서는 캐시 설정하는 것만큼 극단적인 효과를 보기가 쉽지 않습니다. 물론 캐시 역시 만들어서 특정 데이터베이스에 저장해 놓는 것이기 때문에 데이터에 접근하기 위한 시간이 소모되긴 하지만 적절한 위치에 캐시를 적용해 놓기만 하면 훨씬 개선된 성능을 볼 수 있습니다. 

이것도 예를 한번 들어보겠습니다.
제품에 대한 정보를 가지고 올 때, 단지 제품에 대한 정보만 가지고 오면 될까요? 사실 제품에는 묶여 있는 것들이 많습니다.
제품에 대한 정보뿐만 아니라 제품 옵션들 각각에 대한 정보, 그리고 배송에 관한 정보, 재고에 관한 정보, Q&A에 대한 정보, 후기에 대한 정보 등등 굉장히 다양한 데이터 테이블들이 묶여서 우리가 보는 제품에 대한 페이지를 구성합니다.

그럼 필연적으로 해당 테이블들을 모두 쿼리 해올 수밖에 없습니다. 하지만 사실 재고에 대한 정보를 제외하고는 제품에 대한 정보, 그리고 옵션에 대한 정보, 배송에 대한 정보 등은 한번 설정을 하고 나면 잘 바뀌지 않습니다.(물론 리뉴얼 등으로 바뀔 수도 있습니다.) 

이렇게 바뀌지 않는 정보들에 대한 것은 모두 모아 캐시로 설정해놓고, 재고에 대한 정보만을 쿼리 해와서 함께 붙여주기만 하면 성능이 훨씬 개선될 수 있습니다. 

여기서 캐시 설정에 주의할 점들이 있습니다.
먼저 해당 캐시의 시간을 적절하게 세팅해주는 것(너무 긴 시간을 설정해주면, 정보가 변경되었지만 반영이 안 되게 되고, 너무 짧은 시간 설정해주면 의미가 없게 됩니다.)
그리고 적절한 위치에 캐시를 설정해주고 (잘 변경되지 않는 데이터들), 또 특정한 순간(상품에 대한 정보가 바뀔 때 등)에 캐시를 깨 주는 로직을 추가해주어야 합니다. 

넷 째 : 원시 SQL문 적용해주기

django에서도 원시 SQL?을 사용하여 쿼리를 요청할 수 있습니다. 방법은 크게 2가지가 있습니다.
1. Manger.raw ()를 사용하여 원시 쿼리를 수행하고 모델 인스턴스를 반환하는 것
2. 모델 레이어를 완전히 파괴하고 사용자 정의 SQL을 직접 실행시키는 것

2가지의 방법을 통해 직접 SQL 문을 작성하여 실행시킬 수 있습니다. 현재 다노에서도 많은 데이터를 쿼리 해와야 할 때는
직접 SQL을 작성하여 DB에 connection을 뚫어서 직접 SQL을 요청하고 있습니다. 

다만 해당 부분은 ORM을 직접적으로 사용하는 방법이 아니므로, 이번 글에서는 자세히 다루지 않겠습니다.


이번에 잘못된 부분들에 대해서 리팩토링을 진행하면서는 크게 위의 1,2,3번의 요소들을 반영해 주었습니다.
그리고 실제 개선된 부분들에 대해서 이야기를 해보고자 합니다.

과연 그 결과는 ...!! 두둥

기존 결과 값과 비슷하거나 더 개선된 수치들도 존재하였다. (로직이 많이 추가된 것에 비해서 많은 부분 개선되었다고 판단하였습니다!)
다시 리팩토링을 하여서 비록 작업을 2번 하게 되었지만, 이번 기회를 통해 정말 정말 정말... ORM의 무서움을 직접 몸으로 겪게 되었고,
현재는 위의 규칙들을 잘 생각하며 코딩하고 있습니다..

ORM은 너무 편하고 좋지만, ORM의 실제 쿼리들이 어떻게 생성되는지 초창기에는 창을 함께 띄어놓고
보시면서 개발을 하시면 저와 같은 시행착오를 겪지 않으실 거라 생각하고,
개발하시는데도 많은 도움이 되실 거라 생각합니다.

읽어주셔서 감사합니다.

  1. 우직 2020.08.09 12:12

    안녕하세요 우연히 블로그 둘러보다가 댓글 남깁니다!

    저는 최근 개발에 관심을 가지게 된 학생입니다.

    파이썬 백앤드 개발자가 되고 싶은데 프론드 지식 (html, css) 을 먼저 공부하는 것보다 파이썬을 공부하는 것이 좋을까요?

    • 익명 2020.08.10 20:49

      비밀댓글입니다

  2. 쟁고뉴비 2020.10.11 11:08

    Orm 리팩토링 예제 찾다 도움받고 갑니다
    감사합니다

  3. test 2020.10.29 17:37

    감사합니다 대구올빼미님
    큰 도움이되었습니다

Django ORM... 너무 믿었다.

그리고 무엇보다, 개인 프로젝트를 진행할 때 그리고 부분적으로 붙이는 간단한 기능들을 개발 할 때는,
해당 부분에 대해서 성능이 얼마나 되는지 측정할 일도 없고, 또 보지도 않았다.

 

실제 ORM 쿼리가 어떻게 요청이 되는지, 사수분이 여러 번 이야기해주셨지만
직접적으로 와닿지가 않아 그렇게 넘어갔다. (죄송합니다... 반성합니다ㅠㅠ)

그리고 그 문제는 생각보다 크리티컬하게 일어났다.
이번에 맡게 된 업무 중에 6주년 프로모션과 관련하여 옵션별로 프로모션을 진행할 수 있도록
개발해야 하는 부분이 있었다.

 

추가적으로 벌크(대량)으로 구매하였을 때 얼마만큼의 할인이 되는지
표기가 될 수 있도록 하는 작업도 진행하게 되었다.

따라서 모든 상품들에 대해서 그리고 해당 상품들의 하위 옵션들에 대해서
할인율을 계산해주기 위해 이번에 작업하는 부분들을 모두 거쳐야 하는 이슈가 있었다.

이번 작업을 진행하며 시간이 짧았지만 나름 정말 몰입하여, 구조를 짜고, 코드를 구성하였다.
(물론 ORM의 성능은 신경쓰지 않았다.)

그리고 그 결과는 아주 처참하게 나타났다....
사실 올해 2월에도 그 동안의 ORM들로 인해 성능적인 이슈가 있었고, 해당 부분에 대해서 사수분의 개선 작업이 있었다.

그 결과는 놀라웠다

주로 이슈가 있었던 메인페이지/ 카테고리/ 상세페이지에서 작업이 이루어졌고,

newrelic 기준
메인페이지 : 800ms -> 200ms 초반

카테고리 : 800ms -> 300ms

상품상세 : 170ms -> 150ms

로 성능 개선이 이루어졌다.

 

그리고 내가 작업한 것들을 붙였다... 처음에는 동작하는 것에만 집중해서 동작이 잘 하는 것에 만족하였다.
그리고 곧 웹사이트가 늦어지는 것을 느끼기 시작했다.

다급하게 newrelic을 살폈다

메인페이지 : 1350ms

카테고리 : 1100ms

상품상세 : 300ms

로 나왔다 ㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎ.....ㅠㅠㅠㅠㅠ

오히려 개선 전보다도 성능이 더 안 좋아져버린 기적이 나타났다...ㅠㅠ
이렇게 수치로 명확히 나타나자, 나는 깨달았다... ORM이 얼마나 무서운지 ㅠㅠ..

그제서야 깊은 후회가 밀려왔다. 일단 프로모션 기간이니 전체 캐시를 적용해주어서 성능 이슈를 막고 있었지만 이번 프로모션 기간이 끝나기 전에 해당 부분을 개선해 주어야 했다. 

먼저 예전의 실제 요청되는 로그를 보는 방법을 알려주신 것을 찾아보았다.

DB 클라이언트에서 해당 내용 돌려주기

show processlist;

show variables like 'general_log%';

켤 때 : set GLOBAL general_log='ON';

끌 때 : set GLOBAL general_log='OFF';

해당 부분을 통해 실제로 ORM 로그가 어떻게 요청되는지 보았다.

결과는 충격이었다...

쓸데없이 너무나 많은 쿼리들이 요청되고 있었다.

첫째: 

무엇보다 filter해오는데 select_related를 통해 Foreign key 묶인 것을 먼저 가지고 올 수 있는 부분을 가지고 오면 뒤이어서 굳이 또 query문이 생성되지 않아도 된다는 것. 항상 이야기만 들었지 실제로 query문이 요청되는 것을 보니 확 느낄 수 있었다.

filter를 할 떄는 실제로 query문이 요청되지 않는다. => 이 말은 사실 이었다. 그리고 for문을 돌면서 실제 필요로 할 때 query문이 요청되었다.

만약에 for문 안에서 해당 key 값에 foreign key로 묶인 변수를 가지고 온다고 하면, 

ex) 멤버 정보를 filter하고 그 안에서 foreign key로 묶인 detail 정보를 가지고 온다고 하면

member_list = Member.objects.filter(age= 29)

for member in member_list:

    member_detail = member.detail 

 

위와 같이 하면 모든 member_list에 대해서 member_detail을 가지고 오기 위해 또 쿼리문을 생성했다.

이것을 방지 하기 위해 

member_list = Member.objects.select_related('detail').filter(age= 29)


for member in member_list:

    member_detail = member.detail

이렇게 진행하게 되면 기존에 member_list의 숫자만큼 요청되던 쿼리문이 따로 생성되지 않고, member_list를 가져오기 위한 쿼리 한번으로 끝날 수 있었다.

 

둘째:

되도록 함수의 매개변수를 넘겨줄 때는 객체 형태로 넘겨주는 것이 좋다. 매개변수에 member를 넘겨줄 때 member_id를 주게 되면

해당 함수 안에서 또 멤버 정보를 가지고 오기 위한 query문이 필요하게 된다. 하지만 그게 아닌 member 객체 자체를 넘겨주면 따로 그 함수 안에서 멤버 정보를 가지고 오기 위한 쿼리문이 필요 없어지게 된다. 

이게 간단해보이지만, 그 갯수가 많아지게 되면, 실제로 굉장히 많은 쿼리문을 줄일 수 있었다.

간단한 예를 보자

member_list = Member.objects.filter(age= 29)

for member in member_list:

    member_detail = member_detail(member.id)


def member_detail(member_id)

    member = member.objects.get(member_id)

    member_detail = member.detail

    return member_detail

 

member_detail을 가지고 오는 함수가 따로 있다고 가정하고 

위와 같은 형태로 매개변수에 member_id를 주게 되면, 또 member_detail 함수에서 해당 멤버의 정보를 넘겨준 매개변수를 바탕으로 get을 해야 한다.

굳이 신경쓰지 않고 만들다 보면 이런 형태가 해당 함수 안에서 모든 것을 처리하는 것으로 보여서 직관적이고 편해보이지만 실제로는 굉장히... 비효율적이다 ㅠㅠ.(나만 편했다...)

이것을 어떻게 개선할까?

member_list = Member.objects.filter(age= 29)


for member in member_list:

    member_detail = member_detail(member)


def member_detail(member)

    member_detail = member.detail

    return member_detail

위와 같이 member 객체 자체를 넘겨주면, 굳이 member_detail 함수 안에서 또 get query문을 날릴 필요가 없어진다. 

 

그럼 이것을 한번 더 개선할 수 있을까?

member_detail 안에서 foregin key로 묶인 member.detail을 가지고 오기 위해 결국 query문을 한번 더 요청하고 있다.

이것을 어떻게 개선할까?

member_list = Member.objects.select_related('detail').filter(age= 29)


for member in member_list:

    member_detail = member_detail(member)


def member_detail(member)

    return member.detail

 

이렇게 해줄 수 있다. 이렇게 하면 이미 member 객체 안에 detail에 대한 정보까지 들어가 있어서 따로 query 문을 한번 더 요청 할 필요가 없어지게 된다...

실제 함수를 호출해보면서 쿼리문이 어떻게 요청되는지 보아야 감이 오기 시작했다.

실제로 한 함수 안에서 몇십번씩 쿼리문이 호출 되던 것을 매개변수에 객체들을 넘겨주면서 1번으로 줄이기도 하고, 추가적으로 필요한 정보를 미리 select_related하여 넘겨주니 아예 필요 없는 경우도 있었다.

셋 째:

필요한 부분에는 캐시를 적용해준다.

많은 부분 성능 개선이 이루어졌음에도 캐시를 적용하는 것만큼 극단적인 효과를 보기는 쉽지 않다. 물론 캐시 역시 해당 데이터에 접근하기 위해 시간을 소모하긴 하지만, 훨씬 개선된 성능을 보였다.

캐시를 많이 적용하지 않고 정말로 꼭 필요한 부분에만 적용해주며, 적절한 때에 해당 캐시를 깨주도록 처리를 해주어야, 상품 정보 등이 변경되었을 때 실시간으로 변경이 될 수 있다.

주로 위의 3가지를 통해 작업물에 대해 다시 한번 성능개선을 해주었다. 즉... 일을 2번하고 있는 것이다 ㅠㅠ...

아무튼 위의 작업들을 통해 불필요한 쿼리문을 굉장히 줄일 수 있었다.

그 결과는??

메인페이지 : 1350ms -> 200ms

카테고리 : 1100ms -> 380ms

상품상세 : 300ms -> 98ms

로 성능 개선을 할 수 있었다.

이번 계기를 통해 다시 한번 ORM의 무서움을 느낌과 동시에 성능적인 이슈를 생각하고 서버 코드를 짜야 한다는 것을 느낄 수 있었다.

다른 분들께도 도움이 되었으면 한다.

  1. 위지원 2020.05.14 20:10 신고

    댓글 감사합니당 처음으로 댓글달아주신거라 저도 들렀다가요 화이팅! :)

+ Recent posts