oz_externship - 17~ 코드 작업

김기훈·2025년 12월 23일

부트캠프 프로젝트

목록 보기
27/39

[2025.12.23] 질문 수정 api 작성 시작

[2025.12.24] 질문 수정 api 구현 완료 및 테스트코드 작성 PR


2025.12.23


Permission: 질문 수정 권한

from django.contrib.auth.models import AnonymousUser
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework.views import APIView

from apps.core.exceptions.exception_messages import EMS
from apps.qna.exceptions.question_exceptions import QuestionUpdateNotAuthenticated
from apps.qna.models import Question
from apps.user.models import RoleChoices


class QuestionUpdatePermission(BasePermission):
    message = EMS.E403_QNA_PERMISSION_DENIED("수정")["error_detail"]

    def has_permission(self, request: Request, view: APIView) -> bool:
        user = request.user

        # 401
        if isinstance(user, AnonymousUser) or not user.is_authenticated:
            raise QuestionUpdateNotAuthenticated()

        # 학생만 가능
        if user.role != RoleChoices.ST:
            return False

        return True

    def has_object_permission(self, request: Request, view: APIView, obj: Question) -> bool:
        # 작성자 본인만 가능
        return obj.author_id == request.user.id

Serializer: 질문 수정용

  • 이미지 수정 흐름

    • image_urls → write_only / 기존 QuestionImage 전부 삭제 후 새로 생성
from rest_framework import serializers
from apps.qna.models import Question, QuestionCategory

class QuestionUpdateSerializer(serializers.ModelSerializer):
    category = serializers.PrimaryKeyRelatedField(
        queryset=QuestionCategory.objects.all()
    )

    image_urls = serializers.ListField(
        child=serializers.URLField(),
        write_only=True,
        required=False,
    )

    class Meta:
        model = Question
        fields = [
            "title",
            "content",
            "category",
            "image_urls",
        ]

Selector: 수정 대상 질문 조회

from apps.qna.exceptions.question_exceptions import QuestionNotFoundError
from apps.qna.models import Question


def get_question_for_update(*, question_id: int) -> Question:
    question = Question.objects.select_related("author").filter(id=question_id).first()

    if question is None:
        raise QuestionNotFoundError()

    return question

APIView 수정 (PUT 추가)

from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView

from apps.core.exceptions.exception_messages import EMS
from apps.qna.permissions.question.question_update_permission import (
    QuestionUpdatePermission,
)
from apps.qna.serializers.question.question_update import QuestionUpdateSerializer
from apps.qna.services.question.question_update.selectors import (
    get_question_for_update,
)
from apps.qna.services.question.question_update.service import update_question


class QuestionDetailAPIView(APIView):

    def get_permissions(self):
        if self.request.method == "PUT":
            return [QuestionUpdatePermission()]
        return []

    def put(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)

        serializer = QuestionUpdateSerializer(
            instance=question,
            data=request.data,
        )
        serializer.is_valid(raise_exception=True)

        update_question(
            question=question,
            validated_data=serializer.validated_data,
        )

        return Response(
            {"message": "질문이 성공적으로 수정되었습니다."},
            status=status.HTTP_200_OK,
        )

수정버튼이 자기글에서만 보이게 하기

  • 프론트가 구현해야 할 버튼이지만 판단주체는 백엔드가 할 일
    • 퍼미션은 차단 역할 / is_editable는 표현용 상태 값이기 때문에 serializer 위치
is_editable = serializers.SerializerMethodField()

def get_is_editable(self, obj):
    request = self.context.get("request")
    if not request or not request.user.is_authenticated:
        return False
    return obj.author_id == request.user.id
  • SerializerMethodField() — “계산해서 내려주는 필드”

  • is_editable = serializers.SerializerMethodField()
    • DB 컬럼이 아님 / 모델에 없는 값 / 응답에만 포함되는 계산 필드
    • DRF 내부 동작

      • 필드 이름: is_editable
      • 자동으로 get_is_editable(self, obj) 메서드를 찾음
        • self → Serializer 인스턴스 / obj → 현재 직렬화 중인 객체
  • request = self.context.get("request")
    • context 사용 이유
      • Serializer는 기본적으로 request를 모름 / View에서 넘겨줘야 함
    • QuestionDetailSerializer(question,context={"request": request})
      • 현재 요청을 보낸 유저 정보를 얻기 위함
  • if not request or not request.user.is_authenticated:
    • 이 조건이 필요한 이유
      • request가 없는 경우
        • context를 안 넘겼을 때테스트 코드에서 serializer 단독 사용 시
      • 로그인 안 한 사용자: AnonymousUser
  • return obj.author_id == request.user.id
    • 현재 질문의 작성자 ID / 현재 요청 유저의 ID

