2025/12/15 MainProject - 8

김기훈·2025년 12월 15일

TIL

목록 보기
84/194

오늘 학습 내용 ✅

카테고리

카테고리 진행 방식

  • 전체 흐름

    • query parameter (id) → DB 필터링(FK) → 응답에서는 문자열
  • 요청 단계 → 카테고리는 “숫자 ID”로 들어온다

    • GET /api/v1/qna/questions?category=3
      • category=3 → QuestionCategory 테이블의 PK → 문자열 "3"의 형태로 들어옴
  • Query Serializer 단계

    – “정수인가?”만 검증 → 이 Serializer는 “요청 형태”만 검증하는 계층
    class QuestionListQuerySerializer(serializers.Serializer):
        category = serializers.IntegerField(required=False)
    • 여기에서 하는 일 → "3" → 3 (int 변환) / category 없으면 → 필터 미적용
  • Service 단계 → “FK 기준으로 필터링”

    • Question.category → category = models.ForeignKey(QuestionCategory, ...)
      • 현재 → 소분류 ID를 주면 → 그 소분류 질문만 / 중분류 ID를 주면 → 그 중분류에 직접 달린 질문만
if category is not None:
    qs = qs.filter(category_id=category)
  • 응답 단계 → 카테고리는 “문자열”로 내려간다

class QuestionListSerializer(serializers.ModelSerializer):
    category = serializers.CharField()

1. Question.category는 FK 객체
2. 응답이 기대 하는 내용 → "category": "프론트엔드 > React > 상태관리"
3., 조회조건에서는 ID / 응답에서는 문자열로 출력

트리 구조

QuestionCategory
 ├─ parent (self FK)
 └─ children

1. 트리구조는 존재는 하지만 아직 사용은 X
2. 즉, “중분류를 선택하면 소분류 질문까지 같이 보여준다” → 아직 구현 안 됨

카테고리 추가

  • 카테고리는 어드민에서만 관리한다

    • 카테고리는 어드민에서만 CRUD → 일반 API에서는 ‘참조만 하는 정적 데이터’로 취급
  • 일반 API에서 카테고리의 역할

    • 질문 등록 시 선택 / 질문 조회 시 필터 / 응답에서 표시
    • 즉, 카테고리는 시스템이 정의한 기준값으로 사용
  • 코드 맞물리기

    • 질문 등록 API에서 카테고리

      • category = serializers.IntegerField()
        • 프론트가 어드민이 만들어 둔 카테고리 목록을 받아서 그중 하나의 ID만 전송
        • Service에서 category_id로 FK 연결
    • 질문 조회 API에서 카테고리 필터

      • qs = qs.filter(category_id=category)
        • 이 카테고리에 속한 질문” / 어드민이 구조를 변경해도 id기반은 안정적임
    • 응답에서 카테고리 표현

      • "category": "프론트엔드 > React > 상태관리"
        • 이 문자열은 어드민이 만든 트리구조를 그대로 읽어서 표현한 것 (시스템 정의)
  • 주의 ⚠️

    • 카테고리는 Soft 규칙, Hard 참조
    class Question(models.Model):
        category = models.ForeignKey(
            QuestionCategory,
            on_delete=models.PROTECT
        )
    
    1. PROTECT를 쓰는 이유
    - 이미 질문이 달린 카테고리는 삭제 ❌ / 데이터 무결성 보장 → 어드민 실수 방지 
    • 카테고리 트리는 “고정 구조”로 취급
      • 대분류 / 중분류 / 소분류 개수 / 깊이 / 이름 규칙
      • API 코드에서는 “트리가 몇 단계인지” / “이게 소분류인지" 같은 걸 몰라도 됨
  • 카테고리 문자열을 책임지는 위치

    • Service
    • 카테고리 트리 규칙은 도메인 규칙 / 어드민이 정의한 구조를 / “어떻게 보여줄지”는 서버 책임
    • Serializer는 문자열을 그냥 출력만

