2025/12/26 PR Riview - 2

김기훈·2025년 12월 26일

TIL

목록 보기
95/194

오늘 학습 내용 ✅

PR 리뷰 진짜 반영

이미지 태그를 파싱

class QuestionImagePatchSerializer(serializers.Serializer[QuestionImage]):
    delete_ids = serializers.ListField(
        child=serializers.IntegerField(),
        required=False,
        default=list,
    )
    add_urls = serializers.ListField(
        child=serializers.URLField(),
        required=False,
        default=list,
    )
  • 리뷰 내용

    • 질의내용 본문에서 이미지 태그를 파싱해서 기존에 저장된 이미지와 비교해서 삭제, 추가해야할 이미지에 대한 로직을 처리하는게 어떨까요?
    • 임의로 받게되면 본문내용에는 사용되지않은 이미지를 추가할 수도 있겠다는 생각이드네요
  • 해석

    • 지금 이미지 변경의 기준이 클라이언트가 준 리스트가 되는데 이미지의 출처는 본문이어야 한다.
    • 이미지 태그를 파싱
      • content 안의 <img src="..."> 태그를 파싱(실제로 사용된 이미지 url 목록 추출)
      • 본문에 없는 이미지는 삭제하고 새로 등장한 이미지는 추가한다.
  • 문제점

    • 현재 방식은 프론트엔드가 delete_ids와 add_urls를 계산해서 보냄
      • 사용자가 에디터에서 이미지를 지웠는데 delete_ids에 안담기면
        • DB에는 남고 화면에는 안보이는 불필요한 이미지 발생
      • 사용자가 이미지를 업로드만하고 본문에 삽입을 안 했는데 add_urls에 담기면
        • 사용되지 않는 불필요한 데이터 저장
  • 해결책

    • 백엔드는 content 안의 <img> 태그들을 분석(Parsing)
    • DB에 있는 이미지들 vs 본문에 있는 이미지들을 비교(Diff)하여 삭제/추가를 백엔드가 직접 판단

