oz_externship - MainProject

김기훈·2026년 1월 3일
post-thumbnail

내가 구현해야 할 파트

시작 전 인지해야할 이론


Create 🟥


전체 흐름

  • Client
    • (이미지 첨부 시) 서버에 업로드용 '임시 출입증(Presigned URL)' 요청
  • Server
    • AWS S3와 통신하여 URL 발급 후 반환
  • Client
    • S3에 이미지 직접 업로드 -> 성공 시 이미지 URL 획득 -> 본문(Markdown)에 URL 삽입
  • Client
    • [제목 + 본문(이미지 URL 포함) + 카테고리] 를 서버에 전송 (최종 저장 요청)
  • Server
    • 질문 저장 후, 본문을 분석하여 이미지-질문 연결 정보(DB) 동기화

상세 흐름

  • 1. 이미지 업로드 권한 발급 (Presigned URL)

    • 서버는 단순히 인증된 업로드 주소만 발급하고,
    • 실제 파일 트래픽은 클라이언트와 S3가 직접 주고받습니다.
# presigned_url_view.py

class PresignedUploadAPIView(APIView):
	
    ...
    
    def post(self, request: Request) -> Response:
        # 1. 파일 확장자 검증 (보안)
        ext = original_name.split(".")[-1].lower()
        if ext not in self.ALLOWED_EXTENSIONS:
            return Response(...) # 에러 처리

        # 2. 파일명 난수화 (충돌 방지)
        new_filename = f"{uuid.uuid4()}.{ext}"
        key = f"{path_prefix}{new_filename}"

        # 3. AWS S3 SDK를 이용해 임시 업로드 URL 생성
        s3_client = S3Client()
        presigned_url = s3_client.generate_presigned_url(key=key)

        # 4. 프론트엔드에 '업로드할 주소(presigned)'와 '보여줄 주소(img_url)' 반환
        return Response(
            {"presigned_url": presigned_url, "img_url": full_url, "key": key}, ...
        )

  • 2. 질문 등록 요청 (View Layer)

    • "Controller 역할의 View에서는 비즈니스 로직을 직접 처리하지 않습니다.
    • 입력값 검증(Validation)과 서비스 호출(Delegation) 역할에만 집중했습니다.
# question_api.py

def post(self, request: Request) -> Response:
    # 1. 데이터 유효성 검증
    serializer = QuestionCreateSerializer(data=request.data)
    serializer.is_valid(raise_exception=True)

    # 2. 검증된 데이터와 유저 정보를 Service Layer로 전달 (역할 분리)
    question = create_question(
        author=cast(User, request.user),
        category=serializer.validated_data["category"],
        validated_data=serializer.validated_data,
    )

    return Response(..., status=status.HTTP_201_CREATED)

  • 3. 질문 객체 생성 (Service Layer - Main)

    • 질문이 저장된 직후, sync_question_images 함수를 호출합니다.
    • 게시글 본문에 삽입된 이미지들을 자동으로 감지하여 게시글과 연관 짓는 로직입니다.
# question_create_service.py

def create_question(*, author: User, category: QuestionCategory, validated_data: dict[str, Any]) -> Question:
    # 1. 질문(Question) 본체 DB 저장
    question = Question.objects.create(
        author=author,
        title=validated_data["title"],
        content=validated_data["content"],
        category=category,
    )

    # 2. 본문 내 이미지 처리 로직 호출 (이미지 서비스 위임)
    sync_question_images(question, validated_data["content"])

    return question

  • 4. 이미지 파싱 및 DB 동기화

    • "본문에 포함된 이미지 URL을 파싱하여 QuestionImage 테이블에 저장합니다.
    • 이때 bulk_create를 사용하여 이미지가 여러 장이더라도
      • 단 한 번의 DB 쿼리로 처리가 끝나도록 성능을 최적화했습니다.
# question_image_service.py

def sync_question_images(question: Question, content: str) -> None:
    # 1. 본문 파싱: 정규표현식 등을 이용해 이미지 URL 추출
    raw_urls_in_content = set(extract_image_urls_from_content(content))

    # 2. 유효성 검사: 우리 버킷의 S3 URL인지 확인하고 Key만 추출
    current_keys_in_content = set()
    for url in raw_urls_in_content:
        if is_valid_s3_url(url):
            key = extract_key_from_url(url)
            if key:
                current_keys_in_content.add(key)

    # 3. Create 시점에는 '추가'만 존재 (기존 이미지가 없으므로)
    new_images = []
    for key in current_keys_in_content:
        new_images.append(QuestionImage(question=question, img_url=key))

    # 4. Bulk Insert로 성능 최적화 (한 번의 쿼리로 여러 이미지 매핑 저장)
    if new_images:
        QuestionImage.objects.bulk_create(new_images)

list 🟥


전체 흐름

  • Client
    • 검색 조건(검색어, 카테고리, 정렬 등)을 포함하여 목록 API 요청
  • Server (View)
    • 요청 파라미터(Query String) 유효성 검증
  • Server (Service)
    • DB 쿼리 최적화 (Join, Subquery) 준비
    • 필터링 조건 적용 (동적 쿼리)
  • DB
    • 조건에 맞는 데이터 + 썸네일 이미지 1장 + 답변 개수 등을 조회하여 반환
  • Server (Serializer)
    • 본문 HTML 태그 제거 및 텍스트 미리보기 생성
  • Client
    • 페이지네이션 된 질문 목록 수신

상세 흐름

  • 1. 요청 파라미터 검증 (View Layer)

    • 검색어, 카테고리 필터, 정렬 기준 등 다양한 쿼리 파라미터를 안전하게 처리하는 단계
      • View에서는 QuestionListQuerySerializer를 통해 category_id나 sort 같은
      • 파라미터를 엄격하게 검증하여, 서비스 로직이 깨끗한 데이터만 처리하도록 보장
