2025/12/29 MainProject - 16

김기훈·2025년 12월 29일

TIL

목록 보기
97/194

오늘 학습 내용 ✅

질문 등로 api

  • Request Body 수정
{
  "title": "str",
  "content": "str",
  "category": "bigint",
   image: binary file (image/jpeg, image/png, image/jpg)
}
  • 해석

    • 명세(image: binary file)는 프론트엔드가 이미지 파일을 백엔드 서버로 직접 전송하는 방식
    • JSON 데이터(title, content)와 바이너리 데이터(image)를 하나의
      • multipart/form-data 패킷에 담아 보냄

일반적인 방식

  • 1. Presigned URL 발급 요청 (FE -> BE)

    • 프론트엔드는 사용자가 이미지를 선택하면, 먼저 백엔드에 허가증(URL) 달라라고 요청
    • BE (Django DRF): boto3를 사용하여 S3에 업로드할 수 있는 임시 URL을 생성해 반환
# FR Request
{ "file_name": "my_photo.jpg", "file_type": "image/jpeg" }

# BE Response
{
  "presigned_url": "https://s3.amazonaws.com/...",
  "image_key": "uploads/2024/uuid_my_photo.jpg" // 나중에 DB 저장용
}

  • 2. S3로 이미지 직접 업로드 (FE -> S3)

    • 프론트엔드는 발급받은 presigned_url을 사용하여 이미지를 S3에 직접 PUT
      • 이때 백엔드는 관여하지 않음

  • 3. 질문 등록 (FE -> BE)

    • 이미지 업로드가 성공하면, 프론트엔드는 텍스트 데이터와 1단계에서 받은 image_key(또는 url)를
      • 합쳐서 질문 등록 API를 호출
# FE Request
{
  "title": "에러가 발생합니다",
  "content": "상세 내용은...",
  "category": 3,
  "image": "uploads/2024/uuid_my_photo.jpg" // 문자열(String) 전송
}

이미지 처리 과정

질문 등록

# question_create_service.py

def create_question(
    *,
    author: User,
    category: QuestionCategory,
    validated_data: dict[str, Any],
) -> Question:
    question = Question.objects.create(
        author=author,
        title=validated_data["title"],
        content=validated_data["content"],
        category=category,
    )

    sync_question_images(question, validated_data["content"])

    return question
    1. Question.objects.create(...)로 텍스트 데이터(제목, 내용 등)를 먼저 DB에 저장
    1. 그 직후 sync_question_images(question, validated_data["content"])를 호출하여
    • 이미지 처리를 위임

# image_service.py

def sync_question_images(question: Question, content: str) -> None:
    s3_client = S3Client()

    # 1. 본문 파싱
    current_urls_in_content = extract_image_urls_from_content(content)

    # 2. DB 상태 확인
    existing_images_map = {img.img_url: img for img in QuestionImage.objects.filter(question=question)}
    existing_urls = set(existing_images_map.keys())

    # 3. 비교 (Diff)
    urls_to_delete = existing_urls - current_urls_in_content
    urls_to_add = current_urls_in_content - existing_urls

    # 4. 삭제 처리 (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()

    # 5. 추가 처리 (검증 + DB 저장)
    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)
    1. extract_image_urls_from_content(content)를 호출하여
    • 본문(content) 내의 모든 <img src="..."> 태그에서 URL을 추출(예: ['url_A', 'url_B'])
    1. 방금 만든 질문이므로 DB에 저장된 기존 이미지는 없음 (existing_urls = empty)
    1. 방금 만든 질문이므로 DB에 저장된 기존 이미지는 없음 (existing_urls = empty)
    • 파싱된 모든 URL이 urls_to_add가 됨
    1. rls_to_add에 있는 URL들이 유효한 S3 URL인지 검증(is_valid_s3_url)한 후,
    • QuestionImage 테이블에 bulk_create로 일괄 저장

질문 수정

@transaction.atomic
def update_question(
    *,
    question: Question,
    validated_data: dict[str, Any],
) -> Question:
    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
    1. content가 수정되었는지 확인합니다 (if new_content is not None:)
    1. 수정되었다면 sync_question_images(question, new_content)를 호출
def sync_question_images(question: Question, content: str) -> None:
    s3_client = S3Client()

    # 1. 본문 파싱
    current_urls_in_content = extract_image_urls_from_content(content)

    # 2. DB 상태 확인
    existing_images_map = {img.img_url: img for img in QuestionImage.objects.filter(question=question)}
    existing_urls = set(existing_images_map.keys())

    # 3. 비교 (Diff)
    urls_to_delete = existing_urls - current_urls_in_content
    urls_to_add = current_urls_in_content - existing_urls

    # 4. 삭제 처리 (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()

    # 5. 추가 처리 (검증 + DB 저장)
    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)
    1. 수정된 본문(new_content)에서 현재 남아있는 이미지 URL들을 추출 (Set A)
    1. 해당 질문(question)에 이미 연결되어 있던 QuestionImage URL들을 DB에서 가져옴 (Set B)
    • 삭제 대상 (urls_to_delete): B - A (과거엔 있었는데 지금 본문엔 없는 것)
    • 추가 대상 (urls_to_add): A - B (과거엔 없었는데 지금 본문에 새로 생긴 것)
    1. urls_to_delete에 포함된 URL에 대해 s3_client.delete_from_url(url)을 호출하여
    • S3 실제 파일을 즉시 삭제
    • 그 후 DB(QuestionImage)에서도 해당 레코드를 delete() 합니다.
    1. urls_to_add에 포함된 URL을 검증 후 DB에 insert

