2025.8.29: 장고 ORM 요리책 (2)

jiyongg·2025년 8월 29일

TIL: Today I Learned

목록 보기
30/30

저번에 이 포스트에서 장고 ORM 요리책을 꼭 끝까지 읽어보겠다.. 라고 썼었다. 그 이후 해커톤 준비로 거의 못 읽어보고 있었고, 오늘 오랜만에 이 책을 읽게 되었다.

사실 앞으로 스프링 공부 비중을 늘릴 생각이라, 앞으로 장고를 얼마나 자주 쓸지는 모르겠다. 그래도 ORM이라는 관점에서 이 책을 바라보면 스프링에서 ORM을 공부할 때에도 적용할 수 있는 부분이 있지 않을까 생각하여 이 책은 끝까지 읽어볼 생각이다. 저번 포스트에서 적었듯, 순서는 5부터 시작한다. 이 사이트 기준으로 '정보를 조회하고 필요한 항목을 선별하는 방법' 챕터의 7번부터 11번까지에 해당하는 내용이다. 주제가 비슷한 내용들은 묶어서 정리했기 때문에, 실제 책의 번호와는 다르다.

1편에서는 DRF 튜토리얼을 바탕으로 예제를 구성했지만, 이번에는 책의 예제 코드를 좀 많이 사용할 것이다. 내 컴퓨터에 있는 DRF 튜토리얼 소스 코드의 경우 모델이 하나밖에 없기도 하고, 데이터도 다양하지 않다. 그래서, 이번 편에서는 9번만 DRF 튜토리얼 코드를 기반으로 하고, 다른 번호는 책의 예제를 사용하겠다.

📖 다른 편 보기

5. 📚 서브쿼리

Django에서 django.db.models 모듈에 있는 Subquery 객체를 이용하여 서브쿼리를 구성할 수 있다.

그리고 서브쿼리에서 서브쿼리문 밖의 테이블을 참조해야 한다면, OuterRef를 이용
할 수 있다.

예시를 살펴보자. Django 공식 문서에 있는 코드이다.

>>> from django.db.models import OuterRef, Subquery
>>> newest = Comment.objects.filter(post=OuterRef("pk")).order_by("-created_at")
>>> Post.objects.annotate(newest_commenter_email=Subquery(newest.values("email")[:1]))

이 코드는 각 Post 별로 가장 최신 댓글(Comment)의 작성자의 이메일을 newest_commenter_email이라는 이름으로 annotate하는 코드이다.

위 코드로 만들어지는 QuerySet의 쿼리문은 아래와 같다. 위 코드만으로는 조금 헷갈릴 수 있는데, SQL문을 보면 감이 바로 올 것이다.

SELECT "post"."id" (
    SELECT U0."email"
    FROM "comment" U0
    WHERE U0."post_id" = ("post"."id")
    ORDER BY U0."created_at" DESC LIMIT 1
) AS "newest_commenter_email" FROM "post"

스칼라 서브쿼리가 사용되었다.

  • 먼저, comment 테이블에서 post_id가 서브쿼리문 밖의 post 테이블의 id와 일치하는 데이터들의 email을 가져온다.
  • 해당 데이터들을 생성일 기준으로 내림차순 정렬 후 1개만을 선택한다. 즉, 가장 최신 댓글의 email만을 선택한다.

참고로, OuterRef를 사용한 QuerySet을 서브쿼리로 사용하지 않고 단독으로 사용할 시 ValueError: This queryset contains a reference to an outer query and may only be used in a subquery.가 발생한다.

그럼 이제 요리책의 예제를 살펴보자.

예제: Category 모델의 각 행 별로 가장 선한 Hero 행 구하기

모델은 다음과 같다.

class Category(models.Model):
    name = models.CharField(max_length=100)

class Hero(models.Model):
    # ...
    name = models.CharField(max_length=100)
    category = models.ForeignKey(Category, on_delete=models.CASCADE)
    
    benevolence_factor = models.PositiveSmallIntegerField(
        help_text="How benevolent this hero is?",
        default=50
    )

여기에서 각 Category 별로 가장 선한 Hero를 구해보자.

hero_qs = Hero.objects.filter(
    category=OuterRef("pk")
).order_by("-benevolence_factor")
Category.objects.all().annotate(
    most_benevolent_hero=Subquery(
        hero_qs.values('name')[:1]
    )
)
  • 먼저 OuterRef를 이용해 서브쿼리로 사용할 QuerySet hero_qs를 만든다.
    • 이 서브쿼리의 필터링 조건에서 외부의 Category의 pk, 즉, id를 참조하게 된다.
    • 또한, benevolence_factor를 기준으로 내림차순 정렬하여 가장 선한 영웅이 첫 번째로 나타나게 한다.
  • QuerySet의 values 메소드와 슬라이싱을 사용해서 가장 선한 영웅의 이름을 most_benevolent_hero라는 이름으로 Category의 QuerySet에 annotate한다.