# question_api.py

def get(self, request: Request) -> Response:
    # 1. Query Params 검증 (엄격한 타입 체크)
    query_serializer = QuestionListQuerySerializer(data=request.query_params)
    query_serializer.is_valid(raise_exception=True)

    # 2. Service Layer 호출 (검증된 데이터만 전달)
    queryset = get_question_list(**query_serializer.validated_data)

    # 3. 페이지네이션 (대량 데이터 분할)
    paginator = QuestionPageNumberPagination()
    page = paginator.paginate_queryset(queryset, request)

    # ... 응답 반환
  • 2. 쿼리 최적화 및 썸네일 처리

    • Django ORM의 select_related와 Subquery를 사용하여 성능을 최적화
      • "목록 조회 성능의 핵심은 select_related를 통해 작성자와 카테고리 정보를 미리 가져와
        • N+1 문제를 방지
      • 특히, 썸네일 이미지를 가져오기 위해 별도의 쿼리를 날리는 대신 Subquery를 사용하여
      • 메인 쿼리 한 방에 필요한 이미지 URL까지 가져오도록 튜닝
# question_list/service.py

def get_question_list(*, answer_status=None, category_id=None, search_keyword=None, sort="latest") -> QuerySet[Question]:
    # 1. N+1 문제 해결 (Eager Loading)
    # 작성자(author)와 카테고리(category) 정보를 조인(Join)하여 한 번에 가져옴
    base_qs = Question.objects.select_related("author", "category").annotate(
        answer_count=Count("answers", distinct=True) 
    )

    ... 

    # 2. 썸네일 최적화 (Subquery 활용)
    # 질문에 포함된 이미지 중 '가장 먼저 생성된 1장'만 서브쿼리로 가져옴
    return qs.annotate(
        thumbnail_image_url=Subquery(
            QuestionImage.objects.filter(question=OuterRef("pk"))
            .order_by("created_at", "id")
            .values("img_url")[:1]
        ),
    )
  • 3. 페이지네이션 (API View)

    • "수천 개의 질문이 쌓여도 성능 저하가 없도록 DRF의 PageNumberPagination을 적용
    • 클라이언트는 필요한 페이지만 요청하여 로딩 속도를 최적화 가능
# question_api.py

# DRF Pagination 적용
paginator = QuestionPageNumberPagination()
page = paginator.paginate_queryset(queryset, request)

# Serializer를 통해 결과 변환
serializer = QuestionListSerializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
  • 4. 데이터 가공 및 미리보기

    • "목록 화면에서 HTML 태그가 그대로 노출되는 것을 막기 위해 strip_tags와 정규식을 활용
    • 이를 통해 사용자에게 깔끔한 텍스트 미리보기를 제공하여 가독성을 높임
# question_list.py

class QuestionListSerializer(serializers.ModelSerializer[Question]):
    # ...
    def get_content_preview(self, obj: Question) -> str:
        # 1. 본문 데이터 가져오기
        content = obj.content or ""

        # 2. HTML 태그 제거 (strip_tags) 및 특수문자 복원
        # 예: "<p>안녕하세요</p>" -> "안녕하세요"
        text = strip_tags(content)
        text = html.unescape(text)

        # 3. 불필요한 공백 제거 및 100자 커팅
        text = re.sub(r"\s+", " ", text).strip()
        return text[:100]

serializer을 2개로 분리한 이유

  • question_list_query.py → 입력 (query params 검증)
    • 요청 파라미터 검증 (page, category, keyword 등)
  • question_list.py → 출력 (response 직렬화)
    • 응답 데이터 구조 정의 (id, title, author, created_at 등)
  • 입력 / 출력은 관심사가 다르기 때문에 합치지 않는 게 정상
  • 조회니까 하나로 묶는 경우
    • 입력과 출력이 섞임 / serializer.is_valid()의 의미가 흐려짐
  • 역할(입력 검증 vs 출력 표현)이 완전히 다르기 때문
    • 하나의 Serializer로 다 처리 하려고 한다면
      - 의미 없는 필드 혼재 / Validation 책임 혼란
      | 구분 | 역할 | 예 |
      | ------------------ | --------- | --------------------- |
      | 입력용 Serializer | 요청 데이터 검증 | QueryParam, POST body |
      | 출력용 Serializer | 응답 데이터 표현 | 카드 UI, 상세 화면 |
  • 쿼리시리얼라이저가 없을 경우

    • 기술적으로는 필터를 구현 가능하지만 설계적으로는 안좋음 / DB조회도 가능하기는 함
    • 단점

      • 타입이 전부 문자열이다 → 경우의 수 폭발
      • 유효성 검증이 없다 → ?page=-999도 통과 → 방어 로직이 뷰에 퍼짐
      • View가 더러워진다 → View가 로직 + 검증 + 변환 다 함 + 테스트 지옥
    • 즉, QuerySerializer는 필터를 “안전하게, 예측 가능하게” 만드는 것
항목보장
타입bool / int / str
기본값page=1, page_size=10
허용 범위min / max
없는 값None 처리
이상한 값400으로 차단

Detail 🟥

전체 흐름

  • Client
    • 특정 질문(question_id)의 상세 페이지 진입 요청
  • Server (View)
    • 요청 수신 및 기본적인 권한/인증 확인
  • Server (Selector)
    • 질문(Question) 조회
    • 작성자(User), 카테고리(Category): Inner Join으로 즉시 로딩 (select_related)
    • 이미지(Images): 별도 쿼리로 조회 후 메모리 매핑 (prefetch_related)
    • 답변(Answer) 및 답변의 댓글(Comment) Prefetch (Prefetch Related)
  • DB
    • 최적화된 쿼리로 연관 데이터를 한 번에 조회 (N+1 문제 방지)
  • Server (Service)
    • 조회수(View Count) +1 증가 (업데이트 쿼리 최소화)
  • Server (Serializer)
    • 질문 -> 이미지 -> 답변 -> 댓글 순으로 계층형 JSON 구성
  • Client
    • 상세 데이터 수신

