[Spring] AWS S3 Presigned URL로 이미지 업로드 구현

jinsung·2026년 4월 27일

BootCamp

목록 보기
8/10
post-thumbnail

개요

뮤지션이 자신의 음악과 콘텐츠를 효과적으로 홍보할 수 있도록 돕는 팀 프로젝트를 진행 중이다.
홍보 페이지에는 활동명, 곡 제목, 발매일, 스트리밍 링크, 소개 문구뿐 아니라 대표 이미지도 함께 노출되어야 했다.
사용자가 직접 이미지를 업로드하고, 이를 안정적으로 저장한 뒤 홍보 페이지에 노출할 수 있는 구조가 필요했다.

내가 했던 고민

  • 서버 로컬 디스크에 저장하면 배포 환경에서 파일 유실 가능성은 없는가?
  • 이미지 요청이 많아질수록 백엔드 서버 부하가 커지지 않는가?
  • 여러 사용자가 동시에 업로드해도 안정적인가?
  • 사용자가 이미지 선택 후 취소하면 불필요한 파일이 남지 않는가?

이러한 이유로 이미지 저장소를 애플리케이션 서버와 분리하고, 확장성과 운영 안정성이 높은 AWS S3를 선택하게 되었다. 또한 단순 업로드가 아니라 Presigned URL 방식을 적용해 프론트엔드가 S3에 직접 업로드하도록 설계했다.


AWS 내에서의 S3 설정

S3를 사용하기 위해 해줘야되는 설정들이 많은데 이거 하는데만 한시간은 넘게 걸렸다.

1. S3 버킷 생성

2. CORS 설정

3. IAM 사용자 권한

4. Access Key 발급

IAM → Users → 사용자 선택 → Security credentials → Access keys → Create access key

발급받는 값:

AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...

5. 환경변수 설정

로컬 .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}

6. 배포 서버 Docker 환경변수 주입


이미지 업로드 플로우

S3 Configuration / Service

  • S3Config

    S3 presigned URL을 발급할 수 있는 S3Presigner를 Spring Bean으로 등록
@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();
    }
}
  • S3Service

    프론트가 S3에 직접 이미지를 업로드할 수 있도록, 30분짜리 임시 업로드 URL을 발급하는 서비스 코드
@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("지원하지 않는 이미지 형식입니다.");
        };
    }
}

1. 사용자가 이미지 선택하면 프론트에서는 미리보기만 보여준다.

-> 이때 S3 업로드는 하지 않는다.

2. 사용자가 “홍보 만들기” 버튼을 누르면 먼저 업로드 URL을 발급받는다.

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"
}

3. 받은 uploadUrl로 S3에 PUT 업로드한다.

await fetch(uploadUrl, {
  method: "PUT",
  headers: {
    "Content-Type": file.type
  },
  body: file
});

4. 업로드 성공 후 홍보 생성 API를 호출한다.

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 호출
profile
Data Engineer

0개의 댓글