본문 바로가기
🌱 Spring

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

by iirin 2023. 11. 6.
이 글은 Secondhand 프로젝트를 하며 트러블 슈팅하고 학습한 내용을 정리한 글입니다.

 

시작하며

 

Secondhand에 글을 작성할 때는 최대 10장의 이미지를 업로드할 수 있습니다.

이 기능을 구현하고 나서 가장 마음에 걸렸던 부분이 있는데요, 바로 이 10장의 이미지가 하나의 스레드에서 동기적으로 처리된다는 것입니다. 꽤 고화질인 이미지를 10장 한번에 처리를 하면 3초정도 소요되기도 합니다 👀....

계속 마음에 걸렸던 코드라 이참에 리팩토링을 해보기로 했습니다.

 

 

개선 해보자

기존 로직 및 문제점 분석

원래 코드와 시퀀스 다이어그램을 먼저 공유하자면 다음과 같습니다.

    @Override
    public List<ItemDetailImage> uploadItemDetailImages(List<MultipartFile> request) throws ImageHostException {
        checkFilesSize(request);

        List<ItemDetailImage> images = new ArrayList<>();

        for (MultipartFile multipartFile : request) {
            ImageInfo imageInfo = uploadItemDetailImage(multipartFile);
            images.add(ItemDetailImage.create(imageInfo.getImageUrl()));
        }

        return images;
    }
    
    @Override
    public ImageInfo uploadItemDetailImage(MultipartFile file) throws ImageHostException {
        String imageUrl = "";

        try {
            imageUrl = upload(file, Directory.ITEM_DETAIL);
        } catch (IOException e) {
            throw new ImageHostException("물품 사진 업로드에 실패하였습니다.");
        }

        return ImageInfo.create(imageUrl);
    }
    
    public String upload(MultipartFile file, Directory directory) throws IOException, TooLargeImageException, NotValidImageTypeException {
        checkFileSize(file);
        checkFileType(file);

        String newFileKey = generateKey(file.getOriginalFilename(), directory.getPrefix());
        amazonS3.putObject(new PutObjectRequest(properties.getBucket(), newFileKey, file.getInputStream(), getMetadata(file)));
        return amazonS3.getUrl(properties.getBucket(), newFileKey).toString();
    }

스레드 하나에서 최대 10개의 이미지를 개미처럼 천천히 하나씩 처리하고 있습니다. 🐜🐜🐜🐜🐜🐜🐜🐜🐜🐜...

누가 이렇게 비효율적으로 짰나? 바로 접니다 허허허허....

 

그리고 이것을 모두 다 처리한 뒤에 섬네일을 처리하므로 S3 업로드로 트리거 발생하는 섬네일용 람다는 더 느리게 작업될 수 밖에 없었습니다.

 

테스트로 로컬에서 스레드 로그도 찍어보았는데요. 역시나 한개의 스레드로 직렬 처리하고 있었습니다. 깔끔한 이 로직..

 

DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: 232, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: 232, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: 232, image: 115064144.jpeg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 115064144.jpeg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: 232, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: 232, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg

 

 

마법의 어노테이션 @Async을 붙이면 어떻게 될까?

흔히들 동기 작업을 하기 위해서는 어노테이션 @Async를 붙여주곤 합니다. 그럼 해결 될까요?

 

위 코드에서 `upload()`에 @Async 를 붙이고 AsyncConfig 를 만들어줍니다. 그리고 기대하며 다시 테스트를 해보았지만...

여전히 동일한 스레드에서 동기로 실행됩니다. 심지어 스레드 그룹을 지정해주었음에도 해당 그룹에서 스레드를 가져오지도 않고 있습니다.

2023-11-05 22:18:39.177 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: main 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:18:41.300 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:18:41.364 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: main 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:18:41.443 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:18:41.448 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: main 232, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg
2023-11-05 22:18:42.105 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg
2023-11-05 22:18:42.113 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: main 232, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
2023-11-05 22:18:42.169 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
2023-11-05 22:18:42.170 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: main 232, image: 115064144.jpeg
2023-11-05 22:18:42.270 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 115064144.jpeg
2023-11-05 22:18:42.273 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: main 232, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
2023-11-05 22:18:42.360 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
2023-11-05 22:18:42.365 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: main 232, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg
2023-11-05 22:18:42.569 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg

(참고용으로 수정한 코드를 추가합니다)

@EnableAsync
@Configuration
public class AsyncConfig {

    @Bean(name = "imageUploadExecutor")
    public Executor imageUploadExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setThreadGroupName("imageUploadExecutor");
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(50);
        executor.initialize();
        return executor;
    }
}
    @Async("imageUploadExecutor")
    public String upload(MultipartFile file, Directory directory) throws IOException, TooLargeImageException, NotValidImageTypeException {
        checkFileSize(file);
        checkFileType(file);

        String newFileKey = generateKey(file.getOriginalFilename(), directory.getPrefix());
        amazonS3.putObject(new PutObjectRequest(properties.getBucket(), newFileKey, file.getInputStream(), getMetadata(file)));
        return amazonS3.getUrl(properties.getBucket(), newFileKey).toString();
    }

 

 

 

⭐️ @Async 를 사용할 때 주의할 점

문제점은 제가 그동안 Async 에 대해 잘못 사용하고 있었다는 것입니다.

@Async는 어떻게 동작할까요?

 

이 어노테이션은은 Spring의 큰 특징인 AOP 의 기능으로 구현됩니다.

메서드에 @Async 어노테이션을 붙이면 proxyTargetClass 를 기반으로 해당 객체의 프록시가 만들어집니다. 그 후 Spring은 context에 연결된 스레드 풀을 찾아 해당 메서드의 로직을 별도 스레드로 실행하려고 합니다. 만약 명명된 빈을 찾을 수 없으면 기본 SimpleAsyncTaskExecutor를 사용합니다. (👉 참고 링크)

Proxy란 Target을 감싸 Target의 요청을 대신 받아주는 Wrapping Object인데요. 호출자에서 타겟을 호출하게되면 타겟이 아닌 프록시가 호출되어, 타겟 메소드 실행전 선처리→타겟 메소드→후처리를 실행합니다. (👉 참고 링크)

 

이러한 동작 방식 때문에 구현시 주의할 점이 있는데요. 주의사항을 지키지 않으면 어노테이션이 무시되고 동기 방식으로 동작할 수 있습니다.

 

@EnableAsync 어노테이션

애플리케이션의 메인 설정 클래스나 configuration 파일에서 해당 어노테이션을 사용해주어야 합니다. 이 어노테이션으로 스프링의 @Async 어노테이션과 EJB 3.1 javax.ejb.Asynchronous 를 감지합니다. (👉 참고 링크)

 

Bean으로 관리되고 있어야 합니다.

@ComponentScan 어노테이션에 의해 스캔되거나 @Configuration 클래스 내부에 빈으로 정의되어야 합니다. Spring 에서 프록시를 생성하기 위해서는 Spring IoC 컨테이너에 의해 관리되는 Bean이어야 하기 때문입니다.

 

Private 메서드에는 동작하지 않습니다.

런타임에 프록시를 생성할 수 없으므로 동작하지 않습니다.

 

호출하는 메서드와 비동기 메서드가 같은 클래스에 있으면(즉, Self-invocation 이면) 안됩니다

이부분이 제가 실수했던 부분이었는데요.

 

The default is AdviceMode.PROXY. Please note that proxy mode allows for interception of calls through the proxy only. Local calls within the same class cannot get intercepted that way; an Async annotation on such a method within a local call will be ignored since Spring's interceptor does not even kick in for such a runtime scenario. For a more advanced mode of interception, consider switching this to AdviceMode.ASPECTJ.

 

프록시가 생성되더라도 같은 클래스 안에서 호출하기 때문에 프록시가 소용이 없게됩니다. 당연히 프록시의 선처리, 후처리 기능이 당연히 동작하지 않습니다.

이렇게 메서드를 호출하는 경우 스프링의 인터셉터가 작동하지 않기 때문입니다. 프록시를 우회하고 메서드를 직접 호출하게 되어 별도 Thread에서 동작하지 않아 Async 어노테이션은 무시됩니다.

(이미치 출처)

 

 

학습한 내용을 적용해보았습니다.

알고보니 @Async 어노테이션이 붙은 메서드를 내부 함수에서 호출해주었기 때문입니다.

클래스를 분리해주고, AsyncConfig도 다시 점검하여 수정해주었습니다.

 