상세 흐름

  • 1. 최적화된 데이터 조회 (Selector Layer)

    • 단순 조회(get)가 아닌, 연관된 모든 데이터를 효율적으로 긁어옴
      • 상세 화면은 질문 내용뿐만 아니라 답변, 작성자, 댓글 등 많은 정보가 필요
      • 이를 각각 조회하면 수십 번의 쿼리가 발생(N+1 문제)
      • select_related와 prefetch_related를 조합하여 이를 최소화
        • 특히 Prefetch 객체를 사용하여,
        • 달려 있는 답변들을 DB 레벨에서 미리 최신순으로 정렬해 가져오도록 정밀하게 튜닝
# selectors.py

def get_question_detail_queryset(question_id: int) -> Question | None:
    return (
        Question.objects.select_related("author", "category")  # 1:1 관계 (Join)
        .prefetch_related(
            "images",  # 1:N 관계 (역참조)
            Prefetch(
                "answers",  # 1:N 관계 (답변)
                queryset=(
                    Answer.objects.select_related("author")
                    .prefetch_related("comments__author") # 대댓글 작성자까지
                    .order_by("-created_at")  # ★ 핵심: 답변을 최신순으로 정렬해서 가져옴
                ),
            ),
        )
        .filter(id=question_id)
        .first()
    )
  • 2. 조회수 증가 로직 (Service Layer)

    • 데이터를 가져온 후, 조회수를 올리는 비즈니스 로직
      • 조회수를 올릴 때 save()를 그냥 호출하면 제목이나 내용까지 덮어씌워질 위험이 있음
        • 그래서 불필요한 연산이 발생함
      • update_fields=['view_count']를 명시하여 오직 조회수 컬럼만 수정하는
        • 최적화된 UPDATE 쿼리가 나감
# question_detail/service.py

def get_question_detail(*, question_id: int) -> Question:
    question = get_question_detail_queryset(question_id)

    if question is None:
        raise QuestionNotFoundError()

    # 메모리 상에서만 증가
    question.view_count += 1

    # update_fields를 사용하여 'view_count' 컬럼만 딱 집어서 업데이트
    question.save(update_fields=["view_count"])

    return question
  • 3. 계층형 데이터 응답 (Nested Serializer)

    • 복잡하게 얽힌 데이터를 프론트엔드가 사용하기 편한 트리(Tree) 구조의 JSON으로 변환
      • 프론트엔드에서 질문 API, 답변 API, 댓글 API를 따로 호출할 필요가 없도록 구현
      • QuestionDetailSerializer 안에 AnswerSerializer,
        • 그 안에 CommentSerializer를 중첩(Nested)시켜,
        • 단 한 번의 API 호출로 상세 페이지를 그릴 수 있는 완성된 데이터를 내려줌
# question_detail.py

# 답변(Answer) 시리얼라이저 (댓글 포함)
class AnswerSerializer(serializers.ModelSerializer[Answer]):
    author = AuthorSerializer(read_only=True)
    comments = AnswerCommentSerializer(many=True, read_only=True) # 중첩
    # ...

# 질문 상세(Question Detail) 시리얼라이저 (답변 포함)
class QuestionDetailSerializer(serializers.ModelSerializer[Question]):
    # ...
    images = QuestionImageSerializer(many=True, read_only=True)
    answers = AnswerSerializer(many=True, read_only=True) # 중첩

    class Meta:
        model = Question
        fields = [
            "id", "title", "content", "category",
            "images",      # 질문의 이미지들
            "answers",     # 질문에 달린 답변들 (댓글 포함)
            ...
        ]

Update 🟥

전체 흐름

  • Client
    • 수정된 제목, 본문(이미지 URL 포함), 카테고리 정보를 담아 PATCH 요청
  • Server (View)
    • 작성자 본인 확인 (Permission Check)
    • 수정 대상 질문 조회 (Selector)
    • 입력 데이터 유효성 검증 (Serializer)
  • Server (Service - Atomic Transaction Start)
    • 트랜잭션 시작
    • Step A (Field Update): 제목, 카테고리 등 변경된 필드만 DB 업데이트
    • Step B (Image Sync):
      • 본문 파싱 후 기존 DB 이미지 목록과 비교 (Set 연산)
      • 삭제 대상(Delete Set)과 추가 대상(Add Set) 식별
      • DB
        • 삭제 대상 레코드 즉시 삭제 / 추가 대상 레코드 Bulk Insert
      • S3
        • 삭제 대상 파일은 트랜잭션 커밋 이후(on_commit)로 삭제 작업 예약
  • Server (Service - Atomic Transaction End)
    • 트랜잭션 커밋 (DB 반영 확정)
  • Server (Post-Commit)
    • 예약된 S3 파일 삭제 작업 실행
  • Client
    • 수정된 상세 정보 수신

상세 흐름

  • 1. 권한 확인 및 검증 (View Layer)

    • 요청자가 '진짜 작성자'인지 확인하고 데이터를 검증
      • 수정 요청이 들어오면 check_object_permissions를 통해 작성자 본인인지 엄격하게 검증
      • 또한 partial=True 옵션을 사용하여 변경하고 싶은 필드만 보내도 처리가 가능하도록 함
# question_detail.py

