2025/12/21 작동 흐름 및 각 코드 리뷰

김기훈·2025년 12월 21일

TIL

목록 보기
90/194

오늘의 목표 ❗️

현재 구현된 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) 시 이미지 처리 흐름

  • Request

{
  "title": "...",
  "content": "...",
  "category_id": 1,
  "image_urls": [
    "https://s3.../img1.png",
    "https://s3.../img2.png"
  ]
}
  • Serializer

      1. URL 형식 검증만 수행
      1. DB 모델과 직접 연결 ❌
      1. 단순 입력값
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) 시 이미지 처리 흐름

  • Selector 단계(.prefetch_related("images"))

    • 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()
    )
  • Serializer 단계

    • 이미지 객체 → URL 문자열 배열로 변환
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) 시 이미지 처리 흐름

  • Request

{
  "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 새로 생성

결론

    1. 프론트가 S3에 이미지를 업로드
    1. S3가 URL을 반환
    1. 그 URL을 질문과 연결해서 QuestionImage 테이블에 저장
    1. 조회 시 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에서 실제로 하는 일(등록)

    • 아래의 예시

        1. Question 먼저 생성됨 → question.id 확정
        1. image_urls 리스트 순회
        1. URL 하나당 question_id = question.id / img_url = S3에서 받은 URL
        1. QuestionImage 테이블에 row 생성
for url in validated_data.get("image_urls", []):
    QuestionImage.objects.create(
        question=question,
        img_url=url,
    )
    
# DB
## questions
| id | title |
| -- | ----- |
| 1  | 질문   |

## question_images
| 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) |
  • 조회할 때 prefetch_related("images")가 하는 일

    • 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()]
      • URL 문자열 배열로 응답
  • 수정

    • 기존 이미지 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]

    • 하나만 가져옴

새롭게 알게된 내용 ✅

오늘 발생한 문제(발생 했다면) ✅

profile
안녕하세요.

0개의 댓글