HttpClient 기술 중 무엇을 사용??
개발을 진행하다보면, 외부 서비스와 통신하여 데이터를 가져와야할 경우가 있습니다.
이를 위해 HttpClient 를 활용하여 진행하는데, 이때 이 HttpClient를 활용하기 위한 2가지 방법이 있습니다.
저는 openFeign을 활용하여 진행할 것입니다. 아래에는 두가지 기술에 대해서 알아보아서 정리했습니다.
첫번째는, openFeign 입니다.
OpenFeign 은 선언적 방식의 HttpClient 라이브러리로, Spring Cloud Project 중 한가지입니다. (Spring Cloud Config, Spring Cloud Eureka ... etc )
Interface를 정의하고 Annotation을 활용하여 외부서비스의 API를 선언하는 방식입니다. 이는 RestTemplate과 유사한 모습으로 진행된다고 이해하면 됩니다.
이렇게 선언된 인터페이스는 Spring이 자동으로 구현체를 생성하고 주입합니다.
또한, OpenFeign은 편리한 선언 방식을 통해 API를 호출하고, 서비스 디스커버리와 연동하여 동적으로 서비스를 찾을 수 있습니다. ( 이것이 큰 장점입니다. ) OpenFeign은 기본적으로 Ribbon을 내장하고있어, 로드밸런싱과 서비스 디스커버리를 자동으로 처리합니다.
두번째는, WebClient 입니다.
webClient는 Spring WebFlux 프레임워크의 일부로, 비동기 및 리액티브 웹 어플리케이션 개발을 위하여 설게되었습니다. ( 예시로 들면, Spring GateWay 도 Non Blocking I/O 로 설계되었습니다. Spring에서 NIO 형태의 개발을 계속해서 추천하고, 개발할려고 하는것으로 보입니다. 비동기 방식으로 사용할 경우 쓰레드가 적더라도 동시에 쓰레드를 사용할 수 있어 높은 트래픽을 만날경우에도 좋은 성능을 낼 수 있어서 그런것으로 보입니다. )
정리하자면, WebClient를 활용한다면 높은 트래픽이 주어지는 상황에서 더 높은 효율을 보일 것 같습니다.
---
이 2가지 중에 어떤 기술을 사용할지 우선 고민을 해보았습니다.
먼저, 위의 특징에 나와있듯이 WebClient가 Spring에서 계속 발전시키고, 비동기 방식임으로써 더 진보된 기술을 가지고 있다고 생각하는데
Spring Cloud와 Spring netFlix OSS 를 기반으로 하여 개발된 openfeign을 사용하는것이 Spring Cloud 환경에서 사용하는 편리성이 크기에 큰 장점이 있다고 생각합니다.
WebClient는 Spring WebFlux 프로젝트의 일부로 개발되었기에 Spring CLoud와 물론 함께 사용할 수 있지만, 비교적 복잡할 것 입니다. 그러한 점들에 있어서 저는 openFeign을 활용하여 진행해보겠습니다.
본론 ( feign 구현해보기 with requestInterceptor & errorDecoder )
1. build.gradle에 의존성을 추가합니다.
implementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-openfeign', version: '4.0.4'
2. feign 패키지를 따로 생성한뒤, client 패키지를 생성하여 해당 패키지에 feignClient를 추가해줍니다. (여기서 feignClient 위치를 Service단에 위치하게할지, 혹은 package를 새로 만들어서 feign만 모아둘지 고민했지만 feign package를 만들어서 feign 관련 내용은 모두 해당 패키지에 담기로 하였습니다.
@FeignClient(name = "${application.config.member-seminar-application-name}", configuration = {Member_SeminarFeignClientConfiguration.class})
public interface Member_SeminarFeignClient {
@GetMapping("/api/v1/member-seminar/findAllByMember_id/{member_id}")
List<MemberSeminarDTO> findAllMember_SeminarByMember_id(@PathVariable("member_id") String member_id);
}
- 코드를 보면, application.yml에서 설정값을 통해 서비스 네임을 설정하고 있습니다.
- 저는 Spring Cloud Config와 Eureka를 활용하여 프로젝트를 진행하고 있어서 name에는 service name을 application yml에 설정해두고 사용하고 있습니다.
application.yml
spring:
application:
name: seminarhub-member-seminar-api-server
3. feign/Config 패키지에 Member_SeminarFeignClientConfiguration.class 를 추가해줍니다.
@Configuration
public class Member_SeminarFeignClientConfiguration {
@Bean
public RequestInterceptor requsetInterceptor(){
return template -> {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String jwtToken = request.getHeader("Authorization");
if(jwtToken != null){
template.header("Authorization", jwtToken);
}
};
}
}
위에처럼 requestInterceptor를 코드내에서 바로 구현하는 방안도 있고,
아래의 코드처럼 RequestImterceptor를 implements 하여 Class형태로 만든뒤 Configuration 파일에 return 하여 사용하는 방안도 있습니다.
만약에 해당 코드가 너무 많아진다면 아래와 같이 분리시켜서 공통코드로 core에 모아서 같이 사용하는것도 좋을 것 같습니다.
public class customFeignRequestInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 현재 요청의 HttpServletRequest 가져오기
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String jwtToken = request.getHeader("Authorization");
if(jwtToken != null) {
template.header("Authorization", jwtToken);
}
}
}
- 서비스 A로 들어온 Header값을 가져와서 서비스 B에 보내주기 위해 RequestContextHolder에서 서비스 A에 방금 요청으로 들어와서 저장하고 있는 Header 정보를 가져와서 사용합니다.
4. 저는 feign/handler 패키지를 생성하여 ErrorDecoder를 구현했습니다. ErrorDecoder를 활용해서 Feign에서 다른 서비스 호출시 발생한 Exception을 나타낼 수 있습니다.
@Log4j2
public class Member_SeminarFeignClientErrorDecoder implements ErrorDecoder {
private ErrorDecoder errorDecoder = new Default();
@Override
public Exception decode(String methodKey, Response response) {
log.error("{} 요청이 성공하지 못했습니다. status: {} requestUrl: {}, requestBody: {}, responseBody: {}",
response.status(), methodKey, response.request().url(), response.request().body(), response.body());
int status = response.status();
if (status >= 400 && status <= 499) {
return new FeignClientErrorException("Client Error - " + response.reason());
} else if (status >= 500 && status <= 599) {
return new FeignServerErrorException("Server Error - " + response.reason());
}
return errorDecoder.decode(methodKey, response);
}
}
- ErrorDecoder를 활용하여 다른 서비스를 호출했을때 다른 서비스에서 받아오는 에러메세지를 기반으로 하여 에러메세지를 보여줍니다. 이를 통해 우리는 어떤 것이 오류인지 손쉽게 인지할 수 있기에 errorDecoder를 구현합니다.
- 400~499 번까지는 Client 오류 일것이고, 500번대는 서버오류입니다. 이에 맞추어서 Exception을 각 에러에 Exception을 반환해줍니다. 이떄 만약에 400~599번까지의 에러가 아니라면 errorDecoder가 에러를 내보냅니다.
- 1xx (Informational): 처리 중임을 나타냅니다.
- 2xx (Successful): 요청이 성공적으로 처리되었음을 나타냅니다.
- 3xx (Redirection): 요청을 완료하기 위해 추가 동작이 필요함을 나타냅니다.
- 4xx (Client Error): 클라이언트의 잘못된 요청이나 처리 실패 등을 나타냅니다.
- 5xx (Server Error): 서버의 처리 실패나 오류를 나타냅니다.
4-1. feign/exception 패캐지를 생성하여 해당 에러Exception들을 정의해줍니다. 저처럼 안하셔도 되고 단순히 throw new Exception 으로 하셔도 됩니다.
public class FeignClientErrorException extends RuntimeException {
/** default serialVersionUID */
private static final long serialVersionUID = 1L;
public FeignClientErrorException(String message) {
super(message);
}
}
5. 이제 직접 호출해보겠습니다. 사용할 Service에 Member_SeminarFeignClinets를 Bean으로 주입받아서 사용합니다.
@Service
@Log4j2
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService{
private final MemberRepository memberRepository;
// @Autowired
// private Member_SeminarClient member_SeminarClient;
private final Member_SeminarFeignClient member_SeminarFeignClient;
@Override
public MemberWithMember_SeminarDTO getMemberWithMember_Seminar(String member_id) {
Optional<Member> memberResult = memberRepository.findByMember_id(member_id);
if(memberResult.isPresent()){ //find all the seminars from the seminar micro-service
MemberDTO memberDTO = entityToDTO(memberResult.get());
List<MemberSeminarDTO> memberSeminarDTOList = member_SeminarFeignClient.findAllMember_SeminarByMember_id(member_id);
return MemberWithMember_SeminarDTO.builder()
.memberDTO(memberDTO)
.memberSeminarDTO(memberSeminarDTOList)
.build();
}
return null;
}
}
6. 호출할 다른 서비스 Controller에 URL Mapping 합니다.
@CheckRole(roles = {RoleType.USER})
@GetMapping(value ="/findAllByMember_id/{member_id}")
@Operation(summary = "4. findAll Member_Seminar information by member_id")
public ResponseEntity<List<MemberSeminarDTO>> findAllMember_SeminarByMember_id(@PathVariable("member_id") String member_id){
log.info("-------------------read----------------------");
log.info(member_id);
return new ResponseEntity<>(memberSeminarService.getListByMember_id(member_id), HttpStatus.OK);
}
- 해당 API를 5번에서 호출 시 6번에서 해당 List<MemberSeminarDTO> 를 내보내주겠습니다.
7. Feign을 활용할 서비스에 @EnableFeignClients를 추가합니다.
@EnableDiscoveryClient
@SpringBootApplication
@EnableJpaAuditing
@EnableFeignClients
public class SeminarHubMember {
public static void main(String[] args) {
System.out.println("Hello world!");
SpringApplication.run(SeminarHubMember.class, args);;
}
}
8. 추가적으로 Feign에서 만약 API를 호출했을때 API 호출이 실패한다면, 다시 호출할 수 있는 Retry 기능이 있습니다.
[feignConfiguration.class]
@Bean
public Retryer retryer() {
return new Retryer.Default(3, 2000, 3); // 최대 3번까지 2초 간격으로 재시도
}
이떄, retryer Bean을 등록하지 않고, exception에서 throw new RetryableException 을 해주면 됩니다.
@Log4j2
public class Member_SeminarFeignClientErrorDecoder implements ErrorDecoder {
private ErrorDecoder errorDecoder = new Default();
@Override
public Exception decode(String methodKey, Response response) {
log.error("{} 요청이 성공하지 못했습니다. status: {} requestUrl: {}, requestBody: {}, responseBody: {}",
response.status(), methodKey, response.request().url(), response.request().body(), response.body());
int status = response.status();
if (status >= 400 && status <= 499) {
//
throw new RetryableException(response.status(), "server", response.request().httpMethod(), null, response.request());
} else if (status >= 500 && status <= 599) {
return new FeignServerErrorException("Server Error - " + response.reason());
}
return errorDecoder.decode(methodKey, response);
}
}
RetryableException 생성자 코드
public class RetryableException extends FeignException {
private static final long serialVersionUID = 1L;
private final Long retryAfter;
private final HttpMethod httpMethod;
/**
* @param retryAfter usually corresponds to the {@link feign.Util#RETRY_AFTER} header.
*/
public RetryableException(int status, String message, HttpMethod httpMethod, Throwable cause,
Date retryAfter, Request request) {
super(status, message, request, cause);
this.httpMethod = httpMethod;
this.retryAfter = retryAfter != null ? retryAfter.getTime() : null;
}
/**
* @param retryAfter usually corresponds to the {@link feign.Util#RETRY_AFTER} header.
*/
public RetryableException(int status, String message, HttpMethod httpMethod, Date retryAfter,
Request request) {
super(status, message, request);
this.httpMethod = httpMethod;
this.retryAfter = retryAfter != null ? retryAfter.getTime() : null;
}
/**
* Sometimes corresponds to the {@link feign.Util#RETRY_AFTER} header present in {@code 503}
* status. Other times parsed from an application-specific response. Null if unknown.
*/
public Date retryAfter() {
return retryAfter != null ? new Date(retryAfter) : null;
}
public HttpMethod method() {
return this.httpMethod;
}
}
해당 기술 적용하며
생각했던것
1. Exception 생성시 Feign에서 제공하는 FeignException 을 기반으로 개발을 할까 라는 고민이 있었습니다.
FeignException이 RuntimeException을 구현하고 있어서 손쉽게 할 수는 있지만, 기존에 사용하던 ErrorDTO에 맞추어서 사용하는것이 더 나을것같다고 느껴서 단순히 4-1에 써놓은 코드처럼 바로 runTimeException을 구현해서 Exception을 표현했습니다.
[feignException.class extends RuntimeException]
public class FeignException extends RuntimeException {
2. feign 을 처음에 어떻게 패키지로 나눠야할까? 라는 고민이 있었습니다.
처음에는
feignConfiguration은 config에,
feignClient는 Service에,
feginRequestInterceptor 는 Interceptor에
feginErrorDecode 는 common-error에
이런 생각을 하다가 feign 관련 Class들은 모두 모아두는것이 관리에 용이하다는 생각이 들어 feign 패키지를 만들어서 관리하기로 했습니다.
feign 패키지를 새로 만들어서
feign/config
feign/client
feign/handler (requestInterceptor, errorDecoder, 그 외의 처리함수들)
feign/error
아마 이렇게 될 것 같습니다.
도움을 받은 글
https://techblog.woowahan.com/2657/
코드레벨에서 분석한 글
https://goodgid.github.io/Analyzing-the-Feign-Client-and-Use/