전체 흐름 요약

APIView
 ├─ Permission (QuestionUpdatePermission)
 │    ├─ 인증(401)
 │    ├─ 역할(Role) 권한(403)
 │    └─ 객체 소유자 권한(403)
 │
 ├─ Selector (get_question_for_update)
 │    └─ 수정 대상 Question 조회
 │
 ├─ Serializer (QuestionUpdateSerializer)
 │    └─ 입력값 검증 + FK 검증
 │
 └─ Service (update_question)
      ├─ 트랜잭션 처리
      ├─ Question 필드 수정
      └─ 이미지 전체 교체
  • Selector


PATCH 확장

  • 부분수정을 위해 PATCH까지 확장하려 했지만 그냥 PUT 대신 PATCH 사용하기로 결정
  • 원래 전체수정은 PUT로 처리하고 부분수정은 PATCH로 처리하려 하였으나
    • 질문 api구현 후에 다른 기능 구현할게 있기에 PATCH만 단독 사용으로 결정
  • “사진 단위로 삭제/유지/추가”

PATCH

# Request Body
{
  "title": "수정된 제목",
  "content": "수정된 내용",
  "category": 3,
  "images": {
    "delete_ids": [2, 4],
    "add_urls": [
      "https://cdn.example.com/new1.png",
      "https://cdn.example.com/new2.png"
    ]
  }
}
필드설명
title / content / category수정할 필드만 전달
images.delete_ids삭제할 기존 이미지 ID 목록
images.add_urls새로 추가할 이미지 URL
images 없음이미지 변경 없음

2025.12.24


코드 리뷰

전체 흐름

PATCH /api/v1/qna/questions/{question_id}

View
 └─ QuestionDetailAPIView.patch
     ├─ 인증/권한 체크
     ├─ 수정 대상 Question 조회
     ├─ 입력값 검증 (Serializer)
     └─ 수정 로직 위임 (Service)

Permission
 └─ QuestionUpdatePermission
     ├─ 로그인 여부 (401)
     ├─ 학생 권한 (403)
     └─ 작성자 본인 여부 (403)

Serializer
 └─ QuestionUpdateSerializer
     └─ QuestionImagePatchSerializer

Service
 └─ update_question (transaction.atomic)

Selector
 └─ get_question_for_update

serializer

  • Serializer는 “저장(save)”를 하지 않는다
    • 요청 데이터 구조 검증 + 파싱만 담당
    • title / content / category → 선택 수정
    • images → 부분 삭제 / 부분 추가 (기존 이미지 유지 가능)
    • 필요한 것만 삭제/추가 가능

class QuestionImagePatchSerializer(serializers.Serializer):
    delete_ids = serializers.ListField(
    # 삭제할 이미지 ID 목록 (ListField: 리스트 형태의 값만 허용)
    ## 이 필드는 "기존 이미지 중 일부를 삭제"하기 위한 용도
    
        child=serializers.IntegerField(),
        # 리스트 안의 각 요소는 정수(Integer)여야 함
        ## QuestionImage 테이블의 PK(id)를 의미

        required=False,
        # PATCH 요청이므로 필수값이 아님
        ## (이미지 추가만 하거나, 아예 이미지 수정이 없을 수도 있음)
        
        default=list,
        # delete_ids가 요청에 없을 경우 기본값은 빈 리스트 []
        ## default=[] 는 mutable default 문제 발생 가능
        ## default=list 는 호출 시마다 새로운 리스트 생성
    )

    add_urls = serializers.ListField(
    # 새로 추가할 이미지 URL 목록
    ## 업로드 완료된 이미지의 접근 가능한 URL을 받음

        child=serializers.URLField(),
        # 리스트 안의 각 요소는 URL 형식이어야 함
        ## http:// 또는 https:// 로 시작하는 유효한 URL인지 검증
        
        required=False,
        # PATCH 요청이므로 필수값 아님
        ## (이미지 삭제만 하는 요청도 가능)
        
        default=list,
        # add_urls가 요청에 없을 경우 기본값은 빈 리스트 []
    )
