
Create 🔴
질문등록api
- 응답은 "question_id"만 내려주기
- 응답은 무엇을 만들었는가 / 성공했는가 가 중요 등록api의 응답은 아이디값 하나로 충분
- 응답을 풍부하게 줄 경우
- 책임이 섞인다 (Single Responsibility 위반)
- 현재 api명세서에 나온 응답 스키마는 사실상 상세 조회 응답급
- Create API가 Read API의 책임까지 떠안는 구조
- 중복 데이터 & 유지보수 비용 증가
- 등록 응답 / 상세 조회 응답 둘 다에 같은 필드들이 중복된다.
- 나중에 필드 하나 바뀌면?
- 등록 API 수정 / 상세 조회 API 수정 / 테스트 2배 / 프론트 파싱 로직도 2군데
- ID만 주는 게 왜 유용한 이유
- API 역할이 명확해진다
- POST /questions = 생성 | GET /questions/{id} = 조회
- 프론트 흐름이 자연스럽다
질문 등록
→ question_id 수신
→ 상세 페이지로 이동
→ 상세 조회 API 호출
List 🔴
카테고리 🟢
전체 흐름
- query parameter (id) → DB 필터링(FK) → 응답에서는 문자열
요청 단계 → 카테고리는 “숫자 ID”로 들어온다
GET /api/v1/qna/questions?category=3
category=3 → QuestionCategory 테이블의 PK → 문자열 "3"의 형태로 들어옴
if category is not None:
qs = qs.filter(category_id=category)
3. 응답 단계 → 카테고리는 “문자열”로 내려간다
class QuestionListSerializer(serializers.ModelSerializer):
category = serializers.CharField()
1. Question.category는 FK 객체
2. 응답이 기대 하는 내용 → "category": "프론트엔드 > React > 상태관리"
3. 즉, 조회조건에서는 ID / 응답에서는 문자열로 출력
트리 구조
QuestionCategory
├─ parent (self FK)
└─ children
1. 트리구조는 존재는 하지만 아직 사용은 X
2. 즉, “중분류를 선택하면 소분류 질문까지 같이 보여준다” → 아직 구현 안 됨
카테고리 트리는 “고정 구조”로 취급
- 대분류 / 중분류 / 소분류 개수 / 깊이 / 이름 규칙
- API 코드에서는 “트리가 몇 단계인지” / “이게 소분류인지" 같은 걸 몰라도 됨
카테고리 문자열을 책임지는 위치
Service
- 카테고리 트리 규칙은 도메인 규칙 (Serializer는 문자열을 그냥 출력만)
- 어드민이 정의한 구조를 / “어떻게 보여줄지”는 서버 책임
카테고리 핵심
| id | name | parent_id | 백엔드 (id=1) # 백엔드.parent_id = NULL
| -- | ------ | --------- | └─ Django (id=2) # Django.parent_id = 1
| 1 | 백엔드 | NULL | └─ ORM (id=3) # ORM.parent_id = 2
| 2 | Django | 1 |
| 3 | ORM | 2 |
- 질문 등록 시에 대/중/소를 각각 필수로 선택 하는 것이 아닌 선택된 하나의 카테고리만 저장 하는 구조
- 프론트에서는 단계적으로 선택하게 보이지만, 백엔드는 항상 “최종 선택된 카테고리 1개”만 받는다
1. 모델의 의미
카테고리는 트리 구조 | 깊이는 고정 ❌ (2단계든 3단계든 가능) | “대/중/소”는 UI 개념, DB 제약 X
class QuestionCategory(TimeStampedModel):
parent = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="children",
)
name = models.CharField(max_length=15)
2. related_name="children"
- “부모 입장에서, 나를 parent로 참조하는 애들을 children이라고 부르겠다.”
- 즉, parent를 참조하는 row들이 있어서, Django가 역참조 관계(children)를 만들어준 것
3. Question은 카테고리 하나만 참조한다
class Question(models.Model):
category = models.ForeignKey(
QuestionCategory,
on_delete=models.PROTECT,
)
ORM
- 1. 자식 → 부모
orm = QuestionCategory.objects.get(name="ORM")
orm.parent
- 2. 부모 → 자식
django = QuestionCategory.objects.get(name="Django")
django.children.all()
if category.parent is None:
elif category.children.exists():
else:
backend = QuestionCategory.objects.create(name="백엔드")
django = QuestionCategory.objects.create(name="Django", parent=backend)
orm = QuestionCategory.objects.create(name="ORM", parent=django)
django.parent_id = backend.id
orm.parent_id = django.id
구현방식
{
"main_category": 1,
"sub_category": 3,
"detail_category": 7
}
- 이 방식을 사용하면, DB 모델과 안 맞음 / 불필요한 중복 정보 / 정합성 검증 지옥
- “대분류와 중분류가 실제로 연결돼 있나?” / “중분류 없이 소분류가 가능한가?” / 테스트 폭발
카테고리 기능 추가 ❗️
중분류 선택 시 하위 포함 처리
- 대/중/소는 UI 개념인데, 백엔드는 어떻게 구분하는가?
- 백엔드는 ‘대/중/소’를 구분하지 않는다. 대신 “부모가 있느냐 / 자식이 있느냐”만 본다.
class QuestionCategory(TimeStampedModel): |
parent = models.ForeignKey( | parent == None → 루트 카테고리 (UI상 대분류)
"self", | parent != None
null=True, | ├─ children 있음 → 중간 노드 (UI상 중분류)
blank=True, | └─ children 없음 → leaf 노드 (UI상 소분류)
on_delete=models.SET_NULL, |
related_name="children",
)
name = models.CharField(max_length=15)
-
백엔드 판단법
| UI 개념 | 백엔드 판단 기준 |
|---|
| 대분류 | parent is None |
| 중분류 | parent != None AND children.exists() |
| 소분류 | children.exists() == False |
중분류 선택 시 하위 포함 기능 기본 초안
- 선택된 카테고리 + 그 하위 모든 카테고리 ID를 구해서 IN 조건으로 필터링
def get_descendant_ids(category):
ids = [category.id]
for child in category.children.all():
ids.extend(get_descendant_ids(child))
return ids
category_ids = get_descendant_ids(selected_category)
qs = qs.filter(category_id__in=category_ids)
작동 예시 ❗️
-
-
backend = QuestionCategory.objects.create(name="백엔드")
-
django = QuestionCategory.objects.create(name="Django", parent=backend)
-
주의 ⚠️
backend = QuestionCategory(name="백엔드")
django = QuestionCategory.objects.create(name="Django", parent=backend)
- 여기서는 에러 발생
- backend는 아직 DB에 저장되지 않아서 backend.id == None
- QuestionCategory.objects.create(name="백엔드") 이렇게 사용 필요
QuerySet(qs) 🟢
QuerySet = Django 객체들의 집합
- “데이터”가 아닌 ‘어떤 데이터를 어떻게 가져올지 적어놓은 주문서’
- “아직 실행되지 않은 DB 조회 계획서이자, 실행되면 Django 객체들의 집합이 되는 것”
QuerySet이 생기는 순간
qs = Question.objects.all()
- DB 조회 안 함 / 데이터 안 가져옴 / “Question 테이블을 전부 가져올 예정” 이라는 계획만 존재
- 1. “질문 전체 가져와라 + 정렬 조건” → qs = Question.objects.annotate(...).order_by(...)
- 2. “근데 답변 있는 것만 / 없는 것만” → qs = filter_by_answered(qs, answered)
- 3. “그리고 카테고리 조건도 추가” → qs = filter_by_category(qs, category)
- 4. “검색어도 포함” → qs = filter_by_search(qs, search)
- 5. 데이터 전체 / 있는지 없는지만 확인 / 그래서 가벼운 조회 1번 발생if not qs.exists():
- 6. 진짜 데이터가 나오는 순간 → page_obj = paginator.get_page(page)
- 7. 결과 → page_obj.object_list
QuerySet은 “DB 쿼리 조립기"
qs = (
Question.objects
.filter(title__icontains="ORM")
.exclude(view_count=0)
.order_by("-created_at")
)
DB에 진짜 쿼리를 날리는 시기
list(qs) / qs[0] / for q in qs: / len(qs) / bool(qs)
QuerySet의 결과물
qs = Question.objects.all()
[
<Question 객체>,
<Question 객체>,
<Question 객체>,
]
QuerySet vs 객체 차이
| 구분 | 정체 |
|---|
Question.objects.get() | 객체 1개 |
Question.objects.filter() | QuerySet |
qs[0] | 객체 |
qs | 객체들의 묶음 |
question = Question.objects.get(id=1)
qs = Question.objects.filter(id=1)
type(question)
type(qs)
QuerySet 특성
qs = Question.objects.all()
qs = qs.filter(category=1)
qs = qs.exclude(is_deleted=True)
QuerySet은 리스트X
qs.append() / qs.pop() 불가능 | QuerySet은 읽기 전용 결과 뷰(view)에 가깝다
- QuerySet은 DB 조회 결과 / 리스트는 메모리 데이터
QuerySet이 Serializer로 넘어갈 때
serializer = QuestionSerializer(qs, many=True)
- Serializer는 QuerySet을 순회
- 내부의 Question 객체 하나씩 처리 / dict로 변환 / 최종적으로 JSON 응답
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")
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")
)
위의 qs의 의도 → 질문 목록 카드 UI에 필요한 데이터를 한 번에 가져오자
Question.objects
- FROM questions 테이블 / 즉, Question 모델의 row들이 기준
- 하지만, 카드 UI에서는 Question 필드만으로는 부족
- 작성자 닉네임 / 작성자 프로필 이미지 / 카테고리 이름 → 전부 다른 테이블에 위치
- "author", "category" 는 모델의 필드명 (related_name X)
- ForeignKey / OneToOne 관계를 JOIN으로 한 번에 가져오는 것
- author → User 테이블 FK / category → QuestionCategory 테이블 FK
SELECT *
FROM questions
JOIN users ON questions.author_id = users.id
JOIN question_categories ON questions.category_id = question_categories.id
ORDER BY questions.created_at DESC;
- 1. select_related를 사용하지 않은 경우
questions = Question.objects.all()
for q in questions:
print(q.author.nickname)
- 2. select_related 쓴 경우
Question.objects.select_related("author", "category")
qs = Question.objects.select_related("author", "category")
question = qs.first()
question.title
question.view_count
question.author.id
question.author.nickname
question.author.profile_image_url
question.category.id
question.category.name
Django ORM 규칙
annotate
qs = Question.objects.annotate(answer_count=Count("answers"))
- 해석: “Question 객체를 가져오는데 answer_count라는 가짜 컬럼을 붙여서 가져와라”
qs = qs.annotate(
content_preview=Substr("content", 1, 100)
)
- content 컬럼을 1~100자만 잘라서 결과 컬럼으로 내려준다.
- Question.objects.annotate(content_preview=Substr("content", 1, 100))
- 실제형태에 따라 원래는 Question 모델 전체 필드도 같이 조회됨
- 즉, content (원문)도 조회됨 / content_preview (잘린 값)도 조회됨
- 하지만 시리얼라이즈에서 목록에서 원문이 보이지 않게 하면 됨
fields = ["title","content_preview",# content ❌]
코드 분석
qs = (
Question.objects
.select_related("author", "category")
.annotate(
answer_count=Count("answers", distinct=True),
)
.order_by("-created_at")
)
select_related("author", "category") : "author", "category" → Question의 필드명
answer_count=Count("answers", distinct=True)
"answers" : Answer(답변) 모델의 ForeignKey에 정의된 related_name
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)
author, category: Question 쪽에 필드 존재 → select_related("author") ⭕️
answers: Question 모델에 필드가 없음 → Question FK의 related_name
Question.answers → Question에 매달린 Answer들의 QuerySet
Detail 🔴
기본 세팅 🟢
Student ───▶ School
│
└───▶ Assignment (여러 개)
│
└───▶ Comment (여러 개)
class School(models.Model):
name = models.CharField(max_length=50)
class Student(models.Model):
name = models.CharField(max_length=50)
school = models.ForeignKey(School, on_delete=models.CASCADE)
class Assignment(models.Model):
student = models.ForeignKey(Student, on_delete=models.CASCADE)
title = models.CharField(max_length=100)
class Comment(models.Model):
assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE)
content = models.TextField()
최적화 전
students = Student.objects.all() | | 단계 | 쿼리 |
| | --------- | --------- |
for s in students: | | 학생 목록 | 1 |
print(s.school.name) | | 학생마다 학교 | 10 |
| | 학생마다 과제 | 10 |
for a in s.assignment_set.all(): | | 과제마다 댓글 | 30 |
print(a.title) | | **총합** | ❌ 51번 |
|
for c in a.comment_set.all():
print(c.content)
“1명당 1개” 관계를 JOIN / 한 객체당 딱 하나만 연결된 관계 / FK
students = Student.objects.select_related("school")
for s in students:
print(s.school.name)
- “여러 개” 관계를 따로 모아서 묶기 / 1명이 여러 개를 가질 때
students = Student.objects.select_related("school").prefetch_related("assignment_set")
for s in students:
for a in s.assignment_set.all():
print(a.title)
Prefetch
for a in s.assignment_set.all():
for c in a.comment_set.all():
print(c.content)
from django.db.models import Prefetch
students = Student.objects.select_related("school").prefetch_related(
Prefetch(
"assignment_set",
queryset=Assignment.objects.prefetch_related("comment_set")
)
)
| 단계 | 쿼리 |
|---|
| 학생 + 학교 | 1 |
| 모든 과제 | 1 |
| 모든 댓글 | 1 |
| 총합 | ✅ 3번 |
정리
| 도구 | 한 문장 설명 |
|---|
| select_related | “딱 1개 연결된 건 JOIN으로 한 번에” |
| prefetch_related | “여러 개는 따로 모아서 파이썬에서 묶기” |
| Prefetch | “prefetch할 때 가져오는 방식까지 직접 정하기” |
내 코드 🟢
def get_question_detail_queryset(question_id: int) -> Question | None:
return (
Question.objects
.select_related("author", "category")
.prefetch_related(
"images",
Prefetch(
"answers",
queryset=Answer.objects
.select_related("author")
.prefetch_related(
"answer_comments__author"
)
)
)
.filter(id=question_id)
.first()
)
Question (1) # 질문 1개를 가져오면서
├─ images (N) # 질문의 이미지들
└─ answers (N) # 질문의 답변들
├─ author (1) # 각 답변의 작성자
└─ answer_comments (N) # 각 답변의 댓글들
└─ author (1) # 각 댓글의 작성자
# 를 추가 쿼리 없이 한 번에 메모리에 올리기
| 관계 | 방법 |
|---|
| FK / OneToOne | select_related |
| 1:N / M:N | prefetch_related |
| 1:N 안에서 FK | Prefetch + select_related |
| 깊은 중첩 | Prefetch 중첩 |
Question
├─ author (FK)
├─ category (FK)
├─ images (1:N)
└─ answers (1:N)
├─ author (FK)
└─ answer_comments (1:N)
└─ author (FK)
.select_related("author", "category")
- FK / OneToOne
- SQL JOIN
- “한 행당 하나”일 때
- JOIN이 아니라 별도 쿼리로 미리 로딩하고 싶을 때
.prefetch_related("answers", "images")
- 1:N / M:N
- 별도 쿼리 + Python에서 매칭
- “여러 개일 수 있을 때”
Prefetch
- prefetch_related("answers")는 “그냥 다 가져오기”밖에 못 함
- 답변 최신순 정렬 / 특정 조건만 가져오기
- 답변에 또 select_related / prefetch_related 적용 가능
from django.db.models import Prefetch
Prefetch(
"관계명",
queryset=커스텀_queryset,
to_attr="저장할_속성명"
)
| 상황 | 선택 |
|---|
| 그냥 관계 미리 불러오기 | prefetch_related("xxx") |
| 필터 / 정렬 필요 | Prefetch |
내부에 select_related 필요 | Prefetch |
| 댓글 → 작성자 같이 필요 | Prefetch |
| 성능 튜닝 | Prefetch |
from django.db.models import Prefetch
question = (
Question.objects
.select_related(
"author",
"category",
)
.prefetch_related(
"images",
Prefetch(
"answers",
queryset=Answer.objects
.select_related("author")
.prefetch_related(
Prefetch(
"answer_comments",
queryset=AnswerComment.objects.select_related("author"),
)
),
),
)
.get(id=question_id)
)
- 1. 질문 1개당 작성자 1명, 카테고리 1개(Question.author, Question.category → FK)
Question.objects.select_related("author", "category")
- 2. 이미지 개수는 0~N개 / JOIN 하면 row 수가 폭발함
Question.objects.prefetch_related("images")
- 3. answers는 1:N(prefetch) 하지만 answer.author는 FK(select_related)
Prefetch("answers",queryset=Answer.objects.select_related("author")...)
- 4. 댓글은 답변마다 여러 개 / 댓글 작성자도 FK
.prefetch_related(Prefetch
("answer_comments",queryset=AnswerComment.objects.select_related("author"),))
N+1 문제
questions = Question.objects.all()
for q in questions:
print(q.author.nickname)
1. 질문 목록 가져오는 쿼리 1번 (N개 질문)
2. 각 질문마다 author를 따로 가져오면 추가로 N번
3. 총 1 + N → 이게 N+1 문제
DRF serializer에서 중첩된 관계(작성자/이미지/답변/댓글)를 펼칠수록 N+1이 더 심해짐.
.prefetch_related(
"images",
Prefetch(
"answers",
queryset=Answer.objects
.select_related("author")
.prefetch_related("answer_comments__author")
)
)
해석
- Question을 가져올 때 Question.images 는 prefetch 하고
- Question.answers 는 특별한 조건이 적용된 쿼리셋으로 prefetch 하겠다
- 그 answers 안에서는
- answer.author 는 select_related
- answer.answer_comments 와 그 author 는 prefetch 하겠다
question.images.all()
[
QuestionImage(question_id=1, img_url="a.jpg"),
QuestionImage(question_id=1, img_url="b.jpg"),
QuestionImage(question_id=1, img_url="c.jpg"),
]
- prefetch 없을 때
question = Question.objects.get(id=1)
question.images.all()
- prefetch 있을 때
question = Question.objects.prefetch_related("images").get(id=1)
question.images.all()
- prefetch는 이 역참조 이름을 기준으로 동작 related_name이 없었다면
question.questionimage_set
-
images는 Prefetch 객체 안쓴 이유
| 이유 | 설명 |
|---|
| 필터 없음 | 이미지 전부 필요 |
| 정렬 필요 없음 | 생성순 그대로 OK |
| 추가 FK 접근 없음 | serializer에서 img_url만 씀 |
-
요약
- related_name="images" 로 인해 Question.pk == QuestionImage.question_id 인
- QuestionImage 객체들(row들) 을 .prefetch_related("images") 가 미리
- 로딩해서 question.images 에 붙여준다
Prefetch
- Question.answers 를 불러올 때 기본 Answer 쿼리 말고 내가 정의한 이 쿼리셋으로 가져와라
Prefetch(
"answers",
queryset=Answer.objects
.select_related("author")
.prefetch_related("answer_comments__author")
)
| 항목 | 의미 |
| ----------- | ----------------------- |
| `"answers"` | Question → answers 관계 |
| `queryset=` | answers를 가져올 **커스텀 쿼리** |
- .prefetch_related("answer_comments__author")
- answer → answer_comments (N)
- answer_comment → author (FK)
전체 요약
- 이 prefetch_related 블록은 Question → Answers → AnswerComments → Author 까지
- 모든 중첩 관계를 N+1 없이 한 번에 로딩하기 위한 최적화 쿼리 구성이다