2026/03/08 Blog - 18

김기훈·2026년 3월 8일

TIL

목록 보기
158/194
post-thumbnail

코딩테스트(1874)


이미지 처리

구현방식

S3

  • 장점
    • 완벽한 생태계 (Django 연동)
      • 현재 Django 프로젝트를 진행하고 있음
      • Django는 boto3와 django-storages라는 강력한 라이브러리를 통해
        • S3와 매우 쉽게 연동됨
      • 코드 몇 줄과 설정값(settings.py) 수정만으로
        • 로컬에 저장하던 방식을 S3로 바로 전환가능
    • 서버와 저장소의 분리
      • 이미지를 웹 서버(EC2 등)에 직접 저장하면 서버 용량이 금방 차고
      • 나중에 서버를 확장(Scale-out)하거나 배포할 때 이미지가 유실될 위험이 있음
      • S3에 저장하면 서버는 가볍게 유지하고 파일은 안전하게 보관 가능
    • 성능 최적화 (CDN)
      • 나중에 블로그 방문자가 많아져서 이미지 로딩 속도를 높여야 할 때
      • S3 앞에 AWS CloudFront(CDN)를 붙여서 글로벌하게 빠른 이미지 제공이 가능해짐

Cloudinary (클라우디너리)

  • 특징
    • 이미지 저장뿐만 아니라 업로드된 이미지를 실시간으로 리사이징
      • 자르기, 필터 적용, 용량 최적화(WebP 변환 등) 해주는 강력한 플랫폼
    • URL에 파라미터만 추가해서 이미지를 조작할 수 있어 매우 편리
  • 장점
    • 넉넉한 무료 티어를 제공해서 토이 프로젝트나 초기 블로그에 돈을 들이지 않고 시작하기 좋음

고민

  • 이미지 파일이 서버(Django)를 거쳐서 가느냐 아니면 클라이언트(브라우저)에서 S3로 바로 쏘느냐

일반적인 S3 업로드

  • 프론트엔드 → Django 서버 → AWS S3

    • 클라이언트가 이미지를 Django 서버로 보내고
    • Django가 그 이미지를 받아 S3로 다시 전송하는 방식
  • 장점

    • 구현이 매우 쉬움 (Django의 ImageField와 완벽하게 호환됨)
    • 이미지 조작이 용이
      • 서버에 파일이 일단 들어오기 때문에
      • S3에 올리기 전에 이미지 해상도를 줄이거나(리사이징)
      • 워터마크를 넣거나, 썸네일을 생성하는 등의 전처리가 아주 쉬움
  • 단점

    • 서버 부하 및 비용 증가
      • 이미지가 서버의 메모리와 대역폭을 모두 차지
      • 사용자가 10MB짜리 사진을 올리면, 서버는 10MB를 받고 다시 S3로 10MB를 보내야 함
        • 총 20MB의 네트워크 트래픽이 발생함
        • 즉, 트래픽이 몰리면 서버가 쉽게 뻗을 수 있음

Presigned URL

  • 프론트엔드에서 S3로 직접 업로드
    • Django 서버는 "S3에 업로드할 수 있는 임시 허가증(Presigned URL)"만 발급해주고
    • 실제 파일 전송은 프론트엔드에서 S3로 직접 하는 방식
  • 동작 흐름

      1. 프론트엔드 → Django (업로드용 URL 좀 줘!)
      1. Django → 프론트엔드 (여기 Presigned URL 10분짜리 발급했어!)
      1. 프론트엔드 → AWS S3 (파일 직접 전송)
      1. 프론트엔드 → Django (방금 업로드한 이미지 URL 저장해줘!)
  • 장점

    • 서버 부하가 거의 제로(0)
      • 파일 전송 트래픽이 Django 서버를 거치지 않으므로 매우 가볍고 빠르며
      • 서버 비용이 획기적으로 절감 (현대적인 실무 아키텍처의 표준)
  • 단점

    • 구현이 다소 복잡
      • 백엔드뿐만 아니라 프론트엔드에서도
      • 추가적인 API 호출(URL 요청 -> S3 업로드 -> 백엔드에 결과 전송) 로직을 짜야 함
    • 서버에서 직접 이미지 전처리가 불가능
      • 파일이 서버를 거치지 않으므로 Django에서 리사이징을 할 수 없음
      • 이 경우 보통 AWS Lambda를 S3에 연결하여, 업로드되는 순간 리사이징되도록 분산 처리

