Django ORM 기초: 개념과 예제
Django를 사용하다 보면 **ORM(Object-Relational Mapping)**이라는 개념을 자주 접하게 됩니다. ORM은 간단히 말해 객체 지향 프로그래밍의 객체와 관계형 데이터베이스의 테이블을 매핑해주는 기술입니다. 즉, 데이터베이스에 접근하기 위해 일일이 SQL 쿼리를 작성하는 대신, 파이썬 코드만으로 데이터베이스의 데이터를 조회하고 조작할 수 있도록 도와줍니다. Django에서는 이 ORM을 활용하여 개발자가 직관적이고 가독성 높은 코드로 데이터베이스 작업을 수행할 수 있으며, 데이터베이스 종류에 관계없이 동일한 코드로 동작하게 할 수 있다는 큰 장점이 있습니다. 이번 포스트에서는 Django ORM의 기초 개념과 함께 모델 정의, 마이그레이션, 주요 QuerySet API 사용법, 그리고 약간 더 나아가 중급자를 위한 활용 팁까지 예제 코드와 함께 살펴보겠습니다.
ORM이란 무엇이며 Django에서 왜 사용할까?
ORM은 앞서 언급했듯 Object-Relational Mapping의 약자로, 객체 지향 언어의 객체와 관계형 DB의 데이터를 자동으로 연결해주는 기술입니다. Django의 ORM을 사용하면 개발자가 파이썬 클래스와 객체 조작만으로 데이터베이스 레코드를 처리할 수 있습니다. 예를 들어 Django 모델 클래스와 ORM을 이용하면, 아래와 같은 파이썬 코드만으로 데이터베이스에 테이블을 만들고 레코드를 추가할 수 있습니다:
from django.db import models
# 사용자(User) 모델 정의 예시
class User(models.Model):
name = models.CharField(max_length=45)
email = models.CharField(max_length=100, unique=True)
password = models.CharField(max_length=200)
위 User 모델을 데이터베이스에 **마이그레이션(migrate)**하면, 해당 모델에 대응하는 users 테이블이 생성됩니다. id 필드를 명시적으로 정의하지 않았지만, Django가 자동으로 기본 키(primary key) 필드를 추가해 줍니다. ORM을 사용하지 않는 경우라면 개발자가 SQL로 직접 CREATE TABLE문을 작성해야 하지만, Django ORM 덕분에 그런 수고를 덜 수 있습니다.
Django에서 ORM을 사용하는 이유는 다음과 같습니다:
- 생산성과 편의성: 파이썬 코드로 데이터베이스를 다룰 수 있어 개발 생산성이 높습니다. 복잡한 SQL 문법보다는 Python 코드와 메소드로 직관적으로 데이터 질의를 구성할 수 있습니다. 예를 들어 User.objects.create(...) 메소드를 호출하면 내부적으로 INSERT 쿼리가 실행되어 레코드가 추가되는데, 이를 통해 INSERT SQL을 직접 쓰는 과정을 생략할 수 있습니다.
- 높은 가독성과 유지보수성: ORM은 객체 지향적인 코드 작성이 가능하므로 코드가 직관적이고 비즈니스 로직에 집중하기 쉽습니다. SQL을 분리하여 작성할 필요 없이 모델 클래스와 메소드를 통해 DB를 제어하므로, 코드의 유지보수도 더 용이해집니다.
- DB 독립성: Django ORM은 다양한 데이터베이스 백엔드를 지원하며(예: SQLite, PostgreSQL, MySQL 등), ORM 레이어 위에서 코드가 동작하기 때문에 특정 DB에 종속되지 않습니다. 필요한 설정만 바꾸면 다른 DB로 이전하기 상대적으로 수월합니다.
- 추가적인 이점: ORM은 SQL을 자동 생성해주므로 일반적으로 개발 속도가 빠르고, Python 객체를 다루듯 데이터를 처리할 수 있어 일관성 있는 코드를 작성하게 해줍니다. 또한 Django ORM은 보안적으로도 SQL 인젝션을 방지하는 등 여러 이점을 제공합니다.
참고: ORM이 모든 것을 해결해주지는 않습니다. 매우 복잡한 쿼리나 성능이 중요한 경우 ORM만으로는 한계가 있을 수 있고, 때로는 직접 SQL을 사용해야 할 때도 있습니다. 하지만 대부분의 웹 애플리케이션에서는 ORM만으로도 충분하며, 개발 생산성과 유지보수 측면에서 큰 도움이 됩니다.
Django 모델 정의: 기본 구조와 주요 필드 타입
**모델(Model)**은 Django에서 데이터베이스 테이블을 표현하는 파이썬 클래스입니다. 모델 클래스는 django.db.models.Model을 상속하며, 클래스의 각 속성(attribute)이 데이터베이스의 **컬럼(column)**에 대응됩니다. 예를 들어 간단한 블로그 게시글과 댓글 모델을 정의해보겠습니다.
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=100) # 제목: 최대 100자까지의 문자 필드
content = models.TextField() # 내용: 글 본문 전체를 담는 긴 텍스트 필드
author = models.CharField(max_length=50) # 작성자 이름: 50자 이내 문자열
created_at = models.DateTimeField(auto_now_add=True) # 작성 시간: 생성 시 자동 저장
views = models.IntegerField(default=0) # 조회수: 기본 0, 정수 필드
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
# 어떤 Post의 댓글인지 연결 (Post 모델과 일대다 관계)
content = models.TextField() # 댓글 내용
author = models.CharField(max_length=50) # 댓글 작성자
created_at = models.DateTimeField(auto_now_add=True) # 댓글 작성 시간
위 예시에서 Post와 Comment 두 개의 모델을 정의했습니다. 모델 정의의 기본 구조를 살펴보면:
- class 모델이름(models.Model): 형식으로 정의하며, 각 클래스는 하나의 데이터베이스 테이블에 대응됩니다.
- 클래스 안에 정의된 클래스 변수들이 곧 테이블의 컬럼이 됩니다. Django는 다양한 **필드 타입(Field Types)**을 제공하며, 몇 가지 주요 예시는 다음과 같습니다:
- CharField: 짧은 문자열을 저장할 때 사용합니다. 반드시 max_length (최대 길이) 인자를 지정해야 합니다. 예를 들어 위 title, author 필드처럼 제목이나 이름 등에 주로 사용합니다.
- TextField: 길이 제한이 없는 긴 텍스트를 저장할 때 사용합니다. 위 content나 Comment.content처럼 본문이나 댓글 내용 등 큰 텍스트에 적합합니다.
- IntegerField: 정수 값을 저장하는 필드입니다. 예시의 views 필드처럼 조회수, 카운터 등에 사용합니다. 이와 비슷하게 큰 정수용 BigIntegerField, 음수 불가 정수용 PositiveIntegerField 등도 있습니다.
- BooleanField: True/False 값을 저장하는 필드입니다. 예/아니오, 활성화 여부 등 논리값을 다룰 때 사용됩니다.
- DateField 및 DateTimeField: 날짜 또는 날짜+시간 정보를 저장하는 필드입니다. 예시의 created_at처럼 auto_now_add=True 옵션을 주면 생성시점이 자동 기록됩니다.
- ForeignKey: 외래 키를 정의하여 **다른 모델과의 관계(일대다)**를 설정합니다. 위 Comment.post 필드는 Post 모델에 연결된 외래 키입니다. on_delete=models.CASCADE는 참조된 Post가 삭제될 때 관련 Comment도 삭제되도록 설정한 것입니다. 또한 related_name='comments'를 주면 Post 객체에서 post.comments로 해당 게시글의 모든 댓글 QuerySet에 접근할 수 있습니다 (지정하지 않으면 기본값으로 comment_set이라는 이름이 사용됩니다).
- ManyToManyField: 다대다(Many-to-Many) 관계를 위한 필드입니다. 예를 들어 게시글과 태그(Tag) 모델 간의 관계를 다대다로 표현할 때 사용합니다.
- OneToOneField: 일대일(One-to-One) 관계를 위한 필드입니다. 하나의 객체가 다른 모델의 객체 하나와 1:1로 연결될 때 사용합니다 (예: 사용자와 프로필 정보 모델).
- 그 외에도 FloatField(실수), DecimalField(고정 소수점), EmailField(이메일 형식 검증 문자필드), URLField(URL용 문자필드) 등 다양한 필드 타입을 Django가 제공합니다. 각 필드는 내부적으로 데이터베이스의 컬럼 타입과 매핑되어 동작하며, 유효성 검증(validation)도 지원합니다.
모델을 정의한 후 Django에 이 변경사항을 알려야 데이터베이스에 반영됩니다. 이것이 **마이그레이션(Migration)**을 통해 이루어집니다 (다음 섹션에서 자세히 다룹니다). 모델 정의를 수정(필드 추가/삭제 등)할 때마다 마이그레이션을 생성하고 적용하여 DB 스키마를 업데이트해야 합니다.
마이그레이션(Migration)이란?
마이그레이션은 Django에서 모델의 변경사항(예: 새로운 필드 추가, 기존 필드 수정/삭제, 모델 추가/삭제 등)을 데이터베이스 스키마에 적용하는 방법을 말합니다. 쉽게 말해, Django의 DB 스키마 버전 관리 도구라고 할 수 있습니다. Django는 모델 변경 내역을 따라 자동으로 마이그레이션 파일을 만들어주고, 이를 적용하여 데이터베이스를 모델 정의와 동기화합니다.
일반적인 마이그레이션 작업 흐름은 다음과 같습니다:
- 모델 수정: models.py에서 모델이나 필드를 추가/변경/삭제합니다.
- 마이그레이션 파일 생성: 터미널에서 python manage.py makemigrations 명령을 실행합니다. Django가 변경 내용을 감지하고, 해당 앱의 migrations/ 디렉토리 아래에 새로운 마이그레이션 파일(예: 0002_add_views_field.py)을 생성합니다. 이 파일에는 어떤 DB 변경을 수행해야 하는지에 대한 작업 목록이 기록됩니다 (예: 테이블 생성, 필드 추가 등).
- 마이그레이션 적용: 생성된 마이그레이션을 실제 데이터베이스에 적용하려면 python manage.py migrate 명령을 실행합니다. 그러면 해당 마이그레이션 파일의 지시에 따라 DB에 ALTER TABLE이나 CREATE TABLE 등의 SQL이 실행되어 스키마 변경이 반영됩니다.
마이그레이션 파일은 일종의 이력서처럼 누적되며, 각 버전별 DB 스키마 변경 내용을 담고 있습니다. Django는 이러한 마이그레이션을 차례로 적용하거나 되돌리면서, 여러 개발자나 여러 배포 환경에서 데이터베이스 상태를 일관되게 유지할 수 있게 해줍니다. 마치 Git과 같은 버전 관리 시스템(VCS)이 코드 변경 이력을 추적하듯, 마이그레이션은 데이터베이스 설계(스키마)의 변경 이력을 관리한다고 볼 수 있습니다.
예시: 우리가 앞에서 정의한 Post와 Comment 모델의 처음 추가가 0001_initial.py라는 마이그레이션으로 기록되고, 이후 Post에 views 필드를 추가했다면 0002_post_add_views.py 같은 파일에 해당 ALTER TABLE 명령이 기록됩니다. 이렇게 생성된 마이그레이션들을 migrate하면 실제 DB에 Post 테이블과 Comment 테이블 생성 -> Post 테이블에 views 컬럼 추가 작업이 순서대로 반영됩니다.
(마이그레이션 파일 내용은 사람이 읽을 수도 있지만 굳이 편집할 필요는 없으며, Django가 자동 생성한 코드를 그대로 사용하는 것이 원칙입니다.)
마이그레이션에 관한 몇 가지 추가 팁:
- 새로운 모델이나 필드를 추가한 경우 가급적 즉시 makemigrations를 실행하여 변경을 커밋하는 것이 좋습니다. 모델 변경이 누락되면 서버 구동 시 오류가 발생하거나, 동료와의 DB 스키마 상태 불일치가 생길 수 있습니다.
- 이미 운영 중인 테이블에 컬럼을 추가할 때는 기본값 설정이나 null=True 옵션을 고려해야 합니다. Django 마이그레이션은 컬럼 추가 시 기본값이 없으면 모든 기존 행에 대해 값을 채우도록 요구하기 때문입니다. (예: 대용량 테이블에 기본값 없는 non-null 필드 추가 시 성능 영향 유의)
- 불필요한 마이그레이션 파일이 많이 생겼다면 squash migrations 기능으로 여러 마이그레이션을 합치는 방법도 있습니다 (중급 주제).
QuerySet으로 데이터 조회하기
QuerySet은 Django ORM에서 데이터베이스 쿼리의 결과 집합을 표현하는 객체입니다. 보통 모델클래스.objects 매니저를 통해 쿼리를 만들면 QuerySet이 반환됩니다. QuerySet은 지연 평가(lazy evaluation) 특성을 가지는데, 이는 QuerySet을 생성하는 코드 자체는 즉시 DB에 질의를 보내지 않고, 그 결과가 실제로 필요할 때(DB를 hit해야 할 때) 쿼리가 수행된다는 뜻입니다.
이제 실제로 QuerySet API를 사용하여 데이터를 조회하는 방법을 살펴보겠습니다. 앞서 정의한 Post와 Comment 모델을 활용하여 예제를 들겠습니다.
전체 조회 및 필터링: all(), filter(), exclude()
- 모든 객체 조회: Model.objects.all()을 호출하면 해당 모델의 모든 레코드를 담은 QuerySet을 반환합니다. 예를 들어 Post.objects.all()은 모든 Post를 담은 QuerySet을 반환하고, 아직 데이터베이스에 Post 레코드가 없다면 빈 QuerySet([])이 됩니다.
- 필터링 (filter): filter(필드__조건=값) 형식으로 조건을 주어 해당 조건에 맞는 객체들만 조회할 수 있습니다. filter()는 조건에 맞는 객체들을 포함하는 새로운 QuerySet을 반환합니다. 여러 조건을 쉼표로 구분하여 넣으면 (키워드 인자 형태로) AND 조건으로 모두 만족하는 결과를 조회합니다. 또한 filter()를 연달아 체인(chain)으로 연결하면 조건이 누적됩니다.
- 제외 (exclude): exclude()는 filter()와 반대로, 주어진 조건에 맞지 않는 객체들만을 포함하는 QuerySet을 반환합니다. 즉, 특정 조건을 배제한 결과를 얻고자 할 때 사용합니다.
# 모든 Post 객체 조회
posts = Post.objects.all()
# 특정 조건으로 필터링: 예를 들어 author가 "Alice"인 Post만 조회
posts_by_alice = Post.objects.filter(author="Alice")
# 복수 조건 필터링: 제목에 "Django"가 들어가면서(author는 AND) 작성자가 "Bob"인 Post
posts = Post.objects.filter(title__icontains="Django", author="Bob")
# 제외 조건 사용: 제목이 "[Draft]"로 시작하지 않는(Post) 객체만 조회
public_posts = Post.objects.exclude(title__startswith="[Draft]")
위 코드에서 filter(title__icontains="Django")처럼 이중 밑줄(__)을 사용한 부분은 Django ORM의 필드 조회 조건(Field lookup) 문법입니다. title__icontains="Django"는 SQL로 번역하면 WHERE title ILIKE '%Django%' (대소문자 불문 포함)과 유사하며, 이 외에도 exact(정확히 일치), contains(대소문자 구분 포함), gte/lte(크거나 같다/작다) 등 다양한 조회 조건 키워드를 제공합니다. 필터 조건을 지정하는 자세한 방법은 Django 공식 문서의 "Field lookup reference"를 참고하면 됩니다.
참고: QuerySet은 슬라이싱을 통해 결과의 일부만 가져올 수도 있습니다. 예를 들어 Post.objects.all()[:5]는 첫 5개의 객체만, Post.objects.all()[10:20]은 11번째부터 20번째까지의 객체만 가져오는 쿼리를 생성합니다 (SQL의 LIMIT/OFFSET에 해당). 슬라이싱도 역시 QuerySet을 반환하며, 슬라이스 결과에 추가 필터를 적용하는 것은 지원되지 않습니다.
단일 객체 조회: get()
get() 메소드는 주어진 조건에 딱 하나의 객체만 반환할 때 사용합니다. 주로 기본 키를 통한 조회나, unique 제약조건을 가지는 필드를 조회할 때 사용합니다. 예를 들어 Post.objects.get(id=1)은 데이터베이스에서 PK가 1인 Post 객체를 반환합니다. get()은 조건에 맞는 객체가 없을 경우 모델명.DoesNotExist 예외를 발생시키고, 조건에 맞는 객체가 2개 이상이면 모델명.MultipleObjectsReturned 예외를 발생시킵니다. 따라서 get()을 사용할 때는 반드시 결과가 하나임이 보장되는 조건을 사용해야 합니다.
try:
post = Post.objects.get(pk=1)
print(post.title)
except Post.DoesNotExist:
print("해당 ID의 Post가 없습니다!")
위 코드에서 pk는 "primary key"의 약자로, id 필드와 동일한 의미입니다. get()은 내부적으로 즉시 DB 질의를 수행하므로 (결과를 하나만 반환해야 하므로), QuerySet이 아닌 모델 인스턴스를 바로 돌려준다는 점이 filter()와 다릅니다.
주의: get()을 사용할 때 결과가 없거나 여러 개인 상황을 항상 염두에 두어야 합니다. 예외 처리를 하지 않으면 어플리케이션이 오류로 중단될 수 있으므로, 위처럼 try/except로 처리하거나, 존재 여부를 먼저 filter().exists()로 확인하는 방법도 있습니다. 또한 여러 개 중 첫 번째 하나만 가져오고 싶다면 filter(...).first() 또는 order_by(...)[0] 인덱싱을 사용하는 편이 낫습니다. (인덱싱과 get()의 예외 발생 차이에 대해서는 Django 문서에 설명되어 있습니다.)
정렬: order_by()
기본적으로 QuerySet의 결과 순서는 모델의 Meta 설정이나 기본 키 순서 등에 따르지만, 정렬을 지정하고 싶을 때는 order_by(필드명)을 사용합니다.
# Post를 작성 최신순으로 정렬 (created_at 내림차순)
recent_posts = Post.objects.order_by('-created_at')
# Comment를 작성자 이름 알파벳순으로 정렬 (오름차순, 기본값)
sorted_comments = Comment.objects.order_by('author')
order_by에 필드명을 문자열로 넘기며, 앞에 -를 붙이면 내림차순(descending), 붙이지 않으면 오름차순(ascending) 정렬입니다. 다중 필드 정렬도 가능하여 order_by('author', '-created_at')처럼 쓰면 작성자명 오름차순 정렬 후 동일 작성자 내에서는 생성일시 내림차순 정렬을 하게 됩니다.
TIP: order_by를 사용한 후 filter를 이어서 호출할 수도 있지만, 이미 평가(evaluation)된 QuerySet에 추가 정렬을 하는 것은 주의해야 합니다. 가급적 필요한 경우 한 번의 QuerySet 체인으로 정렬까지 수행하거나, QuerySet을 변수에 할당하기 전에 마지막에 order_by를 호출하는 것이 좋습니다.
데이터 생성, 수정, 삭제
Django ORM을 통해 데이터베이스에 새 레코드를 추가하거나, 기존 레코드를 수정 및 삭제하는 것도 간단합니다. 모델 인스턴스의 메서드 또는 QuerySet 매니저의 메서드를 활용하여 수행할 수 있습니다.
생성(Create): create() 및 save()
새로운 객체를 생성하는 방법으로 두 가지가 널리 사용됩니다:
- 모델 클래스 생성자 + save(): 모델 클래스를 호출하여 인스턴스를 만들고, save() 메서드를 호출하여 DB에 저장합니다.
- 매니저의 create() 메서드: Model.objects.create(필드=값, ...) 형태로 한 번에 객체 생성과 저장을 수행합니다.
예를 들어 새로운 Post를 생성해보겠습니다.
# 1. 생성자 사용 + save()
post = Post(title="첫 번째 글", content="Hello World!", author="Admin")
post.save() # 이 때 INSERT 쿼리가 수행되어 DB에 저장됨
# 2. objects.create() 사용 (한 줄로 생성 및 저장)
comment = Comment.objects.create(post=post, content="첫 댓글입니다.", author="Alice")
첫 번째 방식에서는 post.save()를 호출하기 전까지는 DB에 반영되지 않다가, save()를 호출하는 순간에야 INSERT SQL이 실행됩니다. 두 번째 방식인 objects.create()는 내부적으로 객체를 만든 뒤 save()까지 해주므로 편의성이 있습니다. create()는 객체를 반환하므로 필요하면 변수에 담아서 사용할 수도 있습니다 (comment 변수 참조).
추가 정보: save() 메서드는 이미 DB에 있는 객체에 대해서 호출하면 UPDATE를 수행합니다. 이를 이용해 객체를 수정할 수도 있습니다. 예를 들어 post.title = "제목 수정" 후 post.save()를 호출하면 해당 객체의 변경사항이 DB에 업데이트됩니다. 다만, 여러 객체를 한 번에 수정하려면 다음 update() 기능을 사용하는 편이 효율적입니다.
수정(Update): update() 및 객체 save()
특정 객체 하나를 수정하는 가장 단순한 방법은 인스턴스를 가져와서 필드를 변경한 뒤 save()를 호출하는 것입니다. 하지만 여러 객체를 동시에 갱신해야 할 경우 QuerySet의 update() 메서드를 사용하면 단 한 번의 SQL으로 업데이트할 수 있어 효율적입니다.
# 1개의 객체를 수정하는 경우 - 객체를 가져와서 저장
post = Post.objects.get(pk=1)
post.title = "제목 변경"
post.save() # 해당 Post(id=1)의 title이 업데이트됨 (UPDATE 쿼리 실행)
# 다수의 객체를 한 번에 수정 - QuerySet.update()
Post.objects.filter(author="Admin").update(author="Administrator")
# -> author 필드가 "Admin"이었던 모든 Post들의 author 값이 "Administrator"로 일괄 변경됩니다.
위 update() 예시는 author="Admin"인 모든 Post 레코드의 author를 "Administrator"로 바꾸는 단일 SQL 쿼리를 수행합니다 (WHERE ...; UPDATE ...). 반면에 개별 객체에 대해 반복문으로 save()를 부르는 것은 N번의 쿼리가 일어나기 때문에 대량 업데이트 시에는 update() 사용을 권장합니다.
주의: update()는 QuerySet (매니저)에서 제공되는 함수이며, 반환값은 영향받은 행 수(int)입니다. 또한 update()를 호출하면 QuerySet이 곧바로 평가되어 DB에 즉시 적용됩니다. 이 때 Django 모델의 save() 메서드가 호출되지 않기 때문에, save()에 정의된 사용자 커스텀 동작이나 신호(signal)는 발동되지 않는다는 점을 알아두세요.
삭제(Delete): delete()
ORM으로 객체를 삭제하는 방법도 유사합니다. 특정 객체 인스턴스에서 delete() 메서드를 호출하여 삭제할 수 있고, QuerySet에 대해 delete()를 호출하면 조건에 맞는 모든 객체를 삭제합니다.
# 1개의 객체 삭제
post = Post.objects.get(pk=1)
post.delete()
# -> DB에서 해당 Post(id=1) 삭제. 이 Post에 연결된 Comment들도 on_delete=CASCADE에 의해 함께 삭제됨:contentReference[oaicite:47]{index=47}.
# 다수 객체 삭제 (예: 1년 이전에 작성된 댓글 모두 삭제)
from django.utils import timezone
one_year_ago = timezone.now() - timezone.timedelta(days=365)
Comment.objects.filter(created_at__lt=one_year_ago).delete()
# -> 조건에 맞는 모든 Comment 레코드 삭제
post.delete()처럼 개별 객체에서 delete()를 호출하면 그 객체만 삭제됩니다. 이때 앞서 모델 정의에서 ForeignKey(on_delete=models.CASCADE)로 설정한 경우라면 연관된 객체들도 함께 삭제됩니다 (위 예시에서 특정 Post를 지우면 그 Post에 연결된 Comment들도 삭제됨). QuerySet에 대해 delete()를 호출하면, 그 QuerySet에 포함된 모든 객체를 삭제합니다. 예를 들어 Comment.objects.all().delete()는 Comment 테이블의 모든 레코드를 삭제합니다.
참고: delete() 역시 즉시 DB 반영이 이루어지는 메서드입니다. 또한, 모델 클래스에 delete()를 오버라이드하거나 pre_delete/post_delete 신호를 연결해 두었다면 그 로직이 실행됩니다. 대량 삭제 시에도 delete()는 개별 객체를 하나씩 삭제하지 않고 DB 레벨에서 한 번에 삭제하지만, Django는 모든 삭제된 객체마다 delete 신호를 보냅니다. 이로 인해 수만 건 이상을 지울 때는 신호 처리 비용이 존재할 수 있습니다.
Aggregation과 Annotate 사용
ORM의 장점 중 하나는 데이터베이스의 집계 연산(aggregation)을 Python 코드로 쉽게 할 수 있다는 것입니다. Django는 django.db.models 모듈에서 다양한 집계 함수를 제공하며 (Count, Sum, Avg, Max, Min 등), QuerySet의 aggregate() 및 annotate() 메서드를 통해 이를 활용할 수 있습니다.
- aggregate(): QuerySet 전체에 대해 하나의 요약 값을 계산하고자 할 때 사용합니다. 이를 호출하면 SQL의 SUM, COUNT 등 집계 함수를 수행한 딕셔너리 형태의 결과를 돌려줍니다. 예를 들어 전체 Post 개수를 세거나, 댓글의 평균 길이를 구하는 등의 작업에 쓰입니다.
- annotate(): QuerySet의 각 객체별로 집계 값을 계산하여 필드처럼 부가해줍니다. 예를 들어 각 Post마다 댓글 수를 계산해서 함께 조회하거나, 상품 리스트 각 항목마다 판매량 합계를 붙여서 가져오는 등이 가능합니다. annotate()는 호출해도 여전히 QuerySet을 반환하므로, 이어서 filter()나 order_by()를 추가로 적용할 수도 있습니다.
예제를 통해 살펴보겠습니다. 앞서 정의한 Post와 Comment 모델을 활용하여 댓글 수 집계를 해보겠습니다. Post 모델에는 댓글과의 관계가 related_name='comments'로 정의되어 있으므로 이를 활용합니다:
from django.db.models import Count
# 1. aggregate() 사용 - 전체 댓글 수 집계
result = Comment.objects.aggregate(total_comments=Count('id'))
print(result) # 예: {'total_comments': 42}
# 2. annotate() 사용 - Post별 댓글 수 집계
posts = Post.objects.annotate(comment_count=Count('comments'))
for p in posts:
print(p.title, p.comment_count)
# 출력 예시:
# "첫 번째 글" 3
# "두 번째 글" 5
# ...
첫 번째에서 Comment.objects.aggregate(total_comments=Count('id'))는 Comment 테이블의 모든 행을 세어서 'total_comments' 키로 딕셔너리를 반환합니다. 즉 결과는 {'total_comments': <댓글 총 개수>} 형태이며, aggregate()에 여러 함수를 전달하면 여러 값을 계산해줍니다 (예: Comment.objects.aggregate(count=Count('id'), max_id=Max('id')) 등).
두 번째에서 Post.objects.annotate(comment_count=Count('comments'))는 JOIN을 활용하여 각 Post에 연결된 comments의 개수를 세고, 그 값을 comment_count 필드로 각 Post 객체에 추가합니다. 이렇게 하면 루프 등으로 Post를 순회할 때 p.comment_count로 미리 계산된 값을 사용할 수 있습니다. (내부적으로 SQL에서는 GROUP BY와 서브쿼리/조인을 사용하여 구현됩니다.)
또한 annotate() 결과에 대해 정렬을 할 수도 있습니다. 예를 들어 댓글 많은 순으로 Post를 나열하려면 Post.objects.annotate(comment_count=Count('comments')).order_by('-comment_count')와 같이 활용하면 됩니다.
참고: 복잡한 annotate() 조합을 여러 개 사용하면 의도와 다른 결과가 나올 수 있습니다. Django ORM이 SQL JOIN을 사용하여 각 annotate를 계산하기 때문인데, 이로 인해 합쳐진 결과가 곱집합처럼 나올 수 있습니다. 이러한 문제를 피하기 위해 Django 2.0+에서는 Subquery와 OuterRef 등을 사용한 하위쿼리 기반 annotate 기법을 제공하거나, Count 함수의 filter 옵션, distinct=True 옵션을 활용할 수 있습니다. 기본적인 사용에서는 크게 걱정할 부분은 아니지만, annotate를 여러 개 사용할 때 결과가 이상하면 SQL을 확인해보는 습관도 좋습니다 (QuerySet의 query 속성을 출력).
효율적인 쿼리: select_related와 prefetch_related
ORM을 쓰다 보면 N+1 문제라 불리는 성능 이슈를 접할 수 있습니다. 이는 관련 객체를 조회할 때 쿼리가 추가로 반복 실행되어 비효율이 발생하는 상황입니다. Django에서는 이를 완화하기 위해 select_related와 prefetch_related 메서드를 제공합니다. 두 메서드 모두 연관된 객체를 미리 당겨와서 나중에 다시 DB를 hit하지 않도록 해주는 기능이지만, 작동 방식에 차이가 있습니다.
- select_related(‘<필드명>’): 외래 키나 일대일 관계를 따라 JOIN을 수행하여 하나의 SQL로 함께 가져옵니다. 주로 ForeignKey, OneToOneField와 같은 1:1 또는 다대일 관계에서 사용합니다. 예를 들어 Comment를 가져오면서 연결된 Post 객체를 미리 가져오고 싶다면 Comment.objects.select_related('post').filter(...)처럼 합니다. 이렇게 하면 각 Comment의 comment.post를 접근할 때 추가 쿼리가 발생하지 않습니다. select_related()는 필요한 경우 다단계로도 사용 가능한데 (select_related('post__author')처럼), 인자로 아무 것도 주지 않고 select_related()를 호출하면 모델에 정의된 모든 ForeignKey를 따라 전부 JOIN해오기도 합니다.
- prefetch_related('<역참조명>'): 별도의 쿼리를 사용해 관련 객체들을 미리 조회하고, 파이썬 단에서 매칭해주는 방식입니다. 주로 ManyToManyField나 역방향 ForeignKey(1:N의 N쪽 역참조) 관계에서 사용됩니다. 예를 들어 Post와 그에 달린 Comment들을 한꺼번에 가져오려면 Post.objects.prefetch_related('comments').all()처럼 씁니다. 그러면 Django는 먼저 Post를 모두 가져온 뒤 (쿼리 1번), 해당 Post들의 ID 목록을 모아 두 번째 쿼리로 Comment를 한꺼번에 가져옵니다 (쿼리 2번). 그리고 Python 코드에서 각 Post에 자신의 댓글 목록을 할당합니다. 총 2번의 쿼리로 N+1 문제 없이 모든 Post와 댓글 정보를 얻을 수 있게 되는 것입니다.
# select_related 예시: 댓글과 함께 해당 게시글 정보를 한번에 가져오기
comments = Comment.objects.select_related('post').filter(author="Tom")
for c in comments:
# c.post를 참조해도 추가 쿼리가 발생하지 않음 (이미 JOIN으로 가져옴)
print(c.content, "-> 게시글 제목:", c.post.title)
# prefetch_related 예시: 게시글과 함께 모든 댓글 미리 가져오기
posts = Post.objects.prefetch_related('comments').all()
for p in posts:
# p.comments (역참조 QuerySet)이 이미 미리 로드되어 있어 추가 DB 조회 없음
print(p.title, "댓글 개수:", p.comments.count())
select_related는 한 번의 JOIN 쿼리로 관련 객체를 가져오는 반면, prefetch_related는 별도의 쿼리 두 번(혹은 관계 개수에 따라 그 이상)으로 가져와 파이썬에서 조합합니다. ForeignKey처럼 단일 객체를 가져오는 경우엔 select_related가 간편하고 빠르며, ManyToMany처럼 여러 객체 세트를 가져와야 하면 prefetch_related를 써야 합니다 (select_related는 ManyToMany에 사용할 수 없습니다). 대부분의 경우 이 원칙을 따르면 되고, 둘 다 활용 가능한 상황(예: 1:N 관계 조회)에서는 실제 데이터 규모에 따라 성능 차이가 있을 수 있습니다. (일반적으로는 select_related가 한 번의 쿼리로 가져오므로 더 효율적이지만, 매우 큰 join의 경우 prefetch_related가 나을 수도 있습니다.)
요약: 외래 키 등으로 이어진 하나의 객체를 함께 가져올 때는 select_related, 여러 객체의 세트를 미리 가져올 때는 prefetch_related를 주로 사용하세요. 이 둘을 적절히 사용하면 ORM 사용으로 인한 불필요한 쿼리 증가를 줄이고 성능을 개선할 수 있습니다.
복잡한 쿼리 처리를 위한 팁 (Q 객체, F 객체, 트랜잭션)
이제 Django ORM을 조금 더 중급 수준에서 활용할 수 있는 몇 가지 기법을 소개합니다. 복잡한 조건의 필터링, 필드 값 비교/갱신, 그리고 데이터베이스 트랜잭션 처리에 대해 살펴보겠습니다.
Q 객체로 복잡한 필터 구성하기
기본적인 필터링은 키워드 인자를 AND 조건으로 주거나, filter().exclude() 체인을 통해 AND/NOT 논리를 구현합니다. 그러나 OR 조건이나 그룹화된 논리 조합이 필요할 때는 Django의 Q 객체를 사용하는 것이 편리합니다. Q 객체를 사용하면 비교 조건을 객체로 캡슐화하여 & (AND), | (OR), ~ (NOT) 연산자로 조합할 수 있습니다. 이를 통해 (조건1 AND 조건2) OR 조건3 같은 복잡한 WHERE 절도 표현 가능합니다.
from django.db.models import Q
# OR 조건 예시: 제목에 "Django"가 들어가거나 내용에 "Django"가 들어가는 Post
posts = Post.objects.filter(Q(title__icontains="Django") | Q(content__icontains="Django"))
# AND 조합 예시: (제목에 "Python"이 들어가거나 작성자가 "Admin")이면서, 공개글인 Post
posts = Post.objects.filter(
Q(title__icontains="Python") | Q(author="Admin"),
is_public=True # 다른 조건과 혼용 시 콤마로 연결하면 AND
)
# NOT 예시: 제목에 "Draft"가 포함되지 않은 Post (위 exclude와 동일한 결과)
posts = Post.objects.filter(~Q(title__icontains="Draft"))
위 예에서처럼 Q(A) | Q(B)는 A 또는 B 조건을 만족하는 결과를, Q(A) & Q(B)는 A와 B를 모두 만족하는 결과를 의미합니다. ~Q(A)는 A 조건의 부정을 뜻합니다. Q 객체를 여러 개 filter()에 인수로 전달하면 자동으로 AND로 결합되며, 이는 Q(A) & Q(B)와 동일합니다. 따라서 복잡한 OR 조건이 필요한 곳에 Q를 적절히 활용하고, 필요한 경우 괄호를 쳐서 구룹핑도 가능하므로 Python에서 논리식을 세우듯 쿼리 조건을 구성할 수 있습니다.
실전 팁: 동적으로 필터 조건을 구성해야 할 때 (if 문에 따라 조건 추가 등) Q 객체를 미리 만들어 두고 조건부로 조합하면 유연하게 QuerySet을 만들 수 있습니다. 또한 Q 객체를 사용하면 하나의 filter() 호출에 OR 조건을 넣을 수 있으므로, DB hit 횟수를 줄이는 데 도움이 됩니다.
F 객체로 필드 간 비교 및 값 갱신하기
ORM에서 F 객체는 모델 필드의 값을 참조하는 기능을 제공합니다. 일반적으로 filter(x=y) 조건을 주면 y는 리터럴 값으로 간주되지만, filter(x=F('y')) 처럼 F 객체를 사용하면 같은 레코드 내의 y 필드 값을 참조하여 비교하게 됩니다. 또한 산술 연산을 지원하여, 필드 값을 기준으로 +1 증가시키거나 두 필드 값을 더한 결과와 비교하는 등의 작업을 가능하게 합니다.
주로 사용되는 상황은 다음과 같습니다:
- 필드 간 비교: 예를 들어 게시물의 조회수보다 댓글수가 많은 Post를 찾고 싶다면 Post.objects.filter(comment_count__gt=F('views')) 같은 쿼리를 작성할 수 있습니다. 이는 SQL로 WHERE comment_count > views에 해당합니다.
- 필드 값 갱신: 현재 값에 상대적인 연산을 수행하여 업데이트할 때, F 객체를 사용하면 단 한 번의 쿼리로 가능합니다. 예를 들어 조회수를 1 증가시킬 때 post.views += 1; post.save()를 하면 조회 -> 파이썬 계산 -> 업데이트로 2번의 쿼리가 필요하지만, Post.objects.filter(id=1).update(views=F('views')+1) 처럼 하면 SQL 레벨에서 바로 증가시킵니다.
from django.db.models import F
# 예1) 필드 간 비교: 조회수보다 댓글이 많은 Post 찾기 (comment_count > views)
popular_posts = Post.objects.filter(comment_count__gt=F('views'))
# 예2) 대량 업데이트: 모든 Post의 조회수 1씩 증가
Post.objects.update(views=F('views') + 1)
위 예2에서 update(views=F('views') + 1)은 SQL로 보면 UPDATE post SET views = views + 1;와 같으며, 모든 행에 대해 한 번의 쿼리로 처리됩니다. F 객체는 이처럼 DB단에서 계산을 수행하게 하여 효율을 높이고, 또한 동시성 이슈에서도 안전합니다 (여러 쓰레드/프로세스가 동시에 실행해도 각자의 연산이 누락 없이 적용됨).
추가로, F 객체는 날짜/시간 필드와 timedelta를 더하거나, 비트 연산 메서드(.bitand(값) 등)를 제공하는 등 다양한 기능이 있습니다. 하지만 가장 흔한 사용 사례는 필드 값 증가/감소나 필드 간 비교이며, 이를 통해 ORM으로도 SQL의 SET field = field + 1 이나 WHERE field1 < field2 같은 조건을 자연스럽게 표현할 수 있습니다.
트랜잭션과 transaction.atomic() 사용
마지막으로, 데이터베이스 트랜잭션(transaction) 관리에 대해 알아보겠습니다. 트랜잭션은 일련의 DB 작업을 하나의 논리적 작업으로 묶어 모두 성공하거나 모두 실패하도록 만드는 것입니다. Django는 기본적으로 자동 커밋(autocommit) 모드로 동작하여, 각 개별 ORM 쿼리마다 즉시 DB에 커밋합니다. 하지만 여러 쿼리를 묶어 원자적으로 실행해야 할 때 (예: 두 개의 모델에 연관된 변경을 모두 적용하거나 모두 취소해야 할 때) 트랜잭션 관리가 필요합니다.
Django는 django.db.transaction 모듈을 통해 트랜잭션을 지원하며, 가장 많이 사용하는 방법은 transaction.atomic 컨텍스트 매니저 또는 데코레이터를 활용하는 것입니다. transaction.atomic()을 쓰면 그 블록 내의 DB 연산이 하나의 트랜잭션으로 처리되어, 블록이 성공적으로 끝나면 커밋되고, 중간에 예외가 발생하면 자동으로 롤백됩니다.
from django.db import transaction
# 예: 게시글과 첫 댓글을 함께 생성하는 트랜잭션 블록
try:
with transaction.atomic():
post = Post.objects.create(title="트랜잭션 테스트", content="본문", author="Tester")
# 의도적으로 에러를 발생시키거나, 혹은 다음 줄이 실패하면 전체 롤백됨
comment = Comment.objects.create(post=post, content="첫 댓글입니다", author="User1")
# ... 다른 DB 작업 가능
except Exception as e:
print("트랜잭션 내에서 에러 발생:", e)
# 블록을 벗어나면, 에러가 없으면 commit, 에러가 있으면 rollback 됩니다.
위 코드에서 with transaction.atomic(): 블록 안에서 Post와 Comment를 생성하고 있습니다. 두 INSERT 모두 성공하면 트랜잭션이 커밋되어 DB에 반영되지만, 만약 블록 안의 어느 지점에서든 예외가 발생하면 (예를 들어 무결성 제약조건 위반이나 명시적 raise 등) 이미 수행된 이전 쿼리도 모두 롤백되어 DB가 처음 상태로 돌아갑니다. 이렇게 함으로써 데이터의 일관성을 유지할 수 있습니다.
transaction.atomic은 함수 데코레이터로도 사용할 수 있어, 뷰 전체를 하나의 트랜잭션으로 처리하도록 지정할 수도 있습니다. 또한 트랜잭션 블록 안에 또 다른 트랜잭션 블록을 중첩(nested)해서 사용하는 것도 가능합니다 (이 경우 내부 블록에서 예외가 발생해도 외부 블록에서 캐치하면 전체 롤백되지 않고 일부만 롤백하는 세이브포인트(savepoint) 역할을 합니다).
참고: 데이터베이스에 따라 트랜잭션 동작 방식에 차이가 있을 수 있습니다. 일반적으로 InnoDB를 쓰는 MySQL, PostgreSQL 등은 DML에 트랜잭션이 적용되지만, DDL(예: 테이블 생성)이나 일부 DDL성 기능은 트랜잭션이 불가하거나 부분적으로만 지원됩니다. 또한 Django 설정에서 ATOMIC_REQUESTS를 활성화하면 각 HTTP 요청을 자동으로 트랜잭션으로 묶어 처리해줄 수도 있는데, 이는 편리하지만 성능 오버헤드가 있으므로 상황에 맞게 사용해야 합니다.
결론
이상으로 Django ORM의 기초 개념부터 모델 정의, 마이그레이션, QuerySet을 이용한 CRUD 조작과 다양한 활용법을 살펴보았습니다. 정리하면 Django ORM을 사용하면 SQL을 직접 다루지 않고도 파이썬 코드로 대부분의 데이터베이스 작업을 수행할 수 있으며, 생산성과 유지보수성 면에서 큰 이점을 얻을 수 있습니다. 특히 QuerySet API는 직관적이면서도 강력하여, 단순 조회부터 복잡한 집계와 조인까지 폭넓은 기능을 제공합니다.
물론 ORM을 사용할 때도 데이터베이스 이해가 뒷받침되면 더 좋습니다. ORM이 자동 생성한 쿼리를 Django의 개발자 콘솔이나 query 속성 등을 통해 확인해보고, 필요한 경우 인덱스 튜닝이나 raw SQL 활용도 고려해볼 수 있습니다. 중급 개발자라면 이번 포스트에서 다룬 select_related, prefetch_related로 쿼리 최적화를, Q, F 객체와 트랜잭션 제어로 더욱 견고하고 효율적인 코드를 작성할 수 있을 것입니다.
Django ORM에 숙달되면 데이터베이스 작업이 한결 쉬워지고 실수도 줄어듭니다. 문서를 참고하면서 다양한 QuerySet 메서드를 시도해보고, 실제 프로젝트에서도 적극 활용해 보세요. ORM을 잘 활용하는 것이 Django 개발을 한 단계 업그레이드하는 밑거름이 될 것입니다!