앱스토어에 앱을 배포한 이후, 테스트를 해보는 중에 해변별 기상 정보들을 불러오는 부분에서 필요 이상으로 시간이 오래 걸리기에 이를 해결하기로 하였다.
문제점

저번에도 꽤 걸렸었지만 이번에는 엄청 오래 걸린다.. 대략 13.30 s 정도..?
현재 도시에 있는 해변 별로 기상청 API를 3가지 정도(해양 정보, 파주기, 수온) 불러오고 있는데 이를 순차적으로 불러오다보니 속도가 저하되는 것으로 의심하고 있다. 아마 말로만 듣던 순차적 API 호출로 인한 성능 저하로 의심되는데.. 이를 확인하기 위해 로그를 출력해보았다.

4번 도시에는 3개의 해변이 등록되어 있고, 각 해변 당 BeachForecast, WaterTemp, WavePeriod 기상청 API를 호출하고 있는데 이들 모두를 불러오는데 걸린 총 걸린 시간이 8989ms이다. 초로 환산하면 대략 9초 정도 걸린 셈이다. 위의 13초까지는 아니지만 그래도 상당히 크리티컬해보일만큼 긴 시간이 걸린다.
성능적으로 분석해보아도 꽤나 크리티컬한 부분이었다. 예를 들어, 동시 사용자가 여러 명일 때, 동기식 순차 처리로 인해 응답 시간이 누적되고 이는 서버 쪽에서도 부하가 걸릴 가능성이 크다. 또한 캐싱 처리도 되어있지 않기 때문에 API도 중복적으로 호출하게 되는 일이 발생한다. 이 외에도 단일 스레드에서 모든 작업을 수행하는 등의 문제도 있다. 이러한 문제점들을 개선하기 위해 병렬 처리와 캐싱을 도입하게 되었다.
최적화 방안
병렬 처리
위와 같은 문제들로 인해 한 엔드포인트에서 요청하는 외부 API들을 비동기 식으로 호출하여 병렬 처리를 해야 할 필요가 있다.
예시로 들자면 아래 방식으로의 리팩토링이 필요하다.
현재 (순차 호출):
해변1: API1 → API2 → API3 (1.2초)
해변2: API1 → API2 → API3 (1.2초)
총 2.4초
최적화 (병렬 호출):
해변1: API1 + API2 + API3 동시 실행 (0.5초)
해변2: API1 + API2 + API3 동시 실행 (0.5초)
기존 코드의 문제점
기존의 코드를 보면 외부 API들을 순차적으로 호출한다는 점이 가장 큰 병목 지점이다. 다음 세 개의 API는 서로 의존성이 없음에도 불구하고 순차적으로 실행된다는 것이 문제이다.
- getWaterTemp()는 getBeachForecast()의 결과를 사용하지 않는다.
- getWavePeriod()는 앞의 두 API 결과를 사용하지 않는다.
- 따라서 호출하는 외부 API들은 서로 의존성이 없으므로 동시에 호출이 가능하다.
기상청 API 서버가 일시적으로 과부하 증상을 보일 경우, 장애 시나리오는 다음과 같다.
- 예를 들어 하나의 getBeachForecast() 호출이 30초 타임아웃이다.
- 전체 메소드가 30초 × 해변 수만큼 지연된다.
- 다른 정상적인 API 데이터도 함께 손실된다.
...
.map((Seashore seashore) -> {
// 1단계: 해변 예보 API 호출 (하나의 API가 실패하면 전체 해변 데이터 손실)
BeachForecastResponse forecastResponse = getBeachForecast(seashore.getBeachCode());
// 이 외부 API가 타임아웃되면 해당 해변의 모든 정보가 없어짐
// 2단계: 수온 API 호출
// ↑ 위 API 완료 후에야 시작
String waterTemp = getWaterTemp(seashore.getBeachCode());
// 3단계: 파주기 API 호출
// ↑ 위 API 완료 후에야 시작
String wavePeriod = getWavePeriod(seashore.getBeachCode());
// 총 소요시간: 1 + 2 + 3단계 호출 시간
return SeashoreResponse.create(seashore, waterTemp,
BeachForecast.create(forecastResponse), wavePeriod);
})
이러한 기존 코드를 CompletableFuture를 적용하여 비동기로 호출하도록 개선하였다. 그런데 의문점이 하나 생긴다. Parallel Stream으로도 병렬 처리가 가능한데 왜 하필 CompletableFuture를 쓰는걸까?
Parallel Stream 대신 CompletableFuture를 쓰는 이유
1. 조합 기능
현재 기능적으로는 각 해변마다 3개의 다른 API(단기예보, 수온, 파주기)를 호출해야 한다. allOf()로 3개 작업이 모두 완료되길 기다린 후 결과 조합를 조합하여 반환할 수 있는 메서드가 있는 것에 비해 Parallel Stream으로는 이런 조합을 만드는 것이 어렵다.
CompletableFuture<BeachForecastResponse> forecastFuture = ...
CompletableFuture<Double> waterTempFuture = ...
CompletableFuture<Double> wavePeriodFuture = ...
return CompletableFuture.allOf(forecastFuture, waterTempFuture, wavePeriodFuture)
.thenApply(v -> SeashoreResponse.create(...));
2. 이중 병렬화 구조
외부 루프(stream) 쪽에서는 여러 해변을 병렬 처리하는데 map 내부적으로는 각 해변의 3개 API를 다시 병렬 처리한다. 이에 비해 Parallel Stream은 단일 레벨 병렬화만 지원한다.
List<CompletableFuture<SeashoreResponse>> futures = seashores.stream() // 외부: 해변 간 병렬
.map(seashore -> {
// 내부: 각 해변의 API 3개 병렬
CompletableFuture<BeachForecastResponse> forecastFuture = ...
CompletableFuture<Double> waterTempFuture = ...
CompletableFuture<Double> wavePeriodFuture = ...
})
// 불가능 - 각 해변의 3개 API를 동시 실행할 수 없음
seashores.parallelStream()
.map(seashore -> {
// 여기서 3개 API는 순차 실행됨
var forecast = getBeachForecast(...);
var waterTemp = getWaterTemp(...);
var wavePeriod = getWavePeriod(...);
return SeashoreResponse.create(...);
})
3. 스레드 제어
CompletableFuture.supplyAsync()은 나중에 커스텀 Executor를 주입이 가능하다. 그러나 Parallel Stream은 스레드 풀 커스터마이징이 제한적므로 CompletableFuture가 좀 더 유연한 활용이 가능하다.
CompletableFuture를 활용한 비동기 병렬 처리 구현
다음과 같이 CompletableFuture과 전용 ThreadPool을 사용하여 병렬 처리를 적용해보았다.
해변 목록 조회
seashoreRepository.findByCityId(cityId)로 특정 도시의 해변들을 가져온다.
각 해변마다 병렬 처리: 각 해변에 대해 3개의 API를 병렬로 호출한다
- getBeachForecast() - 해변 예보 정보
- getWaterTemp() - 수온 정보
- getWavePeriod() - 파도 주기 정보
CompletableFuture 생성
각 API 호출을 supplyAsync()로 감싸서 비동기 작업으로 만든다.
작업 완료 대기: CompletableFuture.allOf()로 3개 API 호출이 모두 완료될 때까지 기다린다.
결과 조합: thenApply()에서 3개 API 결과를 조합해서 SeashoreResponse 객체를 생성한다.
최종 결과 수집: 모든 해변의 CompletableFuture가 완료되면 join()으로 결과를 수집한다.
@Service
public class SeashoreService {
private final SeashoreRepository seashoreRepository;
private Executor asyncExecutor; // 전용 ThreadPool 사용
...
public List<SeashoreResponse> getSeashoresByCity(Long cityId) {
List<Seashore> seashores = seashoreRepository.findByCityId(cityId);
long startTime = System.currentTimeMillis();
List<CompletableFuture<SeashoreResponse>> futures = seashores.stream()
.map(seashore -> {
// 타임아웃 설정 포함
CompletableFuture<BeachForecastResponse> forecastFuture =
CompletableFuture.supplyAsync(() -> getBeachForecast(seashore.getBeachCode()), asyncExecutor)
.orTimeout(10, TimeUnit.SECONDS);
CompletableFuture<String> waterTempFuture =
CompletableFuture.supplyAsync(() -> getWaterTemp(seashore.getBeachCode()), asyncExecutor)
.orTimeout(10, TimeUnit.SECONDS);
CompletableFuture<String> wavePeriodFuture =
CompletableFuture.supplyAsync(() -> getWavePeriod(seashore.getBeachCode()), asyncExecutor)
.orTimeout(10, TimeUnit.SECONDS);
return CompletableFuture.allOf(forecastFuture, waterTempFuture, wavePeriodFuture)
.thenApply(v -> SeashoreResponse.create(seashore,
waterTempFuture.join(),
BeachForecast.create(forecastFuture.join()),
wavePeriodFuture.join()
))
.exceptionally(ex -> {
log.error("기상청 API 호출 실패, 해변코드 {}: {}", seashore.getBeachCode(), ex.getMessage());
return SeashoreResponse.create(seashore, "", null, "");
});
})
.toList();
List<SeashoreResponse> results = futures.stream()
.map(CompletableFuture::join)
.toList();
long endTime = System.currentTimeMillis();
log.info("전체 기상청 API 호출 소요 시간: {}ms, {} seashores (cityId: {})",
endTime - startTime, seashores.size(), cityId);
return results;
}
...
}
위 수정사항에 대해 하나하나 뜯어보자.
1. 전용 ThreadPool 사용
스레드 풀 크기를 설정하되, 고정이 아닌 동적으로 계산하는 방식으로 개선하였다.
@PostConstruct
public void init() {
// cpu 코어 수 조회 (jvm이 사용하 수 있는 cpu 코어 수 반환) => 물리 코어 + 하이퍼스레딩
int availableProcessors = Runtime.getRuntime().availableProcessors();
// 스레드풀 크기 계산 (CPU 코어 수 * 배수) => CPU가 api응답을 기다리는 동안 유휴 상태
// 3배 할당하면 대기 시간 동안 다른 작업 처리 가능
int calculatedSize = availableProcessors * ThreadPoolConfig.IO_BOUND_MULTIPLIER;
// 최소값과 최대값 사이 범위 제한 (최소값 <= threadPoolSize <= 최대값)
int threadPoolSize = Math.max(
ThreadPoolConfig.MIN_THREAD_POOL_SIZE,
Math.min(calculatedSize, ThreadPoolConfig.MAX_THREAD_POOL_SIZE)
);
// 스레드풀 생성 전 로그 출력
log.info("스레드풀 초기화: CPU 코어 {}개, 계산된 크기 {}, 최종 크기 {}",
availableProcessors, calculatedSize, threadPoolSize);
this.asyncExecutor = Executors.newFixedThreadPool(threadPoolSize);
}
2. 타임아웃 설정
.orTimeout(10, TimeUnit.SECONDS)
- 문제: API 응답 지연 시 무한 대기 가능
- 해결: 10초 타임아웃으로 무한적으로 지연되는 부분을 해결했다.
- 효과: 시스템 안정성을 향상시키고, 예측 가능한 응답 시간을 제공할 수 있다.
3. 예외 처리 강화
.exceptionally(ex -> {
log.error("기상청 API 호출 실패, 해변코드 {}: {}", seashore.getBeachCode(), ex.getMessage());
return SeashoreResponse.create(seashore, "", null, "");
});
- 문제: 하나의 API 실패가 전체 결과에 영향
- 해결: 실패 시 기본값으로 대체하여 부분적 성공 보장
- 효과: 서비스 가용성이 향상되었다.
성능 개선 효과
5.69s초까지 응답 시간을 줄였다. (솔직히 이것도 조금 오래 걸린다고 생각했으나 13초보다는 훨씬 낫다)

위 병렬 처리 로직의 전과 후를 비교해보자면 아래와 같다.
순차 처리 : 3개 API(각 응답 시간) × N개 해변
병렬 처리 : max(3개 API 중 최대 시간) × N개 해변 (병렬 처리)
예를 들면 다음과 같다. 한 도시에 5개의 해변이 있고, 각 기상청 외부 API를 호출하는 시간이 2초 쯤 걸린다고 하면
이전 : 5 × (2+2+2) = 30초
개선 후 : 5 × max(2,2,2) = 10초
정량적인 수치로 따진다면 3배의 성능 향상이 이루어졌고, 퍼센트로 따지면 약 66% 정도의 개선율을 보였다.