팀 프로젝트 진행 중 이미지 업로드 기능을 구현해야 하는 상황에서, 클라이언트에서 균일한 크기의 이미지가 보여야 하기 때문에 이미지를 리사이징하는 작업이 필요했다. 따라서 이미지 리사이징의 방법을 찾아본 결과 대표적으로 3가지 방법이 있었고, 이 중 장단점을 비교해 가장 나은 선택지를 선택하고자 했다.
이 포스트에서는 S3 버킷을 생성하는 과정에 대해서는 생략되어 있다. 만약 AWS S3를 사용하는 방법을 모른다면 이에 대해 먼저 알아보고 올 것을 권한다. 최근 잘 정리되어 있는 포스트를 하나 찾았기에 링크를 따라 진행해 보는 것도 좋은 방법이다.
이미지 리사이징에는 크게 3가지 방법이 있다.
위 방법 각각에 대해 하나하나 알아보자
s3 서비스를 스프링 프로젝트에서 사용하기 위해서는 아래의 과정을 거쳐야 한다.
build.gradle에 아래의 의존성을 추가한다.
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
이후 S3Config 클래스에서 사용할 값들에 대한 정보들을 yml로 작성해 둔다. 액세스 키와 시크릿 키를 넣어두고, S3Config에서 @Value로 값을 가져오는 방식. 여기서 액세스 키와 시크릿 키는 공개되면 악용될 여지가 크기 때문에 깃허브에 업로드되어서는 안 되고, 만약 깃허브에 업로드될 경우 Aws에서 자동으로 AMI 사용자를 비활성화하고 메일이 날라온다.
cloud:
aws:
credentials:
accessKey: #AMI 사용자 액세스 키
secretKey: #AMI 사용자 시크릿 키
region:
static: #aws에서 설정한 region, 보통 ap-northeast-2이다.
s3:
bucket: #S3에서 생성한 버킷 이름을 적는다.
stack:
auto: false
이제 S3에 이미지를 업로드하는 책임을 맡은 클래스를 생성해 준다.
@Configuration
public class S3Config {
//S3 accessKey, 아이디라고 봐도 무방하다.
@Value("${cloud.aws.credentials.accessKey}")
private String accessKey;
//S3 secretKey, 비밀번호라고 봐도 무방하다.
@Value("${cloud.aws.credentials.secretKey}")
private String secretKey;
//S3 region, S3버킷을 생성할 때의 지역
@Value("${cloud.aws.region.static}")
private String region;
//위 3가지 정보를 가진 AmazonS3Client를 생성하고
//AWS로 요청을 보낼 때 이 AmazonS3Client가 AWS 서버로 요청을 보내는 형태
@Bean
@Primary
public AmazonS3Client amazonS3Client() {
// AMI 정보를 가진 객체
BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
return (AmazonS3Client) AmazonS3ClientBuilder.standard()
.withRegion(region) // 어느 지역의
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) // 어떤 사용자인지
.build();
}
}
S3요청을 받는 S3Controller, 해당 예시에서는 이미지 저장과 삭제 api만을 만들었다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/s3")
public class S3Controller {
private final S3Service s3Service;
@PostMapping("/file")
public ResponseEntity<List<String>> uploadFile(@RequestPart List<MultipartFile> multipartFile) {
List<String> imageUrlList = s3Service.uploadImage(multipartFile);
return ResponseEntity.ok(imageUrlList);
}
@DeleteMapping("/file")
public String deleteFile(@RequestParam String fileName) {
s3Service.deleteImage(fileName);
return "이미지 삭제에 성공했습니다.";
}
}
S3요청을 수행하는 S3Service, 해당 예시에서는 이미지 저장과 삭제 로직만을 구현하였다.
@Service
@RequiredArgsConstructor
public class S3Service {
@Value("${cloud.aws.s3.bucket}")
private String bucket;
private final AmazonS3 amazonS3;
public List<String> uploadImage(List<MultipartFile> multipartFile) {
List<String> fileNameList = new ArrayList<>();
// 리스트에 있는 모든 파일들에 대해 작업 반복
multipartFile.forEach(file -> {
if(Objects.requireNonNull(file.getContentType()).contains("image")) {
String originalFilename = file.getOriginalFilename();
String fileName = createFileName(file.getOriginalFilename());
String fileFormatName = file.getContentType().substring(file.getContentType().lastIndexOf("/") + 1);
MultipartFile resizedFile = resizeImageByMarvin(fileName, originalFilename, fileFormatName, file, 768);
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentLength(resizedFile.getSize());
objectMetadata.setContentType(file.getContentType());
try(InputStream inputStream = resizedFile.getInputStream()) {
amazonS3.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata)
.withCannedAcl(CannedAccessControlList.PublicRead));
} catch(IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 업로드에 실패했습니다.");
}
fileNameList.add(fileName);
}
});
return fileNameList;
}
public void deleteImage(String fileName) {
amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName));
}
//파일명을 난수로 생성하는 메서드
private String createFileName(String fileName) {
return UUID.randomUUID().toString().concat(getFileExtension(fileName));
}
// 파일 형식을 String으로 가져오는 메서드
private String getFileExtension(String fileName) {
try {
return fileName.substring(fileName.lastIndexOf("."));
} catch (StringIndexOutOfBoundsException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 형식의 파일(" + fileName + ") 입니다.");
}
}
MultipartFile resizeImageByMarvin(String fileName, String originalFilename, String fileFormatName, MultipartFile originalImage, int targetWidth) {
try {
// MultipartFile -> BufferedImage Convert
BufferedImage image = ImageIO.read(originalImage.getInputStream());
// newWidth : newHeight = originWidth : originHeight
int originWidth = image.getWidth();
int originHeight = image.getHeight();
// 가로 길이가 기준 길이보다 작은 이미지일 경우 이미지를 resize 하지 않음
if(originWidth < targetWidth)
return originalImage;
MarvinImage imageMarvin = new MarvinImage(image);
Scale scale = new Scale();
scale.load();
scale.setAttribute("newWidth", targetWidth);
scale.setAttribute("newHeight", targetWidth * originHeight / originWidth);
scale.process(imageMarvin.clone(), imageMarvin, null, null, false);
BufferedImage imageNoAlpha = imageMarvin.getBufferedImageNoAlpha();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(imageNoAlpha, fileFormatName, baos);
baos.flush();
return new CustomMultipartFile(fileName, originalFilename, fileFormatName, baos.toByteArray());
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 resize에 실패했습니다.");
}
}
}
여기까지가 S3에 이미지를 저장하는 요청을 보내고, 로직을 수행하기 위한 최소 조건이다. 이제 이미지를 리사이징해서 저장하는 방법으로 넘어가 보자.
marvin 라이브러리는 오픈 소스 라이브러리로, marvin framework로 접속해 보면 소스 코드를 찾아볼 수 있다. 다만 2016년 12월 22일 1.5.5 버전이 마지막 릴리즈인 것으로 보아 추가적인 유지 보수가 이루어지고 있는 건 아닌 듯 하다. 현재 글을 작성하고 있는 2023년 5월 기준으로 버전이 호환되지 않는 등의 설정 문제는 없는 것으로 보인다.
marvin 라이브러리를 이용해 이미지를 리사이징하기 위해서는 아래의 의존성을 gradle에 추가해 주어야 한다.
implementation 'com.github.downgoon:marvin:1.5.5'
implementation 'com.github.downgoon:MarvinPlugins:1.5.5'
의존성을 추가했다면 MultipartFile의 구현체를 만들어야 한다. 정말 단순히 사진 업로드, 삭제만이 목적이라면 File 클래스로도 가능하지만 MultipartFile의 구현체를 직접 커스텀해 사용할 경우, 확장성과 프로젝트 DB에서의 관리의 측면에서 보다 유리할 수 있다. 해당 예시에서는 File을 쓰는 게 훨씬 단순하지만 처음에 클라이언트로부터 이미지 파일을 받을 때부터 CustomMultipartFile 클래스로 받아서 객체에 대한 조작을 비즈니스 로직에 추가할 수 있다. 다만 여기서는 resize를 위해 byte화된 정보를 다시 이미지 객체로 변환하는 데 사용되었다.
public class CustomMultipartFile implements MultipartFile {
private final String fileName;
private String originalFilename;
private String contentType;
private final byte[] content;
public CustomMultipartFile(String fileName, String originalFilename, String contentType, byte[] content) {
this.fileName = fileName;
this.originalFilename = (originalFilename != null ? originalFilename : "");
this.contentType = contentType;
this.content = (content != null ? content : new byte[0]);
}
@Override
public String getName() {
return this.fileName;
}
@Override
public String getOriginalFilename() {
return this.originalFilename;
}
@Override
public String getContentType() {
return this.contentType;
}
@Override
public boolean isEmpty() {
return (this.content.length == 0);
}
@Override
public long getSize() {
return this.content.length;
}
@Override
public byte[] getBytes() throws IOException {
return this.content;
}
@Override
public InputStream getInputStream() throws IOException {
return new ByteArrayInputStream(this.content);
}
@Override
public void transferTo(File dest) throws IOException, IllegalStateException {
FileCopyUtils.copy(this.content, dest);
}
}
MultipartFile 인터페이스를 implements하는 클래스라면 반드시 구현해야 하는 메서드 외에는 추가로 구현하지는 않았지만, 특정 비즈니스 로직에서 필요한 메서드가 있다면 이 클래스에서 직접 구현해 활용할 수 있다.
다음으로 S3Service를 아래와 같이 변경한다.
@Service
@RequiredArgsConstructor
public class S3Service {
@Value("${cloud.aws.s3.bucket}")
private String bucket;
private final AmazonS3 amazonS3;
public List<String> uploadImage(List<MultipartFile> multipartFile) {
List<String> fileNameList = new ArrayList<>();
multipartFile.forEach(file -> {
if(Objects.requireNonNull(file.getContentType()).contains("image")) {
String originalFilename = file.getOriginalFilename();
String fileName = createFileName(file.getOriginalFilename());
String fileFormatName = file.getContentType().substring(file.getContentType().lastIndexOf("/") + 1);
MultipartFile resizedFile = resizeImageByMarvin(fileName, originalFilename, fileFormatName, file, 400);
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentLength(resizedFile.getSize());
objectMetadata.setContentType(file.getContentType());
try(InputStream inputStream = resizedFile.getInputStream()) {
amazonS3.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata)
.withCannedAcl(CannedAccessControlList.PublicRead));
} catch(IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 업로드에 실패했습니다.");
}
fileNameList.add(fileName);
}
});
return fileNameList;
}
public void deleteImage(String fileName) {
amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName));
}
//파일명을 난수로 생성하는 메서드
private String createFileName(String fileName) {
return UUID.randomUUID().toString().concat(getFileExtension(fileName));
}
// 파일 형식을 String으로 가져오는 메서드
private String getFileExtension(String fileName) { // file 형식이 잘못된 경우를 확인하기 위해 만들어진 로직이며, 파일 타입과 상관없이 업로드할 수 있게 하기 위해 .의 존재 유무만 판단하였습니다.
try {
return fileName.substring(fileName.lastIndexOf("."));
} catch (StringIndexOutOfBoundsException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 형식의 파일(" + fileName + ") 입니다.");
}
}
// 이미지 리사이징 메서드
MultipartFile resizeImageByMarvin(String fileName, String originalFilename, String fileFormatName, MultipartFile originalImage, int targetWidth) {
try {
// MultipartFile -> BufferedImage Convert
BufferedImage image = ImageIO.read(originalImage.getInputStream());
// newWidth : newHeight = originWidth : originHeight
int originWidth = image.getWidth();
int originHeight = image.getHeight();
// origin 이미지가 resizing될 사이즈보다 작을 경우 resizing 작업 안 함
if(originWidth < targetWidth)
return originalImage;
MarvinImage imageMarvin = new MarvinImage(image);
Scale scale = new Scale();
scale.load();
scale.setAttribute("newWidth", targetWidth);
scale.setAttribute("newHeight", targetWidth * originHeight / originWidth);
scale.process(imageMarvin.clone(), imageMarvin, null, null, false);
BufferedImage imageNoAlpha = imageMarvin.getBufferedImageNoAlpha();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(imageNoAlpha, fileFormatName, baos);
baos.flush();
return new CustomMultipartFile(fileName, originalFilename, fileFormatName, baos.toByteArray());
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "파일 리사이즈에 실패했습니다.");
}
}
}
크게 위화감 없이 리사이징이 잘 이루어진 것을 볼 수 있다.
Graphics2D를 활용한 방법은 뒤의 ImageIO를 이용한 방법에 비해 성능은 유사한데 이미지 도트화가 너무 심하게 진행되었기에 생략하였다.
앞서 marvin 라이브러리를 사용한 방법의 경우 build.gradle에 의존성을 추가하자 약 60,000,000KB에서 약 260,000,000KB로 증가하였다. 따라서 ec2 프리티어의 환경을 서버로 채택할 경우 메모리 부족 문제가 발생할 수도 있다. 때문에 별도의 의존성 추가 없이 자바에서 자체 제공하는 클래스인 ImageIO를 이용한 방법을 사용해 보았다.
@Slf4j
@Component
@Service
@RequiredArgsConstructor
public class S3Service {
@Value("${cloud.aws.s3.bucket}")
private String bucket;
private final AmazonS3 amazonS3;
// 여러 개의 사진 업로드
public List<String> uploadImageList(List<MultipartFile> imageList) throws IOException{
List<String> imageUrlList = new ArrayList<>(); // 리사이징된 이미지를 저장할 공간
for (MultipartFile image : imageList){
imageUrlList.add(uploadImage(image));
}
return imageUrlList;
}
// 단일 사진 업로드
public String uploadImage(MultipartFile multipartFile) throws IOException {
String originalFilename = multipartFile.getOriginalFilename();
String fileName = createFileName(originalFilename);
File file = convertMultipartFileToFile(multipartFile, 400);
amazonS3.putObject(new PutObjectRequest(bucket, fileName, file)
.withCannedAcl(CannedAccessControlList.PublicRead));
file.delete(); // Delete temporary files
return amazonS3.getUrl(bucket, fileName).toString();
}
public void deleteImage(String fileName){
DeleteObjectRequest request = new DeleteObjectRequest(bucket, fileName);
amazonS3.deleteObject(request);
}
//파일명을 난수로 생성하는 메서드
private String createFileName(String fileName) {
return UUID.randomUUID().toString().concat(getFileExtension(fileName));
}
// 파일 형식을 String으로 가져오는 메서드
private String getFileExtension(String fileName) { // file 형식이 잘못된 경우를 확인하기 위해 만들어진 로직이며, 파일 타입과 상관없이 업로드할 수 있게 하기 위해 .의 존재 유무만 판단하였습니다.
try {
return fileName.substring(fileName.lastIndexOf("."));
} catch (StringIndexOutOfBoundsException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 형식의 파일(" + fileName + ") 입니다.");
}
}
// 이미지 리사이징 + MultipartFile을 File로 만드는 메서드
private File convertMultipartFileToFile(MultipartFile multipartFile, int width) throws IOException {
File file = new File(System.getProperty("java.io.tmpdir") + "/" + multipartFile.getOriginalFilename());
String formatName = multipartFile.getContentType().split("/")[1];
try (FileOutputStream fos = new FileOutputStream(file)) {
fos.write(multipartFile.getBytes());
}
BufferedImage originalImage = ImageIO.read(file);
int originWidth = originalImage.getWidth();
int originHeight = originalImage.getHeight();
if(originWidth < width)
return new File(multipartFile.getOriginalFilename());
double ratio = (double) originHeight / (double) originWidth;
int height = (int) Math.round(width * ratio);
java.awt.Image scaledImage = originalImage.getScaledInstance(width, height, java.awt.Image.SCALE_SMOOTH);
BufferedImage resizedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
resizedImage.getGraphics().drawImage(scaledImage, 0, 0, null);
File resizedFile = new File(System.getProperty("java.io.tmpdir") + "/resized_" + multipartFile.getOriginalFilename());
ImageIO.write(resizedImage, formatName, resizedFile);
return resizedFile;
}
}
ImageIO를 이용한 방법에서는 File 클래스를 이용해 구현했다. 결과는 marvin 라이브러리를 이용했을 때와 크게 차이 없이 리사이징이 잘 이루어졌다.
marvin 라이브러리를 이용한 방법과 ImageIO를 이용한 방법 모두 리사이징 이미지의 품질에 있어서는 큰 차이를 보이지 않았다. 따라서 마지막으로 성능을 비교해 보았다. 만약 성능이 동일하다면 marvin은 프로젝트 크기를 비대화한다는 점에서 지양되어야 할 것이고, 만약 성능이 marvin이 앞선다면 그래도 고려해 볼 만한 선택지가 될 것이다. 아래는 marvin과 ImageIO 각각의 방법을 postman에서 1회, 2회, 7회째의 시도한 결과다.
두 방법 모두 시도 횟수가 증가할 수록 성능이 개선되던 중 7회 째부터는 평균적인 성능을 유지하는 것으로 나타났다.
성능은 marvin 라이브러리를 홣용할 경우 약 5~7% 정도 개선되는 것을 확인할 수 있었다. 해당 예시에서는 한 개의 이미지를 업로드한 결과이기 때문에, 만약 서비스가 이미지 업로드를 자주 하지 않는다면 프로젝트를 비대화하지 않는 ImageIO를 이용한 방식을, 이미지 업로드가 자주 발생한다면 marvin 라이브러리를 고려해 볼 수 있을 것으로 보인다.