- 발행일
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로 터져서 원인 줄을 찾는 데 시간을 쓴 적이 있습니다(그 기록2026-07-20없는 관계를 prefetch하고 있었다 — 다운로드 4종을 죽인 한 단어카탈로그 PNG 다운로드가 500을 뱉어서 들어갔는데, 원인은 성능 최적화라고 믿고 있던 prefetch_related의 인자 하나였습니다. Dataset에 없는 관계명이라 QuerySet 평가 시점에 AttributeError가 났고, 지연 평가 때문에 선언부가 아니라 버튼 클릭에서 터졌어요. 같은 파일 안에서 따옴표 표기가 갈려 grep이 한쪽만 잡은 이야기와, AST로 인자 478건을 전수 대조한 기록입니다.). QuerySet 계열 버그는 터진 줄이 아니라 QuerySet을 만든 줄부터 거슬러 봐야 합니다.
2. N+1은 코드 리뷰가 아니라 쿼리 로그에서 보인다
select_related/prefetch_related를 "성능 개선"이라고 한 줄로 적어놨지만 문제는 어디에 필요한지가 코드만 봐서는 안 보인다는 겁니다. 템플릿에서 {{ post.author.name }} 한 줄이 루프 안에 있으면 목록 크기만큼 쿼리가 나갑니다. 실제 프로젝트에서 겪은 과정은 N+1 글2025-10-31Django N+1 쿼리 문제와 해결법Django ORM에서 자주 발생하는 N+1 쿼리 문제를 진단하고 select_related, prefetch_related로 해결하는 방법을 정리합니다.에 따로 정리했습니다.
3. update()와 bulk_*는 시그널·auto_now를 건너뛴다
위 수정 표에 save()와 update()를 나란히 적어놨는데, 이 둘은 동치가 아닙니다. update()·bulk_update()는 SQL을 바로 쏘기 때문에:
save()오버라이드가 실행되지 않고pre_save/post_save시그널이 발화하지 않으며auto_now필드가 갱신되지 않습니다
모르고 당하면 "분명 저장했는데 시그널 기반 캐시가 안 갱신되는" 버그가 되고 알고 쓰면 시그널 연쇄를 의도적으로 우회하는 도구가 됩니다 — 파생 캐시를 재계산할 때 시그널 폭풍을 피하려고 일부러 bulk_update를 쓴 사례를 따로 정리2026-07-10고쳤는데 목록은 옛날 값 — 정본과 파생 캐시를 시그널로 다시 붙인 회고인스턴스를 수정해도 목록·다운로드·RDF는 옛 값을 보여줬습니다. 링크 시점에 한 번 복사하고 끝이었거든요. 캐시 제거·조회 시 재계산·쓰기 시 시그널 세 안을 놓고 마지막을 고른 이유와, 감사 로그가 폭발하지 않게 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()나 뷰 테이블로 내리는 게 나은 시점이 왔는데, 그 경계를 일반화할 만큼의 경험은 아직 없습니다.