Spring 기초/Spring 기초 지식

Spring: @ControllerAdvice와 AOP를 함께 사용하여 에러 로깅하기

Stark97 2023. 10. 24. 19:37
반응형
 
 

@ControllerAdvice와 AOP 를 동시에 적용하여 로깅을 해보자

📌 서론

Spring Boot에서는 일반적으로 @ControllerAdvice와 @ExceptionHandler를 사용하여 전역 에러 핸들링을 수행한다. 그러나 이러한 방식은 상세한 로깅에 한계가 있을 수 있다. 이 글을 작성하게 된 계기는 많은 개발자들이 로깅을 어떻게 효율적으로 할 수 있을지, 그리고 예외 상황에서 어떻게 로깅을 해야 할지에 대한 고민을 하고 있기 때문이다. 다니선 회사의 동료 또한 이에 대한 궁금증을 가졌기에 이 글을 통해 그러한 고민을 조금이라도 해결할 수 있으면 좋겠다.

 

1. 문제 상황 파악

  • @ExceptionHandler가 명시된 메서드에서는 지정된 Exception 객체만을 받을 수 있기 때문에, 에러가 발생한 상황에 대한 상세한 로깅이 어렵다.
  • 예를 들어, 어떤 파라미터 값으로 인해 에러가 발생했는지, 사용자의 요청이 무엇이었는지 등에 대한 정보를 얻기 어렵다.

 

2.  해결 방법1 (HttpServletRequest, WebRequest 사용)

HttpServletRequest 사용

  • 이 문제를 해결하기 위해 @ControllerAdvice 클래스 내에서 HttpServletRequest 객체를 파라미터로 받아 로깅을 수행할 수 있다.
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import javax.servlet.http.HttpServletRequest;

@ControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger logger = LogManager.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(Exception.class)
    public void handleException(Exception e, HttpServletRequest request) {
        // 로깅
        logger.error("Request URL : " + request.getRequestURL());
        logger.error("Exception : " + e.getMessage());

        // 여기서 추가적인 에러 핸들링 로직을 구현할 수 있습니다.
    }
    
}

@ControllerAdvice:

  • 이 어노테이션이 붙은 클래스는 전역에서 예외를 잡아 처리한다.

@ExceptionHandler(Exception.class):

  • 이 어노테이션이 붙은 메서드는 Exception.class 타입의 예외를 처리한다.

HttpServletRequest request:

  • 클라이언트의 요청 정보를 담고 있는 객체이다. 이를 통해 어떤 URL에서 에러가 발생했는지, 어떤 파라미터가 들어왔는지 등을 로깅할 수 있다.

 

WebRequest 사용

  • @ControllerAdvice 내부에서 WebRequest를 파라미터로 받아 상세한 정보를 로깅할 수 있다.
  • WebRequest를 사용할 때는 주의가 필요하다. WebRequest는 일반적으로 스프링의 DispatcherServlet에서만 사용되므로, 이 외의 경우에는 HttpServletRequest를 사용하는 것이 더 안전할 수 있다.
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public void handleAllExceptions(Exception ex, WebRequest request) {
        // 로깅
        System.out.println("Exception: " + ex.getMessage());
        System.out.println("Request Params: " + request.getParameterMap().toString());
    }
}

 

이렇게 하면, 에러가 발생했을 때 어떤 URL에서 발생했는지, 어떤 예외 메시지가 출력되는지 등을 Log4J를 통해 로깅할 수 있다. 다만 이렇게 해서는 어떤 동작에 의해서 예외가 발생했는지 알 수 없다.

 

3. 해결 방법2 (AOP 사용)

단순히 WebRequest나 HttpServletRequest를 사용해서는 어떤 동작으로 인해 에러가 발생했는지 파악하기 어렵다.
이러한 상황에서 Aspect-Oriented Programming (AOP)를 활용하면 매우 유용하다.

 

AOP를 사용하는 이유

  • Cross-cutting Concerns를 모듈화할 수 있습니다. 로깅, 트랜잭션 관리, 보안 등을 한 곳에서 관리할 수 있다. 코드의 재사용성과 유지보수성이 향상된다.

 

예제: AOP를 사용한 로깅

  • Spring AOP와 Log4j 또는 SLF4J를 사용하여 메서드 호출과 예외 발생 시점에 로깅을 할 수 있다.

 

예시 코드

@Aspect // 이 클래스가 Aspect임을 선언
@Component // 스프링 빈으로 등록
public class LoggingAspect {

    private final Logger logger = LoggerFactory.getLogger(this.getClass()); // 로거 인스턴스 생성

