레시피(이미지+Body) 업로드 API 부하테스트 및 제안

김예지·2024년 2월 5일
0

집다방

목록 보기
3/5

1. 기존 코드(동기) 부하테스트

썸네일 1장과 5개 스텝별 사진, 총 6개의 2.5mb 이미지(6개 * 2.5mb = 15mb)를 레시피 설명과 함께 업로드 하는 동작을 각 10번씩 수행하여 테스트를 진행하였다.

멀티쓰레드를 테스트하기 위해 1vCPU로 동작하던 t2.small 대신 t3.xlarge(4vCPU, 16.0Gib mem)로 변경하였으며, 최소 서버 1개, 최대 2개로 로드밸런싱하였다.

레시피 정보+썸네일 등록 > 카테고리 매핑 > step별 등록 > ingredient별 등록

코드는 위의 순서로 작성되어 있으며, 로그를 남겨 위의 동작이 동기적으로 진행됨을 파악하였다.

Feb  3 16:26:18 web[2296]: categoryMappingDto Thread: 0
Feb  3 16:26:18 web[2296]: stepDto Thread: 1(stepNum)
Feb  3 16:26:18 web[2296]: stepDto Thread: 2(stepNum)
Feb  3 16:26:18 web[2296]: stepDto Thread: 3(stepNum)
Feb  3 16:26:19 web[2296]: stepDto Thread: 4(stepNum)
Feb  3 16:26:19 web[2296]: stepDto Thread: 5(stepNum)
Feb  3 16:26:19 web[2296]: ingredientDto Thread: 1
Feb  3 16:26:19 web[2296]: ingredientDto Thread: 2
Feb  3 16:26:19 web[2296]: ingredientDto Thread: 3

테스트 결과는 위와 같다. 소요 시간은 (ms)기준이다.

2. 비동기 적용

(수정) DB 접근하는 곳에 비동기를 적용하면 DeadLock이 발생한다. 조심하자.
아래는 이 문제에 대해 작성한 트러블슈팅 글이다.
https://velog.io/@nwactris/Hikari-Pool-Error

다양한 비동기 처리 방법 중 어떤 방법을 적용할까 고민하다가, ParallelStream과 CompletableFuture를 적용하기로 하였다.
https://velog.io/@nwactris/Java에서-비동기를-사용하기-위한-선택-3가지

1. Common ForkJoinPool 사용하는 ParallelStream

Service에서 사용하는 stream()은 DB 업데이트에 사용되므로 동시성 문제가 발생할 수 있어 비동기를 적용하지 않았다. 대신, 요청으로 들어온 DTO List를 변환하는 Converter 내의 stream()parallel()을 적용하였다.

아래의 예시는 List< StepDto >를 각각 Step으로 변환하는 코드이다.

