새소식

인기 검색어

개발일기

3주간 Django ORM 써보면서 느낀 점

  • -

들어가면서

최근 회사에서 Django를 만지면서, 또 핸즈온 장고라는 책을 읽으면서 ORM 자체에 대한 이해도도 늘었다. (Django ORM을 공부하면서 JPA 실력이 는 기분이라니...) 이번 기회에 내가 새롭게 느낀 것들에 대해서 이야기 해보려 한다

 

N + 1 문제에 대한 오해

이전에는 N + 1 문제 발생의 원인이 단순히 기술적 한계라고 생각했고, 다른 한 편으로는 그렇게 그 이유에 대해 뭉뚱그려서 생각하니까 복잡한 것이라 생각했고, 해결하기 복잡한 것이라 생각했다.

 

이 생각은 사실 저번주 까지도 유효했고, 이번에 처리한 티켓에서 N+1 문제를 해결하면서 성능을 많이 이끌어 내면서 아 N + 1 이란게 RDBMS 구조상 당연히 일어나는 것이고 별로 어려운 문제도 아님을 느끼게 되었다.

 

우선 내가 N + 1이 RDBMS에서 발생할 수 밖에 없다고 생각하는 이유는 테이블 조회시 관심 있는 것은 당연히 "해당 테이블의 정보"이지, 해당 테이블이 알고 있는 주변 정보들이 아니다.

 

따라서 특정 테이블에 대한 조회를 하면 그 테이블이 가지고 있는 정보만 가져와야 하는 것은 매우 당연한 일이다.

 

하다못해 sql을 짜더라도 주변 정보를 알고 싶으면 JOIN을 건다. 그러나, ORM을 사용할 때는 종종 "이 엔티티와 관계된 것에 대한 정보"와 "이 엔티티의 정보"의 경계를 헷갈리고 호출하는 경우가 잦은 것 같다.

 

따라서, 엔티티와 관계된 것에 대한 정보는 현재 없을 것이기 때문에 그때서야 쿼리를 날려 추가로 얻어오게 되는 것이다. 이것이 내가 생각하는 N + 1의 가장 큰 원인이다.

 

이렇게 말하면 잘 와닿지 않을 수 있으니 좀 더 쉽게 이야기 해보자.

 

프로그래밍을 처음 배우는 지인이 파이썬을 공부해 보려고 한다. 그래서 당신에게 파이썬에 대해서 질문을 한다.

 

 

이때 당신은 파이썬에 대해서만 알려주는 것이 당연하다. 만약, 파이썬 뿐만 아니라 혼자 신나서 Java, C, Golang 등 눈치 없이 여러 정보들을 함께 제공하면, 큰 낭비일 뿐만 아니라 흥미를 잃을 것이다. 질의를 요청받은 내용만 전달해야 하는 것이다.

 

 

Entity도 마찬가지이다. 지인이 요청한 python에 대한 내용만 반환하고, 현재 요청받지 않은 C나, Java, Golang과 같은 것들은 파이썬과 함께 요청하면 그때 함께 전달하는 것이다. 

 

이걸 깨달으니 N + 1 문제가 더이상 어렵지 않은 문제인 것을 알게 되었고, 만약 발생한다면 JOIN으로 해결하던(select_related) WHERE로 처리하던(prefetch_related) 할 수 있는 것이다.

 

JOIN으로 해결한다는 것은 정방향 관계에서 한 방 쿼리로 해결한다는 의미이다. 정방향 관계라는 것은 FK를 가지는 입장(Many)에서 One에 대해 알고 있을 때의 관계이다. 이때는 어떤 FK에 대해 질의할지 알고 있기 때문에 한 방 쿼리로 접근 가능하며 JOIN 쿼리를 날릴 수 있다.

 