class QuestionUpdateSerializer(serializers.Serializer):
    title = serializers.CharField(
    # 질문 제목 수정    
    
        required=False,
        # 부분 수정 허용
        ## 요청에 없으면 기존 title 유지
    )

    content = serializers.CharField(
    # 질문 내용 수정
    
        required=False,
        # 부분 수정 허용
    )

    category = serializers.PrimaryKeyRelatedField(
    # 질문 카테고리 수정
    # - PrimaryKeyRelatedField:
    # 요청에서 category 값으로 "카테고리 ID"를 받음
    # - 내부적으로 QuestionCategory 객체로 변환됨
        
        queryset=QuestionCategory.objects.all(),
		# 유효한 카테고리인지 DB에서 검증
        
        required=False,
        # PATCH이므로 필수 아님
        # (카테고리를 수정하지 않는 경우도 허용)
    )

    images = QuestionImagePatchSerializer(
    # 이미지 수정 정보
    # - 중첩 Serializer 사용
    # - images 필드가 존재할 경우:
    #   {
    #     "delete_ids": [...],
    #     "add_urls": [...]
    #   }
    #   구조를 검증
    
        required=False,
    )
  • ModelSerializer가 아니라 Serializer를 쓴 이유

    • DB에 바로 저장하는 역할이 아니라 요청 데이터 구조를 “검증/파싱”하는 목적이기 때문
    • PATCH 특성상 “부분 변경” 구조를 자유롭게 설계하기 좋음

selectors

  • get() 대신 first()인 이유
    • get(): 없으면 DoesNotExist 예외 (ORM 예외)
    • first(): 없으면 None / 도메인 예외를 직접 던질 수 있음
      • ORM 예외를 그대로 노출하지 않고 비즈니스 의미가 담긴 예외로 변환
  • 404 뱉는 이유
    • “질문이 없다”는 조회 단계의 책임 / View에서 if 문으로 검사 ❌ / Service에서 검사 ❌
# 질문 수정용 조회 함수 (Selector)
def get_question_for_update(*, question_id: int) -> Question:
# 수정 대상 질문을 조회
    
    question = (
        Question.objects
        .select_related("author")
    	# Question.author 는 ForeignKey
    	## 이후 Permission 단계에서 obj.author_id 접근이 있으므로 미리 JOIN해서 쿼리 수를 줄임
        
        .filter(id=question_id)
        # PK 기반 조회
        
        .first()
        # 존재하지 않으면 None 반환
        ## get() 대신 first()를 써서 예외를 직접 제어
    )

    # 조회 결과가 없는 경우
    if question is None:
        # 질문이 존재하지 않음을 의미하는 도메인 예외 발생
        # View나 Service가 아닌 Selector에서 판단
        # 전역 예외 핸들러에서 404 응답으로 변환됨
        raise QuestionNotFoundError()

    # 정상적으로 조회된 Question 객체 반환
    return question

service

@transaction.atomic
# 함수 내부에서 수행되는 모든 DB 변경 작업을 하나의 트랜잭션으로 처리
## 중간에 예외 발생 시 질문 수정 / 이미지 수정 모두 롤백됨

def update_question(
    *,
    question: Question,
    validated_data: dict[str, Any],
) -> Question:
    # question: 수정 대상 질문 객체
    # validated_data: Serializer에서 검증된 PATCH 요청 데이터

1. 일반 필드 수정
    for field in ("title", "content", "category"):
    # PATCH 방식이므로 수정 가능한 필드 목록을 순회

        if field in validated_data:
        # 요청 데이터에 해당 필드가 포함된 경우에만 수정

            setattr(question, field, validated_data[field])
            # Question 객체의 속성을 동적으로 변경
            # → title / content / category 중 전달된 값만 갱신

    question.save()
    # 변경된 Question 필드를 DB에 저장