annotate된 Category의 QuerySet의 쿼리문은 아래와 같다.

SELECT "entities_category"."id",
       "entities_category"."name",

    (SELECT U0."name"
       FROM "entities_hero" U0
      WHERE U0."category_id" = ("entities_category"."id")
      ORDER BY U0."benevolence_factor" DESC
      LIMIT 1) AS "most_benevolent_hero"
  FROM "entities_category"

annotate 메소드

QuerySet에는 annotate라는 메소드가 있다.

네이버 영어사전에 따르면, annotate는 주석을 달다라는 의미를 가지고 있다. annotate 메소드는 QuerySet의 각 객체에 주석을 다는 메소드인 것이다.

annotate의 인자

annotate의 인자로는 다음의 값들이 올 수 있다. 참고로 모두 django.db.models에서 import하면 된다.

  • Value: 값을 의미한다.
    • 표현식의 가장 작은 요소를 나타내며, F('field') + 1과 같은 표현식에서 1은 자동으로 Value로 감싸진다.
  • F: 모델 필드, annotation된 컬럼의 값을 나타낸다.
  • Q: SQL문에서 WHERE절에 해당하는 객체로, 이전 포스트에서 설명한 적이 있다. 이 부분을 참고하면 될 것 같다.
  • QuerySet 내의 객체들에 대해 계산된 집계 표현 (Aggregate Expression)의 결과값

annotate의 alias

annotate의 alias (위에서 most_benevolent_hero에 해당)는 키워드 인자의 key이다. 하지만, 키워드 인자가 아닐 경우 참조하는 모델 필드명과 집계 함수의 이름을 바탕으로 자동으로 생성한다. 모델필드명__집계함수명으로, lower된 문자열이다.

참조하는 필드가 여러 개인 집계 표현의 경우, 반드시 키워드 인자로 전달해야 한다.

집계 표현 (Aggregate Expression)

django.db.models에서 import하여 사용하며, Avg, Count, Max 등이 이에 해당한다. 이들은 공통 매개변수를 가지는데, 이는 아래와 같다.

  • expressions: 모델의 필드나 Query Expression이다.
  • output_field: 반환값의 모델 필드 타입이다.
  • filter: 선택적인 매개변수로 Q 객체를 받는다.
  • default: QuerySet이 비어있을 때의 기본값이다.

6. 🟰 필드의 값을 서로 비교하여 항목 선택

F 객체를 사용하면 필드의 값을 서로 비교할 수 있다. 예제로 살펴보자.

예제 1: 이름과 성이 동일한 사용자 구하기

먼저 create_user 메소드를 이용해 유저 인스턴스 2개를 만든다.

>>> User.objects.create_user(email="shabda@example.com", username="shabda", first_name="Shabda", last_name="Raaj")
>>> User.objects.create_user(email="guido@example.com", username="Guido", first_name="Gudio", last_name="Guido")
>>> User.objects.filter(last_name=F("first_name"))
<QuerySet [<User: Guido>]>
  • filter에서 키워드 인자의 값에 다른 필드의 값을 참조하기 위해서 F 객체를 이용했다.

예제 2: 이름의 첫 글자와 성의 첫 글자가 동일한 사용자 구하기

앞선 유저 모델에서 유저 인스턴스를 1개 더 만든다.

>>> User.objects.create_user(email="guido@example.com", username="Tim", first_name="Tim", last_name="Teters")
>>> User.objects.annotate(first=Substr("first_name", 1, 1), last=Substr("last_name", 1, 1)).filter(first=F("last"))
<QuerySet [<User: Guido>, <User: Tim>]>
  • Substrpos(1)부터 시작해서 length(1) 길이의 문자열을 반환한다. (Substring)
    • lengthNone이라면 문자열은 pos부터 시작하여 문자열의 마지막 글자까지 추출한 것과 같다.
  • 앞서 F 객체 설명에서 모델 필드, annotation된 컬럼의 값을 나타낸다고 했다. 이 예제가 바로 annotation된 컬럼의 값을 F 객체로 참조하는 예시에 해당한다.

7. 💾 FileField에 파일이 들어있지 않은 행 구하기

FileField, ImageField와 같이 파일에 관련된 필드들은 MEDIA_ROOT에 대한 파일의 상대 경로를 저장한다.

DB에서는 CharField와 동일한 방식으로 저장된다. 다시 말해, 상대 경로가 문자열로 저장된다.

따라서, 아래와 같은 방법으로 FileField 등의 필드에 파일이 들어있지 않은 행을 구할 수 있다.

no_files_objects = MyModel.objects.filter(
    Q(file='')|Q(file=None)
)

8. 🔗 두 모델 간의 JOIN

두 모델 간의 JOIN을 하는 방법은 두 가지로 나뉜다.

select_related 메소드는 연관된 모델 필드를 미리 DB에서 가져온 QuerySet을 반환하는 메소드이다.

