
[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
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 필드 수정
└─ 이미지 전체 교체
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(
child=serializers.IntegerField(),
required=False,
default=list,
)
add_urls = serializers.ListField(
child=serializers.URLField(),
required=False,
default=list,
)
class QuestionUpdateSerializer(serializers.Serializer):
title = serializers.CharField(
required=False,
)
content = serializers.CharField(
required=False,
)
category = serializers.PrimaryKeyRelatedField(
queryset=QuestionCategory.objects.all(),
required=False,
)
images = QuestionImagePatchSerializer(
required=False,
)
ModelSerializer가 아니라 Serializer를 쓴 이유
- DB에 바로 저장하는 역할이 아니라 요청 데이터 구조를 “검증/파싱”하는 목적이기 때문
- PATCH 특성상 “부분 변경” 구조를 자유롭게 설계하기 좋음
selectors
- get() 대신 first()인 이유
- get(): 없으면 DoesNotExist 예외 (ORM 예외)
- first(): 없으면 None / 도메인 예외를 직접 던질 수 있음
- ORM 예외를 그대로 노출하지 않고 비즈니스 의미가 담긴 예외로 변환
- 404 뱉는 이유
- “질문이 없다”는 조회 단계의 책임 / View에서 if 문으로 검사 ❌ / Service에서 검사 ❌
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
service
@transaction.atomic
def update_question(
*,
question: Question,
validated_data: dict[str, Any],
) -> Question:
1. 일반 필드 수정
for field in ("title", "content", "category"):
if field in validated_data:
setattr(question, field, validated_data[field])
question.save()
2. 이미지 수정 처리
images_data = validated_data.get("images")
if images_data:
delete_ids = images_data.get("delete_ids", [])
add_urls = images_data.get("add_urls", [])
3. 이미지 삭제 처리
if delete_ids:
QuestionImage.objects.filter(
id__in=delete_ids,
question=question,
).delete()
4. 이미지 추가 처리
for url in add_urls:
QuestionImage.objects.create(
question=question,
img_url=url,
)
5. 수정 완료 후 결과 반환
return question
view
class QuestionDetailAPIView(APIView):
def get_authenticators(self) -> list[BaseAuthentication]:
request = getattr(self, "request", None)
if request and request.method == "GET":
return []
return super().get_authenticators()
def get_permissions(self) -> list[BasePermission]:
if self.request.method == "PATCH":
return [QuestionUpdatePermission()]
return []
@extend_schema(
tags=["질의응답"],
summary="질문 상세 조회 API",
responses=QuestionDetailSerializer,
)
def get(self, request: Request, question_id: int) -> Response:
self.validation_error_message = EMS.E400_INVALID_REQUEST("질문 상세 조회")["error_detail"]
question = get_question_detail(question_id=question_id)
return Response(
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)
self.check_object_permissions(request, question)
serializer = QuestionUpdateSerializer(
instance=question,
data=request.data,
partial=True,
)
serializer.is_valid(raise_exception=True)
question = update_question(
question=question,
validated_data=serializer.validated_data,
)
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
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:
if obj.author_id != request.user.id:
raise PermissionDenied(
detail=EMS.E403_OWNER_ONLY_EDIT("질문")["error_detail"]
)
return True
PATCH를 이용한 수정 흐름
{
"title": "...",
"content": "...",
"category": 3,
"images": {
"delete_ids": [1, 2],
"add_urls": ["https://..."]
}
}
view = def patch(self, request, question_id):
- DB에서 Question 조회 (존재하지 않으면 → 즉시 404 (QuestionNotFoundError))
question = get_question_for_update(question_id=question_id)
has_permission - if not authenticated → 401 / if role != 학생 → 403
has_object_permission - if 작성자 아님 → 403
- PATCH이므로 partial=True
serializer = QuestionUpdateSerializer(
instance=question,
data=request.data,
partial=True,
)
serializer.is_valid(raise_exception=True)
question = update_question(
question=question,
validated_data=serializer.validated_data,
)
- 전달된 필드만 수정
for field in ("title", "content", "category"):
if field in validated_data:
setattr(question, field, ...)
question.save()
images_data = validated_data.get("images")
- 삭제
QuestionImage.objects.filter(
id__in=delete_ids,
question=question,
).delete()
- 추가
QuestionImage.objects.create(
question=question,
img_url=url,
)
return Response(
QuestionDetailSerializer(
question,
context={"request": request},
).data,
status=200,
)
selector 사용 이유
- “질문 수정이라는 유스케이스에서 ‘존재하는 질문 하나’를 보장하는 역할”
- “질문이 있다”는 전제를 이 함수 하나가 책임지고 보장해줌
selector 이 없는 경우
question = Question.objects.filter(id=question_id).first()
if question is None:
raise QuestionNotFoundError()
- view 가 더러워짐 = 조회 + 예외 판단을 view가 떠안음
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()
class Question(models.Model):
author = models.ForeignKey(User, ...)
“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. 도메인 처리
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