파일 업로드/다운로드 기능을 만들어야 할 일이 생겼다. 그래서 Spring Boot에서 AWS S3로 파일 업로드/다운로드 기능을 구현하면서 어떻게 처리했는지 어떤 문제가 발생했는지 작성해 보려고 한다.
이 글에서는 AWS S3 설정 부분은 다루지 않는다. 나중에 S3에 대한 내용을 포스팅 예정이다.
/** AWS S3 관련 */
// https://mvnrepository.com/artifact/io.awspring.cloud/spring-cloud-starter-aws
implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.4.4'
/** 파일 업로드 */
// https://mvnrepository.com/artifact/commons-io/commons-io
implementation 'commons-io:commons-io:2.14.0'// https://mvnrepository.com/artifact/commons-fileupload/commons-fileupload
implementation 'commons-fileupload:commons-fileupload:1.5'
/** 파일 형식 체크를 위한 라이브러러 */
// https://mvnrepository.com/artifact/org.apache.tika/tika-core
implementation 'org.apache.tika:tika-core:2.9.1'
cloud:
aws:
credentials:
accessKey: 액세스 키
secretKey: 시크릿 키
s3:
bucket: 버킷명
region:
static: 리전명(ap-northeast-2) -> 리전을 서울로 사용 중이라 ap-northeast-2
stack:
auto: false
/** S3 연결을 위한 Bean 등록 */
@Configuration
public class AWSS3Config {
@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 AmazonS3Client amazonS3Client() {
AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return (AmazonS3Client)AmazonS3ClientBuilder
.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
}
/** 파일 타입 체크를 위한 Bean 등록 */
@Configuration
public class FileConfig {
// 파일 타입 체크를 위한 라이브러리 Bean 등록
@Bean
public Tika getTika() {
return new Tika();
}
}
/** 파일을 업로드 하는 메소드 */
public File uploadFile(MultipartFile multipartFile, String directory) {
String fileName = createFileName(multipartFile.getOriginalFilename());
String key = profile + "/" + directory + "/" + fileName;
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(multipartFile.getSize());
metadata.setContentType(multipartFile.getContentType());
try (InputStream inputStream = multipartFile.getInputStream()) {
amazonS3Client.putObject(new PutObjectRequest(bucket, key, inputStream, metadata));
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 업로드에 실패했습니다.");
}
File file =
File
.builder()
.key(key)
.contentType(multipartFile.getContentType())
.size(multipartFile.getSize())
.name(multipartFile.getOriginalFilename())
.build();
fileRepository.save(file);
return file;
}
/**
파일명을 UUID로 변경. 뒤에 파일 확장자를 붙이기 위해 파일명을 파라미터로 받는다.
*/
private String createFileName(String fileName) {
return UUID.randomUUID().toString().concat(getFileExtension(fileName));
}
/** 파일을 다운로드하는 메소드 */
@Override
public ResponseEntity<?> downloadFileBlob(long id, HttpServletRequest request, HttpServletResponse response) {
File file = findById(id);
String downloadFileName = file.getName();
try (S3Object s3Object = amazonS3Client.getObject(bucket, file.getKey()); S3ObjectInputStream objectInputStream = s3Object.getObjectContent()) {
// 파일을 메모리에 로딩해 바이트로 변환
byte[] bytes = IOUtils.toByteArray(objectInputStream);
String fileName = makeFileName(request, Objects.requireNonNullElse(downloadFileName, file.getKey()));
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(file.getContentType()));
headers.setContentLength(bytes.length);
headers.setContentDispositionFormData("attachment", fileName);
return new ResponseEntity<>(bytes, headers, HttpStatus.OK);
} catch (IOException e) {
log.debug(e.getMessage(), e);
throw new BusinessException(ApiResponseCode.FILE_DOWNLOAD_FAILED, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/** 파일을 다운로드하는 메소드 */
@Override
public ResponseEntity<?> downloadFileBlob(long id, HttpServletRequest request, HttpServletResponse response) {
File file = findById(id);
String downloadFileName = file.getName();
try {
String fileName = makeFileName(request, Objects.requireNonNullElse(downloadFileName, file.getKey()));
S3Object s3Object = amazonS3Client.getObject(bucket, file.getKey());
S3ObjectInputStream objectInputStream = s3Object.getObjectContent();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(file.getContentType()));
headers.setContentDispositionFormData("attachment", fileName);
// 파일을 스트리밍 방식으로 응답
return new ResponseEntity<>(new InputStreamResource(objectInputStream), headers, HttpStatus.OK);
} catch (IOException e) {
log.debug(e.getMessage(), e);
throw new BusinessException(ApiResponseCode.FILE_DOWNLOAD_FAILED, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/**
* 파일명이 한글인 경우 URL encode이 필요함.
* @param request
* @param displayFileName
* @return
* @throws UnsupportedEncodingException
*/
private String makeFileName(HttpServletRequest request, String displayFileName) throws UnsupportedEncodingException {
String header = request.getHeader("User-Agent");
String encodedFilename = null;
if (header.contains("MSIE")) {
encodedFilename = URLEncoder.encode(displayFileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
} else if (header.contains("Trident")) {
encodedFilename = URLEncoder.encode(displayFileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
} else if (header.contains("Chrome")) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < displayFileName.length(); i++) {
char c = displayFileName.charAt(i);
if (c > '~') {
sb.append(URLEncoder.encode("" + c, StandardCharsets.UTF_8));
} else {
sb.append(c);
}
}
encodedFilename = sb.toString();
} else if (header.contains("Opera")) {
encodedFilename = "\"" + new String(displayFileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1) + "\"";
} else if (header.contains("Safari")) {
encodedFilename = URLDecoder.decode("\"" + new String(displayFileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1) + "\"", StandardCharsets.UTF_8);
} else {
encodedFilename = URLDecoder.decode("\"" + new String(displayFileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1) + "\"", StandardCharsets.UTF_8);
}
return encodedFilename;
}
이전 회사에서 S3 파일 업로드/다운로드 시 인증이 필요하지 않아서 객체 URL을 DB로 관리하는 방식을 사용했는데, 이번 업무에는 인증이 필요해 API를 거쳐 업로드/다운로드하는 기능을 구현했다. 예전에 파일 다운로드 시 스트리밍 방식으로 다운로드를 처리했었는데, 그 방식을 잊어서 예상치 못한 삽질을 했다. 미리 정리를 해놓았으면 어땠을까 싶다.
내용 중 개선될 수 있는 코드나 내용이 있으면 댓글 남겨주세요 😃