상세 컨텐츠

본문 제목

비동기 처리를 통해 검색 성능을 개선해보자!

Backend

by 준빅 2023. 10. 7. 18:17

본문

스럽에서 중요한 비중을 차지하는 검색기능!

스럽은 연예인의 아이템 정보를 검색하는 앱입니다. 때문에 검색 기능이 많은 비중을 차지합니다.

 

특히! 아이템 + 커뮤니티 게시글 + 사용자를 모두 검색하는 통합검색은 가장 중요한 검색 기능입니다.

 

스럽의 검색 API

스럽의 검색 API는 총 4개로 구성되어 있습니다. 

1. 아이템 게시글 검색 API.

2. 커뮤니티 게시글 검색 API.

3. 사용자 검색 API.

4. 아이템 + 커뮤니티 게시글 + 사용자를 모두 검색하는 통합 검색 API.

 

1~3번은 API Server 내부에서 ELK Server의 API를 호출합니다.

 

4번API Server 내부에서 1~3번의 기능을 모두 호출합니다.

 

성능이 떨어지는 통합검색..

기존의 통합검색 API의 속도는 최대 20초, 평균 5초, 캐시 호출 평균 2.7초가 걸리는 성능을 가지고 있었습니다.

 

스럽은 검색 탭에서 검색ELK를 기반으로 한 검색엔진을 통해 검색이 됩니다.

때문에 통합 검색을 요청하면 Front -> API Server -> ELK Server -> API Server -> Front로 흐르는 구조입니다.

검색 API는 다른 API에 비해 API Server -> ELK Server로 요청이 한 번 더 이루어지기 때문에 속도가 느리다고 생각했지만, 생각보다 너무 느리다고 판단하였습니다.

 

개별 검색은 문제 없는데..?

 

아이템만 검색하는 API도 같은 방식이지만 최초 호출 2.6초, 캐시 호출 1.1초로 차원이 다른 성능을 보여주었습니다.

 

문제는 순차적인 처리!

기존의 통합검색은 순차적인 검색을 하였습니다.

 

아이템 게시글 검색 -> 커뮤니티 게시글 검색 -> 사용자 검색

또한 커뮤니티 게시글찾아주세요 -> 이거 어때 -> 이 중에 뭐 살까 -> 추천해 줘 순서로 총 4개가 될 때까지 검색하며, 최악의 경우 커뮤니티 게시글만 4번 검색합니다.

 

, 최악의 경우 (1)아이템 게시글 검색 -> (2)찾아주세요 게시글 검색 -> (3)이거 어때 게시글 검색 -> (4)이 중에 뭐 살까 게시글 검색 -> (5)추천해 줘 게시글 검색  -> (6)사용자 검색. 총 6번의 검색이 순서대로 이루어집니다.

 

때문에 통합 검색 API 내부에서 6번의 ELK Server API 호출이 이루어졌습니다.

 

해당 부분을 비동기로 처리하여 성능을 개선할 수 있을 것이라고 확신하였습니다.

 

@Async! 비동기 처리를 도와줘!!

Spring에서는 @Async 어노테이션을 통해 비동기 처리를 할 수 있습니다. 

 

사용법은 다음 링크에 정리해두었으니 참고하시면 될 것 같습니다!

@Async 사용법

 

Spring에서 @Async로 비동기 처리를 해보자!

Spring에서 @Async 어노테이션을 이용해서 비동기 처리를 해보자!

velog.io

 

1. @Async 어노테이션 붙이기.

아이템 검색, 커뮤니티 게시글 검색, 사용자 검색 API 메소드에 각각 @Async 어노테이션을 붙여 비동기 처리를 하였습니다.

또한 @Async를 사용하는 메소드에 반환값이 있을 경우 CompletableFuture<>를 사용해야 합니다. 해당 타입으로 기존의 반환값 타입을 감쌌습니다.

 

아이템 게시글 검색

@Async
public CompletableFuture<PaginationResDto<ItemSimpleResDto>> getSearchItem(User user, String keyword, SearchFilterReqDto dto, Pageable pageable) {
	...
    return CompletableFuture.completedFuture( ... );
}

커뮤니티 게시글 검색

@Async
public CompletableFuture<PaginationResDto<QuestionSimpleResDto>> getSearchQuestion(User user, String keyword, String qType, Pageable pageable) {
	...
    return CompletableFuture.completedFuture( ... );
}

사용자 검색 

@Async
public CompletableFuture<PaginationResDto<UserSearchInfoDto>> getSearchUser(User user, String keyword, Pageable pageable) {
	...
    return CompletableFuture.completedFuture( ... );
}

 

2. 통합검색 메소드의 Class 분리

비동기 처리가 정상 작동하려면 호출하는 메소드는 호출되는 메소드와 Class가 분리되어야 합니다. 때문에 통합검색 메소드를 다른 3개의 메소드와 분리하였습니다.

 

@Service
@RequiredArgsConstructor
public class SearchTotalService {
    private final SearchService searchService;
    private final RecentSearchRepository recentSearchRepository;
    private final SearchDataRepository searchDataRepository;