그에 비해 WHERE로 해결하는 역방향 관계에서는 One에서 Many에 대한 정보를 얻고 싶다면 조건절에 대해 알아야 하고, 그 key를 통해 연관된 정보를 찾아야 한다. 왜냐하면 내 column 정보만으론 연관된 테이블의 연관된 정보(주로 PK)에 접근할 수 있는지 모르기 때문이다.

 

따라서, SELECT로 내 정보를 우선 확인하고 연관 테이블에 대한 IN 절을 날리는 식으로 동작한다. 예를 들면 다음과 같은 식이다.

 

# 1, 15, 45, 1234
SELECT * FROM dog WHERE name = "봄이";

SELECT FROM dog_toy WHERE dog_toy.dog_id IN (1, 15, 45, 1234)

 

이 경우에는 한 방 쿼리는 사용할 수 없고, 연관 관계 수만큼 추가 쿼리가 날아간다. 하지만 이게 N + 1 보다는 훨씬 나을 것이고 여러 연관관계에 있을 때는 JOIN의 경우 (NL 기준) 카타시안 곱의 형태가 되기 때문에 오히려 이런 식으로 쿼리 여러 번 쏘는 것이 더 빠른 경우도 있을 것이다.

 

ORM에서 Inline View를 지원하지 않는 건 당연한 것

Inline View는 일종의 임시 테이블이다. 객체가 아니라 dto에 가깝다고 할 수 있을 것이다. ORM이란 것은 Object에 Model을 Relation시켜주는 것이다. 여기서 Object란 도메인을 의미할 것이기 때문에 dto에 맵핑 시켜주는 것은 ORM에게 기대할 것이 아니다. 따라서 Inline View를 지원하지 않는 것은 당연하다는 생각이 든다.

 

JPA vs Django ORM

Django 쓰면서 ORM이 좋다는 느낌을 많이 받았다. Sequelize 쓰다가 JPA로 넘어왔을 때도 wow 했는데, Django ORM에서도 wow 했다.

 

JPA와 비교해서 좋았던 점들은 다음과 같다

- 간접 참조 필요 없음

- N + 1 방지 쿼리 작성 등 많은 함수를 제공해서 생산성이 뛰어남

- 쿼리셋의 다형성과 재활용 간편함

- Many 관계 표현하기 좋음

- Proxy Bean 특성의 한계가 없음

 

불편한 점은 다음과 같다

- 최적화 되는 방법을 잘 모르겠다. 쿼리 캐시에 대해서도 최적화가 잘 안 되는 것 같다

- default로 테이블 명에 app 네임 붙이는 것 ㅡㅡ

 

JPA와 설계적으로 다른 느낌을 받았던 부분은 다음과 같다

- JPA의 엔티티 vs Django ORM의 모델

 

순서대로 하나씩 보도록 하자

 

좋은 점

1. 간접 참조 필요 없음

JPA 쓸때 좀 불편한 것들 중 하나는, PK만 필요한데도 객체 참조해서 해당 객체에 대해 쿼리를 쏘는 경우가 있다는 점이다. 이런 경우를 막기 위해서 간접 참조를 하는 경우가 있지만, 직접 참조 하면서 PK를 가져와야할 때 좀 불편함을 느꼈다.

 

예시는 다음과 같다

 

 

 

Comment2는 Board를 간접참조한 경우, Comment는 직접 참조한 경우다. 만약 다음과 같은 로직이 있으면 Comment는 Comment를 조회하는 쿼리를 한 번 더 날려야 한다

 

 

2. N + 1 방지 쿼리 작성 등 많은 함수를 제공해서 생산성이 뛰어남

 

JPA에서는 fetch join 하려면 그것에 대한 쿼리를 작성해야 하고, 그 fetch join이 필요할 때마다 해당 함수를 일일이 지정해 줘야하고, 함수명도 길어져서 좀 불편하다.

 

그러나, Django ORM은 연관관계에 대해서 한꺼번에 조회하는 함수들을 제공하기 때문에 체이닝해서 표현식을 간략화할 수도 있고, 쿼리셋을 활용해서 이를 재활용할 수도 있다.

 

