포스트

프로젝트:샐로그 / 최적화 - 조회 성능 개선 1 (비동기 처리)


개요

지난 번 포스트까지 JMeter를 활용하여 대용량 트래픽을 발생시키고 주요 지표를 확인해 모니터링을 진행했다.

해당 과정을 거쳐 조회 성능을 향상 시키기 위해 어떤 방법이 있는지 찾아 보았고, 최소한의 방법으로 최대한의 개선을 할 수 있도록 몇 가지 방법을 적용, 결과를 살펴보았다.


앞서 말했듯이 가장 빈번하게 일어나는 조회 성능을 향상 시키는 것에 중점을 두었으며, 짧은 지식이지만 최대한 모니터링 결과에 기반하여 개선 방법을 생각, 적용했다.

단순하지만 사용자에게 가장 가까운 기능인 조회에 대한 성능을 향상 시키면 사용자 경험 개선에 크게 기여할 수 있을 것이라 생각했기 때문이다.




주요 문제와 개선 방안

모니터링 결과를 바탕으로 생각할 수 있는 주된 문제는 크게 두 가지였다.

  1. 병목현상으로 인한 임계점 수준의 요청 시 처리 완료 시간이 길어짐
  2. 메모리 누수 현상으로 임계점 수준의 요청 이후 애플리케이션이 느려짐

이렇게 두 가지 문제를 바탕으로 개선 작업을 시도해보았으며, 첫 번째 문제인 병목현상을 해결하는 데에 집중했다.

두 번째 문제인 메모리 누수 현상은 추가해야 할 기능이나 코드 최적화 등의 이슈를 해결한 다음 다시 돌아와서 해결해볼 예정이다.


우선 병목현상을 해결 하기 위해 생각할 수 있는 솔루션은 크게 세 가지였다.

  1. 비동기 처리
  2. 캐싱
  3. 인덱싱

가장 먼저 인덱싱부터 말하자면, 당장 슬로우 쿼리가 발생하지 않으며 조회에 사용된 쿼리 메서드 자체가 그리 복잡한 로직이 아니기 때문에 DB에 인덱싱을 적용해서 튜닝하는 것 자체가 적합하지 않다고 생각했다.

그래서 앞선 두 가지를 하나 씩 적용해보고 문제에 대한 해결이 안될 경우 마지막으로 인덱싱을 적용해보려 했는데, 작업 중에 문제가 해결되어서 인덱싱은 넘겼다.




솔루션 1 : 비동기 처리

가장 먼저 접근한 방법은 비동기 처리였다.

이 비동기 처리는 각 요청을 즉각적으로 반환할 수 있도록 하여, 사용자가 대기하는 시간을 줄이는 방법이기 때문에 동시에 많은 요청이 들어오는 상황에서 효과적이라고 생각했기 때문이다.

즉, 요청에 대한 처리 속도를 향상 시켜 사용자 경험을 개선하고자 적용했던 방법이다.


개요에서 언급한 대로 최소한의 투자로 최대한의 개선을 하는 것이 목표이기 때문에 크게 복잡한 방법을 사용하지 않았다.

스프링 부트에서 기본적으로 비동기 처리를 사용할 수 있는 방법인 @Async 어노테이션 적용을 시도해보았다.


이 @Async 어노테이션을 적용해서 애플리케이션에 비동기 처리를 적용하기 위해서는 의존성으로

1
	implementation 'org.springframework.boot:spring-boot-starter-web'

이 줄이 추가되어야 하는데, 이 의존성 자체는 처음 스프링 부트 웹 애플리케이션 프로젝트를 생성할 때 기본적으로 포함되기 때문에 넘어갔다.


다음으로 비동기 처리를 적용하기 전, 해당 옵션을 스프링 부트 전체적으로 키기 위해

1
2
3
4
5
6
7
8
9
10
11
12
13
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

@SpringBootApplication
@EnableAsync // 비동기 처리 옵션
public class SalogApplication {

	public static void main(String[] args) {
		SpringApplication.run(SalogApplication.class, args);
	}

}

위와 같이 @EnableAsync 어노테이션을 애플리케이션 메인 클래스에 추가해 주어야 한다.


여기까지 진행했다면 비동기 처리의 기본 기능을 사용할 수 있게 된 것이다.

이제, 비동기 처리를 적용할 메서드에 @Async 어노테이션을 적용하여 이 메서드는 비동기 처리로 실행할 것이다라는 표시를 해야한다.

1
2
3
4
    @Async
    public MultiResponseDto<IncomeDto.Response> getIncomes(String token, int page, int size, String incomeTag, String date) {
        ...
    }

이렇게 서비스 레이어의 비동기 처리를 적용할 메서드에 @Async 어노테이션을 적용하면 이 메서드는 비동기 처리 방식으로 실행시킬 수 있다.


이외에도 조금 더 복잡할 설정을 적용하여 비동기 처리를 커스터마이징 하고 싶다면 Future 또는 CompletableFuture 클래스를 사용하여 자신이 원하는 방식으로 비동기 처리를 실행할 수 있다.

하지만 최대한 단순한 방법으로 여러 가지 솔루션을 적용해보기 위해 해당 클래스를 사용하진 않았다.




생각할 점