카테고리 핵심

  • 질문 등록 시에 대/중/소를 각각 필수로 선택 하는 것이 아닌 선택된 하나의 카테고리만 저장 하는 구조
    • 프론트에서는 단계적으로 선택하게 보이지만, 백엔드는 항상 “최종 선택된 카테고리 1개”만 받는다
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)

1. 모델의 의미 
카테고리는 트리 구조 / 깊이는 고정 ❌ (2단계든 3단계든 가능) / “대//소”는 UI 개념, DB 제약 X

class Question(models.Model):
    category = models.ForeignKey(
        QuestionCategory,
        on_delete=models.PROTECT,
    )
    
2. Question은 카테고리 하나만 참조한다
  • 대/중/소 를 각각 선택?

{
  "main_category": 1,
  "sub_category": 3,
  "detail_category": 7
}

- 이 방식을 사용하면, DB 모델과 안 맞음 / 불필요한 중복 정보 / 정합성 검증 지옥
- “대분류와 중분류가 실제로 연결돼 있나?/ “중분류 없이 소분류가 가능한가?/ 테스트 폭발
  • 최종 구현 방식

    • 프론트 UX
      • 대분류 선택 → 해당 대분류의 중분류 목록 표시
      • 중분류 선택 → 해당 중분류의 소분류 목록 표시
      • 어디서 멈추든 괜찮음
    • 백엔드 API
{
  "title": "질문 제목",
  "content": "질문 내용",
  "category": 7
}

- 7이 대분류일 가능성 / 중분류일 가능성 / 소분류일 가능성 백엔드는 여기있는 ID 하나만 신뢰
  • 결론

    • 최종적으로 백엔드는 카테고리로 하나의 정수를 받음
    • 그게 대분류/중분류/소분류인지는 중요하지 않음
    • 존재 여부만 Service에서 체크
  • 오약

    • 카테고리는 프론트에서는 단계적으로 선택하지만,
      • 백엔드에서는 항상 “최종 선택된 카테고리 ID 하나”만 받는다.
      • 대/중/소는 UI 개념이지 API 계약이 아니다.

중분류 선택 시 하위 포함 처리

  • 대/중/소는 UI 개념인데, 백엔드는 어떻게 구분하는가?
    • 백엔드는 ‘대/중/소’를 구분하지 않는다. 대신 “부모가 있느냐 / 자식이 있느냐”만 본다.
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)

# 백엔드 구분법
parent == None        → 루트 카테고리 (UI상 대분류)
parent != None
  ├─ children 있음  → 중간 노드 (UI상 중분류)
  └─ children 없음  → leaf 노드 (UI상 소분류)
  • 백엔드 판단법

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)

  • parent에 child가 존재하는 이유

    • parent가 자기 자신(self)을 참조하는 ForeignKey이기 때문
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)
  • "self" 의미

    • models.ForeignKey("self", ...) → 이 모델은 자기 자신을 참조할 수 있다
question_categories
-------------------
id | name | parent_id 

1. parent_id 는 같은 question_categories.id 를 가리킴
| 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         |
    • “부모 입장에서, 나를 parent로 참조하는 애들을 children이라고 부르겠다.”
    • 즉, parent를 참조하는 row들이 있어서, Django가 역참조 관계(children)를 만들어준 것
    • ORM

# 자식 → 부모
orm = QuestionCategory.objects.get(name="ORM")
orm.parent  # Django 카테고리 객체

# 부모 → 자식
django = QuestionCategory.objects.get(name="Django")
django.children.all()  # ORM 카테고리 queryset
  • 보통 사용법

if category.parent is None:
    # 루트
elif category.children.exists():
    # 중간 노드
else:
    # leaf
backend = QuestionCategory.objects.create(name="백엔드")
django = QuestionCategory.objects.create(name="Django", parent=backend)
orm = QuestionCategory.objects.create(name="ORM", parent=django)

# Django 내부적으로 하는 일
django.parent_id = backend.id
orm.parent_id = django.id

