뮤지션이 자신의 음악과 콘텐츠를 효과적으로 홍보할 수 있도록 돕는 팀 프로젝트를 진행 중이다.
홍보 페이지에는 활동명, 곡 제목, 발매일, 스트리밍 링크, 소개 문구뿐 아니라 대표 이미지도 함께 노출되어야 했다.
사용자가 직접 이미지를 업로드하고, 이를 안정적으로 저장한 뒤 홍보 페이지에 노출할 수 있는 구조가 필요했다.
내가 했던 고민
- 서버 로컬 디스크에 저장하면 배포 환경에서 파일 유실 가능성은 없는가?
- 이미지 요청이 많아질수록 백엔드 서버 부하가 커지지 않는가?
- 여러 사용자가 동시에 업로드해도 안정적인가?
- 사용자가 이미지 선택 후 취소하면 불필요한 파일이 남지 않는가?
이러한 이유로 이미지 저장소를 애플리케이션 서버와 분리하고, 확장성과 운영 안정성이 높은 AWS S3를 선택하게 되었다. 또한 단순 업로드가 아니라 Presigned URL 방식을 적용해 프론트엔드가 S3에 직접 업로드하도록 설계했다.
S3를 사용하기 위해 해줘야되는 설정들이 많은데 이거 하는데만 한시간은 넘게 걸렸다.



IAM → Users → 사용자 선택 → Security credentials → Access keys → Create access key
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
로컬 .env 또는 배포 서버 환경변수:
AWS_ACCESS_KEY_ID=발급받은_ACCESS_KEY
AWS_SECRET_ACCESS_KEY=발급받은_SECRET_KEY
AWS_S3_BUCKET=버킷 이름
AWS_S3_BASE_URL=버킷 url
cloud:
aws:
region: ap-northeast-2
s3:
bucket: ${AWS_S3_BUCKET}
base-url: ${AWS_S3_BASE_URL}
@Configuration
public class S3Config {
@Value("${cloud.aws.region}")
private String region;
@Bean
public S3Presigner s3Presigner() {
return S3Presigner.builder()
.region(Region.of(region))
.credentialsProvider(DefaultCredentialsProvider.create())
.build();
}
}
@Service
@RequiredArgsConstructor
public class S3ImageService {
private static final Set<String> ALLOWED_EXTENSIONS =
Set.of("jpg", "jpeg", "png", "webp");
private final S3Presigner s3Presigner;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
@Value("${cloud.aws.s3.base-url}")
private String s3BaseUrl;
public PresignedUrlResponse createMusicPromotionImageUploadUrl(String originalFilename) {
String extension = extractExtension(originalFilename);
String contentType = resolveContentType(extension);
String imageKey = "music-promotions/" + UUID.randomUUID() + "." + extension;
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(imageKey)
.contentType(contentType)
.build();
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(30))
.putObjectRequest(putObjectRequest)
.build();
PresignedPutObjectRequest presignedRequest =
s3Presigner.presignPutObject(presignRequest);
String imageUrl = s3BaseUrl + "/" + imageKey;
return new PresignedUrlResponse(
presignedRequest.url().toString(),
imageKey,
imageUrl
);
}
private String extractExtension(String filename) {
if (filename == null || !filename.contains(".")) {
throw new IllegalArgumentException("파일 확장자가 없습니다.");
}
String extension = filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
if (!ALLOWED_EXTENSIONS.contains(extension)) {
throw new IllegalArgumentException("지원하지 않는 이미지 형식입니다.");
}
return extension;
}
private String resolveContentType(String extension) {
return switch (extension) {
case "jpg", "jpeg" -> "image/jpeg";
case "png" -> "image/png";
case "webp" -> "image/webp";
default -> throw new IllegalArgumentException("지원하지 않는 이미지 형식입니다.");
};
}
}
-> 이때 S3 업로드는 하지 않는다.
POST /api/uploads/music-promotion-image?filename=파일명.jpg
응답:
{
"uploadUrl": "S3 업로드용 임시 URL",
"imageKey": "music-promotions/uuid.jpg",
"imageUrl": "https://hoppin-s3-bucket.s3.ap-northeast-2.amazonaws.com/music-promotions/uuid.jpg"
}
uploadUrl로 S3에 PUT 업로드한다.await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Type": file.type
},
body: file
});
POST /api/music-promotions
{
"activityName": "첫 싱글 발매 프로모션",
"instagramAccount": "@artist",
"songTitle": "Blue Night",
"releaseDate": "2026-04-25",
"streamingLinks": [
{
"url": "https://musicpeak.site"
}
],
"imageUrl": "위에서 받은 imageUrl",
"shortDescription": "첫 싱글 발매 홍보입니다."
}
정리
- 이미지 선택 시 업로드 X
- 홍보 만들기 클릭 시
업로드 URL 발급 → S3 PUT 업로드 → 홍보 생성 API 호출