2021년 10월 말, 블랙프라이데이를 3주 앞두고 기획팀에서 숫자 하나를 들고 왔다. 앱 마이페이지 진입 응답이 p99 기준 4.2초라는 것이었다. 행사 기간에는 주문 내역과 쿠폰을 확인하려는 사람이 몰리는 화면이라 그 전에 줄여 달라는 요청이었다. 코드를 열어 보니 원인은 단순했다. 서로 상관없는 외부 API 다섯 개를 한 줄씩 차례로 부르고 있었다. “CompletableFuture로 동시에 부르면 끝나겠네.” 그렇게 시작한 일이 2주 동안 네 번 터졌다. 이 글은 그 기록이다.
당시 구조를 먼저 적어 둔다. 마이페이지 API는 앱 전용 중계 서버(BFF)에 있었고, Java 11·Spring Boot 2.4 환경이었다. 이 서버가 회원·주문·포인트·쿠폰·배송 다섯 서비스를 RestTemplate으로 호출해 한 화면 분량으로 묶어 내려 주었다. 운영은 컨테이너 4개, 각각 2 vCPU였다. 이 “2”가 나중에 첫 번째 사고의 원인이 된다.
순차 호출 다섯 줄, 응답 시간은 그냥 더해지고 있었다

기존 코드는 이랬다. 다섯 호출 모두 회원 ID만 있으면 되고, 서로의 결과를 쓰지 않았다(쿠폰 하나만 예외였는데, 그 얘기는 뒤에서 한다).
// 2021.10 당시: p50 1.9초, p99 4.2초
Member member = memberClient.get(id); // 평균 0.25s
OrderSummary order = orderClient.summary(id); // 평균 0.6s
Point point = pointClient.get(id); // 평균 0.2s
List<Coupon> coupons = couponClient.list(id); // 평균 0.5s
Delivery delivery = deliveryClient.recent(id); // 평균 0.35s
return MyPage.of(member, order, point, coupons, delivery);
평균만 더해도 1.9초다. p99가 4.2초까지 벌어진 건 다섯 서비스 중 하나만 꼬리 지연이 와도 그 시간이 그대로 합산되기 때문이었다. 동시에 부르면 총 시간은 “가장 느린 하나”로 줄어든다. 여기까지는 누구나 아는 이야기였고, 나도 하루면 끝날 줄 알았다.
첫 번째 사고 — 로컬에선 0.7초, 운영에선 스레드 2,300개
첫 버전은 supplyAsync로 다섯 호출을 띄우고 allOf로 기다리는 교과서 그대로의 코드였다. 실행기는 따로 넘기지 않았다. 8코어 노트북에서 p50이 0.7초로 떨어졌고, 스테이징에서도 잘 돌았다. 10월 28일 배포했다.
그날 저녁 부하가 오르자 컨테이너 스레드 수 그래프가 요청량을 따라 톱니처럼 치솟았다. 평소 200개 남짓이던 것이 2,300개까지 올라갔고, 컨테이너 하나가 메모리 한도에 걸려 OOMKilled로 재시작됐다. 힙은 멀쩡했다. 스레드 하나마다 잡히는 네이티브 스택이 쌓인 것이었다.
원인을 찾는 데 반나절이 걸렸다. 실행기를 넘기지 않은 supplyAsync는 공용 풀(ForkJoinPool.commonPool)을 쓴다고만 알고 있었는데, 조건이 하나 붙어 있었다. 공용 풀의 병렬도는 기본이 “코어 수 − 1″이고, 이 값이 1 이하이면 CompletableFuture는 공용 풀 대신 작업마다 새 스레드를 만드는 실행기로 바꿔 쓴다. Java 11은 컨테이너의 CPU 제한을 인식하므로 2 vCPU 컨테이너에서는 코어 수가 2, 병렬도는 1이 된다. 결국 요청 하나당 스레드 다섯 개를 새로 만들고 버리고 있었던 것이다. 노트북에서는 병렬도가 7이라 이 상황이 재현되지 않았다.
그날 밤 바로 이전 버전으로 되돌렸다. 교훈은 하나였다. 실행기를 생략하는 순간, 어떤 스레드에서 돌지는 운영 환경의 코어 수가 정한다. 그 뒤로 외부 호출을 비동기로 띄울 때는 실행기를 반드시 직접 넘긴다.
전용 스레드풀, 크기는 감이 아니라 계산으로
전용 풀을 만들면서 크기를 어떻게 정할지가 문제였다. 처음엔 “넉넉히 200″을 적었다가 지웠다. 앞의 사고가 바로 스레드를 넉넉히 만들다 난 것이었기 때문이다. 대신 리틀의 법칙으로 어림했다. 피크 때 마이페이지 요청은 초당 120건, 호출은 그 다섯 배인 600건이다. 평균 응답이 0.4초이니 동시에 떠 있는 호출은 600 × 0.4 = 240개, 컨테이너 4개로 나누면 하나당 60개다. 여기에 꼬리 지연 여유를 두고 코어 60, 최대 80으로 잡았다.
@Bean("mypageExecutor")
public ThreadPoolTaskExecutor mypageExecutor(MdcTaskDecorator decorator) {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(60); // 600건/초 × 0.4초 ÷ 4대
executor.setMaxPoolSize(80);
executor.setQueueCapacity(100); // 오래 기다릴 바엔 빨리 거절
executor.setThreadNamePrefix("mypage-");
executor.setTaskDecorator(decorator); // 아래 '잃어버린 것들' 참고
executor.initialize();
return executor;
}
큐를 작게 잡은 데도 이유가 있다. 큐가 크면 하위 서비스가 느려질 때 작업이 큐에서 몇 초씩 기다리다 실행되고, 그 사이 사용자는 이미 화면을 떠난다. 늦게 성공하는 것보다 빨리 실패하고 대체 화면을 보여 주는 편이 낫다고 판단했다. 스레드 이름 접두어는 다음 사고 때 스레드 덤프를 읽으면서 값을 톡톡히 했다. 11월 2일 재배포하자 p50은 0.62초가 됐고, 스레드 수는 260개 안팎에서 평평해졌다.
두 번째 사고 — orTimeout을 걸었는데 스레드가 풀리지 않았다
재배포할 때 호출마다 orTimeout(800ms)과 기본값 대체도 함께 걸었다. 하나가 느려져도 0.8초 뒤에는 나머지만으로 화면을 그리겠다는 생각이었다. 11월 9일 오후, 쿠폰 서비스가 배포 실수로 응답에 8초씩 걸리기 시작했다. 처음 몇 분은 설계대로 쿠폰 칸만 비운 채 0.8초에 응답이 나갔다. 그런데 6분쯤 지나자 마이페이지 전체가 500을 뱉기 시작했고, 이 상태가 11분 동안 이어졌다.
스레드 덤프를 떠 보니 mypage- 스레드 80개 중 70개 넘게가 쿠폰 API의 소켓 읽기에서 멈춰 있었다. orTimeout은 Future를 예외로 완료시킬 뿐, 돌고 있는 작업을 멈추지 않는다. 사용자에게는 0.8초에 응답이 나갔지만, 쿠폰을 부르던 스레드는 RestTemplate에 읽기 타임아웃이 없어 8초를 끝까지 기다리고 있었다. 풀 80개가 차고 큐 100개까지 차자 supplyAsync가 TaskRejectedException을 던졌다. 문제는 이 예외가 Future 안이 아니라 supplyAsync를 호출한 줄에서 곧바로 터진다는 점이었다. 그래서 exceptionally까지 가지도 못하고 컨트롤러 밖으로 새어 나갔다.
타임아웃을 빠뜨려 전 서버가 멈췄던 경험은 이미 RestTemplate 타임아웃 누락, 전 서버가 멈춘 날에 정리해 둔 적이 있다. 그런데 이번에는 “orTimeout이 있으니 괜찮다”는 착각 때문에 같은 실수를 되풀이했다. 고친 내용은 세 가지다.
// 1) 진짜 타임아웃은 HTTP 클라이언트에: 연결 300ms, 읽기 700ms
// 2) orTimeout은 그보다 조금 긴 안전망으로만 (1초)
// 3) 거절도 '실패한 Future'로 바꿔 한 경로에서 처리
static <T> CompletableFuture<T> async(Supplier<T> call, Executor executor) {
try {
return CompletableFuture.supplyAsync(call, executor)
.orTimeout(1, TimeUnit.SECONDS);
} catch (RejectedExecutionException e) {
return CompletableFuture.failedFuture(e);
}
}
여기에 하나를 더했다. 쿠폰처럼 자주 흔들리는 서비스에는 스레드 10개짜리 풀을 따로 떼어 주었다. 이른바 벌크헤드다. 쿠폰이 다시 느려져도 그 10개만 잠기고 회원·주문·포인트 호출은 영향을 받지 않는다. 풀 크기를 계산할 때 하위 서비스별 응답 분포를 따로 봐야 한다는 것도 이때 배웠다.
세 번째 사고 — exceptionally가 만든 “포인트 0원”
11월 16일 새벽, 포인트 서비스가 20분 동안 정기 점검에 들어갔다. 마이페이지는 죽지 않았다. 대신 그 20분 동안 들어온 사람은 모두 포인트가 0원으로 보였다. 아침에 고객센터에 “포인트가 사라졌다”는 문의가 37건 쌓여 있었다. 첫 버전에서 내가 써 둔 exceptionally(ex -> Point.zero())가 한 일이었다.
기본값을 고를 때 “화면이 깨지지 않게”만 생각했지, 그 값이 사용자에게 어떤 의미로 읽힐지는 생각하지 않았다. 0원은 “모름”이 아니라 “없음”으로 읽힌다. 게다가 exceptionally 안에서 로그도 남기지 않아서, 장애가 났다는 사실을 알게 된 것도 고객센터 문의를 통해서였다. 원래 예외는 CompletionException에 한 겹 감싸여 들어오는 경우가 많아서, 그대로 찍으면 원인 대신 래퍼만 보인다는 것도 이때 알았다.
CompletableFuture<Section<Point>> fPoint =
async(() -> pointClient.get(id), mypageExecutor)
.handle((point, ex) -> {
if (ex == null) return Section.ok(point);
Throwable cause = (ex instanceof CompletionException) ? ex.getCause() : ex;
log.warn("mypage point unavailable memberId={}", id, cause);
return Section.unavailable(); // 앱: "포인트를 잠시 불러올 수 없어요"
});
규칙을 하나 정했다. 대체 값은 진짜처럼 보이면 안 된다. 포인트·쿠폰처럼 숫자가 곧 돈인 칸은 “불러올 수 없음” 상태를 따로 내려 주고, 앱이 그 상태를 그리도록 응답 스펙을 바꿨다. 반대로 최근 배송 카드처럼 없어도 오해가 생기지 않는 칸은 조용히 숨겼다.
네 번째 사고 — 다른 스레드로 넘어가며 잃어버린 것들
마지막 사고는 조용했다. 병렬화 이후 배송 칸만 가끔 비어 있었는데, 로그를 아무리 뒤져도 해당 요청을 찾을 수 없었다. 요청마다 붙이던 traceId가 mypage- 스레드에서 찍힌 로그에는 하나도 없었기 때문이다. MDC는 스레드 로컬이라 실행기 스레드로 넘어가는 순간 비어 버린다. 이건 앞의 설정 코드에 있던 TaskDecorator로 요청 스레드의 MDC를 복사해 넘기는 방식으로 해결했다. 같은 패턴을 운영 로깅 체크리스트에서 @Async 쪽으로도 정리해 두었다.
traceId가 살아나자 진짜 원인이 보였다. 배송 클라이언트는 인증 헤더를 넘기려고 내부에서 RequestContextHolder로 현재 요청을 꺼내 쓰고 있었다. 실행기 스레드에는 요청이 없으니 No thread-bound request found 예외가 났고, 그 예외를 대체 값 처리가 조용히 삼키고 있었다. 요청 컨텍스트까지 실행기 스레드로 복사하는 방법도 있지만, 요청이 끝난 뒤 객체를 건드리는 위험이 있어서 택하지 않았다. 대신 필요한 값(토큰, 회원 ID)을 요청 스레드에서 미리 꺼내 파라미터로 넘기도록 클라이언트를 고쳤다. 비동기 경계에서 스레드 로컬이 끊기는 문제는 @Async 잘못 쓰면 트랜잭션·예외가 새어나간다에서 다룬 것과 뿌리가 같다.
미뤄 둔 쿠폰 얘기도 여기서 마무리했다. 쿠폰 목록은 회원 등급에 따라 추천 쿠폰이 달라서, 회원 정보가 있어야 부를 수 있었다. 이 하나 때문에 전체를 순차로 둘 필요는 없었다. fMember.thenCompose(m -> async(() -> couponClient.list(id, m.grade()), couponExecutor))로 회원 호출 뒤에만 이어 붙이고, 나머지 셋은 처음부터 동시에 띄웠다. 총 시간은 “회원 + 쿠폰”과 “주문” 중 긴 쪽이 된다.
2주 뒤의 숫자와 정리한 결정들
11월 19일 최종본을 배포했다. p50은 1.9초에서 0.62초로, p99는 4.2초에서 1.3초로 줄었다. p99가 1초 근처에서 멈춘 건 타임아웃이 꼬리를 잘라 주기 때문이다. 블랙프라이데이 당일에는 쿠폰 서비스가 트래픽을 못 이겨 두 번 느려졌지만, 마이페이지는 쿠폰 칸만 “잠시 후 다시 확인” 상태로 바뀐 채 계속 응답했다. 2주 동안 바꾼 결정을 표로 남긴다.
| 항목 | 처음 | 바꾼 뒤 | 이유 |
|---|---|---|---|
| 실행기 | 생략(공용 풀) | 전용 풀 + 쿠폰 전용 풀 | 2 vCPU에선 작업마다 새 스레드 생성, 흔들리는 서비스 격리 |
| 풀 크기 | “넉넉히” | 요청률 × 응답 시간 ÷ 대수 | 스레드 수가 곧 메모리, 근거 있는 상한 |
| 큐 | 없음(작업마다 새 스레드) | 100 | 늦은 성공보다 빠른 대체 화면 |
| 타임아웃 | orTimeout만 | HTTP 연결 300ms·읽기 700ms + orTimeout 1초 | orTimeout은 작업을 멈추지 않음 |
| 거절 예외 | 처리 안 함 | failedFuture로 감싸기 | supplyAsync 호출 줄에서 동기로 터짐 |
| 실패 시 값 | 0원·빈 목록 | “불러올 수 없음” 상태 + 로그 | 가짜 값은 사용자에게 사실로 읽힘 |
| 스레드 로컬 | 신경 안 씀 | MDC 복사, 요청 값은 파라미터로 | 실행기 스레드에는 요청도 traceId도 없음 |
다시 한다면
병렬화 자체는 하루 만에 끝났고, 나머지 2주는 전부 “동시에 돌린다”의 뒷면을 수습하는 데 썼다. 처음으로 돌아간다면 코드를 고치기 전에 이 순서부터 확인하겠다.
- 운영 환경의 코어 수와
ForkJoinPool.getCommonPoolParallelism()값부터 찍어 본다. 노트북 결과는 믿지 않는다. - 모든 하위 호출에 HTTP 클라이언트 레벨 타임아웃이 있는지 먼저 확인하고, orTimeout은 그 위의 안전망으로만 쓴다.
- 칸마다 “실패하면 사용자에게 무엇을 보여 줄지”를 기획과 먼저 정한다. 기본값을 개발자 혼자 정하지 않는다.
- 스레드 로컬에 기대는 코드(MDC, 요청 컨텍스트, 보안 컨텍스트)를 목록으로 뽑아 두고 시작한다.
지금이라면 Java 21의 가상 스레드로 스레드 수 계산 부담을 덜 수 있을 것이다. 다만 타임아웃, 대체 값, 스레드 로컬 문제는 가상 스레드에서도 그대로 남는다. 이 부분은 Java 가상 스레드 실전 적용과 한계 총정리에 따로 적었다. 하위 서비스가 다섯 개를 넘어가면 BFF에서 호출을 조합하는 방식 자체를 다시 볼 때인데, 그 선택지들은 MSA 서비스 간 호출 해결 방법 비교 가이드에 정리해 두었다. CompletableFuture의 동작 조건은 Java 11 CompletableFuture 공식 문서 첫머리에 짧게 적혀 있다. 내가 처음에 읽지 않고 넘긴 바로 그 문단이다.
“CompletableFuture로 외부 API 병렬 호출하기”에 대한 1개의 생각