본문 바로가기

카테고리 없음

Webflux를 이용한 응답 시간 단축 - 삽질중인 개발자

반응형

빅데이터 분석 서비스를 운영하면서 분석 API는 분석 조건에 따라 내부 API를 N개 호출하여 각 응답 값을 조합해 하나의 응답으로 반환하는 API형태로 운영이 되고 있었다.

 

기존에는 문제없이 동작하던 코드가 운영 정책의 변경으로 분석 조건 중 하나인 최대 분석 가능한 기간을 늘리면서 분석 요청에 대한 응답 시간이 너무 길어지는 문제가 발생하기 시작했다.

 

우선 이 문제를 파악하기 위해서 디버깅을 해본 결과 각각의 응답 속도가 다른 N개의 요청이 순차적으로 처리되면서 전체적으로 응답이 느려지고 있었다. ( ex) 5개의 API를 내부에서 호출하는데 1초, 5초, 10초, 15초, 4초가 걸린다고 가정하면 순차적인 처리로 인해 전체적으로는 35초가 걸린다 )

 

이에 따라 사용자 입장에서 대기 시간이 너무 길어지는 현상이 발생하니 동시에 보내서 모든 요청이 끝나면 조합해서 응답을 해 전체 대기 시간을 줄여보기로 했다.

 

동시에 요청을 보내서 처리할 수 있는 방법을 알아보니 @Async와 Spring의 WebFlux를 이용하는 방법이 눈이 들어왔다.

 

처음에는 @Async를 사용해 간단하게 비동기 처리를 구현하려 해서 해당 방식의 동작 방식을 살펴보니 블로킹 I/O 기반의 스레드 풀을 사용하는 구조였다. 이 방식은 우리 분석 API처럼 응답 시간이 길어지는 작업에서는 각 요청이 스레드를 점유하게 되어 결과적으로 스레드 풀이 고갈될 수 있는 위험이 있다고 판단했다.

그래도 구글링으로 얻은 정보 보다는 실제로 그런 문제가 발생하는지 확인해 보는게 좋을 것 같다고 생각이 돼 간단한 테스트 코드를 작성해 실험해 보았다.

 

아래는 @Async 사용 시 실제로 풀이 고갈되는지 간단한 테스트다.

@Bean("asyncTaskExecutor")
public Executor asyncTaskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(2);
    executor.setMaxPoolSize(3); // 최대 스레드 풀 개수
    executor.setQueueCapacity(0);
    executor.setThreadNamePrefix("async-executor-");

    executor.setTaskDecorator(new MdcTaskDecorator());
    executor.initialize();
    return executor;
}


    @Async("asyncTaskExecutor")
    public CompletableFuture<Integer> asyncJob(int value) {
        System.out.println("Start job " + value + " - " + Thread.currentThread().getName());
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("End job " + value + " - " + Thread.currentThread().getName());
        return CompletableFuture.completedFuture(value);
    }

@Test
void testRejectedExecutionException() {
    List<CompletableFuture<Integer>> futures = new ArrayList<>();

    assertThrows(RejectedExecutionException.class, () -> {
        for (int i = 1; i <= 10; i++) {
            CompletableFuture<Integer> future = asyncService.asyncJob(i);
            futures.add(future);
        }

        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
    });
}

 

실제로 3개의 풀만 있다고 가정했을 때  응답에 2초가 걸리는 상황에서 동시에 10개의 요청을 하면 4개부터는 RejectedExecutionException이 발생되는 걸 확인했다. 그래서 빠르게 @Async 방식은 버려졌다.

 

다음 방식으로 WebFlux에 대한 테스틑를 진행했다. WebFlux는 Project Reactor 기반의 논블로킹 I/O 프로그래밍 모델로 내부적으로 Netty와 같은 서버를 사용할 경우 이벤트 루프 기반으로 동작한다. 이런 특성으로 인해 적은 수의 스레드로도 높은 동시성을 처리할 수 있으며 리소스 사용 측면에서 훨씬 효율적이다.

 

진짜로 그런지 확인하기 위해서 테스트를 해봤다. 

// 코드 예시

public Mono<Integer> asyncJob(int value) {
    return Mono.delay(Duration.ofSeconds(2)) // 2초 비동기 지연
            .doOnSubscribe(sub -> System.out.println("Start job " + value + " - " + Thread.currentThread().getName()))
            .doOnNext(x -> System.out.println("End job " + value + " - " + Thread.currentThread().getName()))
            .map(x -> value); // 결과 반환
}


@Test
public void sumReactiveResults() {
    Assertions.assertDoesNotThrow(() -> {
        Flux.range(1, 9999999)
            .flatMap(reactiveService::asyncJob, 9999999)
            .reduce(0, Integer::sum)
            .block();
    });
}

 

간단하게 만들어본 테스트 코드였지만 확실히 @Async를 사용할 때보다 Reactor 기반의 비동기 처리 방식이 훨씬 많은 요청을 동시에 처리할 수 있었다.

WebFlux는 러닝 커브가 높다고들 하지만 당시에는 요청 지연으로 인해 고객 문의가 지속적으로 들어오고 있었고 더 이상 고민만 하고 있을 여유가 없었다.

 

그래서 개선이 가능하다고 판단된 부분부터 빠르게 적용해 보기로 했고 부랴부랴 구글링하며 적용하고 배포까지 마쳤더니 실제로 응답 시간이 눈에 띄게 줄었고 고객 문의도 크게 줄었다.


물론 급한 불은 껐지만 아직 WebFlux에 대한 이해가 충분하지 않은 상태에서 도입했기 때문에 이후 예상치 못한 장애가 발생했고 디버깅이 쉽지 않아 장애 처리에 시간이 꽤 오래 걸리는 문제도 있었다.

그래도 현재는 큰 문제 없이 안정적으로 잘 운영되고 있는 상태이고 이번 경험을 통해 성능 개선뿐 아니라 Reactor 기반의 비동기 처리에 대해 더 깊이 학습해야겠다는 필요성도 절실히 느꼈다.

반응형