검색 기능 고도화를 어떻게 하면 좋을까?
일반적으로 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, 미드나이트 / 미드나이트 스포츠밴드',

처음에는 검색해서 나오지 않았던 결과값들이 그래도 나름 괜찮게 그리고 의미있게 결과값들을 뽑아낼 수 있게 된다!!

이까지 아이디어가 나왔으면 이제 우리가 할 일은
정규식만 서칭하면 된다!! 오예!!

한글 정규식

hangle = re.compile("[가-힣]+").findall(search_text)

영어 정규식

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 검색 필터를 만들 수 있다.

해당 로직들을 적용 전과 후


만들고 나니, 생각보다 괜찮은 방법인 것 같고,
좋은 아이디어인 거 같아서 공유하기 위해 글을 적는다.

django를 사용하는 주니어 개발자분들에게 도움이 되기를!!

테스트 코드의 작성은 한편으로 굉장히 크게 다가왔다.
아는 지인 개발자 분에게 들었는데, 아예 테스트 코드가 없으면
개발을 한 게 아니라고 본다고 하였다.

그렇다보니 스스로 테스트코드에 대해서 관심을 가졌으나,
직접 사용해보지 못하고 있었다.(물론 나의 학습 부족이 제일 컸다.)

앞으로는 이런 부분에 대해서 더 이상 미루지 않고
학습해서 부딪쳐보기로 하였다.

django에서 혹은 개발에서 테스트의 종류는 크게 3가지로 분류하고 있다.
참고 ( [Django 튜토리얼 파트 10: Django 웹 어플리케이션 테스트하기 - Web 개발 학습하기 | MDN](https://developer.mozilla.org/ko/docs/Learn/Server-side/Django/Testing))

  1. unit test(유닛테스트)
    1. 독립적인 class와 function 단위의 테스트
  2. Regression test(버그 수정 테스트)
    1. 발생하였던 버그에 대한 수정 테스트
  3. Integration test(통합테스트)
    1. 유닛 테스크를 완료한 각각의 독립적인 컴포넌트들이 함께 결합하여 수행하는 동작을 검증한다. 각 컴포넌트들의 내부적인 동작까지는 검증할 필요가 없다.
    2. 해석해보면 비즈니스 로직에 대한 검증인거 같다

구조 

app / tests /



__init__.py

test_models.py

test_forms.py

test_views.py

=> app아래에 tests라는 폴더를 만들고

해당 폴더 아래에 test관련된 파일들을 만든다.

 

실행

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

 

일단 어느정도 정리는 하였으니깐, 이제는 직접 작성해보면서 테스트를 해봐야겠다.

 

 

 

이런 모델이 있다.

class section_products(models.Model):
    products_list = models.ManyToManyField(Product, verbose_name="관련 상품")


이렇게 되면 products를 생성할 때 
section_products_prdocuts_list라는 모델이 하나 더 생성된다.

그래서 하나의 해당 객체를 가지고 와서 관련된 products_list를 뽑아와서 order하고 싶을 때,

section_products = SectionProducts.objects.get(id=10)

section_products.products_list.all().order_by('-id') 

=> 이렇게 하면 연결된 Product의 id로 정렬되어서 나온다.

 

하지만 내가 원하는 것은 새로 생성된 section_products_prdocuts_list의 

id로 정렬하는 것!

 

많은 구글링을 통해 해결하였다.

section_products.products_list.all().order_by('section_products_products_list.id') 로 해결

관련 글 : [python - How can I sort by the id of a ManyToManyField in Django? - Stack Overflow](https://stackoverflow.com/questions/1904978/how-can-i-sort-by-the-id-of-a-manytomanyfield-in-django)

 

기본적인 templates에서 vue 구조 사용하기

기본적인 vue구조
<script type="text/javascript">
new Vue({
      el: '#starting',
      delimiters: ['{{','}}'],
      props : {
            {test: ''}
        },
      data: {
        articles: [],
        loading: true,
        currentArticle: {},
        message: null,
      },
      mounted: function() {
      },
      methods; {
      },
      components: {
      }
 })
</script>


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)
    }
}