결론

  • 둘다 구현해보고 결정하자

    • 일반적인 S3 업로드 (Django를 거치는 방식)부터 구현해보고 서버부하를 체감해보자

일반 S3 업로드

  • 이론정리 바로가기

    • 라이브러리 설치

      • poetry add boto3 django-storages pillow
  • 1. settings.py 설정 (S3 연결)

  • 2. post 수정

    • 기존 코드에서 thumbnail 필드가 문자열을 저장하는 CharField로 되어 있음
      • 이것을 파일을 업로드받을 수 있는 ImageField로 변경
    • ImageField 를 사용하면
      • Django가 알아서 "이 파일이 진짜 이미지 파일이 맞는지(해킹 파일은 아닌지)" 검증해 줌
      • 또한 파일을 업로드하면 S3에 파일을 저장한 뒤
        • DB에는 전체 긴 URL이 아닌 post/thumbnails/.../이미지.jpg 라는
        • 핵심 경로만 가볍게 저장하여 DB 용량을 최적화함
    • PostCreateSerializer에 이미 thumbnail이 포함되어 있기 때문에
      • SerializerView의 코드는 크게 바꿀 것이 없음
    • Django REST Framework(DRF)의 ModelSerializer는
      • 모델이 ImageField로 바뀌면 알아서 파일 업로드를 지원하도록 동작 방식을 바꿈
class Post(TimeStampedModel):
	...
    thumbnail = models.CharField(max_length=255, null=True, blank=True)
    ...
——————————————————————————————————————[비교]—————————————————————————————————————————
class Post(TimeStampedModel):
	...
    # 기존 models.CharField를 models.ImageField로 변경
    # upload_to="post/thumbnails/%Y/%m/%d/": S3 버킷 안에 어떤 폴더 구조로 저장할지 정함
    # %Y/%m/%d/ 를 넣으면, 'post/thumbnails/2026/03/08/이미지.jpg' 처럼 날짜별로 예쁘게 폴더가 나뉘어 정리됨
    # null=True, blank=True: 썸네일 이미지를 안 올리고 글만 써도 에러가 나지 않도록 허용
    thumbnail = models.ImageField(
        upload_to="post/thumbnails/%Y/%m/%d/", 
        null=True, 
        blank=True
    )
    ...

  • 3. 프론트엔드에서 요청시 주의

    • 이전에는 JSON 형식({"title": "제목", "content": "내용"})으로 데이터를 보냈겠지만
      • 이제 파일(이미지)이 포함되어야 하므로 프론트엔드(React, Vue 등)나 Postman에서
        • 요청을 보낼 때 multipart/form-data 형식으로 보내야 함
        • (파일 전송을 위한 인터넷 표준 규약임)
  • 4. Swagger에서도 파일 업로드 테스트가 가능하도록 수정

class PostAPIView(APIView):
    """포스트 등록 및 전체 목록 조회를 담당합니다."""

    permission_classes = [IsAuthenticatedOrReadOnly]
    pagination_class = PostPageNumberPagination
    
——————————————————————————————————————[비교]—————————————————————————————————————————
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser

class PostAPIView(APIView):
    """포스트 등록 및 전체 목록 조회를 담당합니다."""

    permission_classes = [IsAuthenticatedOrReadOnly]
    pagination_class = PostPageNumberPagination
    parser_classes = [JSONParser, MultiPartParser, FormParser]
  • 5. 테스트해보기

    • Swagger 응답(Response) 확인

      • [Execute]를 누른 후, 아래쪽 Server response에 201 Created가 뜨는지 확인
      • 성공했다면, 응답 데이터(JSON) 안의 thumbnail 필드에 S3의 이미지 주소(URL)가 예쁘게 담겨서 반환
    • AWS S3 콘솔에서 확인

      • AWS S3 웹페이지로 이동해서 내 버킷을 클릭해보기
      • 모델에서 설정했던 post/thumbnails/2026/03/08/ (오늘 날짜) 폴더가 자동으로 생성되어 있고
        • 그 안에 방금 올린 이미지 파일이 들어있는 것을 눈으로 직접 확인 가능
    • 브라우저에서 이미지 열어보기 (권한 테스트)

      • Swagger 응답에 나온 thumbnail URL을 복사해서 크롬 같은 인터넷 브라우저 주소창에 붙여넣고 엔터
      • 이미지가 화면에 잘 뜬다면
        • AWS 퍼블릭 권한 설정까지 완벽하게 성공
        • 만약 AccessDenied 에러가 뜬다면, S3 퍼블릭 권한 설정이 살짝 덜 풀린 것


