poetry add boto3 django-storages pillowImageField로 변경ImageField 를 사용하면 post/thumbnails/.../이미지.jpg 라는 PostCreateSerializer에 이미 thumbnail이 포함되어 있기 때문에 Serializer나 View의 코드는 크게 바꿀 것이 없음 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
)
...

multipart/form-data 형식으로 보내야 함 
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]
Swagger 응답(Response) 확인
[Execute]를 누른 후, 아래쪽 Server response에 201 Created가 뜨는지 확인
AWS S3 콘솔에서 확인

브라우저에서 이미지 열어보기 (권한 테스트)


<form> 태그에 파일을 담아 multipart/form-data 형식으로 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)
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 호출{"presigned_url": "https://s3.amazonaws.com/복잡한주소...", "image_url": "https://내도메인/post/thumbnails/..."}presigned_url로 S3에 직접 파일을 전송 (PUT 방식){"title": "안녕", "content": "내용", "thumbnail": "방금받은 image_url"}


# 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 = 'storages.backends.s3boto3.S3Boto3Storage'
——————————————————————————————————————[비교]—————————————————————————————————————————
STORAGES = {
# 1. 미디어 파일 (유저가 업로드하는 파일, 썸네일 등) -> S3로 보냄
"default": {
"BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
},
# 2. 정적 파일 (CSS, JS 등) -> 일단 기존처럼 서버 로컬에서 처리
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
{
"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/*"post/thumbnails/2026/03/08/스크린샷...png 처럼 https://hoon-blog-image.../post/... 처럼 긴 주소를 통째로 저장했다면 https://버킷이름.s3.리전... 을 자동으로 조립해서 프론트엔드에게 전달해줌AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.{AWS_S3_REGION_NAME}.amazonaws.com'