현재 다노에서 다노샵 서버 개발을 하고 있는 대구올빼미입니다.(올빼미지만 밤 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의 실제 쿼리들이 어떻게 생성되는지 초창기에는 창을 함께 띄어놓고
보시면서 개발을 하시면 저와 같은 시행착오를 겪지 않으실 거라 생각하고,
개발하시는데도 많은 도움이 되실 거라 생각합니다.

읽어주셔서 감사합니다.

+ Recent posts