-
CompletableFuture + commonPool은 왜 위험한가 — timeout을 걸어도 서버가 망가지는 이유Spring 2025. 12. 24. 16:46728x90반응형
[개요]
이 글은 다음 질문에서 출발했다.
“CompletableFuture로 비동기 처리하고 timeout까지 걸었는데,
왜 서버는 여전히 터질 수 있을까?”이 질문에 답하기 위해
ForkJoinPool(commonPool), CompletableFuture timeout, RestTemplate 블로킹 I/O를
직접 코드와 로그로 실험하며 검증했다.이 글은 이론 설명이 아니라
실제 실험 결과를 통해 ‘왜 이런 문제가 발생하는지’를 증명하는 기록이다.
1. 처음 상태: commonPool + CompletableFuture (timeout 없음)
대출 비교 API를 구현하면서 여러 금융사 API를 병렬 호출했다.
각 금융사 API는 RestTemplate을 사용한 외부 HTTP 호출이었고, 느린 금융사(SLOW)가 하나 포함되어 있었다.처음 코드는 정말 단순했다.
CompletableFuture.supplyAsync(() -> callBlocking(id, amount));
executor를 지정하지 않았기 때문에, 이 코드는 기본적으로 ForkJoinPool.commonPool에서 실행된다.
의도는 이랬다.
여러 금융사를 병렬로 호출하고, 가장 느린 호출 하나만 기다리면 되니 응답 시간은 그렇게 나쁘지 않을 거라고 생각했다.하지만 k6로 부하를 걸자 바로 문제가 드러났다.
commonPool + timeout 없음 상태에서의 결과는 다음과 같았다.
p95는 50초 이상,
p99는 55~60초까지 튀었고,
처리량은 초당 6건 수준으로 붕괴됐다.병렬 호출인데도 응답 시간이 순차 호출보다 훨씬 나빴다.
이 시점에서 첫 번째 의문이 생겼다.“비동기인데 왜 이렇게까지 느려질 수 있지?”
2. 첫 번째 개선 시도: commonPool + completeOnTimeout(300ms)
가장 먼저 떠오른 해결책은 단순했다.
“느린 외부 호출을 잘라내면 되지 않을까?”그래서 CompletableFuture에 timeout을 추가했다.
CompletableFuture
.supplyAsync(() -> callBlocking(id, amount))
.completeOnTimeout(null, 300, TimeUnit.MILLISECONDS);의도는 명확했다.
300ms가 넘는 금융사 호출은 포기하고,
전체 응답 시간을 300ms 근처로 제한하자.실제로 결과는 겉보기에는 좋아졌다.
p95는 300~400ms 수준으로 떨어졌고,
처리량도 400 req/s 이상으로 회복됐다.이 시점까지만 보면 “문제 해결”처럼 보였다.
하지만 VU를 조금 더 올리자 다시 이상한 현상이 나타났다.
요청 실패율이 급격히 증가했고,
VU가 유지되지 않았으며,
처리량이 다시 무너졌다.여기서 두 번째 의문이 생겼다.
“timeout으로 Future를 끝냈는데, 왜 서버는 여전히 불안정하지?”
3. 결정적인 오해: “timeout이면 스레드도 끝나는 거 아닌가?”
이 시점에서 자연스럽게 이런 생각을 했다.
“completeOnTimeout으로 Future를 종료시켰으면,
그 작업을 하던 스레드도 같이 종료되는 거 아닌가?”이게 이 실험의 핵심적인 오해였다.
이걸 말로 추측하는 대신,
실제로 어떤 스레드가 언제까지 실행되는지 로그로 확인하기로 했다.
4. 실험 설계: 스레드를 직접 찍어보자
다음 정보를 모두 로그로 찍도록 코드를 수정했다.
- 요청을 처리하는 톰캣 요청 스레드
- RestTemplate 호출을 수행하는 실제 작업 스레드
- Future가 timeout으로 완료되는 시점
- 실제 HTTP 호출이 끝나는 시점
또한 실험을 명확하게 하기 위해 SLOW 금융사는 최소 1200ms 이상 걸리도록 고정했고, timeout은 300ms로 유지했다.
이렇게 하면 “Future는 300ms에 끝났는데, 실제 호출은 계속 도는지”가 명확히 보이게 된다.
5. 실제 실험 로그
아래는 /loan/compare/before 요청을 단 한 번 호출했을 때 찍힌 실제 로그다.
[rid=1][REQ_THREAD(before)=http-nio-8080-exec-1][t=0ms][thread=http-nio-8080-exec-1] [rid=1][CALL_START lender=A][t=8ms][thread=ForkJoinPool.commonPool-worker-1] [rid=1][CALL_START lender=B][t=9ms][thread=ForkJoinPool.commonPool-worker-2] [rid=1][CALL_START lender=C][t=10ms][thread=ForkJoinPool.commonPool-worker-3] [rid=1][CALL_START lender=SLOW][t=10ms][thread=ForkJoinPool.commonPool-worker-4] [rid=1][FUTURE_DONE lender=A => TIMEOUT(null)][t=313ms][thread=CompletableFutureDelayScheduler] [rid=1][FUTURE_DONE lender=B => TIMEOUT(null)][t=313ms][thread=CompletableFutureDelayScheduler] [rid=1][FUTURE_DONE lender=C => TIMEOUT(null)][t=314ms][thread=CompletableFutureDelayScheduler] [rid=1][FUTURE_DONE lender=SLOW => TIMEOUT(null)][t=314ms][thread=CompletableFutureDelayScheduler] [rid=1][ALL_OF_JOIN_END before][t=314ms][thread=http-nio-8080-exec-1] [rid=1][RESP_QUOTES=0][t=317ms][thread=http-nio-8080-exec-1] [rid=1][CALL_END lender=A][t=378ms][thread=ForkJoinPool.commonPool-worker-1] [rid=1][CALL_END lender=B][t=451ms][thread=ForkJoinPool.commonPool-worker-2] [rid=1][CALL_END lender=C][t=473ms][thread=ForkJoinPool.commonPool-worker-3] [rid=1][CALL_FAIL lender=SLOW][t=661ms][thread=ForkJoinPool.commonPool-worker-4]
6. 이 로그 하나로 증명된 사실
이 로그는 굉장히 많은 걸 말해준다.
먼저, 요청을 처리한 톰캣 요청 스레드는 314ms 시점에 allOf().join()을 끝내고 응답을 반환했다.
즉, 클라이언트 입장에서의 응답 시간은 timeout 덕분에 짧아졌다.하지만 그 이후를 보면 전혀 다른 일이 벌어지고 있다.
Future가 timeout으로 종료된 이후에도,
RestTemplate 호출을 수행하던 ForkJoinPool.commonPool-worker 스레드들은
378ms, 451ms, 473ms, 심지어 661ms까지 계속 실행되고 있었다.즉, Future는 끝났지만 실제 작업 스레드는 계속 블로킹 상태로 남아 있었다.
이 순간 깨달았다.
completeOnTimeout은
“결과를 기다리는 걸 포기한다”는 의미이지,
“작업을 중단시킨다”는 의미가 아니라는 걸.
7. 왜 ForkJoinPool(commonPool)이 특히 위험했는가
ForkJoinPool.commonPool은 JVM 전체에서 공유되는 전역 풀이다.
CompletableFuture에서 executor를 지정하지 않으면 기본으로 사용된다.문제는 여기에 블로킹 I/O(RestTemplate)를 올렸다는 점이다.
외부 API가 느려질수록 commonPool 워커 스레드는 네트워크 대기 상태로 묶이고,
이 풀을 공유하는 다른 비동기 작업들까지 함께 영향을 받는다.timeout으로 응답은 빨라졌지만,
서버 내부에서는 스레드 점유가 계속 누적되고 있었다.이게 바로 commonPool이 위험해지는 이유였다.
8. 최종 해결: 외부 I/O 전용 ThreadPoolExecutor
해결 방법은 결국 “격리”였다.
외부 API 호출을 commonPool에서 완전히 분리했다.
CompletableFuture.supplyAsync(() -> callBlocking(id, amount), ioExecutor);
외부 I/O 전용 ThreadPoolExecutor를 두면서 다음이 가능해졌다.
외부 금융사 API가 느려져도 내부 로직과 다른 비동기 작업이 영향을 받지 않고,
스레드 수와 큐를 통해 명확한 backpressure를 걸 수 있었고,
p95와 p99가 함께 안정화됐다.실제 실험 결과,
ioExecutor + timeout 구조에서는 p95가 약 400ms 수준으로 유지됐고,
VU를 올려도 처리량이 안정적으로 유지됐다.
최종 결론
CompletableFuture의 timeout은
결과를 기다리는 걸 멈출 뿐이다.이미 블로킹 I/O를 수행 중인 스레드를
중단시키지는 않는다.따라서 외부 I/O는 반드시
ForkJoinPool(commonPool)이 아닌
전용 ThreadPoolExecutor로 격리해야 한다.그래야 p95, p99와 시스템 안정성을 함께 지킬 수 있다.
────────────────────────────────────
마무리
이 글의 핵심은 API 선택이 아니라 시스템 모델 이해다.
- Future는 스레드가 아니다
- timeout은 작업 중단이 아니다
- 비동기는 non-blocking이 아니다
- p95 개선은 곧 안정성을 의미하지 않는다
이 모든 것을
코드, 로그, 부하 테스트로 직접 확인한 경험이
이 글의 가장 큰 가치다.글은 “CompletableFuture를 썼는데도 서버가 왜 터질 수 있는가?”라는 질문에서 시작했다.
이론 정리가 아니라, 실제로 코드를 작성하고 부하 테스트를 돌리면서 겪은 과정을 그대로 정리한 기록이다.핵심은 단순하다.
비동기 = 안전이라는 착각이 어떻게 깨졌는지,
그리고 그 원인을 어떻게 로그로 증명했는지다.728x90반응형'Spring' 카테고리의 다른 글
Spring의 @Transactional은 AOP 프록시 기반 프록시를 “통과하는” 메서드 호출만 트랜잭션 어드바이스가 적용된다. (0) 2025.10.13 List<MultipartFile> 가 톰캣서버,스프링에서 읽어드리는 과정 (1) 2024.11.12 Spring framework에서 사용하는 디자인 패턴 (0) 2024.11.11 AOP vs OOP 차이점 (0) 2024.09.12 Spring에서 @Autowired를 사용하는 것보다 생성자 주입 방식이 나은 이유? (0) 2024.07.24