오늘 학습 내용 ✅
현재까지 구현 현황
질문 등록 API
Endpoint: POST /api/questions/
- 권한: 인증 필요 / 학생(ST)만 가능 / QuestionCreatePermission
Request Body
{
"title": "질문 제목",
"content": "질문 내용",
"category_id": 3,
"image_urls": [
"https://s3.amazonaws.com/xxx/1.png",
"https://s3.amazonaws.com/xxx/2.png"
]
}
{
"message": "질문이 성공적으로 등록되었습니다.",
"question_id": 12
}
APIView
└─ Serializer 검증
└─ get_category_or_raise()
└─ create_question()
├─ Question 생성
└─ QuestionImage bulk 생성
질문 목록 조회 API
Endpoint → GET /api/questions/
- 권한: 공개(비로그인 가능) / QuestionListQuerySerializer
Query Parameters
{
"answered": true,
"category": 2,
"search": "django",
"page": 1,
"page_size": 10
}
{
"page": 1,
"size": 10,
"total_count": 42,
"questions": [
{
"question_id": 1,
"category_path": "Backend > Django",
"profile_img_url": null,
"nickname": "철수",
"title": "Django ORM 질문",
"content_preview": "ORM에서 annotate를 쓰면...",
"answer_count": 2,
"view_count": 15,
"created_at": "2025-12-18T12:30:00",
"thumbnail_img_url": "https://..."
}
]
}
APIView
└─ QuerySerializer 검증
└─ get_question_list()
├─ base queryset
├─ filter_by_answered
├─ filter_by_category (하위 카테고리 포함)
├─ filter_by_search
├─ annotate (preview, thumbnail)
└─ pagination
질문 상세 조회 API
Endpoint: GET /api/questions/{question_id}/
- 권한: GET /api/questions/{question_id}/
Path Parameter
Response
{
"question_id": 1,
"title": "Django ORM 질문",
"content": "내용...",
"images": ["https://..."],
"category_path": "Backend > Django",
"view_count": 16,
"created_at": "2025-12-18T12:30:00",
"author": {
"nickname": "철수",
"profile_img_url": null
},
"answers": [
{
"answer_id": 3,
"content": "이렇게 하세요",
"created_at": "2025-12-18T13:00:00",
"is_adopted": false,
"author": {...},
"comments": [...]
}
]
}
APIView
└─ question_id 검증
└─ get_question_detail()
├─ selector (select_related + prefetch)
├─ 없으면 404
└─ view_count 증가
└─ Serializer 응답
Query Parameter
from rest_framework import serializers
class QuestionListQuerySerializer(serializers.Serializer):
page = serializers.IntegerField(min_value=1, default=1)
size = serializers.IntegerField(min_value=1, max_value=50, default=10)
search_keyword = serializers.CharField(required=False, allow_blank=True)
category_id = serializers.IntegerField(required=False, min_value=1)
answer_status = serializers.ChoiceField(
choices=["answered", "unanswered"],
required=False,
)
sort = serializers.ChoiceField(
choices=["latest", "oldest", "views"],
required=False,
default="latest",
)
def filter_by_answered(qs, answer_status: str | None):
if answer_status == "answered":
...
- 위 처럼 필터코드를 변경하면 문제점
- filter가 HTTP 계약을 알게 됨
- 나중에 enum 변경 시 filter도 수정 / 재사용성 ↓ / 책임 분리 붕괴
sort 방어 로직
def filter_by_sort(qs, sort):
...
return qs
if sort not in {"latest", "oldest", "views"}:
return qs
- 1. 이미 QuerySerializer에서 ChoiceField로 막고 있어서 굳이 추가 안함
새롭게 알게된 내용 ✅
QuestionFactory
- Django 테스트에서 쓰는 “테스트용 객체 생성기
args=[self.question.id]
reverse("question-detail", args=[self.question.id])
- URL path에 들어갈 “동적 숫자 값”을 순서대로 넣어주는 것
args (순서 기반)
- <int:question_id> 가 첫 번째 path 변수라서 리스트의 첫 번째 값이 들어감
kwargs (이름 기반)
reverse("question-detail", kwargs={"question_id": self.question.id})
- path 변수 이름과 매칭 / 순서 바뀌어도 안전 / 가독성 더 좋음
장점
path("questions/<int:question_id>/", ...)
path("questions/<int:question_id>/detail/", ...)
1. 위의 예시처럼 변경될 경우 args=[...] → 문제 생길 가능성 있음
reverse() 사용 시
- path parameter가 있으면 → kwargs 사용
- query parameter는 → ?key=value
(oz-externship-05-py3.13) ➜ oz_externship git:(feature/qna-question-detail) git rebase develop
Current branch feature/qna-question-detail is up to date
어려운 내용(추가 학습 필요) ✅
오늘 발생한 문제(발생 했다면) ✅
1
[ 🔴 문제: 테스트코드 실패 ]
질문 상세 조회 API 호출 시 TypeError: QuestionDetailAPIView.get()
got an unexpected keyword argument 'pk' 에러 발생으로 인해 테스트 실패
[ 🟡 원인: URL Path Parameter와 View 메서드 시그니처 불일치 ]
URL에서는 <int:pk>를 사용하고 있었고 View의 get() 메서드는 question_id 파라미터를 받도록 정의되어 있었다.
# urls.py
path("questions/<int:pk>/", ...)
# views.py
def get(self, request, question_id: int):
...
이로 인해 Django가 pk 인자를 전달했지만 View에서 이를 받을 수 없어 TypeError가 발생했다.
[ 🔵 해결: URL Path Parameter 이름을 View와 통일 - questions/<int:question_id> ]
2
[ 🔴 문제: Permission에서 Exception을 return한 문제 ]
if isinstance(user, AnonymousUser) or not user.is_authenticated:
return QuestionUpdateNotAuthenticated()
[ 🟡 원인: has_permission()의 반환값은 반드시 bool ]
Exception을 return하면 DRF 내부에서 True로 평가될 수 있음
permission이 통과되어 인증되지 않은 사용자도 로직 진입 가능 결과적으로 401이 보장되지 않음
[ 🔵 해결: Exception은 raise 해야 함 ]
if isinstance(user, AnonymousUser) or not user.is_authenticated:
raise QuestionUpdateNotAuthenticated()
3
[ 🔴 문제: has_object_permission이 호출되지 않는 문제 ]
QuestionUpdatePermission에 has_object_permission()이 정의되어 있지만 실제로 한 번도 호출되지 않음
[ 🟡 원인: View에서 get_object() 사용 ❌ / self.check_object_permissions() 호출 ❌]
DRF는 객체 권한 검사를 자동으로 하지 않음
[ 🔵 해결: def has_object_permission(self,request: Request,view: APIView,obj: Question) 제거 ]
작성자 검증은 Service에서만 처리 Permission에서는 인증 여부 수강생(ST) 여부만 체크
4
[ 🔴 문제: Service에서 PermissionError 사용 문제 ]
if question.author_id != author_id:
raise PermissionError()
[ 🟡 원인: PermissionError는 DRF 예외 ❌ / APIException ❌ / EMS ❌ ]
custom_exception_handler와 연결되지 않음
500 에러 / 혹은 의도치 않은 응답 발생 가능
[ 🔵 해결: DRF 예외로 명확히 변환 ]
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied(
EMS.E403_OWNER_ONLY_EDIT("질문")["error_detail"]
)