diff --git a/build.gradle b/build.gradle index 84e9ef9..4925199 100644 --- a/build.gradle +++ b/build.gradle @@ -48,7 +48,7 @@ dependencies { // aws S3 의존성 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' - + implementation 'com.amazonaws:aws-java-sdk-s3:1.12.721' // 파일 압축 의존성 implementation 'net.coobird:thumbnailator:0.4.19' diff --git a/src/main/java/org/example/wowelang_backend/board/controller/PostImageController.java b/src/main/java/org/example/wowelang_backend/board/controller/PostImageController.java index 2ade4ed..27c3b12 100644 --- a/src/main/java/org/example/wowelang_backend/board/controller/PostImageController.java +++ b/src/main/java/org/example/wowelang_backend/board/controller/PostImageController.java @@ -2,7 +2,12 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; -import org.example.wowelang_backend.board.dto.PostImageResponseDTO; +import lombok.extern.slf4j.Slf4j; + +import org.example.wowelang_backend.board.dto.image.MultipartPreSignedUrlRequestDTO; +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.service.ImageService; import org.example.wowelang_backend.common.apiPayLoad.ApiResponse; import org.springframework.web.bind.annotation.*; @@ -12,6 +17,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/file") +@Slf4j public class PostImageController { private final ImageService fileService; @@ -21,6 +27,22 @@ public ApiResponse uploadFile(HttpServletRequest request, @RequestParam("filename") String filename, @RequestParam("contentType") String contentType) throws IOException { - return ApiResponse.onSuccess(fileService.uploadCompressedImage(request.getInputStream(), request.getContentLengthLong(), filename, contentType)); + return ApiResponse.onSuccess(fileService.uploadStreamImage(request.getInputStream(), request.getContentLengthLong(), filename, contentType)); + } + + @PostMapping("/upload-url") + public ApiResponse getMultipartPreSignedUrls(@RequestBody MultipartPreSignedUrlRequestDTO requestDTO){ + + MultipartPreSignedUrlResponseDTO response = fileService.initiateMultipartUpload( + requestDTO.getFilename(), requestDTO.getContentType(), requestDTO.getPartCount() + ); + + return ApiResponse.onSuccess(response); + } + + @PostMapping("/upload-url-complete") + public ApiResponse completeMultipartUpload(@RequestBody MultipartUploadCompleteRequestDTO requestDTO) { + fileService.completeMultipartUpload(requestDTO); + return ApiResponse.onSuccess("업로드 완료"); } } \ No newline at end of file diff --git a/src/main/java/org/example/wowelang_backend/board/domain/Image.java b/src/main/java/org/example/wowelang_backend/board/domain/Image.java index 49b292a..df11ee8 100644 --- a/src/main/java/org/example/wowelang_backend/board/domain/Image.java +++ b/src/main/java/org/example/wowelang_backend/board/domain/Image.java @@ -25,5 +25,23 @@ public class Image extends BaseEntity { @Column(name = "is_posted") private Boolean isPosted; - + // Stream 업로드는 관련 X + @Column(name = "s3_upload_id") + private String s3UploadId; + + // Stream 업로드는 관련 X + @Column(name = "is_s3_uploaded") + private Boolean isS3Uploaded; + + public Image(String imageKey, String imageUrl, String s3UploadId, Boolean isPosted, Boolean isS3Uploaded) { + this.imageKey = imageKey; + this.imageUrl = imageUrl; + this.s3UploadId = s3UploadId; + this.isPosted = isPosted; + this.isS3Uploaded = isS3Uploaded; + } + + public void updateIsS3Uploaded(boolean flag) { + this.isS3Uploaded = flag; + } } diff --git a/src/main/java/org/example/wowelang_backend/board/dto/image/MultipartPreSignedUrlRequestDTO.java b/src/main/java/org/example/wowelang_backend/board/dto/image/MultipartPreSignedUrlRequestDTO.java new file mode 100644 index 0000000..1068dca --- /dev/null +++ b/src/main/java/org/example/wowelang_backend/board/dto/image/MultipartPreSignedUrlRequestDTO.java @@ -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; +} diff --git a/src/main/java/org/example/wowelang_backend/board/dto/image/MultipartPreSignedUrlResponseDTO.java b/src/main/java/org/example/wowelang_backend/board/dto/image/MultipartPreSignedUrlResponseDTO.java new file mode 100644 index 0000000..69740bb --- /dev/null +++ b/src/main/java/org/example/wowelang_backend/board/dto/image/MultipartPreSignedUrlResponseDTO.java @@ -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 preSignedUrls; +} diff --git a/src/main/java/org/example/wowelang_backend/board/dto/image/MultipartUploadCompleteRequestDTO.java b/src/main/java/org/example/wowelang_backend/board/dto/image/MultipartUploadCompleteRequestDTO.java new file mode 100644 index 0000000..11b5fec --- /dev/null +++ b/src/main/java/org/example/wowelang_backend/board/dto/image/MultipartUploadCompleteRequestDTO.java @@ -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 partETagS; +} diff --git a/src/main/java/org/example/wowelang_backend/board/dto/image/PartETagDTO.java b/src/main/java/org/example/wowelang_backend/board/dto/image/PartETagDTO.java new file mode 100644 index 0000000..ce28154 --- /dev/null +++ b/src/main/java/org/example/wowelang_backend/board/dto/image/PartETagDTO.java @@ -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; +} diff --git a/src/main/java/org/example/wowelang_backend/board/dto/PostImageResponseDTO.java b/src/main/java/org/example/wowelang_backend/board/dto/image/PostImageResponseDTO.java similarity index 76% rename from src/main/java/org/example/wowelang_backend/board/dto/PostImageResponseDTO.java rename to src/main/java/org/example/wowelang_backend/board/dto/image/PostImageResponseDTO.java index c56fa13..f3f03a0 100644 --- a/src/main/java/org/example/wowelang_backend/board/dto/PostImageResponseDTO.java +++ b/src/main/java/org/example/wowelang_backend/board/dto/image/PostImageResponseDTO.java @@ -1,4 +1,4 @@ -package org.example.wowelang_backend.board.dto; +package org.example.wowelang_backend.board.dto.image; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/org/example/wowelang_backend/board/repository/ImageRepository.java b/src/main/java/org/example/wowelang_backend/board/repository/ImageRepository.java index 203b33b..d5bd2ce 100644 --- a/src/main/java/org/example/wowelang_backend/board/repository/ImageRepository.java +++ b/src/main/java/org/example/wowelang_backend/board/repository/ImageRepository.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface ImageRepository extends JpaRepository { @@ -18,4 +19,9 @@ public interface ImageRepository extends JpaRepository { List findByIsPostedFalse(); + Optional findByImageKeyAndS3UploadId(String imageKey, String s3UploadId); + + // S3 업로드 실패 또는 미완료된 이미지 (스케줄러용) + List findByIsPostedFalseAndIsS3UploadedFalse(); + } diff --git a/src/main/java/org/example/wowelang_backend/board/service/ImageService.java b/src/main/java/org/example/wowelang_backend/board/service/ImageService.java index 29cf0b3..4f580de 100644 --- a/src/main/java/org/example/wowelang_backend/board/service/ImageService.java +++ b/src/main/java/org/example/wowelang_backend/board/service/ImageService.java @@ -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,20 +80,110 @@ 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 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 생성 중 에러 발생"); + } + } + + // aws Multipart + Pre-Signed URL 방식 + @Transactional + public void completeMultipartUpload(MultipartUploadCompleteRequestDTO requestDTO) { + try { + imageRepository.findByImageKeyAndS3UploadId(requestDTO.getImageKey(), requestDTO.getUploadId()) + .ifPresentOrElse(image -> { + List 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 완료 처리 중 에러 발생"); + } + } + + 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 imageKeyList) { imageRepository.updatePostedTrueByKeys(imageKeyList); - } @Scheduled(cron = "0 0 18 * * *") @@ -95,15 +191,28 @@ public void markImageAsPosted(List imageKeyList) { public void deleteUnnecessaryImage() { List 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 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); + } + } } }