이 글은 오디 - 폴링 로직 리팩터링 시리즈 6편의 글 중 4번째 글입니다.
[오디 -폴링 로직 리팩터링]
1. Warm up Code로 CPU 스파이크 해결하기 feat) JIT Compiler
2. 계정 로드 밸런싱으로 Request Failed를 잡아보자
3. 트랜잭션에서 외부 API를 분리하여 응답 속도를 낮춰보자
개요 : 응답속도 줄이기
오디의 폴링 로직 현황을 보기 위해 40개 약속 - 100명의 가상 유저를 대상으로 30분간 10초간격으로 동시요청을 수행한 결과 외부 API 호출 시점인 10분간격마다 응답속도가 10초 이상 늦어지는 것을 볼 수 있었습니다.

K6로 요청한 18000건의 요청에서 응답지연으로 17571건에서 시나리오가 끝나는 모습도 보입니다.

해당 글은 응답속도 지연의 원인을 파악하고 이것을 개선한 과정을 서술합니다.
가설 : 트랜잭션 내 외부 API 호출로 인한 DB 커넥션 장시간 점유
현재 오디 폴링 로직을 보면 하나의 트랜잭션을 장시간 점유하고 있었습니다.
실제로 순서도로 표현된 해당 로직들이 모두 하나의 트랜잭션 안에서 수행되고 있습니다.

여기서 문제가 되는 것은 외부 API를 트랜잭션 내에서 호출하고 있었다는 사실이었습니다. Odsay 외부 API는 약 0.1초 정도의 delay를 포함합니다. 이는 트랜잭션 시작과 동시에 점유하게 된 DB 커넥션을 장시간 점유하는 문제가 발생할 가능성이 있었습니다.
특히나 프로젝트에서 HikariCP DB Connection Pool을 최적화하지 않은 상태였기 때문에 maxPoolSize =10, corePoolSize = 10으로 설정되어 있었습니다. 이것은 100개의 동시 요청을 보내면 외부 API로 인해 10개의 요청이 트랜잭션을 점유하는 동안 90개의 요청이 대기해야한다는 것을 의미했습니다.
가설 확인하기
이 가설이 맞는지 확인하기 위해 프로메테우스 + 그라파나 도커를 띄워 hikariCP에서 사용가능한 커넥션 개수(hikaricp_connections_idle) 지표를 모니터링해보았습니다.
그리고, 가설대로 외부 API 요청 시점마다 사용가능한 커넥션 개수가 매우 급속도로 고갈되는 모습을 확인할 수 있었습니다.

Task : 외부 API를 트랜잭션에서 분리하라
그럼 지금까지의 문제 상황과 원인, 그리고 태스크를 정리해보겠습니다.
문제 상황 : 10분간격마다 외부 API를 동시요청할 때마다 응답시간이 10s이상으로 지연됨
원인 : 외부 API 호출이 트랜잭션 범위 내에 있어 DB Connection 장시간 점유 -> Connection Pool 고갈
태스크 : 외부 API를 트랜잭션에서 분리하라
Action : 비동기 + 이벤트 구조로 외부 API를 트랜잭션에서 분리하기
현재 코드에서는 외부 API 요청이 하나의 트랜잭션 안에 포함되어 있습니다.
@Transactional //트랜잭션 열림
public MateEtaResponsesV2 findAllMateEtas(MateEtaRequest mateEtaRequest, Mate mate) {
Meeting meeting = mate.getMeeting(); //약속 찾기
Eta mateEta = findByMateId(mate.getId()); //요청자 도착예정정보(Eta) 찾기
updateMateEta(mateEtaRequest, mateEta, meeting); //요청자의 도착 예정정보 업데이트
etaSchedulingService.updateCache(mate);
return etaRepository.findAllByMeetingId(meeting.getId()).stream() //약속 참여자들의 도착 예정정보 매핑 후 반환
.map(eta -> MateEtaResponseV2.of(eta, meeting))
.collect(Collectors.collectingAndThen(
Collectors.toList(),
mateEtas -> new MateEtaResponsesV2(mate.getId(), mateEtas)
));
}
private void updateMateEta(MateEtaRequest mateEtaRequest, Eta mateEta, Meeting meeting) {
mateEta.updateMissingBy(mateEtaRequest.isMissing());
if (mateEta.isMissing()) {
return;
}
if (determineArrived(mateEtaRequest.toCoordinates(), meeting)) {
mateEta.updateArrived();
return;
}
//외부 API 호출
if (isRouteClientCallTime(mateEta)) {
MDC.put("mateId", mateEta.getMate().getId().toString());
RouteTime routeTime = routeService.calculateRouteTime(
mateEtaRequest.toCoordinates(),
meeting.getTargetCoordinates()
);
mateEta.updateRemainingMinutes(routeTime.getMinutes());
}
}
이에 외부 API를 호출하는 로직을 비동기 + 이벤트 리스닝으로 분리하고 비동기 스레드풀을 100개 동시 요청 스파이크에 최적화된 스레드풀로 맞추고자 했습니다.
즉 현재 모든 로직을 하나의 트랜잭션 안에서 처리하고 있는 것을

외부 API 호출 시점에 이벤트만 발행하고, 스파이크에 특화된 스레드풀이 이를 위임받아 처리하도록 개선하고자 하였습니다.
또한, 외부 API 호출과 응답 과정을 트랜잭션 범위에서 분리하고, 응답 결과를 update 할 때만 최소한의 트랜잭션 범위가 잡히도록 하였습니다.

