RestTemplate 타임아웃 누락, 전 서버가 멈춘 날

금요일 오후, 모니터링 화면이 한꺼번에 빨개졌습니다. 응답 시간 그래프가 천장을 뚫었고, 헬스 체크가 줄줄이 실패했습니다. 이상한 건 CPU도 메모리도 한가했다는 점이었습니다. 서버는 살아 있는데, 어떤 요청도 받아주지 않았습니다.

처음엔 우리 코드를 의심했습니다. 그런데 배포한 적도, 트래픽이 폭증한 적도 없었습니다. 한참을 헤맨 뒤에야 진짜 원인이 우리 바깥에 있다는 걸 알았습니다. 결제 대행사 API가 그 시각 느려져 있었고, 타임아웃을 걸지 않은 RestTemplate 한 줄이 그 지연을 우리 서버 전체로 끌어들이고 있었습니다.

이 글은 외부 API 지연이 어떻게 멀쩡한 서버 전체를 마비시키는지, 스레드 덤프로 그 경로를 어떻게 확인했는지, 그리고 RestTemplate 타임아웃과 커넥션 풀·서킷브레이커로 장애를 어떻게 가뒀는지 정리한 기록입니다.


증상: CPU는 한가한데 서버가 응답을 안 한다

장애의 모습은 흔한 과부하와 달랐습니다. CPU 사용률은 낮았고, 힙도 안정적이었습니다. 그런데 어떤 API를 호출해도 응답이 돌아오지 않았습니다. 일을 너무 많이 해서 죽은 게 아니라, 아무 일도 못 하고 묶여 있는 상태였습니다.

이런 그림은 보통 한 가지를 가리킵니다. 일꾼(스레드)이 전부 어딘가에서 무언가를 기다리며 멈춰 있다는 것입니다. 자원이 시간에 따라 잠겨 가다 임계점에서 전체가 무너지는 패턴은, 예전에 겪은 서버 교체 후 1시간마다 죽는 서비스, 원인은 DB 락와 증상이 닮아 있었습니다. 차이는 이번엔 잠긴 자원이 DB 커넥션이 아니라 웹 요청을 처리하는 스레드 자체였다는 점입니다.

확인할 방법은 명확했습니다. 지금 그 스레드들이 무엇을 하고 있는지 들여다보면 됩니다.


스레드 덤프가 가리킨 곳: 워커가 모두 대기 중

살아 있는 프로세스의 스레드 상태는 jstack으로 즉시 떠볼 수 있습니다. JDK에 기본 포함된 도구라 추가 설치도 필요 없습니다.

# 톰캣 워커 스레드 상태 확인
jstack <pid> > threaddump.txt

# 외부 호출에 묶인 스레드만 추려 보기
grep -A 10 "http-nio" threaddump.txt | grep -i "socketRead\|RestTemplate"

결과는 충격적일 만큼 단순했습니다. 톰캣 워커 스레드 약 200개가 거의 전부 같은 자리에서 멈춰 있었습니다. 스택의 맨 위는 소켓에서 응답을 읽는 socketRead0였고, 그 아래로 우리의 결제 API 호출 코드가 줄줄이 이어졌습니다. 200개의 일꾼이 전부 결제사의 응답을 기다리며 손을 놓고 있었던 것입니다.

🖼 이미지 추천 — alt: jstack 스레드 덤프에서 톰캣 워커가 RestTemplate socketRead에 묶인 모습. (스레드 덤프 캡처에서 동일 스택이 반복되는 부분을 표시하면 효과적입니다. 실제 캡처가 없으면 이 블록은 삭제하세요.)

톰캣은 기본적으로 워커 스레드 수에 상한(server.tomcat.threads.max, 기본 200)이 있습니다. 이 200개가 전부 외부 응답을 기다리며 묶이는 순간, 새로 들어온 요청을 받아줄 스레드가 한 개도 남지 않습니다. 그래서 CPU가 한가한데도 서버 전체가 응답 불능에 빠진 것입니다.


진짜 원인: 타임아웃 없는 RestTemplate은 무한히 기다린다

범인은 코드 한 줄이었습니다. 별 설정 없이 만든 RestTemplate이었습니다.

// 타임아웃 설정이 전혀 없는 위험한 코드
RestTemplate restTemplate = new RestTemplate();
PaymentResult result = restTemplate.postForObject(url, request, PaymentResult.class);

문제는 기본 RestTemplate의 연결·읽기 타임아웃이 무제한이라는 데 있습니다. 상대 서버가 응답을 1초 만에 주든, 5분이 지나도 안 주든, 우리 스레드는 끝까지 기다립니다. 평소엔 결제사가 빠르게 답하니 아무도 이 사실을 몰랐습니다. 그러다 상대가 느려진 순간, 모든 호출이 무한정 대기로 바뀌었고 스레드가 한 번에 잠겨 버렸습니다.

이것이 분산 시스템에서 가장 무서운 **장애 전파(cascading failure)**입니다. 느린 의존성 하나가 그것을 호출하는 모든 서비스를 함께 끌어내립니다. 우리 서버는 멀쩡했지만, 멀쩡한 채로 묶여 죽었습니다. 외부 호출을 다룰 때 성능보다 장애 격리를 먼저 따져야 하는 이유를, 이번에 비싸게 배웠습니다. 같은 고민을 통신 방식 차원에서 정리한 글은 MSA 서비스 간 호출 해결 방법 비교 가이드에 따로 정리해 두었습니다.