>>> a1 = Article.objects.select_related('reporter').get(id=5)
>>> a1.reporter

이 코드에서 select_related에 의해 연관된 모델이 미리 DB에서 조회되므로, a1.reporter는 추가적인 조회를 필요로 하지 않는다.

plain lookup

select_related의 설명이 무슨 소리인가 싶을텐데, plain lookup에 대한 설명을 보면 감이 대충 올 것이다. 여기서 말하는 plain lookup은 select_related 메소드를 쓰는 대신 .을 이용하는 방법이다. plain lookup은 두 번의 DB 조회가 발생한다.

>>> a2 = Article.objects.get(id=5)
>>> a2.reporter

이 코드에서 DB에서 조회된 것은 현재 모델만이다. 따라서 a2.reporter는 연관 모델에 대한 추가적인 조회를 필요로 하고, 이렇게 되면 총 두 번의 DB 조회가 발생하게 된다.

따라서, 연관 모델의 데이터를 조회하는 일이 잦다면 select_related가 유리하다고 볼 수 있겠다.

9. 🔢 QuerySet 인덱싱

QuerySet에서 파이썬의 인덱싱 연산을 사용할 수 있다. 예제로 살펴 보자.

⚠️ 주의: 음수 인덱싱은 사용할 수 없다.

예제: 생성일이 두 번째로 느린 Snippet 인스턴스

>>> Snippet.objects.order_by('-created')[1]

여기서 이 코드는 아래와 그 의미가 같다.

>>> Snippet.objects.order_by('-created')[1:2]

여기에서 알 수 있듯, QuerySet은 슬라이싱도 가능하다. 슬라이싱한 QuerySet의 쿼리를 출력해 보면, 아래와 같이 출력된다.

>>> print(Snippet.objects.order_by('-created')[1:2].query)
SELECT "snippets_snippet"."id", "snippets_snippet"."created", "snippets_snippet"."title", "snippets_snippet"."code", "snippets_snippet"."linenos", "snippets_snippet"."language", "snippets_snippet"."style", "snippets_snippet"."owner_id", "snippets_snippet"."highlighted" FROM "snippets_snippet" ORDER BY "snippets_snippet"."created" DESC LIMIT 1 OFFSET 1

출력 결과를 보면 알 수 있듯이, 슬라이싱은 LIMITOFFSET에 해당한다.

LIMIT은 보여줄 행의 개수를 의미하고, OFFSET은 n+1번째 행부터 보여주라는 의미이면서 n개의 행을 건너뛸 것임을 의미한다. OFFSET은 0부터 시작한다. 예를 들어, OFFSET 2는 2개의 행을 건너뛰고 3번째 행부터 보여주라는 의미인 것이다.

위 SQL문은 LIMIT의 값은 시작 인덱스와 끝 인덱스를 뺀 값에 해당하고, OFFSET의 값은 시작 인덱스와 같다.

이 사실을 바탕으로 인덱싱의 경우를 생각해 보면, LIMIT은 무조건 1이고, OFFSET은 인덱스에 해당하게 된다는 사실을 알 수 있다.

참고: firstlast

QuerySet에는 firstlast라는 메소드가 있어 첫 번째 항목이나 마지막 항목을 구할 때 인덱스 대신 이 메소드들을 사용할 수 있다.

>>> Snippet.objects.order_by('-created').first()
>>> Snippet.objects.order_by('-created').last()

위 코드는 각각 가장 최근에 생성된 Snippet, 가장 먼저 생성된 Snippet 인스턴스를 반환한다.

🔚 결론

원래 공부할 때에는 13번까지 봤었다. 그런데, 12~13번 부분을 쓰다 보니 아직 Count 등의 집계 함수와 Field Lookup in에 대한 개념을 좀 더 정리해야겠다는 생각이 들었다. 그래서 12번부터는 다음 파트에서 시작하고자 한다.

어쨌든 이번에도 요리책을 읽으며 좋은 팁과 몰랐던 개념을 알게 되었다. 한 가지 예를 들자면, 해커톤 준비를 할 때 파트너가 소스 코드에 select_related 메소드를 썼었다. 그래서 이 메소드의 존재는 알았지만 쓰는 이유에 대해서는 잘 모르고 있었다. 하지만 이번에 두 모델의 JOIN 부분에 select_related가 나와 select_related를 찾아보게 되었다. 그리고 select_related가 성능 상 이점을 제공한다는 점을 알게 되었다.

이 TIL이 2학기 개강 전의 마지막 TIL이 될 것 같다. 주말에는 TIL을 쓰지 않기 때문이다. 학기 중에 TIL을 지금처럼 꾸준히 쓸 수 있을진 모르겠지만, 일단 가능한 만큼은 열심히 써 보고자 한다.

profile
그냥 쓰고 싶은 것 쓰는 개발(?) 블로그

0개의 댓글