이렇게 비동기 처리를 적용하고서 다시 JMeter로 테스트, 수집된 메트릭을 모니터링 해봤는데, 의아한 점이 몇 가지 있었다.

현재와 같이 리소스가 제한된 상태에서 비동기 처리가 의미가 있을까?

비동기 처리를 제대로 활용하기 위해서는 스레드풀의 크기가 충분해야한다고 알고 있다.

그런데 지금 같은 상황에서는 임의로 스레드풀의 크기를 늘린다고 해도 시스템 리소스 자체가 극히 제한된 상태이기 때문에 메모리 오버헤드나 리소스 오버헤드가 발생하게 되면 오히려 성능이 저하 될 수 있다고 생각이 들었다.

동시다발적인 요청이 발생하면 메모리 소비가 증가하고(메모리 오버헤드) 그 만큼 데이터베이스 접근 요청이 늘어난다는 뜻인데(리소스 오버헤드), 이 경우에도 병목현상이 추가적으로 발생할 여지가 있다.

또한 이 조회 작업이 시간이 오래 걸리는 작업이라면 이 작업을 요청 해놓고 다른 작업을 하는 데서 이점을 가져갈 수 있지만, 단순한 조회 요청이기 때문에 그럴 일도 이유도 없다.

그래서 이 “비동기 처리” 방식이 현재 상황에 적합한가에 대한 의문이 계속 들었다.


그럼에도 적용을 해보았으니 모니터링 결과를 바탕으로 다시 생각해보자.




모니터링 결과

이전 포스트에서 말했던 대로 2.4만개의 로그를 인텔리제이에서 출력하는데 오래 걸릴 뿐이지 요청과 처리의 시간이 거의 동일하기 때문에 이전까지 생각했던 처리 완료 16분에 대한 오해는 바로 잡고 시작했다.

즉, 응답은 요청 시 바로바로 돌아가니까 단순히 렉 걸려서 그렇게 보이는 것 뿐이라고 생각했다.

위 결과가 Async, 비동기 처리를 적용하지 않은 상태의 기본 애플리케이션이다.

기본적으로 이전 모니터링 결과들과 큰 차이는 없다.

다만 한 가지 생각할 것은 평균 요청 처리 시간은 매무 낮은 시간이고, 매트릭 수집 요청만 들어와도 저렇게 0.03초 단위로 발생하기 때문에 이번 모니터링에서는 어느정도 제외하고 생각했다.

가장 의미있게 본 패널이 HTTP 요청의 최대 응답 시간과 메모리 사용량 정도였다.

우선 최대 응답 시간이 0.5 초 까지 올라가고, 메모리 사용량은 처음을 제외하고는 GC가 발생하고 부터 점진적으로 올라간다.


이에 비해 Async 어노테이션을 적용하여 비동기 처리 방식을 사용한 뒤는

이런 양상을 띈다.

평균, 최대 응답 시간은 동일한데 가장 큰 변화를 보인 것이 메모리 사용량이다.

그냥 단순히 살펴봐도 응답 시간에 대한 개선은 없으면서 메모리 사용량만 오히려 늘었다고 볼 수 있다.

이는 결국 요청을 병렬적으로 처리하면서 DB에 한번에 접근하는 횟수가 많아져 메모리 사용량이 기하급수적으로 늘었다고 볼 수 있으며, 이렇게 동시에 요청을 처리하지만 결과적으로는 오버헤드가 발생해 응답 시간에 대한 개선이 되었다고 보기는 어려운 상황이다.

즉, 병목현상을 해결하기 위해 또 다른 병목현상이 발생했다고 밖에 볼 수 없다.




결론

모니터링 결과를 바탕으로 생각하면 결국 비동기 처리는 현 상황을 개선하는데 맞지 않다.

리소스가 제한된 상태에서 적절한 크기의 스레드풀을 형성하기도 어려울 뿐더러 오히려 추가적인 오버헤드가 발생하는데 이를 핸들링할 수 없어 오히려 성능만 저하된다.

간단히 보면 병력적으로 처리함으로써 동시에 DB에 접근하려는 시도가 많아지고 이로인해 또 병목현상이 발생하여 메모리 사용량이 크게 올라간다.


그러면 다음은 캐싱을 적용하여 어떤 결과가 있는지 살펴보자.




후기

결국 비동기 처리를 적용하니 또 다른 문제가 발생했다.

처음에는 단순히 모니터링을 해보고 결과를 바탕으로 개선해보자는 목적을 갖고 시작했는데 하면 할 수록 복잡하고 어려워진다.

또한, 그 어떤 솔루션이라고 하더라도 맞을 때와 맞지 않을 때가 있기 때문에 이를 구분하는데에 큰 어려움을 느꼈다.


그럼에도 이 개선 작업을 진행하는 이유는 추후 도움이 될 것이라 생각하기 때문이다.

현재는 소규모 애플리케이션에 리소스도 제한된 상태로 진행하고 있지만 이 시행착오를 바탕으로 대규모 애플리케이션에 적용하면 더 큰 성능 개선을 이루어낼 수 있지 않을까 하는 마음이다.

즉, 예행 연습을 한다고 생각하고 진행했다.


이제 다음으로 캐싱을 적용해보고 어떤 변화가 있는 지 살펴볼 것이다.

이 블로그는 저작권자의 CC BY 4.0 라이센스를 따릅니다.