예를 들면 이런 식이다.

 

>>> e = Entry.objects.get(id=2)
>>> print(e.blog)  # Hits the database to retrieve the associated Blog.
>>> print(e.blog)  # Doesn't hit the database; uses cached version.

>>> e = Entry.objects.select_related().get(id=2)
>>> print(e.blog)  # Doesn't hit the database; uses cached version.
>>> print(e.blog)  # Doesn't hit the database; uses cached version.

 

이게 다다. 이런 식으로 prefetch 외에 다양한 함수들을 제공하고, 쿼리셋을 통해 체이닝과 재활용할 수 있다는 점에서 활용도가 높다. JPA의 경우 매번 새로운 메서드를 만들어 주어야 하고, 그 메서드명이 길어져서 불편함을 겪었다.

 

물론 이것도 잘 해결할 수 있는 방법이 있겠지만, 단편적으로 보았을 때 그러하다.

 

심지어는 이런 식으로 쿼리가 몇 번 나가는지 추적도 할 수 있다

 

# 출처: https://github.com/KimSoungRyoul/PyConKR2020-DjangoORM/issues/5
from django.test.utils import CaptureQueriesContext
from rest_framework.test import APIClient
# django 기본 프로젝트에서 벗어나는 pytest , drf 를 제가 사용해서 복붙으로 동작하는  테스트 케이스가 아니긴 하지만
# with CaptureQueriesContext(connection) as expected_num_queries: 이거 사용하는 느낌만 알면 될 것 같아서
# 추가 설명 첨부 안했습니다

def test_check_n_plus_1_problem():
    from django.db import connection
    
    # Given: 주문이 2개더 추가되기전 API에서 발생하는 SQL Count (expected_num_queries)
    with CaptureQueriesContext(connection) as expected_num_queries: 
        APIClient().get(path="/restaurants/")
    
   Order.objects.create(
        total_price=9800,
        comment="주문데이터가 N개 생성되었다고 SQL이 N개 더 생상되면 안된다1."
    )
    Order.objects.create(
        total_price=8800,
        comment="주문데이터가 N개 생성되었다고 SQL이 N개 더 생상되면 안된다2."
    )
    # When: 주문이 2개 더 추가된 이후 API에서 발생하는 SQL Count (checked_num_queries)
    with CaptureQueriesContext(connection) as checked_num_queries:
        APIClient().get(path="/restaurants/")
    
    # Then: 주문이 2개더 추가된다고 동일 API에서 SQL이 추가발생하면 안된다. 
    assert len(checked_num_queries.captured_queries) == len(expected_num_queries.captured_queries)

 

3. 쿼리셋의 다형성과 재활용 간편함

위에서 말했던 특징과 겹치는 부분이 많이 존재하는데, Django ORM에서 query하는 객체를 쿼리셋이라 부르고, 하고싶은 작업들을 체이닝할 수 있어 표현력이 좋다. 예를 들어

 

>>> Entry.objects.filter(headline__startswith="What").exclude(
...     pub_date__gte=datetime.date.today()
... ).filter(pub_date__gte=datetime.date(2005, 1, 30))

 

이런 식으로 하려는 동작에 대해 표현이 가능하다. 마치 Java의 스트림을 다루는 것 처럼 동작한다. 실제로도 스트림처럼 get할 때만 SQL이 나간다. 또한 체이닝한 이러한 동작들을 함수로 만들어서 일련의 동작들을 재활용할 수도 있다.

 

또한, Python 특유의 다형성도 십분 활용할 수 있다. 예를 들어 다음과 같은 방식으로 동작할 수 있는 경우들이 많다.

 

for e in Entry.objects.all():
    print(e.headline)

 

사실 JPA에서도 얼마든지 가능한 형태긴 한데... 이런 것들이 전부 미리 다 제공되고 있으므로 django의 생산력이라는 특징이 두드러지지 않나 싶다

 

