비동기 방식으로 API 응답 속도 향상 시키기 | CompletableFuture

o_z·2024년 3월 10일
post-thumbnail

저번에 Google Places API를 사용해서 추천 맛집을 뿌려주는 API를 만들었다.
호출은 의도대로 되지만 응답 속도가 많이 느려서 API 성능 개선에 대해 고민하게 되었다.

API를 Postman으로 호출 테스트 했을 때 걸린 시간이다. 응답 시간이 6.19s 걸렸다. 이 이후로도 몇 번 더 호출 해봤을 때 최소 5s, 최대 7s 이상까지도 걸렸었다.
이정도로 눈에 띄게 속도가 느린 문제를 발견한 건 처음이라 원인 발견부터 좀 어려웠다.


현재 API 로직

현재 추천 맛집 조회 코드는 아래와 같다.

@Value("${google.api.key}")
private String apiKey;
   
// 추천 맛집 검색 결과 반환
public List<GetRecommendedPlacesResponseDTO> getAllRecommendedPlace(Country country, City city){
		List<GetRecommendedPlacesResponseDTO> result = new ArrayList<>();
		
		String searchKeyword = country.toString().concat(" ").concat(city.toString()).concat(" 맛집");
		GeoApiContext context = new GeoApiContext.Builder()
			.apiKey(apiKey)
			.build();
           
		try{
       	// Text Search API 호출
			PlacesSearchResponse response = PlacesApi.textSearchQuery(context, searchKeyword).await();
			if(response.results != null && response.results.length > 0){
           	// 검색한 모든 장소들에 대해, 장소 세부정보 API 호출
				for(PlacesSearchResult res : response.results){
					GetRecommendedPlacesResponseDTO place = getPlaceDetails(res.placeId);
					if(place != null) result.add(place);
				}
			}
			else log.info("No place found : "+searchKeyword);
		}
		catch (Exception e){
			throw new GoogleApiException();
		}
		return result;
}
   
// 장소 세부정보 API
private GetRecommendedPlacesResponseDTO getPlaceDetails(String placeId){
       GeoApiContext context = new GeoApiContext.Builder()
			.apiKey(apiKey)
			.build();
		
       String photoUrl;
		
       try{
       	// Details API 호출
			PlaceDetails details = PlacesApi.placeDetails(context, placeId).language("ko").await();
			if(details.photos == null)
				return null;
			else {
				photoUrl = "https://maps.googleapis.com/maps/api/place/photo?maxwidth=500&photoreference="
					.concat("&key=")
					.concat(apiKey);
			}
			return new GetRecommendedPlacesResponseDTO(
				details.placeId,
				details.name,
				details.url.toString(),
				details.rating,
				photoUrl
			);
		}catch (Exception e){
			throw new GoogleApiException();
		}
}

getAllRecommendedPlaces() 메서드 내에서 Text Search API를 호출해 검색 결과의 장소 정보들을 가져온다. Text Search API는 호출 한 번 당, 20개의 결과를 반환한다. 20개 장소들에 대해 이미지, 별점, url 등 세부 정보를 가져오기 위해 결과 장소들을 반복문으로 순회하여 getPlaceDetails() 메서드를 호출한다. getPlaceDetails() 메서드에서는 인자로 들어온 장소 고유 ID를 사용해 해당 장소에 대한 Details API를 호출한다.


API 응답 속도 저하 원인

동기 방식 VS 비동기 방식

간단하게 동기 방식과 비동기 방식을 알아보자.
동기식(Synchronous)은 먼저 시작된 작업 하나가 끝날 때까지 대기했다가 다 끝나면 그 다음 작업을 시작하는 방식으로, 작업이 직렬로 배치된다.
비동기식(Asynchronous)은 먼저 시작된 작업이 있어도 그 다음 작업을 시작할 수 있는 방식으로, 작업이 병렬로 배치된다.

API의 성능 저하는 여러가지 이유가 있지만, 내 코드에서는 동기/비동기 방식 차이의 문제라 생각했다.

현재 코드는 한 번의 Text Search API 호출에 대해 20번의 Details API 호출로 구성된다. for문으로 반복하기 때문에, 하나의 장소에 대해 Details API 호출이 모두 완료되어야 그 다음 장소에 대한 API를 호출한다. 즉, 동기 방식으로 작동하는 것이다. 직렬적으로 수행되는 만큼, 시간도 더 오래 걸리기 때문에 이 부분에서 성능 저하가 있다고 판단했다.


API 성능 개선

하나의 Details API 호출 결과가 그 다음 호출에 영향을 끼친다면 동기식을 사용하는게 맞겠지만, 현재 기능에서는 영향을 끼치지 않기 때문에 비동기식으로 변경하고자 했다.

Springboot에서 사용할 수 있는 비동기식 구현 방법은 @Async, CompletableFuture로 두 가지 정도 있는 것 같다.
간단하게 설명하자면 @Async은 Spring에서 제공하는 애노테이션으로, @Async가 붙은 메서드가 비동기 방식으로 실행된다. CompletableFuture 는 Java8 부터 제공된 인터페이스로, 비동기 처리에 필요한 유용한 메서드를 제공한다.

