Spring 프로젝트에서 S3 이미지 업로드 시 리사이징 성능 비교

홍예석·2023년 5월 11일
0

들어가며

팀 프로젝트 진행 중 이미지 업로드 기능을 구현해야 하는 상황에서, 클라이언트에서 균일한 크기의 이미지가 보여야 하기 때문에 이미지를 리사이징하는 작업이 필요했다. 따라서 이미지 리사이징의 방법을 찾아본 결과 대표적으로 3가지 방법이 있었고, 이 중 장단점을 비교해 가장 나은 선택지를 선택하고자 했다.

이 포스트에서는 S3 버킷을 생성하는 과정에 대해서는 생략되어 있다. 만약 AWS S3를 사용하는 방법을 모른다면 이에 대해 먼저 알아보고 올 것을 권한다. 최근 잘 정리되어 있는 포스트를 하나 찾았기에 링크를 따라 진행해 보는 것도 좋은 방법이다.

이미지를 리사이징하는 3가지 방법

이미지 리사이징에는 크게 3가지 방법이 있다.

  1. marvin 오픈 라이브러리를 활용한 방법
  2. Graphics2D를 활용한 방법
  3. ImageIO를 활용한 방법

위 방법 각각에 대해 하나하나 알아보자

이미지 업로드를 위한 기본 구성

s3 서비스를 스프링 프로젝트에서 사용하기 위해서는 아래의 과정을 거쳐야 한다.

  1. 의존성 설정

build.gradle에 아래의 의존성을 추가한다.

	implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
  1. application.yml 설정

이후 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
  1. 클래스 구성

이제 S3에 이미지를 업로드하는 책임을 맡은 클래스를 생성해 준다.

  • S3Config 클래스
    Access key와 secret key, region이 있으면 AWS의 클라이언트 객체를 생성할 수 있다. 우리는 S3를 사용할 것이기 때문에 AmazonS3Client 객체를 생성한다.
@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();
    }
}
  • S3Controller

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 "이미지 삭제에 성공했습니다.";
    }
}
  • S3Service

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 라이브러리는 오픈 소스 라이브러리로, 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를 활용한 방법

Graphics2D를 활용한 방법은 뒤의 ImageIO를 이용한 방법에 비해 성능은 유사한데 이미지 도트화가 너무 심하게 진행되었기에 생략하였다.

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회째의 시도한 결과다.

  • marvin 라이브러리
  • ImageIO

두 방법 모두 시도 횟수가 증가할 수록 성능이 개선되던 중 7회 째부터는 평균적인 성능을 유지하는 것으로 나타났다.
성능은 marvin 라이브러리를 홣용할 경우 약 5~7% 정도 개선되는 것을 확인할 수 있었다. 해당 예시에서는 한 개의 이미지를 업로드한 결과이기 때문에, 만약 서비스가 이미지 업로드를 자주 하지 않는다면 프로젝트를 비대화하지 않는 ImageIO를 이용한 방식을, 이미지 업로드가 자주 발생한다면 marvin 라이브러리를 고려해 볼 수 있을 것으로 보인다.

profile
잘 읽어야 쓸 수 있고 잘 들어야 말할 수 있다

0개의 댓글