아래는 따로 구현한 `ImageUploader` 클래스입니다.

 

@Service
@RequiredArgsConstructor
public class ImageUploader {

    private final AwsProperties properties;
    private final AmazonS3 amazonS3;

    @Async("imageUploadExecutor")
    public String upload(MultipartFile file, Directory directory) throws IOException {
        log.debug("Thread upload work start: {}, image: {}", Thread.currentThread().getId(), file.getOriginalFilename());

        String newFileKey = generateKey(file.getOriginalFilename(), directory.getPrefix());
        amazonS3.putObject(new PutObjectRequest(properties.getBucket(), newFileKey, file.getInputStream(), getMetadata(file)));
        log.debug("Thread work end: {}, image: {}", Thread.currentThread().getId(), file.getOriginalFilename());
        return amazonS3.getUrl(properties.getBucket(), newFileKey).toString();
    }

    private String generateKey(String originFileKey, String prefix) {
        return String.format("%s%s-%s", prefix, UUID.randomUUID(), originFileKey);
    }

    private ObjectMetadata getMetadata(MultipartFile file) {
        ObjectMetadata objectMetadata = new ObjectMetadata();
        objectMetadata.setContentLength(file.getSize());
        objectMetadata.setContentType(file.getContentType());
        return objectMetadata;
    }
}

 

 

이제 실행해보니 의도대로 스레드로 잘 쪼개져서 문제를 처리하고 있는데요. 속도도 매우 빨라졌고요.

2023-11-05 22:39:33.688 DEBUG 11379 --- [ploadExecutor-3] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 249, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg
2023-11-05 22:39:33.688 DEBUG 11379 --- [ploadExecutor-2] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 248, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:39:33.689 DEBUG 11379 --- [ploadExecutor-4] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 250, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
2023-11-05 22:39:33.688 DEBUG 11379 --- [ploadExecutor-1] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 247, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:39:33.688 DEBUG 11379 --- [ploadExecutor-7] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 253, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg
2023-11-05 22:39:33.688 DEBUG 11379 --- [ploadExecutor-6] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 252, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
2023-11-05 22:39:33.688 DEBUG 11379 --- [ploadExecutor-5] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 251, image: 115064144.jpeg
Hibernate: 
    insert 
    into
        item_contents
        (contents, detail_image_url, is_deleted) 
    values
        (?, ?, ?)
Hibernate: 
    insert 
    into
        item_counts
        (chat_counts, hits, is_deleted, like_counts) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        item
        (created_at, updated_at, category, item_contents_id, item_counts_id, is_deleted, price, region_id, seller_id, status, thumbnail_url, title) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2023-11-05 22:39:34.986 DEBUG 11379 --- [ploadExecutor-1] c.t.s.api.image.service.ImageUploader    : Thread work end: 247, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:39:34.986 DEBUG 11379 --- [ploadExecutor-5] c.t.s.api.image.service.ImageUploader    : Thread work end: 251, image: 115064144.jpeg
2023-11-05 22:39:34.986 DEBUG 11379 --- [ploadExecutor-6] c.t.s.api.image.service.ImageUploader    : Thread work end: 252, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
2023-11-05 22:39:34.986 DEBUG 11379 --- [ploadExecutor-4] c.t.s.api.image.service.ImageUploader    : Thread work end: 250, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
2023-11-05 22:39:34.986 DEBUG 11379 --- [ploadExecutor-2] c.t.s.api.image.service.ImageUploader    : Thread work end: 248, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:39:35.006 DEBUG 11379 --- [ploadExecutor-7] c.t.s.api.image.service.ImageUploader    : Thread work end: 253, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg
2023-11-05 22:39:35.117 DEBUG 11379 --- [ploadExecutor-3] c.t.s.api.image.service.ImageUploader    : Thread work end: 249, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg

 

근데 쎄함이 느껴집니다...😨..

쿼리가 왜 중간에 나오는거야? 그리고 왜 이렇게 빠른거야?? I/O작업인데 200ms 대가 가능한거야? 정말로...?

 

 

새로운 문제의 등장: 반환값이 있는 비동기 메서드 처리

역시나.. 쿼리가 중간에 나오는게 이상해서 DB를 확인해보니 역시나 null 파티가 났더라고요. null null 한 이미지 url들...

