[open-feign] (Decoder, ErrorDecoder) 200+본문의 에러처리 + AOP
OpenFeign은 Spring Cloud에서 제공하는 HTTP 클라이언트로, RESTful 서비스 간의 통신을 간편하게 처리할 수 있게 해준다.
1. OpenFeign의 기본 에러 처리 방식
OpenFeign은 기본적으로 400번대, 500번대의 HTTP 상태 코드를 에러로 간주하며, feign.FeignException을 발생시킨다.
- 4xx (클라이언트 에러): 잘못된 요청, 인증 실패 등.
- 5xx (서버 에러): 서버 내부 오류, 서비스 불가 등.
2. 에러 처리(httpCode != 200)의 경우
2-1. Feign Client에 ErrorDecoder 설정
ErrorDecoder의 역할
- ErrorDecoder는 HTTP 상태 코드가 200번대가 아닐 때 호출됨
- ErrorDecoder는 주로 4xx(클라이언트 에러)나 5xx(서버 에러)에 대한 예외 처리를 담당
- OpenFeign은 기본적으로 ErrorDecoder.Default를 사용하여 FeignException을 던지며, 이를 커스터마이징하여 예외를 처리할 수 있음.
클래스 레벨 @FeignClient -> configuration에 커스텀 에러 디코더 달아줄 수 있음
import org.springframework.cloud.openfeign.FeignClient;
@FeignClient(name = "example-client", url = "http://example.com", configuration = FeignConfig.class)
public interface ExampleClient {
@GetMapping("/resource")
String getResource();
}
///////////
import org.springframework.context.annotation.Bean;
public class FeignConfig {
@Bean
public ErrorDecoder errorDecoder() { //////FeignException이 발생한 경우
return new CustomErrorDecoder();
}
}
////////////
import feign.Response;
import feign.codec.ErrorDecoder;
import org.springframework.http.HttpStatus;
import org.springframework.util.StreamUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class CustomErrorDecoder implements ErrorDecoder {
//에러 처리
@Override
public Exception decode(String methodKey, Response response) {
String message = null;
try {
if (response.body() != null) {
message = StreamUtils.copyToString(response.body().asInputStream(), StandardCharsets.UTF_8);
}
} catch (IOException e) {
return new Exception("Failed to process response body", e);
}
HttpStatus status = HttpStatus.resolve(response.status());
switch (status) {
case BAD_REQUEST:
return new BadRequestException("Bad Request: " + message);
case NOT_FOUND:
return new NotFoundException("Resource Not Found: " + message);
case INTERNAL_SERVER_ERROR:
return new InternalServerErrorException("Internal Server Error: " + message);
default:
return new Exception("Generic error: " + status + " " + message);
}
}
}
위 예제는 http status가 200이 아닐 경우이다. 200이 아닌 400, 500일 경우 Exception으로 처리되고 자동으로 저기에 걸린다.
3. 성공 처리(httpCode == 200)의 경우
3-1. Decoder 사용해서 처리
Decoder는 OpenFeign에서 HTTP 응답을 처리할 때 사용되는 인터페이스로, 주로 성공적인 HTTP 응답(200번대)을 파싱하여 객체로 변환한다. 기본적으로 Decoder는 예외 처리와는 관계없이 모든 응답을 처리할 수 있다. 그러나 응답이 성공적이지 않은 경우, 즉 HTTP 상태 코드가 200번대가 아닐 경우에는 OpenFeign이 기본적으로 ErrorDecoder를 사용하여 예외를 던지기 때문에 Decoder가 호출되지 않는다.
만약 200인데 body에 있는 값으로 에러를 처리해야한다면 Decoder를 사용할 수 있다.
import feign.Response;
import feign.Util;
import feign.codec.Decoder;
import java.io.IOException;
import java.lang.reflect.Type;
public class CustomDecoder implements Decoder {
private final Decoder defaultDecoder;
public CustomDecoder(Decoder defaultDecoder) {
this.defaultDecoder = defaultDecoder;
}
@Override
public Object decode(Response response, Type type) throws IOException {
// 상태 코드가 200번대일 때만 호출됨
if (response.status() == 200) {
// 응답 바디를 읽고 필요한 처리를 수행
String body = Util.toString(response.body().asReader());
System.out.println("Response Body: " + body);
// 기본 디코더를 사용하여 바디를 객체로 변환
return defaultDecoder.decode(response, type);
}
// 상태 코드가 200번대가 아닌 경우 예외를 던지거나 기본 처리를 수행
throw new RuntimeException("Unexpected status code: " + response.status());
}
}
Decoder와 ErrorDecoder의 비교
- Decoder: 성공적인 HTTP 응답(200번대)에 대한 처리. 응답을 객체로 변환(파싱).
- 상태 코드가 200이더라도 응답 바디에 따라 예외 처리가 필요하다면, Decoder에서 바디를 확인하고 사용자 정의 예외를 던질 수 있다.
- ErrorDecoder: 4xx, 5xx 상태 코드에 대한 예외 처리. 예외를 던짐.
3-2. feign 의 interceptor사용
@FeignClient -> configuration에 feign의 interceptor를 사용할 수 있다.
이 때 interceptor는 모든 처리를 잡기 때문에 조건을 잘 줘야한다.
import feign.Response;
import feign.codec.ErrorDecoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FeignConfig {
@Bean
public ResponseInterceptor responseInterceptor() {
return new ResponseInterceptor();
}
}
///////////////
import feign.Request;
import feign.Response;
import feign.Util;
import feign.codec.ErrorDecoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class ResponseInterceptor implements feign.ResponseInterceptor {
private static final Logger logger = LoggerFactory.getLogger(ResponseInterceptor.class);
@Override
public void apply(Response response) {
// HTTP 상태 코드가 200인 경우만 처리
if (response.status() == 200 && response.body() != null) {
try {
// 응답 바디를 읽어온다.
String body = Util.toString(response.body().asReader(StandardCharsets.UTF_8));
logger.info("Response Body: {}", body);
// 응답 바디의 특정 조건을 확인
if (body.contains("error") || body.contains("INVALID")) {
// 응답 바디에 에러 메시지가 포함된 경우 예외를 발생시킴
throw new CustomException("Error found in response body");
}
} catch (IOException e) {
logger.error("Failed to read response body", e);
throw new RuntimeException("Failed to read response body", e);
}
}
}
}
3-3. AOP로 처리
@FeignClient(name = "gameserver-client", url = "${external.gameserver.url}")
@ExternalGameServerAdminHeaderValid
public interface ExternalGameServerFeignService {
@Slf4j
@Component
@Aspect
public class ExternalGameServerAdminHeaderValidAspect {
@Around(
"@within(com.annotation.ExternalGameServerAdminHeaderValid) || @annotation(com.annotation.ExternalGameServerAdminHeaderValid)")
public Object validationAdminHeader(ProceedingJoinPoint joinPoint) throws Throwable {
Object response = joinPoint.proceed(); //메소드 실행
if (response instanceof ExternalAdminResponseHeader) {
ExternalAdminResponseHeader header = (ExternalAdminResponseHeader) response;
String errorMessage = header.getHeader().getErrMsg();
if (StringUtils.isNotEmpty(errorMessage)) {
MethodSignature method = (MethodSignature) joinPoint.getSignature();
String methodName = method.getMethod().getName();
log.warn("invalid admin header {}, {}", methodName, errorMessage);
throw new AException(AExceptionCode.ADMIN_SUPPORT_API_ERROR, methodName, errorMessage);
}
} else {
log.warn("response is not ExternalAdminResponseHeader");
}
return response;
}
}
클래스/메소드에 사용된 어노테이션 함수 전/후로 실행되도록 짜져 있으나 실질적으로는 후에만 실행하면 되니까 아래처럼 수정해도 될 것 같다.
@AfterReturning(
pointcut = "@within(com.annotation.ExternalGameServerAdminHeaderValid) || @annotation(com.annotation.ExternalGameServerAdminHeaderValid)",
returning = "response")
public void validationAdminHeader(JoinPoint joinPoint, Object response) {
// 메서드가 정상적으로 실행된 후 반환된 response 객체를 이용해 로직 수행
if (response instanceof ExternalAdminResponseHeader) {
ExternalAdminResponseHeader header = (ExternalAdminResponseHeader) response;
String errorMessage = header.getHeader().getErrMsg();
if (StringUtils.isNotEmpty(errorMessage)) {
MethodSignature method = (MethodSignature) joinPoint.getSignature();
String methodName = method.getMethod().getName();
log.warn("Invalid admin header in method: {}, error message: {}", methodName, errorMessage);
}
} else {
log.warn("Response is not of type ExternalAdminResponseHeader");
}
}
- pointcut: 타겟 메서드를 지정하는 포인트컷 표현식을 정의. @within 및 @annotation을 사용하여 특정 어노테이션이 적용된 클래스나 메서드에 대해 어드바이스를 적용
- returning = "response": 반환 값을 response라는 매개변수로 받아옴. 반환 값이 없거나, void인 경우에는 @AfterReturning 어드바이스가 실행되지 않음
1. 포인트컷 표현식의 의미
1.1. @within(com.annotation.ExternalGameServerAdminHeaderValid)
- 의미: 클래스 레벨에 @ExternalGameServerAdminHeaderValid 어노테이션이 적용된 모든 클래스의 모든 메서드를 포인트컷으로 지정
@ExternalGameServerAdminHeaderValid
public class SomeController {
public void someMethod() {
// 이 메서드는 @within 포인트컷에 의해 AOP 적용 대상이 됨
}
public void anotherMethod() {
// 이 메서드도 @within 포인트컷에 의해 AOP 적용 대상이 됨
}
}
1.2. @annotation(com.annotation.ExternalGameServerAdminHeaderValid)
- 의미: 메서드 레벨에 @ExternalGameServerAdminHeaderValid 어노테이션이 적용된 특정 메서드를 포인트컷으로 지정
public class AnotherController {
@ExternalGameServerAdminHeaderValid
public void specificMethod() {
// 이 메서드는 @annotation 포인트컷에 의해 AOP 적용 대상이 됨
}
public void otherMethod() {
// 이 메서드는 @annotation 포인트컷에 의해 AOP 적용 대상이 아님
}
}
@Around와 @After, @AfterReturing, @AfterThrowing의 차이
@Around 어노테이션
- @Around 어노테이션은 메서드 호출 전후에 특정 로직을 실행할 수 있음
- ProceedingJoinPoint를 통해 메서드 호출 전후에 실행되는 로직을 정의할 수 있으며, joinPoint.proceed()를 호출함으로써 실제 메서드를 실행
- joinPoint.proceed()를 호출하지 않으면 실제 메서드가 실행되지 않음
- 메서드의 실행을 제어하거나, 실행 결과를 변환하거나, 예외 처리를 커스터마이징할 때 유용함
- 메서드 실행 여부를 조건에 따라 결정 가능
@Around("@annotation(com.example.MyAnnotation)")
public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
// 메서드 실행 전 로직
log.info("Before method execution");
Object result = joinPoint.proceed(); // 실제 메서드 호출
// 메서드 실행 후 로직
log.info("After method execution");
return result; // 메서드의 결과 반환
}
@After 어노테이션 (= finally)
- @After는 메서드가 정상적으로 실행되었거나 예외가 발생하더라도 항상 실행됨. 메서드의 반환 값에 접근할 수 없고, 메서드가 실행되었음을 확인하거나 리소스 정리 등의 작업을 할 때 사용.
- @After 어노테이션은 메서드의 실행이 완료된 후에 AOP 로직을 실행함
- joinPoint.proceed()와 같은 메서드 호출을 제어할 수 없음!!
- 메서드의 반환 값이나 예외 처리에는 관여하지 않음. 메서드가 종료된 후 추가적인 작업(예: 리소스 정리, 로그 기록)을 수행하는 데 사용됨
- 현재 상황에 부적합.. 무조건 실행되나 return되는 객체를 잡을 수 없다.
@AfterReturning 어노테이션
- 메서드가 성공적으로 완료된 후에만 실행. 예외가 발생하지 않은 경우에만 호출!
- @AfterReturning은 메서드가 성공적으로 반환된 후에만 실행됨. 반환된 값에 접근할 수 있으며, 이를 기반으로 후처리 로직을 작성할 수 있다.
- 타겟 메서드의 반환 값에 접근할 수 있음
@AfterThrowing 어노테이션
- @AfterThrowing: 메서드에서 예외가 발생했을 때만 실행
- 타겟 메서드가 던진 예외에 접근할 수 있음
- 예외 발생 시, 예외를 기반으로 추가 로직이 필요한 경우
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class ComprehensiveLoggingAspect {
@AfterReturning(pointcut = "execution(* com.example.service.MyService.*(..))", returning = "result")
public void logAfterReturning(Object result) {
System.out.println("Method returned successfully with value: " + result);
}
@AfterThrowing(pointcut = "execution(* com.example.service.MyService.*(..))", throwing = "ex")
public void logAfterThrowing(Exception ex) {
System.out.println("Method threw an exception: " + ex.getMessage());
}
}
- pointcut: 어떤 메서드에 대해 어드바이스를 적용할지 지정합니다. 이 예제에서는 MyService 클래스의 모든 메서드에 적용됩니다.
- returning: 타겟 메서드의 반환 값을 받아오기 위한 매개변수 이름을 지정합니다. 이 매개변수를 통해 타겟 메서드의 반환 값에 접근할 수 있습니다.
- throwing: 타겟 메서드가 던진 예외를 받아오기 위한 매개변수 이름을 지정합니다. 이 매개변수를 통해 발생한 예외에 접근할 수 있습니다.
참고
위의 AOP 함수는 아래의 Decoder로 변환 가능하다.
@Slf4j
@RequiredArgsConstructor
public class FeignDecoder implements Decoder {
private final Decoder defaultDecoder;
@Override
public Object decode(Response response, Type type) throws IOException, DecodeException, FeignException {
String responseBody = Util.toString(response.body().asReader(StandardCharsets.UTF_8));
ExternalAdminResponseHeader header = JacksonUtils.jsonToObject(responseBody, ExternalAdminResponseHeader.class);
String errorMessage = header.getHeader().getErrMsg();
if (StringUtils.isNotEmpty(errorMessage)) {
log.warn("invalid admin header {}", errorMessage);
throw new AException(AapokerExceptionCode.ADMIN_SUPPORT_API_ERROR, errorMessage);
}
return defaultDecoder.decode(response, type);
}
}
다만 이 경우 기존에 에러가 발생한 함수를 알 수 없다..
feign은 성공했다고 남기에 에러로그가 없어 어떤 함수에서 호출한 응답인지 확인이 어려워 결국 AOP를 적용하였다.