오늘의 목표 ❗️
현재 구현된 CRU 흐름 확인 및 코드 리뷰
Presign이 어떻게 사용되는지 다시 확인
테스트코드 형식 정리 및 구현중 사용한 이론 정리
오늘 학습 내용 ✅
전체 공통 흐름 (DRF 관점)
HTTP 요청
→ URL 매칭
→ Authentication
→ Permission
→ View 메서드
→ Serializer 검증
→ Service 로직
→ Response / Exception
CRUD
질문 등록 (Create)
POST /api/v1/qna/questions
- Permission
- 비로그인 → 401 (Exception) / 로그인 O + ST 아님 → 403 (False)
- Service
- category 존재 여부 검증 / Question 생성 / QuestionImage 생성
# 요청 흐름
1. get_authenticators()
2. QuestionCreatePermission.has_permission()
3. View.post()
4. QuestionCreateSerializer.is_valid()
5. Service.create_question()
6. Response(201)
# 성공 응답
{
"message": "질문이 성공적으로 등록되었습니다.",
"question_id": 1
}
질문 목록 조회 (Read – List)
GET /api/v1/qna/questions
- Permission ❌
- Service
- 필터(answered, category, search, sort)
- 정렬(latest / oldest / views)
- annotate(answer_count, preview, thumbnail)
- pagination
# 요청 흐름
→ get_authenticators() → []
→ View.get()
→ QuerySerializer 검증
→ Service.get_question_list()
→ ORM 필터/정렬/페이징
→ Serializer
→ Response(200)
질문 상세 조회 (Read – Detail)
GET /api/v1/qna/questions/{question_id}
- Service
- Question 존재 확인
- view_count 증가
- answers / comments / images 미리 로딩
# 요청 흐름
→ get_authenticators() → []
→ View.get()
→ question_id 검증
→ Service.get_question_detail()
→ Selector (select_related / prefetch_related)
→ view_count +1
→ Serializer
→ Response(200)
질문 수정 (Update)
PUT /api/v1/qna/questions/{question_id}
- Permission
- 비로그인 → 401 (Exception)
- 로그인 O + ST 아님 → 403
- Service
- 질문 없음 → 404
- 작성자 아님 → 403
- 수정 로직 수행
예외 처리 흐름
# 예외 처리 흐름(어디에서 예외가 발생하던지)
Exception 발생
→ custom_exception_handler
→ EMS 메시지 매핑
→ Response
책임 분리 요약
| 레이어 | 질문 |
|---|
| Permission | 이 사람이 이 요청을 해도 되나? |
| Service | 이 리소스에 대해 이 행동이 가능한가? |
| View | 연결 |
| Serializer | 입력 검증 |
| ORM | 데이터 |
이미지
요약
- 이미지는 질문 본문과 분리된 테이블(QuestionImage)로 관리하고,
- 등록/수정 시에는 URL 목록을 받아 DB에 매핑하며, 수정 시에는 ‘전체 교체 전략’을 사용한다.
이미지 모델
class QuestionImage(TimeStampedModel):
question = models.ForeignKey(
Question,
on_delete=models.CASCADE,
related_name="images",
)
img_url = models.CharField(max_length=255)
의미
- 질문 1개 ↔ 이미지 N개 (1:N)
- 이미지는 S3 URL 문자열만 저장
- 실제 파일 업로드 ❌ (프리사인드)
질문 등록(Create) 시 이미지 처리 흐름
{
"title": "...",
"content": "...",
"category_id": 1,
"image_urls": [
"https://s3.../img1.png",
"https://s3.../img2.png"
]
}
Serializer
- URL 형식 검증만 수행
- DB 모델과 직접 연결 ❌
- 단순 입력값
image_urls = serializers.ListField(
child=serializers.URLField(),
write_only=True,
required=False,
)
Service 단계 (create_question)
이미지는 “질문 생성 이후 부가 데이터”
- 아래의 코드가 하는 일
- 질문(Question) 먼저 생성
- image_urls가 있으면 URL 하나당 QuestionImage row 1개 생성
- question_id로 FK 연결
for url in validated_data.get("image_urls", []):
QuestionImage.objects.create(
question=question,
img_url=url,
)
질문 조회(Read) 시 이미지 처리 흐름
- question.images.all()을 미리 로딩 / N+1 방지
def get_question_detail_queryset(question_id: int) -> Question | None:
return (
Question.objects.select_related("author", "category")
.prefetch_related(
"images",
Prefetch(
"answers", queryset=Answer.objects.select_related("author").prefetch_related("answer_comments__author")
),
)
.filter(id=question_id)
.first()
)
def get_images(self, obj: Question) -> list[str]:
return [img.img_url for img in obj.images.all()]
"images": [
"https://s3.../img1.png",
"https://s3.../img2.png"
]
질문 수정(Update) 시 이미지 처리 흐름
{
"title": "수정된 제목",
"content": "수정된 내용",
"category_id": 1,
"image_urls": [
"https://s3.../new1.png"
]
}
Service 단계 (update_question)
- if "image_urls" in validated_data:
- image_urls가 없을 때는 이미지 변경 안 함 | 제목/내용/카테고리만 수정
if "image_urls" in validated_data:
QuestionImage.objects.filter(question=question).delete()
for url in validated_data["image_urls"]:
QuestionImage.objects.create(
question=question,
img_url=url,
)
수정파트에서 사용하는 전략: 전체 교체 (Replace All)
- 기존 이미지 전부 삭제 / 요청으로 들어온 이미지 목록만 다시 생성
# 기존
[img1, img2, img3]
# 요청
[img2, img4]
# 결과
기존 전부 삭제
→ img2, img4 새로 생성
결론
-
- 프론트가 S3에 이미지를 업로드
-
- S3가 URL을 반환
-
- 그 URL을 질문과 연결해서 QuestionImage 테이블에 저장
-
- 조회 시 prefetch_related("images")로 질문 ID에 매칭된 이미지들을 한 번에 불러온다.
-
이미지 “업로드”는 백엔드가 안 한다
[프론트]
이미지 선택
→ presigned URL 요청
→ S3에 직접 업로드
→ 업로드 완료
→ 이미지 URL 확보
질문 등록 시, 백엔드가 받는 건 “URL 문자열”
- 아래의 예시에서 이미지는 이미 S3에 올라가 있음
# Request
{
"title": "질문 제목",
"content": "질문 내용",
"category_id": 1,
"image_urls": [
"https://s3.amazonaws.com/bucket/abc.png",
"https://s3.amazonaws.com/bucket/def.png"
]
}
백엔드 결론
Service에서 실제로 하는 일(등록)
아래의 예시
- Question 먼저 생성됨 → question.id 확정
- image_urls 리스트 순회
- URL 하나당 question_id = question.id / img_url = S3에서 받은 URL
- QuestionImage 테이블에 row 생성
for url in validated_data.get("image_urls", []):
QuestionImage.objects.create(
question=question,
img_url=url,
)
| id | title |
| -- | ----- |
| 1 | 질문 |
| id | question_id | img_url |
| -- | ----------- | ------------------------------------------------ |
| 1 | 1 | [https://s3/.../abc.png](https://s3/.../abc.png) |
| 2 | 1 | [https://s3/.../def.png](https://s3/.../def.png) |
-
- Selector 코드
- Question.objects.prefetch_related("images")
- 내부에서 벌어지는 쿼리(2번)
- 질문 쿼리(1번): SELECT * FROM questions WHERE id = 1;
- 이미지 쿼리(1번): SELECT * FROM question_images WHERE question_id IN (1);
-
매핑
- QuestionImage.question_id ↔ Question.id
- related_name="images" 덕분에
- question.images.all()도 가능
- 즉, question_id가 question.id인 모든 QuestionImage row
-
Serializer에서 이미지를 꺼내는 방법
- obj = Question 객체 / obj.images.all() = 이미 prefetch된 이미지들 / 추가 쿼리 ❌
def get_images(self, obj):
return [img.img_url for img in obj.images.all()]
"images": [
"https://s3/.../abc.png",
"https://s3/.../def.png"
]
이미지에 대해 백엔드가 하는 일
등록
- 프론트가 S3에 업로드 완료
- 프론트가 URL 문자열을 요청 바디로 보냄
- 백엔드는 그 URL을 QuestionImage.img_url 컬럼에 저장
조회
- prefetch_related("images")로
- question_id = 질문 id 인 이미지 row 전부 조회
- Serializer에서
[img.img_url for img in obj.images.all()]
수정
- 기존 이미지 row 삭제
- 새 URL들로 다시 QuestionImage 생성
- 여전히 URL만 저장
실제 응답 예시
# 조회 API 응답 예시
{
"images": [
"https://s3.amazonaws.com/bucket/a.png",
"https://s3.amazonaws.com/bucket/b.png"
]
}
# 응답 예시를 프론트가 사용하는 방법
<img src="https://s3.amazonaws.com/bucket/a.png" />
<img src="https://s3.amazonaws.com/bucket/a.png" />
썸네일
- 썸네일은 ‘대표 이미지 1장’이고, 질문 목록 조회 시에만 DB 쿼리로 계산해서 붙이는 가짜 컬럼
- 프론트는 그냥 URL을 받아서
<img>로 보여준다.
썸네일을 사용하는 위치
- 목록 카드 UI:
"thumbnail_img_url": "https://s3.../image1.png"
핵심 코드
annotated_qs = filtered_qs.annotate(
content_preview=Substr("content", 1, 100)
).annotate(
thumbnail_image_url=Subquery(
QuestionImage.objects
.filter(question=OuterRef("pk"))
.order_by("created_at", "id")
.values("img_url")[:1]
)
)
.filter(question=OuterRef("pk"))
- QuestionImage 중에서 현재 질문에 연결된 이미지들
.order_by("created_at", "id")
.values("img_url")[:1]
새롭게 알게된 내용 ✅
오늘 발생한 문제(발생 했다면) ✅