Django ORM 심층 — QuerySet 지연 실행, Manager, Active Record

Django ORM의 핵심인 QuerySet 지연 실행(Lazy Evaluation)이 어떻게 동작하는지, Manager 패턴으로 테이블 레벨 로직을 캡슐화하는 방법, N+1 문제와 해결법을 설명한다.

Seobway · · 14분

Django ORM이 특별한 이유

# 이게 전부다
articles = Article.objects.filter(is_published=True).order_by("-created_at")[:10]

SQL 한 줄 안 쓰고 위와 같이 데이터를 조회할 수 있다.
하지만 이 코드가 언제 SQL을 실행하는지 모르면 심각한 성능 문제를 만든다.

Active Record 패턴

Django ORM은 Active Record 패턴을 구현한다.[1]

모델 클래스 = DB 테이블, 인스턴스 = 행(Row). 데이터와 DB 조작 로직이 같은 객체에 존재한다.

# 모델 클래스 = 테이블
class Article(models.Model):
    title = models.CharField(max_length=200)
    body = models.TextField()

# 인스턴스 = 행
article = Article(title="제목", body="내용")
article.save()           # INSERT INTO articles ...
article.title = "새 제목"
article.save()           # UPDATE articles SET title=...
article.delete()         # DELETE FROM articles WHERE id=...

SQLAlchemy의 Data Mapper 패턴과 대비된다.
Data Mapper는 모델과 DB 매핑 로직이 분리돼 있어 더 유연하지만 복잡하다.
Django의 Active Record는 단순하고 직관적이지만 대규모 도메인 로직에서 모델이 비대해질 수 있다.

QuerySet — 지연 실행(Lazy Evaluation)

Django ORM의 핵심 설계 결정이다.[2]

# 이 시점에서 SQL이 실행되지 않는다
qs = Article.objects.filter(is_published=True)
qs = qs.filter(author__username="seobway")
qs = qs.order_by("-created_at")
qs = qs.select_related("author")

아직 DB에 아무것도 안 했다. QuerySet은 조건을 기억하고 있을 뿐이다.

%% desc: QuerySet은 평가 시점까지 SQL을 실행하지 않는 지연 실행 메커니즘
flowchart TD
  Q1["filter(is_published=True)\n→ QuerySet 객체 생성, SQL X"]
  Q2[".filter(author=user)\n→ 조건 추가, SQL X"]
  Q3[".order_by('-created_at')\n→ 정렬 추가, SQL X"]
  Q4[".select_related('author')\n→ JOIN 계획 추가, SQL X"]

  EVAL{"평가 시점?"}

  E1["for a in qs ← 반복"]
  E2["list(qs) ← 강제 변환"]
  E3["qs[0:5] ← 슬라이싱"]
  E4["qs.get(pk=1) ← 단건 조회"]
  E5["qs.count() ← 개수"]
  E6["qs.exists() ← 존재 여부"]

  SQL["SELECT ... FROM articles\nJOIN auth_user ...\nWHERE is_published=1\nAND author_id=...\nORDER BY created_at DESC"]

  Q1 --> Q2 --> Q3 --> Q4 --> EVAL
  EVAL --> E1 & E2 & E3 & E4 & E5 & E6 --> SQL

왜 지연 실행인가

def get_articles(is_published=True):
    qs = Article.objects.filter(is_published=is_published)
    return qs

def get_recent(qs, limit=10):
    return qs.order_by("-created_at")[:limit]

def get_by_author(qs, username):
    return qs.filter(author__username=username)

# 조합해서 사용 — SQL은 최종 평가 시 단 한 번만 실행
articles = get_by_author(get_recent(get_articles()), "seobway")
for a in articles:   # ← 이 시점에 SELECT 실행
    print(a.title)

QuerySet을 함수 사이에 자유롭게 전달하며 조건을 합성할 수 있다.
SQL은 마지막에 한 번만 실행된다.

QuerySet 캐싱

한 번 평가된 QuerySet은 결과를 캐시한다.

qs = Article.objects.filter(is_published=True)

# 첫 번째 반복 — SQL 실행
for a in qs:
    print(a.title)

# 두 번째 반복 — 캐시 사용, SQL 실행 안 됨
for a in qs:
    print(a.body)

주의: 슬라이싱은 캐시를 만들지 않는다.

qs = Article.objects.all()
print(qs[0])  # SQL 실행
print(qs[0])  # 다시 SQL 실행! (캐시 없음)

# 평가 후 캐시 사용
articles = list(qs)  # SQL 실행
print(articles[0])   # 캐시
print(articles[0])   # 캐시

Manager — 테이블 레벨 작업의 진입점

Model.objects가 기본 Manager다.[3]

Manager는 인스턴스가 아닌 클래스에서만 접근 가능하다.

Article.objects.all()      # ✅ 클래스에서 접근
article = Article()
article.objects.all()      # ❌ AttributeError — 인스턴스에서 접근 불가

