
Rest Client를 이용하여 FastAPI의 api를 호출하던 중 긴 문자열 요청으로 인해 java.net.SocketTimeoutException: Read timed out 예외가 발생했다. 이 문제는 고정된 타임아웃 값과 재시도 메커니즘 부재로 인해 요청이 실패하고, 이를 복구할 수 없는 구조 때문이었다.
이 글에서는 동적 타임아웃 설정과 Spring Retry를 활용한 재시도 로직을 적용하여 Timeout Error를 해결한 사례를 공유한다.
RestTemplate을 사용하여 외부 API를 호출 중이었다.
API 호출 시 고정된 타임아웃 값이 설정되어 있었으며, 이는 짧은 요청에는 적절하지만 긴 요청에 적합하지 않았다.
private static final int READ_TIMEOUT = 7000; // 7초
private static final int CONNECT_TIMEOUT = 10000; // 10초
긴 요청 문자열을 처리할 때 서버가 응답을 반환하기 전에 타임아웃이 발생했고, Read Timeout 예외로 인해 요청이 실패했다.
타임아웃 발생 시 자동으로 재시도하는 로직이 없어 요청이 즉시 실패 처리되었다.
기존의 고정된 타임아웃 설정을 동적으로 변경하였다. 요청 데이터의 길이에 비례하여 추가 타임아웃을 계산하며, 최대 타임아웃(MAX_READ_TIMEOUT)을 초과하지 않도록 제한했다.
RestClientConfig
@Configuration
public class RestClientConfig {
private static final int CONNECT_TIMEOUT = 10000; // 연결 타임아웃
private static final int BASE_READ_TIMEOUT = 9000; // 기본 읽기 타임아웃
private static final int MAX_READ_TIMEOUT = 30000; // 최대 읽기 타임아웃
private static final int LENGTH_FACTOR = 100; // 요청 길이에 따른 증가량 (ms)
@Bean
public RestTemplate restTemplate() {
return createRestTemplate(BASE_READ_TIMEOUT);
}
public RestTemplate restTemplateForRequest(String request) {
int dynamicReadTimeout = calculateDynamicTimeout(request);
return createRestTemplate(dynamicReadTimeout);
}
private RestTemplate createRestTemplate(int readTimeout) {
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setHttpClient(createHttpClient(readTimeout));
return new RestTemplate(factory);
}
private CloseableHttpClient createHttpClient(int readTimeout) {
RequestConfig config = RequestConfig.custom()
.setConnectionRequestTimeout(Timeout.ofMilliseconds(CONNECT_TIMEOUT))
.setResponseTimeout(Timeout.ofMilliseconds(readTimeout))
.build();
return HttpClients.custom()
.setDefaultRequestConfig(config)
.setConnectionManager(new PoolingHttpClientConnectionManager())
.build();
}
private int calculateDynamicTimeout(String request) {
int additionalTimeout = request.length() * LENGTH_FACTOR;
return Math.min(BASE_READ_TIMEOUT + additionalTimeout, MAX_READ_TIMEOUT);
}
}
restTemplate
기본 읽기 타임아웃 값(BASE_READ_TIMEOUT)을 사용하는 RestTemplate을 생성하고 Bean으로 등록.restTemplateForRequest
request 길이를 기반으로 동적으로 Read Timeout 값을 계산.createRestTemplate
Read Timeout 값을 기반으로 커스텀 RestTemplate을 생성.createHttpClient
Read Timeout 값으로 구성된 CloseableHttpClient를 생성.Timeout 값을 설정하는 핵심 메서드.calculateDynamicTimeout
request 길이를 기반으로 동적 타임아웃 값을 계산.@Retryable 어노테이션을 사용하여 타임아웃 발생 시 최대 3회까지 재시도하며, 각 재시도 간 2초의 대기 시간을 추가했다. 이로써 네트워크 지연이나 서버 부하로 인한 일시적 실패를 복구할 수 있다.
Spring Retry는 재시도 로직을 간단하게 구현할 수 있도록 제공하는 Spring 라이브러리이다.
재시도 로직, 백오프 설정(Back off), 회복 메서드(feedback)를 설정하여 외부 API호출에 실패한 상황이나 데이터베이스 연결에 실패한 상황등을 처리 및 복구할 수 있다.
@Retryable이 선언된 메서드는 Spring AOP에 의해 프록시 객체가 생성되고 대충 이해했으면 일단 의존성부터 주입하자.
build.gradle
dependencies {
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework.boot:spring-boot-starter-aop' // Retry와 AOP를 위한 의존성
implementation 'org.apache.httpcomponents.client5:httpclient5' // HttpClient 의존성
implementation 'org.springframework.boot:spring-boot-starter-web' // RestTemplate 사용
}
해당 API를 사용하는 서비스단
/**
* 일기 저장하고 노래 추천 요청
* @param diaryRequestDto 일기 요청 데이터
* @param email 사용자 이메일
* @return 추천 노래 리스트를 담은 응답 객체
*/
@Retryable(
retryFor = {java.net.SocketTimeoutException.class},
maxAttempts = 3, // 최대 재시도 횟수
backoff = @Backoff(delay = 2000) // 재시도 간 대기 시간 2초
)
@Transactional
public BaseResponse<ChatRecommendResponseDto> recommendSongs(DiaryRequestDto diaryRequestDto, String email) {
Diary diary = saveDiary(email, diaryRequestDto);
ChatRecommendResponseDto recommendResponseDto = processChatRecommendResponse(diaryRequestDto, diary.getId());
return new BaseResponse<>(recommendResponseDto);
}
/**
* 일기 저장하고 작사 생성 요청 처리
*
* @param diaryRequestDto 일기 요청 데이터
* @param email 사용자 이메일
* @return 생성된 가사를 담은 응답 객체
*/
@Retryable(
retryFor = {java.net.SocketTimeoutException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 2000)
)
@Transactional
public BaseResponse<ChatLyricsResponseDto> generateLyrics(DiaryRequestDto diaryRequestDto, String email) {
Diary diary = saveDiary(email, diaryRequestDto);
ChatLyricsResponseDto lyricsResponseDto = processChatLyricsResponse(diaryRequestDto, diary.getId());
return new BaseResponse<>(lyricsResponseDto);
}
그 결과, 태연의 젤리송을 무사히 받았다.

나는 재시도 로직만 간단하게 구현했다. 더 자세한 사용법은 README.md에 상세히 나와있다. 누군가 공부한다면 공유해주세요.
참고
✔️ Spring Retry