    // 패키지 내의 모든 메서드에 적용될 Pointcut을 정의
    @Pointcut("within(com.example..*)")
    public void applicationPackagePointcut() {
        // 이 메서드는 Pointcut을 정의하기 위한 것이므로 구현은 필요 없습니다.
    }

    // 실제 Advice. 위에서 정의한 Pointcut에 대해 동작
    @Around("applicationPackagePointcut()")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        // 디버그 모드일 때 메서드 진입 로그 출력
        if (logger.isDebugEnabled()) {
            logger.debug("진입: {}.{}() 인수 = {}", joinPoint.getSignature().getDeclaringTypeName(),
                    joinPoint.getSignature().getName(), Arrays.toString(joinPoint.getArgs()));
        }
        try {
            // 실제 메서드 실행
            Object result = joinPoint.proceed();
            // 디버그 모드일 때 메서드 종료 로그 출력
            if (logger.isDebugEnabled()) {
                logger.debug("종료: {}.{}() 결과 = {}", joinPoint.getSignature().getDeclaringTypeName(),
                        joinPoint.getSignature().getName(), result);
            }
            return result;
        } catch (IllegalArgumentException e) {
            // IllegalArgumentException 발생 시 로그 출력
            logger.error("잘못된 인수: {} in {}.{}()", Arrays.toString(joinPoint.getArgs()),
                    joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName());
            throw e; // 예외를 다시 던져서 처리를 위임
        }
    }
}

설명

  1. @Aspect:  이 클래스가 AOP의 Aspect 역할을 하는 클래스임을 나타낸다.
  2. @Component:   이 클래스를 스프링 빈으로 등록한다.
  3. @Pointcut("within(com.example..*)"):   이 Pointcut은 com.example 패키지와 그 하위 패키지에 있는 모든 메서드에 적용된다.
  4. @Around("applicationPackagePointcut()"):   @Around 어드바이스는 메서드 호출 전후에 로직을 실행한다. 여기서는 로깅을 담당하고 있다.

 

4. @ControllerAdvice와 AOP를 같이 사용했을 때의 동작설명

@ControllerAdvice와 @Aspect를 함께 사용하여 JinanException라는 내가 직접 만든 커스텀 예외가 발생했을 때 어떻게 로깅을 처리하게 되는지 예시 코드를 작성해 봤다.

 

JinanException 작성

public class JinanException extends RuntimeException {

    public JinanException(String message) {
        super(message);
    }
}

이전에 작성한 LoggingAspect를 그대로 사용

@Aspect // 이 클래스가 Aspect임을 선언
@Component // 스프링 빈으로 등록
public class LoggingAspect {

    private final Logger logger = LoggerFactory.getLogger(this.getClass()); // 로거 인스턴스 생성

    // 패키지 내의 모든 메서드에 적용될 Pointcut을 정의
    @Pointcut("within(com.example..*)")
    public void applicationPackagePointcut() {
        // 이 메서드는 Pointcut을 정의하기 위한 것이므로 구현은 필요 없습니다.
    }

    // 실제 Advice. 위에서 정의한 Pointcut에 대해 동작
    @Around("applicationPackagePointcut()")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        // 디버그 모드일 때 메서드 진입 로그 출력
        if (logger.isDebugEnabled()) {
            logger.debug("진입: {}.{}() 인수 = {}", joinPoint.getSignature().getDeclaringTypeName(),
                    joinPoint.getSignature().getName(), Arrays.toString(joinPoint.getArgs()));
        }
        try {
            // 실제 메서드 실행
            Object result = joinPoint.proceed();
            // 디버그 모드일 때 메서드 종료 로그 출력
            if (logger.isDebugEnabled()) {
                logger.debug("종료: {}.{}() 결과 = {}", joinPoint.getSignature().getDeclaringTypeName(),
                        joinPoint.getSignature().getName(), result);
            }
            return result;
        } catch (IllegalArgumentException e) {
            // IllegalArgumentException 발생 시 로그 출력
            logger.error("잘못된 인수: {} in {}.{}()", Arrays.toString(joinPoint.getArgs()),
                    joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName());
            throw e; // 예외를 다시 던져서 처리를 위임
        }
    }
}

 

ExceptionAdvice 작성 (@ControllerAdvice)

@ControllerAdvice // 이 클래스가 Controller Advice임을 선언
public class ExceptionAdvice {

    private final Logger logger = LoggerFactory.getLogger(this.getClass()); // 로거 인스턴스 생성

