오늘 학습 내용 ✅
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 관련 헬퍼 기능 구현
import re
from typing import 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
[^>]+
- "그 뒤에
>(태그 닫힘)가 나오기 전까지, 다른 글자들이 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:
-
Return:
{'https://s3.aws.com/my-bucket/cat.jpg', 'https://s3.aws.com/my-bucket/dog.png'}
-
주의사항 ⚠️ (코드의 한계)
- 현재 작성된 정규식(
html_pattern)은 HTML 표준인 큰따옴표(")를 사용한 경우에만 작동
<img src='image.jpg'> (작은따옴표 사용) -> 못 찾음 ❌
 (마크다운 문법) -> 못 찾음 ❌
이 코드의 목적
- S3에서 파일을 지우려면
https://... 로 시작하는 전체 주소가 아니라,
- 버킷 내부의 파일 경로(Key)가 필요 이 함수는 그 변환 작업을 담당
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
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
- 결과
Service Layer 구현
- 기존의 "프론트엔드가 삭제할 ID와 추가할 URL을 직접 알려주는 방식"을 폐기 후
- "본문 내용을 기준으로 자동으로 동기화하는 방식"으로 변경한 서비스 레이어 코드
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)합니다.
"""
update_fields: list[str] = []
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)
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()
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())
urls_to_delete = existing_urls - current_urls_in_content
urls_to_add = current_urls_in_content - existing_urls
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()
new_images = []
for url in urls_to_add:
if s3_client.is_valid_s3_url(url):
new_images.append(
QuestionImage(question=question, img_url=url)
)
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()
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
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의 기본 기능만 써도 충분하고 더 깔끔
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 변경 이유
- "코드 생산성"과 "유지보수 효율성" 때문
- 모든 필드(title, content, category)를 개발자가 일일이 수동으로 정의해야 함
- 모델 정의(models.py)가 바뀌면(예: title의 max_length 변경)
변경 방식 (serializers.ModelSerializer):
- class Meta에 model = Question만 지정하면, DRF가 자동으로 모델을 분석
- "아, title은 모델에서 CharField네? 그럼 나도 CharField로 만들어야지."
- "아, category는 모델에서 ForeignKey네? 그럼 PrimaryKeyRelatedField로 처리해야지."
- 이렇게 알아서 필드를 생성해주기 때문에 코드가 훨씬 간결
- ModelSerializer는 위의 모델을 보고 자동으로 아래와 같이 변환함
- 따라서 굳이 명시적으로 적지 않아도 ID 값(PK)을 받아서 처리하는 로직이 완성
class Question(models.Model):
category = models.ForeignKey(QuestionCategory, ...)
category = serializers.PrimaryKeyRelatedField(
queryset=QuestionCategory.objects.all(),
many=False
)
partial=True
- ModelSerializer를 사용
- View에서 serializer를 호출할 때 partial=True를 넣어주면,
- 모든 필드가 자동으로 required=False처럼 동작
새롭게 알게된 내용 ✅
오늘 발생한 문제(발생 했다면) ✅