왜? 비동기로 처리하다보니 `upload()` 의 반환값인 url을 기다리지 않고 후다닥 다음 로직을 처리해버린 탓입니다.

(부족하지만 이해를 돕기위한 시퀀스 다이어그램..)

 

이럴 때는 비동기 처리의 반환값을 기다렸다가 처리할 수 있도록 해주어야 하는데요.

기억 저편에 있던 동기-비동기, 블락킹-넌블락킹을 꺼내와 키워드를 찾기 시작했습니다. 해당 개념은 지금 여기서 다루지 않으므로 이 링크를 참고해 주세요.

 

 

반환값이 있는 비동기 메서드

void 반환 유형을 사용하는 메서드의 경우, 간단하게 메서드를 비동기적으로 실행하도록 할 수 있습니다. 하지만 반환값이 있는 경우가 문제였는데요. 일반적으로 반환하면 저의 경험처럼 스택 메모리에서 pop되며 공중으로 흩날리게 되겠죠... 🥲

반환 유형의 경우 아래와같은 선택지가 있습니다.

 

Future

Future 를 사용하여 비동기 프로세스의 결과를 받아 호출했던 스레드에서 사용할 수 있습니다.

`get()` 을 사용하면 결과값이 반환될 때까지 블로킹하고 기다립니다. 주의할 점은 get() 메서드의 파라미터로 time out 시간을 정해주지 않으면 무한정 대기하게 된다는 것입니다.

public class FutureExample {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(1);
        Future<Integer> future = executorService.submit(() -> {
            Thread.sleep(1000);
            return 42;
        });

        Integer result = future.get();
        System.out.println("Result: " + result);

        executorService.shutdown();
    }
}

Future 내부적으로 스레드 세이프하게 구현되어 있어서 따로 syncronized를 작성해주지 않아도 됩니다.

 

 

CompletableFuture

Java8부터 도입되었습니다. Future 인터페이스와 함께 CompletionStage 인터페이스를 구현하였습니다.

단순히 완료를 기다리는 Future와는 달리, 콜백을 추가하거나 작업을 조합하여 사용하는 비동기 파이프라인을 만들거나 여러 CompletableFuture 들을 모아 모두 완료되면 다음 작업을 하거나 예외를 발생시키는 완료를 할 때, 사용할 수 있습니다.

  • 단순한 Future로 사용하는 방법 get())
  • 비동기 계산 결과 처리 (thenApply(), thenAccept(), thenRun())
  • 작업을 결합해서 사용하기 (thenCompose())
  • 여러 Future 병렬로 실행 (allOf())
  • 오류에 대한 처리 (completeExceptionally(), CompleteOnTimeout())

자세한 메서드는 여기를 참고했습니다. 👉 참고 링크

각 메서드에 대한 예시는 여기를 참고했습니다. 👉 참고 링크

 

 

ListenableFuture

호출 메서드에서 성공, 실패시 콜백할 함수를 `addCallback()`으로 추가하여 사용합니다.

public class ListenableFutureExample {
    public static void main(String[] args) {
        SettableListenableFuture<Integer> future = new SettableListenableFuture<>();
        future.addCallback(new ListenableFutureCallback<Integer>() {
            @Override
            public void onSuccess(Integer result) {
                System.out.println("Result: " + result);
            }

            @Override
            public void onFailure(Throwable ex) {
                System.err.println("Error: " + ex.getMessage());
            }
        });

        // 비동기 작업 완료 후 결과 설정
        new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            future.set(42);
        }).start();

        // 비동기 작업이 완료될 때까지 대기하지 않고 계속 진행
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

Spring framework 6.0부터 deprecate 되었습니다. (👉 참고 링크)

deprecate된 정확한 원인은 찾지 못했는데요. 관련해서 알고계시다면 댓글 부탁드립니다.

 

 

반환값을 받아 사용할 수 있도록 수정

위 선택지 중 반환값을 CompletableFurure로 정했습니다. Future보다 예외처리나 반복 작업을 모두 모아서 처리할 수 있기 때문입니다.