public static List<TestStep> toTestStep(RecipeRequestDto.CreateRecipeDto request, TestRecipe recipe, List<MultipartFile> stepImages) {
    return request.getSteps().stream().parallel()
        .map(step-> {
            if (step.getDescription() == null)
                throw new RecipeException(CommonStatus.NULL_RECIPE_ERROR);
                try {
                    return toTestStepDto(step, recipe, stepImages);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
       })
       .collect(Collectors.toList());
}

이 때의 동작 순서는 아래와 같다.

Feb  3 16:36:12 web[2361]: categoryMappingDto Thread: 0
Feb  3 16:36:12 web[2361]: stepDto Thread: 3(stepNum)
Feb  3 16:36:12 web[2361]: stepDto Thread: 1(stepNum)
Feb  3 16:36:12 web[2361]: stepDto Thread: 2(stepNum)
Feb  3 16:36:12 web[2361]: stepDto Thread: 5(stepNum)
Feb  3 16:36:13 web[2361]: stepDto Thread: 4(stepNum)
Feb  3 16:36:13 web[2361]: ingredientDto Thread: 2
Feb  3 16:36:13 web[2361]: ingredientDto Thread: 3
Feb  3 16:36:13 web[2361]: ingredientDto Thread: 1

전체 순서는 지켜졌으나, 각 stream().parallel().map() 내에서 비동기적으로 처리된 것을 확인할 수 있다.

ParallelStream을 적용한 결과는 위와 같다.

2. CompletableFuture 적용

원래는 @Async를 사용하려 했으나, Converter의 메서드가 static이라 사용할 수 없었다.

recipe 객체를 생성한 이후 categoryMapping, step, ingredient를 등록하는 순서는 상관이 없으므로 Service에서 각 Converter를 호출하는 순서를 비동기적으로 처리했다.

@Transactional(readOnly = false)
public TestRecipe testCreate(RecipeRequestDto.CreateRecipeDto request, MultipartFile thumbnail, List<MultipartFile> stepImages) throws IOException {

    CompletableFuture<TestRecipe> savedRecipeFuture = 
        CompletableFuture.supplyAsync(() ->{
            TestRecipe buildRecipe = null;
            try {
                buildRecipe = RecipeConverter.toTestRecipe(request, thumbnail);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
            return testRecipeRepository.save(buildRecipe);
    });

    savedRecipeFuture.thenAccept(recipe -> {
        RecipeConverter.toTestRecipeCategory(request.getCategoryId(),recipe).join().stream()
            .map(categoryMapping -> testRecipeCategoryMappingRepository.save(categoryMapping))
            .collect(Collectors.toList())
            .stream()
            .map(categoryMapping -> categoryMapping.setRecipe(recipe));
    });


    savedRecipeFuture.thenAccept(recipe -> {
        RecipeConverter.toTestStep(request, recipe, stepImages).join().stream()
                .map(step -> testStepRepository.save(step))
                .collect(Collectors.toList())
                .stream()
                .map(step -> step.setRecipe(recipe));
    });

    savedRecipeFuture.thenAccept(recipe -> {
        RecipeConverter.toTestIngredient(request, recipe).join().stream()
                .map(ingredient -> testIngredientRepository.save(ingredient))
                .collect(Collectors.toList())
                .stream()
                .map(ingredient -> ingredient.setRecipe(recipe));
        });

        return savedRecipeFuture.join();
    }

Converter 내에서의 순서는 보장되지만, 각 converter는 병렬적으로 동작하는 것을 확인할 수 있다.

Feb  3 23:56:55 web[2359]: ingredientDto Thread: 1
Feb  3 23:56:55 web[2359]: stepDto Thread: 1(stepNum)
Feb  3 23:56:55 web[2359]: ingredientDto Thread: 2
Feb  3 23:56:55 web[2359]: ingredientDto Thread: 3
Feb  3 23:56:55 web[2359]: categoryMappingDto Thread: 0
Feb  3 23:56:56 web[2359]: stepDto Thread: 2(stepNum)
Feb  3 23:56:56 web[2359]: stepDto Thread: 3(stepNum)
Feb  3 23:56:56 web[2359]: stepDto Thread: 4(stepNum)
Feb  3 23:56:56 web[2359]: stepDto Thread: 5(stepNum)

테스트 결과는 아래와 같다.

3. CompletableFuture + ParallelStream

쓰레드를 과하게 사용할 경우 오히려 속도가 낮아질 수 있지만, CompletableFuture과 ParallelStream을 조합하면 불필요한 대기가 줄어들 것 같아 두개를 조합한 경우도 테스트해 보았다.

Feb  4 00:06:18 web[2346]: ingredientDto Thread: 2
Feb  4 00:06:18 web[2346]: ingredientDto Thread: 1
Feb  4 00:06:18 web[2346]: stepDto Thread: 3(stepNum)
Feb  4 00:06:18 web[2346]: ingredientDto Thread: 3
Feb  4 00:06:18 web[2346]: stepDto Thread: 1(stepNum)
Feb  4 00:06:18 web[2346]: stepDto Thread: 2(stepNum)
Feb  4 00:06:18 web[2346]: categoryMappingDto Thread: 0
Feb  4 00:06:19 web[2346]: stepDto Thread: 5(stepNum)
Feb  4 00:06:19 web[2346]: stepDto Thread: 4(stepNum)

동작 순서는 예상대로 모두 병렬적으로 처리되었으며, 테스트 결과는 다음과 같다.

4. I/O Blocking 차단

3번까지 진행을 했지만, 유의미하게 레시피 업로드 시간이 감소하지는 않았다. 어느 부분에서 시간이 가장 많이 소요될까 고민한 결과, s3에 이미지를 등록하는데서 Blocking이 발생해 thread가 Pool로 돌아가지 못하고 무의미하게 낭비될 것이라고 예상되었다.

따라서, s3에 이미지를 등록하는 부분(thumbnail, stepImages)에 CompletableFuture 코드를 추가해주었다.
아래는 레시피 기본 정보(text)와 Thumbnail을 등록하는 코드이다.

private static final ExecutorService ioExecutor = Executors.newFixedThreadPool(Math.min(Runtime.getRuntime().availableProcessors(), 8));


public static TestRecipe toTestRecipe(RecipeRequestDto.CreateRecipeDto request, MultipartFile thumbnail) throws IOException {

    CompletableFuture<TestRecipe> buildRecipe = new CompletableFuture<>();
    CompletableFuture<String> setThumbnail = new CompletableFuture<>();

    ioExecutor.submit(() -> buildRecipe.complete(TestRecipe.builder()
                //.(내용 추가)
                .build()));

    if(thumbnail != null)
        ioExecutor.submit(()-> setThumbnail.complete(uploadTestThumbnail(thumbnail)));
    else
        throw new RecipeException(CommonStatus.NULL_RECIPE_ERROR);

    return buildRecipe.thenCombine(setThumbnail, (recipe, imageUrl) -> {
        recipe.setThumbnail(imageUrl);
        return recipe;
    }).join();
}

이 때의 결과는 다음과 같다.

3. 결론

Latency

1. 1명의 유저가 보내는 경우

코드가 동기적으로 동작하든, 비동기로 동작하든 지연 시간에 큰 차이는 없었다. 오히려 ParallelStream과 CompletableFuture을 같이 사용한 경우, 복잡도가 증가해 가장 지연 시간이 길었다.

2. 10명의 유저가 동시에 보내는 경우

서버의 부하가 크지 않은 상황에서는, 비동기를 적용함에 따라 Latency가 감소하는 것을 볼 수 있다.

3. 50명의 유저가 동시에 보내는 경우

응답에 실패한 요청은 없었지만, 서버의 부하가 매우 큰 상황이다. 이 때, 쓰레드가 감당 가능한 수준 이상으로 생성되어 동기적으로 수행한 경우보다 시간이 더 소요되는 것을 확인할 수 있다.

Throughput

Throughput은 단위 시간당 대상 서버에서 처리되는 요청의 수를 말하며 JMeter에서는 시간 단위를 보통 TPS(Transaction Per Second)로 표현한다.

비동기를 적용하며, Latency에 비해 처리량(Throughput)은 증가한 것을 확인할 수 있다.
하지만, 50개의 요청을 보낼 때 혼합+Blocking 제거에서 처리량이 갑자기 낮아진 것으로 보아 과한 멀티쓰레드는 서버의 성능을 떨어뜨린다는 것을 다시 한번 확인할 수 있었다.

제안

PM의 요청대로 API 한번에 모든 이미지를 전송하면서도 성능을 높게 하고자 비동기를 적용해보았으나, 부하테스트 결과 서비스에 큰 불편을 겪을 정도로 긴 지연 시간이 측정되었다.

따라서 S3 presigned URL upload를 이용해 이미지는 프론트에서 바로 업로드 하고, 서버에서는 이미지 URL 및 json만 받도록 하는 것이 옳을 것으로 생각하고, 이와 같이 코드를 수정한 후 서비스 가능한 속도인지 다시 확인해보고자 한다.

0개의 댓글

관련 채용 정보