중첩 “표현”은 시리얼라이저 덕분이고, 중첩 “데이터 구조”는 ORM 관계 덕분이다
| 구분 | 정체 |
|---|---|
Question | 클래스 |
question | 객체 (한 행) |
QuerySet | 객체들의 묶음 |
dict | 단순 데이터 |
JSON | 문자열 데이터 |
[1번 예시]
# AnswerComment
| id | content | answer_id |
| -- | -------- | --------- |
| 7 | 좋은 답변이네요 | 3 |
# Django ORM으로 조회
comment = AnswerComment.objects.get(id=7) # 이 comment가 “객체”
# 파이썬 객체
comment.id # 7
comment.content # "좋은 답변이네요"
comment.answer_id # 3
[2번 예시]
# 클래스 | | DB | Django |
class User: | | ------ | ---------- |
def __init__(self, name): | | 테이블 | Model 클래스 |
self.name = name | | 컬럼 | 필드 |
| | 행(Row) | 객체(Object)|
# 객체(인스턴스)
user = User("철수")
# User → 설계도(클래스) / user → 실제 만들어진 물건(객체) / user.name → 객체가 가진 값
[ 1. DB에 데이터가 있을 때 ]
id | title | content
1 | ORM? | ORM 설명
[ 2. Django가 이걸 가져오면 ]
question = Question.objects.get(id=1)
[ 3. 이때 만들어지는 question이 Django 객체 ]
question.id # 1
question.title # "ORM?"
question.content # "ORM 설명"
[ 1. 이 객체는 단순한 dict 아님 / JSON 아님 / DB 그 자체 아님 ]
[ 2. DB와 연결된 파이썬 객체 ]
question = Question.objects.get(id=1)
[ 3. 따라서 이 작업 가능 ]
question.title = "새 제목"
question.save() # DB UPDATE 발생
[ 4. 객체 조작 = DB 조작 ]
class Answer(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE)
# 사용 → 객체 안에 객체가 연결됨
answer = Answer.objects.get(id=1)
answer.question # Question 객체 / question_id 숫자 아님
answer.question.title # 질문 제목
class QuestionSerializer(serializers.Serializer):
title = serializers.CharField()
# obj = Serializer의 obj = Django 모델 객체
obj = Question.objects.get(id=1)
obj.title
obj.author.nickname
# question = 인스턴스 → 실제 DB 데이터 1줄
question = Question.objects.first()
question.title / question.author → 이미 DB에서 가져온 값
Question.objects.filter(id=1)qs = Question.objects.filter(category=1)# 아직 DB쿼리 안나감
qs = Question.objects.filter(category=1)
print(qs)
# 아래 순간 DB 쿼리 실행
list(qs)
qs.first()
qs[0]
/questions?category=3&page=2&answered=true? : Query Parameter 시작 / key=value : 하나의 파라미터 / & : 여러 개 연결Question.objects.filter() / .get() / .annotate() / .select_related()# 결과: QuerySet | 0개여도 에러 ❌
Question.objects.filter(category=1)
# 결과: Instance | 0개 ❌ / 2개 이상 ❌ → 에러
Question.objects.get(id=1)
# 조건을 제외
Question.objects.exclude(is_deleted=True)
생성 → `create()` | 단건 조회 → `get()` | 필터 → `filter()` / `exclude()`
정렬 → `order_by()` | 개수 → `count()` | 존재 여부 →`exists()`
create() / bulk_create() / get_or_create() / update_or_create()all() / get() / filter() / exclude() / first() / last() / values() / values_list()update() / delete()annotate() → 행 단위 계산(QuerySet) / aggregate() → 전체 요약(dict)| 구분 | select_related | annotate / filter |
|---|---|---|
| 방향 | 정방향 FK | 정/역방향 모두 |
| 관계 | 1:1 / N:1 | 1:N 가능 |
| JOIN 결과 | row 1개 유지 | row 늘어날 수 있음 |
| 목적 | 객체 접근 최적화 | 조건 / 집계 |
# “질문 전체 가져와라 + 정렬 조건”
qs = Question.objects.annotate(...).order_by(...)
# “근데 답변 있는 것만 / 없는 것만”
qs = filter_by_answered(qs, answered)
# “그리고 카테고리 조건도 추가”
qs = filter_by_category(qs, category)
# “검색어도 포함”
qs = filter_by_search(qs, search)
# 🔥 데이터 전체 / 있는지 없는지만 확인 / 그래서 가벼운 조회 1번 발생
if not qs.exists():
# 진짜 데이터가 나오는 순간
page_obj = paginator.get_page(page)
# 결과
page_obj.object_list
# models.py
class Question(TimeStampedModel):
author = models.ForeignKey(settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, related_name="questions")
category = models.ForeignKey(QuestionCategory,
on_delete=models.PROTECT, related_name="questions")
# services.py
qs = (
Question.objects
.select_related("author", "category")
.annotate(
answer_count=Count("answers", distinct=True),
is_answered=Case(
When(answers__isnull=False, then=True),
default=False,
output_field=BooleanField(),
),
)
.order_by("-created_at")
)
Question.objectsselect_related("author", "category")select_related 사용/미사용 차이 확인- 1. select_related를 사용하지 않은 경우 # 질문 목록 쿼리 1번
# 질문 개수만큼 author 조회 쿼리 N번
questions = Question.objects.all() # 질문 개수만큼 category 조회 쿼리 N번
# N + 1 문제 발생
for q in questions:
print(q.author.nickname)
- 2. select_related 쓴 경우
Question.objects.select_related("author", "category") # 질문 + 작성자 + 카테고리 → 쿼리 1번
qs = Question.objects.select_related("author", "category")
question = qs.first()
# 아래 전부 가능
question.title # Question 테이블
question.view_count # Question 테이블
question.author.id # User 테이블
question.author.nickname # User 테이블
question.author.profile_image_url
question.category.id # QuestionCategory 테이블
question.category.name
# models.py
class Question(models.Model):
author = models.ForeignKey(User, ...)
category = models.ForeignKey(QuestionCategory, ...)
class Answer(models.Model):
question = models.ForeignKey(Question,related_name="answers",on_delete=models.CASCADE)
# selector.py
qs = (
Question.objects
.select_related("author", "category")
.annotate(
answer_count=Count("answers", distinct=True),
is_answered=Case(
When(answers__isnull=False, then=True),
default=False,
output_field=BooleanField(),
),
)
.order_by("-created_at")
)
select_related("author", "category") author, category는 Question 쪽에 필드가 있음 → select_related("author") 가능answer_count=Count("answers", distinct=True) Question.answers → Question에 매달린 Answer들의 QuerySetWhen(answers__isnull=False, then=True)qs = qs.annotate(
content_preview=Substr("content", 1, 100)
)
Question.objects.annotate(content_preview=Substr("content", 1, 100))fields = ["title","content_preview",# content ❌] qs = (
Question.objects
.select_related("author", "category")
.annotate(answer_count=Count("ai_answers", distinct=True))
.order_by("-created_at")
)
- QuerySet 생성 시작 / 아직 DB 쿼리는 실행되지 않음 (Lazy Evaluation)
- ### .select_related("author", "category")
- Question → author(User), category(FK)
- 사용하지 않을 경우
- 질문 10개 조회 / 작성자/카테고리 접근 시 N+1 쿼리 발생
- ### .annotate(answer_count=Count("ai_answers", distinct=True))
- 질문마다 답변 수를 계산 (카드 UI에 필요한 핵심 데이터)
- ai_answers: Question ↔ QuestionAIAnswer related_name
- distinct=True 이유: join 상황에서 중복 count 방지
- ### .order_by("-created_at")
- 최신순 정렬
qs = qs.filter(
Q(title__icontains=search) |
Q(content__icontains=search)
)
- 1. 제목 OR 내용 검색 - 2. icontains → 대소문자 무시 부분 검색
from django.db.models import Count, OuterRef, Subquery, Case, When, BooleanField
# Count : 답변 개수 집계
# OuterRef : 서브쿼리에서 현재 Question row 참조
# Subquery : 썸네일 이미지 1개 추출
# Case / When : 조건에 따라 is_answered(Boolean) 생성
# BooleanField : is_answered 필드 타입 명시
.select_related("author", "category")- 2. Question 모델
class Question(models.Model):
author = models.ForeignKey(User, ...)
category = models.ForeignKey(QuestionCategory, ...)
- 3. select_related가 없으면?
questions = Question.objects.all()
for q in questions:
q.author.nickname # ❌ 매번 DB 쿼리
q.category.name # ❌ 매번 DB 쿼리
# 질문 20개 조회 → author 접근 → 20번 쿼리 / category 접근 → 20번 쿼리
# 총 41번 쿼리 → N+1 문제
- 4. select_related 사용
Question.objects.select_related("author", "category")
# SQL적 의미
SELECT ...FROM questions
LEFT OUTER JOIN users ON ...
LEFT OUTER JOIN question_categories ON ...
# 질문 + 작성자 + 카테고리 한 번에 / 이후 serializer에서 접근해도 추가 쿼리 없음
qs = (
Question.objects
.select_related(...)
.annotate(...)
.order_by(...)
)
Question.objects → 질문 테이블을 조회하겠다는 선언.select_related("author", "category") .annotate(answer_count=Count("answers", distinct=True),)- 1
qs = Question.objects.all()
- 2
qs = (
Question.objects
.filter(...)
.annotate(...)
)
qs = Question.objects.all()
qs = filter_by_answered(qs, answered)
qs = filter_by_category(qs, category)
qs = filter_by_search(qs, search)
# QuerySet evaluation(실행)
qs.exists()
list(qs) # 여기서 실행하는 듯
for q in qs:
paginator = Paginator(qs, ...)
# 내 코드 기준
if not qs.exists():
paginator = Paginator(qs, page_size) # DB 조회 1번
page_obj = paginator.get_page(page) # 여기서 실제 SELECT 실행
qs = (
Question.objects
.select_related("author", "category")
.annotate(
answer_count=Count("answers", distinct=True),
is_answered=Case(
When(answers__isnull=False, then=True),
default=False,
output_field=BooleanField(),
),
)
.order_by("-created_at")
)
| 항목 | answer_count | is_answered |
|---|---|---|
| 타입 | Integer | Boolean |
| 의미 | 몇 개 있는지 | 하나라도 있는지 |
| SQL | COUNT() | CASE WHEN |
| 용도 | 숫자 표시 | 탭 필터 / 상태 |
에러 발생 / qs에는 있고 시리얼라이즈에는 없는경우는 에러 발생 X
| 상황 | 결과 |
|---|---|
| qs에 있음 + serializer에 있음 | ✅ 정상 출력 |
| qs에 있음 + serializer에 없음 | ❌ 무시 |
| qs에 없음 + serializer에 있음 | 💥 에러 |
Serializer에 정의된 필드 값은 모델 필드이거나, annotate로 qs에 붙어 있거나,
qs = (
Question.objects
.select_related("author", "category")
.annotate(...)
.order_by("-created_at")
)
qs = filter_by_answered(qs, answered)
qs = filter_by_category(qs, category)
qs = filter_by_search(qs, search)
if not qs.exists(): # 여기서 첫번 째 실행
기본 질문 목록에
# services.py
qs = (
Question.objects
.select_related("author", "category")
.annotate(
answer_count=Count("answers", distinct=True),
is_answered=Case(
When(answers__isnull=False, then=True),
default=False,
output_field=BooleanField(),
),
)
.order_by("-created_at")
)
# srializers.py
class QuestionListSerializer(serializers.ModelSerializer):
category = serializers.SerializerMethodField()
author = QuestionAuthorSerializer()
content_preview = serializers.CharField()
answer_count = serializers.IntegerField()
is_answered = serializers.BooleanField()
thumbnail_image_url = serializers.CharField(allow_null=True)
class Meta:
model = Question
fields = [ # Question 모델 필드 (자동 OK)
"id", # id / title / view_count / created_at
"category", # select_related 로 해결되는 것
"author", # author / category
"title", # annotate로 추가된 가짜 컬럼
"content_preview", # answer_count / is_answerd
"answer_count", # annotate로 추가된 가짜 컬럼(서비스에서 처리)
"is_answered", # content_preview / thumbnail_image_url
"view_count",
"created_at",
"thumbnail_image_url",
]
thumbnail_subquery = (
QuestionImage.objects
.filter(question=OuterRef("pk")) # question: QuestionImage의 필드명
.order_by("created_at")
.values("img_url")[:1]
)
qs = qs.annotate(thumbnail_image_url=Subquery(thumbnail_subquery))
OuterRef("pk").values("img_url")[:1][:1] : 그 중 딱 1개만 (LIMIT 1)paginator = Paginator(qs, page_size)
page_obj = paginator.get_page(page)
return page_obj.object_list, {
"page": page,
"page_size": page_size,
"total_pages": paginator.num_pages,
"total_count": paginator.count,
}
# page info 메타
"page": page | → 요청한 페이지 번호
"page_size": page_size | → 한 페이지에 몇 개씩
"total_pages": paginator.num_pages | → 전체 페이지 수
(내부적으로 count 기반으로 계산됨)
"total_count": paginator.count | → 전체 질문 개수 (필터 적용 이후 기준)
paginator.count는 보통 DB에 SELECT COUNT(*) ... 같은 쿼리를 한 번 날려서 전체 개수를 알아냄
qs = (
Question.objects # 바깥 쿼리(outer query) ⭐️
.select_related("author", "category")
.annotate(
thumbnail_image_url=Subquery(thumbnail_subquery)
)
)
# QuestionImage 테이블을 조회하는 쿼리
thumbnail_subquery = (
QuestionImage.objects
.filter(question=OuterRef("pk"))
...
)
[바깥] Question ────────────────▶ 한 row씩 처리
│
└── [안쪽] QuestionImage ─▶ 해당 Question의 이미지 1개
바깥 쿼리: Question
-------------------
row 1: pk=10
row 2: pk=11
row 3: pk=12
안쪽 쿼리: QuestionImage
-----------------------
question_id = OuterRef("pk")
# DB처리
row 1 → question_id = 10
row 2 → question_id = 11
row 3 → question_id = 12
각 Question마다 자기 이미지 1개를 찾아서 붙임
조회 시 DB는 Question을 기준으로 한 row씩 처리하면서,
실전 예시
# --- [ Question ] ---
class Question(TimeStampedModel):
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="questions")
category = models.ForeignKey(QuestionCategory, on_delete=models.PROTECT,
related_name="questions")
title = models.CharField(max_length=50)
content = models.TextField()
view_count = models.BigIntegerField(default=0)
# --- [ User ] ---
class User(AbstractBaseUser, PermissionsMixin, TimeStampedModel):
email = models.EmailField(unique=True)
name = models.CharField(max_length=30)
nickname = models.CharField(max_length=15, unique=True)
phone_number = models.CharField(max_length=20)
gender = models.CharField(choices=GenderChoices.choices, max_length=1)
birthday = models.DateField()
profile_image_url = models.URLField(null=True, blank=True)
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
role = models.CharField(choices=RoleChoices.choices, max_length=2,
default=RoleChoices.USER)
# --- [ Category ] ---
class QuestionCategory(TimeStampedModel):
CATEGORY_TYPES = (
("large", "대분류"),
("medium", "중분류"),
("small", "소분류"),
)
name = models.CharField(max_length=50, verbose_name="카테고리 이름")
type = models.CharField(max_length=10, choices=CATEGORY_TYPES, default="large", verbose_name="카테고리 종류")
parent = models.ForeignKey(
"self", on_delete=models.CASCADE, null=True, blank=True, related_name="children", verbose_name="부모 카테고리"
)
author__nicknameauthor: Question 모델에 정의된 ForeignKey 필드 (작성자)__: Django에서 모델 간의 관계를 "따라가기(follow)" 위해 사용하는 약속된 구분자nickname: 연결된 User 모델이 실제로 가지고 있는 속성(필드) 이름category__namecategory: Question 모델의 ForeignKey 필드__: 관계 연결 구분자name: 연결된 QuestionCategory 모델의 필드 (카테고리명)category__parent__namecategory: Question 모델의 카테고리 필드__: 첫 번째 연결parent: QuestionCategory 모델 내부의 부모 카테고리 필드__: 두 번째 연결name: 부모 카테고리의 이름 필드questions__title (역참조)questions: Question 모델의 author 필드에 정의된 related_name="questions"__: 관계 연결 구분자title: Question 모델의 제목 필드User.objects.filter(questions__title__contains="오류") -> 제목에 '오류'가 들어간 질문을 쓴 유저 찾기created_at__year (날짜 필드 조회)created_at: 생성일시(DateTimeField) 필드__: 필드 속성 접근 구분자year: 날짜 데이터의 내장 속성 (연도)