Presigned URL

  • 프론트엔드 비교

    • 일반 S3 업로드의 프론트엔드

      • <form> 태그에 파일을 담아 multipart/form-data 형식으로
      • Django 서버에 직접 쏘는 로직을 작성해야 함
    • Presigned URL의 프론트엔드

      • ① Django에 "파일 올릴 임시 URL 좀 줘!" 하고 요청을 보냄
      • ② 받은 URL로 S3에 파일을 직접 PUT 요청으로 쏨
      • ③ S3에 다 올라가면, Django에 "나 다 올렸어! DB에 이 경로 저장해 줘!" 하고 JSON 데이터만 보냄
    • 비교

      • 두 방식은 프론트엔드에서 API를 호출하는 순서와 로직이 완전히 다름
        • 일반 S3업로드 프론트엔드 연동을 기껏 완성해 놓아도
        • Presigned URL로 백엔드를 바꾸는 순간 프론트엔드 코드도 싹 다 갈아엎어야 함
  • 작업 순서

    • S3 CORS 설정 (AWS 콘솔)
      • 프론트엔드(브라우저)에서 Django를 거치지 않고 S3로 직접 파일을 쏘려면
      • S3가 브라우저의 직접 접근을 허락하도록 CORS(교차 출처 리소스 공유) 설정을 추가해 줘야 함
    • Presigned URL 발급 API 생성 (Django)
      • 프론트엔드가 파일 이름과 확장자를 보내면
      • S3에 올릴 수 있는 "10분짜리 임시 허가증(URL)"을 만들어주는 전용 API를 만들어야 함
class PresignedUrlAPIView(APIView):
    """
    S3에 직접 이미지를 업로드할 수 있는 임시 URL(Presigned URL)을 발급합니다.
    """
    # 이미지는 로그인한(인증된) 사용자만 올릴 수 있도록 제한합니다. (해킹/도배 방지)
    permission_classes = [IsAuthenticated]

    @extend_schema(
        tags=["이미지"],
        summary="S3 Presigned URL 발급",
        parameters=[
            OpenApiParameter(
                name="filename",
                description="업로드할 파일의 원본 이름 (예: my_photo.png)",
                type=OpenApiTypes.STR,
                location=OpenApiParameter.QUERY,
                required=True,
            )
        ]
    )
    def get(self, request):
        # 1. 프론트엔드가 보낸 원본 파일 이름을 가져옵니다.
        filename = request.query_params.get("filename")
        if not filename:
            return Response({"error": "filename은 필수입니다."}, status=status.HTTP_400_BAD_REQUEST)

        # 2. 파일 이름 충돌(덮어쓰기)을 막기 위해 고유한 파일명을 생성합니다.
        # 확장자(ext)를 분리한 뒤, 임의의 고유 문자열(uuid)을 붙여줍니다.
        ext = filename.split(".")[-1]
        unique_filename = f"{uuid.uuid4().hex}.{ext}"

        # 3. S3 버킷 내에 저장될 최종 경로를 생성합니다. (예: post/thumbnails/2026/03/08/고유문자열.png)
        today = datetime.now().strftime("%Y/%m/%d")
        object_name = f"post/thumbnails/{today}/{unique_filename}"

        # 4. boto3 S3 클라이언트를 생성합니다. (settings.py에 적어둔 환경변수를 가져옵니다)
        s3_client = boto3.client(
            's3',
            region_name=settings.AWS_S3_REGION_NAME,
            aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
            aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY
        )

        try:
            # 5. 대망의 Presigned URL 생성 부분입니다! (가장 핵심)
            # 'put_object'는 S3에 파일을 올리는 작업을 의미합니다.
            presigned_url = s3_client.generate_presigned_url(
                'put_object',
                Params={
                    'Bucket': settings.AWS_STORAGE_BUCKET_NAME,
                    'Key': object_name,  # 저장될 경로
                    'ContentType': f'image/{ext}'  # 파일 형식 지정 (필수)
                },
                ExpiresIn=600  # 이 URL의 유효기간을 600초(10분)로 설정합니다. 10분이 지나면 쓸 수 없는 휴지조각이 됩니다.
            )

            # 6. S3에 파일이 저장된 후, 프론트엔드가 사용할 이미지의 최종 접속 주소를 만들어 줍니다.
            # 이 주소를 나중에 게시글 저장 API로 보내게 됩니다.
            image_url = f"https://{settings.AWS_S3_CUSTOM_DOMAIN}/{object_name}"

            # 7. 프론트엔드에게 임시 업로드 URL과 최종 이미지 URL을 모두 넘겨줍니다.
            return Response({
                "presigned_url": presigned_url,
                "image_url": image_url
            }, status=status.HTTP_200_OK)

        except Exception as e:
            return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

  • 기존 게시글 작성 API 수정 (Django)

    • 이제 더 이상 Django 서버가 파일을 직접 받지 않으므로
    • ImageField 대신 파일의 경로(문자열)만 가볍게 전달받도록 코드를 수정
      • parser_classes = [...] 라고 적었던 줄을 삭제
      • @extend_schema 안에 적었던
        • request={'multipart/form-data': PostCreateSerializer} 부분도 삭제
      • 선택사항
        • 모델(models.py)의 thumbnail 필드를 다시 CharField로 돌리셔도 좋고
          • ImageField를 그대로 두셔도 됨
        • 프론트엔드에서 {"thumbnail": "https://..."} 처럼 텍스트(JSON)로 보내도
          • 장고가 알아서 처리해 줌
  • 프론트엔드 동작흐름

    • 사용자가 이미지를 올리면, GET /post/image/presigned-url/?filename=테스트.png 호출
    • 백엔드(Django)가 응답해 줌
      • {"presigned_url": "https://s3.amazonaws.com/복잡한주소...", "image_url": "https://내도메인/post/thumbnails/..."}
    • 프론트엔드는 받은 presigned_url로 S3에 직접 파일을 전송 (PUT 방식)
    • 전송 완료 후, 백엔드 게시글 등록 API(POST /post/)에 JSON 전송
      • {"title": "안녕", "content": "내용", "thumbnail": "방금받은 image_url"}
  • 테스트

  • 파이썬 스크립트로 테스트

    • test.png 이 이름을 가진 사진 한장을 test_upload.py와 같은 위치에 놓음
    • python test_upload.py

