Skip to content
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

feat(S3PreSignedUrl): S3 파일 업로드 방식을 PreSigned URL로 구현 #159

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public class QPostEntity extends EntityPathBase<PostEntity> {

public final EnumPath<Status> status = createEnum("status", Status.class);

public final EnumPath<SuggestionTarget> suggestionTarget = createEnum("suggestionTarget", SuggestionTarget.class);

public final StringPath thumbnailImage = createString("thumbnailImage");

public final StringPath title = createString("title");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package ussum.homepage.infra.jpa.post.entity;

import static com.querydsl.core.types.PathMetadataFactory.*;

import com.querydsl.core.types.dsl.*;

import com.querydsl.core.types.PathMetadata;
import javax.annotation.processing.Generated;
import com.querydsl.core.types.Path;
import com.querydsl.core.types.dsl.PathInits;


/**
* QRightsDetailEntity is a Querydsl query type for RightsDetailEntity
*/
@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QRightsDetailEntity extends EntityPathBase<RightsDetailEntity> {

private static final long serialVersionUID = 30014936L;

private static final PathInits INITS = PathInits.DIRECT2;

public static final QRightsDetailEntity rightsDetailEntity = new QRightsDetailEntity("rightsDetailEntity");

public final NumberPath<Long> id = createNumber("id", Long.class);

public final StringPath major = createString("major");

public final StringPath name = createString("name");

public final EnumPath<RightsDetailEntity.PersonType> personType = createEnum("personType", RightsDetailEntity.PersonType.class);

public final StringPath phoneNumber = createString("phoneNumber");

public final QPostEntity postEntity;

public final StringPath studentId = createString("studentId");

public QRightsDetailEntity(String variable) {
this(RightsDetailEntity.class, forVariable(variable), INITS);
}

public QRightsDetailEntity(Path<? extends RightsDetailEntity> path) {
this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
}

public QRightsDetailEntity(PathMetadata metadata) {
this(metadata, PathInits.getFor(metadata, INITS));
}

public QRightsDetailEntity(PathMetadata metadata, PathInits inits) {
this(RightsDetailEntity.class, metadata, inits);
}

public QRightsDetailEntity(Class<? extends RightsDetailEntity> type, PathMetadata metadata, PathInits inits) {
super(type, metadata, inits);
this.postEntity = inits.isInitialized("postEntity") ? new QPostEntity(forProperty("postEntity"), inits.get("postEntity")) : null;
}

}

Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package ussum.homepage.application.image.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import ussum.homepage.application.image.controller.dto.request.FileUploadConfirmRequest;
import ussum.homepage.application.image.controller.dto.request.FileUploadRequest;
import ussum.homepage.application.image.controller.dto.response.PreSignedUrlResponse;
import ussum.homepage.application.image.service.ImageService;
import ussum.homepage.application.post.service.dto.response.postSave.PostFileListResponse;
import ussum.homepage.global.ApiResponse;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/image")
public class ImageController {

private final ImageService imageService;

@PostMapping("/presigned-url")
public ResponseEntity<ApiResponse<?>> getPreSignedUrls(
@RequestParam(value = "userId") Long userId,
@RequestParam(value = "boardCode") String boardCode,
@RequestBody FileUploadRequest request
) {
return ApiResponse.success(
imageService.createPreSignedUrls(
userId,
boardCode,
request.files(),
request.images()
)
);
}

@PostMapping("/confirm")
public ResponseEntity<ApiResponse<?>> confirmUpload(
@RequestParam(value = "userId") Long userId,
@RequestBody List<FileUploadConfirmRequest> confirmRequests
) {
return ApiResponse.success(
imageService.confirmUpload(userId, confirmRequests)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package ussum.homepage.application.image.controller.dto.request;

public record FileRequest(
String fileName,
String contentType
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package ussum.homepage.application.image.controller.dto.request;

public record FileUploadConfirmRequest(
String fileUrl,
String fileType,
String originalFileName
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package ussum.homepage.application.image.controller.dto.request;

import java.util.Collections;
import java.util.List;

public record FileUploadRequest(
List<FileRequest> files,
List<FileRequest> images
) {
public static FileUploadRequest of(List<FileRequest> files, List<FileRequest> images) {
return new FileUploadRequest(
files != null ? files : Collections.emptyList(),
images != null ? images : Collections.emptyList()
);
}

// null check를 위한 기본 생성자
public FileUploadRequest {
files = files != null ? files : Collections.emptyList();
images = images != null ? images : Collections.emptyList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package ussum.homepage.application.image.controller.dto.response;

import ussum.homepage.application.image.service.dto.PreSignedUrlInfo;

import java.util.List;
import java.util.Map;

public record PreSignedUrlResponse(
List<PreSignedUrlInfo> preSignedUrls
) {
public static PreSignedUrlResponse of(List<PreSignedUrlInfo> preSignedUrls) {
return new PreSignedUrlResponse(preSignedUrls);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package ussum.homepage.application.image.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ussum.homepage.application.image.controller.dto.request.FileRequest;
import ussum.homepage.application.image.controller.dto.request.FileUploadConfirmRequest;
import ussum.homepage.application.image.controller.dto.response.PreSignedUrlResponse;
import ussum.homepage.application.image.service.dto.PreSignedUrlInfo;
import ussum.homepage.application.post.service.dto.response.postSave.PostFileListResponse;
import ussum.homepage.application.post.service.dto.response.postSave.PostFileResponse;
import ussum.homepage.domain.post.PostFile;
import ussum.homepage.domain.post.service.PostFileAppender;
import ussum.homepage.infra.utils.S3PreSignedUrlUtils;

import java.io.FileNotFoundException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import org.springframework.util.CollectionUtils;
import ussum.homepage.infra.utils.s3.S3FileException;

import static ussum.homepage.global.error.status.ErrorStatus.FILE_NOT_FOUND;


@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ImageService {
private final S3PreSignedUrlUtils s3PreSignedUrlUtils;
private final PostFileAppender postFileAppender;

public PreSignedUrlResponse createPreSignedUrls(
Long userId,
String boardCode,
List<FileRequest> files,
List<FileRequest> images
) {
List<PreSignedUrlInfo> preSignedUrls = new ArrayList<>();

// 이미지 파일 처리
if (!CollectionUtils.isEmpty(images)) {
List<PreSignedUrlInfo> imageUrls = s3PreSignedUrlUtils.generatePreSignedUrlWithPath(
userId,
boardCode,
images.stream().map(FileRequest::fileName).toList(),
images.stream().map(FileRequest::contentType).toList(),
"images"
);
preSignedUrls.addAll(imageUrls);
}

// 일반 파일 처리
if (!CollectionUtils.isEmpty(files)) {
List<PreSignedUrlInfo> fileUrls = s3PreSignedUrlUtils.generatePreSignedUrlWithPath(
userId,
boardCode,
files.stream().map(FileRequest::fileName).toList(),
files.stream().map(FileRequest::contentType).toList(),
"files"
);
preSignedUrls.addAll(fileUrls);
}

return PreSignedUrlResponse.of(preSignedUrls);
}

@Transactional
public PostFileListResponse confirmUpload(Long userId, List<FileUploadConfirmRequest> confirmRequests) {
// 모든 파일의 존재 여부를 비동기로 확인
List<CompletableFuture<Void>> validationFutures = confirmRequests.stream()
.map(request -> s3PreSignedUrlUtils.doesObjectExistAsync(request.fileUrl())
.thenAccept(exists -> {
if (!exists) {
throw new S3FileException.FileNotFoundException(FILE_NOT_FOUND);
}
}))
.toList();

// 모든 검증 완료 대기
CompletableFuture.allOf(validationFutures.toArray(new CompletableFuture[0]))
.join();

// 이후 기존 로직 수행
List<PostFile> postFiles = confirmRequests.stream()
Copy link
Contributor

@chahyunsoo chahyunsoo Nov 28, 2024

Choose a reason for hiding this comment

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

S3PreSignedUrlUtils.java 클래스에

// S3 객체 존재 여부 확인 메서드
    public boolean doesObjectExist(String fileUrl) {
        String fileKey = extractKeyFromUrl(fileUrl);
        return amazonS3.doesObjectExist(bucket, fileKey);
    }

    // S3 URL에서 객체 키를 추출하는 유틸리티 메서드
    private String extractKeyFromUrl(String fileUrl) {
        // fileUrl에서 S3 객체 키 추출 (버킷 이름 이후의 경로)
        return fileUrl.substring(fileUrl.indexOf(bucket) + bucket.length() + 1);
    }

이 메소드를 추가해서 ImageService의 confirmUpload 메소드에서 builder 들어가기전에, 한번 S3 객체 존재 여부를 확인하는건 어떻게 생각하시나여?

  • S3 객체 존재 여부 확인 메서드 추가

    • doesObjectExist 메서드를 통해 S3 객체가 실제로 존재하는지 확인하고
    • fileUrl에서 S3 키를 추출하는 extractKeyFromUrl 메서드로 URL 기반 키 처리를 안정적으로 수행하는 거!
  • ImageService 클래스에서 검증 적용

    • confirmUpload 메서드에서 S3 객체가 존재하지 않을 경우 예외 발생
    • 클라이언트가 잘못된 URL을 전달하거나 파일이 삭제된 경우를 방지

Copy link
Contributor Author

@qogustj qogustj Nov 29, 2024

Choose a reason for hiding this comment

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

public boolean doesObjectExist(String fileUrl) {
    String fileKey = extractKeyFromUrl(fileUrl);
    return amazonS3.doesObjectExist(bucket, fileKey);
}

이 방식에서 더 나아가서

존재 여부 확인 메서드 같은 경우에는 네크워크 요청이기에 다수의 파일일때 블로킹으로 인한 자원 낭비가 있기 때문에,
현재 시스템이 작더라도 확장성을 고려했을때 비동기처리로 구현해놓으면 훨씬 효율적이라고 생각합니다!

Copy link
Contributor

Choose a reason for hiding this comment

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

그렇네요! doesObjectExist 메서드는 S3 파일 존재 여부를 확인하는 간단한 방식으로 구현되어 있지만, 코멘트 주신 대로 다수의 파일을 처리할 때는 네트워크 요청이 블로킹 방식으로 동작하여 비효율적일 수 있을 것 같네여!!;;

말씀하신 시스템이 작더라도 확장성과 효율성을 고려해 비동기 방식(CompletableFuture)으로 리팩토링하여 네트워크 요청의 병렬 처리를 지원하는 것은 좋은 방식인 것 같습니다!!

.map(request -> PostFile.builder()
.url(request.fileUrl())
.typeName(request.fileType())
.build())
.toList();

List<PostFile> afterSaveList = postFileAppender.saveAllPostFile(postFiles);

String thumbnailUrl = afterSaveList.stream()
.filter(postFile -> postFile.getTypeName().equals("images"))
.min(Comparator.comparing(PostFile::getId))
.map(PostFile::getUrl)
.orElse(null);

List<PostFileResponse> postFileResponses = IntStream.range(0, afterSaveList.size())
.mapToObj(i -> PostFileResponse.of(
afterSaveList.get(i).getId(),
afterSaveList.get(i).getUrl(),
confirmRequests.get(i).originalFileName()
))
.toList();

return PostFileListResponse.of(thumbnailUrl, postFileResponses);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package ussum.homepage.application.image.service.dto;

import lombok.Getter;

import java.time.LocalDateTime;
import java.util.Objects;

@Getter
public class PreSignedUrlInfo {
private final String preSignedUrl;
private final String fileUrl;
private final String originalFileName;
private final LocalDateTime expirationTime; // URL 만료 시간 추가

private PreSignedUrlInfo(String preSignedUrl, String fileUrl, String originalFileName, LocalDateTime expirationTime) {
this.preSignedUrl = validateNotBlank(preSignedUrl, "PreSigned URL");
this.fileUrl = validateNotBlank(fileUrl, "File URL");
this.originalFileName = validateNotBlank(originalFileName, "Original File Name");
this.expirationTime = Objects.requireNonNull(expirationTime, "Expiration Time must not be null");
}

private String validateNotBlank(String value, String fieldName) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(fieldName + "은(는) 필수값입니다.");
}
return value;
}

public static PreSignedUrlInfo of(String preSignedUrl, String fileUrl, String originalFileName, LocalDateTime expirationTime) {
return new PreSignedUrlInfo(preSignedUrl, fileUrl, originalFileName, expirationTime);
}

public boolean isExpired() {
return LocalDateTime.now().isAfter(expirationTime);
}
}
2 changes: 2 additions & 0 deletions src/main/java/ussum/homepage/domain/post/PostFile.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import jakarta.persistence.ManyToOne;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import ussum.homepage.infra.jpa.post.entity.PostEntity;

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class PostFile {
private Long id;
private String fileName;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public enum ErrorStatus implements BaseErrorCode {
INVALID_FILETYPE(HttpStatus.BAD_REQUEST,"ENUM_017","유효하지 않은 FILETYPE입니다."),
PERSON_TYPE_NULL(HttpStatus.BAD_REQUEST,"ENUM_18","PERSON_TYPE이 NULL입니다."),
INVALID_PERSON_TYPE(HttpStatus.BAD_REQUEST,"ENUM_19","유효하지 않은 PERSON_TYPE입니다."),
INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST,"ENUM_20","유효하지 않은 CONTENT_TYPE입니다."),
/**
* 401 Unauthorized, Token 관련 에러
*/
Expand All @@ -104,6 +105,8 @@ public enum ErrorStatus implements BaseErrorCode {
S3_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,"S3_001","S3에 파일 저장이 실패했습니다."),
INVALID_S3_URL(HttpStatus.BAD_REQUEST,"S3_002","유효하지 않은 S3 파일 URL입니다."),
URL_DELETE_ERROR(HttpStatus.BAD_REQUEST,"S3_003","S3 파일 삭제에 실패했습니다."),
FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "S3_004", "S3에서 파일을 찾을 수 없습니다."),
S3_VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "S3_005", "S3 파일 검증에 실패했습니다."),


//Body 에러
Expand Down
Loading