2. 이미지 수정 처리
    images_data = validated_data.get("images")
    # images 필드가 요청에 포함되지 않았다면 None 반환
    ## 이미지 수정이 없는 경우 전체 이미지 로직 스킵

    if images_data:
    # 이미지 관련 수정 요청이 있는 경우에만 처리

        delete_ids = images_data.get("delete_ids", [])
        # 삭제할 이미지 ID 목록 / 값이 없으면 기본값은 빈 리스트 []

        add_urls = images_data.get("add_urls", [])
        # 새로 추가할 이미지 URL 목록 / 값이 없으면 기본값은 빈 리스트 []

3. 이미지 삭제 처리
        if delete_ids:
        # 삭제할 이미지가 하나라도 있는 경우에만 실행

            QuestionImage.objects.filter(
                id__in=delete_ids,
                question=question,
            ).delete()
            # 현재 질문에 속한 이미지 중
            ## delete_ids에 포함된 이미지들만 삭제 다른 질문 이미지가 삭제되는 것을 방지

4. 이미지 추가 처리
        for url in add_urls:
        # 새로 추가할 이미지 URL들을 순회

            QuestionImage.objects.create(
                question=question,
                img_url=url,
            )
            # QuestionImage 객체 생성
            ## 이미지는 기존 이미지와 함께 유지되며 추가됨

5. 수정 완료 후 결과 반환
    return question
    # 수정이 완료된 Question 객체 반환
    # View에서 최신 상태를 응답 Serializer로 변환하여 클라이언트에 전달

view

class QuestionDetailAPIView(APIView):
    def get_authenticators(self) -> list[BaseAuthentication]:
	# HTTP Method에 따라 인증 적용 여부를 분기하기 위해 오버라이드
    
        request = getattr(self, "request", None)
        if request and request.method == "GET":
            return []
        return super().get_authenticators()
        # PATCH 등 다른 요청은 프로젝트 기본 인증 로직 사용

    def get_permissions(self) -> list[BasePermission]:
    # HTTP Method에 따라 Permission을 분기 적용
    
        if self.request.method == "PATCH":
            return [QuestionUpdatePermission()]
        return []
		# GET 요청은 Permission 검사 없음 (공개 API)
        
    @extend_schema(
        tags=["질의응답"],
        summary="질문 상세 조회 API",
        responses=QuestionDetailSerializer,
    )
    
    def get(self, request: Request, question_id: int) -> Response:
    # URL path parameter로 question_id를 전달받음
    
        self.validation_error_message = EMS.E400_INVALID_REQUEST("질문 상세 조회")["error_detail"]

        question = get_question_detail(question_id=question_id)

        return Response(
        # 조회된 질문 정보를 Serializer로 변환하여 200 OK로 응답
        
            QuestionDetailSerializer(
                question,
                context={"request": request},
            ).data,
            status=status.HTTP_200_OK,
        )

    @extend_schema(
        tags=["질의응답"],
        summary="질문 수정 API",
        request=QuestionUpdateSerializer,
    )
    def patch(self, request, question_id):
        self.validation_error_message = EMS.E400_INVALID_REQUEST("질문 수정")["error_detail"]
        
        question = get_question_for_update(question_id=question_id)
        # 수정 대상 질문 조회 - 존재하지 않으면 404 예외 발생

        self.check_object_permissions(request, question)
        # 객체 단위 Permission 검사 수행
        ## 인증 여부 / 학생 권한 / 작성자 본인 여부

        serializer = QuestionUpdateSerializer(
            instance=question,
            data=request.data,
            partial=True,
        )
        # PATCH 요청이므로 partial=True 설정
        ## 전달된 필드만 검증 및 수정 가능
        
        serializer.is_valid(raise_exception=True)
        # 요청 데이터 검증 - 유효하지 않을 경우 400

        question = update_question(
            question=question,
            validated_data=serializer.validated_data,
        )
        # 질문 수정 도메인 로직을 Service에 위임
        ## 트랜잭션 처리 / 필드 수정 / 이미지 선택 수정

        return Response(
            QuestionDetailSerializer(question,context={"request": request},).data,
            status=status.HTTP_200_OK,
        )
        # 수정이 완료된 질문의 최신 상태를 응답
  • 중복검사?

    • 위에서 한번 권한 검사를 하는데 왜 아래에서 다시한번 check_object_permissions를 호출할까
      • self.check_object_permissions(request, question)
    • DRF Permission은 원래 “2단계 구조”

      • has_permission(): 이 요청 자체를 할 수 있는가?
      • has_object_permission(): 이 특정 객체를 다룰 수 있는가?
