속도가 느린 외부 API 개선기

Minu·2024년 4월 15일

성능 개선

목록 보기
2/3

배경

동시에 두 외부 API를 호출하면서 응답 속도가 느려 겪었던 문제 개선기

웹툰 정보를 A서비스와 B서비스에 API를 동시에 요청하여, 필요한 부분만 추출해 서버 DB에 저장하는 과정에서 외부 API의 응답속도가 느려 겪었던 문제와 어떻게 속도를 개선시켰는지에 대한 글입니다.

모니터링툴은 Scouter를 이용했습니다.


환경

  • SpringBoot
  • RestTemplate

문제점

저는 아래 그림과 같이 두 곳의 외부 API를 RestTemplate 을 이용해 호출하는 과정이 있었는데요.

여기서 두가지의 문제점이 있었습니다.

동기적으로 두 외부 API호출 로직

  • A서비스 호출 메소드가 끝나야 B서비스를 호출한다.
  • 이 때 A서비스의 응답 속도는 4초다.

이로 인해 B서비스 호출 메소드까지 대기 시간 4초가 생긴다

B서비스 API 반복 호출 시 응답을 기다린다

  • 최대 100개의 데이터만 요청 가능
  • 일일 트래픽 제한
  • 응답 속도 1초

  1. 페이지1, 100개 요청
  2. 응답 받을때까지 대기 (1초)
  3. 페이지2, 100개 요청
  4. 응답 받을때까지 대기 (1초)
  5. n 페이지까지 요청 후 대기 반복

이런식으로 처리가 됩니다. 근데 B서비스는 1페이지당 응답 속도가 대략 1초 정도 걸리는데 그럼 사용자가 20 페이지를 요청하게 된다면 20초정도 걸리게 됩니다.

또한 A서비스 호출 후 B서비스가 호출되므로 4초+20초+로직+DB 저장 및 응답 속도 등... 20초 이상 걸립니다.

코드를봐야 이해가 더 잘 될거라 생각하기 때문에 바로 코드로 넘어가겠습니다.

아래부터는 변경 전 로직입니다.

상위 메소드

 public void mergeAndSave(Platform platform){
    // A API 서비스 호출
    Awebtoon aWebtoon = getAwebtoon(platform);
    
    // B API 서비스 호출 <-- A 서비스 호출 메소드가 끝나야 실행된다
    Bwebtoon bWebtoon = getBwebtoon(platform);
    
    List<Webtoon> webtoons = merge(aWebtoon,bWebtoon);
    save(webtoons);
 }

A서비스 API 호출 메소드

public Awebtoon getAwebtoon(Platform platform){
	String targetUri = buildUri(platform);
	return restTemplate.getForObject(targetUri, Awebtoon.class);
}

B서비스 API 호출 메소드

public Bwebtoon getBwebtoon(Platform platform){

 // from  DB마지막 호출 페이지 to +21페이지
 for (int pageNo = startPage; pageNo <= lastPage; pageNo++) {
            String targetUri = buildUri(platformKeyword, pageNo);
            Bwebtoon response = restTemplate.getForEntity(targetUri, Bwebtoon.class).getBody();
            
            if (isResponseSuccess(response)) {
                // 중복 제거
                response.getItemList().forEach(item -> webtoonMap.putIfAbsent(item.getPrdctNm(), item));
              apiRequestLog.setLastRequestedPage(pageNo);
            } else {
               	// 에러 처리 로직
                break; // 요청 중단
            }
        }
  	}

Scouter 모니터링 툴을 이용해 실제 걸리는 시간을 확인해봤는데요

  • 총 API 요청 횟수 22번, 걸린 시간 27초

  • API 응답 속도 서비스별 확인

  • A 서비스 : 4초
  • B 서비스 : 개당 약 1초

이전에 예상했다시피 20초 이상인 27초나 걸렸습니다.

그나마 다행인건 해당 기능은 관리자만이 할 수 있는 기능이고, 현재 운영중인 사이트엔 저만 관리자이므로 참고 하려했으나.. 너무 느려..


개선

