본문 바로가기

백/spring boot

이미지 업로드 비동기 처리

1. AsyncConfig 

우선 AsyncConfig 클래스부터 만들어주어야 한다.

이는 Spring에서 비동기 작업을 설정하기 위한 설정 클래스로, @Async 메서드를 어떻게 실행할지 설정하는 것이 AsyncConfig의 역할이다.  주로 비동기 작업을 위한 스레드 풀을 설정하고 관리하는데 사용된다.

 

 Thread Pool 의 작동방식

 1) 초기에 Core Pool Size만큼 스레드가 생성되고, 작업들이 이 스레드에서 처리된다.

 2) CorePoolSize만큼의 스레드가 모두 작업중이면, 새로 들어온 작업은 Work Queue에 대기하게 된다.

 3) Work Queue는 스레드가 비게 되면 대기 중인 작업을 꺼내서 처리한다.

 4) 만약 Work Queue가 가득 찼다면, Thread Pool은 추가적인 스레드를 생성하여 작업을 처리한다. 이때, 스레드는 최대 MaxPoolSize까지만 생성된다. 

 5) Queue도 가득 차고, MaxPoolSize에 도달하면 더이상 작업을 처리할 수 없으므로, 추가 작업은 RejectedExecutionHandler에 의해 거부되거나 다른 처리 방식이 적용된다.

 

  • @EnableAsync : @Async 어노테이션을 감지하는 역할
  • Core Pool Size: 기본적으로 생성해두고 실행 대기하는 thread의 개수를 설정  (CPU 코어수만큼 설정)
  • Max Pool Size : Queue가 가득 찰 경우 생성할 수 있는 최대 스레드 수로,이 값을 초과하면 요청이 처리되지 못하고 거부 정책이 적용된다.  (Core Pool Size *2 만큼 설정)
  • Queue Capacity: 대기 중인 작업을 저장할 수 있는 큐의 크기로, 스레드가 부족한 경우 작업을 큐에 대기시켜 나중에 처리할 수 있게 한다. 즉 core pool size 개수를 넘어서는 task가 들어왔을 때 queue에 해당 task가 쌓인다.  (50~500)

컨트롤러나 서비스 메서드에서 @Async("ImageUploadExecutor")를 붙인 메서드를 호출하면, 해당 작업은 별도의

 이미지 업로드 작업 전용 스레드 풀에서 비동기적으로 실행된다. -> 메인 스레드와 독립적으로 실행됨

@EnableAsync
@Configuration
public class AsyncConfig {
    @Bean(name="ImageUploadExecutor")
    public Executor imageUploadExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(9); 
        executor.setMaxPoolSize(18); 
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("ImageUpload-");
        executor.initialize();
        return executor;
    }
}

https://velog.io/@vanillacake369/Async-Size-%EC%84%A4%EC%A0%95-%EA%B8%B0%EC%A4%80%EC%97%90-%EB%8C%80%ED%95%B4-%EA%B3%A0%EB%AF%BC%ED%95%B4%EB%B3%B4%EC%9E%90-feat.ThreadPoolQueue

 

ThreadPool 설정 기준에 대해 고민해보자 (feat.@Async)

올바른 스레드 풀 설정값을 찾음으로써 극강의 효율성을 챙길 수 있기 때문이다. 여기서 여러 의문점이 제기되었다. 우리는 과연 스레드에 대해서 알고 사용하는 것인가? 스레드 풀에 대한 기본

velog.io

 

 

2. @Async 이용해 비동기 함수 작성하기

@Async 주의 사항

⚠️ @Async는 AOP기반으로 작동되기 때문에 아래와 같은 경우에 비동기 처리가 되지 않아 유의해야 한다!

1. Private 메소드에서는 @Async가 동작하지 않는다.

  • Spring AOP는 프록시 기반이다. 따라서 프록시 객체가 실제 메소드를 가로채야 하는데 private 메서드는 외부에서 호출될 수 없으므로 프록시가 이를 가로채지 못한다. 따라서 @Async가 적용된 메소드는 반드시 public이어야 한다.