# test_upload.py

import requests

presigned_url = "Swagger에서 복사한 엄청 긴 주소 그대로 붙여넣기"
file_path = "test.png" 

# 1. [핵심 수정 부분] S3 문지기에게 보여줄 명찰(헤더)을 만듭니다.
# 주의: Swagger에서 URL을 발급받을 때 적었던 확장자와 맞춰주세요! (예: image/png, image/jpeg 등)
headers = {
    'Content-Type': 'image/png'
}

with open(file_path, "rb") as file_data:
    # 2. 요청을 보낼 때 headers 옵션을 추가해서 같이 보냅니다!
    response = requests.put(presigned_url, data=file_data, headers=headers)

if response.status_code == 200:
    print("🎉 S3 업로드 완벽하게 성공! 상태 코드: 200")
else:
    print(f"❌ 업로드 실패... 상태 코드: {response.status_code}")
    print("에러 내용:", response.text)

프론트

<div class="col-lg-6">
                            <div class="bg-light rounded-4 p-4 h-100 border border-light-subtle d-flex flex-column">
                                <h5 class="fw-bold text-dark mb-3"><i class="bi bi-image text-success me-2"></i>대표 썸네일
                                </h5>

                                <label for="thumbnail" class="form-label text-secondary" style="font-size: 0.8rem;">
                                    이미지 주소(URL)를 입력해주세요 (파일 업로드 준비 중)
                                </label>
                                <input type="url" class="form-control border-0 shadow-sm mb-3" id="thumbnail"
                                       placeholder="https://example.com/image.jpg">

                                <div id="thumbnail-preview"
                                     class="rounded-3 border border-2 border-dashed border-secondary border-opacity-25 d-flex align-items-center justify-content-center overflow-hidden flex-grow-1 bg-white relative"
                                     style="min-height: 160px;">
                                    <span class="text-muted small"
                                          id="preview-text">이미지 URL을 입력하면<br>여기에 미리보기가 표시됩니다.</span>
                                    <img id="preview-img" src="" class="w-100 h-100 object-fit-cover d-none"
                                         alt="썸네일 미리보기">
                                </div>
                            </div>
                        </div>
