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의 무서움을 느낌과 동시에 성능적인 이슈를 생각하고 서버 코드를 짜야 한다는 것을 느낄 수 있었다.

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

'Django' 카테고리의 다른 글

django celery beat crontab time 설정  (0) 2020.05.14
CELERY에 대해(주의할 점)  (0) 2020.05.06
장고 모델 생성시간 지정해주기  (0) 2020.04.01
django celery extension(django-celery-results)  (0) 2020.03.17
django celery 적용하기  (0) 2020.03.17

+ Recent posts