2025/12/17 MainProject - 10

김기훈·2025년 12월 17일

TIL

목록 보기
86/194

오늘 학습 내용 ✅

qs 이해 2

# 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")
)
  • 위의 qs의 의도

    • 질문 목록 카드 UI에 필요한 데이터를 한 번에 가져오자
  • Question.objects

    • FROM questions 테이블 / 즉, Question 모델의 row들이 기준
    • 하지만, 카드 UI에서는 Question 필드만으로는 부족
      • 작성자 닉네임 / 작성자 프로필 이미지 / 카테고리 이름 → 전부 다른 테이블에 위치
  • select_related("author", "category")

    • "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를 사용하지 않은 경우		# 질문 목록 쿼리 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

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_relatedannotate / filter
방향정방향 FK정/역방향 모두
관계1:1 / N:11: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]:
        # GET: 모두 허용
        if self.request.method == "GET":
            return []

        # POST: 질문 등록 권한
        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
→ 질문 생성 성공
profile
안녕하세요.

0개의 댓글