좋은 글이 있어서 링크 첨부 :

[NodeJs/VueJs] 상위, 하위 컴포넌트간 데이터 전달 방법

 

[NodeJs/VueJs] 상위, 하위 컴포넌트간 데이터 전달 ᄇ

안녕하세요. 이번에는 Vue 를 사용하면서 저를 많이 혼란스럽게 했던 기능 중 주요 기능인 상위, 하위 컴포넌트간 데이터 전달 방법에 대해 살펴보려고 합니다. 오늘도 미래의 저에게 도움이 될

question0.tistory.com

mounted()를 활용하면 아래  methods에 지정해놓은 함수를 랜더링 이후에 1회 실행한다.

data : { 
	'test': ''
},
components: {
	'products' : Products
},
# 랜더링 이후 해당 함수 1회실행
mounted() {
	sayHello()
},
methods : {
	sayHello(){
    	alert('안녕이라고 말해')
    }	
}
# urls.py에 해당 api를 들어올 수 있도록 url을 만들어 준다.
re_path(r'^inner/static/$', static_serving),


# view에 작성할 파일 

import mimetypes

# static 경로 설정(static파일 경로에 있는 파일을 다운로드)
STATIC_ROOT = getattr(settings, 'STATIC_ROOT')


@api_view(['GET'])
def static_serving(request):

    file_name = request.GET.get('filename', '')

    if file_name == '':
        return None


    fl_path = STATIC_ROOT+'/{}'.format(file_name)
    filename = file_name

    fl = open(fl_path, 'r')
    mime_type, _ = mimetypes.guess_type(fl_path)
    response = HttpResponse(fl, content_type=mime_type)
    response['Content-Disposition'] = "attachment; filename=%s" % filename


    return response

장고로 static 파일을 다운받을 수 있는 경로를 만들어줄 수 있다.

해당 경로로 filename을 담아서 요청을 하게 되면 파일을 다운 받을 수 있다.
다운로드 요청 경로

https://127.0.0.1:8000/inner/static/?filename='hello.py'


 

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

읽어주셔서 감사합니다.

[이슈]
celery beat를 이용하여 cron을 대처하기 위해
celery beat를 붙였다.

잘 작동하였다.

 

특히 beat의 스케줄은 crontab으로 관리하였는데, 
이슈는 crontab에 등록해놓은 시간이...  제시간에 실행되지 않고, 9시간 뒤의 task를 땡겨와서 실행하고 있었다... 하...

import datetime


app = Celery('****')
app.conf.timezone = "Asia/Seoul"
app.conf.enable_utc = False

app.now = datetime.datetime.now

app.conf.beat_schedule = {
    'alliance_order_daily_upload_worker':{
        'task': 'celery_batch.tasks.alliance_order_daily_upload',
        'schedule': crontab(minute=0, hour=1),
        'options': {'queue': 'slow_tasks'},
        'args': ()
    },
}

 

위와 같이 설정하였고, 기대는 새벽 1시에 beat가 돌기를 기대하였다.

하지만 기대와 다르게 업무 시간이었던 오후 4시에 crontab이 task를 queue에 넣었다.
즉 9시간 뒤의 task를 queue에 넣은 것이다.

도대체 뭐가 꼬인 걸까?
분명 UTC와 한국 시간의 차이가 9시간 이고

python shell에서 
생성된 app의 
app.now()를 체크해보면 한국 시간으로 체크되고 분명 한국 시간과 같은데, 
또 다시 9시간 뒤에 것을 떙겨와서 queue에 쌓는다라...

 

너무 이상한 점이 많았다.
beat에 등록된 crontab 시간을 한국 시간이 아니라 UTC 시간으로 보았다면, UTC 1시는 한국 시간 10시니깐 
10시에 시작을 했어야 했는데, 새벽 1시에 등록된 task가 한국 시간 오후 4시에 시작이 되었다. 

그럼 crontab에 등록된 시간이 UTC인가??
아니다 한국 시간이 맞다 ㅠㅠㅠㅠ..

