[Project] 파일 업로드 기능 구현 2 - 프로젝트에 적용하기!

현주·2024년 4월 21일

📌 파일 업로드 기능 구현 방법은 아래 포스팅을 참고해주세요.

이전 게시물에서 S3 버킷을 만들었으니

이번 게시물에서는 Access Key로 해당 버킷에 파일을 업로드할 수 있는 기능을 구현하려고 한다!


✏️ 내 프로젝트와 연결하기

1. 의존성 추가

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

2. 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
    지역을 한국으로 고정한다.

3. 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();
	}
}

4. 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();
	}
}

5. Uploader 인터페이스

➜ 파일 업로드 기능 정의
➜ 이 인터페이스를 기반으로 S3Uploader와 테스트 용도의 LocalUploader 두 가지 구현체를 만듦

public interface Uploader {
	String[] upload(MultipartFile file, FileCategory category, Long userId) throws IOException;
}

6. 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;
		}
	}

7. FileUploadUtil 클래스

LocalUploaderS3Uploader에서 공통적으로 사용되는 파일 업로드 관련 유틸리티 클래스
➜ 파일의 경로 및 이름 생성, 임시 파일 삭제 등의 기능 수행

@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("임시 파일이 삭제되지 못했습니다.");
		}
	}
}

8. 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;
		}
	}
}

9. 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())));
	}

10. 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 파일 크기 제한 설정은 아래 포스팅을 참고해주세요.

0개의 댓글