Post

Spring Boot 3 + RestClient + Apache HttpClient 설정 방법

Spring Boot 3 + RestClient + Apache HttpClient 설정 방법

build.gradle에 의존성 추가

  • 최신 http client 5 버전을 추가한다.
    1
    2
    
    // Apache HttpClient 5
    implementation 'org.apache.httpcomponents.client5:httpclient5:5.4.3'
    

RestClientConfig 설정

  • HttpClient를 Bean으로 등록하기 위한 Config 클래스
  • Apache HTTP Components를 사용하는 경우, RequestFactory를 HttpComponentsClientHttpRequestFactory로 사용 (공식 문서)
    • 외부 라이브러리 없이 RestClient를 사용하는 경우, RequestFactory의 기본값은 SimpleClientHttpRequestFactory 이다.
    • SimpleClientHttpRequestFactory는 다양한 속성이 지원되지 않고, connection pool 역시 지원 안됨.
  • 설정 예시
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    
    @Slf4j
    @Configuration
    public class TMDBRestClientConfig {
    
        private final String API_READ_ACCESS_TOKEN;
        private final String API_KEY;
    
        public TMDBRestClientConfig(
            @Value("${tmdb.api-read-access-token}")
            String API_READ_ACCESS_TOKEN,
            @Value("${tmdb.api-key}")
            String API_KEY
        ) {
            this.API_READ_ACCESS_TOKEN = API_READ_ACCESS_TOKEN;
            this.API_KEY = API_KEY;
        }
    
        private static final ObjectMapper objectMapper = new ObjectMapper();
        private static final String BASE_URL = "https://api.themoviedb.org/3";
        private static final int CONNECTION_TIMEOUT = 5;
        private static final int READ_TIMEOUT = 25;
        private static final int MAX_TOTAL_CONNECTIONS = 100;
        private static final int MAX_PER_ROUTE = 20;
    
        @Bean
        public TMDBRestClient tmdbRestClient() {
            return HttpServiceProxyFactory
                .builderFor(RestClientAdapter.create(getTMDBRestClient()))
                .build()
                .createClient(TMDBRestClient.class);
        }
    
        private RestClient getTMDBRestClient() {
            return RestClient.builder()
                .baseUrl(BASE_URL)
                .defaultHeaders(httpHeaders -> {
                    httpHeaders.add(HttpHeaders.AUTHORIZATION, getAuthorizationHeaderValue());
                    httpHeaders.setContentType(MediaType.APPLICATION_JSON);
                })
                .requestFactory(getClientHttpRequestFactory())
                .defaultStatusHandler(HttpStatusCode::is4xxClientError, default4xxErrorHandler())
                .defaultStatusHandler(HttpStatusCode::is5xxServerError, default5xxErrorHandler())
                .build();
        }
    
        private String getAuthorizationHeaderValue() {
            return "Bearer " + this.API_READ_ACCESS_TOKEN;
        }
    
        private ClientHttpRequestFactory getClientHttpRequestFactory() {
            return new HttpComponentsClientHttpRequestFactory(createApacheHttpClient());
        }
    
        private HttpClient createApacheHttpClient() {
            RequestConfig requestConfig = RequestConfig.custom()
                .setConnectionRequestTimeout(CONNECTION_TIMEOUT, TimeUnit.SECONDS)
                .setResponseTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
                .build();
    
            PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
            connectionManager.setMaxTotal(MAX_TOTAL_CONNECTIONS); // 전체 커넥션 수 최대값
            connectionManager.setDefaultMaxPerRoute(MAX_PER_ROUTE); // 도메인(호스트) 당 최대 커넥션 수
    
            return HttpClients.custom()
                .setConnectionManager(connectionManager)
                .setDefaultRequestConfig(requestConfig)
                .evictIdleConnections(Timeout.ofSeconds(30)) // 일정 시간 이상 유휴 커넥션 정리 (메모리 누수 방지)
                .build();
        }
    
        private ResponseSpec.ErrorHandler default4xxErrorHandler() {
            return (req, res) -> {
                Map<String, Object> errorBody = getErrorBody(res);
                HttpStatusCode statusCode = res.getStatusCode();
                printErrorLog(statusCode, errorBody);
                switch (statusCode) {
                    case HttpStatus.UNAUTHORIZED ->
                        throw new ApiErrorException(ErrorCode.AUTHENTICATION_FAIL);
                    case HttpStatus.NOT_FOUND -> throw new ApiErrorException(ErrorCode.NOT_FOUND);
                    default -> throw new ApiErrorException(ErrorCode.BAD_REQUEST);
                }
            };
        }
    
        private ResponseSpec.ErrorHandler default5xxErrorHandler() {
            return (req, res) -> {
                Map<String, Object> errorBody = getErrorBody(res);
                HttpStatusCode statusCode = res.getStatusCode();
                printErrorLog(statusCode, errorBody);
                throw new ApiErrorException(ErrorCode.EXTERNAL_SERVER_ERROR);
            };
        }
    
        private Map<String, Object> getErrorBody(ClientHttpResponse res) {
            Map<String, Object> errorBody;
            try {
                errorBody = objectMapper.readValue(
                    res.getBody(),
                    new TypeReference<>() {
                    }
                );
            } catch (Exception e) {
                log.error("Failed to parse TMDBRestClient error response body", e);
                throw new ApiErrorException(ErrorCode.EXTERNAL_SERVER_ERROR);
            }
            return errorBody;
        }
    
        private void printErrorLog(
            HttpStatusCode statusCode,
            Map<String, Object> errorBody
        ) {
            log.error("TMDBRestClient Error Status Code: {}", statusCode.value());
            log.error("TMDBRestClient Error Body: {}", errorBody);
        }
    
    }
    

사용 예제

  • 설정한 값에 맞게 Bean 생성 후 서비스 레이어에서 RestClient 인터페이스를 주입하여 사용
  • Interface 코드
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    @Component
    @HttpExchange
    public interface TMDBRestClient {
    
        @GetExchange(value = "/movie/popular")
        TMDBPopularMoviePageResponse getPopularMovies(
            @RequestParam(name = "language", defaultValue = "ko-KR") String language,
            @RequestParam(name = "page", defaultValue = "1") Integer page
        );
    
    }
    
  • Service 코드에서 실제 사용 예시
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class TMDBService {
    
        private final TMDBRestClient tmdbRestClient;
    
        public PopularMoviePageResponse getPopularMovies(int page) {
            return PopularMoviePageResponse.from(
                tmdbRestClient.getPopularMovies(null, page)
            );
        }
    }
    
This post is licensed under CC BY 4.0 by the author.