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 역시 지원 안됨.
- 외부 라이브러리 없이 RestClient를 사용하는 경우, RequestFactory의 기본값은
- 설정 예시
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.