스프링부트

이미지 업로드 비동기 처리

컴공코딩러 2024. 6. 19. 15:52

 

 

 

 

 

 

나의 미간지 프로젝트

무엇을 더 업그레이드 할수 있을까를 생각하고 글쓰기 기능을 개선하기로 생각했다.

 

그 중 이미지 업로드 하는 부분이 오래걸려 리팩토링 하려고 생각이 들었다.

 

문제점

글쓰기 API가 너무 오래걸린다. (이미지 resize 함수 영향)

 

결론

함수 비동기 처리 하여 30s -> 1s 로 성능 개선 완료

 

개선 전

 

글쓰기 -> 함수의 모든 동작이 완료 되고 데이터 return ( resize 함수에 종료 시간을 기다려야 해서 시간이 오래걸림 ) 30.31S

 

개선 후

1.372S

글쓰기 -> 이미지 resize 함수를 비동기로 처리 , 이미지 resize함수는 비동기 처리 후 완료 데이터 return

 

(만약 이미지 reszie 전 게시물을 본다면 이미지 업로드 중이라는 사진을 보게 됨)

 

이미지 resize 되면 DB에 업로드된 이미지 url update 시켜줌

 

개선 전

코드

@Operation(summary = "게시글 작성 API")
    @PostMapping("/post/write")
    public ResponseEntity<?> writePost(UserPostRequestDto userPost, HttpServletRequest httpServletRequest) throws IOException {
        String token = jwtTokenProvider.resolveToken(httpServletRequest);
        String nickname = getUserNicknameFromJwtToken(token);
        if (userPost.getContent().length()<2 || userPost.getContent().length()>500)
        {
            return apiResponse.fail("내용은 2~500자 사이로 입력해주세요.");
        }
        String detailImageUrl = gcsService.uploadDetailImage(userPost.getImageFile());
        String thumbnailImageUrl = gcsService.uploadThumbnailImage(userPost.getImageFile());
        UserPost post = UserPost.builder()
                .content(userPost.getContent())
                .detailImageUrl(detailImageUrl)
                .thumbnailImageUrl(thumbnailImageUrl)
                .lat(userPost.getLat())
                .lng(userPost.getLng())
                .tags(userPost.getTags())
                .profileImage(profileImage)
                .tagsNum(convertTags(userPost.getTags()))
                .address_name(userPost.getAddress_name())
                .music_id(userPost.getMusic_id())
                .build();
        Optional<User> user = userService.findUser(nickname);
        if (!user.isPresent()){
            return apiResponse.fail("유저 에러", HttpStatus.BAD_REQUEST);
        }
        User user1 = user.get();
        user1.addPost(post);
        post.setUser(user.get());
        userPostService.writePost(post);
        return apiResponse.success("성공",HttpStatus.ACCEPTED);
    }

 

일반적인 글쓰기 API 이다

 

 

        String detailImageUrl = gcsService.uploadDetailImage(userPost.getImageFile());
        String thumbnailImageUrl = gcsService.uploadThumbnailImage(userPost.getImageFile());

 

이 두가지 함수가 이미지를 resize 시키는 함수이다.

(이미지를 resize 후 이미지 용량을 줄여 스토리지 비용을 낮추기 위해)

 

상세페이지 이미지와 썸네일 이미지 2가지를 생성해서 저장하는 형식으로 했다.

( 한개 이미지만 사용해도 되지만 썸네일 이미지 사용으로 사용자는 로딩 속도 감소)

 

    public String uploadDetailImage(MultipartFile multipartFile) throws IOException {
        // !!!!!!!!!!!이미지 업로드 관련 부분!!!!!!!!!!!!!!!
        String ext = multipartFile.getContentType(); // 파일의 형식 ex) JPG
        MultipartFile detailImage = postImgFileService.resize(multipartFile, multipartFile.getContentType().substring(6), 350, 467);
        String uuid = UUID.randomUUID().toString(); // Google Cloud Storage에 저장될 파일 이름
        // Cloud에 이미지 업로드
        BlobInfo blobInfo = storage.create(
                BlobInfo.newBuilder(bucketName, uuid)
                        .setContentType(ext)
                        .build(),
                detailImage.getInputStream()
        );

        return bucketUrl+uuid;
    }

 

이미지 업로드 함수이고 정해진 이미지 사이즈인 350*467로 resize 한 후 저장한다.

 

하지만 성능이 너무 오래걸린다는 단점을 발견했다. (클라우드 서버의 성능이 느려 가끔 10초이상 걸리는 상황이 발생)

 

 

    public MultipartFile resize(MultipartFile multipartFile,String fileFormatName,int width, int height)
            throws IOException {

        BufferedImage inputImage = ImageIO.read(multipartFile.getInputStream());  // 받은 이미지 읽기

        BufferedImage outputImage = new BufferedImage(width, height, inputImage.getType());
        int originWidth = inputImage.getWidth();
        int originHeight = inputImage.getHeight();

        // origin 이미지가 resizing될 사이즈보다 작을 경우 resizing 작업 안 함
        if (originWidth < width && originHeight < height)
            return multipartFile;
        // 입력받은 리사이즈 길이와 높이

        Graphics2D graphics2D = outputImage.createGraphics();
        graphics2D.drawImage(inputImage, 0, 0, width, height, null); // 그리기
        graphics2D.dispose(); // 자원해제
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ImageIO.write(outputImage, fileFormatName, baos);
        inputImage.flush();
        outputImage.flush();
        baos.flush();

        return new MockMultipartFile(multipartFile.getName(), baos.toByteArray());
    }