def patch(self, request: Request, question_id: int) -> Response:
    # 1. 대상 객체 조회 (존재 여부 확인)
    question = get_question_for_update(question_id=question_id)

    # 2. 권한 체크 (작성자 본인인가?)
    self.check_object_permissions(request, question)

    # 3. 데이터 검증 (Partial Update 허용)
    serializer = QuestionUpdateSerializer(
        instance=question,
        data=request.data,
        partial=True, # 일부 필드만 수정 가능
    )
    serializer.is_valid(raise_exception=True)

    # 4. 서비스 레이어로 위임
    question = update_question(
        question=question,
        validated_data=serializer.validated_data,
    )
    ...
  • 2. 원자적 업데이트 (Service Layer - Main)

    • 모든 작업이 성공해야만 저장되고, 하나라도 실패하면 롤백
      • 수정 로직은 @transaction.atomic 데코레이터로 감싸져 있음
      • 텍스트 수정은 성공했는데 이미지 처리가 실패하는 등 데이터가 꼬이는 상황을
        • 원천 차단하여 데이터 무결성(Integrity)을 보장
# question_update/service.py

@transaction.atomic  # 함수 전체를 하나의 트랜잭션으로 묶음
def update_question(*, question: Question, validated_data: dict[str, Any]) -> Question:
    update_fields = []
    new_content = validated_data.get("content")

    # 1. 변경된 필드만 감지하여 업데이트 (Dirty Checking 방식 흉내)
    for field in ("title", "content", "category"):
        if field in validated_data:
            setattr(question, field, validated_data[field])
            update_fields.append(field)

    # 2. 실제 변경사항이 있을 때만 DB 저장
    if update_fields:
        question.save(update_fields=update_fields)

    # 3. 본문(Content)이 변경되었다면 이미지 동기화 로직 수행
    if new_content is not None:
        sync_question_images(question, new_content)

    return question
  • 3. 이미지 차분 동기화 & 안전한 삭제 (Service Layer - Image Support)

    • 저는 집합(Set) 연산을 통해 추가/삭제된 이미지를 효율적으로 계산
    • 특히 S3 파일 삭제는 transaction.on_commit 훅을 사용하여,
    • DB 트랜잭션이 완벽하게 성공했을 때만 실제 파일을 지우도록 하여
      • '실수로 파일이 지워지는' 사고를 방지
# question_image_service.py

def sync_question_images(question: Question, content: str) -> None:
    # 1. 파싱: 현재 본문에 남아있는 이미지 URL 추출
    current_keys_in_content = set(...) 

    # 2. DB 조회: 기존에 저장되어 있던 이미지 URL 추출
    existing_keys = set(...)

    # 3. 집합 연산(Set Operation)으로 차분 계산
    keys_to_delete = existing_keys - current_keys_in_content # 삭제할 것
    keys_to_add = current_keys_in_content - existing_keys    # 추가할 것

    # 4. 삭제 로직 (S3 삭제 지연 처리)
    if keys_to_delete:
        # DB에서는 즉시 삭제 (트랜잭션 안이라 롤백 가능)
        existing_images_qs.filter(img_url__in=keys_to_delete).delete()

        # S3 파일 삭제는 'DB 트랜잭션이 성공적으로 커밋된 후'에 실행
        def delete_s3_files() -> None:
            for key in keys_to_delete:
                s3_client.delete(key)

        transaction.on_commit(delete_s3_files) # Hook 등록

    # 5. 추가 로직
    if keys_to_add:
        QuestionImage.objects.bulk_create(...)

Update 권한

  • self.check_object_permissions(request, question)
    • 현재 View에 설정된 permission_classes 리스트를 가져옴
    • 리스트에 있는 모든 권한 클래스들을 순회
    • 각 권한 클래스 안에 정의된 has_object_permission(request, view, obj) 메서드를 실행
    • 어느 하나라도 False를 반환하면, 즉시 PermissionDenied (403 Forbidden) 에러를 발생
  • 내 코드

    • get_permissions 메서드에서 PATCH 요청일 경우
      • QuestionUpdatePermission을 사용하도록 설정
    • patch 메서드 내부에서 check_object_permissions
  • 즉, 이 코드가 실행되면

    • DRF는 QuestionUpdatePermission 클래스 안에 있는 has_object_permission 로직을 수행
# permissions.py

class QuestionUpdatePermission(BasePermission):

	...

    def has_object_permission(self, request: Request, view: APIView, obj: Question) -> bool:
        user = request.user
        if obj.author_id != user.id:
            raise PermissionDenied(detail=EMS.E403_OWNER_ONLY_EDIT("질문")["error_detail"])
        return True

# views.py
class QuestionDetailAPIView(APIView):
	
    ...

  def get_permissions(self) -> list[BasePermission]:
      if self.request.method == "PATCH":
          return [QuestionUpdatePermission()] # 이 클래스가 검사 대상이 됩니다.
      return []
	
    ...
    
    def patch(self, request: Request, question_id: int) -> Response:
        self.validation_error_message = EMS.E400_INVALID_REQUEST("질문 수정")["error_detail"]
        question = get_question_for_update(question_id=question_id)  # 존재 확인

        self.check_object_permissions(request, question)  # 작성자와 사용자가 같은 사람인가 확인
        
    ...

어드민


카테고리 🟥

계층 구조 유효성 검사 (Model Level)

  • 잘못된 상하 관계가 형성되지 않도록 모델단에서 강력한 유효성 검사(Validation)를 수행
    • 대분류: 부모 카테고리가 없어야 함 (parent is None).
    • 중분류: 부모가 반드시 존재해야 하며, 부모의 타입은 '대분류'여야 함
    • 소분류: 부모가 반드시 존재해야 하며, 부모의 타입은 '중분류'여야 함
  • 관리자가 실수로 소분류를 대분류 밑에 넣는 등의 논리적 오류를 범하지 않도록,
    • clean 메서드를 오버라이딩하여 데이터 무결성을 강제
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="부모 카테고리"
    )

	...

    def clean(self) -> None:
        """계층 구조 유효성 검사"""
        if self.type == "large" and self.parent is not None:
            raise ValidationError({"parent": "대분류는 부모 카테고리를 가질 수 없습니다."})

        if self.type == "medium":
            if self.parent is None or self.parent.type != "large":
                raise ValidationError({"parent": "중분류의 부모는 [대분류]여야 합니다."})

        if self.type == "small":
            if self.parent is None or self.parent.type != "medium":
                raise ValidationError({"parent": "소분류의 부모는 [중분류]여야 합니다."})