비동기처리할 메서드의 반환값을 CompletableFurure로 바꾸어 주고 호출 메서드에서 이를 처리할 수 있도록 수정해주었습니다.

    @Async("imageUploadExecutor")
    public CompletableFuture<String> upload(MultipartFile file, Directory directory) throws IOException, TooLargeImageException, NotValidImageTypeException {
        log.debug("Thread upload work start: {}, image: {}", Thread.currentThread().getThreadGroup().getName()+ " " + Thread.currentThread().getId(), file.getOriginalFilename());
        CompletableFuture<String> future = new CompletableFuture<>();

        String newFileKey = generateKey(file.getOriginalFilename(), directory.getPrefix());
        amazonS3.putObject(new PutObjectRequest(properties.getBucket(), newFileKey, file.getInputStream(), getMetadata(file)));
        log.debug("Thread work end: {}, image: {}", Thread.currentThread().getId(), file.getOriginalFilename());
        future.complete(amazonS3.getUrl(properties.getBucket(), newFileKey).toString());
        return future;
    }
    @Override
    public List<ItemDetailImage> uploadItemDetailImages(List<MultipartFile> request) throws ImageHostException {
        checkFilesCount(request);

        List<ItemDetailImage> images = new ArrayList<>();
        List<CompletableFuture<String>> uploadFutures = new ArrayList<>(); // 결과 future

        for (MultipartFile multipartFile : request) {
            CompletableFuture<String> upload = null;
            try {
                upload = imageUploader.upload(multipartFile, Directory.ITEM_DETAIL);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
            uploadFutures.add(upload);
        }
		
        uploadFutures.forEach(upload -> images.add(ItemDetailImage.create(
                upload.exceptionally(e -> {
                            log.error("이미지 업로드에 실패하였습니다.", e);
                            throw new CompletionException(e);
                        })
                        .join()))); // 완료된 반환값

        return images;
    }

그리고 실행 결과는 의도했던 대로 비동기로 진행되고 있음을 확인할 수 있었습니다.

2023-11-05 23:27:52.294 DEBUG 13264 --- [ploadExecutor-6] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 282, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 23:27:52.341 DEBUG 13264 --- [ploadExecutor-6] c.t.s.api.image.service.ImageUploader    : Thread work end: 282, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 23:27:52.342 DEBUG 13264 --- [ploadExecutor-5] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 281, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 23:27:52.342 DEBUG 13264 --- [loadExecutor-10] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 286, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg
2023-11-05 23:27:52.342 DEBUG 13264 --- [ploadExecutor-3] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 279, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
2023-11-05 23:27:52.342 DEBUG 13264 --- [ploadExecutor-8] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 284, image: 115064144.jpeg
2023-11-05 23:27:52.342 DEBUG 13264 --- [ploadExecutor-4] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 280, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
2023-11-05 23:27:52.344 DEBUG 13264 --- [ploadExecutor-2] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 278, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg
2023-11-05 23:27:52.414 DEBUG 13264 --- [ploadExecutor-4] c.t.s.api.image.service.ImageUploader    : Thread work end: 280, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
2023-11-05 23:27:52.419 DEBUG 13264 --- [ploadExecutor-8] c.t.s.api.image.service.ImageUploader    : Thread work end: 284, image: 115064144.jpeg
2023-11-05 23:27:52.440 DEBUG 13264 --- [ploadExecutor-3] c.t.s.api.image.service.ImageUploader    : Thread work end: 279, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
2023-11-05 23:27:52.451 DEBUG 13264 --- [ploadExecutor-5] c.t.s.api.image.service.ImageUploader    : Thread work end: 281, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 23:27:52.515 DEBUG 13264 --- [ploadExecutor-2] c.t.s.api.image.service.ImageUploader    : Thread work end: 278, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg
2023-11-05 23:27:52.815 DEBUG 13264 --- [loadExecutor-10] c.t.s.api.image.service.ImageUploader    : Thread work end: 286, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg
Hibernate: 
    insert 
    into
        item_contents
        (contents, detail_image_url, is_deleted) 
    values
        (?, ?, ?)
Hibernate: 
    insert 
    into
        item_counts
        (chat_counts, hits, is_deleted, like_counts) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        item
        (created_at, updated_at, category, item_contents_id, item_counts_id, is_deleted, price, region_id, seller_id, status, thumbnail_url, title) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

 

이 블로그에서 언급한 코드는 Secondhand 레포지토리에서 확인 가능합니다.

글에 대해 잘못된 부분이 있다면 댓글로 알려주시면 감사하겠습니다 😀


참고링크