수정 1 — 연결·읽기 타임아웃부터 건다

가장 먼저, 가장 급하게 한 일은 타임아웃을 거는 것이었습니다. 외부 호출에 시간 상한을 두면, 상대가 아무리 느려도 우리 스레드는 정해진 시간 뒤에 풀려납니다.

@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
    return builder
        .setConnectTimeout(Duration.ofSeconds(2))  // 연결 수립까지 최대 2초
        .setReadTimeout(Duration.ofSeconds(3))      // 응답 수신까지 최대 3초
        .build();
}

두 타임아웃은 역할이 다릅니다. 연결 타임아웃은 상대 서버와 TCP 연결을 맺기까지의 한도이고, 읽기 타임아웃은 연결된 뒤 응답 데이터를 받기까지의 한도입니다. 둘 다 걸어야 의미가 있습니다. 이 설정만으로도 “무한 대기”라는 최악의 시나리오는 사라집니다. 상대가 느리면 3초 뒤 예외가 나고, 스레드는 즉시 다음 요청을 처리하러 돌아갑니다.


수정 2 — 커넥션 풀과 서킷브레이커로 장애를 가둔다

타임아웃은 출혈을 멈추는 응급 처치입니다. 그 위에 두 겹을 더 쌓아 재발을 막았습니다.

첫째, 커넥션 풀입니다. 기본 RestTemplate은 매 호출마다 연결을 새로 맺는데, 호출이 많아지면 이 자체가 부담이 됩니다. Apache HttpClient 기반 커넥션 풀을 붙이면 연결을 재사용하고, 풀 크기로 동시 호출 수에 상한을 둘 수 있습니다.

var cm = PoolingHttpClientConnectionManagerBuilder.create()
        .setMaxConnTotal(100)       // 전체 연결 상한
        .setMaxConnPerRoute(20)     // 대상 서버당 상한
        .build();

CloseableHttpClient client = HttpClients.custom()
        .setConnectionManager(cm)
        .build();

var factory = new HttpComponentsClientHttpRequestFactory(client);
factory.setConnectTimeout(Duration.ofSeconds(2));
RestTemplate restTemplate = new RestTemplate(factory);

둘째, 서킷브레이커입니다. 특정 외부 API가 반복해서 실패하거나 느려지면, 회로를 잠시 열어 아예 호출을 끊고 즉시 대체 응답을 돌려줍니다. 느린 의존성으로 가는 길목을 차단해 우리 스레드를 보호하는 장치입니다.

@CircuitBreaker(name = "paymentApi", fallbackMethod = "fallback")
public PaymentResult pay(PaymentRequest req) {
    return restTemplate.postForObject(url, req, PaymentResult.class);
}

public PaymentResult fallback(PaymentRequest req, Throwable t) {
    return PaymentResult.pending(); // 즉시 대체 응답, 스레드는 곧바로 해방
}

타임아웃이 한 건의 호출을 지키는 장치라면, 서킷브레이커는 시스템 전체를 지키는 장치입니다. 둘은 경쟁이 아니라 층위가 다른 방어선입니다. 블로킹 호출 자체를 줄이고 싶다면 Java 가상 스레드 실전 적용과 한계 총정리에서 다룬 접근도 함께 고려할 만합니다.


타임아웃 값은 어떻게 정하나

설정을 걸기로 했다면, 다음 질문은 “몇 초로 잡을 것인가”입니다. 무작정 길게 잡으면 보호 효과가 약하고, 너무 짧으면 정상 응답까지 끊깁니다. 기준은 데이터에서 나옵니다.

읽기 타임아웃은 대상 API의 평소 응답 시간 분포(특히 P99)를 기준으로, 거기에 약간의 여유를 더한 값으로 잡는 것이 출발점입니다. 평소 P99가 800ms인 API라면 읽기 타임아웃을 3초쯤으로 두면, 정상 요청은 거의 다 통과시키면서 비정상 지연은 빠르게 끊을 수 있습니다. 여기에 한 가지 원칙을 더합니다. 여러 외부 호출이 겹치는 요청이라면, 개별 타임아웃의 합이 그 요청 전체의 타임아웃 예산을 넘지 않도록 설계해야 합니다. 안 그러면 사용자는 이미 떠났는데 서버만 끝까지 기다리는 일이 생깁니다.


남는 교훈

이 장애의 원인은 어려운 기술이 아니었습니다. 타임아웃을 안 걸었다는, 너무 기본적인 한 가지였습니다. 그래서 더 뼈아팠습니다. 평소엔 아무 문제가 없으니 잊고 지내다가, 외부가 흔들리는 단 한 순간에 전체가 무너졌습니다.

이번 일 이후로 팀에는 단순한 규칙 하나가 생겼습니다. 외부로 나가는 모든 호출에는 예외 없이 타임아웃을 건다. 타임아웃 없는 호출은 평소엔 보이지 않다가 최악의 순간에 터지는, 장애 전파의 통로이기 때문입니다. 그리고 서버가 응답 없이 묶였을 때 jstack 한 번이면 스레드가 어디에 잠겨 있는지 곧바로 보인다는 것도, 이번에 몸으로 익힌 자산입니다.

한 줄로 남기면, 외부 호출의 타임아웃은 선택이 아니라 기본값이며, 그 위에 커넥션 풀과 서킷브레이커를 더해야 느린 의존성 하나가 전체를 무너뜨리지 못합니다.

댓글 남기기