2. 동일 클래스 내부에서 호출하는 메소드에서는 작동하지 않는다.

  • Spring에서 프록시는 클래스의 메소드 호출을 가로채서 추가적인 기능을 제공하는 역할을 한다. 이러한 프록시가 올바르게 동작하기 위해서는 해당 메소드가 외부에서 호출되어야 한다. 내부 호출은 프록시가 아닌, 실제 객체(자기 자신)의 메소드를 직접 호출하게 돼서 프록시가 개입하지 않아 프록시가 제공하는 기능(@Async)가 적용되지 않는다. 따라서 프록시는 외부에서 자기 자신을 호출해야만 그 기능이 제대로 동작한다.
@Service
public class MyService {

    @Async
    public void asyncMethod() {
        // 비동기 작업
    }
}
@Service
public class AnotherService {

    private final MyService myService;

    public AnotherService(MyService myService) {
        this.myService = myService;
    }

    public void callAsyncMethod() {
        myService.asyncMethod(); // 외부에서 호출되므로 프록시가 가로채서 비동기 처리를 수행
    }
}

-> MyService 객체가 프록시로 감싸져 있어 프록시가 호출을 가로채고 비동기 작업을 백그라운드에서 실행한다.

 

3. Bean으로 관리되고 있지 않은 경우

  • Spring에서 @Async 어노테이션을 사용하려면 해당 클래스는 반드시 Spring 컨테이너에서 관리되는 Bean이어야 한다. Bean으로 관리되지 않으면 프록시 객체가 생성되지 않으므로 @Async가 적용되지 않는다.
@Service
public class MyService {

    @Async
    public void asyncMethod() {
        // 비동기 처리 가능
    }
}

-> @Service 어노테이션과 같은 어노테이션을 이용하여 해당 클래스를 Spring Bean으로 등록해야 한다.

 

이 원칙을 바탕으로 코드를 바꿔보자!

<비동기 처리 전 기존 코드>

@Service
@RequiredArgsConstructor
@Transactional(readOnly=true)
public class PostImageService {

    private final PostImageRepository postImageRepository;
    private final AmazonS3 amazonS3;

    @Value("${cloud.aws.s3.bucketName}")
    private String bucketName;

    @Value("${cloud.aws.s3.bucketUrl}")
    private String bucketUrl;


    @Transactional
    public List<PostImage> changeToPostImage(List<MultipartFile> images, Post post) {

        if(images.isEmpty()){ //@ModelAttribute로 프론트한테 받을 시 List<MultipartFile>가 누락되어 있으면 스프링이 자동으로 빈 리스트로 처리한다.
            return new ArrayList<>();
        }
        List<PostImage> newImages= images.stream()
                .map(image -> {
                    String url=saveImage(image);

                    return PostImage.builder()
                            .postImageurl(url)
                            .originalFileName(image.getOriginalFilename())
                            .post(post)  //연관관계의 주인인 postImage를 post와 연관관계를 설정해줘야 postImage에 post의 id가 외래키로 제대로 저장됨
                            .build();

                })
                .toList();

        return newImages;

    }

    // 이미지파일 저장하고 url 반환
    private String saveImage(MultipartFile image) {
        if(image.isEmpty()){
            return null;
        }
        //확장자 명이 올바른지 확인 (파일 확장자가 jpg, jpeg, png, gif 중에 속하는지)
        validateFileExtension(image.getOriginalFilename());

        //파일 이름에 uuid를 붙여 unique하게 만들어줌
        String filename= UUID.randomUUID().toString()+"-"+image.getOriginalFilename();
        //String encodedFileName= URLEncoder.encode(filename, StandardCharsets.UTF_8);

        try{
            ObjectMetadata metadata=getObjectMetaData(image);

            PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, filename, image.getInputStream(), metadata).clone().withCannedAcl(
                    CannedAccessControlList.PublicRead);
            amazonS3.putObject(putObjectRequest);  //bucket에 저장된 파일의 url 경로 반환

        } catch (IOException e){
            throw new RuntimeException("이미지를 s3에 업로드 하는 중에 문제 발생", e);
        }

        return amazonS3.getUrl(bucketName, filename).toString();