def get_permissions(self):
    if self.request.method == "PATCH":
        return [QuestionUpdatePermission()]

1. get_permissions()에서 Permission을 붙인다
- 여기서 DRF는 이 요청은 Permission 검사가 필요하다 라고 인식 
- 여기서 인증 여부 / 학생 권한 을 검증 

self.check_object_permissions()

2. self.check_object_permissions()를 직접 호출하는 이유
- GenericAPIView + get_object()를 쓰면 → DRF가 자동으로 has_object_permission() 호출
- APIView는 개발자가 직접 호출해야 함

question = get_question_for_update(question_id=question_id)
self.check_object_permissions(request, question)

3. 위의 question 객체는 DRF가 모르는 객체이기 때문에 객체 권한 검사를 직접 호출해야 함

def has_object_permission(self, request, view, obj):
    if obj.author_id != request.user.id:
        raise PermissionDenied(...)

4. permission에 있는 이 함수만 실행 됨 
- 작성자 본인 여부만 검증 
- 인증 / 학생 여부는 다시 검사하지 않음 

permission

class QuestionUpdatePermission(BasePermission):

    def has_permission(self, request: Request, view: APIView) -> bool:
        user = request.user

        # 401
        if isinstance(user, AnonymousUser) or not user.is_authenticated:
        # 비로그인 사용자이거나 인증되지 않은 경우
        
            raise QuestionUpdateNotAuthenticated()

        # 학생만 가능
        if request.user.role != RoleChoices.ST:
            raise PermissionDenied(
                detail=EMS.E403_QNA_PERMISSION_DENIED("등록")["error_detail"]
            )

        return True

    # 작성자 본인만 가능
    def has_object_permission(self, request: Request, view: APIView, obj: Question) -> bool:
    # 실제 Question 객체를 기준으로 한 권한 검사 / has_permission 통과 이후에 호출됨

        if obj.author_id != request.user.id:
            raise PermissionDenied(
                detail=EMS.E403_OWNER_ONLY_EDIT("질문")["error_detail"]
            )
        return True

PATCH를 이용한 수정 흐름

# 1. 요청시작 = Request
{
  "title": "...",
  "content": "...",
  "category": 3,
  "images": {
    "delete_ids": [1, 2],
    "add_urls": ["https://..."]
  }
}

# 2. PATCH 요청이 들어오면 아래의 메서드 호출 
view = def patch(self, request, question_id):

# 3. 수정 대상 질문 조회 (Selector)
- DB에서 Question 조회 (존재하지 않으면 → 즉시 404 (QuestionNotFoundError))
question = get_question_for_update(question_id=question_id)

# 4. 권한 검사 (Permission)
has_permission - if not authenticated → 401 / if role != 학생 → 403
has_object_permission - if 작성자 아님 → 403

# 5. 요청 데이터 검증 (Serializer)
- PATCH이므로 partial=True
serializer = QuestionUpdateSerializer(
    instance=question,
    data=request.data,
    partial=True,
)
serializer.is_valid(raise_exception=True)

# 6. 실제 수정 로직 실행 (Service)
question = update_question(
    question=question,
    validated_data=serializer.validated_data,
)

# 6-1. 일반 필드 수정
- 전달된 필드만 수정
for field in ("title", "content", "category"):
    if field in validated_data:
        setattr(question, field, ...)
question.save()

# 6-2. 이미지 수정 (선택)
images_data = validated_data.get("images")

- 삭제 
QuestionImage.objects.filter(
    id__in=delete_ids,
    question=question,
).delete()

- 추가 
QuestionImage.objects.create(
    question=question,
    img_url=url,
)