관리자 UI 시각화 (Custom Display)

  • 텍스트로만 나열되면 계층 구분이 어렵기 때문에, 색상 배지(Badge)를 통해 직관적인 구분이 가능하도록 구현
    • format_html을 사용하여 안전하게 스타일이 적용된 span 태그를 렌더링

조회 성능 최적화 (Query Optimization)

  • 카테고리 목록을 조회할 때 발생하는 N+1 문제를 해결하여 어드민 페이지 로딩 속도를 높임
    • 기본 설정대로라면
      • 목록에 있는 각 카테고리의 '부모 이름'과 '자식 목록'을 표시하기 위해 행마다 추가 쿼리가 발생
    • get_queryset 메서드 재정의를 통해 해결
def get_queryset(self, request: HttpRequest) -> QuerySet[QuestionCategory]:
    queryset = super().get_queryset(request)
    # 부모(One)는 Join으로, 자식(Many)은 Prefetch로 한 번에 가져옴
    return queryset.select_related("parent").prefetch_related("children")

삭제 안전장치 (Safety Mechanism)

  • 카테고리는 상하위 관계가 CASCADE로 묶여 있음
    • 상위 카테고리 삭제 시 하위 데이터가 연쇄적으로 삭제되는 위험이 있음
    • 이를 방지하기 위해 경고 메시지를 강화
    def delete_view(
        self, request: HttpRequest, object_id: str, extra_context: Optional[dict[str, Any]] = None
    ) -> HttpResponse:
        obj = self.get_object(request, object_id)
        extra_context = extra_context or {}

        if obj:
            warning_msg = ""
            base_msg = " 해당 카테고리의 질의응답은 '일반질문'으로 자동 전환되며, 삭제된 카테고리는 복구할 수 없습니다."

            if obj.type == "large":
                warning_msg = f"⚠️ [대분류 삭제 경고] 하위 '중분류' 및 '소분류'가 모두 함께 삭제됩니다!{base_msg}"
            elif obj.type == "medium":
                warning_msg = f"⚠️ [중분류 삭제 경고] 하위 '소분류'가 모두 함께 삭제됩니다!{base_msg}"
            else:  # small
                warning_msg = f"⚠️ [소분류 삭제 경고]{base_msg}"

            extra_context["title"] = warning_msg

        return super().delete_view(request, object_id, extra_context=extra_context)

질의응답 관리 🟥

  • 작성자의 상세 정보, 답변 상태, 카테고리 경로 등을 한눈에 파악할 수 있는 통합 대시보드 형태로 구현
    • 질문 상세 페이지 안에서 답변(Answer)까지 한 번에 조회하고 관리할 수 있도록 설계된 것이 특징

사용자 정보 시각화 (User Info Visualization)

  • 관리자가 작성자(학생, 조교 등)가 누구인지 빠르게 식별할 수 있도록,
    • 단순 텍스트 대신 프로필 이미지와 소속 정보(기수, 과정 등)가 포함된 카드 형태로 정보를 표시
      • 사용자의 역할(Role)을 분석하여 맞춤형 정보를 HTML로 렌더링
      • 수강생(ST): 현재 수강 중인 과정명과 기수 정보를 조회하여 표시
      • 조교(TA): 담당하고 있는 기수 정보를 표시
      • 기타(매니저 등): '러닝 코치', '교육 운영 매니저' 등의 직함을 표시
def get_user_display_info(user: Any) -> SafeString:
    """
    [답변 목록 표시용] 유저 Role에 따라 (썸네일 + 닉네임 + 과정/직함) 정보를 HTML로 반환
    """
    if not user:
        return mark_safe("-")

	...
    
    role = getattr(user, "role", "U")

	...

    info_text = ""

    # [A] 수강생 (ST) & 조교 (TA) -> 과정 및 기수 정보 필요
    if role in ["ST", "TA"]:
        target_obj = None

        try:
            if role == "ST":
                # 수강생: 현재 진행 중인 수강 정보 가져오기
                target_obj = getattr(user, "in_progress_cohortstudent", None)

            elif role == "TA":
                # 조교: 담당하고 있는 기수 정보 가져오기
                if hasattr(user, "trainingassistant_set"):
                    target_obj = user.trainingassistant_set.select_related("cohort__course").first()
        except Exception:
            target_obj = None

        # 기수/과정명 파싱
        course_name = ""
        generation = ""

        if target_obj and hasattr(target_obj, "cohort"):
            cohort = target_obj.cohort
            course_name = getattr(cohort.course, "name", "")
            if hasattr(cohort, "number"):
                generation = f"{cohort.number}기"

        course_info = f"{course_name} {generation}".strip()

        if role == "ST":
            info_text = course_info
        else:
            # 과정 정보가 없으면 그냥 "조교"만 출력
            info_text = f"{course_info} 조교".strip() if course_info else "조교"

    else:
        if role == "LC":
            info_text = "러닝 코치"
        elif role == "OM":
            info_text = "교육 운영 매니저"
        elif role == "AD":
            info_text = "관리자"
        else:
            info_text = "일반 회원"

    # 만약 정보가 비어있다면 시스템 기본 역할명으로 대체
    if not info_text:
        info_text = user.get_role_display()