        //return "Temp post image url";
    }
    private void validateFileExtension(String filename) {
        if(filename==null || filename.isEmpty()){
            throw new BadRequestException(ExceptionCode.NO_FILENAME);
        }

        int lastDotIndex=filename.lastIndexOf(".");
        String extension=filename.substring(lastDotIndex+1);

        List<String> extensionList= Arrays.asList("jpg", "jpeg", "png", "gif");

        if(!extensionList.contains(extension)){
            throw new BadRequestException(ExceptionCode.INVALID_EXTENSION);
        }
    }
    private ObjectMetadata getObjectMetaData(MultipartFile image) {
        ObjectMetadata metadata=new ObjectMetadata();
        metadata.setContentLength(image.getSize());
        metadata.setContentType(image.getContentType());

        return metadata;
    }

비동기 처리해야하는 saveImage 메소드와, 이 메소드를 호출하는 changeToPostImage 메소드가 같은 클래스 내에 위치하여 2번 원칙 위반한다. + saveImage가 private 메소드라 1번 원칙도 위반한다.

 

<비동기 처리를 위한 수정된 코드>

@Service
@RequiredArgsConstructor
@Transactional(readOnly=true)
public class PostImageService {

    private final PostImageRepository postImageRepository;
    private final AmazonS3 amazonS3;
    private final S3ImageService s3ImageService;

    @Value("${cloud.aws.s3.bucketName}")
    private String bucketName;

    @Value("${cloud.aws.s3.bucketUrl}")
    private String bucketUrl;


    @Transactional
    public List<PostImage> changeToPostImage(List<MultipartFile> images, Post post) {

        if(images.isEmpty()){ //@ModelAttribute로 프론트한테 받을 시 List<MultipartFile>가 누락되어 있으면 스프링이 자동으로 빈 리스트로 처리한다.
            return new ArrayList<>();
        }
        List<PostImage> newImages= images.stream()
                .map(image -> {
                    String url=s3ImageService.saveImage(image);

                    return PostImage.builder()
                            .postImageurl(url)
                            .originalFileName(image.getOriginalFilename())
                            .post(post)  //연관관계의 주인인 postImage를 post와 연관관계를 설정해줘야 postImage에 post의 id가 외래키로 제대로 저장됨
                            .build();

                })
                .toList();

        return newImages;

    }
@Service
@RequiredArgsConstructor
public class S3ImageService {
    private final AmazonS3 amazonS3;

    @Value("${cloud.aws.s3.bucketName}")
    private String bucketName;

    // 이미지파일 저장하고 url 반환
    @Async("ImageUploadExecutor")
    public String saveImage(MultipartFile image) {
        if (image.isEmpty()) {
            return null;
        }
        //확장자 명이 올바른지 확인 (파일 확장자가 jpg, jpeg, png, gif 중에 속하는지)
        validateFileExtension(image.getOriginalFilename());

        //파일 이름에 uuid를 붙여 unique하게 만들어줌
        String filename = UUID.randomUUID().toString() + "-" + image.getOriginalFilename();
        //String encodedFileName= URLEncoder.encode(filename, StandardCharsets.UTF_8);

        try {
            ObjectMetadata metadata = getObjectMetaData(image);

            PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, filename, image.getInputStream(), metadata).clone().withCannedAcl(
                    CannedAccessControlList.PublicRead);
            amazonS3.putObject(putObjectRequest);  //bucket에 저장된 파일의 url 경로 반환

        } catch (IOException e) {
            throw new RuntimeException("이미지를 s3에 업로드 하는 중에 문제 발생", e);
        }

        return amazonS3.getUrl(bucketName, filename).toString();

        //return "Temp post image url";
    }

    private void validateFileExtension(String filename) {
        if (filename == null || filename.isEmpty()) {
            throw new BadRequestException(ExceptionCode.NO_FILENAME);
        }

        int lastDotIndex = filename.lastIndexOf(".");
        String extension = filename.substring(lastDotIndex + 1);

        List<String> extensionList = Arrays.asList("jpg", "jpeg", "png", "gif");

        if (!extensionList.contains(extension)) {
            throw new BadRequestException(ExceptionCode.INVALID_EXTENSION);
        }
    }