    @ExceptionHandler(JinanException.class) // JinanException이 발생했을 때 이 메서드가 동작
    public ResponseEntity<String> handleJinanException(JinanException e) {
        logger.error("JinanException 발생: {}", e.getMessage()); // 오류 로그 남김
        return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); // 응답 반환
    }
}

오류가 발생할 코드의 서비스 레이어 작성

@Service
public class SomeService {

    public void someMethod() {
        // ... 로직 처리
        throw new JinanException("커스텀 예외 발생!");
    }
}

이렇게 하면 LoggingAspect의 동작은 다음과 같다.

  1. someMethod() 메서드를 호출 시에 로깅한다.
  2. ExceptionAdvice는 JinanException이 발생했을 때 로깅한다.
  3. 두 개의 클래스가 함께 동작하여 로깅을 수행하게 되는것이다.

예를 들어, 서비스 레이어의 someMethod()에서 지금처럼 JinanException이 발생하면, LoggingAspect의 logAround 메서드가 "잘못된 인수" 라는 로그 메시지를 남기고, ExceptionAdvice의 handleJinanException 메서드가 "JinanException 발생" 로그 메시지를 남길 것이다.

 

예상 로그는 다음과 같다.

진입: com.example.SomeService.someMethod() 인수 = []  // LoggingAspect에서 출력
잘못된 인수: [] in com.example.SomeService.someMethod()  // LoggingAspect에서 출력 (JinanException 발생 시)
JinanException 발생: 커스텀 예외 발생!  // ExceptionAdvice에서 출력

 

5. ExceptionHandler에서 특정 예외 유형을 지정하고 AOP적용

여러 종류의 커스텀 예외 처리:

  • @ExceptionHandler를 사용할 때, 여러 종류의 예외를 처리하려면 해당 예외 클래스를 배열 형태로 지정할 수 있다. 그러나 이렇게 하면 각 예외 유형에 따른 세부 로직을 분기 처리해야 할 수도 있다.
@ExceptionHandler({JinanException1.class, JinanException2.class, JinanException3.class})
public ResponseEntity<String> handleMultipleExceptions(RuntimeException e) {

    // 분기 처리
    if (e instanceof JinanException1) {
        // ...
    } else if (e instanceof JinanException2) {
        // ...
    }
    // ...
}

특정 예외 클래스에만 AOP 적용

  • @ExceptionHandler에서 처리하는 특정 예외 유형에 대해서만 AOP를 적용하고 싶다는 경우에는 경우에는 AOP의 Pointcut 표현식을 더 세밀하게 조정하여 특정 예외를 던지는 메서드에만 AOP를 적용할 수 있다.

  • 예를 들어, JinanException1, JinanException2, JinanException3 예외를 던지는 메서드가 특정 패키지나 클래스에 있다면, Pointcut 표현식을 그에 맞게 수정할 수 있다.
@Pointcut("execution(* com.example.service.*.*(..))")
public void serviceLayerPointcut() {

    // Pointcut 표현식으로 서비스 레이어의 모든 메서드를 지정
}

예외 유형에 따른 로깅

  • @Around 어드바이스에서는 catch 블록에서 예외 유형을 체크하여 로깅을 다르게 할 수 있다.
@Around("serviceLayerPointcut()")
public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {

    try {
        Object result = joinPoint.proceed();
        return result;
    } catch (Exception e) {
        if (e instanceof JinanException1 || e instanceof JinanException2 || e instanceof JinanException3) {
            logger.error("특정 JinanException 발생: {}", e.getMessage());
        } else {
            logger.error("기타 예외 발생: {}", e.getMessage());
        }
        throw e;
    }
}
  • 이렇게 하면, @ExceptionHandler에서는 HTTP 응답 처리를 담당하고, AOP에서는 로깅을 담당하게 된다.
  • 또한, AOP는 JinanException1, JinanException2, JinanException3 예외 유형에 대한 로깅을 특별히 처리하게 된다.
  • 이 방법으로 @ExceptionHandler와 AOP를 함께 사용하여 예외 처리와 로깅을 더 효율적으로 할 수 있다.

 

📌 서론

이번 내용을 정리하면서 중앙집중 예외처리 방식을 사용할 때 내가 확인하고 싶은 메서드에 주입된 인자와 그 메서드가 어떤 동작을 했는지 알아보기 위한 로깅처리에 대해 조금 더 알아보게 되었다. 

나도 이전 프로젝트에 이것들을 잘 적용시켰다면 빠르게 문제를 발견하고 해결할 수 있었을거라는 생각이 들기도 했다. 이런 생각을 가지게 해준 후배(설입사는 후배)동료에게 감사함 전하며 이번 포스팅을 마친다.

반응형