이 제약은 의도된 설계다. "테이블 전체를 다루는 작업"과 "개별 행을 다루는 작업"을 구분한다.

커스텀 Manager

반복되는 필터 로직을 Manager로 캡슐화한다.

class PublishedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(is_published=True)

class RecentManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().order_by("-created_at")

class Article(models.Model):
    title = models.CharField(max_length=200)
    is_published = models.BooleanField(default=False)

    objects = models.Manager()              # 기본 Manager (전체)
    published = PublishedManager()          # 게시된 글만
    recent = RecentManager()                # 최신순 정렬
# View에서 반복 없이 사용
Article.published.all()                    # 게시된 글만
Article.published.filter(author=user)      # 게시된 + 특정 작성자
Article.recent.filter(is_published=True)   # 최신순 + 게시된

N+1 문제와 해결

Django ORM의 가장 흔한 성능 함정이다.

# N+1 문제 발생 — 절대 이렇게 하지 말 것
articles = Article.objects.all()  # SQL 1번

for article in articles:
    print(article.author.username)  # 매번 SQL 1번 → N번 추가 실행!
    # 100개 글이면 101번 SQL 실행
%% desc: N+1 문제 — articles 조회 1번 + author 조회 N번 vs select_related로 JOIN 1번
flowchart TD
  subgraph BAD["❌ N+1 문제 (100개 글)"]
    B1["SELECT * FROM articles\n(1번)"]
    B2["SELECT * FROM auth_user WHERE id=1\n(1번)"]
    B3["SELECT * FROM auth_user WHERE id=2\n(1번)"]
    BN["... 총 101번 SQL"]
    B1 --> B2 --> B3 --> BN
  end

  subgraph GOOD["✅ select_related (JOIN)"]
    G1["SELECT articles.*, auth_user.*\nFROM articles\nJOIN auth_user ON articles.author_id = auth_user.id\n(1번으로 끝)"]
  end

해결: select_related vs prefetch_related

# select_related — ForeignKey, OneToOne (SQL JOIN)
articles = Article.objects.select_related("author").all()
# SELECT articles.*, auth_user.* FROM articles JOIN auth_user ...

# prefetch_related — ManyToMany, reverse FK (별도 쿼리 후 Python에서 연결)
articles = Article.objects.prefetch_related("tags").all()
# SELECT * FROM articles
# SELECT * FROM tags WHERE article_id IN (1, 2, 3, ...)

# 중첩 관계
articles = Article.objects.select_related("author__profile").prefetch_related("comments__author")
select_related prefetch_related
관계 타입 FK, OneToOne ManyToMany, reverse FK
SQL 방식 JOIN (1개 쿼리) 별도 쿼리 후 Python 조합
결과 SQL 수 감소 SQL 수 감소

주요 QuerySet 메서드

# 조회
Article.objects.all()
Article.objects.filter(is_published=True, author=user)
Article.objects.exclude(is_published=False)
Article.objects.get(pk=1)           # 1개, 없거나 2개 이상이면 예외
Article.objects.first()             # 첫 번째 또는 None
Article.objects.last()

# 집계
Article.objects.count()
Article.objects.exists()            # bool, count()보다 빠름
Article.objects.aggregate(Count("id"), Avg("view_count"))

# 정렬 / 슬라이싱
Article.objects.order_by("-created_at")[:10]   # LIMIT 10

# 수정 / 삭제
Article.objects.filter(author=user).update(is_published=True)  # 한 번의 UPDATE
Article.objects.filter(created_at__lt=cutoff).delete()         # 한 번의 DELETE

# 필드 조회
Article.objects.values("title", "author__username")   # dict QuerySet
Article.objects.values_list("id", "title")            # tuple QuerySet
Article.objects.only("title", "created_at")           # 특정 필드만 로드
Article.objects.defer("body")                         # 특정 필드 지연 로드

Raw SQL 탈출구

Django ORM으로 표현하기 어려운 복잡한 쿼리는 Raw SQL로 처리한다.

from django.db import connection

# QuerySet.raw() — 모델 인스턴스 반환
articles = Article.objects.raw("SELECT * FROM articles WHERE id = %s", [article_id])

# connection.execute() — 완전한 Raw SQL
with connection.cursor() as cursor:
    cursor.execute("""
        SELECT a.title, COUNT(c.id) as comment_count
        FROM articles a
        LEFT JOIN comments c ON a.id = c.article_id
        GROUP BY a.id
        ORDER BY comment_count DESC
        LIMIT %s
    """, [10])
    rows = cursor.fetchall()

Raw SQL을 쓸 때는 반드시 파라미터 바인딩을 사용해야 한다. 문자열 포맷팅으로 SQL을 조합하면 SQL Injection에 취약하다.

참고

  1. Martin Fowler, Active Record — Patterns of Enterprise Application Architecture
  2. Django Project, Making queries — Django Docs
  3. Django Project, Managers — Django Docs

관련 글