커스텀 메서드

  • 카테고리 전체 경로 표시

    • 현재 카테고리에서 parent를 타고 최상위까지 올라가며 이름을 수집한 뒤
    • 이를 역순으로 정렬하여 대분류 > 중분류 > 소분류 형태의 문자열로 반환
@admin.display(description="카테고리 경로")
    def get_category_hierarchy(self, obj: Question) -> str:
        """대분류 > 중분류 > 소분류 형태로 표시"""
        category: Optional[QuestionCategory] = obj.category
        path: List[str] = []

        # 현재 카테고리부터 부모를 타고 올라가며 경로 수집
        current = category
        while current:
            path.append(current.name)
            current = current.parent

        # [소, 중, 대] -> [대, 중, 소] 순서로 뒤집고 화살표로 연결
        full_path = " > ".join(reversed(path))
        return full_path
  • 답변 상태

    • 답변이 달린 글과 아닌 글을 색상으로 구분하여 시각화
      • answers_count가 0보다 크면 초록색 Y, 없으면 빨간색 N 배지를 출력
@admin.display(description="답변 여부", ordering="answers_count")
    def get_is_answered(self, obj: Question) -> str:
        """
        답변 개수(answers_count)를 기반으로 Y/N 표시
        """
        has_answer = getattr(obj, "answers_count", 0) > 0

        if has_answer:
            # 초록색 Y 뱃지
            return format_html(
                '<span style="color: white; background-color: #28a745; padding: 4px 8px; border-radius: 50%;">Y</span>'
            )
        else:
            # 회색 N 뱃지
            return format_html(
                '<span style="color: white; background-color: #dc3545; padding: 4px 8px; border-radius: 50%;">N</span>'
            )
  • 쿼리 성능 최적화 (N+1 방지)

    • select_related
      • 작성자, 카테고리, 카테고리의 부모들(categoryparentparent)까지 한 번의 Join 쿼리로 가져옴
    • annotate(answers_count=Count("answers"))
      • 각 질문에 달린 답변 개수를 DB 레벨에서 미리 계산(Count)
    def get_queryset(self, request: HttpRequest) -> QuerySet[Question]:
        """
        답변 개수를 미리 계산(annotate)
        """
        queryset = super().get_queryset(request)
        return queryset.select_related("author", "category", "category__parent", "category__parent__parent").annotate(
            answers_count=Count("answers")
        )

통합 상세 관리

  • 질문 상세 페이지를 열었을 때, 해당 질문에 달린 답변 목록을 바로 확인하고 관리할 수 있는 구조
    • 질문(Question) 관리 페이지 하단에 답변(Answer)들을 리스트 형태로 삽입(StackedInline)
      • model = Answer
        • 질문 모델과 연결된 답변 모델을 지정
      • extra = 0
        • 기본적으로 보여주는 빈 입력 폼을 없애 화면을 깔끔하게 유지
      • readonly_fields
        • 답변의 내용 확인이 주 목적이므로, 작성자 정보나 작성일 등은 수정할 수 없도록
          • 읽기 전용으로 설정하여 데이터 무결성을 보호
class AnswerInline(_AnswerInlineBase):
    model = Answer
    extra = 0
    verbose_name = "등록된 답변"
    verbose_name_plural = "답변 목록"

    readonly_fields = ("get_answerer_info", "created_at", "updated_at")

    fieldsets = ((None, {"fields": ("get_answerer_info", "content", "is_adopted", "created_at", "updated_at")}),)

    @admin.display(description="답변 작성자")
    def get_answerer_info(self, obj: Answer) -> SafeString:
        return get_user_display_info(obj.author)