# 7. 수정 결과 응답 반환
return Response(
    QuestionDetailSerializer(
        question,
        context={"request": request},
    ).data,
    status=200,
)

selector 사용 이유

  • “질문 수정이라는 유스케이스에서 ‘존재하는 질문 하나’를 보장하는 역할”
    • “질문이 있다”는 전제를 이 함수 하나가 책임지고 보장해줌
  • selector 이 없는 경우

# 1. View에서 직접 처리하는 경우
question = Question.objects.filter(id=question_id).first()
if question is None:
    raise QuestionNotFoundError()

- view 가 더러워짐 = 조회 + 예외 판단을 view가 떠안음

# 2. question = Question.objects.get(id=question_id)를 사용하는건 ?
try:
    question = Question.objects.get(id=question_id)
except Question.DoesNotExist:
    raise QuestionNotFoundError()

- try/except를 매번 사용해야 함 
종류목적예시
데이터 구성 Selector어떻게 가져올지get_question_detail_queryset
존재 보장 Selector존재하는지get_question_for_update

수정 selector 분석

question = Question.objects.select_related("author").filter(id=question_id).first()

# Question 모델
class Question(models.Model):
    author = models.ForeignKey(User, ...)

# Question.objects.select_related("author")
“Question을 조회할 때, 연결된 User(author)도 SQL JOIN으로 한 번에 같이 가져오기”

question.author_id == user.id
  • 즉, Question 테이블의 author_id 컬럼 / User 테이블의 id 컬럼 = 둘이 같은 row를 JOIN
      • .prefetch_related("answers")
      • “이 queryset에 포함된 Question들에 대해서만, 각 Question에 연결된 Answer들을 전부 가져온다”

is_editable = serializers.SerializerMethodField()

    def get_is_editable(self, obj):
        request = self.context.get("request")
        if not request or not request.user.is_authenticated:
            return False
        return obj.author_id == request.user.id
  • == 연산자
    • 이 표현식의 결과는 무조건 Bool값 = True / Fasle

수정 가능 여부

  • “is_editable은 버튼 표시용
    • 실제 수정 가능 여부는 여기서 결정되지 않는다
  • 실제 수정 가능 여부는 QuestionUpdatePermission 여기서 결정

질문 수정 API 흐름

  • 1. 인증

    • get_authenticators() → PATCH 이므로 기본 인증 적용
  • 2. 권한

    • get_permissions() → QuestionUpdatePermission
      • 401: 로그인 안됨 → QuestionUpdateNotAuthenticated
      • 403: 학생 아님 → E403_QNA_PERMISSION_DENIED
  • 3. 객체 존재 확인

    • get_question_for_update() 없으면 → QuestionNotFoundError (404)
  • 4. 객체 권한

    • self.check_object_permissions()
      • 작성자 아님 → E403_OWNER_ONLY_EDIT
  • 5. 부분 수정

    • QuestionUpdateSerializer(partial=True)
  • 6. 도메인 처리

    • update_question()
  • 7. 응답

    • 수정된 질문 상세 반환
  • 권한 / 존재 / 부분 수정 / 트랜잭션 전부 정상

ModelSerializer를 사용하지 않은 이유

  • 수정 시리얼라이저는 “모델을 표현”하는 게 아니라
    • “수정 요청을 해석하는 DTO”이기 때문에 ModelSerializer를 쓰지 않았다.

2025.12.25


본문 이미지url 파싱 유틸

  • content에서 이미지 URL 파싱 유틸 만들기

    • 원하는 기능: <img src="..."> 태그에서 URL만 추출하는 함수 작성
import re

IMG_TAG_PATTERN = re.compile(r'<img[^>]+src="([^">]+)"')

def extract_image_urls_from_content(content: str) -> set[str]:
    if not content:
        return set()
    return set(IMG_TAG_PATTERN.findall(content))
  • “DB 이미지 vs content 이미지” 비교 로직 설계

    • content_images: 본문에서 파싱한 URL / db_images: QuestionImage.img_url
      • 삭제 대상 = db_images - content_images
      • 추가 대상 = content_images - db_images

2025.12.26


profile
안녕하세요.

0개의 댓글