본문 바로가기
SpringBoot/성능 개선 흔적들

[Spring] 반복문 CompletableFuture과 스트림 parallelStream 성능 비교

by rla124 2024. 7. 26.

재밌는 실험을 해봤다. 한 번의 API 호출로 여러 개의 이미지 파일을 S3에 업로드해야 할 때 나는 기존에 이 게시글에서 소개한 대로 for 문으로 completableFuture을 통해 순차적으로 서로 다른 스레드 풀에서 작업을 수행하도록 비동기처리를 함으로써 각 스레드에서 output이 나오는대로 resonse에 추가하는 로직을 설계했었다.

 

하지만 CS 기반을 다지고자 stream에 대해 공부를 해보니 이 completableFuture 처리를 하는 기존의 코드가 for문인데 이를 stream을 이용해서 병렬처리를 해본다면 성능이 어떻게 개선될까라는 의문점이 들어 실험을 해보게 되었다. 결과 PR은 이 링크에서 코드를 확인할 수 있다.

 

먼저 기존 completableFuture을 활용한 service layer 코드는 아래와 같다.

우선 아래 코드에서는 위에서 언급한대로 for 문으로 프론트에서 List<String>으로 받아올 base64 인코딩된 문자열을 순차적으로로 순회하면서 서로 다른 스레드에 넣어 작업이 실행되도록 하고 있다.

// 다중 이미지 업로드 
public List<StudyImageUrlResponse> uploadFiles(Long studyId, StudyImageUploadRequest request) throws ExecutionException, InterruptedException {

            List<Image> originalImages = imageRepository.findAllByStudyId(studyId);
            imageRepository.deleteAll(originalImages);

            List<String> base64ImagesList = request.base64ImagesList();
            List<CompletableFuture<StudyImageUrlResponse>> futures = new ArrayList<>();

			// 이 부분 for문 처리 부분을 바꿀 예정이다
            for (String base64Image : base64ImagesList) { 
                futures.add(CompletableFuture.supplyAsync(() -> {
                    try {
                        return uploadFile(studyId, base64Image);
                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    }
                }));
            }

            List<StudyImageUrlResponse> responses = new ArrayList<>();
            for (CompletableFuture<StudyImageUrlResponse> future : futures) {
                responses.add(future.get());
            }

            return responses;
        }

// 단일 이미지 업로드 
private StudyImageUrlResponse uploadFile(Long studyId, String base64Image) throws IOException {
    String extension = "";
    Pattern pattern = Pattern.compile("^data:image/([a-zA-Z]+);base64,");
    Matcher matcher = pattern.matcher(base64Image);
    if (matcher.find()) {
        extension = matcher.group(1);
    }

    String base64ImageData = base64Image.replaceFirst("^data:image/[^;]+;base64,", "");

    byte[] imageBytes = Base64.getDecoder().decode(base64ImageData);
    ByteArrayInputStream inputStream = new ByteArrayInputStream(imageBytes);

    ObjectMetadata metadata = new ObjectMetadata();
    metadata.setContentLength(imageBytes.length);
    metadata.setContentType("image/" + extension);

    String randomFileName = generateUUID();
    String fullFileName = randomFileName + "." + extension;

    amazonS3.putObject(s3Properties.bucket(), fullFileName, inputStream, metadata);

    String base64ToS3url = amazonS3.getUrl(s3Properties.bucket(), fullFileName).toExternalForm();

    Study study = findStudyById(studyId);

    Image savedImage = imageRepository.save(
        Image.createBase64ToImage(
            study,
            base64ToS3url
        )
    );

    return new StudyImageUrlResponse(savedImage.getId(), base64ToS3url);
}

 

 

바뀐 부분은 가장 위에 있는 uploadFiles 메소드로 그 아래의 uploadFile은 그대로이다. stream의 parallelStream을 이용했다. 

public List<StudyImageUrlResponse> uploadFiles(Long studyId, StudyImageUploadRequest request) throws IOException {
    List<Image> originalImages = imageRepository.findAllByStudyId(studyId);
    imageRepository.deleteAll(originalImages);

    List<String> base64ImagesList = request.base64ImagesList();

    return base64ImagesList.parallelStream() // 이렇게 stream 병렬 처리를 해주었다
        .map(base64Image -> {
            try {
                return uploadFile(studyId, base64Image);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        })
        .collect(Collectors.toUnmodifiableList());

 

이미지 두 장을 업로드 했을 때

사실 결론부터 말하면 이미지 두 장만 업로드를 했을 경우 CompletableFuture과 parallelStream의 속도차이는 거의 없었다. 두 방식의 평균 실행 시간 백의 자리수가 동일하고(300~400ms 사이의 범위가 최빈값) 오히려 CompletableFuture가 오차 범위가 좀 더 작아서 "일관된 수치"를 그나마 더 보였다. 하지만 차이가 미미해서 좀 더 부하를 주기 위해 request로 보내는 string의 개수를 더 늘려보았다.

 

 

이미지 다섯 장을 업로드 했을 때 

결과적으로 Stream 병렬처리가 이번에는 조금 확실히(?) CompletableFuture보다 더 나은 성능을 보였다. 정확히는 백의 자리 숫자에서부터 차이가 있었다. CompletableFuture의 경우 주로 400대의 수치를 보였고 parallelStream의 경우 300대의 수치를 거의 유지했다. 

 

왜 이 상황에서 부하가 클 경우 Stream이 더 우세하지?

쓰레드 관리의 효율성 측면

CompletableFuture의 경우 ForkJoinPool을 이용해서 비동기 작업을 처리한다고 알고 있다. CPU 코어 수에 따라 동적으로 조정되지만 비동기 작업이 지금처럼 이미지 수가 늘어남에 따라 동시에 처리되는 양이 많아질 경우 쓰레드 풀이 많이 생성되어야 하므로 스레드 관리 비용과 스레드 간 오버헤드, OS 시간에 배웠던 컨텍스트 스위칭 문제 때문에 성능 저하로 이어질 수 있다고 생각한다. 

하지만 Stream의 병렬처리는 이 기능 자체가 작업 분배와 비동기 처리에 최적화가 되어있기 때문에 CompletableFuture보다 효율적일 수 있다. 애초에 대량의 데이터를 효과적으로 처리하기 위해 고안된 것이므로 병렬화에 적합한 작업일 수록 강점을 지니는 것이다. 

 

메모리 자원 활용 측면

CompletableFuture의 경우 비동기 작업이 각각 서로 다른 스레드 풀에서 실행되므로 메모리와 자원을 비효율적으로 사용하는 문제가 있을 수 있다. 

 


구현을 해보고 개선을 해보는 것에서 더 나아가 이제는 다양한 실험을 통해서 지식을 확장해보려고 한다