이건 이미지를 resize하는 함수이다.

 

여러가지 resize 하는 함수를 비교하다가 Graphics2D 사용하였다.

 

https://github.com/mi-gan-zi/BE-MiGanZi/issues/86

 

이미지 resize 속도개선 · Issue #86 · mi-gan-zi/BE-MiGanZi

변경 사항 이전 기능 Image.SCALE_DEFAULT 현재 기능 Image.SCALE_FAST 참고 사항 Graphics2D 사용 Image.SCALE_AREA_AVERAGING //Area Averaging 이미지 스케일링 알고리즘 사용 Image.SCALE_DEFAULT //기본 이미지 스케일링 알고

github.com

나름 함수안에서 개선 하려고 코드를 변경한 적이 있지만 크게 개선이 되진 않았다.

 

그래서 생각한것이

 

어떻게 해결할수 있을까?

이미지 resize 함수를 따로 처리하면 되겠다 라는 결론이 나와서 여러가지 생각을 해봤다.

첫번째 생각

서버를 한개 더 만들어서 성능 개선 <- 함수 하나 처리 하자고 서버 추가는 비용에서 비효율적인것 같음

 

두번째 생각

업로드를 비동기로 처리하자! <- 라는 결론이 나옴

 

그리고 그 동안은 

라는 이미지를 통해 업로드 중이라는 걸 사용자가 볼수있도록 하였다.

 

개선 후

코드

    @Operation(summary = "게시글 작성 API")
    @PostMapping("/write")
    public ResponseEntity<?> writePost(UserPostRequestDto userPost, HttpServletRequest httpServletRequest) {
        String token = jwtTokenProvider.resolveToken(httpServletRequest);
        String nickname = getUserNicknameFromJwtToken(token);
        if (userPost.getContent().length() < 2 || userPost.getContent().length() > 500) {
            return apiResponse.fail("내용은 2~500자 사이로 입력해주세요.");
        }

        // 기본 이미지 URL 설정
        String uploadingImageUrl = bucketUrl+"uploading.png";

        UserPost post = UserPost.builder()
                .content(userPost.getContent())
                .detailImageUrl(uploadingImageUrl)
                .thumbnailImageUrl(uploadingImageUrl)
                .lat(userPost.getLat())
                .lng(userPost.getLng())
                .tags(userPost.getTags())
                .profileImage(profileImage)
                .tagsNum(convertTags(userPost.getTags()))
                .address_name(userPost.getAddress_name())
                .music_id(userPost.getMusic_id())
                .build();
        Optional<User> user = userService.findUser(nickname);
        if (!user.isPresent()) {
            return apiResponse.fail("유저 에러", HttpStatus.BAD_REQUEST);
        }
        User user1 = user.get();
        user1.addPost(post);
        post.setUser(user.get());
        userPostService.writePost(post);

        // 비동기로 이미지 리사이즈 작업 수행
        Future<String> detailImageFuture = imageService.resizeAndUploadDetailImage(userPost.getImageFile());
        Future<String> thumbnailImageFuture = imageService.resizeAndUploadThumbnailImage(userPost.getImageFile());

        // 리사이즈된 이미지 URL로 업데이트
        CompletableFuture.runAsync(() -> {
            try {
                String detailImageUrl = detailImageFuture.get();
                String thumbnailImageUrl = thumbnailImageFuture.get();

                post.setDetailImageUrl(detailImageUrl);
                post.setThumbnailImageUrl(thumbnailImageUrl);
                userPostService.writePost(post); // DB 업데이트

            } catch (InterruptedException | ExecutionException e) {
                e.printStackTrace();
            }
        });

        return apiResponse.success("성공", HttpStatus.ACCEPTED);
    }

 

비동기 처리는 리턴값과 예외 처리가 가능하고 중간 처리과정을 확인할수 있는

 

Future 를 사용 ->   (Runnable 은 두개 모두 불가능 하여 제외)

 

 

결론적으로 최악에선 30s 걸리던 처리시간을 1s로 단축할수 있게 되었다.

 

 

이미지를 보면 리사이즈 전엔 업로드 중입니다 이미지 url 이 들어가있고

 

업로드 후 변경된 이미지 url으로 삽입되어있다.

 

덕분에 

 

비동기 처리 방법과 효율적으로 성능 개선하는 법을 배웠고

 

Runnable

 

Callable

 

Future

 

세가지 비동기 처리 방법의 차이점도 정확히 알게 되었다.