    private ObjectMetadata getObjectMetaData(MultipartFile image) {
        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentLength(image.getSize());
        metadata.setContentType(image.getContentType());

        return metadata;
    }
}
  • changeToPostImage 메소드가 선언된 PostImageService 클래스로부터 save메소드를 분리하여 S3ImageService 클래스에 선언함으로써 2번 원칙을 지켰다. 그 후 save 메소드에 @Async를 달아주어 비동기 처리하도록 했다.
  • 비동기 처리 원리 : changeToPostImage 메서드는 for 문을 통해 각 이미지에 대해 saveImage 메서드를 호출한다. saveImage 메서드에는 @Async("imageUploadExecutor") 어노테이션이 붙어 있어, 해당 메서드는 비동기적으로 실행된다. 이로 인해 saveImage는 호출되자마자 즉시 반환되며 다음 이미지에 대해 곧바로 saveImage가 호출된다. saveImage 작업은 백그라운드의 별도 스레드에서 비동기적으로 처리되기에, 메인 스레드의 흐름이 차단되지 않고 다른 작업을 계속 처리할 수 있다.

 

이렇게까지만 다음과 같은 오류가 뜨게 된다.

java.lang.IllegalArgumentException: Invalid return type for async method (only Future and void supported): class java.lang.String] with root cause

-> @Async는 return value가 void인 경우만 적용 가능하다. 따라서 String을 비롯한 return 타입이 필요한 경우엔 CompletableFuture를 사용해야 한다. 

 

따라서 saveImage 메소드 리턴타입을 CompletableFuture로 수정해야 한다!! 

 

<S3ImageService>

@Async("ImageUploadExecutor") //지정한 쓰레드 풀에서 실행되도록 설정
    public CompletableFuture<String> saveImage(MultipartFile image) {
        return CompletableFuture.supplyAsync(()->{
            if (image.isEmpty()) {
                return null;
            }
            //확장자 명이 올바른지 확인 (파일 확장자가 jpg, jpeg, png, gif 중에 속하는지)
            validateFileExtension(image.getOriginalFilename());

            //파일 이름에 uuid를 붙여 unique하게 만들어줌
            String filename = UUID.randomUUID().toString() + "-" + image.getOriginalFilename();
            //String encodedFileName= URLEncoder.encode(filename, StandardCharsets.UTF_8);

            try {
                ObjectMetadata metadata = getObjectMetaData(image);

                PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, filename, image.getInputStream(), metadata).clone().withCannedAcl(
                        CannedAccessControlList.PublicRead);
                amazonS3.putObject(putObjectRequest);  //bucket에 저장된 파일의 url 경로 반환

            } catch (IOException e) {
                throw new RuntimeException("이미지를 s3에 업로드 하는 중에 문제 발생", e);
            }

            return amazonS3.getUrl(bucketName, filename).toString();

        }).exceptionally(ex->{
            // 비동기 작업 중 예외 발생한 경우를 처리
            throw new RuntimeException("S3 이미지 업로드 실패", ex);
        });

    }
  • 반환타입을 CompletableFuture로 바꾼다.
  • supplyAsync : supplyAsync 내부에 있는 코드를 별도의 스레드에서 실행시킴으로써, 파일을 s3에 업로드 하는 작업을 별도의 스레드에서 비동기적으로 처리한다.
  • exceptionally : 비동기 작업 중에 발생하는 예외를 처리하기 위해
  • @Async("ImageUploadExecutor") : 내가 정한 ImageUploadExecutor라는 스레드 풀에서 작동할 수 있게 함

 

