티스토리 뷰

백오피스의 신규기능을 추가해달라는 운영팀의 요구사항이 들어와서 해당기능을 구현했다. 해당 기능은 상세페이지에서 해당 내용과 관련된 파일들을 S3에 업로드하는 기능이었는데 파일의 용량은 적었지만 갯수가 최대 15개까지 업로드가 되야했다.

 

문제는 파일을 업로드 하고 업로드된 파일을 서버에 전송할 때 서버에서 처리하는 시간이 2 ~ 2.5초 내외로 걸리다 보니 바로 응답이 오지 않아 운영팀에서는 로딩바를 추가해달라고 요청이 들어왔다.

 

하지만 나는 백엔드 개발자다보니 로딩바를 추가하는 것 보다 서버에서 처리속도를 개선하고 싶어서 어떻게 하면 빨리 처리될 수 있을까 고민을 하다가 비동기 방식의 멀티쓰레드를 이용해서 처리하면 아무래도 하나의 쓰레드로 모든 요청을 처리하는 것 보단 시간이 오래걸리는 작업들은 별도 쓰레드로 작업을 위임해서 처리하는게 더 빠를 것 같아서 Spring에서 제공해주는 ThreadPoolTaskExecutor를 이용해서 처리하겠다고 생각했다.

 

Controller

import com.example.demo.service.AmazonS3Service;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class DemoController {
    private final AmazonS3Service amazonS3Service;

    @PostMapping
    public void upload(List<MultipartFile> multipartFiles) {
        amazonS3Service.uploadS3Objects(multipartFiles);
    }
}

컨트롤러에서는 MultipartFile을 List로 받아서 uploadS3Objects 메서드로 넘긴다.

Service

import com.amazonaws.AmazonServiceException;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.internal.Mimetypes;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.amazonaws.util.IOUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class AmazonS3Service {
    private final AmazonS3 amazonS3;

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

    public void uploadS3Objects(List<MultipartFile> multipartFiles) {
        multipartFiles.forEach(multipartFile -> uploadS3Object(multipartFile));
    }

    public void uploadS3Object(MultipartFile multipartFile) {
        try (InputStream inputStream = multipartFile.getInputStream()) {
            String key = multipartFile.getOriginalFilename();
            byte[] bytes = IOUtils.toByteArray(inputStream);

            ObjectMetadata objectMetadata = new ObjectMetadata();
            objectMetadata.setContentType(Mimetypes.getInstance().getMimetype(key));
            objectMetadata.setContentLength(bytes.length);

            ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
            PutObjectRequest putObjectRequest = new PutObjectRequest(bucket, key, byteArrayInputStream, objectMetadata);
            amazonS3.putObject(putObjectRequest);
            byteArrayInputStream.close();
        } catch (IOException | AmazonServiceException e) {
            log.error("s3 upload error ! {}", e.getMessage());
        };
    }
}

서비스에서는 컨트롤러로부터 넘겨받은 MultipartFile 파일들을 S3 저장소에 업로드한다.

Html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
    <body>
        <input type="file" id="file" multiple>
        <input type="button" id="upload" value="업로드">
        <script type="text/javascript">
            const formData = new FormData();
            document.getElementById("file").addEventListener("change", setFileFormData);
            document.getElementById("upload").addEventListener("click", upload);

            function setFileFormData() {
                for (let i = 0; i < this.files.length; i++) {
                    formData.append("multipartFiles", this.files[i]);
                }
            }
            function upload() {
                fetch("/", {
                    method : "POST"
                    , body : formData
                }).then(response => {
                    if (response.ok) {
                        alert("업로드 완료");
                    } else {
                        alert("업로드 실패");
                    }
                })
            }
        </script>
    </body>
</html>

프론트에서는 파일을 업로드해서 서버로 전송한다.

테스트

PDF 파일 14개를 8번정도 업로드를 해보니 평균 2초정도 걸리는 것 같다.



