Spring Application을 사용하다보면 다른 API Server를 호출하는 경우가 자주 발생합니다.
이때 사용할 수 있는 Spring Cloud에서 제공하는 OpenFeign 메뉴얼을 정리해보고, 이외에 사용할 수 있는 여러 REST Client에 대해 정리하는 글입니다. (RestTemplate, WebClient, RestClient, HttpInterface)
아래 나오는 모든 예제 코드는 Github에서 확인하실 수 있습니다.
아래 두 가지에 해당되는 분들은 RestClient & HttpInterface 챕터를 보시길 추천드립니다.
- RestTemplate, WebClient에 대해 학습을 했고, 이미 사용 중이신 분들
- Spring Cloud 혹은 MSA에 익숙하며 OpenFeign을 이미 사용 중이신 분들
RestClient와 HttpInterface에 대해서는 최근 기술이다보니, 도큐먼트 외에는 자료가 많이 없습니다. 정리한 내용에 오류가 있다면 알려주시면 감사하겠습니다.
- RestClient : Spring Boot 3.2부터 지원된 기능
- RestTemplate + WebClient의 장점만을 합친 기능
- HttpInterface : Spring 6에 추가된 선언형 Http Client 기능.
- OpenFeign과 같이 애너테이션 & 인터페이스 기반의 선언형으로 작성.
Netflix에 의해 처음 만들어진 Declarative(선언적) HTTP Client 도구로써, 외부 API 호출을 쉽게 할 수 있도록 도와준다.
여기서 선언적이란 애너테이션 사용을 의미한다.
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<version>4.1.0</version>
</dependency>
@EnableFeignClients
: Feign Client 활성화@EnableFeignClients
@SpringBootApplication
public class RequestServerApplication { ... }
Feign Client 정의
@FeignClient
: FeignClient 임을 명시
- value : 이름 지정
- url : 요청 주소 지정
@FeignClient(value = "openFeignClient", url = "${url.server.response.endpoint")
public interface OpenFeignClient {
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
CommonResponse send(@RequestBody CommonRequest request);
}
사용 방식
@Slf4j
@Service
@RequiredArgsConstructor
public class OpenFeignService {
private final OpenFeignClient feignClient;
/**
* FeignClient로 외부 API를 호출하는 예시
*/
public CommonResponse send(final CommonRequest request) {
log.info("request[{}]", request);
return feignClient.send(request);
}
}
실행 결과
타임아웃, 로그 레벨 설정
- connect-timeout : 클라이언트가 서버에 연결하기 위해 대기하는 시간을 설정
- read-timeout : 클라이언트가 서버로부터 데이터를 읽는 데 걸리는 시간을 설정
- logger-level : 로그 레벨 설정(NONE, BASIC, HEADERS, FULL)
spring:
cloud:
openfeign:
client:
config:
default: # 공통 설정
connect-timeout: 5000 # default : 1000
read-timeout: 30000 # default : 60000
logger-level: BASIC
openFeignClient: # 특정 Feign Client 설정
connect-timeout: 1000
read-timeout: 20000
logger-level: FULL
FULL 로그 레벨 설정 예시
설정 클래스 작성
/**
* @author 이동엽(Lee Dongyeop)
* @date 2024. 03. 24
* @description FeignClient 전용 Retry 설정 클래스
*/
public class FeignClientRetryConfig {
/**
* openFeignClient 전용 Retryer
* {period} 의 간격으로 시작해 최대 {duration}의 간격으로 증가
* 최대 {maxAttempt}번 재시도한다.
*/
@Bean
Retryer.Default openFeinClientRetryer() {
// 0.1초의 간격으로 시작해 최대 3초의 간격으로 점점 증가하며, 최대5번 재시도한다.
return new Retryer.Default(
period, // default : 100
TimeUnit.SECONDS.toMillis(duration), // default : 1L
maxAttempt // default : 5
);
}
}
FeignClient에만 적용되도록 설정 클래스 지정
@FeignClient(
value = "openFeignClient",
url = "${url.server.response.endpoint}",
configuration = FeignClientRetryConfig.class)
public interface OpenFeignClient {
...
}
요청을 수행하기 전, 인터셉터에서 헤더 정보를 수정할 수도 있다.
@Slf4j
public class UrlDecodeRequestInterceptor implements RequestInterceptor {
/**
* 요청이 수행되기 전, Authorization 헤더를 설정
*/
@Override
public void apply(final RequestTemplate requestTemplate) {
requestTemplate.header(CommonConst.AUTHORIZATION, CommonConst.BEARER);
}
}
마찬가지로, 구성 속성을 이용해 원하는 Feign Client에 지정할 수 있다.
spring:
cloud:
openfeign:
client:
config:
openFeignClient:
request-interceptors:
- {경로}.AuthorizationRequestInterceptor
Feign은 오류 발생시 항상 FeignException을 발생 시킨다.
따라서 응답의 상태 코드에 따라 사용자 정의에 적합하게 동작하도록 ErrorDecoder를 정의할 수 있다.
@Slf4j
public class CustomErrorDecoder implements ErrorDecoder {
@Override
public Exception decode(final String methodKey, final Response response) {
log.warn("statusCode[{}], methodKey[{}]", response.status(), methodKey);
return switch (response.status()) {
case 400 -> new CustomException("bad request", 400);
case 404 -> new CustomException("not found", 404);
case 500 -> new CustomException("internal server error", 500);
default -> new CustomException("feign client call error");
};
}
}
마찬가지로, 구성 속성을 이용해 원하는 Feign Client에 지정할 수 있다.
spring:
cloud:
openfeign:
client:
config:
openFeignClient:
error-decoder: io.dongvelop.requestserver.common.CustomErrorDecoder
장점
기능
spring:
cloud:
openfeign:
client:
config:
feignName:
url: http://remote-service.com
connectTimeout: 5000
readTimeout: 5000
loggerLevel: full
errorDecoder: com.example.SimpleErrorDecoder
retryer: com.example.SimpleRetryer
defaultQueryParameters:
query: queryValue
defaultRequestHeaders:
header: headerValue
requestInterceptors:
- com.example.FooRequestInterceptor
- com.example.BarRequestInterceptor
responseInterceptor: com.example.BazResponseInterceptor
dismiss404: false
encoder: com.example.SimpleEncoder
decoder: com.example.SimpleDecoder
contract: com.example.SimpleContract
capabilities:
- com.example.FooCapability
- com.example.BarCapability
queryMapEncoder: com.example.SimpleQueryMapEncoder
micrometer.enabled: false
스프링 프레임워크에서 제공하는 RESTful API 통신을 위한 도구이다.
동기식
방식으로 통신한다.동기식
으로 요청을 보내고 응답을 받을 때까지 블로킹된다. (응답을 기다린다.)번외
Spring 공식 문서에서 RestTemplate을 확인하면 아래와 같이 내용이 있다.
메서드명 | 설명 |
---|---|
delete(…) | 지정된 URL의 리소스에 HTTP DELETE 요청을 수행한다. |
exchange(…) | 지정된 HTTP 메서드를 URL에 실행, ResponseEntity를 반환 |
execute(…) | 지정된 HTTP 메서드를 URL에 실행, 응답 Body와 연결되는 객체를 반환 |
getForEntity(…) | HTTP GET 요청을 전송, ResponseEntity를 반환 |
getForObject(…) | HTTP GET 요청을 전송, 응답 Body와 연결되는 객체를 반환 |
headForHeaders(…) | HTTP HEAD 요청을 전송, 지정된 리소스 URL의 헤더를 반환 |
optionsForAllow(…) | HTTP OPTIONS 요청을 전송, 지정된 리소스 URL의 Allow 헤더를 반환 |
patchForObject(…) | HTTP PATCH 요청을 전송, 응답 Body와 연결되는 객체를 반환 |
postForEntity(…) | URL에 데이터를 POST, ResponseEntity를 반환 |
postForLocation(…) | URL에 데이터를 POST, 새로 생성된 리소스의 URL을 반환 |
postForObject(…) | URL에 데이터를 POST, 응답 Body와 연결되는 객체를 반환 |
put(…) | 리소스 데이터에 지정된 URL에 PUT |
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependencies>
RestTemplate은 DEBUG 레벨로 API 통신 정보를 보여준다.
logging:
level:
web: debug
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
<version>2.0.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
@EnableRetry
@SpringBootApplication
public class RequestServerApplication {
public static void main(String[] args) {
SpringApplication.run(RequestServerApplication.class, args);
}
}
@Retryable(maxAttempts = 3)
public CommonResponse sendRetry(final CommonRequest request) { ... }
- 클래스 내의 객체로 생성하기
RestTemplate restTemplate = new RestTemplate();
- Bean으로 등록 후, 의존성 주입하여 사용하기 + 타임아웃 설정 추가
@Configuration
public class RestTemplateConfig {
@Value("${restTemplate.connectTimeOut}")
private Long connectTimeOut;
@Value("${restTemplate.readTimeOut}")
private Long readTimeOut;
@Bean
@Qualifier("restTemplate")
public RestTemplate restTemplate(final RestTemplateBuilder restTemplateBuilder) {
return restTemplateBuilder
.setConnectTimeout(Duration.ofSeconds(connectTimeOut)) // 외부 API 서버에 연결 요청 시간
.setReadTimeout(Duration.ofSeconds(readTimeOut)) // 외부 API 서버로부터 데이터를 읽어오는 시간
.build();
}
}
@Slf4j
@Service
@RequiredArgsConstructor
public class RestTemplateService {
@Value("${url.server.response.endpoint}")
private String responseServerUrl;
@Value("${url.server.response.retry}")
private String retryRequestPath;
private final RestTemplate restTemplate;
/**
* RestTemplate 으로 응답 서버에 요청보내기 <br/>
* - Retry 및 TimeOut을 제공하지 않음. <br/>
* - 비즈니스 코드에 지저분한 설정 코드들이 섞임. <br/>
* - OpenFeign에 비해 제공되는 로그가 불친절함
*
* @param request : 공통 요청 형태
* @return : 공통 응답 형태
*/
public CommonResponse send(final CommonRequest request) {
log.info("request[{}]", request);
settingHeader();
return restTemplate.postForObject(responseServerUrl, request, CommonResponse.class);
}
/**
* RestTemplate은 Retry를 지원하지 않아, 아래처럼 Spring-Retry를 이용하여 구현
*/
@Retryable(maxAttempts = 3)
public CommonResponse sendRetry(final CommonRequest request) {
log.info("request[{}]", request);
settingHeader();
return restTemplate.postForObject(responseServerUrl + retryRequestPath, request, CommonResponse.class);
}
private void settingHeader() {
/* Header에 필요한 정보 삽입 */
final ArrayList<ClientHttpRequestInterceptor> interceptors = new ArrayList<>();
interceptors.add(new RequestHeaderInterceptor(CommonConst.AUTHORIZATION, CommonConst.BEARER + "restTemplateExample"));
interceptors.add(new RequestHeaderInterceptor(CommonConst.CONTENT_TYPE_KEY, MediaType.APPLICATION_JSON_VALUE));
restTemplate.setInterceptors(interceptors);
}
}
기본적으로 외부 API를 호출하기 위해 내부적으로 HttpClient를 이용하는 점은 RestTemplate과 동일하다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
logging:
level:
web: debug
reactor: debug
@Configuration
public class WebClientConfig {
@Value("${url.server.response.endpoint}")
private String responseServerUrl;
@Value("${webClient.timeout.connect}")
private Integer connectTimeout;
@Value("${webClient.timeout.response}")
private Long responseTimeout;
@Value("${webClient.timeout.read}")
private Long readTimeout;
@Value("${webClient.timeout.write}")
private Long writeTimeout;
@Value("${webClient.maxMemorySize}")
private int maxMemorySize;
/**
* 공통으로 쓰이는 WebClient
*/
@Bean
public WebClient webClient() {
// 타임아웃 설정을 위한 HttpClient
final HttpClient httpClient = HttpClient.create()
// 연결 타임아웃
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout)
// 응답 타임아웃
.responseTimeout(Duration.ofSeconds(responseTimeout))
.doOnConnected(conn -> conn
// 읽기 작업 타임아웃
.addHandlerLast(new ReadTimeoutHandler(readTimeout, TimeUnit.SECONDS))
// 쓰기 작업 타임아웃
.addHandlerLast(new WriteTimeoutHandler(writeTimeout, TimeUnit.SECONDS))
);
/*
* Spring WebFlux 에서는 애플리케이션 메모리 문제를 피하기 위해 in-memory buffer 값이 256KB로 기본설정 되어 있음.
* 따라서 256KB 보다 큰 HTTP 메시지를 처리 시도 → DataBufferLimitException 에러 발생
* in-memory buffer 값을 늘려주기 위한 설정 추가
*/
return WebClient.builder()
.baseUrl(responseServerUrl)
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(maxMemorySize))
.clientConnector(new ReactorClientHttpConnector(httpClient))
.defaultHeaders(httpHeaders -> {
httpHeaders.add(CommonConst.AUTHORIZATION, CommonConst.BEARER);
httpHeaders.add(CommonConst.CONTENT_TYPE_KEY, MediaType.APPLICATION_JSON_VALUE);
})
.build();
}
}
/**
* WebClient를 이용한 재시도 처리 <br/>
*
* @see <a href=https://www.baeldung.com/spring-webflux-retry>WebClient Retry</a>
*/
public CommonResponse sendRetry(final CommonRequest request) {
log.info("request[{}]", request);
return webClient.post()
.uri(retryRequestPath)
.body(Mono.just(request), CommonRequest.class)
.retrieve()
.bodyToMono(CommonResponse.class)
.retryWhen(Retry.fixedDelay(retryMaxAttempt, Duration.ofSeconds(retryDelay))) // 1초로 고정된 간격으로 재시도
// .retryWhen(Retry.max(3)) // 매우 짧은 간격으로 재시도 - 복구할 기회를 주지 않아 위험함.
// .retryWhen(Retry.backoff(3, Duration.ofSeconds(2))) // 2초. 4초. 8초의 백오프 전략으로 제시도
.block();
}
/**
* Response Server로부터 받은 HTTP 상태코드별로 에러 처리
*/
public CommonResponse get500status(final CommonRequest request) {
log.info("request[{}]", request);
return webClient.post()
.uri(retryRequestPath)
.body(Mono.just(request), CommonRequest.class)
.retrieve()
.onStatus(HttpStatusCode::is5xxServerError, response -> Mono.error(new WebClientResponseException(
response.statusCode().value(),
String.format("5xx error. %s", response.bodyToMono(String.class)),
response.headers().asHttpHeaders(), null, null
)
))
.bodyToMono(CommonResponse.class)
.block();
}
5xx 에러 핸들링 시 남는 로그 예시
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependencies>
RestClient는 DEBUG 레벨로 API 통신 정보를 보여준다.
logging:
level:
web: debug
RestTemplate와 동일하게 Retry(재시도)를 지원하지않아, Spring-Retry를 이용해야 함
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
<version>2.0.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
@EnableRetry
@SpringBootApplication
public class RequestServerApplication {
public static void main(String[] args) {
SpringApplication.run(RequestServerApplication.class, args);
}
}
@Retryable(maxAttempts = 3)
public CommonResponse sendRetry(final CommonRequest request) { ... }
@Configuration
public class RestClientConfig {
@Value("${url.server.response.endpoint}")
private String responseServerUrl;
@Value("${restTemplate.connectTimeOut}")
private Long connectTimeOut;
@Value("${restTemplate.readTimeOut}")
private Long readTimeOut;
@Bean
public RestClient restClient() {
// 아래와 같이 Timeout 설정하는 부분은 RestTemplate 와 동일
final ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(ClientHttpRequestFactorySettings.DEFAULTS
.withReadTimeout(Duration.ofSeconds(readTimeOut))
.withConnectTimeout(Duration.ofSeconds(connectTimeOut)));
// 기본적인 공통 헤더/Url 설정 방식은 WebClient 와 동일
return RestClient.builder()
.baseUrl(responseServerUrl)
.defaultHeaders(httpHeaders -> {
httpHeaders.add(CommonConst.AUTHORIZATION, CommonConst.BEARER);
httpHeaders.add(CommonConst.CONTENT_TYPE_KEY, MediaType.APPLICATION_JSON_VALUE);
})
.requestFactory(requestFactory)
.build();
}
}
/**
* RestClient를 이용한 재시도 처리 <br/>
* RestTemplate과 마찬가지로 Retry를 지원하지 않아, Spring-Retry를 이용해야 함. <br/>
* 재시도 전략은 백오프 전략
*/
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000L))
public CommonResponse sendRetry(final CommonRequest request) {
log.info("request[{}]", request);
return restClient.post()
.uri(retryRequestPath)
.body(request)
.retrieve()
.body(CommonResponse.class);
}
/**
* Response Server로부터 받은 HTTP 상태코드별로 에러 처리
*/
public CommonResponse get500status(final CommonRequest request) {
log.info("request[{}]", request);
return restClient.post()
.uri(retryRequestPath)
.body(request)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (httpRequest, httpResponse) -> {
log.error("errorStatus[{}], errorText[{}]", httpResponse.getStatusCode(), httpResponse.getStatusText());
throw new RuntimeException();
})
.onStatus(HttpStatusCode::is5xxServerError, (httpRequest, httpResponse) -> {
log.error("errorStatus[{}], errorText[{}]", httpResponse.getStatusCode(), httpResponse.getStatusText());
throw new RuntimeException();
})
.body(CommonResponse.class);
}
Spring Boot 3.2 이하 버전일 경우에만 추가
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
logging:
level:
web: debug # Spring Boot 3.2 이후(RestClient 사용시에만)
reactor: debug # Spring Boot 3.1 이하(WebClient 사용시에만)
@PostExchange()
의 value 속성에 환경변수를 지정할 수 없어, 하드코딩이 요구되는 단점이 있다.public interface ExampleHttpInterface {
@PostExchange(contentType = MediaType.APPLICATION_JSON_VALUE, accept = MediaType.APPLICATION_JSON_VALUE)
CommonResponse send(@RequestBody CommonRequest request);
@PostExchange(value = "/retry", contentType = MediaType.APPLICATION_JSON_VALUE, accept = MediaType.APPLICATION_JSON_VALUE)
CommonResponse sendRetry(@RequestBody CommonRequest request);
}
@UtilityClass
public class HttpInterfaceBuilder {
public <T> T builder(final RestClient restClient, Class<T> httpInterfaceType) {
final RestClientAdapter adapter = RestClientAdapter.create(restClient);
final HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
return factory.createClient(httpInterfaceType);
}
}
/**
* HttpInterface를 이용한 재시도 처리 <br/>
* Retry를 지원하지 않아, Spring-Retry를 이용해야 함. <br/>
*/
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000L))
public CommonResponse sendRetry(final CommonRequest request) {
log.info("request[{}]", request);
return HttpInterfaceBuilder
.builder(restClient, ExampleHttpInterface.class)
.sendRetry(request);
}
/**
* Response Server로부터 받은 HTTP 상태코드별로 에러 처리
*/
public CommonResponse get500status(final CommonRequest request) {
log.info("request[{}]", request);
final RestClient reBuildRestClient = restClient.mutate()
.defaultStatusHandler(HttpStatusCode::isError, (httpRequest, httpResponse) -> {
log.error("errorStatus[{}], errorText[{}]", httpResponse.getStatusCode(), httpResponse.getStatusText());
throw new RuntimeException();
})
.build();
return HttpInterfaceBuilder
.builder(reBuildRestClient, ExampleHttpInterface.class)
.get500Error(request);
}
Spring 6, Spring Boot 3.1에 들어서면서 많은 HTTP 도구들이 나왔고, 이제는 선택지가 많은 것 같다.
개인적으로는 위에서 설명한 다섯가지 선택지 중에서는 OpenFegin이나 HttpInteface이 좋아 보인다.
이 두가지 도구 중에서 고르라면 OpenFeign이 가장 좋은 것 같다.