4. Many 관계 표현하기 좋음

 

JPA를 사용하면 One 입장에서 Many를 컨트롤하기 어렵다. 어떻게 보면 당연하다. One에서는 Many에 대한 PK 및 정보를 알지 못하기 때문에 One에서 제어할 수 없다는 점이 오히려 자연스러울지도 모른다.

 

하지만 Django는 One에서도 Many에 대해 마음껏 컨트롤할 수 있고, ManyToMany도 자연스럽게 컨트롤할 수 있다. 따라서 JPA에서는 가능한 안 쓰려고 하는 OneToMany도 잘 활용하는 것 같다.

 

이를 역방향 관계라고 표현하는데, 예를 들면 다음과 같다.

 

# 출처: https://docs.djangoproject.com/en/5.0/topics/db/examples/many_to_one/
from django.db import models


class Reporter(models.Model):
    first_name = models.CharField(max_length=30)
    last_name = models.CharField(max_length=30)
    email = models.EmailField()

    def __str__(self):
        return f"{self.first_name} {self.last_name}"


class Article(models.Model):
    headline = models.CharField(max_length=100)
    pub_date = models.DateField()
    reporter = models.ForeignKey(Reporter, on_delete=models.CASCADE)

    def __str__(self):
        return self.headline

    class Meta:
        ordering = ["headline"]
        
 # One 모델을 생성하고 저장한다
>>> r = Reporter(first_name="John", last_name="Smith", email="john@example.com")
>>> r.save()

>>> r2 = Reporter(first_name="Paul", last_name="Jones", email="paul@example.com")
>>> r2.save()

# Many 모델을 생성하고 저장한다
>>> from datetime import date
>>> a = Article(id=None, headline="This is a test", pub_date=date(2005, 7, 27), reporter=r)
>>> a.save()

>>> a.reporter.id
1

>>> a.reporter
<Reporter: John Smith>

>>> r = a.reporter

# One 측에서 many에 접근하는 역방향 관계. 분명 Reporter에서 Article에 대한 필드가 존재하지 않는데 사용 가능하다
>>> new_article = r.article_set.create(
...     headline="John's second story", pub_date=date(2005, 7, 29)
... )
>>> new_article
<Article: John's second story>
>>> new_article.reporter
<Reporter: John Smith>
>>> new_article.reporter.id
1

 

 

5. Proxy Bean 특성의 한계가 없음

 

이건 Spring Data JPA의 특성 때문인 것 같긴 한데... Spring에서 Transactional은 기본적으로 Spring AOP를 활용한 방식이기 때문에 Context에서 Bean을 직접 꺼내오지 않으면 Proxy method를 실행시킬 수 없다.

 

그러나 python decorator는 그러한 한계가 없다. 이것도 어떠한 문제가 있을 것 같긴 한데 아직 잘 모르겠다.

 

불편한 점

1. 최적화 되는 방법을 잘 모르겠다. 쿼리 캐시에 대해서도 최적화가 잘 안 되는 것 같다

예를 들면 다음과 같은 예시가 있다

 

# 출처: 핸즈 온 장고
company_list = [Company.objects.prefetch_related("product_set").all()]

company = company_list[0]

# SQL 나가지 않는다. result_cache 적용되었기 때문
company.product_set.all() 

# Bad. SQL 발생한다. result_cache 타지 않는다
company.product_set.filter(name="불닭볶음면")

# Good. SQL 발생하지 않는다.
fire_noodle_product_list = [product for product in company.product_set.all() if product.name == "불닭볶음면"]

 

원인은 버그는 아니고 Django ORM 설계 철학이라는 것 같은데, Django ORM 구조를 잘 모르면 실수하기 좋은 것 같다.

 

이것 외에도 조건 절에 같은 상황을 여럿 명시하면 distinct 되지 않고 그대로 중복해서 나간다던가 하는 경우도 봤고, 뭔가 신뢰하기 어렵다는 느낌이 좀 들었다.

 

