검색 기능 고도화를 어떻게 하면 좋을까? 일반적으로 django에서 검색 기능을 만든다고 하면 대부분은 아래와 같이 만든다.
Product라는 모델이 있고 name을 기준으로 검색을 한다고 하면
search_text <= 이건 검색하는 단어
product_list = Product.objects.filter(name__icontains=search_text)
대부분 이렇게 검색 필터를 만들 것이다.
하지만 여기에는 치명적인 단점들이 존재한다. 예를 들면 Product의 이름이 "아이폰 14,128G, 실버" 라고 한다면 누군가를 "아이폰14"로 붙여서 검색을 한다면 결과에 잡히지 않는다. 또 "아이폰 실버"라고 검색해도 나오지 않는다.
문제는 여기서 끝이 아니다. "맥북m2", "애플워치se2" 이런 영어와 숫자까지 함께 들어간다면, 어떻게 해야할까?
해당 텍스트와 정확한 이름(띄어쓰기포함)이 없으면 그냥 결과는 아무것도 나오지 않는다.
"맥북m2"를 검색한 고객의 의도는 무엇일까? 맥북프로 m2 모델을 검색하고 싶었을 수도 있고 맥북에어 m2 모델을 검색하고 싶었을 수도 있다. 또 그것 뿐만 아니라 16인지 m2 모델도 있고, 14인치 m2 모델이 있을 수도 있다.
고객의 의도를 알 수 없는 우리는 최대한 많은 검색 결과를 알려줘야 하는데 위의 로직대로 구현을 해버리면 아무 검색 결과도 나오지 않게된다.
왜나하면 상품명은 Apple 2022 맥북에어, 실버, m2, 8G 애플 2021 맥북프로 16, 실버, m2 이런 형태로 되어 있기 때문이다.
그럼 우리가 취해야 하는 것은 무엇일까? 일단 구글에 더 좋은 방법으로 한 것이 없을까 열심히 서칭을 해봤지만, 나오지 않았다.
그래서 다음에 생각한 것은 Product에 검색할 때 이용할 검색 참고 field를 하나 만들어서, Apple 2022 맥북에어, 실버, m2, 8G의 해당 필드에 맥북에어, 맥북m2, 맥북 실버, 2022 맥북 등 검색에 쓰일만한 텍스트들을 모두 넣어두는 것이다. 그래서 제품명 + 해당 필드에 있는 내용을 활용해서, 검색기능을 고도화 시키는 것이다. 그럼 유사한 keyword에도 검색결과가 잡히게 할 수 있으니 좋은 방법이기도 하다! (실제로 커머스에서 많이 사용하는 방법이기도 하다)
하지만 상품이 많다면... 그리고 처음부터 도입시켜 놓지 않았다면 막막할 수 밖에 없다. 그리고 해당 검색 field에 모든 예시들을 넣어놓을 수 없을테니깐 이슈가 또 발생할 것 같았다.
그럼 현재 상황에서 가장 간단하게 최선의 방법을 선택하면 어떻게 하면 될까? 머리를 돌려보았다.
내가 생각했던 방법은 아래와 같다. 실제 고객이 검색하고 아무런 결과값을 보내지 못 했던 검색어가 아래에 있다.
"맥북m2", "애플워치se2"
결국 찾고 싶은 것은 "맥북", "m", "2"가 모두 포함된 제품의 이름을 찾으면 되지 않을까?
애플워치se2는 "애플워치", "se", "2"가 모두 포함된 제품의 이름을 찾으면 되지 않을까?
완벽하지 않지만 당장에는 이렇게 하게 되면 "맥북m2"라고 검색을 해도
Apple 2022 맥북에어, 실버, m2, 8G 애플 2021 맥북프로 16, 실버, m2
이 2개의 제품이 모두 검색되게 할 수 있을 것이다. 왜냐하면 위의 제품명에는 맥북이라는 글자와 m이라는 글자, 2라는 글자 모두 포함되어 있기 때문이다. (이 예시를 보면 m2를 합쳐서 생각하면 더 좋을 것 같긴 하다. 하지만 더 고려해야 할 것들이 많으니 일단은 넘어가자)
이런 형태로 하게 되면 애플워치se2도 많은 부분 개선된다. 애플워치se2를 한글, 영어, 숫자로 나눠보면 "애플워치", "se", "2"로 나눌 수 있고 이것을 제품명에 모두 포함한 제품들은 아래와 같이 될 수 있다.
'Apple 2022 애플워치 SE 2세대 알루미늄 케이스, 40mm, GPS, 실버 / 화이트 스포츠밴드', 'Apple 2022 애플워치 SE 2세대 알루미늄 케이스, 40mm, GPS, 스타라이트 / 스타라이트 스포츠 밴드', 'Apple 2022 애플워치 SE 2세대 알루미늄 케이스, 40mm, GPS+Cellular, 스타라이트 / 스타라이트 스포츠 밴드', 'Apple 2022 애플워치 SE 2세대 알루미늄 케이스, 44mm, GPS+Cellular, 미드나이트 / 미드나이트 스포츠밴드',
처음에는 검색해서 나오지 않았던 결과값들이 그래도 나름 괜찮게 그리고 의미있게 결과값들을 뽑아낼 수 있게 된다!!
english = re.compile("[a-zA-Z]+").findall(search_text)
숫자 정규식
number = re.compile("[0-9]+").findall(search_text)
findall을 하면 list가 만들어지고
search_text = "애플워치se2"
hangle = ["애플워치"]
english = ["se"]
number = ["2"]
그럼 이렇게 된 것을 하나의 list에 넣어준다.
temp_search_text = ["애플워치", "se", "2"]
그럼 우리가 해야 하는 것은 이 list안에 것들이 모두 포함된 제품명을 찾으면 된다!!
그럼 이제 로직으로 보면
hangle = re.compile("[가-힣]+").findall(search_text)
english = re.compile("[a-zA-Z]+").findall(search_text)
number = re.compile("[0-9]+").findall(search_text)
temp_search_text = hangle + english + number
# temp_search_text = ["애플워치", "se", "2"]
product_list = Product.objects.filter(is_deleted=False)
for text in temp_search_text:
product_list = product_list.filter(name__icontains=text)
result_product_list = []
for product in product_list:
result_product_list.append(product)
쏘 심플!! django의 ORM 쿼리셋은 lazy loading이라 매번 호출하지 않는다. 실제로 제일 아래 product_list를 for문으로 돌 때 실행되기 때문에 저런 형태로 작성해도 걱정하지 않아도 된다!
그럼 Product의 Name을 기준으로 해당 나눠놓은 텍스트들이 모두 포함된 제품들을 반환할 수 있게 된다.
DB 변경 없이 코드 몇 줄 변경으로 아주 좋은 효율의 Django 검색 필터를 만들 수 있다.
만들고 나니, 생각보다 괜찮은 방법인 것 같고, 좋은 아이디어인 거 같아서 공유하기 위해 글을 적는다.
from django.test import TestCase
class YourTestClass(TestCase):
@classmethod
def setUpTestData(cls):
print("setUpTestData: Run once to set up non-modified data for all class methods.")
pass
def setUp(self):
print("setUp: Run once for every test method to setup clean data.")
pass
기본적으로 TestCase를 상속받아 만든 클래스는 2개의 메쏘드를 정의한다.
setUpTestData() 는 클래스 전체에서 사용되는 설정을 위해서 테스트 시작 때 딱 한 번만 실행됩니다. 테스트 메쏘드가 실행되면서 수정되거나 변경되지 않을 객체들을 이곳에서 생성할 수 있습니다.
setUp() 은 각각의 테스트 메쏘드가 실행될 때마다 실행됩니다. 테스트 중 내용이 변경될 수 있는 객체를 이곳에서 생성할 수 있습니다 (모든 테스트 메쏘드는 방금 막 생성된 ("fresh") 오브젝트를 입력받게 됩니다).
test코드 작성
from django.test import TestCase
class YourTestClass(TestCase):
@classmethod
def setUpTestData(cls):
member = Member.objects.create(name='byeonguk')
def test_name_label(self):
first_member =Member.objects.get(name='byeonguk').first_name
self.assertEquals(first_name, 'first name')
def test_age_bigger_19(self):
age = Member.objects.get(name='byeonguk').age
check_age = age > 19
self.assertTrue(check_age)
체크해주는 함수
self.assertEquals => 생각한 값과 같은지 체크해주는 함수
self.assertTrue(True) => () 안의 값이 True인지 체크
self.assertFalse(False) => () 안의 값이 False인지 체크
test코드 실행
python3 manage.py test
test코드 더 많은 정보 출력하기
python3 manage.py test --verbosity 2
verbosity 는 기본적으로 1이며, 0,1,2,3으로 조절가능
test코드 일부만 실행하기
테스트 중 일부만 실행하려면 패키지, 모듈, TestCase 서브클래스, 메서드의 전체 경로를 지정해주면 됩니다.
# Run the specified module
python3 manage.py test catalog.tests
# Run the specified module
python3 manage.py test catalog.tests.test_models
# Run the specified class
python3 manage.py test catalog.tests.test_models.YourTestClass
# Run the specified method
python3 manage.py test catalog.tests.test_models.YourTestClass.test_one_plus_one_equals_two
vue.js에서 el은 id를 의마한다. => react에서는 id=root를 사용해서 지정해주는 것과 같다. 따라서 제일 상단에 id로 해당 staring을 지정해줘야 한다.
<div id="staring">
<v-app>
</v-app>
</div>
props는 상위 컴포넌트에서 하위 컴포넌트로 state를 넘길 때 사용한다. 아래와 같이 사용하면 된다. 하위 컴포넌트에서는 props : 에 받는 인자들을 적어주면 되고, 상위에서는 해당 컴포넌트를 쓸 때 같이 적어주면 된다.
# 상위
# 사용할 컴포넌트를 지정해주고
data : {
'test': ''
}
components: {
'products' : Products
}
# 코드라인 (같이 state을 넘겨준다.)
<products :test='test'> </products>
#하위 Products 컴포넌트
# props에 받은 state를 적어주면 data에 따로 test model을 지정해주지 않아도 쓸 수 있다.
# 단 해당 변경점을 상위 컴포넌트로 다시 전달하려고 하면 function을 걸어주어야 한다.
props : {
'test' : ''
}
dlimiters는 아래의 data를 사용할 때 사용할 태그를 정의한다. 예를 들면 dlimiters를 [[ , ]] 로 지정해놓았다면
<div>
[[ test ]]
</div>
와 같이 하면 아래 지정해놓은 data test를 화면에 쓸 수 있다.
methods 는 사용할 함수를 지정할 수 있다.
# 함수 지정
methods : {
sayHello(){
alert('안녕이라고 말해')
}
}
# 코드 내에서 사용
<v-btn
@click="sayHello">
</v-btn>
추가적으로 지정해놓은 함수를 하위 컴포넌트로 넘길 수도 있다.
# 상위
# 사용할 컴포넌트를 지정해주고
data : {
'test': ''
}
components: {
'products' : Products
}
methods : {
sayHello(){
alert('안녕이라고 말해')
}
}
# 코드라인 (같이 state을 넘겨준다.)
<products :test='test' @say-hello="sayHello"> </products>
#하위 Products 컴포넌트
# $emit을 통해 상위 컴포넌트에서 내려준 해당 함수를 하위 컴포넌트에서 호출 할 수 있다.
props : {
'test' : ''
}
methods : {
TestSayHello(param){
this.$emit('say-hello', param)
}
}
현재 다노에서 다노샵 서버 개발을 하고 있는 대구올빼미입니다.(올빼미지만 밤 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'으로 하고 나면 어디에 로그를 쌓는지 보이게 됩니다.
이제 docker(아마 docker 환경에서 개발할 것으로 생각됩니다.)에서 해당 경로로 가서 해당 파일의 log를 tail로 보게 되면 실행되는 쿼리들을 볼 수 있습니다.
첫 째 : 그 동안 크게 생각하고 있지 않던 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의 실제 쿼리들이 어떻게 생성되는지 초창기에는 창을 함께 띄어놓고 보시면서 개발을 하시면 저와 같은 시행착오를 겪지 않으실 거라 생각하고, 개발하시는데도 많은 도움이 되실 거라 생각합니다.
물론 그것이 제대로 처리가 되지 않아도 된다는 이야기는 아니기 떄문에 별도라 잘 만들어진 처리기가 필요하다. => 항상 잘 처리되고 유실도 되지 않아야 한다.
why celery?
완전 쉽게 연동할 수 있다.
우리가 상상하는 모든 기능을 제공한다.
일단 남들이 제일 많이 쓴다.
Celery 시작하기
먼저 Celery 인스턴스를 생성해준다. 이 인스턴스를 통해 작업 생성 및 작업 관리와 같이 모든 작업의 시작점으로 사용된다.
from celery import Celery
app = Celery('tasks', broker='pyamqp://guest@localhost//')
@app.task
def add(x, y):
return x + y
tasks라는 Celery 인스턴스를 생성하고 broker의 url을 지정해준다.
함수 비동기 실행하기
from tasks import add
add.delay(1,2)
Worker 실행하기
celery -A tasks worker --loglevel=info
celery 여러가지 기능들
실행이 언제 될지 지정할 수도 있다.
# Crontab을 대체할 수 있다.
CELERYBEAT_SCHEDULE로 대체
+ 버전 관리가 가능하다.
# Celery 상태관리
celery event => 쓰기 위해서는 worker를 킬 때 -E 명령어를 같이 써줘야 한다.
celery flower
BUT 성장을 위한 가장 큰 문제는 Celery
# 잘 알고 써야 하는 Celery
적은 규모에서 간편하게 쓰기에는 더 없이 훌륭하다
의외로 조금만 커져도 신경써야 할 부분이 많다.
처음부터 고려하지 않으면 알 수 없는 이상 동작처럼 느껴질 수도 있으니 주의가 필요한 부분이 있다.
# Broker
어떤 녀석이 일(tasks)를 처리할지 중개 (broker)
RabbitMQ - AMQP처럼 동작?
AMQP란??
RabbitMQ이 가장 default broker이다.
# AMQP
- 최소한 한번은 전달된다. - But 이 말은 여러번 전달 될 수도 있다.
# 다른 Broker를 쓰면 문제 점(Rabbitmq 가 아닌)
ACK
visibility_timeout
# ACK
worker가 진행한 일에 대한 결과 혹은 상태를 broker에게 전달
그럼 broker는 해당 업무를 그냥 done처리해버림
- rabbitmq가 아닌 다른 broker들에서는 visibility_timeout으로 ACK를 구현
# visibility_timeout
# ack + visibility_timeout
ack가 오지 않으면 다시 message를 전송한다.
이것을 visibility_timeout을 통해서 구현을 해놓음
따라서 visibility_timeout내에서 ack가 전달되지 않으면 task가 중복실행된다.
eta, countdown 시간보다 visibility_timeout이 커야한다.
# Redis를 broker로 쓸 때
Redis는 메모리가 부족한 상황에서 임의로 key를 삭제될 수 있다.
즉 task를 받아도 삭제 될 수 있다.
# Celery 구조
web application에서 일을 Broker로 넘긴다.
나머지는 Broker가 알아서 Worker로 분배
# prefetch의 배신
샐러리의 가장 큰 그림자
prefetch는 broker에서 tasks(message)들을 그냥 미리 땡겨두는 거다?
Prefetch is a term inherited from AMQP that is often misunderstood by users, The prefetch limit is a limit for the number of tasks (messages) a worker can reserve for itself
실제 동작은 처음 4개의 공간에 당겨왔던 tasks를 다 끝내야지 이후에 tasks를 가지고 와서 새롭게 시작한다.
prefefetch된 단위 전체의 작업을 소비해야(ack) 다음 prefetch가 수행된다.
task가 비워지는대로 다음 task를 broker에서 가져올꺼라고는 일반적인 기대와는 다르다.
- 긴 task에 대해서는 worker_prefetch_multiplier을 1로 설정해주고, acks_late를 True로 해주면 실행 중인 task 1개를 끝낼 때 마다 떙겨오기 떄문에 불필요 하게 다른 worker들이 쉬게 되는 일이 없다.
# Prefork
prefetch limit은 broker에서 worker로 분배하는 로직이고
해당 worker안에서 분배하는게 prefork
-O fair같은 경우 해당 worker안에서 Master가 분배하는 방법
# Prefork pool prefetch
현재 일하고 있지 않은 worker에게 일을 주는게 아니라 지속적으로 그냥 순차적으로 쌓아버림
-ofair => 일 안하고 있는 worker에서 일을 시킴
해결책
task를 한 큐에 담지 마세요
prefetch의 특성상 평균 수행 시간이 비슷한 것들이 같은 queue에 있는 것이 성능상 훨씬 유리하다
task의 절대적인 수 자체도 중요한 요소이다.
처리의 중요도/ 시급도 에 따른 분류도 중요하다
위와 같은 요소를 고려해서 Queue를 나눠주세요
CELERY ROUTE
worker를 특정 queue를 붙여 둔다.
정말 간단한데 성능에 큰 영향을 주는 또 다른 요소
ingore_result => 이게 default로 켜져있다.
Celery는 기본적으로 수행 결과(return)을 저장 해야 작업이 끝난다.
하지만 대부분 TASK내에서 직접 결과를 다른 곳에 저장하지, return 자체를 쓰는 경우는 드물다.
그리고 무엇보다, 개인 프로젝트를 진행할 때 그리고 부분적으로 붙이는 간단한 기능들을 개발 할 때는, 해당 부분에 대해서 성능이 얼마나 되는지 측정할 일도 없고, 또 보지도 않았다.
실제 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의 무서움을 느낌과 동시에 성능적인 이슈를 생각하고 서버 코드를 짜야 한다는 것을 느낄 수 있었다.
이럴 경우 proj/proj/celery.py 라는 파일을 새로 만들고 여기에 celery instance를 정의해주는 것을 추천한다.
proj/proj/celery.py
from __future__ import absolute_import, unicode_literals
import os
from celery import Celery
# set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'proj.settings')
app = Celery('proj')
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
# should have a `CELERY_` prefix.
app.config_from_object('django.conf:settings', namespace='CELERY')
# Load task modules from all registered Django app configs.
app.autodiscover_tasks()
@app.task(bind=True)
def debug_task(self):
print('Request: {0!r}'.format(self.request))
해당 파일에 이렇게 작성을 해주고 하나씩 알아보자. 아 이것을 알아보기 전에 먼저 proj/proj/__init__.py module을 작성해줘야 한다. init.py를 작성해주면 이것은 django가 시작했을 때, app을 load해주고, @shared_task decorator를 사용가능하게 되는 것을 보장한다.
proj/proj/init.py
from __future__ import absolute_import, unicode_literals
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
from .celery import app as celery_app
__all__ = ('celery_app',)
이제 celery.py에 있는 것을 하나씩 보자
from __future__ import absolute_import
이것은 celery.py module이 import 될 때 다른 library와 충돌되지 않도록 해준다.
위에 namespace를 설정해준 것은 celery 구성 옵션들이 모두 앞에 CELERY_가 붙게 되는 것을 의미한다. 예를 들면 broker_url 을 setting하면 이게 CELERY_BROKER_URL로 표시될 것이다. 이것은 worker setting에서도 적용되고, worker_concurrency가 CELERY_WORKER_CONCURRENCY로 표시 될 것이다.
그리고 일반적인 사항으로 재사용 가능한 앱은 모두 tasks.py에 빼서 정의하는 것이다. 그리고 celery에서는 이것은 자동적으로 찾아주는 모듈을 가지고 있다. => tasks.py에 celery tasks에서 반복적으로 재사용할 앱을 정의하라
app.autodiscover_tasks()
위의 줄을 추가해주면 celery가 자동적으로 tasks를 찾는데, 우리가 설치되어 있는 앱에서 찾는다.