-
Notifications
You must be signed in to change notification settings - Fork 0
Feat : (Damain) 이미지(파일) 업로드 Aws Multipart Upload + Pre-Signed URL 추가 #80
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "54-refactor-global-\uC774\uBBF8\uC9C0-\uC5C5\uB85C\uB4DC-\uB85C\uC9C1-\uB9AC\uD329\uD1A0\uB9C1"
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package org.example.wowelang_backend.board.dto.image; | ||
|
|
||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| public class MultipartPreSignedUrlRequestDTO { | ||
| private String filename; | ||
| private String contentType; | ||
| private Long partCount; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package org.example.wowelang_backend.board.dto.image; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| @AllArgsConstructor | ||
| public class MultipartPreSignedUrlResponseDTO { | ||
| private String uploadId; | ||
| private String imageKey; | ||
| private String imageUrl; | ||
| private List<String> preSignedUrls; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package org.example.wowelang_backend.board.dto.image; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| public class MultipartUploadCompleteRequestDTO { | ||
| private String imageKey; | ||
| private String uploadId; | ||
| private List<PartETagDTO> partETagS; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package org.example.wowelang_backend.board.dto.image; | ||
|
|
||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| public class PartETagDTO { | ||
|
|
||
| private int partNumber; | ||
| private String etag; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,27 +1,42 @@ | ||
| package org.example.wowelang_backend.board.service; | ||
|
|
||
| import com.amazonaws.HttpMethod; | ||
| import com.amazonaws.services.s3.AmazonS3; | ||
| import com.amazonaws.services.s3.model.AbortMultipartUploadRequest; | ||
| import com.amazonaws.services.s3.model.CompleteMultipartUploadRequest; | ||
| import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; | ||
| import com.amazonaws.services.s3.model.InitiateMultipartUploadRequest; | ||
| import com.amazonaws.services.s3.model.InitiateMultipartUploadResult; | ||
| import com.amazonaws.services.s3.model.ObjectMetadata; | ||
| import com.amazonaws.services.s3.model.PartETag; | ||
| import com.amazonaws.services.s3.model.PutObjectRequest; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import net.coobird.thumbnailator.Thumbnails; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| import org.example.wowelang_backend.board.domain.Image; | ||
| import org.example.wowelang_backend.board.dto.PostImageResponseDTO; | ||
| import org.example.wowelang_backend.board.dto.image.MultipartPreSignedUrlResponseDTO; | ||
| import org.example.wowelang_backend.board.dto.image.MultipartUploadCompleteRequestDTO; | ||
| import org.example.wowelang_backend.board.dto.image.PostImageResponseDTO; | ||
| import org.example.wowelang_backend.board.repository.ImageRepository; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.scheduling.annotation.Scheduled; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.io.ByteArrayInputStream; | ||
| import java.io.ByteArrayOutputStream; | ||
| import java.io.IOException; | ||
| import java.io.InputStream; | ||
| import java.net.URL; | ||
| import java.util.ArrayList; | ||
| import java.util.Comparator; | ||
| import java.util.Date; | ||
| import java.util.List; | ||
| import java.util.UUID; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| @Slf4j | ||
| public class ImageService { | ||
|
|
||
| private final AmazonS3 amazonS3; | ||
|
|
@@ -30,19 +45,10 @@ public class ImageService { | |
| @Value("${cloud.aws.s3.bucket}") | ||
| private String bucket; | ||
|
|
||
| // Stream 업로드 방식 | ||
| @Transactional | ||
| public PostImageResponseDTO uploadCompressedImage(InputStream inputStream, Long contentLengthLong, String filename, String contentType) throws IOException { | ||
| public PostImageResponseDTO uploadStreamImage(InputStream inputStream, Long contentLengthLong, String filename, String contentType) throws IOException { | ||
| try { | ||
| // ByteArrayOutputStream compressedOut = new ByteArrayOutputStream(); | ||
| // | ||
| // Thumbnails.of(inputStream) | ||
| // .size(1024, 1024) | ||
| // .outputQuality(1.0) | ||
| // .outputFormat("jpg") | ||
| // .toOutputStream(compressedOut); | ||
| // | ||
| // byte[] compressedImage = compressedOut.toByteArray(); | ||
| // InputStream compressedInputStream = new ByteArrayInputStream(compressedImage); | ||
|
|
||
| String extension = getExtension(contentType); | ||
| String uuid = UUID.randomUUID().toString(); | ||
|
|
@@ -74,36 +80,139 @@ public PostImageResponseDTO uploadCompressedImage(InputStream inputStream, Long | |
| } | ||
| } | ||
|
|
||
| // 확장자 설정 메서드 | ||
| // aws Multipart + Pre-Signed URL 방식 | ||
| @Transactional | ||
| public MultipartPreSignedUrlResponseDTO initiateMultipartUpload(String filename, String contentType, Long partCount) { | ||
| try { | ||
| String extension = getExtension(contentType); | ||
|
|
||
| String uuid = UUID.randomUUID().toString(); | ||
|
|
||
| String key = "tmp/" + uuid + "_" + filename + extension; | ||
|
|
||
| InitiateMultipartUploadResult initiateResult = amazonS3.initiateMultipartUpload( | ||
| new InitiateMultipartUploadRequest(bucket, key) | ||
| .withObjectMetadata(new ObjectMetadata()) | ||
| ); | ||
|
|
||
| String uploadId = initiateResult.getUploadId(); | ||
|
|
||
| String imageUrl = amazonS3.getUrl(bucket, key).toString(); | ||
|
|
||
| Image image = Image.builder() | ||
| .imageKey(key) | ||
| .imageUrl(imageUrl) | ||
| .s3UploadId(uploadId) | ||
| .isPosted(false) | ||
| .isS3Uploaded(false) | ||
| .build(); | ||
|
|
||
| imageRepository.save(image); | ||
|
|
||
| List<String> preSignedUrls = new ArrayList<>(); | ||
|
|
||
| Date expiration = new Date(); | ||
| long expTimeMillis = expiration.getTime(); | ||
| expTimeMillis += 1000 * 60 * 10; // 10분 유효 (클라이언트가 모든 파트를 업로드할 시간) | ||
| expiration.setTime(expTimeMillis); | ||
|
|
||
| for (int i = 1; i <= partCount; i++) { | ||
| GeneratePresignedUrlRequest generatePresignedUrlRequest = | ||
| new GeneratePresignedUrlRequest(bucket, key) | ||
| .withMethod(HttpMethod.PUT) // PUT 메소드로 업로드 가능하도록 | ||
| .withExpiration(expiration); | ||
|
|
||
| generatePresignedUrlRequest.addRequestParameter("uploadId", uploadId); // 업로드 ID 파라미터 추가 | ||
| generatePresignedUrlRequest.addRequestParameter("partNumber", String.valueOf(i)); // 파트 번호 파라미터 추가 | ||
|
|
||
| URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest); | ||
| preSignedUrls.add(url.toString()); | ||
| } | ||
|
|
||
| return new MultipartPreSignedUrlResponseDTO(uploadId, key, imageUrl, preSignedUrls); | ||
| } catch (Exception e) { | ||
| log.error("Multipart Pre-Signed URL 생성 중 에러 발생", e); | ||
| throw new RuntimeException("Multipart Pre-Signed URL 생성 중 에러 발생"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이런 에러 같은 경우는 기존에 만들어둔 에러 response에 들어가지 않는 종류인가요? |
||
| } | ||
| } | ||
|
|
||
| // aws Multipart + Pre-Signed URL 방식 | ||
| @Transactional | ||
| public void completeMultipartUpload(MultipartUploadCompleteRequestDTO requestDTO) { | ||
| try { | ||
| imageRepository.findByImageKeyAndS3UploadId(requestDTO.getImageKey(), requestDTO.getUploadId()) | ||
| .ifPresentOrElse(image -> { | ||
| List<PartETag> partETags = requestDTO.getPartETagS().stream() | ||
| .map(dto -> new PartETag(dto.getPartNumber(), dto.getEtag())) | ||
| .sorted(Comparator.comparingInt(PartETag::getPartNumber)) | ||
| .collect(Collectors.toList()); | ||
|
|
||
| CompleteMultipartUploadRequest completeRequest = | ||
| new CompleteMultipartUploadRequest(bucket, requestDTO.getImageKey(), requestDTO.getUploadId(), | ||
| partETags); | ||
| amazonS3.completeMultipartUpload(completeRequest); | ||
|
|
||
| image.updateIsS3Uploaded(true); | ||
| }, () -> { | ||
| abortMultipartUpload(requestDTO.getImageKey(), requestDTO.getUploadId()); | ||
| }); | ||
| } catch (Exception e) { | ||
| log.error("Multipart Upload 완료 처리 중 에러 발생", e); | ||
| throw new RuntimeException("Multipart Upload 완료 처리 중 에러 발생"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여기도요 |
||
| } | ||
| } | ||
|
|
||
| private void abortMultipartUpload(String imageKey, String uploadId) { | ||
| try { | ||
| amazonS3.abortMultipartUpload(new AbortMultipartUploadRequest(bucket, imageKey, uploadId)); | ||
| log.info("S3 Multipart Upload 취소 성공: Key - {}, UploadId - {}", imageKey, uploadId); | ||
| } catch (Exception e) { | ||
| log.error("S3 Multipart Upload 취소 실패: Key - {}, UploadId - {}", imageKey, uploadId, e); | ||
| } | ||
| } | ||
|
|
||
| // 이미지 확장자 설정 메서드 | ||
| private String getExtension(String contentType) { | ||
| return switch (contentType) { | ||
| case "image/jpeg", "image/jpg" -> ".jpg"; | ||
| case "image/png" -> ".png"; | ||
| case "image/gif" -> ".gif"; | ||
| default -> ""; | ||
| default -> throw new IllegalArgumentException("지원하지 않는 이미지 형식입니다: " + contentType); | ||
| }; | ||
| } | ||
|
|
||
| @Transactional | ||
| public void markImageAsPosted(List<String> imageKeyList) { | ||
| imageRepository.updatePostedTrueByKeys(imageKeyList); | ||
|
|
||
| } | ||
|
|
||
| @Scheduled(cron = "0 0 18 * * *") | ||
| @Transactional | ||
| public void deleteUnnecessaryImage() { | ||
| List<Image> unusedImages = imageRepository.findByIsPostedFalse(); | ||
|
|
||
| // Stream업로드는 isS3Uploaded와 관련이 없음 | ||
| if(!unusedImages.isEmpty()) { | ||
| for( Image image : unusedImages) { | ||
| try { | ||
| amazonS3.deleteObject(bucket, image.getImageKey()); | ||
| } catch (Exception e) { | ||
| System.out.println("S3 삭제 실패: " + image.getImageKey()); | ||
| log.error("S3 삭제 실패 (스케줄러): {}", image.getImageKey(), e); | ||
| } | ||
| imageRepository.delete(image); | ||
| } | ||
| } | ||
|
|
||
| // isPosted=false 이고 isS3Uploaded=false 이며, 일정 시간 이상 경과된 이미지 처리 | ||
| // (Pre-Signed URL만 발급되고 S3 업로드가 완료되지 않은 경우) | ||
| List<Image> pendingOrFailedUploads = imageRepository.findByIsPostedFalseAndIsS3UploadedFalse(); | ||
| if(!pendingOrFailedUploads.isEmpty()) { | ||
| log.warn("S3 Multipart 업로드 완료되지 않은 이미지 발견 (DB에서만 삭제): {}개", pendingOrFailedUploads.size()); | ||
| for (Image image : pendingOrFailedUploads) { | ||
| // S3에 파트가 남아있을 수 있으므로 Multipart Upload를 취소 | ||
| abortMultipartUpload(image.getImageKey(), image.getS3UploadId()); | ||
| imageRepository.delete(image); | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이런 부분에서는 따로 예외, 에러 처리를 해주지 않아도 되나요?