S3에 있는 객체도 삭제

    # 이미지 수정
    images_data = validated_data.get("images")
    if images_data:
        delete_ids = images_data.get("delete_ids", [])
        add_urls = images_data.get("add_urls", [])

        # 삭제
        if delete_ids:
            QuestionImage.objects.filter(
                id__in=delete_ids,
                question=question,
            ).delete()

        # 추가
        for url in add_urls:
            QuestionImage.objects.create(
                question=question,
                img_url=url,
  • 리뷰 내용

    • 이미지 삭제시 단순히 모델만 제거하는게 아니라 모델에 저장된 S3 Key를 활용해서
      • S3에 있는 이미지 객체 또한 삭제해주셔야 합니다.
    • 추가 시에는 해당 이미지가 S3에 올바르게 업로드된 이미지가 맞는지 확인할 필요가 있어보이구요
  • 문제점

    • QuestionImage.objects.delete()는 DB의 줄(Row)만 지움
      • AWS S3 버킷에는 파일이 그대로 남아 있어 스토리지 비용이 계속 발생
    • 악의적인 사용자가 S3 버킷이 아닌 http://malicious-site.com/virus.jpg 같은
      • URL을 보낼 수도 있음 또는 업로드가 실패한 URL을 보낼 수도 있음
  • 해결

    • 삭제의 경우
      • DB 삭제 직전에 해당 URL에서 Key를 추출해 s3_client.delete(key)를 호출
    • 추가의 경우
      • 들어온 URL이 S3 도메인(Bucket URL)으로 시작하는지 검증(Validation)

리뷰 해결 🔴


유틸리티 구현 (URL 파서 & S3 검증)

  • 본문에서 이미지URL을 추출하는 도구와 S3 관련 헬퍼 기능 구현

# qna/utils/content_image_parser.py

import re
# 파이썬의 정규표현식(Regular Expression) 모듈을 불러옴 (문자열에서 특정 패턴을 찾기위해 필수)

from typing import Set
# 결과값으로 '중복이 없는 문자열들의 집합(Set)'을 반환

def extract_image_urls_from_content(content: str) -> Set[str]:
    """
    HTML/Markdown 본문에서 <img src="..."> 또는 마크다운 이미지 링크를 추출합니다.
    """
    html_pattern = r'<img[^>]+src="([^">]+)"'
    
    urls = set(re.findall(html_pattern, content))
    return urls
  • html_pattern = r'<img[^>]+src="([^">]+)"'

    • <img
      • "무조건 <img 라는 글자로 시작"
    • [^>]+
      • "그 뒤에 >(태그 닫힘)가 나오기 전까지, 다른 글자들이 1개 이상 있어도 됨"
      • <img class="my-img" src="...">
      • 위 예시처럼 src 앞에 다른 속성(class, style 등)이 올 수 있기 때문에 위의 코드가 필요
    • src="
      • 엔 반드시 src=" 라는 글자가 나와야 함" (이미지 주소의 시작점)
    • ([^">]+)"
      • 여기서 뽑아 내야 할 데이터
        • "(따옴표)>(태그 끝) 가 나오기 전까지의 모든 글자를 잡아냄
        • 즉, http://.../image.jpg 부분을 캡처하라는 뜻
    • "
      • "마지막은 따옴표(")로 끝나야 함
  • urls = set(re.findall(html_pattern, content))

    • re.findall(pattern, content)
      • 위에서 만든 규칙(pattern)에 맞는 것들을 본문(content)에서 찾아 리스트(List)로 만들어 줌
        • 예: ['url1.jpg', 'url2.png', 'url1.jpg']
    • set(...)
      • 리스트를 집합(Set)으로 변환 / 집합의 특성상 중복이 자동으로 사라짐

사용 예시

<p>안녕하세요</p>
<img class="w-full" src="https://s3.aws.com/my-bucket/cat.jpg">
<p>이건 고양이입니다.</p>
<img src="https://s3.aws.com/my-bucket/dog.png" alt="강아지">
<img src="https://s3.aws.com/my-bucket/cat.jpg"> 
  • Findall:

    • cat.jpg, dog.png, cat.jpg 세 개를 찾음
  • Set:

    • 중복된 cat.jpg 하나를 버림
  • Return:

    • {'https://s3.aws.com/my-bucket/cat.jpg', 'https://s3.aws.com/my-bucket/dog.png'}
  • 주의사항 ⚠️ (코드의 한계)

    • 현재 작성된 정규식(html_pattern)은 HTML 표준인 큰따옴표(")를 사용한 경우에만 작동
      • <img src='image.jpg'> (작은따옴표 사용) -> 못 찾음
      • ![](image.jpg) (마크다운 문법) -> 못 찾음

  • 이 코드의 목적

    • S3에서 파일을 지우려면 https://... 로 시작하는 전체 주소가 아니라,
    • 버킷 내부의 파일 경로(Key)가 필요 이 함수는 그 변환 작업을 담당
# qna/utils/s3_client.py

from urllib.parse import urlparse

    def delete_from_url(self, url: str) -> None:
        """
        전체 URL을 받아서 Key를 추출하고 삭제를 수행
        예: https://my-bucket.s3.ap-northeast-2.amazonaws.com/uploads/uuid.jpg 
        	-> uploads/uuid.jpg 삭제
        """
        if not url:
            return
            
        # URL에서 도메인 부분 제거하고 Key만 추출하는 로직
        try:
            parsed = urlparse(url)
            key = parsed.path.lstrip('/')
            self.delete(key)
        except Exception as e:
            logger.error(f"Failed to parse key from URL {url}: {e}")

    def is_valid_s3_url(self, url: str) -> bool:
        """
        이 URL이 우리 버킷의 URL인지 검증합니다.
        """
        if not url:
            return False

        return self.bucket_name in url and "amazonaws.com" in url
  • urllib.parse 동작 원리 및 필요한 이유

    • 입력: https://my-bucket.s3.ap-northeast-2.amazonaws.com/uploads/uuid.jpg
      • urlparse(url)
        • URL을 머리(Scheme), 몸통(Netloc), 꼬리(Path)로 분해
      • parsed.path
        • 여기서 /uploads/uuid.jpg 부분만 가져옴
      • lstrip('/')
        • S3 Key는 보통 앞의 슬래시(/)를 뺌 (uploads/uuid.jpg로 만듦)
      • self.delete(key)
        • 마지막으로 우리가 찾은 Key를 이용해 실제 S3 삭제 요청을 보냄
    • 필요한 이유

      • DB에는 전체 URL(https://...)이 저장되어 있음
      • 하지만, S3 삭제 명령(delete_object)은 Key(path/to/file) 를 요구
        • 이 변환 과정이 없으면 삭제가 실패
  • is_valid_s3_url(self, url: str) -> bool

    • 역할: "이 URL이 우리 창고(버킷) 물건이 맞는지 검사"
    • 동작 원리

      • self.bucket_name in url
        • URL 안에 우리 프로젝트의 버킷 이름이 포함되어 있는지 확인
      • "amazonaws.com" in url
        • 이것이 AWS S3 도메인 형태인지 확인
      • 결과
        • 둘 다 포함되어 있어야만 True를 반환

Service Layer 구현

  • 기존의 "프론트엔드가 삭제할 ID와 추가할 URL을 직접 알려주는 방식"을 폐기 후
  • "본문 내용을 기준으로 자동으로 동기화하는 방식"으로 변경한 서비스 레이어 코드
# qna/services/question/question_update/service.py

from typing import Any

from django.db import transaction

from apps.qna.models import Question, QuestionImage
from apps.qna.utils.content_image_parser import extract_image_urls_from_content
from apps.qna.utils.s3_client import S3Client


@transaction.atomic
def update_question(
    *,
    question: Question,
    validated_data: dict[str, Any],
) -> Question:
    """
    질문을 수정하고, 변경된 본문 내용에 맞춰 이미지를 동기화(Sync)합니다.
    """
    
    # 1. 기본 필드 업데이트 (Title, Category 등)
    update_fields: list[str] = []
    
    # content는 이미지 동기화를 위해 따로 변수로 빼둡니다.
    new_content = validated_data.get("content")
    
    for field in ("title", "content", "category"):
        if field in validated_data:
            setattr(question, field, validated_data[field])
            update_fields.append(field)

    if update_fields:
        question.save(update_fields=update_fields)

    # 2. 이미지 동기화 (본문이 수정되었을 때만 수행)
    # 만약 본문 수정 없이 제목만 바꿨다면 이미지를 굳이 체크할 필요가 없습니다.
    if new_content is not None:
        _sync_question_images(question, new_content)

    return question


def _sync_question_images(question: Question, content: str) -> None:
    """
    본문(Content)에 포함된 이미지 URL을 추출하여
    DB 및 S3와 동기화(삭제 및 추가)를 수행합니다.
    """
    s3_client = S3Client()

    # A. 현재 본문에 살아있는 URL들 추출 (파싱)
    # 예: {'https://.../a.jpg', 'https://.../b.png'}
    current_urls_in_content = extract_image_urls_from_content(content)

    # B. DB에 저장되어 있던 기존 이미지들 가져오기
    # 비교를 쉽게 하기 위해 { "URL": QuestionImage객체 } 형태의 딕셔너리로 만듦
    existing_images_map = {
        img.img_url: img 
        for img in QuestionImage.objects.filter(question=question)
    }
    existing_urls = set(existing_images_map.keys())

    # C. 삭제해야 할 URL 계산 (DB에는 있었는데, 본문에서 사라진 것)
    # 예: {old_url} - {new_url} = 삭제할 것
    urls_to_delete = existing_urls - current_urls_in_content

    # D. 추가해야 할 URL 계산 (본문에 새로 생겼는데, DB에는 없는 것)
    # 예: {new_url} - {old_url} = 추가할 것
    urls_to_add = current_urls_in_content - existing_urls

    # --- 처리 로직 ---

    # 1. 삭제 처리 (DB 삭제 + S3 실제 파일 삭제)
    if urls_to_delete:
        # 1-1. S3에서 파일 삭제 (비용 절감)
        for url in urls_to_delete:
            s3_client.delete_from_url(url) # 앞서 만든 유틸리티 메서드 사용
        
        # 1-2. DB에서 데이터 삭제 (이미지 테이블 정리)
        # 삭제할 URL들에 해당하는 QuestionImage 객체들을 한 번에 지움
        QuestionImage.objects.filter(
            question=question, 
            img_url__in=urls_to_delete
        ).delete()

    # 2. 추가 처리 (DB 추가 + 유효성 검증)
    new_images = []
    for url in urls_to_add:
        # 2-1. 보안 검증: 우리 S3 버킷의 URL이 맞는지 확인
        if s3_client.is_valid_s3_url(url):
            new_images.append(
                QuestionImage(question=question, img_url=url)
            )
        # 검증 실패한 URL은 DB에 저장하지 않고 무시함 (외부 이미지 등)

    # 2-2. DB에 일괄 저장 (Bulk Create로 성능 최적화)
    if new_images:
        QuestionImage.objects.bulk_create(new_images)
  • if new_content is not None:

    • content가 수정 요청에 포함되었을 때만 이미지 동기화 로직을 실행
  • _sync_question_images

    • "집합(Set) 연산"을 통해 무엇을 지우고 무엇을 더할지 수학적으로 계산
def _sync_question_images(question: Question, content: str) -> None:
    s3_client = S3Client()
  • Content와 현재 상태(DB) 가져오기

    current_urls_in_content = extract_image_urls_from_content(content)

    existing_images_map = {
        img.img_url: img
        for img in QuestionImage.objects.filter(question=question)
    }
    existing_urls = set(existing_images_map.keys())
  • 차집합(Difference)으로 대상 식별

    • urls_to_delete (A - B): "DB엔 있었는데 본문엔 없네?" → 삭제 대상
    • urls_to_add (B - A): "본문엔 있는데 DB엔 없네?" → 신규 추가 대상
    urls_to_delete = existing_urls - current_urls_in_content
    urls_to_add = current_urls_in_content - existing_urls
  • 삭제 실행 (S3 + DB)

    if urls_to_delete:
        for url in urls_to_delete:
            s3_client.delete_from_url(url)

        QuestionImage.objects.filter(
            question=question,
            img_url__in=urls_to_delete
        ).delete()

Serializer 간소화

  • 이전에는 이미지 처리를 위해 delete_ids, add_urls 같은 모델에 없는 필드를 받아야 했으므로 커스텀
  • 현재는 이미지 처리를 '본문 파싱' 방식으로 바꾸면서,
    • API가 받는 데이터는 순수하게 Question 모델의 필드(title, content, category)
  • 순수한 모델 필드만 남았으니, 가장 효율적인 ModelSerializer의 기본 기능만 써도 충분하고 더 깔끔
# qna/serializers/question/question_update.py

class QuestionUpdateSerializer(serializers.ModelSerializer):
    
    class Meta:
        model = Question
        fields = [
            "title", 
            "content", 
            "category", 
        ]

요약

  • content의 <img> 태그를 파싱해서 백엔드가 비교(Diff)하여 처리하는 방식으로 변경
    • 변경함으로써 API 요청값 단순화 / 데이터 정합성 보장
  • 이미지 삭제 시 S3 키를 추출해 실제 파일도 삭제하도록 s3_client에 로직을 추가
    • 추가되는 이미지도 우리 버킷 도메인인지 검증(Validation)하는 로직을 서비스 레이어에 추가

질문 생성

질문 수정 변경에 따른 질문 생성 코드 수정

  • 현재 코드는 프론트엔드가 준 URL을 맹신하고 저장하기 때문에 위험함
    • 본문의 이미지를 파싱 + 검증하기 위해서 특정 함수를 사용하여
      • 본문(content) 안에 있는 이미지를 찾아서 저장하고, 유효한 S3 URL인지도 체크함
        • 이 변경으로 인하여 시리얼라이저의 img_url도 제거
# 변경 전
for url in validated_data.get("image_urls", []):
        QuestionImage.objects.create(
            question=question,
            img_url=url,
        )

    return question
    
 # 변경 후
    from apps.qna.services.question.question_update.service import _sync_question_images
    
    _sync_question_images(question, validated_data["content"])

    return question


이론 정리 🔴


ModelSerializer 전환

  • Question 모델에 category가 ForeignKey로 정의되어 있다면,
    • DRF가 알아서 "아, 이건 ID를 받는 필드구나" 라고 인식하고 PrimaryKeyRelatedField로 처리
  • 특정 조건의 카테고리만 선택 가능하게 하거나(queryset 필터링), 필수 여부(required) 를 커스텀
    • 명시적으로 적는것도 좋음

Serializer vs ModelSerializer 변경 이유

  • "코드 생산성"과 "유지보수 효율성" 때문
  • 기존 방식 (serializers.Serializer):

    • 모든 필드(title, content, category)를 개발자가 일일이 수동으로 정의해야 함
    • 모델 정의(models.py)가 바뀌면(예: title의 max_length 변경)
      • 시리얼라이저도 기억했다가 수정해줘야 함
  • 변경 방식 (serializers.ModelSerializer):

    • class Meta에 model = Question만 지정하면, DRF가 자동으로 모델을 분석
      • "아, title은 모델에서 CharField네? 그럼 나도 CharField로 만들어야지."
      • "아, category는 모델에서 ForeignKey네? 그럼 PrimaryKeyRelatedField로 처리해야지."
    • 이렇게 알아서 필드를 생성해주기 때문에 코드가 훨씬 간결

PrimaryKeyRelatedField

  • ModelSerializer는 위의 모델을 보고 자동으로 아래와 같이 변환함
    • 따라서 굳이 명시적으로 적지 않아도 ID 값(PK)을 받아서 처리하는 로직이 완성
# models.py 
class Question(models.Model):
    category = models.ForeignKey(QuestionCategory, ...)
    
# serializers.py
category = serializers.PrimaryKeyRelatedField(
    queryset=QuestionCategory.objects.all(),
    many=False
)

partial=True

  • ModelSerializer를 사용
    • View에서 serializer를 호출할 때 partial=True를 넣어주면,
      • 모든 필드가 자동으로 required=False처럼 동작

새롭게 알게된 내용 ✅

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

profile
안녕하세요.

0개의 댓글