Post

[SPRING] IMAGE 업로드 서비스 트러블 슈팅 기록일지

이미지 서비스 기획

기존의 서비스에서는 이미지 업로드시 프론트에게 이미지 링크 반환까지 너무 오랜 시간이 소요되었다.(5초 이상..) 이는 고객에게 불편함을 느끼게 하고 고객 이탈을 만드는 큰 이유가 된다. 이미지링크가 오래걸리는 이유는 아래와 같았다.

이미지

프론트에서 받은 이미지를 WAS 를 거쳐 S3 에 업로드 한다. 그런데 S3 에 업로드 하는 시간이 생각보다 길었다. 파일 용량이 커지면 커질수록 매우 길어졌다. 이 부분을 해결하기 위해 S3 업로드 과정을 비동기로 처리하기로 계획했다.

비동기로 처리하는겸, 이미지를 썸네일로 만들어 추가로 업로드 하기로 했다. 썸네일 이미지 없이 서비스를 운영하다보니 작은 이미지에도 원본 이미지가 사용되어 사용자에게 많은 데이터를 소모하게 되었고, 이미지가 많은 경우 이미지가 천천히 띄워지는 모습이 보였다.

변경 사항 요약

  1. S3 업로드 과정을 비동기로 처리
  2. 원본 사진뿐만 아니라 썸네일 사진도 업로드

이미지 업로드 로직

S3 에 이미지를 비동기로 업로드하는 로직은 다음과 같다.

  1. 반환될 이미지 링크를 만든다.
  2. 비동기로 이미지를 S3에 업로드한다.
  3. 프론트에 이미지 링크를 반환한다.

이미지링크는 WAS 에서 만들고 바로 프론트에 반환해주면 된다. 그 이미지링크 대로 S3 는 이미지를 저장해둘테니까 말이다.

사실 비동기로 업로드시 속도는 빨라지지만 안전성이 떨어진다. S3 에서 업로드를 실패해도 이미지링크는 이미 프론트에 반환되기 때문이다. 이 문제를 보완하기위해 업로드 실패시 파일을 남겨두고 스케줄러를 통해 재업로드를 수행했다.

구체적인 구현코드는 다음과 같다. MultipartFile List 를 프론트로부터 받는다. 리스트에 있는 MultipartFile 을 순회하면서 비동기로 업로드 로직을 호출한다. 썸네일이미지를 업로드할려면, MultipartFile 이 아닌 File 이 필요하다. 그래서 MultipartFile 을 File 로 변환한뒤 업로드를 수행했다.

