오늘 학습 내용 ✅
qs 이해 2
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
qs 2
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의 필드명
- answer_count=Count("answers", distinct=True)의
- "answers"는 Answer(답변) 모델의 ForeignKey에 정의된 related_name
- When(answers__isnull=False, then=True)
- 이 Question에 연결된 Answer row가 하나라도 있으면 True
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
Django ORM 규칙
- annotate, filter, exclude에서는 related_name을 필드처럼 사용 가능
| 구분 | select_related | annotate / filter |
|---|
| 방향 | 정방향 FK | 정/역방향 모두 |
| 관계 | 1:1 / N:1 | 1:N 가능 |
| JOIN 결과 | row 1개 유지 | row 늘어날 수 있음 |
| 목적 | 객체 접근 최적화 | 조건 / 집계 |
qs 3
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 ❌]
새롭게 알게된 내용 ✅
- 출력 Serializer에서는 뭐까지 허용되는가
- SerializerMethodField
- 데이터 가공 (path, preview)
- 계산 필드
- 캐싱 (in-memory)
- 포맷 변경 (날짜, 문자열)
- UI 친화적 구조 생성
입력 Serializer
└─ validation only
Service
└─ 데이터 조회 + 계산 필드
출력 Serializer
└─ 표현 + 가공 + 캐싱
Serializer가 내부적으로 하는 일
Serializer.__init__() 안에서는 다음 작업들이 일어남
- self.instance 설정 (QuerySet / 객체 저장)
- self.initial_data 설정 (입력 Serializer일 때)
- self.fields 구성 (Meta.fields 기반)
- SerializerMethodField 바인딩
- context 설정
- many=True 처리
DRF는 요청 처리 순서
- Authentication
- Permission
- View logic
어려운 내용(추가 학습 필요) ✅
오늘 발생한 문제(발생 했다면) ✅
- 문제: view안에서 view를 잘못 호출함
- 질문 등록 / 조회 두가지 api가 같은 엔드포인트를 가지고
- 서로 권한부여가 다르기에 그냥 아래와 같이 관리하기로 하였으나 테스트할때 펑펑
class QuestionAPIView(APIView):
def get(self, request, *args, **kwargs):
return QuestionListAPIView.as_view()(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
return QuestionCreateAPIView.as_view()(request, *args, **kwargs)
- 원인: DRF가 QuestionAPIView에 들어오면서 HttpRequest → DRF Request 로 변환
- 그 DRF Request를 다시 QuestionListAPIView.as_view()에 넘김
- 그런데 .as_view()는 HttpRequest를 기대함 그래서 타입 충돌 → AssertionError
- 해결: 뷰를 하나로 합침
- 권한때문에 나눈 거였는데 아래와 같이 사용해도 되는걸 몰랐음
- self.validation_error_message = "유효하지 않은 목록 조회 요청입니다."
- 문제2: 원래 잘 되던 질문등록 api 테스트 코드가 갑자기 터짐
- 401에러 메세지가 포매팅이 안되고 drf자체 에러 메세지 표기 됨
- 원인: Authentication 단계에서 NotAuthenticated 발생 → Permission까지 오지도 않음
- 그리하여 Permission에서 raise QuestionCreateNotAuthenticated() 해도 안됨
- 해결: 뷰에서 동적으로 처리하도록 변경
- 클래스단에서 전체 허용해놓고 포스트에서만 권한처리 했는데 전혀안되서 그냥 전체적으로 관리
- 문제3: 문제 2를 해결하기 위해 아래와 같이 작성했으나 POST가 항상 401
class QuestionAPIView(APIView):
authentication_classes = []
def get_permissions(self) -> list[BasePermission]:
if self.request.method == "GET":
return []
if self.request.method == "POST":
return [QuestionCreatePermission()]
return []
- 원인: 클래스 레밸에
authentication_classes = []를 박아버림
- 이렇게 되면 DRF가 아예 인증을 수행하지 않음 그렇게 되면
- 토큰/세션을 보내도 request.user가 AnonymousUser로 남고,
- QuestionCreatePermission에서 무조건 401 예외
- 해결: GET만 인증 비활성, POST는 기본 인증 사용
- 클래스 레밸에 있는
authentication_classes = []만 지우면
- GET → permission 검사 ❌ (누구나 가능) / POST → QuestionCreatePermission에서 검사
- 기존 조건에 맞기 때문에 작동은 하지만 안전하지 않다
- DRF 요청 흐름은 Authentication → Permission → View
- get_permissions()는 인증이 끝난 다음에 실행됨
class QuestionAPIView(APIView):
def get_authenticators(self):
if self.request.method == "GET":
return []
return super().get_authenticators()
def get_permissions(self):
if self.request.method == "POST":
return [QuestionCreatePermission()]
return []
# GET /questions
get_authenticators()
→ []
→ 인증 생략
→ request.user = AnonymousUser
get_permissions()
→ []
→ 권한 검사 생략
→ 목록 조회 성공 (200)
POST /questions
get_authenticators()
→ super()
→ [JWTAuthentication]
JWTAuthentication.authenticate()
→ 토큰 검증
→ request.user = User
get_permissions()
→ [QuestionCreatePermission]
→ request.user.is_authenticated == True
→ 질문 생성 성공