나는 CompletableFuture을 사용해 비동기 방식으로 변경하고자 했다.

1️⃣ 각 호출별 예외 처리를 세밀하게 하고 싶었다.

추천 맛집 API는 Text Search 결과로 나온 최대 20개의 장소에 대해 각각 Google Places Details API를 호출한다.

한 두 개 장소의 조회가 실패하더라도 전체 API를 실패시키기보다는, 실패한 호출만 로깅하고 나머지 정상 결과만 모아서 응답하는 구조가 필요하다.

CompletableFutureexceptionally, handle 등의 메서드를 통해 호출 단위로 예외를 잡고 대체값을 리턴하거나 필터링하는 패턴을 쉽게 적용할 수 있어, “부분 실패 허용 + 세밀한 예외 처리”에 더 적합했다.

2️⃣ @Async는 self-invocation 문제로 구조가 어색해질 수 있다.

@Async는 스프링이 만든 프록시를 통해 호출될 때만 비동기로 동작한다.

같은 클래스 내부에서 this.asyncMethod() 형태로 호출하면 프록시를 타지 않기 때문에,
@Async를 붙여도 동기 호출이 되어버리는 self-invocation 문제가 있다.

내 코드에서는 getAllRecommendedPlace()getPlaceDetails()가 같은 서비스 클래스 안에 있고, 이 안에서 반복문으로 비동기 호출을 생성해야 했다.

이 경우 @Async를 쓰려면 별도 빈으로 분리하거나 자기 자신을 다시 주입받는 등 구조를 바꿔야 해서, 차라리 순수 자바인 CompletableFuture.supplyAsync(...)로 바로 비동기 작업을 만들고 조합하는 쪽이 더 단순하고 명확하다고 판단했다.


CompletableFuture 인터페이스 관련한 정보는 다른 좋은 글들이 많기에.. 여기서는 내가 어떻게 활용했는지, 어느정도로 개선되었는지를 보여주고자 한다.

CompletableFuture를 사용한 비동기식 호출 코드

아래는 CompletableFuture를 사용해 비동기 방식으로 getPlaceDetails 메서드를 호출하도록 변경한 코드이다. getPlaceDetails 메서드는 이전과 동일하게 쓰고 getAllRecommendedPlace 메서드에서만 변경하면 됐다.

public List<GetRecommendedPlacesResponseDTO> getAllRecommendedPlace(Country country, City city) {
    String searchKeyword = country.toString().concat(" ").concat(city.toString()).concat(" 맛집");

    GeoApiContext context = new GeoApiContext.Builder()
            .apiKey(placesApiKey)
            .build();

    try {
        PlacesSearchResponse response = PlacesApi.textSearchQuery(context, searchKeyword).await();

        if (response.results == null || response.results.length == 0) {
            log.info("No place found : {}", searchKeyword);
            return List.of();
        }

        List<CompletableFuture<GetRecommendedPlacesResponseDTO>> futures = Arrays.stream(response.results)
                .map(res ->
                        CompletableFuture.supplyAsync(() -> getPlaceDetails(res.placeId))
                                .exceptionally(ex -> {
                                    // 개별 Details 호출 실패시 로그만 남기고 해당 결과는 제외
                                    log.error("Failed to get place details. placeId={}", res.placeId, ex);
                                    return null;
                                })
                )
                .toList();

        // 모든 비동기 작업이 끝날 때까지 대기
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

        // null(실패한 호출) 제외하고 결과만 모아서 반환
        return futures.stream()
                .map(CompletableFuture::join)
                .filter(Objects::nonNull)
                .toList();

    } catch (Exception e) {
        log.error("Google Places text search failed. keyword={}", searchKeyword, e);
        throw new GoogleApiException();
    }
}

개선 결과 비교

아래가 개선 전 (동기식) API를 Postman으로 호출 테스트 했을 때 걸린 시간이다. Response가 반환되기까지 6.19s 걸렸다.

그리고 비동기식으로 변경해서 호출한 결과, 평균 2s 미만으로 개선됐다. 기존에 6s 걸리던 API 호출이 2s 미만으로 속도가 빨라졌다. 이젠 호출할 때 거슬리지 않는 수준으로 개선됐다!


이번에 API 성능 관련한 글을 여기저기 찾아보니, 성능 저하 원인부터 개선 방법까지 생각보다 훨씬 다양하게 있단걸 깨달았다. 나중에 또 이런 API 성능 저하 이슈가 발생했을 때 동기/비동기 외에도 다양한 개선 방법을 도입해보고 싶다.


참고 및 이미지 출처
https://appmaster.io/ko/blog/api-seongneungiran-mueosibnigga
https://learnjs.vlpt.us/async/
https://mangkyu.tistory.com/263

profile
트러블슈팅과 구현기를 위주로 기록합니다-

0개의 댓글