동시에 두 외부 API를 호출하면서 응답 속도가 느려 겪었던 문제 개선기
웹툰 정보를 A서비스와 B서비스에 API를 동시에 요청하여, 필요한 부분만 추출해 서버 DB에 저장하는 과정에서 외부 API의 응답속도가 느려 겪었던 문제와 어떻게 속도를 개선시켰는지에 대한 글입니다.
모니터링툴은 Scouter를 이용했습니다.
저는 아래 그림과 같이 두 곳의 외부 API를 RestTemplate 을 이용해 호출하는 과정이 있었는데요.

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

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

- 페이지1, 100개 요청
- 응답 받을때까지 대기 (1초)
- 페이지2, 100개 요청
- 응답 받을때까지 대기 (1초)
- 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);
}
public Awebtoon getAwebtoon(Platform platform){
String targetUri = buildUri(platform);
return restTemplate.getForObject(targetUri, Awebtoon.class);
}
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 모니터링 툴을 이용해 실제 걸리는 시간을 확인해봤는데요


이전에 예상했다시피 20초 이상인 27초나 걸렸습니다.
그나마 다행인건 해당 기능은 관리자만이 할 수 있는 기능이고, 현재 운영중인 사이트엔 저만 관리자이므로 참고 하려했으나.. 너무 느려..
다시 한 번 정리하자면 문제점은 A 서비스 API 호출이 끝나야 B 서비스를 실행하는 대기 시간과 B 서비스 반복 호출 시 응답이 와야 그 다음 응답을 보내는 문제점이었습니다.
이를 개선하기 위해 아래와 같이 변경하기로 했습니다.
public void mergeAndSave(Platform){
// B서비스 로직은 비동기로 처리한다.
Bwebtoon bwebtoon = getBwebtoon(platform);
// 순서 변경
Awebtoon awebtoon = getAwebtoon(platform);
// 병합 및 저장 로직...
}

이렇게하여 오래걸리는 B서비스를 호출하도록 던져놓고, A서비스를 바로 호출해 1차적으로 소요 시간을 줄였습니다.
하지만 여전히 문제점이 있었습니다
B서비스 페이지 요청 후 응답이 올때까지 해당 Thread가 여전히 대기하는 시간이 있습니다.
이를 개선하기 위해 여러 개의 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입니다. 왜냐하면 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에 남아있는 작업을 실행한다.
최종적으로 변경된 코드 흐름도는 아래와 같습니다.

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();
}
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 로 모니터링을 해봤습니다.
우선 개선전 얼마나 걸렸는지부터 다시 확인해보도록 하겠습니다.



A 서비스의 응답 속도는 이전과 동일하게 4초
B서비스의 응답 정보를 보면 단 한개만 있는데 이는 비동기로 처리 했기 때문에 그렇습니다.
이로써 27초->6초 약 4.5배나 개선되었습니다...!
아무래도 외부 API 서비스의 응답 속도 자체는 개선하지 못하더라도 이렇게나마 개선 할 수 있는것만으로도 만족합니다.
처음엔 외부 API 요청에 제한이 있을 때 어떻게 해야 개선할 수 있는지 막막했었는데.. 정말 좋은 기회였던 것 같습니다.
그리고 이 글을 쓰면서 모던 자바 인 액션 책이 없었다면 어쨋을까... 라는 생각도 들었습니다.
처음엔 책 내용을 이해하기가 어려웠는데, 실제 프로젝트에 적용해보고 다시 읽으니 이해가 잘 됐습니다.
역시 개발자는 이론->코드 따라치기가 아닌 실제 프로젝트에 적용하고 삽질을 해봐야 하는 거 같습니다.