ImageController.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    @Operation(summary = "s3 이미지 저장", description = "사용자가 올릴 이미지를 s3에 저장, 해당 url 을 반환 합니다.")
    @PostMapping(value = "/s3/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE)
    public ApiResponse<ImagesRes> saveS3Image(@ModelAttribute ImagesReq request) {

        List<String> imageUrls = request.getImages().stream()
                .map(i -> {
                    String fileName = imagesService.makeFileName();
                    imagesService.upload(i, fileName);
                    return imagesService.makeImageUrl(fileName);
                })
                .collect(Collectors.toList());

        ImagesRes imagesRes = new ImagesRes(imageUrls);

        return new ApiResponse<>(imagesRes);
    }

ImageService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    @Async
    public void upload(MultipartFile multipartFile, String name) {
 
        // MultipartFile -> File 변환
        File localFile = convertToFile(multipartFile, name);
        // 이미지 파일인지 검사
        checkFile(localFile);

        // 썸네일 업로드
        uploadThumbnail(localFile, name, LARGE);
        uploadThumbnail(localFile, name, MEDIUM);
        uploadThumbnail(localFile, name, SMALL);

        // 원본 업로드
        uploadToS3(localFile, name);
    }

이슈 발생 - FileNotFoundException

FileNotFoundException

이미지

분명히 MultipartFile 을 프론트로부터 받아서 이를 비동기로 업로드하는 서비스로직을 호출했으나, 서비스로직에서 그런 파일은 없다고 예외를 발생시킨다.

문제 원인

MultipartFile 은 임시로 파일이 생성되었다가 요청이 끝나면(메서드가 종료되면) 해당 파일은 삭제된다. /tmp/tomcat.8081.11656... 경로가 바로 이미지가 임시로 저장되는 폴더의 위치다. 업로드 로직은 비동기로 실행되기 때문에, 업로드 로직이 비동기로 수행될때 이미 ImageController 에 있는 saveS3Image() 메서드는 이미지 링크를 반환하고 메서드가 종료되어 MultipartFile 이 삭제된다. 그래서 비동기로 업로드할 이미지파일은 이미 삭제되어 있어서 파일을 찾지 못한다.

문제 해결

임시 파일이 아닌 물리적인 파일을 서버에 저장해두는 방법으로 해결할 수 있다. MultipartFile -> File 변환을 비동기로 처리하지 않고 먼저 동기로 처리후 수행한다. 변경된 실제 로직은 아래와 같다.

ImageController.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    @Operation(summary = "s3 이미지 저장", description = "사용자가 올릴 이미지를 s3에 저장, 해당 url 을 반환 합니다.")
    @PostMapping(value = "/s3/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE)
    public ApiResponse<ImagesRes> saveS3Image(@ModelAttribute ImagesReq request) {

        List<String> imageUrls = request.getImages().stream()
                .map(i -> {
                    String fileName = imagesService.makeFileName();
                    File file = imagesService.convertToFile(i, fileName); // MultipartFile -> File 로 저장
                    imagesService.checkFile(file); // 이미지 파일 인지 체크
                    imagesService.upload(file, fileName); // 비동기로 s3 에 업로드
                    return imagesService.makeImageUrl(fileName);
                })
                .collect(Collectors.toList());

        ImagesRes imagesRes = new ImagesRes(imageUrls);

        return new ApiResponse<>(imagesRes);
    }

ImageService.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    @Async
    public void upload(File localFile, String name) {

        // 원본 업로드
        uploadToS3(localFile, name + FILE_EXTENSION);

        // 썸네일 업로드
        uploadThumbnail(localFile, name, LARGE);
        uploadThumbnail(localFile, name, MEDIUM);
        uploadThumbnail(localFile, name, SMALL);

        // 물리 파일 삭제
        deleteFile(localFile);
    }

아쉬운점

MultipartFile -> File 변환을 통해 파일을 서버에 저장하고 비동기로 S3 업로드를 수행했다. 하지만 MultipartFile -> File 변환과정도 상당한 시간을 소요하지만 동기로 밖에 처리를 못하는게 아쉽다.

이슈 발생 - OutOfMemoryError

이미지

원본이미지를 썸네일 이미지로 변환하는데 메모리가 부족하다!

문제 원인

썸네일 작업이 생각보다 메모리를 많이 사용했다. 이미지 용량도 작으면, 썸네일 작업도 금방 끝날 줄 알았는데 썸네일 작업에서 중요한것은 이미지 용량이 아니었다. 썸네일 작업은 “해상도” 에 따라 메모리 사용량이 결정된다. 해상도는 이미지의 가로x세로 크기로 결정된다. 보통 해상도가 높으면 이미지 용량이 크지만, 이미지가 잘 압축되어 있다면 용량이 낮더라도 해상도는 높을 수 있다.

그리고 썸네일 작업은 3차원 배열로 이미지를 메모리에 띄우고 크기를 재조정 한다. 3차원 배열인 이유는 가로x세로x3(R,G,B 표현값) 이다. 그럼 메모리 사용량은 가로x세로x3 byte 가 나온다.

현재 아이폰15 pro max 의 경우 4800만 해상도를 지원하는 사진을 찍을 수 있다… 즉 48,000,000x3= 1,440,000,000 byte 로 mb 로 환산하면 대략 144 mb 이다. 그런데 우리가 배포하는 WAS 는 EC2 프리티어 버전인데, RAM 이 1GB 이다. Swap Memory 인 2GB 까지 추가하여 3GB 까지 사용할 수 있다.

Swap Memory? 작디작은 EC2 RAM 은 1GB 이기 때문에, 항상 메모리가 부족한다. 그래서 나온게 Swap Memory 로 디스크를 메모리처럼 사용할 수 있게 해준다. 물론 속도는 실제 메모리보다 느리다. 최대 2GB 까지 추가할 수 있다.

물론 썸네일작업에 사용되는 메모리 말고도 이것저것 사용하고 있겠지만, 최대 메모리가 3GB 나 있는데 OOM 이 터지는건 이상했다. 그래서 EC2 에서 사용 메모리량을 찍어봤다.

이미지

free -h 를 통해 메모리 사용량을 확인할 수 있었는데 기존에 1GB 메모리는 거의다 사용하고 있었지만 Swap Memory 는 300Mb 밖에 사용하지 않고 있다. 그래서 수상해서 JVM 의 메모리 설정을 찍어봤다.

이미지

java -XX:+PrintFlagsFinal -version | grep HeapSize 통해 메모리 설정을 찍어봤더니 최소메모리(MinHeapSize)가 8Mb, 최대메모리(MaxHeapSize) 가 256Mb 로 설정되어있다. 그래서 좀만 큰 이미지가 와도 OOM 이 터져버린것이다.

HeapSize 설정은 기본값으로 최소힙은 램의 1/64, 최대힙은 1/4 로 잡힌다. 근데 EC2 에서 기본 힙사이즈를 잡을때 스왑메모리를 고려하지 않고 기본메모리 1Gb 만 고려하는것 같다.

문제 해결

스왑메모리가 있는데 기본설정으로는 이를 활용하지 못하고 있으니 직접 최소힙과 최대힙 사이즈를 설정해 준다.

1
    java -Xms256m -Xmx1024m -jar $JAR_FILE 

최소힙은 256Mb, 최대힙은 1024Mb 로 넉넉하게 잡아준다.

이미지

free-h 를 찍어보면 Swap Memory 도 잘 사용하고 있는것을 확인할 수 있었다.

추가로 WAS 로직에서 해상도를 체크해 너무 고해상도면 예외를 발생하도록 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    // 이미지가 너무 고해상도 인지 체크
    public void checkFile(File file) {

        // 사전에 고해상도 이미지 차단
        // 이미지 메타데이터를 통해 해상도를 체크해, 이미지 전체를 메모리에 띄우지 않음
        try (ImageInputStream input = ImageIO.createImageInputStream(file)) {
            Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
            if (readers.hasNext()) {
                ImageReader reader = readers.next();
                reader.setInput(input, true);

                long width = reader.getWidth(0);
                long height = reader.getHeight(0);

                if (width * height > 64_000_000) { // 64_000_000 이면 썸네일 변환 작업시 64x3 mb 를 사용함
                    // 고해상도 이미지를 올릴시 에러 반환
                    throw new CustomException(ErrorCode.IMAGE_OVER_RESOLUTION);
                }
            }
        } catch (IOException e) {
            throw new CustomException(ErrorCode.INVALID_IMAGE_URL);
        }
    }  

나아갈 점

이미지 업로드 스레드 제한

이미지 업로드 작업은 비동기로 처리되어 별도의 스레드가 사용되어, 다수의 사용자 요청 발생시 서버의 스레드가 빠르게 소진될 수 있다. resilience4j 의 bulkhead 패턴을 사용하여 동시에 처리될 수 있는 작업 수를 제한하여 다른 비즈니스 로직에 영향을 주지 않도록 설정할 수 있다고 한다. 어떤곳은 이미지 업로드 서버를 따로 두는곳도 있다고 한다.

적절한 스레드 설정값 찾기

현재 비동기 스레드 설정값(코어수, 스레드풀, 큐)이 명확한 기준없이 설정되어있는데 EC2 프리티어에 맞는 설정 할 필요가 있다. 비동기를 사용한다면 한번쯤 고민해봐야 된다고 생각한다.

This post is licensed under CC BY 4.0 by the author.