——————————————————————————————————————[비교]—————————————————————————————————————————
<div class="col-lg-6">
                            <div class="bg-light rounded-4 p-4 h-100 border border-light-subtle d-flex flex-column">
                                <h5 class="fw-bold text-dark mb-3"><i class="bi bi-image text-success me-2"></i>대표 썸네일
                                </h5>

                                <label for="thumbnail-file" class="form-label text-secondary fw-bold small">
                                    이미지 파일 업로드 (S3 직접 전송)
                                </label>
                                <input type="file" class="form-control border-0 shadow-sm mb-3" id="thumbnail-file"
                                       accept="image/*">

                                <div id="thumbnail-preview"
                                     class="rounded-3 border border-2 border-dashed border-secondary border-opacity-25 d-flex align-items-center justify-content-center overflow-hidden flex-grow-1 bg-white relative"
                                     style="min-height: 160px;">
                                    <span class="text-muted small"
                                          id="preview-text">이미지를 선택하면<br>여기에 미리보기가 표시됩니다.</span>
                                    <img id="preview-img" src="" class="w-100 h-100 object-fit-cover d-none"
                                         alt="썸네일 미리보기">
                                </div>
                            </div>
                        </div>

문제

스웨거에서 저장잘되는데 콘솔에서 안보임

  • Django가 버전업을 하면서 파일 저장소를 설정하는 방식이 변경되었기 때문
    • DEFAULT_FILE_STORAGE 방식은 과거 버전의 방식
    • Django 최신 버전에서는 이 설정어를 완전히 무시하고 기본값인 '로컬 저장소'로 폴백(Fallback) 시켜버림
    • 최신 장고 버전에 맞는 STORAGES 설정으로 바꿔주어 해결
# Django 미디어 파일 저장소 변경
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'

——————————————————————————————————————[비교]—————————————————————————————————————————
STORAGES = {
    # 1. 미디어 파일 (유저가 업로드하는 파일, 썸네일 등) -> S3로 보냄
    "default": {
        "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
    },
    # 2. 정적 파일 (CSS, JS 등) -> 일단 기존처럼 서버 로컬에서 처리
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
    },
}

버킷에 이미지 들어가는데 url들어가면 안보임

  • S3 버킷에 올라간 객체(이미지)에 대한 '읽기 권한'이 외부(인터넷)에 열려있지 않기 때문
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::본인의-버킷-이름/post/thumbnails/*"
        }
    ]
}
  • Principal: "*"
    • 전 세계 누구든지 이 파일에 접근할 수 있게 허용합니다.
  • Action: "s3:GetObject"
    • 오직 '읽기(다운로드/보기)' 권한만 줍니다. 수정이나 삭제는 불가능하므로 안전합니다.
  • Resource: ".../post/thumbnails/*"
    • 버킷 전체를 여는 것이 아니라, 우리가 이미지를 저장하는 특정 경로만 공개

배운점

DB에 저장되는 값과 스웨거의 값이 다름

  • DB에 post/thumbnails/2026/03/08/스크린샷...png 처럼
    • 핵심 경로(S3 객체 키)만 저장되는 것은 용량 최적화를 위한 설계
  • 만약 DB에 https://hoon-blog-image.../post/... 처럼 긴 주소를 통째로 저장했다면
    • DB 용량도 많이 차지할 뿐만 아니라
    • 나중에 도메인 주소가 바뀌거나 AWS CloudFront(CDN)를 붙이게 되면
      • DB에 있는 수만 개의 글을 전부 찾아다니며 주소를 수정해야 하는 대참사가 벌어짐
  • Swagger(API)에서는 어떻게 풀(Full) URL이 나옴?
    • 사용자가 게시글을 조회하려고 하면, DRF가 DB에서 짧은 경로를 꺼내옴
    • settings.py에 세팅해 둔 AWS S3 설정값을 읽어와서
      • 짧은 경로 앞에https://버킷이름.s3.리전... 을 자동으로 조립해서 프론트엔드에게 전달해줌
AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.{AWS_S3_REGION_NAME}.amazonaws.com'
  • 한글 인코딩(%E1%84%...)
    • 주소에 한글이 들어가면 웹 브라우저가 에러를 낼 수 있기 때문에
      • 안전하게 인터넷 표준 기호(퍼센트 인코딩)로 변환까지 알아서 해줌

도커 재빌드

  • docker compose down
  • docker compose up --build
profile
안녕하세요.

0개의 댓글