2. default로 테이블 명에 app 네임 붙이는 것 ㅡㅡ

아마 가장 불편한 점이라고 생각하는데, 실제로 이것 때문에 사내 레거시가 잔뜩이라 불만이 많다.

 

예를 들어 다음처럼 모델을 선언하면

from django.db import models


class Person(models.Model):
    first_name = models.CharField(max_length=30)
    last_name = models.CharField(max_length=30)

 

다음과 같은 쿼리가 나간다

 

CREATE TABLE myapp_person (
    "id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
    "first_name" varchar(30) NOT NULL,
    "last_name" varchar(30) NOT NULL
);

 

개인적으로 Django에서 가장 만족하는 점이 ORM인데, 이건 개인적으로 생각했을 때 정말 최악의 설계 오류가 아닌가 싶다.

 

물론 table 명을 바꾸는 것도 가능하지만, default로 이렇게 박아놓은 것이 너무 맘에 들지 않는다. 명시적인 것이 묵시적인 것보다 낫다는 철학은 어디가고 Table 명을 자기들 맘대로 짓는지...

 

특히나 django 외 다른 곳에서도 DB를 사용하는 경우 (ex. spring) 문제가 될 수 있지 않나 후...

 

설계적으로 다른 부분

1. JPA의 엔티티 vs Django ORM의 모델

내가 느끼기에 JPA는 뭔가 db 접근에 대한 부분을 도메인 단에서 아예 신경쓰지 않도록 만드는 것이 목표인 것 같다. (기술적인 한계로 현재 불가능한 것은 예외로 치고)

 

더티체킹이나 영속성 컨텍스트 같은 부분들이 좋은 예시 같다. 최대한 service 단에서 영속 계층을 직접 보도록 만들지 않도록 하는 것 같다.

 

그런데 내가 느낀 Django ORM은 "로직은 DB와 떼어놓을 수 없어. 그걸 인정할게. 대신 더 편하게 관리해 줄게" 라는 느낌이다. 그래서 실제로도 간편하게 만들어 주는 함수들도 굉장히 많기도 하고 save 같은 것들도 명시적으로 수행해야 한다.

 

개인적으로 나는 후자 쪽의 사상이 더 마음에 든다. 아직 로직과 영속 계층을 완전히 분리했을 때 얻는 이점에 대해 잘 공감하지 못하기도 하고, 그걸 신경 쓰기에는 할 것들이 너무 많다고 생각하기 때문이다.

 

마치면서

짧은 시간 Django를 찍먹하면서 느꼈던 점들을 적어보았다. 사실 3주란 시간이 긴 시간도 아니거니와, 공부만 한 것도 아니기 때문에 엄청난 것들은 느끼지 못했기도 하고, 내가 잘 모르는 것들이라 위에서 말한 내 생각들이 틀렸을 확률도 많이 높다.

 

일단은 첫인상 정도로 생각하고 좀더 잘 다루게 되면 다시 한 번 다뤄봐야겠다

 

참조

- 핸즈 온 장고: https://product.kyobobook.co.kr/detail/S000202404727

- Django ORM (QuerySet)구조와 원리 그리고 최적화전략 - 김성렬 - PyCon Korea 2020 : https://www.youtube.com/watch?v=EZgLfDrUlrk

- Django에서는 QuerySet이 당신을 만듭니다 (1) https://techblog.yogiyo.co.kr/django-queryset-1-14b0cc715eb7

 

지금 보니 참조한 자료를 만드신 분이 모두 같은 분이네요... 리스펙합니다

그 다음 목표

- 쿼리셋 잘 다루기 (Q 함수 등)

- SQL 잘 다루기

- 물리적 조인 방법에 따라 어떤 장단점 있는지 파악하기 (with index)

- group by 원리 이해하기 (with index)

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.