개발/spring-cloud

[open-feign] (Decoder, ErrorDecoder) 200+본문의 에러처리 + AOP

방푸린 2024. 9. 25. 10:38
반응형

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를 적용하였다.

728x90
반응형