@admin.register(Question)
class QuestionAdmin(_QuestionBaseAdmin):

	...

    inlines = [AnswerInline]
    
    ```


PR 🟥


첫번 째 PR (2025/12/12)

    1. 권한 검증은 뷰나 퍼미션으로 옮기기
    1. 시리얼라이저는 데이터 유효성 검사만
    1. 제목 중복여부는 도메인 규칙 서비스에서 처리
    1. 카테고리 에러도 서비스로
    • 질문등록 api

      • 변경된 프로젝트 구조에맞게 분리
        • “View / Serializer / Service / Permission”
          • Permission → 권한 / 중복 제목
          • Servier → DB 생성 로직
          • Serializer → 검증 + 데이터 정리만 담당
          • View → HTTP 책임만 유지
    1. 뷰 로직의 에러타입을 validationerror 구조로 변경
    • ValidationError 구조

      • 원래 사용하던 방식

        • 문제

          • DRF 표준 구조가 아님
          • View가 도메인 규칙을 알게 됨
          • 문자열 오타 → 런타임 버그
          • 테스트도 "type" 문자열에 의존
------------------------------- [ 처음 작성 방식 ] -------------------------------

raise serializers.ValidationError({"type": "title_conflict"})

# view에서 
error_type = e.detail.get("type")

if error_type == "title_conflict":
    ...

------------------------------- [ DRF가 기대하는 구조 ] -------------------------------

ValidationError({
    "field_name": ["error message"]
})

ValidationError({
    "non_field_errors": ["error message"]
})

------------------------------ [ ValidationError 구조로 변경 ] ------------------------------

# 기존
raise serializers.ValidationError({"type": "title_conflict"})

# 변경
raise serializers.ValidationError({
    "title": ["중복된 질문 제목이 이미 존재합니다."]
})

## 의미
title 필드와 관련된 에러 / View는 타입 몰라도 됨 / DRF 표준 에러 응답 가능

----------------------------- [ ValidationError 구조로 변경 2 ] -----------------------------
# 기존
raise serializers.ValidationError({"type": "category_not_found"})

# 변경
raise serializers.ValidationError({
    "category": ["선택한 카테고리를 찾을 수 없습니다."]
})

--------------------------------- [ handler 적용 ] ---------------------------------

except serializers.ValidationError as e:
    if "title" in e.detail:
        return Response(
            {"error_detail": e.detail["title"][0]},
            status=status.HTTP_409_CONFLICT,
        )

    if "category" in e.detail:
        return Response(
            {"error_detail": e.detail["category"][0]},
            status=status.HTTP_404_NOT_FOUND,
        )

    return Response(
        {"error_detail": "유효하지 않은 질문 등록 요청입니다."},
        status=status.HTTP_400_BAD_REQUEST,
    )

두번 째 PR (2025/12/15)

  • views.py

# 이전
create_question(
    author=user,
    title=serializer.validated_data["title"],
  	content=serializer.validated_data["content"],
 	category_id=serializer.validated_data["category"],
  	image_urls=serializer.validated_data.get("image_urls", []),
)

- 1. View에서 데이터 분해 → validated_data를 하나씩 분해 / service 인터페이스가 필드 나열형
- 2. 필드 추가 시 View / Service 둘 다 수정 필요
- 3. View가 데이터 구조를 “알아야” 하는 상태

# 수정 후 
question = create_question(
    author=user,
    category=category,
    validated_data=serializer.validated_data,
)

- 1. View에서 데이터 분해 ❌ / Service가 **“검증 완료된 데이터 묶음”**을 받음
- 2. View가 얇아짐 / 인터페이스 안정성 증가

  • services.py

# 변경 전
try:
    category = QuestionCategory.objects.get(...)
except:
    raise CategoryNotFoundError()

- 1. 생성 로직과 검증 로직이 섞여 있었음 (create” 라는 이름과 책임 불일치)

# 변경 후 
def get_category_or_raise(category_id: int) -> QuestionCategory:

- 1. 카테고리 존재 여부를 별도 함수로 분리 / create_question 은 생성만 담당
- 2. 단일 책임 원칙 (SRP) 충족 | 테스트 / 재사용성 향상
# Before = 필드 단위 함수
create_question(author, title, content, category, image_urls)

# After = 질문 생성 이라는 행위 단위 함수 
create_question(author, category, validated_data)
  • 비교

    • 리뷰 전
      • View에서 serializer 데이터를 분해하여 service에 전달하고,
      • 생성 서비스 내부에서 검증 로직까지 함께 처리
    • 리뷰 후
      • 검증된 데이터는 그대로 service에 전달하고,생성 로직과 도메인 검증 로직을 분리하여
      • service의 책임을 명확히 함

  • 변경 전

# url을 가져올땐 reverse를 사용해서 
## url name을 활용하여 가져오는게 유지보수 측면에서 유용
class QuestionCreateAPITests(APITestCase):
    def setUp(self) -> None:
        self.url = "/api/v1/qna/questions"

# View단에서 Error Raise에 사용 권장
if isinstance(exc, ValidationError) and isinstance(view, QuestionCreateAPIView):
        response.data = {"error_detail": "유효하지 않은 질문 등록 요청입니다."}
        return response
        
# ValidationError에 대한 정보 손실
elif isinstance(detail, dict):
            response.data = {"error_detail": next(iter(detail.values()))}

  • 1. reverse 사용

  • “테스트 코드에서 URL을 문자열로 하드코딩하지 말고, reverse() + url name으로 가져와라”
    • 현재 self.url = "/api/v1/qna/questions"
      • 테스트는 통과할 수 있음 하지만 URL 구조가 바뀌는 순간 테스트가 전부 깨짐
  • URL을 이름(name)으로 역참조하는 공식 방법
    • path("questions", QuestionCreateAPIView.as_view(), name="question_create"),
      • name="question_create" 이 이름을 기준으로 URL을 가져오라는 뜻
  • 개선

# reverse 사용 예시
from django.urls import reverse

class QuestionCreateAPITests(APITestCase):
    def setUp(self) -> None:
        self.url = reverse("question_create")

# 실제 사용 결과
/api/v1/qna/questions
  • 유지보수에 유리한 이유

self.url = "/api/v1/qna/questions"

1. 나중에 기획 변경으로 인한 주소 변경시에 아래의 상황 발생
2. 테스트 코드 전부 수정 / 누락되면 CI 깨짐 / 문자열 검색으로 찾다가 실수 가능

from django.urls import reverse
self.url = reverse("question_create")

  • 2. 400 메시지 포맷팅

  • ValidationError를 View에서 직접 발생시키는 방식도 검토했으나,
    • Serializer가 제공하는 검증 책임과 필드별 오류 정보를 유지하기 위해
    • raise_exception=True를 사용하고 View에서는 에러 메시지 정책만 선언하는 방식이
      • 구조적으로 더 적절하다고 판단
# apps/core/exception_handler.py
view = context.get("view")

if isinstance(exc, ValidationError) and isinstance(view, QuestionCreateAPIView):
    response.data = {"error_detail": "유효하지 않은 질문 등록 요청입니다."}
    
1. exception_handler가 특정 View를 직접 알고 있음
2. exception_handler가 
  2-1. QnA 도메인 / QuestionCreateAPIView / 특정 API의 비즈니스 정책을 전부 알아버림
  2-2. 레이어 침범
  • 안좋은 이유

    • 이건 View 책임인데 왜 전역 핸들러에서 분기 ?

      레이어책임
      View“이 API에서 어떤 에러 메시지를 쓸 것인가”
      Serializer“무엇이 잘못되었는가”
      Exception handler“응답 포맷을 어떻게 통일할 것인가”
  • 해결

# 처음 코드 
## exceptions.py
class QuestionCreateValidationError(ValidationError):
    default_detail = "유효하지 않은 질문 등록 요청입니다."

## views/question_create.py
from apps.qna.exceptions.question_exceptions import QuestionCreateValidationError

serializer = QuestionCreateSerializer(data=request.data)
if not serializer.is_valid():
    raise QuestionCreateValidationError()

1. 기존 serializer의 ValidationError 구조를 버리고 API 전용 ValidationError를 새로 정의
2. 필드별 에러 정보 완전히 소실 
  • 1차 리뷰 요구 → 기존 ValidationError를 유지해라

## views/question_create.py
serializer = QuestionCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        category = get_category_or_raise(serializer.validated_data["category"])

## exception_handler.py
    view = context.get("view")

    # 질문 등록 API 전용 400 메시지
    if isinstance(exc, ValidationError) and isinstance(view, QuestionCreateAPIView):
        response.data = {"error_detail": "유효하지 않은 질문 등록 요청입니다."}
        return response

1. View에서 ValidationError를 새로 raise 하면 안 된다 그럼 기존 ValidationError를 살리기 위해
2. handler에서 메시지를 바꾸자 
3. 기존 ValidationError 유지 / raise_exception=True 유지 / serializer.errors 유지
  • 2차 리뷰 요구 → View단에서 Error Raise할때 해야할것 같다

## ExceptionHandler에서 API 의미를 해석하지 마라

# view
class QuestionCreateAPIView(APIView):
    validation_error_message = "유효하지 않은 질문 등록 요청입니다."

# ExceptionHandler
def custom_exception_handler(exc, context):
    response = exception_handler(exc, context)
    if response is None:
        return None

    view = context.get("view")

    if isinstance(exc, ValidationError):
        message = getattr(view, "validation_error_message", "유효하지 않은 요청입니다.")

        response.data = {
            "error_detail": message,
            "errors": exc.detail,
        }
        return response

    if isinstance(response.data, dict) and "detail" in response.data:
        response.data = {"error_detail": str(response.data["detail"])}

    return response

  • 요약

    • 검증 실패는 Serializer가 발생시키고 에러 의미(메시지 정책)는 View가 선언하고
      • 응답 포맷은 ExceptionHandler가 통일한다
- 1. 요청이 들어온다 → title이 없음 → 검증 실패 예정
POST /api/v1/qna/questions
Content-Type: application/json

{
  "content": "제목 없음",
  "category": 1
}

- 2. View가 Serializer 검증을 호출한다 → DRF 내부적 작동(아래)
  - 2-1. ValidationError 발생 
  - 2-2. 메시지/필드 정보는 serializer가 생성 / View는 여기서 아무 것도 안 함
if not serializer.is_valid():
    raise ValidationError(serializer.errors)

- 3. DRF가 ExceptionHandler를 호출
  - 3-1. context["view"]에 현재 View 인스턴스가 들어 있음
custom_exception_handler(
    exc=ValidationError(...),
    context={
        "view": QuestionCreateAPIView(...),
        "request": request,
        ...
    }
)

- 4. ExceptionHandler가 “의미”를 읽는다
view = context.get("view")

message = getattr(
    view,
    "validation_error_message",
    "유효하지 않은 요청입니다.",
)

- 5. Handler는 포맷만 바꾼다
response.data = {
    "error_detail": message,
    "errors": exc.detail,
}

- 6. 최종 응답
{
  "error_detail": "유효하지 않은 질문 등록 요청입니다.",
  "errors": {
    "title": ["This field is required."]
  }
}

  • 다른 예시

if not serializer.is_valid():
    raise ValidationError("유효하지 않은 질문 등록 요청입니다.")

1. DRF는 기본적으로 ValidationError는 Serializer가 책임진다 라는 전제를 깔고 있음.
2. serializer.errors 완전히 버림
3. 어떤 필드가 왜 잘못됐는지 알 수 없음 / 테스트에서 세밀한 검증 불가

# 유용한 경우
1. 내부 관리자용 API 
2. 프론트가 에러 상세 안 씀 / ValidationError 의미가 항상 동일 / 빠른 개발이 최우선

  • 정보 손실

    • ValidationError detail을 가공하는 과정에서 정보 손실이 생김
detail = exc.detail

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

- 1. 손실되는 정보 

- 2. 원래 serializer 에러
  "title": ["This field is required."],
  "content": ["This field may not be blank."]
  
- 3. handler 결과 → 어떤 필드인지 모름 / 여러 에러 중 하나만 남음 / 프론트에서 필드별 처리 불가
  "error_detail": "This field is required."
  • 형태별 분기로직 불 필요

    • 기존 전제 (형태 분기 필요)
      • ValidationError를 → “사용자에게 보여줄 최종 메시지”로 바로 변환해야 한다
    • 지금 전제 (형태 분기 불필요)
      • ValidationError의 detail
        • 보존해야 할 ‘원본 데이터’ → 최종 메시지는 View가 이미 결정했다
    if isinstance(exc, ValidationError):
        detail = exc.detail

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

        return response
  • 필요했던 이유

    • 이전 목표 → "error_detail": "어떤 한 문장"

      • 하지만 detail의 실제 타입은 제각각임
    • 지금의 전제

      • ValidationError의 detail은 “보여줄 문장”이 아니라 “보존해야 할 원본 데이터”다

      • 사용자에게 보여줄 데이터 : error_detail = view.validation_error_message

        역할담당
        대표 메시지View
        상세 에러 데이터Serializer (exc.detail)
- 1. 과거
exc.detail = {
  "title": ["This field is required."],
  "content": ["This field may not be blank."]
}

{
  "error_detail": "This field is required."
}

- 2. 현재 
exc.detail = {
  "title": ["This field is required."],
  "content": ["This field may not be blank."]
}

{
  "error_detail": "유효하지 않은 질문 등록 요청입니다.",
  "errors": {
    "title": ["This field is required."],
    "content": ["This field may not be blank."]
  }
}


profile
안녕하세요.

0개의 댓글