<PostImage Service>

 @Transactional
    public List<PostImage> changeToPostImage(List<MultipartFile> images, Post post) {

        if(images.isEmpty()){ //@ModelAttribute로 프론트한테 받을 시 List<MultipartFile>가 누락되어 있으면 스프링이 자동으로 빈 리스트로 처리한다.
            return new ArrayList<>();
        }

        // 비동기적으로 이미지 업로드 작업 수행
        List<CompletableFuture<PostImage>> futures= images.stream()
                .map(image -> s3ImageService.saveImage(image) //비동기 이미지 업로드
                
                        .thenApply(url->PostImage.builder() //업로드 완료되고나서 실행됨
                                .postImageurl(url)
                                .originalFileName(image.getOriginalFilename())
                                .post(post)  //연관관계의 주인인 postImage를 post와 연관관계를 설정해줘야 postImage에 post의 id가 외래키로 제대로 저장됨
                                .build()))
                .toList();

        // 모든 업로드 작업이 완료될 때까지 기다리고 실행
        CompletableFuture<List<PostImage>> allImagesFuture=CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
                .thenApply(v->futures.stream()
                        .map(CompletableFuture::join)
                        .toList());

        return allImagesFuture.join();


    }
                • 흐름 : 비동기적으로 여러 이미지를 S3에 업로드하고, 업로드가 완료된 후 해당 이미지 URL을 이용해 PostImage 객체를 생성해 futures 리스트에 담는다. 이후 List<CompletableFuture<PostImage>>타입인 futures를 List<PostImage> 타입으로 변환하여 반환한다.
                • 하나의 이미지가 업로드될 때마다 PostImage 객체가 생성되고 futures 리스트에 담긴다. 즉, 이미지가 업로드되는 즉시 PostImage 객체가 비동기적으로 생성되며, 각각의 CompletableFuture<PostImage>가 futures 리스트에 추가된다.

 

                • Q. SaveImage 메소드는 CompletableFuture<String> 타입을 반환하는데 어떻게 url을 String타입으로 바로 쓸 수 있나?
                  • CompletableFuture<String> : 비동기적으로 실행되는 작업을  표현하는 객체이고, 작업의 결과를 CompletableFuture가 기다리고 있는 상태이다. 즉 작업이 아직 완료되지 않았다면 CompletableFuture<String>은 작업이 완료될 때까지 기다리는 역할을 한다. 그러다가 비동기 작업이 끝나면, CompletableFuture<String>은 그 결과로 String 값을 반환한다.
                  • thenApply() : CompletableFuture완료된 후그 결과를 가지고 추가적인 작업을 처리할 수 있게 하는 메서드이다. 따라서 비동기 작업이 완료된 상태이므로 CompletableFuture<String>에서 String 값을 꺼내서 PostImage 객체 생성 같은 이후 작업을 할 수 있다.
                • Q. url String은 join 없이 바로 꺼내서 쓸 수 있는데 CompletableFuture<List<PostImage>>는 왜 바로 List<PostImage>를 꺼내서 못 쓰고 join이 필요할까?
                  • 비동기 작업은 독립적으로 실행되기 때문에 각 작업이 언제 완료되는지 알 수 없다. 따라서 List<PostImage>와 같이 작업 완료 결과를 List로 모으고 싶다면, 각 작업의 결과를 수동으로 수집해야 한다. 따라서 join을 이용해 작업의 결과를 명시적으로 가져오는 작업이 필요하다. 
                  • CompletableFuture.allOf() : 여러 개의 CompletableFuture를 입력으로 받아 모든 작업이 완료될 때까지 기다리는 메서드. 작업이 완료되었다는 사실만 알릴뿐, 작업 결과는 반환하지 않는다. 또한 이 메소드는 여러 개의 CompletableFuture배열 형태로 전달받아야 하므로 리스트 형태인 futures를 array로 형변환 해야 한다.
                  • CompletableFuture<PostImage>에서 join()을 통해 비동기 작업의 결과로 PostImage 객체를 추출하여 리스트로 변환하고 이를 반환하게 된다.
                    • join() : 비동기 작업이 완료될 때까지 기다렸다가 결과를 반환해주며, 완료되면 CompletableFuture 안에 있는 값을 꺼내 쓸 수 있다

 

 

 

S3 업로드를 비동기로 처리하고 싶어요✊ : 반환이 있는 @Async를 사용할 때 주의할 것들....

이 글은 Secondhand 프로젝트를 하며 트러블 슈팅하고 학습한 내용을 정리한 글입니다. 시작하며 Secondhand에 글을 작성할 때는 최대 10장의 이미지를 업로드할 수 있습니다. 이 기능을 구현하고 나서

new-pow.tistory.com

https://velog.io/@jinny-l/spring-s3-async-image-upload

 

[Spring + S3] 비동기로 이미지 업로드 속도 개선

💬 들어가며 이전에 중고 거래 플랫폼 프로젝트를 하면서 동기로 처리하고 있던 이미지 업로드 속도를 개선하기 위해 비동기로 개선했던 적이 있는데, 취준하느라 미루었던 기록을 이제 작성

velog.io

 

 

Spring에서 비동기에 대한 테스트 하는 방법

countdownlatch랑 synctaskexecutor

          •  
  •