AWS S3에서 Key

  • AWS S3에서 Key(키)는 "버킷 내에서 파일이 저장된 전체 경로(파일명 포함)"를 의미
    • 우리가 사용하는 웹 주소(URL)에서 도메인(주소) 뒷부분이 바로 Key
  • https://{버킷이름}.s3.{지역}.amazonaws.com/{폴더명}/{파일명}

    • https://...amazonaws.com/ 까지가 도메인(집 주소)
    • {폴더명}/{파일명} 가 Key(방 위치 + 물건 이름)

현재 내 코드 상황

  • DB (PostgreSQL 등)에 저장되는 것

    • 저장 위치: QuestionImage 모델의 img_url 필드
    • https://my-bucket.s3.ap-northeast-2.amazonaws.com/qna_images/uuid_image.jpg
  • 이렇게 저장되는 이유
    • 프론트엔드가 이 값을 받아서 바로 <img src="..."> 태그에 넣으면 이미지가 화면에 나오기 때문
  • AWS S3에 저장되는 것

    • 저장되는 것: 실제 .jpg, .png 파일 데이터
    • 식별자 (Key): 버킷(최상위 폴더) 이후의 경로 + 파일명
    • ex. qna_images/uuid_image.jpg
    • 이렇게 저장되는 이유
      • S3 API(삭제, 이동 등)를 호출할 때는 "어느 버킷의, 어떤 파일(Key)"인지 정확히 알려줘야
        • 작업을 수행할 수 있기 때문

이미지 저장 흐름

  • 준비 (Phase 1)

    • FE가 BE에게 "S3에 올릴 출입증(Presigned URL)"을 요청
  • 업로드 (Phase 2)

    • FE가 받은 출입증으로 이미지를 S3에 직접 업로드하고, 받은 주소를 에디터 본문에 삽입
  • 저장 (Phase 3)

    • 사용자가 "등록"을 누르면, FE는 이미지 파일이 아닌 본문 텍스트(HTML)만 BE로 보냄
  • 동기화 (Phase 4)

    • BE는 본문을 분석해 이미지를 DB에 등록하고, 나중에 배치 작업으로 찌꺼기 파일을 청소

파싱 위험요소

  • 이 코드는 다음과 같은 상황에서 이미지를 놓칠(Skip) 위험 존재
    • 홑따옴표 사용: <img src='url'> (패턴은 쌍따옴표만 찾음)
    • 속성 순서: src 앞에 줄바꿈이나 이상한 속성이 끼어들 경우 Regex 엔진에 따라 실패 가능성 있음.
    • 대소문자: <IMG SRC="..."> (플래그 안 쓰면 못 찾음)

해야할 것

  • Presigned url 요청 받기 로직 구현
  • 고아 이미지 처리 question 기준으로 리팩토링

새롭게 알게된 내용 ✅

기존의 S3_client.py

  • 1. upload(self, file, ...)

    • 기능: 서버가 파일 객체(binary)를 받아서 S3에 업로드하는 함수입니다.
    • 왜 안 쓰는가?

    • 회원님은 Presigned URL 방식을 선택하셨습니다.
    • 이 방식에서는 프론트엔드가 S3로 파일을 직접 업로드합니다.
      즉, 백엔드(Django) 서버는 파일 자체를 전달받지 않기 때문에, 이 upload 함수를 호출할 데이터도 없고 이유도 없습니다. 이 함수는 "서버가 직접 업로드할 때"만 필요합니다.
  • 2. generate_presigned_url(self, key, ...)

    기능: 프론트엔드가 업로드할 수 있는 '임시 허가증(URL)'을 발급해 주는 함수입니다.

왜 안 쓰는가? (아직 구현 안 됨)

이 함수는 설계상 반드시 필요합니다.

하지만 현재 업로드해주신 코드 파일들(Views, Services) 중 이 함수를 호출해서 프론트엔드에게 URL을 내려주는 API(View)가 아직 구현되어 있지 않습니다.

따라서 코드는 존재하지만, 아직 "호출하는 곳"이 없는 상태입니다. (곧 만드셔야 할 기능입니다.)

  • 3. delete(self, key)

    기능: key (예: qna_images/uuid.jpg)만 가지고 파일을 삭제합니다.

왜 (직접) 안 쓰는가?

현재 회원님의 sync_question_images 로직은 HTML 본문에서 전체 URL(예: https://s3.../qna_images/uuid.jpg)을 추출하여 처리합니다.

서비스 계층에서는 key가 아니라 url을 기준으로 움직이기 때문에, delete(key)를 직접 부르지 않고 URL을 파싱해주는 delete_from_url(url)을 호출해서 사용합니다.

(참고: delete_from_url 내부에서 self.delete(key)를 사용하고 있으므로 간접적으로는 사용 중입니다.)

  • 4. get_url(self, key)

    기능: key를 입력하면 https://... 형태의 풀 URL을 만들어줍니다.

왜 안 쓰는가?

DB 저장 방식: 현재 QuestionImage 모델 등에 이미지 경로를 저장할 때, Key만 저장하는 게 아니라 Full URL을 통째로 저장하거나, 프론트에서 이미 Full URL을 보내주고 있습니다.

이미 URL 형태인 데이터를 다루고 있으므로, 굳이 Key를 URL로 변환하는 이 함수를 사용할 필요가 없는 것입니다.

(단, 앞서 작성해 드린 cleanup_s3_image.py 스크립트에서는 S3 목록(Key)을 DB(URL)와 비교하기 위해 이 함수를 사용하게 됩니다.)

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

profile
안녕하세요.

0개의 댓글