하나의 쓰레드가 처리를 하다보니 시간이 오래걸렸는데 이제 이 부분을 비동기 방식으로 변경한 후 멀티쓰레드를 이용해서 코드를 변경해보자.

ThreadPoolTaskExecutor 설정

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@EnableAsync
@Configuration
public class AsyncConfig {
    private static int CORE_POOL_SIZE = 15;
    private static int MAX_POOL_SIZE = 25;
    private static int QUEUE_CAPACITY = 10;
    private static String THREAD_NAME_PREFIX = "async-task";

    @Bean
    public Executor asyncTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(CORE_POOL_SIZE);
        executor.setMaxPoolSize(MAX_POOL_SIZE);
        executor.setQueueCapacity(QUEUE_CAPACITY);
        executor.setThreadNamePrefix(THREAD_NAME_PREFIX);
        executor.initialize();
        return executor;
    }
}

@EnableAsync 어노테이션을 선언하면 비동기 처리할 수 있는 기능을 활성화 한다.

 

그리고 ThreadPoolExecutor 인스턴스를 초기화할 때 여러가지 설정값들이 있는데 그중 대표적인 옵션 3가지에 대해 알아보자.

 

corePoolSize

  • 동시에 실행시킬 쓰레드의 갯수를 의미하며 default 값은 1이다.

maxPoolSize

  • 쓰레드 풀의 최대사이즈를 지정하며 default 값은 Integer.MAX_VALUE 이다.

queueCapacity

  • 큐의 사이즈를 지정하며 default 값은 Integer.MAX_VALUE 이다.

ThreadPoolTaskExecutor의 기본적인 동작원리

  • 현재 점유하고 있는 쓰레드의 갯수가 corePoolSize만큼 있을 때 요청이 오면 지정된 queueCapacity의 갯수만큼 요청을 큐에 넣는다.
  • 현재 점유하고 있는 쓰레드의 갯수가 corePoolSize만큼 있고 큐에 담긴 요청이 queueCapacity의 갯수만큼 있을 때 요청이 오면 maxPoolSize만큼 쓰레드 풀을 생성한다.
  • 만약 현재 점유하고 있는 쓰레드의 갯수가 maxPoolSize만큼 있고 큐에 담긴 요청이 queueCapacity의 갯수만큼 있을때 요청이 오면 java.util.concurrent.RejectedExecutionException 예외가 발생된다.

ThreadPoolTaskExecutor 사용법

Executor를 주입받아서 사용하는것과 @Async 어노테이션을 선언하는 방법이 있다.

Executor 주입

Executor를 주입받은 뒤 처리하고자 할 작업을 Runnable 인터페이스의 run() 메서드에 정의하고 Executor의 execute 메서드로 Runnable를 인자로 넘겨주면 된다.

테스트

여러개의 파일을 각각 별도의 쓰레드가 맡아서 처리한 걸 확인할 수 있다.

@Async 어노테이션 사용

컨트롤러쪽에서 List 갯수만큼 루프를 돌려 @Async 어노테이션이 선언된 메서드를 호출하자

테스트

위와 마찬가지로 여러개의 파일을 각각 별도의 쓰레드가 맡아서 처리한 걸 확인할 수 있다.

덕분에 응답처리시간을 2초 -> 0초로 줄여져서 보다 빠른 응답처리를 할 수 있게되었다.

 

참고

소스코드를 보면 눈치채신분들은 있겠지만 지금 위 코드는 Thread Safe하지않는 코드처럼 보인다. AmazonS3 타입의 인스턴스 변수가 여러 쓰레드로부터 공유가 되고 있는데 AmazonS3는 인터페이스이고 실제동작은 AmazonS3Client 에서 이뤄지는데 AmazonS3Client 클래스를 보면 위 사진과 같은 어노테이션이 선언되있다. 해당 어노테이션에 대한 설명은 여기에서 확인할 수 있다.

 

비동기로 처리된 메서드에서 반환값을 받고싶으면 Future, CompletableFuture, ListenableFuture 타입, 반환값 없이 실행만 하고싶으면 void로 처리하면 된다.

728x90
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함