In [1]: from danoshop.celery import *


In [2]: app.now()
Out[2]: datetime.datetime(2020, 5, 14, 16, 50, 38, 574737, tzinfo=<DstTzInfo 'Asia/Seoul' KST+9:00:00 STD>)

이렇게 나온다.

[Spoqa 기술 블로그 | 파이썬의 시간대에 대해 알아보기(datetime.timezone)](https://spoqa.github.io/2019/02/15/python-timezone.html)

위의 문서를 참고해보면

import datetime
from pytz import timezone, utc

KST = timezone('Asia/Seoul')

now = datetime.datetime.utcnow()
# UTC 기준 naive datetime : datetime.datetime(2019, 2, 15, 4, 18, 28, 805879)

utc.localize(now)
# UTC 기준 aware datetime : datetime.datetime(2019, 2, 15, 4, 18, 28, 805879, tzinfo=<UTC>)

KST.localize(now)
# UTC 시각, 시간대만 KST : datetime.datetime(2019, 2, 15, 4, 18, 28, 805879, tzinfo=<DstTzInfo 'Asia/Seoul' KST+9:00:00 STD>)

utc.localize(now).astimezone(KST)
# KST 기준 aware datetime : datetime.datetime(2019, 2, 15, 13, 18, 28, 805879, tzinfo=<DstTzInfo 'Asia/Seoul' KST+9:00:00 STD>)

 

와 같이 나와있는데 결국 위의 shell에서 나온 것에 대한 해석은 해당 시간을 3번째 케이스로 보기에는 시간대가 한국과 같아 제일 아래로 보는게 맞다고 판단하였다.

 

그럼 app.now의 시간은 지금 시간은 맞아.... 지금 시각이야.
crontab에 등록된 것도 지금 시각이야...

근데 9시간 뒤의 것을 먼저 실행한다...??

어떤 원인인지 정확히 파악은 못했다.
app.now의 시각은 분명히 한국 시간인데, 이것을 beat에서는 어느중간 UTC 시간으로 바꾸고, 9시간 뒤의 crontab을 가지고 온다고 하니 이야기가 맞아 떨어졌다.

 

해결

해결책 1. 

  • 현재 app의 시간을 utc시간으로 바꾸어 준다.

  • 따로 crontab의 시간을 바꾸지 않아도 된다.

# app.conf.timezone = "Asia/Seoul" : CELERY_TIMEZONE에 있어서 주석처리

# app.conf.enable_utc = False 어디에 쓰는 걸까...? CELERY_ENABLE_UTC = False 에 있다.

app.now = datetime.datetime.utcnow

  • 단 djcelery_periodictask; db의 last_run_at time이 UTC 기준으로 들어가게 된다. 즉 한국 시간보다 9시간 뒤의 시간이 저장된다.

 

해결책 2

  • app.now의 값은 그대로 놔두고 crontab의 시간을 9시간씩 더해서 해준다.

  • 그러면 너무 쉽지 않으니깐.. 만들어진 함수를 쓰자

def convert_cron_hour(hour):

    now = datetime.datetime.now()

    utc_datetime = datetime.datetime(

        now.year, now.month, now.day, hour, 0, 0, 0, tzinfo=pytz.timezone("UTC")

    )

    local_datetime = utc_datetime.astimezone(pytz.timezone('Asia/Seoul'))

    return local_datetime.hour





app.conf.beat_schedule = {

    'alliance_order_daily_upload_worker':{

        'task': 'celery_batch.tasks.alliance_order_daily_upload',

        'schedule': crontab(minute=0, hour=convert_cront_hour(1)),

        'options': {'queue': 'slow_tasks'},

        'args': ()

    },



  • 위와 같이 해주면 알아서 9시간 뒤로 cronschdule를 저장해준다. 

 

하... 원인을 좀 더 자세히 파악해보고 싶은데,
영혼까지 털털 털려서 ㅠㅠ 일단은 추후에 좀 더 알아봐야겠다

 

 

 

 

 

 

 

 

 

- 해당 정리는 pycon 2015 celery 강의 자료와 pycon 2019 celery 강의자료를 바탕으로 만들었습니다.
아래의 2개의 영상은 celery를 이해하는데 매우 좋습니다. 꼭 보시기를 추천드립니다!

- 참고:
정민영: Celery의 빛과 그림자 - PyCon Korea 2015 - YouTube](https://www.youtube.com/watch?v=3C8gBRhtkHk)

셀러리 핵심과 커스터마이제이션 - 이지훈 - PyCon.KR 2019 - YouTube](https://www.youtube.com/watch?v=vGPyjJ1jWUs)

 

Celery

  • Distributed task queue 혹은 종합적인 비동기 처리기

비동기 처리기?

  • 비동기 처리기는 동기적으로 수행하지 않아도 되는 일들을 처리해주는 역할

  • 결과를 즉시 받을 필요가 없거나 지연하여 처리해야 되는 일들을 보통 처리

  • 물론 그것이 제대로 처리가 되지 않아도 된다는 이야기는 아니기 떄문에 별도라 잘 만들어진 처리기가 필요하다. => 항상 잘 처리되고 유실도 되지 않아야 한다.

 

why celery?

  • 완전 쉽게 연동할 수 있다.

  • 우리가 상상하는 모든 기능을 제공한다.

  • 일단 남들이 제일 많이 쓴다.

 

Celery 시작하기

  1. 먼저 Celery 인스턴스를 생성해준다.
    이 인스턴스를 통해 작업 생성 및 작업 관리와 같이 모든 작업의 시작점으로 사용된다.

  2.  
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 자체를 쓰는 경우는 드물다.

  • 보통은 TASK 연계를 하는 경우만 필요하다.

  • 결과를 저장하는 비용이 적지 않기 때문에 이걸 끄기만 해도 무척 성능이 좋아진다.

 

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

장고에서 

생성 시점의 시간을 기록하기 위해서는 2가지 방법이 있다.

(생성 시점 field를 reg_time)이라고 하자

첫 번째

class Post(models.model):
    reg_time = models.DateTimeField(auto_now_add=True)

 

두 번째

import datetime
class Post(models.model):
    reg_time = models.DateTimeField(default=datetime.datetime.now)

 

위와 같이 2가지 방법으로 reg_time을 설정해줄 수 있다.

추가 : 

여기서 default=datetime.datetime.now()를 하게 되면

함수를 실행시켜서 반환하는 값을 전달하게 되므로, 제대로 값이 전달 될 수 없다.

 

참고 : 

[python-장고 날짜 시간 문제 (default = datetime.now ())](https://stackoverflow.com/questions/2771676/django-datetime-issues-default-datetime-now)

 

추가 이슈 : 

실제 고객이 사용하고 있는 서비스(DB)에서 reg_time을 모델에 추가해주려고 할 때,

일단 db에서 먼저 migrate를 해주게 되면, 해당 DB를 생성할 때 아직 모델에 해당 reg_time을 어떻게 넣어라는 내용이 없으므로

"Field 'reg_time' doesn't have a default value") 라는 에러가 생긴다.

그럼 먼저 API 서버에 코드를 먼저 배포해주면, reg_time이라는 필드가 DB에 생성되지 못해서 에러가 발생한다....

어떻게 해야할지 고민이다.

 

해당 글은 celery documentation을 보고 정리한 것입니다. 원문은 [First steps with Django — Celery 4.4.1 documentation](http://docs.celeryproject.org/en/latest/django/first-steps-with-django.html)

불러오는 중입니다...

여기에 있습니다.

django-celery-results 

=>using the django orm/cache as a result backend  

django-celery-result extions은 django orm또는 django cache 프레임워크를 사용하여 결과 백엔드를 제공한다.

$ pip install django-celery-results

pip를 통해 django-celery-results를 설치해준다.

그리고 INSTALLED_APPS에 추가해준다.

INSTALLED_APPS = (
    ...,
    'django_celery_results',
)

이후에 migrate를 통해 django_celery_results를 생성해준다.

python manage.py migrate django_celery_results

이후에 django setting에 celery 구성을 추가해준다.

CELERY_RESULT_BACKEND = 'django-db'

# 만약에 cache도 사용할 것이면
CELERY_CACHE_BACKEND = 'django-cache'

# cache 셋팅또 추가할 수 있다.
# celery setting.
CELERY_CACHE_BACKEND = 'default'

# django setting.
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
        'LOCATION': 'my_cache_table',
    }
}

해당 글은 Celery 4.4.1 documentation을 읽으면서 혼자 정리한 글입니다.

원문은 [First steps with Django — Celery 4.4.1 documentation](https://docs.celeryproject.org/en/stable/django/first-steps-with-django.html)

불러오는 중입니다...

여기에 있어요!

 

django에서 샐러리 활용하기

먼저 celery를 django prodjct에서 쓸려면 Celery library를 정의해줘야 한다.

일반적으로 모델링을 아래와 같이 했다고 가정한다.

- proj/
  - manage.py
  - proj/
    - __init__.py
    - settings.py
    - urls.py

이럴 경우 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와 충돌되지 않도록 해준다.

그 이후에 DJANGO_SETTINGS_MODULE의 환경 변수를 설정해준다.

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'proj.settings')

이 줄이 꼭 필요한 것은 아닌데, 이것을 해주면 항상 설정 모듈이 셀러리 프로그램으로 전달하지 않아도 된다.

app = Celery('proj')

이것은 우리의 libary instance를 이야기 해주는데, 많은 인스턴스를 가질 수 있지만 django를 사용할 때는 이유가 없다.

=> 이해가 안되네??

이제 django setting module을 샐러리 구성에 더해줘야하는데, 이것이 의미하는 것은 너는 복수의 구성 파일을 가질 필요가 없고, 대신에 django setting에서 celery 구성을 바로 할 수 있다. 하지만 너가 원하면 분리할 수도 있다.

app.config_from_object('django.conf:settings', namespace='CELERY')

위에 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를 찾는데, 우리가 설치되어 있는 앱에서 찾는다. 

- app1/
    - tasks.py
    - models.py
- app2/
    - tasks.py
    - models.py

 위와 같은 형태라면 celery에서 import 해주지 않아도, 알아서 tasks를 celery가 찾는다.

Using thr @shared_task decorator

재사용 가능한 app은 단지 한 프로젝트에 국한 되지 않을 것이고, 그래서 앱 instance를 다이렉트로 import 해줄 수가 없다. 

하지만 @shared_task 데코레이터는 앱 인스턴스에 국한되지 않고 tasks를 생성해준다.

예를 들어보면 아래와 같다.

from __future__ import absolute_import, unicode_literals

from celery import shared_task
from demoapp.models import Widget


@shared_task
def add(x, y):
    return x + y


@shared_task
def mul(x, y):
    return x * y


@shared_task
def xsum(numbers):
    return sum(numbers)


@shared_task
def count_widgets():
    return Widget.objects.count()


@shared_task
def rename_widget(widget_id, name):
    w = Widget.objects.get(id=widget_id)
    w.name = name
    w.save()

 

# 추가 

작업 모듈을 가지고 오는데 일관성이 있어야 한다.

예를 들어 INSTALLED_APPS에 project.app가 있는 경우 project.app에서 작업을 가져와야 한다. 그렇지 않으면 작업 이름이 달라지게 된다.

알아두면 좋은 migrations 명령어

1. python manage.py showmigrations => 현재 migrations이 어떻게 진행되었는지 눈으로 바로 보여준다. 

2. python manage.py migrate libs [특정 번호] => 특정 번호로 migration을 roll back해준다. 
-> 여기서 libs는 app 이름이다.

3. python manage.py makemigrations => migrations 파일을 만들어준다.

4. python manage.py migrate => migrations 파일을 직접 DB에 적용시켜준다.

 

최근에 migrations까지 적용한 이후에 적용되었던 테이블을 지운 경우가 있었다.

그래서 내가 원했던 것은 이 테이블을 다시 생성해주기 위해서 다시 migrate를 해주는 것이었다.

근데 migrate를 해보면 migrate를 할게 없다고 나온다. => python manage.py showmigrations으로 보아도 적용이 된 것으로 나온다.

( 아니 나는 테이블이 없다고 ㅠㅠ)

 

그래서 시도한 방법은 libs를 통해서 되돌리는 것이다.

그래서 libs로 그 이전 migrations으로 되돌려보니 근데 이것도 미치는게.... 이런 상황일 경우 이미 지워진 테이블이라 Unknown 테이블이라고 하면서 돌리지도 못한다. ㅠㅠ

하 그래서 해당 migrations 파일을 지우고 다시 생성했다. python manage.py makemigrations

오 그러니깐 migrations 파일이 다시 생성되었고 다시 python manage.py migrate를 돌려주었다.

이런 경우 아무리 해당 migrations 파일을 지우고 makemigrations으로 생성해봐도 이미 migration이 적용되었다고 나오고, migrate를 돌려도 no change deteched라고 나온다. ㅠㅠ 

 

이미 시간을 많이 사용했고 혼도 났다 .... 흑흑 ...


결론적으로 알게 된 것은 
django 모델 테이블 안에  django_migrations이라는 폴더가 있다

 

해당 폴더는 현재 db에서 migrations이 어디까지 진행 되었는지 기록되어 있다.

python manage.py showmigrations 할 때 나오는 것도 여기를 바탕으로 보여준다.

 

위와 같이 미치는 상황에 이럴 때 활용해야 되는게 django_migrations이라는 폴더이다. 

 

여기에서 기록을 지워주면 바로 마이그레이션 적용되었던 것이 없어진다. python manage.py showmigrations을 해봐도 기존에 적용되었던 부분이 없어진 것을 확인할 수 있다.

이것만 지우고 다시 migrate하면 끝... ㅠㅠ 

 

하 너무 돌고 돌았다. 슬프다.

'Django' 카테고리의 다른 글

django celery extension(django-celery-results)  (0) 2020.03.17
django celery 적용하기  (0) 2020.03.17
Django select_related, prefetch_related에 대해서  (0) 2019.12.28
Django queryset filter와 exists()  (0) 2019.12.28
Django Lock에 관해서  (0) 2019.12.28

2019.09.24 django select_related, prefetch_related

문제의 제기

  • 기본적으로 장고의 ORM은 불필요한 쿼리문을 많이 요청한다는 이야기가 많았다. 실제로 확인을 해보니 불필요한 중복 쿼리문이 너무 많이 사용되고 있었다.
  • 디버그 툴바를 활용하면 불필요한 쿼리문의 요청들을 확인할 수 있다.

기본적으로 ORM에서는 하나의 모델을 바탕으로 Foreignkey가 묶인 다른 자료들도 불러서 사용할 수 있다.

  • 문제는 여기서 발생하게 된다. 하나의 모델을 불러오고 또 그 관련된 Foreignkey가 연관된 자료들을 불러오려고 하면 DB에 접근을 2번이나 해야 한다.
  • 그렇다면 하나의 모델을 불러올 때 미리 foreignkey로 연결된 자료까지 불러 올 수 있으면 어떨까? 그렇게 되면 DB에 접근을 한번만 해도 된다!

select_related와 prefetch_related란?

  • select_related와 prefetch_related는 하나의 Queryset을 가져올 때, 미리 related objects들까지 다 불러와주는 함수이다.
  • 비록 query를 복잡하게 만들지만, 불러온 데이터는 모두 cache에 남아있게 되므로 DB에 다시 접근해야 하는 이슈가 줄어들 수 있다.
  • 2가지는 모두 DB에 접근하는 수를 줄여주어, performance를 줄여주지만 그 방식에는 차이가 있다.

select_related와 prefetch_related의 차이

  • 와 위의 글을 읽고 머리를 딱 쳤다.
  • select_related와 prefetch_related 모두 필요한 데이터들을 모두 한번에 가지고 올 수 있게 도와준다.
  • select_related는 foreign-key 혹은 one-to-one과 같은 1:1 관계에서 사용이 가능하다.
  • 그에 반해 prefetch_related의 경우 many to many, foreign-key 등 가리지 않고 사용이 가능하다.
  • 하지만 실제로 해당 내용을 한번에 모두 가지고 오기 위해 호출해야 하는 횟수가 다르다.
  • 결론부터 말하자면 prefetch_related가 더 많은 쿼리문(순차적으로 불러옴)을 실행해야 되는 반면에 select_related는 한번의 쿼리문으로 모든 것을 가지고 온다.

결론

  • select_related와 prefetch_related를 적절하게 사용하면 제일 좋다 :)

