Django 웹 프레임워크를 사용하다 보면 반드시 마주치게 되는 것이 ORM(Object-Relational Mapping)입니다. Django ORM은 데이터베이스 테이블을 Python 클래스로, 레코드를 객체로, 필드를 속성으로 매핑하여 SQL 쿼리 대신 직관적인 Python 코드로 데이터베이스를 다룰 수 있게 해줍니다.
저는 Django 프로젝트에서 ORM을 효과적으로 활용하는 방법을 찾다가 많은 시행착오를 겪었습니다. 이 글에서는 Django ORM과 QuerySet을 기본부터 심화까지 단계별로 살펴보고, 실제 프로젝트에서 바로 활용할 수 있는 실용적인 예제와 최적화 방법을 공유하고자 합니다.
아래 코드는 모두 아래 Git 링크에서 확인 할 수 있어요!.
blog-code/class-101 at main · jack7141/blog-code
https://dentuniverse.tistory.com/에서 제공하는 예제 Code. Contribute to jack7141/blog-code development by creating an account on GitHub.
github.com
실행 명령어는 아래와 같아요! shell < 실행하고자하는 queryset 파일!
python manage.py shell < queryset_retrieve.py
Django ORM이란?
ORM(Object-Relational Mapping)은 객체 지향 프로그래밍 언어와 관계형 데이터베이스 사이의 데이터를 변환하는 기술입니다. Django의 ORM은 Python 클래스(Models)를 통해 데이터베이스 테이블을 정의하고, Python 객체를 통해 데이터베이스 레코드를 조작할 수 있게 해줍니다.
QuerySet 기본: 데이터 조회의 시작
Django ORM에서 가장 중요한 개념은 QuerySet입니다. QuerySet은 데이터베이스에서 데이터를 조회하는 객체로, 필터링, 정렬, 그룹화 등 다양한 작업을 지원합니다.
가장 기본적인 QuerySet부터 시작해서 점차 심화되는 Django ORM에 대해서 배워보도록 할게요!
아래는 기본적인 ORM의 CheatSheet입니다.
Queryset API Map
Queryset을 반환하는 명령어
all() | 해당 모델 테이블의 모든 데이터 조회 | Article.objects.all() |
filter() | 특정 조건에 맞는 모든 데이터 조회 | Article.objects.filter(title_contains=’Django’) |
exclude() | 특정 조건을 제외한 모든 데이터 조회 | Article.objects.exclude(title_contains=’Django’) |
order_by() | 특정 조건으로 정렬된 데이터 조회(-를 붙이면 오름차순으로 정렬) | Article.objects.order_by(‘-created_at’) |
values() | 쿼리셋의 값을 딕셔너리 형태로 반환한다. | Article.objects.filter(title_contains=’Django’).values() |
values_list() | 쿼리셋의 값을 튜플 형태로 반환한다. | Article.objects.filter(title_contains=’Django’).values_list() |
단일 데이터 객체를 반환하는 명령어 -> 응답값이 복수로 돌아올시 Error가 발생합니다.
get() | 조건에 맞는 하나의 데이터 조회 | Article.objects.get(id=1) |
create() | 하나의 데이터를 생성하고 해당 모델 데이터를 반환 | Article.objects.create(title=’Django’, context=’Good’) |
get_or_create() | 조건에 맞는 데이터를 조회하고 해당 데이터가 없으면 생성 | Article.objects.get_or_create(title=’Django’, context=’Good’) |
update_or_create() | 조건에 맞는 데이터를 수정하고 해당 데이터가 없으면 생성 | Article.objects.update_or_create( title="Django ORM 가이드", # 조회 조건 defaults={ # 생성 또는 업데이트할 필드 'content': '업데이트된 내용입니다.', 'is_published': True } ) |
latest() | 주어진 필드 기준으로 가장 최신 데이터 반환 | Article.objects.latest(‘-created_at’) |
first() | 쿼리셋의 가장 첫번째 모델 데이터를 반환, 정렬하지 않은 쿼리셋이면 pk를 기준으로 정렬 후 반환, 만약 없으면 None 반환 | Article.objects.order_by(‘-created_at’).first() |
last() | 쿼리셋 중 가장 마지막 데이터 반환, 없으면 None. | Article.objects.order_by(‘-created_at’).last() |
get_object_or_404() | 조건에 맞는 하나의 데이터 조회, 없으면 404 Error. 이 명령어를 사용하면, 예외처리를 할 필요가 사라지므로 View에서 사용하기 유용하다. | get_object_or_404(Article, id=1) |
aggregate() | 집계 Queryset -> 필드 전체의 합, 평균, 개수 등을 계산할 때 사용한다. | Order.objects.aggregate(total_price=Sum(‘price’)) |
필드 조건 옵션(Field Lookups)
__contains | 대소문자를 구분하여 문자열 포함 여부 확인 | Post.objects.filter(email__contains=’Test’) |
__icontains | 대소문자를 구분하지 않고 문자열 포함 여부 확인 | Post.objects.filter(email__contains=’user’) |
__in | 반복 가능한 객체 안에서의 포함 여부를 확인 | Post.objects.filter(id__in=[1,2,3]) |
__gt | 초과 여부 확인(greater than) | Post.objects.filter(id__gt=1) |
__gte | 이상 여부 확인(greater than equal) | Post.objects.filter(id__gte=1) |
__lt | 미만 여부 확인(little than) | Post.objects.filter(id__lt=4) |
__lte | 이하 여부 확인(little than equal) | Post.objects.filter(id__lte=4) |
__startswith | 대소문자를 구분하여 문자열로 시작하는지 여부 확인 | Post.objects.filter(email__startswith=’test’) |
__istartwith | 대소문자를 구분하지 않고 문자열로 시작하는지 여부 확인 | Post.objects.filter(email__istartwith=’test’) |
__endswith | 대소문자를 구분하여 문자열로 끝나는지 여부 확인 | Post.objects.filter(email__endswith=’Com’) |
__iendswith | 대소문자를 구분하지 않고 문자열로 끝나는지 여부 확인 | Post.objects.filter(email__endswith=’com’) |
__range | range로 제시하는 범위 내에 포함되는지 확인 | Post.objects.filter(created_at__range=(datetime.date(2022,1,1), datetime.date(2023,1,1)) |
__isnull | 해당 필드가 Null인지 여부 확인 | Post.objects.filter(emil__isnull=True) |
기본 조회 메서드
위의 CheatSheat를 가지고 한번 예시 코드를 한번 작성해볼게요!
# queryset_retrieve.py
# 모든 게시글 조회
from articles.models import Article
all_articles = Article.objects.all()
# 조건으로 필터링
published_articles = Article.objects.filter(is_published=True)
# 특정 ID로 단일 객체 가져오기
try:
first_article = Article.objects.get(id='ada7a1826ce74be3b3585cf3ea13bb00')
print(f"ID가 1인 게시글 제목: {first_article.title}")
except Article.DoesNotExist:
print("ID가 1인 게시글이 없습니다.")
all(), filter(), get() 메서드는 QuerySet을 다루는 가장 기본적인 메서드입니다. all()은 모든 레코드를, filter()는 조건에 맞는 여러 레코드를, get()은 조건에 맞는 단일 레코드를 가져옵니다. get()은 결과가 없거나 여러 개인 경우 예외를 발생시키므로 주의해야 합니다.
다양한 lookup 타입 활용
Django ORM은 다양한 형태의 조건식(lookup)을 지원합니다
# queryset_lookup.py
# 다양한 lookup 타입 활용
# 대소문자 구분 없이 포함된 문자열 검색 (icontains)
import datetime
from django.utils import timezone
from articles.models import Article
# 대소문자 구분 없이 포함된 문자열 검색 (icontains)
django_articles = Article.objects.filter(title__icontains='django')
print(f"'django'가 제목에 포함된 게시글 수: {django_articles.count()}")
# 특정 문자열로 시작하는 항목 찾기 (startswith)
python_articles = Article.objects.filter(title__startswith='Python')
print(f"'Python'으로 시작하는 게시글 수: {python_articles.count()}")
# 특정 문자열로 끝나는 항목 찾기 (endswith)
guide_articles = Article.objects.filter(title__endswith='가이드')
print(f"'가이드'로 끝나는 게시글 수: {guide_articles.count()}")
# 특정 값들 중 하나와 일치하는 항목 찾기 (in)
specific_articles = Article.objects.filter(id__in=[1, 3, 5])
print(f"ID가 1, 3, 5 중 하나인 게시글 수: {specific_articles.count()}")
# 범위 검색 (range)
one_week_ago = timezone.now() - datetime.timedelta(days=7)
one_month_ago = timezone.now() - datetime.timedelta(days=30)
recent_articles = Article.objects.filter(created__range=(one_month_ago, one_week_ago))
print(f"1주일~1달 사이에 작성된 게시글 수: {recent_articles.count()}")
# 특정 날짜보다 이후에 생성된 게시글 (gt: greater than)
newer_articles = Article.objects.filter(created__gt=one_week_ago)
print(f"일주일 이내 작성된 게시글 수: {newer_articles.count()}")
# 특정 날짜보다 이전에 생성된 게시글 (lt: less than)
older_articles = Article.objects.filter(created__lt=one_month_ago)
print(f"한달보다 오래된 게시글 수: {older_articles.count()}")
lookup을 활용하면 SQL의 WHERE 절과 같은 복잡한 조건을 Python 코드로 쉽게 표현할 수 있습니다.
정렬 (order_by)
데이터 정렬도 간단히 할 수 있습니다
# queryset_order.py
from articles.models import Article
# 최신순 정렬
recent_articles = Article.objects.order_by('-created')[:3]
print("최근에 작성된 게시글 (최신순):")
for article in recent_articles:
print(f"- {article.title} ({article.created.strftime('%Y-%m-%d')})")
# 오래된순 정렬
oldest_articles = Article.objects.order_by('created')[:3]
print("\n가장 오래된 게시글:")
for article in oldest_articles:
print(f"- {article.title} ({article.created.strftime('%Y-%m-%d')})")
# 제목 알파벳순 정렬
title_sorted = Article.objects.order_by('title')[:3]
print("\n제목 알파벳순 정렬:")
for article in title_sorted:
print(f"- {article.title}")
# 여러 필드로 정렬 (작성자 이름순, 같은 작성자면 최신순)
author_date_sorted = Article.objects.order_by('author__username', '-created')[:5]
print("\n작성자 이름순, 같은 작성자면 최신순:")
for article in author_date_sorted:
print(f"- {article.title} (작성자: {article.author.username}, 날짜: {article.created.strftime('%Y-%m-%d')})")
기본적인 QuerySet을 사용하는 방식은 꽤나 간단하죠? 이제 조금 더 복잡한 QuerySet을 배워볼게요. 이를테면 특정 데이터를 제외하고 데이터를 가져오는 방법같은거 말이죠?
이제부턴 약간 중급? 과정으로 넘어가도 될거같네요!ㅎㅎ
QuerySet 중급: 더 복잡한 조회 방법
조건 제외하기 (exclude)
# queryset_exclude.py
from articles.models import Article
# 미발행 게시글 찾기
unpublished = Article.objects.exclude(is_published=True)
print(f"발행되지 않은 게시글 수: {unpublished.count()}")
# 특정 단어가 제목에 포함되지 않는 게시글
non_django = Article.objects.exclude(title__icontains='django')
print(f"제목에 'django'가 포함되지 않은 게시글 수: {non_django.count()}")
# 특정 작성자의 게시글 제외
non_admin = Article.objects.exclude(author__username='admin')
print(f"admin이 작성하지 않은 게시글 수: {non_admin.count()}")
# 여러 조건 제외하기
filtered_articles = Article.objects.exclude(
title__icontains='django'
).exclude(
author__username='admin'
)
print(f"제목에 'django'가 없고, admin이 작성하지 않은 게시글 수: {filtered_articles.count()}")
중복 제거 (distinct)
# 게시글을 작성한 고유 사용자 ID 목록
author_ids = Article.objects.values_list('author', flat=True).distinct()
print(f"게시글을 작성한 고유 사용자 수: {len(author_ids)}")
같은 값이 여러 번 나타나는 경우 중복을 제거하고 싶을 때 distinct()를 사용합니다
values와 values_list - 필요한 필드만 가져오기
전체 객체가 아닌 특정 필드만 가져오고 싶을 때 유용합니다
# queryset_values_values_list.py
from articles.models import Article
# values() - 딕셔너리 형태로 반환
articles_dict = Article.objects.values('id', 'title', 'author__username')
print(articles_dict)
# values_list() - 튜플 형태로 반환
articles_tuple = Article.objects.values_list('id', 'title')
print(articles_tuple)
# flat=True 옵션으로 단일 필드만 리스트로 받기
article_titles = Article.objects.values_list('title', flat=True)
print(article_titles)
근데 왜 굳이 filter 혹은 다른 get queryset으로도 데이터는 가지고 올 수 있는데 values, values_list를 써야할까요?
이유는 아래와 같습니다.
values()와 values_list()는 필요한 필드만 선택적으로 가져와 메모리 사용량을 줄이고 성능을 향상시킬 수 있습니다. 때문에 ORM 최적화 실제 프로젝트에서 QuerySet을 최적화할때 튜닝에서 필수적이예요.
- 사실 쿼리 튜닝의 기본은 SELECT * 를 지양하는 것이다. 필요한 DB 컬럼만 가져오는 것이 DB 부하를 줄이는 가장 빠르고 기본적인 방법이기 때문에 value, value_list를 적절하게 활용하는 것이 Django에서 Query 튜닝을하는 기본이 될 것이다.
체이닝 - 여러 메서드 연결하기
Django ORM의 강력한 기능 중 하나는 메서드 체이닝입니다.
체이닝 말 그대로 여러개를 엮을 수 있다는거죠! 위의 Queryset은 objects.쿼리셋! 이렇게 하나씩만 했는데 사실은 여러개를 엮을 수 있어요!
# queryset_chaining.py
import datetime
from django.utils import timezone
from articles.models import Article
one_week_ago = timezone.now() - datetime.timedelta(days=7)
# 여러 조건을 차례로 적용
chained_query = Article.objects.filter(is_published=True).filter(created__gte=one_week_ago).exclude(title__icontains='python').order_by('-created')[:3]
이렇게 QuerySet을 사용하는 방법도 배웠고! 마지막으로 QuerySet의 심화 최적화에 대해서 배워볼게요 사실 데이터를 어떻게 꺼내오든 상관은 없지만. 실제 프로젝트에선 성능이 매우 중요해요! 그렇기 때문에 심화에선 최적화 하는 방법에 대해서 배워볼게요.
QuerySet 심화: 고급 기능과 최적화
get_or_create, update_or_create - 없으면 생성하기
# queryset_get_or_create.py
from articles.models import Article, Tag
# get_or_create() - 있으면 가져오고, 없으면 생성
tag, created = Tag.objects.get_or_create(name='FastAPI')
print(f"태그 '{tag.name}'는 {'새로 생성됨' if created else '이미 존재함'}")
# queryset_update_or_create.py
from articles.models import Article, Tag
# update_or_create() - 있으면 업데이트, 없으면 생성
article, created = Article.objects.update_or_create(
id="ada7a1826ce74be3b3585cf3ea13bb00", # 조회 조건
defaults={ # 생성 또는 업데이트할 필드
'title': '업데이트된 내용입니다.',
'is_published': True
}
)
print(f"Article '{article.title}'는 {'새로 생성됨' if created else '이미 존재함'}")
get_or_create()와 update_or_create()는 데이터를 조회하고 없으면 생성하는 작업을 원자적으로 수행합니다. 이를 통해 경쟁 상태(race condition)를 방지할 수 있습니다.
only(), defer() - 특정 필드만 가져오거나 제외하기
only()는 지정한 필드만 가져오고, defer()는 지정한 필드를 제외하고 가져옵니다. 두 메서드 모두 lazy loading을 적용하여, 제외된 필드에 접근할 때 추가 쿼리가 발생합니다.
# queryset_only_defer.py
from articles.models import Article, Tag
# only() - 특정 필드만 가져오기
# 실제 SQL: SELECT id, title, created FROM articles
articles_only_title = Article.objects.only('title', 'created')
for article in articles_only_title:
print(f"특정 필드만 가져오기: article created: {article.created}, article title: {article.title}")
# defer() - 특정 필드 제외하고 가져오기
# 실제 SQL: SELECT id, title, created, author_id, ... FROM articles (content 필드 제외)
articles_defer_content = Article.objects.defer('content')
print(articles_defer_content.values())
select_related와 prefetch_related - N+1 문제 해결
⭐️⭐️⭐️⭐️ Django ORM에서 발생하는 대표적인 성능 문제인 N+1 쿼리 문제를 해결하기 위한 방법입니다.
# queryset_N_problem.py
from articles.models import Article, Tag
# select_related: 1:1 또는 N:1 관계에서 사용 (JOIN 활용)
articles = Article.objects.select_related('author')
for article in articles:
# 추가 쿼리 없이 author 정보에 접근 가능
print(article.author.username)
# prefetch_related: M:N 또는 1:N 역참조 관계에서 사용 (별도 쿼리 + Python에서 결합)
articles = Article.objects.prefetch_related('tags')
for article in articles:
# 추가 쿼리 없이 tags 정보에 접근 가능
print([tag.name for tag in article.tags.all()])
# 중첩된 관계도 처리 가능
articles = Article.objects.select_related('author').prefetch_related(
'tags', 'comments', 'comments__author'
)
print([tag.name for tag in article.tags.all()])
# 중첩된 관계도 처리 가능
articles = Article.objects.select_related('author').prefetch_related(
'tags', 'comments', 'comments__author'
)
- prefetch_related(): ManyToMany 관계나 역참조 관계에서 별도의 쿼리로 관련 객체를 미리 가져옵니다.
이 두 메서드를 적절히 활용하면 데이터베이스 쿼리 횟수를 크게 줄여 성능을 향상시킬 수 있습니다. - select_related(): ForeignKey 관계에서 JOIN을 사용하여 한 번의 쿼리로 관련 객체를 함께 가져옵니다.
아래 글은 이전에 쓴 게시글인데 확인하면 도움이 될거같아요!
2022.04.06 - [프로그래밍/Django] - [Django][Python]QuerySet N+1 prblem 해결하기
정참조! 혹은 1:1에선 select_related
역참조! 혹은 다대다에선 prefetch_related
위 두 QuerySet은 너무 많이 쓰이기 때문에 꼭 알고계셔야해요!
다음은 이제 연산작업! 예를 들어서 SNS에서 좋아요 갯수를 Count하거나 장바구니에 수량을 늘릴때? 같은 연산 작업의 최적화에 대해서 한번 배워볼게요!
F 표현식 - 필드 참조와 연산
# queryset_F.py
from django.db.models import F
import datetime
from articles.models import Article, Tag
# 조회 조건에서 F 표현식 사용
# likes가 comments보다 많은 게시글 찾기
popular_articles = Article.objects.filter(likes_count__gt=F('comments_count'))
for article in popular_articles:
print(article.title)
# 업데이트에서 F 표현식 사용
# 조회수 증가
article_count = Article.objects.filter(id=1).update(views_count=F('views_count') + 1)
for article in article_count:
print(article.views_count)
F 표현식의 장점:
- 경쟁 상태(race condition) 방지: 현재 값을 기반으로 업데이트할 때 안전
- 성능 향상: 필드 간 비교나 연산을 데이터베이스에서 직접 처리
- 메모리 효율성: Python 객체를 생성하지 않고 처리 가능
Q 객체 - 복잡한 조건 표현
Q 객체를 사용하면 OR, AND, NOT 등 복잡한 조건을 표현할 수 있습니다. 예를 들어서 Article에서 title이 Django이면서~ 발행이되었고~ 생성날짜는 2025인 경우는? 이렇게 복잡한 쿼리도 처리할 수 있어요! 즉 Q 객체를 사용하면 filter()나 exclude()로 표현하기 어려운 복잡한 조건을 명확하게 표현할 수 있습니다.
# queryset_Q.py
from django.db.models import F
import datetime
from articles.models import Article, Tag
from django.db.models import Q
# OR 조건 (title에 'Django'가 포함되거나 content에 'ORM'이 포함)
articles = Article.objects.filter(
Q(title__icontains='Django') | Q(content__icontains='ORM')
)
# AND 조건 (title에 'Django'가 포함되고 is_published가 True)
articles = Article.objects.filter(
Q(title__icontains='Django') & Q(is_published=True)
)
# NOT 조건 (author가 'admin'이 아닌 게시글)
articles = Article.objects.filter(~Q(author__username='admin'))
# 복잡한 조합
articles = Article.objects.filter(
(Q(title__icontains='Django') | Q(title__icontains='Python')) &
Q(created__year=2025) &
~Q(author__username='admin')
)
집계 및 주석 - annotate()와 aggregate()
annotate()란 새로 이름을 라벨링 한다는 뜻이예요.
아래와 같이 집계 후! 내가 원하는 이름으로 네이밍을 바꿀 수 있어요! 더 가독성이 좋은 데이터셋이 되겠죠?
# annotate() - 각 객체에 집계 필드를 추가하여 QuerySet 반환
authors_with_stats = User.objects.annotate(
articles_count=Count('article'),
total_likes=Sum('article__likes_count')
).order_by('-total_likes')[:5]
# queryset_annotate_aggreate.py
from django.db.models import Count, Sum, Avg, Max
from articles.models import Article, Tag
from django.db.models.functions import TruncMonth
from users.models import User
# aggregate() - 전체 QuerySet에 대한 집계 결과를 딕셔너리로 반환
result = Article.objects.aggregate(
total_articles=Count('id'),
avg_likes=Avg('likes_count'),
max_comments=Max('comments_count')
)
print(result)
# annotate() - 각 객체에 집계 필드를 추가하여 QuerySet 반환
authors_with_stats = User.objects.annotate(
articles_count=Count('article'),
total_likes=Sum('article__likes_count')
).order_by('-total_likes')[:5]
for author in authors_with_stats:
print(f"{author.username}: 게시글 {author.articles_count}개, 좋아요 {author.total_likes}개")
# 그룹화와 함께 사용
# 태그별 게시글 수 계산
tags_with_counts = Tag.objects.annotate(
articles_count=Count('article')
).order_by('-articles_count')
# 날짜별 집계
# 월별 게시글 작성 통계
monthly_stats = Article.objects.annotate(
month=TruncMonth('created')
).values('month').annotate(
count=Count('id')
).order_by('month')
마무리
Django ORM은 데이터베이스 작업을 Python 코드로 간편하게 수행할 수 있게 해주는 강력한 도구입니다. 기본적인 CRUD 작업부터 복잡한 조회, 관계 처리, 성능 최적화까지 다양한 기능을 제공합니다.
이 글에서는 Django ORM의 기본부터 심화 기능까지 살펴보았습니다. 실제 프로젝트에서 최적화와 필요한 QuerySet이 있다면 도움이 되길 바라면서 적어봤습니다!ㅎㅎ
주의사항
- N+1 쿼리 문제
연관 객체를 조회할 때 select_related나 prefetch_related를 사용하지 않으면 N+1 쿼리 문제가 발생할 수 있습니다. 특히 반복문 안에서 관계 필드에 접근할 때 실제 DB로 접근을 하기 때문에 성능 저하의 원인이 됩니다. 미리 Join으로 데이터를 가지고 와서 관련 객체를 다 호출한 다음 조회하는게 성능에 훨씬 유리해요!
- 대용량 데이터는 all()을 지양하자!
대용량 QuerySet은 메모리를 많이 사용합니다 큰 테이블에서 all()을 호출하면 모든 레코드가 메모리에 로드됩니다. 대량의 데이터를 처리할 때는 iterator()를 사용하거나 페이지네이션을 적용하세요.
# 메모리에 모든 객체를 로드하지 않고 하나씩 처리
for article in Article.objects.iterator():
process_article(article)
'프로그래밍 > Django' 카테고리의 다른 글
Django ORM 성능 최적화: F() 표현식과 Q() 객체 완벽 가이드 (0) | 2025.04.15 |
---|---|
Django Multi-Tenancy: SaaS 서비스에서 기업별 데이터 분리하기 (0) | 2025.04.15 |
Django에서 비즈니스 로직 분리하기: 유지보수 가능한 코드의 비결 (1) | 2025.04.13 |
🚀 Django REST Framework (DRF) - Serializer가 Response를 만들기까지 Serializer Core에 대하여 (1) | 2025.02.27 |
🐜 Locust: 부하 테스트의 끝판왕! (0) | 2025.02.27 |