비동기 스레드풀 설정은 최적화 이전에 일단 다음과 같이 설정하였습니다.
@Configuration
public class RouteTimeCallTaskExecutorConfig {
@Bean("routeTimeCallExecutor")
public ThreadPoolTaskExecutor spikeThreadPool(MeterRegistry meterRegistry) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int coreCount = Runtime.getRuntime().availableProcessors();
executor.setCorePoolSize(coreCount * 2);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(50);
executor.setKeepAliveSeconds(10);
executor.setThreadNamePrefix("route-time-call-task-executor-");
executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
executor.getThreadPoolExecutor().prestartAllCoreThreads();
ExecutorService executorService = executor.getThreadPoolExecutor();
ExecutorServiceMetrics.monitor(meterRegistry, executorService, "routeTimeCallExecutor", "async");
return executor;
}
}
- corePoolSize : 코어 X 2(Ec2 환경에서는 4개) - 주로 외부 API를 호출하는 I/O 바운드 작업이므로
- maxPoolSize : 100 - 최대 동시 요청이 100개를 수용가능한 설정
- queueSize : 50
그리고 외부 API 호출을 이벤트 발행 -> 비동기 수신 과정으로 분리하였습니다.
private void updateMateEta(MateEtaRequest mateEtaRequest, Eta mateEta, Meeting meeting) {
// 중략
if (isRouteClientCallTime(mateEta)) {
UpdateRouteTimeEvent updateRouteTimeEvent = new UpdateRouteTimeEvent(
this,
mateEta.getId(),
mateEtaRequest.toCoordinates(),
meeting.getTargetCoordinates()
);
applicationEventPublisher.publishEvent(updateRouteTimeEvent); // 이벤트 발행
}
}
// 이벤트 수신
@Async("routeTimeCallExecutor")
@EventListener(UpdateRouteTimeEvent.class)
public void updateByRouteTimeCall(UpdateRouteTimeEvent event) {
Coordinates origin = event.getOrigin();
Coordinates target = event.getTarget();
RouteTime routeTime = routeService.calculateRouteTime(origin, target);
etaRepository.updateRemainingTimeById(
event.getEtaId(),
routeTime.getMinutes(),
TimeUtil.nowWithTrim()
);
}
Result
다시 동일조건으로 Warm -up 후 부하테스트를 진행해보았습니다.
일단 30분 후, K6 요청결과를 확인해보니 시나리오에 요구한 18000건의 요청이 30분 30초안에 지연으로 미수신없이 모두 성공한 화면을 보았습니다. 예감이 좋았습니다.

그라파나를 통해 HikariCp DB Connection Pool을 확인해본 결과 API 호출 시점마다 Connection Pool이 고갈되지 않고 5개를 점유하는 수준에서 유지되었습니다.

또한 API 호출 시점의 응답시간 또한 기존의 13s > 평균 80ms 정도로 개선되었으며
외부 API를 다른 스레드풀에서 실행함에 따라 TPS도 1/3 토막나지 않고 10을 유지하였습니다.

2차 시도를 해본 결과 API 호출시점에 43ms 라는 매우 단축된 응답시간을 보여주기도 했습니다.


그러나, 그라파나를 통해 비동기 스레드풀의 Pool Size 를 모니터링한 결과, 요청 과정에서 Pool Size가 4 > 45 로 급증하는 현상을 보였습니다. 이에 따라 tomact과 비동기 스레드풀 최적화의 필요성을 느꼈습니다.

또한 외부 API를 호출 이벤트를 수신하는 비동기 스레드풀을 corePoolSize = 4, maxPoolSize = 100, queue Size = 50으로 한 결과, 큐에 태스크가 50개까지 쌓이면서 100개의 외부 API를 호출하는 시점이 동일하지 않다는 문제가 발생했습니다.
즉, 어떤 약속참여자는 10시 정각에 외부 API를 호출하고 다른 약속참여자의 외부 API 호출 태스크는 큐에 대기하다가 10시 01분에 호출이 되었습니다. 이에 따라 최대한 호출 시점을 동기화할 수 있도록 스레드풀 설정을 변경해주어야 겠다는 생각이 들었습니다.
다음 글에서는 비동기 스레드풀을 최적화한 기준에 대해 다루어보고자 합니다.
요약
- 문제 상황 : 10분간격마다 외부 API를 동시요청할 때마다 응답시간이 10s이상으로 지연됨
- 원인 : 외부 API 호출이 트랜잭션 범위 내에 있어 DB Connection 장시간 점유 -> Connection Pool 고갈
- 외부 API 호출 후 업데이트 로직을 이벤트 구조로 분리하고 트랜잭션 범위 내에서 API 로직을 분리함
- Connection Pool이 최소 5개 수준을 유지하며 고갈되지 않음
- API 호출 시점의 응답시간은 10s 이상 > 80ms 정도로 대폭 개선됨
[오디 -폴링 로직 리팩터링]
1. Warm up Code로 CPU 스파이크 해결하기 feat) JIT Compiler
2. 계정 로드 밸런싱으로 Request Failed를 잡아보자
'프로젝트 > 오디' 카테고리의 다른 글
| [오디] '따닥' 중복 삽입 동시성 이슈 대응을 위한 8가지 대안 (5) | 2025.10.30 |
|---|---|
| [오디 - 폴링 로직 리팩터링] 4. 동시 호출 스파이크에 맞는 스레드풀 최적화 (0) | 2025.09.22 |
| [오디 - 폴링 로직 리팩터링] 2. 계정 로드 밸런싱으로 Request Failed를 잡아보자 (0) | 2025.09.21 |
| [오디 - 폴링 로직 리팩터링] 1. Warm up Code로 CPU 스파이크 해결하기 feat) JIT Compiler (2) | 2025.09.21 |
| [오디 - 폴링 로직 리팩터링] 0. 실험 설계 (0) | 2025.09.19 |