다시 한 번 정리하자면 문제점은 A 서비스 API 호출이 끝나야 B 서비스를 실행하는 대기 시간과 B 서비스 반복 호출 시 응답이 와야 그 다음 응답을 보내는 문제점이었습니다.

이를 개선하기 위해 아래와 같이 변경하기로 했습니다.

1. 서비스 호출 후 발생하는 대기시간 개선

  1. A서비스는 API 호출 후 발생하는 대기 시간이 4초가 있는데 이 때 A서비스는 단 한번만 호출한다.
  2. B서비스는 API 호출 후 여러 페이지를 반복해서 요청하기 때문에 더 오래 걸린다.
  3. 더 오래걸리는 B서비스 API 호출 메소드를 먼저 하도록 하고, B서비스 호출 메소드를 비동기로 변경한다.
public void mergeAndSave(Platform){
	// B서비스 로직은 비동기로 처리한다.
	Bwebtoon bwebtoon = getBwebtoon(platform);
    
    // 순서 변경
    Awebtoon awebtoon = getAwebtoon(platform);
    
    // 병합 및 저장 로직...
}

변경 후 흐름도

  1. 비동기로 B서비스를 호출한다.
  2. 이 때 비동기 호출이기 때문에 메인 Thread는 기다리지 않고 다음 A서비스 호출 메소드를 실행한다.
  3. A 서비스 응답을 받는다.
  4. 그 다음 B 서비스 응답을 받는다.
  5. 모든 요청이 왔을 경우, merge 후 commit한다.

이렇게하여 오래걸리는 B서비스를 호출하도록 던져놓고, A서비스를 바로 호출해 1차적으로 소요 시간을 줄였습니다.

하지만 여전히 문제점이 있었습니다

B서비스 페이지 요청 후 응답이 올때까지 해당 Thread가 여전히 대기하는 시간이 있습니다.

2. API 여러 페이지 호출 개선

이를 개선하기 위해 여러 개의 Thread를 생성하여 아래와 같은 구조로 변경했습니다.

이 때 Thread를 생성할 때 매번 Thread를 생성하고, 삭제하는건 비용이 크기 때문에 비효율적인데요.
(메모리 할당, 컨텍스트 스위칭, 공유 자원 동기화 등..)

그래서 직접 Thread를 생성하기보단 ThreadPool을 이용한다면 미리 정해진 수의 Thread를 생성하고, 요청올때마다 Pool에 있는 Thread를 가져와 해당 Thread가 작업을 실행하고, 작업을 마치고난 뒤 삭제하는게 아니라 다시 Pool로 반환되어 다음 작업을 대기합니다. 이러한 방식을 통해 Thread 생성,삭제 비용을 아낄 수 있습니다.

Java에서는 아래와 같이 ThreadPool을 생성 할 수 있는데요

  ExecutorService executor = Executors.newFixedThreadPool(쓰레드 개수);
  ExecutorService executor = Executors.newWorkStealingPool(쓰레드 개수);
  ExecutorService executor = Executors.newCachedThreadPool(쓰레드 개수);

newFixedThreadPool

  • 고정된 수의 스레드를 갖는 스레드 풀을 생성
  • 즉 할당한 스레드의 수만 생성하고, 더 이상 생성하지 않습니다.
  • 동시에 처리할 수 있는 작업의 수를 제한하고자 할 때 적합합니다. 자원이 제한된 상황에서 자원의 과도한 사용을 방지하고자 할 때 유용합니다.

newWorkStealingPool()

  • 많은 수의 단기 비동기 작업을 처리해야 하고, 작업 처리량을 최대화하고자 할 때 적합합니다. 병렬 작업 처리에 최적화되어 있습니다.
  • 병렬처리 레벨을 지정하지 않으면 현재 시스템의 core 프로세스 개수를 기반으로 동적으로 Pool 사이즈가 할당 됩니다
  • 즉 시스템에 가용 가능한 만큼 쓰레드를 활용하는 ExecutorService를 생성합니다.

newCachedThreadPool

  • 필요에 따라 새 스레드를 생성하는 풀입니다 즉 스레드 수가 동적으로 할당 됩니다.

