- 발행일
Django ORM 정리 — 그리고 실무에서 안 통했던 지점
Django ORM (Object-Relational Mapping)
프런트가 주력이고 백엔드는 넓혀가는 중이라, Django를 실무에서 쓰게 되면서 ORM부터 표로 정리했던 글입니다. 앞부분은 그 레퍼런스 그대로 두고, 뒤에 "실무에서 안 통했던 지점"을 덧붙였습니다. 정리한 시점에는 몰랐고, 이후 운영하면서 몸으로 배운 것들입니다.
ORM이란?
ORM(Object-Relational Mapping)은 파이썬 객체와 관계형 데이터베이스(MySQL, PostgreSQL, SQLite 등)의 데이터를 자동으로 매핑해주는 기술입니다. Django에서는 ORM을 통해 SQL을 직접 쓰지 않고 데이터베이스를 제어합니다.
Django ORM의 핵심 원리
| 원리 | 설명 |
|---|---|
| 모델 기반 설계 | models.Model을 기반으로 DB 테이블 정의 |
| SQL 자동 생성 | .save(), .filter() 등 ORM 메서드 사용 시 내부적으로 SQL 자동 생성 |
| 지연 평가 (Lazy) | 실제 쿼리는 for, list(), print() 등에서 필요할 때만 실행됨 |
| DB 이식성 | 같은 파이썬 코드로 다양한 데이터베이스에 적용 가능 |
기본 ORM 명령어
| 목적 | 메서드 | 예시 |
|---|---|---|
| 조회 | all() | User.objects.all() |
| 단일 조회 | get() | User.objects.get(id=1) |
| 조건 조회 | filter(), exclude() | User.objects.filter(name='kim') |
| 정렬 | order_by() | User.objects.order_by('name') |
| 선택 필드 | values(), values_list() | User.objects.values('name') |
| 생성 | create() | User.objects.create(name='kim') |
| 수정 | save(), update() | User.objects.filter(id=1).update(name='lee') |
| 삭제 | delete() | user.delete() |
자주 쓰는 고급 기능
# 관계 조회 최적화
Post.objects.select_related('author') # FK 정방향 — JOIN
Post.objects.prefetch_related('tags') # M2M·역방향 — 별도 쿼리 후 파이썬에서 병합
# 존재 확인
User.objects.filter(email='test@example.com').exists()
# 복합 조건 / 필드 간 연산
from django.db.models import Q, F
User.objects.filter(Q(name='kim') | Q(email='test@example.com'))
Product.objects.update(price=F('price') * 1.1)
# 집계
from django.db.models import Count
User.objects.annotate(post_count=Count('posts'))
# 대량 처리
User.objects.bulk_create([User(name='kim'), User(name='lee')])
# 부분 로딩
User.objects.only('username')
User.objects.defer('profile_picture')
실무에서 안 통했던 지점
여기서부터가 이 글을 다시 꺼낸 이유입니다. 위 표만 들고 실무에 들어갔다가 걸린 것들이에요.
1. 지연 평가는 에러가 터지는 줄을 속인다
표에는 "필요할 때만 실행됨"이라고 장점처럼 적어놨는데, 디버깅에서는 이게 함정이었습니다. QuerySet을 만드는 줄과 실행되는 줄이 다르니, 잘못 만든 쿼리는 만든 자리가 아니라 순회하는 자리에서 터집니다. 실제로 존재하지 않는 관계명을 prefetch_related에 넣은 코드가 한참 떨어진 순회 지점에서 AttributeError로 터져서, 원인 줄을 찾는 데 시간을 쓴 적이 있습니다(그 기록). QuerySet 계열 버그는 터진 줄이 아니라 QuerySet을 만든 줄부터 거슬러 봐야 합니다.
2. N+1은 코드 리뷰가 아니라 쿼리 로그에서 보인다
select_related/prefetch_related를 "성능 개선"이라고 한 줄로 적어놨지만, 문제는 어디에 필요한지가 코드만 봐서는 안 보인다는 겁니다. 템플릿에서 {{ post.author.name }} 한 줄이 루프 안에 있으면 목록 크기만큼 쿼리가 나갑니다. 실제 프로젝트에서 겪은 과정은 N+1 글에 따로 정리했습니다.
3. update()와 bulk_*는 시그널·auto_now를 건너뛴다
위 수정 표에 save()와 update()를 나란히 적어놨는데, 이 둘은 동치가 아닙니다. update()·bulk_update()는 SQL을 바로 쏘기 때문에:
save()오버라이드가 실행되지 않고pre_save/post_save시그널이 발화하지 않으며auto_now필드가 갱신되지 않습니다
모르고 당하면 "분명 저장했는데 시그널 기반 캐시가 안 갱신되는" 버그가 되고, 알고 쓰면 시그널 연쇄를 의도적으로 우회하는 도구가 됩니다 — 파생 캐시를 재계산할 때 시그널 폭풍을 피하려고 일부러 bulk_update를 쓴 사례를 따로 정리했습니다. 같은 맥락에서 auto_now도 조심할 게, "무슨 저장이든 무조건 현재 시각"이라는 프레임워크 편의가 도메인의 수정일시 의미와 다를 수 있습니다. 데이터 보정 배치가 돌기만 해도 문서의 '수정일'이 바뀌는 게 맞는지는 도메인이 정할 일이에요.
4. only()는 잘못 쓰면 쿼리를 늘린다
"일부 필드만 로딩하여 최적화"라고 적어놨지만, only()에서 뺀 필드에 나중에 접근하면 그 시점마다 추가 쿼리가 나갑니다. 루프 안에서 빠진 필드를 건드리면 N+1을 직접 만든 셈이 돼요. 접근 패턴을 확실히 알 때만 쓸 도구지, 기본 최적화로 깔 도구가 아니었습니다.
5. "SQL을 몰라도 된다"는 이 글 안에서도 모순이었다
원문 장점에 "SQL을 몰라도 DB 다루기 가능"이라 적고, 단점에 "동작 원리를 이해하지 않으면 쿼리 폭주"라고 적어놨습니다. 한 글 안에서 서로 부정하는 문장인데 정리할 때는 몰랐어요. 실무 결론은 후자였습니다. ORM은 SQL을 안 쓰게 해주는 게 아니라 안 보이게 해주는 것이고, 안 보이는 SQL을 읽는 수단(str(queryset.query), connection.queries, django-debug-toolbar)까지가 ORM 사용법이었습니다.
한계
여기 적은 건 전부 단일 DB·중소 규모 데이터 기준의 경험입니다. 복잡한 리포팅 쿼리는 ORM으로 끝까지 가기보다 raw()나 뷰 테이블로 내리는 게 나은 시점이 왔는데, 그 경계를 일반화할 만큼의 경험은 아직 없습니다.