코딩 하는 가든

Django - Queryset 합치기와 주의점 ('|' 연산자, union의 차이점) 본문

Django

Django - Queryset 합치기와 주의점 ('|' 연산자, union의 차이점)

가든리 2020. 3. 18. 21:50

Django Queryset 합치기

 Django에서는 ORM이라는 뛰어난(?) 기술 덕분에 데이터들을 객체처럼 갖고 놀 수 있다. 하지만 편리하다고 이 ORM에 대한 자세한 이해 없이 사용한다면 언젠가 큰 코 다칠 일이 있을 것이다. 오늘은 이 ORM 덕에 큰코다칠 뻔한 일화에 대해서 적어보려고 한다.

 

Django에서는 Queryset이라는 모델 객체의 집합을 사용할 수 있다. 예시를 위해 예시 코드를 작성해보았다.

class Toy(models.Model):
    name = models.CharField(max_length=50, help_text='이름')
    price = models.IntegerField(help_text='가격')
    company = models.CharField(max_length=50, help_text='판매사')

 이런 Toy라는 모델이 있을 때, 우리는 Toy.objects.all() 같은 명령어를 통해 Toy 객체의 집합(Queryset)을 얻을 수 있을 것이다. 비지니스 로직을 작성하다 보면 여러 쿼리셋을모아 조합하여 응답으로 보내줘야 할 경우가 있는데 이럴 때 쿼리셋을 합치는 연산을 이용할 수 있다.

qs1 = Toy.objects.filter(price__lte=10000) // 만원 이하인 장난감들
qs2 = Toy.objects.filter(price__gt=20000)  // 2만원이 넘는 장난감들

// 방법1
result_set = qs1 | qs2  // qs1과 qs2를 합친 결과

// 방법2
result_set2 = qs1.union(qs2)

 Django에서는 위의 '|' 연산자나 union 연산을 통해 두 쿼리셋의 결과를 합칠 수 있다. 하지만 처음 쿼리셋을 합칠 당시 나는 이 두 방법의 차이점에 대해 알지 못했었고 그렇기에 계속 엉뚱한 결과를 받아왔다...! 이제 그 차이점에 대해 설명해보겠다.

 

'|' 와 union의 차이점

위에서 언급했듯 Django에서는 쿼리셋을 합치는 기능을 제공하는데, 이 쿼리셋을 합치기 위해 필요한 조건은 합치려고 하는 두 쿼리셋이 같은 필드를 갖고 있어야 한다는 점이다. 사실 쿼리셋을 합치기 위해선 같은 모델의 쿼리셋이라고 설명하는 블로그들이 많은데 엄밀히 말하면 같은 모델이라도 필드명과 타입이 똑같으면 쿼리셋을 합칠 수 있다는 것이다.

 

 물론 필드가 다르더라도 ORM 작성시 annotate를 통해 필요한 컬럼을 붙여 최종적으로 queryset에 담겨있는 모델의 스키마가 같게 해주면 합쳐질 수 있다.

class Toy(models.Model):
    name = models.CharField(max_length=50, help_text='이름')
    price = models.IntegerField(help_text='가격')
    company = models.CharField(max_length=50, help_text='판매사')
    
class Toy2(models.Model):
    name = models.CharField(max_length=50, help_text='이름')
    price = models.IntegerField(help_text='가격')

위의 Toy와 Toy2 모델은 다른 모델이지만 다음과 같이 company 컬럼을 추가해 주어 쿼리셋을 만들어 주면 Toy 쿼리셋과 합칠 수 있다는 것이다. 

Toy2.objects.annotate(company=Value('company2', output_field=CharField())

다음의 예시를 보자.

qs1 = Toy.objects.annotate(is_sold_out=Value(True, output_field=BooleanField()).all()
qs2 = Toy2.objects.annotate(company=Value('company2', output_field=CharField(),
			    is_sold_out=Value(False, output_field=BooleanField())).all()
                            
result = qs1 | qs2

qs1 에는 Toy 모델에 is_sold_out 이라는 컬럼을 추가하여 True라는 값으로 채워 넣었고, Toy2는 company와 is_sold_out 컬럼 두 컬럼을 추가하여 qs1과 qs2에 담긴 객체는 name, price, company, is_sold_out 이라는 동일한 4 컬럼을 가지고 있게 되었다.

 

결과적으로 result쿼리셋 에서는 qs1의 객체들은 is_sold_out이 True, qs2의 객체들은 is_sold_out이 False가 담긴 채 합쳐져 들어가 있을것이라고 예상했다. 하지만 모든 객체의 is_sold_out이 True인게 아니겠는가??

result = qs1.union(q2)

위 방법을 사용하니 qs1에는 True qs2에는 False로 원하는 값이 들어가 있었다. 이게 어떻게 된 일인가 하고 silk라는 django 프로파일링 툴을 이용하여 ORM - > SQL로 바뀐 결과를 살펴보았다.

 

 문제는 Django ORM 코드의 평가 시점 때문에 생긴 문제였다. Django는 db접근의 효율을 높이기 위하여 코드를 읽는 순간 db에 접근하여 ORM의 결과를 수행한 결과를 가져오는 것이 아닌 필요시점에 접근하여 가져오게 된다. 위의 코드 대로 라면 qs1, qs2는 result에 합쳐지는 순간 쿼리를 해서 가져온다. 그 순간에 '|'연산자와 union의 동작 방식의 차이때문에 결과에도 차이가 생기게 된다.

 

qs1 | qs2 는 각 필드들을 OR 조건을 통해 가져오는 SQL문을 작성했으며,

qs1.union(qs2) 는 q1에 해당하는 질의와 qs2에 해당하는 질의를 union연산으로 합친 SQL문을 작성했다.

 

이러한 결과를 위의 Toy모델에 적용시켜 보면 is_sold_out이라는 필드는 Toy에는 True, Toy2에는 False이니 qs1 | qs2 라는 연산을 하면 is_sold_out의 결과는 True or False로 항상 True가 나오는 것이었다.

 

원하는 대로 qs1에는 True, qs2에는 False가 나오게 하려면 qs1질의 따로 qs2질의를 따로 하는 qs1.union(qs2) 로 합쳐야 한다는 결론이 나왔다.

 

이처럼 '|' 연산자와 union의 수행 방식에 차이에 따라 결과가 달라질 수 있으니 쿼리셋을 합칠때는 주의하도록 하자. 특히 '|'연산자는 두 모델이 필드를 OR하기 때문에 복잡한 annotate로 명시적으로 컬럼을 만들어 주면 오류를 내뱉을 확률이 크니 annotate를 사용한 쿼리셋, 그리고 Bool필드가 들어간 쿼리셋을 합칠 시에는 union을 이용하도록하자.