여기서 제가 선택한 방법은 newFixedThreadPool입니다. 왜냐하면 EC2 free tier에 배포중이기 때문입니다.
물론 newWorkStealingPool나 newCachedThreadPool를 이용한다면 성능상 더 이점을 볼 수 있으나 EC2 free tier 사양으로 사용하기엔 무겁고, 엄청나게 많은 API를 호출하지 않기 때문에 newFixedThreadPool를 선택했습니다.

그럼 이제 페이지 요청 방식이 아래 그림처럼 변경됩니다.

1. API 페이지 요청 작업이 들어온다. (1~20)
2. Queue에 해당 작업들이 배치된다.
3. ThreadPool에서 사용가능한 Thread가 Queue에 있는 작업을 가져와 B서비스 API 호출한다.
4. 호출이 끝날 경우 Thread pool 로 돌아와 Queue에 남아있는 작업을 실행한다.

최종적으로 변경된 코드 흐름도는 아래와 같습니다.

  1. B서비스 호출 이 때 해당 메소드는 비동기 처리이므로 다음 메소드로 넘어간다.
    • 할당된 여러 개의 Thread 들이 B서비스 API 동시에 호출
  2. A서비스 호출
  3. B서비스 완료된 작업 응답
  4. A서비스 응답
  5. B서비스 나머지 완료된 작업 응답
  6. 콜백 함수를 사용하여 B서비스 작업이 완료되면 다음 로직 실행
  7. merge 후 DB commit

상위 메소드

 public void mergeAndSave(Platform platform){
     
    // B API 서비스 비동기 호출
    CompletableFuture<Bwebtoon> bWebtoonAsync = getBwebtoonAsync(platform);
    
    // A API 서비스 호출 (동일)
    Awebtoon aWebtoon = getAwebtoon(platform);

    
     return bWebtoonAsync.thenApply(
            bWebtoon -> {
                List<Webtoon> webtoons = merge(aWebtoon,bWebtoon);
                save(webtoons);
        }).join();
 }

B서비스 API 호출 메소드

public CompletableFuture<Bwebtoon> getBwebtoon(Platform platform){

// 고정된 Thread 수를 생성한다.
ExecutorService executor = Executors.newFixedThreadPool(10);
Map<String, Bwebtoon> webtoonMap = new ConcurrentHashMap<>();

List<CompletableFuture<Void>> futures = IntStream.rangeClosed(startPage, lastPage)
    .mapToObj(pageNo -> CompletableFuture.runAsync(() -> {
        String uri = buildRequestUri(platformKeyword, pageNo);
        WebtoonResponse response = restTemplate.getForEntity(uri, WebtoonResponse.class).getBody();
        // 다음 로직
     
    }, executor))
    .collect(Collectors.toList());

문법같은 경우엔 글이 너무 길어지는거 같아 스킵하겠습니다.

마찬가지로 이전과 같이 Scouter 로 모니터링을 해봤습니다.

우선 개선전 얼마나 걸렸는지부터 다시 확인해보도록 하겠습니다.

개선전 소요 시간

  • Api 총 22번 호출, 소요시간 27초

개선후 소요 시간

  • Api 2번 호출, 소요시간 5초

개선후 응답 정보 상세보기

A 서비스의 응답 속도는 이전과 동일하게 4초
B서비스의 응답 정보를 보면 단 한개만 있는데 이는 비동기로 처리 했기 때문에 그렇습니다.

이로써 27초->6초 약 4.5배나 개선되었습니다...!

아무래도 외부 API 서비스의 응답 속도 자체는 개선하지 못하더라도 이렇게나마 개선 할 수 있는것만으로도 만족합니다.

처음엔 외부 API 요청에 제한이 있을 때 어떻게 해야 개선할 수 있는지 막막했었는데.. 정말 좋은 기회였던 것 같습니다.

그리고 이 글을 쓰면서 모던 자바 인 액션 책이 없었다면 어쨋을까... 라는 생각도 들었습니다.

처음엔 책 내용을 이해하기가 어려웠는데, 실제 프로젝트에 적용해보고 다시 읽으니 이해가 잘 됐습니다.

역시 개발자는 이론->코드 따라치기가 아닌 실제 프로젝트에 적용하고 삽질을 해봐야 하는 거 같습니다.

0개의 댓글