확인 방법

  • debug toolbar의 SQL문을 참고해보면 된다!

참고

'Django' 카테고리의 다른 글

django celery 적용하기  (0) 2020.03.17
Django migration 되돌리기, 재실행 방법  (3) 2020.01.29
Django queryset filter와 exists()  (0) 2019.12.28
Django Lock에 관해서  (0) 2019.12.28
Django model(default, blank, null)  (0) 2019.12.28

2019.08.27 django queryset filter와 exists()의 차이

django의 쿼리는 마지막까지 지연(lazy)된다.

  • 예를 들어 filter를 건다고 하면, 이것은 DB에 바로 전달되지 않는다.
    person = Person.objects.filter(first_name='kim')
  • 해당 쿼리셋은 바로 실행되지 않는다.
  • DB에 쿼리를 전달하는 일은 웹 애플리케이션이 느려지는 주범 중 하나이기 때문이다.
  • 실제로 실행 되려면 다음과 같은 과정이 실행될 때 일어난다.

for one_person in person:
print(one_person)

  • 해당 과정이 일어나게 되면 실제로 DB에 전달되면서 발생하게 된다.

DB 쿼리셋은 대신 캐시된다.

  • DB쿼리는 단 한번만 발생한다. 똑같은 순회를 할 경우 DB쿼리는 한번만 시행된다.

