예전에 처음 개발을 시작하고 첫 프로젝트를 했을 때, 소셜 로그인을 위해서 rest template을 사용했다 하지만 코드가 너무 복잡해 지고 Feign client라고 스프링 부트의 어노테이션을 활용하여 정말 깔끔하게 사용할 수 있다 해서 사용했었다
지금 시점에서 그때의 선택을 다시 돌아보고 feign client에서 retry와 예외처리를 정리해보자
rest template은 동기식 클라이언트 라이브러리로 Blocking I/O를 사용하여 동시성이 높은 기능에서 성능 문제가 발생할 수 있다
bloking i/o는 i/o작업이 실행되는 동안 스프링 부트 기준 timeout 지정 안하면 해당 스레드가 멈추고 그런 스레드가 많아지면 성능에 문제가 생긴다
뭐 이건 feign도 똑같긴하다 하지만 클라이언트 인터페이스를 정의 하면 쉽게 기능을 구현할 수 있고 유지 관리할 수 있다. Spring Cloud와 통합하여 여러 기능을 제공한다
WebClient는 WebFlux의 라이브러리의 일부로 Non-Blocking I/O를 사용한다 확장성과 동시 요청 처리에 좋다
지금은 부하가 그리 많지 않아서 Feign으로 하고 있는데 나중에 비동기 상황의 이벤트 기반 아키텍쳐에서 WebClient를 활용하면 좋을 것 같다
@FeignClient(value = "post-api-AlimTalk",
url = "${biz-message.request.alimtalk-url}",
configuration = AlimTalkApiClientConfig.class)
public interface AlimTalkPostApiClient {
@PostMapping("/messages")
SendFormMsgResponse sendBotKeywordMessage(AlimTalkFeignRequest request);
@PostMapping("/messages")
SendFormMsgResponse sendPriorityChoiceCompletedMessage(AlimTalkFeignRequest request);
@PostMapping("/messages")
SendFormMsgResponse sendRecommendationUserProfilesMessage(AlimTalkFeignRequest request);
@PostMapping("/messages")
SendFormMsgResponse sendMaleMatchingCompletedMessage(AlimTalkFeignRequest feignRequest);
@PostMapping("/messages")
SendFormMsgResponse sendFemaleMatchingCompletedMessage(AlimTalkFeignRequest feignRequest);
@PostMapping("/messages")
SendFormMsgResponse sendAdditionalRecommendationMessage(AlimTalkFeignRequest feignRequest);
@PostMapping("/messages")
SendFormMsgResponse sendBothAdditionalRecommendationMessage(AlimTalkFeignRequest feignRequest);
@PostMapping("/messages")
SendFormMsgResponse sendFailMatchingResult(AlimTalkFeignRequest feignRequest);
@PostMapping("/messages")
SendFormMsgResponse sendRecommendationScheduleAnnounce(AlimTalkFeignRequest feignRequest);
@PostMapping("/messages")
SendFormMsgResponse sendAlimTalk(AlimTalkFeignRequest feignRequest);
}
이런식으로 짤 수 있다 전체적인 사용 방법은 스프링의 익숙한 어노테이션들이 있어서 너무 쉽게 식별이 가능 하다
위에 configuration에 집중 해 보자
저거 왜 쓰는거임? 알림톡에 각각 설정 파일 ex) 소셜 로그인, 알림톡, PG 등 외부 api의 설정파일이 단 하나로 할 수 없다 왜 와이? 알림톡은 보내는데 실패하면 재시도 로직을 작성할건데 PG가 실패하면 재시도? 사용자에게 결제 실패 이유를 알려주고 결제를 취소 시키는게 맞다 그래서 각각 설정파일을 다르게 하는 것이다. 또한 헤더 설정도 각각 전부 스펙이 다르기 떄문에 저렇게 한다
// 여기에 @Configuration 달면 안됨 전체 적용 되버리니까
@Import({BizMsgApiHeaderConfig.class}) //헤더 설정하는건데 여기서 할 수 있지만 너무 커져서 분리 후 import
public class AlimTalkApiClientConfig {
private static final long PERIOD = 500L; //기본 재시도 주기 밀리초 설정. 첫 번째 시도 후 다음 시도까지 500ms 대기합니다.
private static final long MAX_PERIOD = TimeUnit.SECONDS.toMillis(3L); // 최대 재시도 주기를 초 단위로 설정하고 밀리초로 변환합니다. 결과적으로 최대 3000ms 동안 대기할 수 있습니다
private static final int MAX_ATTEMPTS = 5; // 최대 5번 재시도합니다.
@Bean
public Retryer retryer() {
return new Retryer.Default(PERIOD, MAX_PERIOD, MAX_ATTEMPTS);
}
@Bean
@ConditionalOnMissingBean(value = ErrorDecoder.class)
public AlimTalkErrorDecoder commonFeignErrorDecoder() {
return new AlimTalkErrorDecoder();
}
@Bean
public FeignFormatterRegistrar localDateFeignFormatterRegistrar() {
return formatterRegistry -> {
DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar(); //시간 찍눈 형식
registrar.setUseIsoFormat(true);
registrar.registerFormatters(formatterRegistry);
};
}
}
주석을 보면 설명이 되어있다
저기에 있는 Retryer도 구현해서 커스텀할 수 있지만 아직 재시도 로직에 대한 스펙이 크지 않아서 저상태로 냅뒀다
우리는 알림톡 디코더를 봐보자
위의 예시에서는 @ConditionalOnMissingBean(value = ErrorDecoder.class) 어노테이션을 사용하여 CustomErrorDecoder 빈을 등록한다. 만약 컨텍스트에 ErrorDecoder 타입의 빈이 이미 존재하지 않는다면, CustomErrorDecoder 빈을 등록한다 하지만 우리는 AlimTalkErrorDecoder라는 ErrorDecoder타입의 빈을 등록 해 주었다
public class AlimTalkErrorDecoder implements ErrorDecoder {
@Override
public Exception decode(String methodKey, Response response) {
try {
FeignException exception = FeignException.errorStatus(methodKey, response);
int status = response.status();
// 500번대는 기본적으로 리트라이
if (HttpStatus.valueOf(status).is5xxServerError()) {
throw new RetryableException(
status,
exception.getMessage(),
response.request().httpMethod(),
exception,
null,
response.request());
}
// 401 Unauthorized 에러에 대해 RetryableException을 던짐
if (status == 401) {
return new RetryableException(
status,
exception.getMessage(),
response.request().httpMethod(),
exception,
null,
response.request());
}
// retry 정책 제외 에러는 AlimTalkUnHandleException 을 던짐
String responseBody = new String(response.body().asInputStream().readAllBytes(), "UTF-8");
if (status >= 400 && status < 500) {
return new AlimTalkApiRequestException(ErrorCode.ALIMTALK_API_CLIENT_ERROR, responseBody);
}
if (status >= 500) {
return new AlimTalkApiRequestException(ErrorCode.ALIMTALK_API_SERVER_ERROR, responseBody);
}
return exception;
} catch (IOException e) {
throw new AlimTalkUnHandleException(ErrorCode.ALIMTALK_UNHANDLE_ERROR, e.getMessage());
}
}
}
주석에 잘 설명 했다 이런식으로 Feign에서 일어나는 외부 api에 대한 재시도 로직을 작성할 수 있다
방금 것과 비슷하다 PG에는 retry전략을 뺏다 왜?
FORBIDDEN_CONSECUTIVE_REQUEST 반복적인 요청은 허용되지 않습니다. 잠시 후 다시 시도해주세요.
애초에 반복적인 요청은 허용되지 않을 뿐더러 그게 아니라 해도
INVALID_REJECT_CARD 카드 사용이 거절되었습니다. 카드사 문의가 필요합니다.
이런 반복해서 될 문제가 아닌 에러들 밖에 없기 때문이다
그냥 retry뺴고
public class TossPaymentsErrorDecoder implements ErrorDecoder {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Exception decode(String methodKey, Response response) {
try {
InputStream bodyStream = response.body().asInputStream();
JsonNode body = objectMapper.readTree(bodyStream);
String message = body.get("message").asText();
return new TossPaymentsConfirmException(ErrorCode.TOSS_PAYMENTS_CONFIRM_ERROR, message);
} catch (IOException e) {
throw new TossPaymentsUnHandleException(ErrorCode.TOSS_PAYMENTS_UNHANDLE_ERROR, e.getMessage());
}
}
}
예외 메시지를 프론트에게 그대로 보내주는 방식을 채택했다 (이 부분은 추후 문제가 생길시 변경될 수 있음)
AlimTalkApiClientConfig에 보면 헤더 설정이 아예 없던데 어캄?
@Import({BizMsgApiHeaderConfig.class}) //헤더 설정하는건데 여기서 할 수 있지만 너무 커져서 분리 후 import
여기서 설정할 수 있다