Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand All @@ -12,6 +17,7 @@
@RestController
@RequiredArgsConstructor
@RequestMapping("/file")
@Slf4j
public class PostImageController {

private final ImageService fileService;
Expand All @@ -21,6 +27,22 @@ public ApiResponse<PostImageResponseDTO> 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<MultipartPreSignedUrlResponseDTO> getMultipartPreSignedUrls(@RequestBody MultipartPreSignedUrlRequestDTO requestDTO){

MultipartPreSignedUrlResponseDTO response = fileService.initiateMultipartUpload(
requestDTO.getFilename(), requestDTO.getContentType(), requestDTO.getPartCount()
);

return ApiResponse.onSuccess(response);
}

@PostMapping("/upload-url-complete")
public ApiResponse<String> completeMultipartUpload(@RequestBody MultipartUploadCompleteRequestDTO requestDTO) {
fileService.completeMultipartUpload(requestDTO);
return ApiResponse.onSuccess("업로드 완료");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
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,4 +1,4 @@
package org.example.wowelang_backend.board.dto;
package org.example.wowelang_backend.board.dto.image;

import lombok.AllArgsConstructor;
import lombok.Getter;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface ImageRepository extends JpaRepository<Image, Long> {
Expand All @@ -18,4 +19,9 @@ public interface ImageRepository extends JpaRepository<Image, Long> {

List<Image> findByIsPostedFalse();

Optional<Image> findByImageKeyAndS3UploadId(String imageKey, String s3UploadId);

// S3 업로드 실패 또는 미완료된 이미지 (스케줄러용)
List<Image> findByIsPostedFalseAndIsS3UploadedFalse();

}
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;
Expand All @@ -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();
Expand Down Expand Up @@ -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())
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런 부분에서는 따로 예외, 에러 처리를 해주지 않아도 되나요?


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 생성 중 에러 발생");
Copy link
Contributor

Choose a reason for hiding this comment

The 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 완료 처리 중 에러 발생");
Copy link
Contributor

Choose a reason for hiding this comment

The 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);
}
}
}
}
Loading