for one_person in person:
print(one_person.firstname)

for one_person in person:
print(one_person.lastname)

  • 해당 쿼리셋은 한번만 DB에 접근하여 캐시로 해당 값을 가지고 있고 아래의 for문을 해결한다.

if문에서는 쿼리셋 평가가 발생한다.

restaurant_set = Restaurant.objects.filter(cuisine="Indian")

if 문은 쿼리셋을 '평가'한다

if restaurant_set:
# 순회할 때는 캐시된 쿼리셋이 사용된다
for restaurant in restaurant_set:
print(restaurant.name)

  • 오홋 신기하네.
  • 하지만 해당 경우 만약에 있는지 없는지만을 평가할 떄는 캐시가 문제가 된다.
  • 따라서 했는지 없는지만을 평가하기 위해서는 exists()라는 것을 활용한다.

존재만을 확인하기 위한 exists()

if restaurant_set.exists():
실행한 내용

  • 이렇게 되면 exists를 통해 존재만을 확인한다.

하지만 쿼리셋 캐시도 엄청 크다고 하면, 문제가 발생된다.

  • 해당 경우 exists()와 iterator()를 함께 사용하여 쿼리셋 캐시를 생성하는 일을 방지할 수 있다.

person = Person.objects.all()

if person.exists():
for one_person in person.iterator():
print(one_person)

  • 해당 과정을 통해서 첫 번째 exists()로 쿼리셋 레코드가 존재하는지 확인하고
  • 존재가 한다고 하면 iterator()를 통해서 하나씩 돌면서 캐시를 쌓지 않고, 프린트를 하게 된다.

'Django' 카테고리의 다른 글

Django migration 되돌리기, 재실행 방법  (3) 2020.01.29
Django select_related, prefetch_related에 대해서  (0) 2019.12.28
Django Lock에 관해서  (0) 2019.12.28
Django model(default, blank, null)  (0) 2019.12.28
Django admin message  (1) 2019.12.28

+ Recent posts