카테고리 최종 작동 방법

    1. backend = QuestionCategory.objects.create(name="백엔드")

      idnameparent_id
      1백엔드NULL
    1. django = QuestionCategory.objects.create(name="Django", parent=backend)
    • parent=backend 는 객체를 넘긴 것

    • Django ORM 내부에서는

      • django.parent_id = backend.id 변환되어 INSERT
      idnameparent_id
      1백엔드NULL
      2Django1
  • 주의 ⚠️

backend = QuestionCategory(name="백엔드")
django = QuestionCategory.objects.create(name="Django", parent=backend)

- 여기서는 에러 발생
  - backend는 아직 DB에 저장되지 않아서 backend.id == None
  - QuestionCategory.objects.create(name="백엔드") 이렇게 사용 필요

새롭게 알게된 내용 ✅

  • 핸들러 작성시 400만 detail구조 통일이 복잡한 이유

    • DRF에서 400만 detail 구조가 일관되지 않기 때문
    • 401 / 403 / 404 는 대부분 APIException 계열이기 때문에 항상 같은 구조로 내려옴
      • response.data == {"detail": "로그인하지 않았습니다."}
    • 400 (ValidationError)은 입력검증 에러이기 때문에 에러 형태가 상황마다 다름

# 필드 하나 누락 = 문자열
raise ValidationError("잘못된 요청") → "detail": "잘못된 요청" 

# 필드 단위 에러 (serializer 기본) = dict + list
{
  "title": ["This field is required."],
  "content": ["This field may not be blank."]
}
exc.detail == {
    "title": ["This field is required."],
    "content": ["This field may not be blank."]
}

# non_field_errors = dict + list
{
  "non_field_errors": ["중복된 값입니다."]
}

# ListSerializer / bulk validation = list of dict
[
  {"title": ["required"]},
  {"content": ["required"]}
]
    if isinstance(exc, ValidationError):
        detail = exc.detail

        if isinstance(detail, list) and detail: # list
            response.data = {"error_detail": str(detail[0])}
        elif isinstance(detail, dict):	# dict
            response.data = {"error_detail": next(iter(detail.values()))}
        else:	# 나머지 
            response.data = {"error_detail": str(detail)}

        return response

  • view = context.get("view")

    • 현재 예외가 어떤 APIView에서 발생했는지 가져옴 DRF가 자동으로 넣어주는 값
    • isinstance(exc, ValidationError)
      • serializer.is_valid() 등에서 발생한 입력값 검증 오류인가 확인
    • isinstance(view, QuestionCreateAPIView)
      • 그 검증 오류가 질문 등록 API에서 발생했는가 확인
    • 주의 ⚠️

      • 공통 ValidationError 처리보다 위에 위치해야 함
      • 순서가 반대면 절대 실행 안됨
        if (
           isinstance(exc, ValidationError)
           and isinstance(view, QuestionCreateAPIView)
       ):
           response.data = {
               "error_detail": "유효하지 않은 질문 등록 요청입니다."
           }
           return response

**validated_data 대체 ?

category = get_category_or_raise(serializer.validated_data["category"])

question = create_question(
    author=user,
    title=serializer.validated_data["title"],
    content=serializer.validated_data["content"],
    category=category,
    image_urls=serializer.validated_data.get("image_urls", []),
)

1. serializer.validated_data에서 title / content / image_urls 를 하나씩 꺼내서 다시 함수 인자로 나열
2. validated_data가 이미 “질문 생성에 필요한 데이터 묶음”인데 View에서 다시 풀어헤치고 있다는 느낌
3., validated_data로 대체 가능 이라는 말은 
  3-1. 이미 검증된 데이터 묶음이 있으니까 그걸 그대로 service로 넘기면 되지 않은가? 라는 말

어려운 내용(추가 학습 필요) ✅

오늘 발생한 문제(발생 했다면) ✅

profile
안녕하세요.

0개의 댓글