📌 파일 업로드 기능 구현 방법은 아래 포스팅을 참고해주세요.
이전 게시물에서 S3 버킷을 만들었으니
이번 게시물에서는 Access Key로 해당 버킷에 파일을 업로드할 수 있는 기능을 구현하려고 한다!
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
application.yml 파일에 설정 추가( # 이걸로 설명해놓은 부분 추가하면 됨 )
❗ 엑세스 키와 시크릿 키는 외부에 유출되면 안되는 정보이기 때문에 꼭 환경변수로 설정해두어야한다.
cloud: aws: credentials: accessKey: # IAM 엑세스 키 secretKey: # IAM 시크릿 키 region: static: ap-northeast-2 s3: bucket: # 버킷 이름 stack: auto: false
- cloud.aws.stack.auto=false
EC2에서 Spring Cloud 프로젝트를 실행시키면 기본으로 CloudFormation 구성을 시작하기 때문에 설정한 CloudFormation이 없으면 프로젝트 실행이 되지 않는다.
해당 기능을 사용하지 않도록 false로 설정
ㅤ- cloud.aws.region.static:ap-northeast-2
지역을 한국으로 고정한다.
S3BucketConfig 클래스➜ AWS S3와의 연동을 위한 설정 정보를 관리하는 클래스
➜ @Value 애너테이션으로 application.yml` 파일에 정의된 AWS 관련 설정을 가져오고, 이를 바탕으로 AWS S3 클라이언트를 생성하는 역할
public class S3BucketConfig { @Value("cloud.aws.credentials.accessKey") private String accessKey; ⠀ ⠀ @Value("cloud.aws.credentials.secretKey") private String secretKey; ⠀ ⠀ @Value("cloud.aws.region.static") private String region; ⠀ ⠀ @Bean public AmazonS3 amazonS3Client() { AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); // AWS 서비스에 엑세스하기 위한 기본적인 인증정보 제공 // accessKey와 secretKey를 가지고 AWSCredentials 인스턴스 생성 ⠀ ⠀ return AmazonS3ClientBuilder // AmazonS3 클라이언트 인스턴스를 생성하고 구성하기 위한 빌더 패턴 클래스 .standard() // AmazonS3ClientBuilder의 기본 구성을 사용하여 빌더 인스턴스 생성 .withCredentials(new AWSStaticCredentialsProvider(credentials)) // 생성된 AmazonS3 클라이언트에 인증 정보 제공 .withRegion(region) // 클라이언트가 작업을 수행할 AWS 리전 설정 .build(); } }
S3UploaderConfig 클래스➜ prod 프로파일에서 활성화되며, S3에 파일을 업로드하기 위한 다양한 Bean을 정의하는 클래스
@Configuration @RequiredArgsConstructor @Profile("prod") @Slf4j public class S3UploaderConfig { private final JpaFileRepository fileRepository; private final UserService userService; ⠀ ⠀ @Bean public FileService imageUploader() { // FileServiceImpl을 생성하는 Bean 정의 return new FileServiceImpl(this.uploader(), fileRepository, userService); } ⠀ ⠀ @Bean(name = "s3Uploader") public Uploader uploader() { // S3Uploader 인스턴스를 생성하여 Bean으로 등록 log.info("AWS Access Key: {}", s3BucketConfig().getAccessKey()); return new S3Uploader(s3BucketConfig().amazonS3Client()); } ⠀ ⠀ @Bean public S3BucketConfig s3BucketConfig() { // S3BucketConfig 인스턴스를 생성하여 Bean으로 등록 return new S3BucketConfig(); } }
Uploader 인터페이스➜ 파일 업로드 기능 정의
➜ 이 인터페이스를 기반으로 S3Uploader와 테스트 용도의 LocalUploader 두 가지 구현체를 만듦
public interface Uploader { String[] upload(MultipartFile file, FileCategory category, Long userId) throws IOException; }
S3Uploader 클래스➜ Uploader 인터페이스의 구현체
➜ prod 프로파일에서 활성화되며, S3에 파일을 업로드하기 위한 upload 기능을 정의하는 클래스
➜ S3에서 파일 삭제 및 업데이트 기능은 현재 코드에는 없음
@Slf4j public class S3Uploader implements Uploader { private final AmazonS3 amazonS3Client; ⠀ ⠀ @Value("${cloud.aws.s3.bucket}") private String bucket; ⠀ ⠀ public S3Uploader(AmazonS3 amazonS3Client) { this.amazonS3Client = amazonS3Client; } ⠀ ⠀ // (1) S3에 업로드 하는 메서드 @Override @Transactional public String[] upload(MultipartFile file, FileCategory category, Long userId) throws IOException { String dirName = FileUploadUtil.generateFilePath(file.getContentType(), category, userId); // 파일 카테고리, 디렉토리명 생성 return new String[] { upload(file, "jandp/" + dirName, category, userId), bucket }; } ⠀ ⠀ // (2) 주어진 MultipartFile을 File 객체로 변환한 후, S3에 업로드하는 메서드 public String upload(MultipartFile multipartFile, String dirName, FileCategory category, Long userId) throws IOException { File uploadFile = convertFile(multipartFile, category, userId) .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패")); ⠀ ⠀ String fileName = uploadFile.getName(); return uploadToS3(uploadFile, dirName + "/" + fileName); } ⠀ ⠀ // (4) S3에 파일을 업로드하고, 로컬에 생성된 임시 파일을 삭제하는 메서드 private String uploadToS3(File uploadFile, String fileName) { String uploadImageUrl = putS3(uploadFile, fileName); FileUploadUtil.removeNewFile(uploadFile); // 로컬에 생성된 임시파일 삭제 ⠀ ⠀ return uploadImageUrl; // 업로드된 파일의 S3 URL 주소 반환 } ⠀ ⠀ // (5) MultipartFile을 File 객체로 변환하는 메서드 private Optional<File> convertFile(MultipartFile file, FileCategory category, Long userId) throws IOException { String mimeType = file.getContentType(); String subDir = FileUploadUtil.generateFilePath(mimeType, category, userId); // 하위 디렉토리 결정 File tmpDir = new File("tmp/" + subDir + "/"); // 결정된 하위 디렉토리를 포함한 경로로 tmpDir 설정 ⠀ ⠀ if (!tmpDir.exists()) { boolean wasSuccessful = tmpDir.mkdirs(); // 디렉토리가 존재하지 않으면 생성 if (!wasSuccessful) { log.error("디렉토리 생성 실패"); // 생성 실패 시 로그 남김 throw new IOException("디렉토리 생성에 실패했습니다."); // 예외를 던져 처리 과정 중단 } } ⠀ ⠀ String safeFileName = FileUploadUtil.generateFileName(Objects.requireNonNull(file.getOriginalFilename())); ⠀ ⠀ File convertFile = new File(tmpDir, safeFileName); if (convertFile.createNewFile()) { // 파일 생성에 성공하면 try (FileOutputStream fos = new FileOutputStream(convertFile)) { fos.write(file.getBytes()); } return Optional.of(convertFile); } else { log.error("임시 파일 생성 실패: " + convertFile.getAbsolutePath()); // 생성 실패 시 로그 return Optional.empty(); // 파일 생성에 실패하면 빈 Optional 반환 } } ⠀ ⠀ // (6) 주어진 파일을 S3에 업로드하고, 업로드된 파일의 URL을 반환하는 메서드 private String putS3(File uploadFile, String fileName) { try { amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, uploadFile) .withCannedAcl(CannedAccessControlList.PublicRead)); return amazonS3Client.getUrl(bucket, fileName).toString(); } catch (AmazonServiceException e) { log.error("AmazonServiceException: " + e.getErrorMessage()); throw e; } catch (AmazonClientException e) { log.error("AmazonClientException: " + e.getMessage()); throw e; } }
FileUploadUtil 클래스➜ LocalUploader와 S3Uploader에서 공통적으로 사용되는 파일 업로드 관련 유틸리티 클래스
➜ 파일의 경로 및 이름 생성, 임시 파일 삭제 등의 기능 수행
@Slf4j @Component public class FileUploadUtil { // 주어진 파일의 콘텐츠 타입, 카테고리, 사용자 ID를 기반으로 파일 경로를 생성하는 메서드 public static String generateFilePath(String contentType, FileCategory category, Long userId) { StringBuilder pathBuilder = new StringBuilder(); ㅤ switch (category) { case PROFILE: pathBuilder.append("profile"); break; case PLACE: pathBuilder.append("place"); break; case REVIEW: pathBuilder.append("review"); break; case DIARY: pathBuilder.append("Diary"); break; default: throw new IllegalArgumentException("<" + category + ">라는 카테고리는 파일 업로드를 지원하지 않습니다."); } ㅤ if (category != FileCategory.PLACE) { // place는 모두 직접 넣어주므로 userId 경로는 생략 pathBuilder.append("/user").append(userId).append("/"); } ㅤ if (contentType != null) { if (contentType.startsWith("image")) { pathBuilder.append("/images"); } else if (contentType.startsWith("video")) { pathBuilder.append("/videos"); } else if (contentType.contains("pdf")) { pathBuilder.append("/pdfs"); } else { throw new IllegalArgumentException("지원하지 않는 파일 타입입니다: " + contentType); } } ㅤ return pathBuilder.toString(); } ㅤ // 원본 파일 이름을 안전한 형식으로 변환하고, UUID를 접두사로 추가하여 고유한 파일 이름을 생성하는 메서드 public static String generateFileName(String originalFileName) { String uuid = UUID.randomUUID().toString(); String safeFileName = originalFileName.replaceAll("\\s", "_").replaceAll("[^a-zA-Z0-9\\.\\-_]", "_"); return uuid + "_" + safeFileName; } ㅤ // 주어진 파일을 삭제하는 메서드 (임시 파일 삭제를 위해 만듦) public static void removeNewFile(File targetFile) { if (targetFile.delete()) { log.info("임시 파일이 삭제되었습니다."); } else { log.info("임시 파일이 삭제되지 못했습니다."); } } }
File 엔티티@Entity @AllArgsConstructor @NoArgsConstructor @Builder @Getter @Setter public class File { @Id @GeneratedValue(generator = "uuid2") @GenericGenerator(name = "uuid2", strategy = "uuid2") @Column(columnDefinition = "BINARY(16)") // binary 형태로 저장 --> 데이터 공간을 적게 차지함 private UUID id; ㅤ private String bucket; ㅤ @Column(length = 1000) private String url; ㅤ @Enumerated(EnumType.STRING) private FileType fileType; // FileType에 따른 저장 경로 구분을 위함 ㅤ @ManyToOne @JoinColumn(name = "user_id") private User user; ㅤ public enum FileType { IMAGE("이미지"), VIDEO("비디오"), PDF("pdf"); ㅤ @Getter private final String value; ㅤ FileType(String value) { this.value = value; } } }
FileController 클래스➜ 프로필 업로드 API
@PostMapping(value = "/profile/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @Operation(summary = "유저의 프로필 사진을 업로드합니다.", description = "프로필 이미지를 수정할 때에도 사용이 가능합니다. <br>" + "( 10mb 이하의 이미지만 업로드 가능합니다. )") public ResponseEntity<SingleResponse<FileResDto>> uploadProfile(@RequestParam(value = "file") MultipartFile file, @AuthenticationPrincipal UserPrincipal principal) throws IOException { ㅤ // 이미지가 아닌 경우 String contentType = file.getContentType(); if (contentType == null || !contentType.startsWith("image/")) { throw new CustomLogicException(ExceptionCode.FILE_NOT_SUPPORTED); } ㅤ // 파일이 비어있는 경우 if (file.isEmpty()) { throw new CustomLogicException(ExceptionCode.FILE_NONE); } ㅤ return ResponseEntity.ok().body(new SingleResponse<>(fileService.uploadProfile(file, principal.getUsername()))); }
FileServiceImpl 클래스➜ 파일 업로드 관련 서비스 로직을 구현
➜ S3UploaderConfig 이미 Bean으로 정의를 해주어서 @Service 애너테이션은 붙이지 않음
( 붙일경우 빈 중복 에러 남 )
현재 코드는 사용자의 프로필 사진 업로드 기능만 예시로 사용하였고,
프로필이 존재하는 경우 새 파일로 갈아끼우는 방식으로 구현함
@Transactional @RequiredArgsConstructor public class FileServiceImpl implements FileService { private final Uploader uploader; private final JpaFileRepository fileRepository; private final UserService userService; ㅤ // 유저 프로필 사진 업로드 @Override @Transactional public FileResDto uploadProfile(MultipartFile file, String email) throws IOException { User user = userService.verifyUser(email); // 사용자 검증 ㅤ String[] info = uploadProfileImage(file, user.getId()); // 이미지 업로드 하고 버킷 이름이랑 url 받음 File fileEntity = File.builder() .bucket(info[1]) .url(info[0]) .fileType(File.FileType.IMAGE) .user(user) .build(); ㅤ if (user.getProfile() != null) { // user의 프로필이 있을 경우 fileRepository.delete(user.getProfile()); // 원래 프로필 삭제 ㅤ // S3에서도 파일 교체 String oldFileUrl = user.getProfile().getUrl(); String oldFileName = oldFileUrl.substring(oldFileUrl.lastIndexOf("/") + 1); uploader.updateFile(file, oldFileName, PROFILE, user.getId()); // (현재 코드에는 없습니다.) } ㅤ // 새로운 파일 정보 저장 및 사용자 프로필 정보 업데이트 후 파일 정보 반환 fileRepository.save(fileEntity); user.setProfile(fileEntity); return FileResDto.builder() .fileId(fileEntity.getId().toString()) .fileUrl(fileEntity.getUrl()) .build(); } ㅤ // 실제 업로드 메서드 private String[] uploadProfileImage(MultipartFile file, Long userId) throws IOException { if (!Objects.requireNonNull(file.getContentType()).startsWith("image")) { // 파일 타입이 이미지인지 확인 (프로필 사진은 이미지만 가능) throw new CustomLogicException(ExceptionCode.FILE_NOT_SUPPORTED); } return uploader.upload(file, PROFILE, userId); // profile에 따른 uploader를 호출하여 파일 업로드 } }
참고로 파일 크기 제한은 application.yml에서 아래와 같이 설정 가능하다!
spring servlet: multipart: max-file-size: 10MB max-request-size: 10MB
그런데 내 프로젝트의 경우에는 Nginx를 이용하여 프록시 설정을 해두었었는데
Nginx는 기본 설정이 1mb의 파일 크기 제한이 있어서 저 기능이 잘 작동하지 않았다.
그래서 Nginx에서도 10mb로 파일 크기 제한 설정을 바꿔두었다!
📌 Nginx 파일 크기 제한 설정은 아래 포스팅을 참고해주세요.