Day13. Springboot: RestAPI 호출에서 발생한 Read Timeout Error 해결

2ㅣ2ㅣ·2024년 11월 15일

Project

목록 보기
12/13
post-thumbnail

개요

Rest Client를 이용하여 FastAPI의 api를 호출하던 중 긴 문자열 요청으로 인해 java.net.SocketTimeoutException: Read timed out 예외가 발생했다. 이 문제는 고정된 타임아웃 값과 재시도 메커니즘 부재로 인해 요청이 실패하고, 이를 복구할 수 없는 구조 때문이었다.

이 글에서는 동적 타임아웃 설정과 Spring Retry를 활용한 재시도 로직을 적용하여 Timeout Error를 해결한 사례를 공유한다.



As-Is

문제 1. 고정된 타임아웃

RestTemplate을 사용하여 외부 API를 호출 중이었다.
API 호출 시 고정된 타임아웃 값이 설정되어 있었으며, 이는 짧은 요청에는 적절하지만 긴 요청에 적합하지 않았다.

private static final int READ_TIMEOUT = 7000; // 7초
private static final int CONNECT_TIMEOUT = 10000; // 10초

문제 2. 재시도 로직 없음

긴 요청 문자열을 처리할 때 서버가 응답을 반환하기 전에 타임아웃이 발생했고, Read Timeout 예외로 인해 요청이 실패했다.
타임아웃 발생 시 자동으로 재시도하는 로직이 없어 요청이 즉시 실패 처리되었다.



To-Be

해결 1.동적 타임아웃 설정

기존의 고정된 타임아웃 설정을 동적으로 변경하였다. 요청 데이터의 길이에 비례하여 추가 타임아웃을 계산하며, 최대 타임아웃(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을 생성.
    • restTemplate과 restTemplateForRequest 메서드에서 호출됨.
  • createHttpClient

    • Read Timeout 값으로 구성된 CloseableHttpClient를 생성.
    • HTTP 요청의 Timeout 값을 설정하는 핵심 메서드.
  • calculateDynamicTimeout

    • request 길이를 기반으로 동적 타임아웃 값을 계산.
    • 기본 타임아웃 값에 요청 길이와 LENGTH_FACTOR를 곱한 값을 더하며, 최대 타임아웃 값(MAX_READ_TIMEOUT)을 초과하지 않도록 제한.


해결 2. Spring Retry를 통한 재시도

@Retryable 어노테이션을 사용하여 타임아웃 발생 시 최대 3회까지 재시도하며, 각 재시도 간 2초의 대기 시간을 추가했다. 이로써 네트워크 지연이나 서버 부하로 인한 일시적 실패를 복구할 수 있다.

Spring Retry가 뭔데?

Spring Retry는 재시도 로직을 간단하게 구현할 수 있도록 제공하는 Spring 라이브러리이다.
재시도 로직, 백오프 설정(Back off), 회복 메서드(feedback)를 설정하여 외부 API호출에 실패한 상황이나 데이터베이스 연결에 실패한 상황등을 처리 및 복구할 수 있다.

Spring Retry 동작 원리

  1. @Retryable이 선언된 메서드는 Spring AOP에 의해 프록시 객체가 생성되고
  2. 지정된 조건(예외 발생, 최대 재시도 횟수 등)에 따라 재시도를 실행한다.
  3. 백오프를 적용하여 재시도 간격을 기반으로 대기 시간을 조정할 수 있다.
  4. 재시도가 성공하면 결과를 반환하고 실패하면 예외를 던지거나 @Recover 메서드를 실행한다.

대충 이해했으면 일단 의존성부터 주입하자.

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

0개의 댓글