    /**
     * 토탈 검색 with ElasticSearch
     */
    @Transactional
    public SearchTotalResDto getSearchTotal(User user, String keyword) throws ExecutionException, InterruptedException {
        final int itemSize = 9;
        final int questionSize = 4;
        final int userSize = 10;

	// Item 페이지 사이즈 및 필터
        Pageable itemPageable = PageRequest.of(0, itemSize);
        SearchFilterReqDto dto = SearchFilterReqDto.builder().build();

	// Question 페이지 사이즈
        Pageable questionPageable = PageRequest.of(0, questionSize);
        
	// User 페이지 사이즈
        Pageable userPageable = PageRequest.of(0, userSize);

	// [아이템, 커뮤니티(찾아주세요, 이거 어때, 이 중에 뭐 살까, 추천해 줘), 사용자]를 비동기로 처리.
        
	// Item 검색 메소드 호출
        CompletableFuture<PaginationResDto<ItemSimpleResDto>> searchItem = searchService.getSearchItem(user, keyword, dto, itemPageable);
        
        // Question 검색 메소드 호출
        CompletableFuture<PaginationResDto<QuestionSimpleResDto>> questionFind = searchService.getSearchQuestion(user, keyword, "Find", questionPageable);
        CompletableFuture<PaginationResDto<QuestionSimpleResDto>> questionHow = searchService.getSearchQuestion(user, keyword, "How", questionPageable);
        CompletableFuture<PaginationResDto<QuestionSimpleResDto>> questionBuy = searchService.getSearchQuestion(user, keyword, "Buy", questionPageable);
        CompletableFuture<PaginationResDto<QuestionSimpleResDto>> questionRecommend = searchService.getSearchQuestion(user, keyword, "Recommend", questionPageable);
		
        // User 검색 메소드 호출
        CompletableFuture<PaginationResDto<UserSearchInfoDto>> searchUser = searchService.getSearchUser(user, keyword, userPageable);
		
        
        // 모두 비동기로 처리.
        CompletableFuture.allOf(searchItem, questionFind, questionHow, questionBuy, questionRecommend, searchUser).join();

       	... 기타 로직 ...

        return SearchTotalResDto.of(resultItem, resultQuestion, resultUser);
    }

1. 아이템 조회 메소드를 호출한다.

2. 각 커뮤니티 게시글 타입에 맞는 커뮤니티 게시글 조회 메소드를 호출한다.

3. 사용자 조회 메소드를 호출한다.

4. CompletableFuture.allOf()를 통해 비동기 처리로 값을 얻는다.

 

개선된 성능

아이템 개시글을 개별 호출 했을 때처럼 최초 호출 2.4초, 캐시 호출 1.5초 정도가 나오는 것을 확인할 수 있습니다.

 

 

 

대용량 트래픽을 대비한 추가적인 개선

그냥 @Async만 사용하여 비동기 처리를 한다면, 대용량 트래픽이 몰렸을 시 Thread를 관리할 수 없어 상당히 위험하다.

요청이 들어오는 만큼 Thread가 생성되기 때문이다. 때문에 대용량 트래픽을 대비하여 Thread Pool을 관리하며 사용하는 방법을 적용해볼 것이다.

 

1. AsyncConfig 생성하기

SluvServerApplication.class

@SpringBootApplication
@EnableJpaAuditing
@EnableScheduling
//@EnableAsync 삭제
public class SluvServerApplication {

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

}

어플리케이션 Class에서 @EnableAsync를 삭제합니다.

 

AsyncConfig.class

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "asyncThreadPoolExecutor")
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(30);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("Sluv-Async-Thread-");
        executor.initialize();
        return executor;
    }
}

AsyncConfig를 만들어 Thread Pool 옵션을 설정해줍니다.

1. setCorePoolSize(); -> 기본 스레드 수

2. setMaxPoolSize(); -> 최대 스레드 수

3. setQueueCapacity(); -> Queue 사이즈

 

2. @Async에 Executor의 이름 붙이기

 

아이템 게시글 검색

@Async(value = "asyncThreadPoolExecutor")
public CompletableFuture<PaginationResDto<ItemSimpleResDto>> getSearchItem(User user, String keyword, SearchFilterReqDto dto, Pageable pageable) {
	...
    return CompletableFuture.completedFuture( ... );
}

커뮤니티 게시글 검색

@Async(value = "asyncThreadPoolExecutor")
public CompletableFuture<PaginationResDto<QuestionSimpleResDto>> getSearchQuestion(User user, String keyword, String qType, Pageable pageable) {
	...
    return CompletableFuture.completedFuture( ... );
}

사용자 검색 

@Async(value = "asyncThreadPoolExecutor")
public CompletableFuture<PaginationResDto<UserSearchInfoDto>> getSearchUser(User user, String keyword, Pageable pageable) {
	...
    return CompletableFuture.completedFuture( ... );
}

각 @Async 어노테이션에 value 옵션으로 Executor의 이름을 지정해줍니다.

 

이렇게 하면 Thread Pool을 이용하여 비동기 처리를 할 수 있습니다!

 

 

참고자료

https://steady-coding.tistory.com/611

 

 

관련글 더보기

댓글 영역