[스프링부트] AOP를 구현하여 Log 남겨보기

김기연·2022년 6월 30일
2

스프링부트

목록 보기
3/3

AOP

관점 지향 프로그래밍
프로그램 구조를 관점 중심으로 바라볼 수 있게 하여 OOP를 완성시킬 수 있도록 한다!
어플리케이션 전반에 걸쳐 흩어져있는 공통적이고 부가적인 기능을 공통 관심사(Aspect)라고 하며,
이러한 관심사를 어플리케이션의 핵심 비즈니스 로직 코드로부터 분리하여 모듈화하는 프로그래밍이다.

AOP 용어

(스프링 AOP 용어가 아닌 통상적으로 사용하는 AOP 용어들)
cross cutting concern
횡단 관심사, 공통 관심사로 다른 관심사에 영향을 미치는 로직
ex) 트랜잭션 관리는 여러 객체에 영향을 주며 crosscutting concern으로 분류된다

core concern
핵심 로직

Aspect
여러 클래스들에 영향을 미치는 로직의 모듈화
로그, 트랜잭션 관리처럼 여러 클래스들에 미치는 로직을 공통화하여 분리한 것

Join point
메서드의 실행 혹은 에러 처리시 프로그램에서 발생하는 에러의 지점
스프링 AOP에서의 join point는 항상 메서드의 실행을 나타낸다

Advice
여러 join point 중 특정 join point에서 발생되는 공통적인 기능
포인트컷과 매치되는 어느 join point에서든 실행됨
특정 함수의 이름을 지정하면 함수가 실행될때마다 advice도 동작함

Pointcut
join point와 매치되는 부분을 설정
=> Point cut으로 어떤 Join Point에만 Advice를 동작시킬 것인지 설정할 수 있다.

Target object
하나 혹은 여러개의 Aspect에 의해 동작되는 객체
스프링 AOP는 런타임 프록시에 의해 실행되기 때문에 이 객체는 항상 프록시 객체

AOP proxy
Advice 메서드 실행 등과 같은 Aspect 내용을 수행하기 위해 AOP 프레임워크가 만든 프록시 객체
스프링 프레임워크에서 AOP 프록시는 JDK dynamic 프록시가 디폴트로, 어떤 인터페이스든 프록시화될 수 있다. CGLIB 프록시도 사용가능하나 CGLIB는 인터페이스를 사용하지 않고 클래스를 사용할 때 디폴트다.

Weaving
Aspect를 연결 Target object와 연결하여 AOP 객체로 만드는 것
다른 자바 AOP 프레임워크와 스프링 AOP는 런타임 때 실행

Spring에서 제공하는 advice의 타입들

advice가 언제 수행되는지 설정

Before advice
join point 이전에 실행되며 예외를 반환하지 않는 이상 join point(메서드)의 실행을 막는 기능은 수행불가

After returning advice
join point가 예외없이 정상적으로 수행된 이후에 Advice가 실행

After throwing advice
join point가 예외를 반환한 후 실행

After (finally) advice
join point가 정상적으로 수행되는지, 예외를 반환하는지에 관계 없이 join point 이후에 실행

Around advice
메서드 실행 전후로 Advice 수행
join point를 실행시킬 것인지, join point 실행 없이 값을 반환하거나 예외를 던질 것인지 설정 가능
매개변수 타입은 반드시 ProceedingJoinPoint이며 메서드 안에서 proceed()함수를 반드시 수행해야함
ex) 함수의 실행 속도 측정(타이머)할 때 사용

advice 타입 지정시 주의 사항

Around advice는 가장 강력한 advice이며, 스프링에서는 가장 덜 강력한 advice 타입을 사용하길 권장한다.
ex) 메서드의 반환 값과 같이 캐시를 업데이트 할 경우, around advice보다는 after returning advice를 사용하는 것이 좋다.

특정 advice 타입을 사용하는 것이 오류 발생의 잠재력을 줄여주고 간단한 프로그래밍 모델을 제공해준다!

ex) 여러 객체들에 영향을 주는 메서드들에 선언적 트랜잭션 관리 around advice를 적용 가능

  • Interceptor vs AOP

Interceptor
DispatchServlet이 컨트롤러를 부르기 전/후로 사용하며 컨트롤러 관련 요청과 응답에 대해 처리한다. 로그인, 권한 체크에 사용된다.
파라미터: HttpServletRequest, HttpServletResponse(HTTP 요청)

AOP
PointCut을 사용하여 특정 메서드들에 적용할 수 있으므로 더 세밀한 조정이 필요할 때 사용된다.
파라미터: JoinPoint나 ProceedingJoinPoint(메서드들)

AOP 구현 - @AspectJ

스프링에서 AOP 구현은 Spring AOP와 AspectJ로 구현할 수 있다.
AspectJ가 AOP에 더 집중한 기능으로 성능이 좋기 때문에 @AspectJ를 사용한다.

  • Spring AOP vs AspectJ

Spring AOP
런타임 위빙
런타임 시점에 동적으로 변할 수 있는 프록시 객체를 이용하기 때문에 성능에 영향을 끼칠 수 있다.

AspectJ
컴파일 시점, 컴파일 후, 로드 시점 위빙
런타임 시점에 영향을 끼치지 않는다. 컴파일이 완료된 이후에는 성능에 영향이 없다.

(차이에 대해 설명한 글은 Spring AOP와 AspectJ 비교하기를 추천!)

AspectJ Configuration 설정

@Configuration
@EnableAspectJAutoProxy
public class AppConfig {

}

@EnableAspectJAutoProxy를 사용하면 @Aspect 어노테이션이 붙은 클래스들은 스프링에 의해 자동으로 인식되고, 스프링 AOP를 설정하는데 사용된다. 이외의 AspectJ를 설정하는 방법은 추천하지 않는다고 한다.

Pointcut 설정

Pointcut은 advice가 언제 실행되는지 통제할 수 있도록 한다.

@Pointcut("execution(* transfer(..))") //transfer로 시작하는 모든 메서드와 매치
private void anyOldTransfer() {} //return 타입은 반드시 void

Pointcut 지정자들

리턴타입
*: 모든 리턴 타입 허용
String: 리턴타입이 String인 메서드
!String: 리턴타입이 String이 아닌 메서드

패키지경로
com.ex.spring: com.ex.spring 패키지만
com.ex.spring..: com.ex.spring과 그 하위패키지

클래스
ClassName: 이름이 ClassName인 클래스만
*Name: 이름이 Name으로 끝나는 클래스만
ClassName+: 클래스 이름 뒤에 '+'가 붙으면 모든 자식 클래스,
인터페이스 뒤에 붙으면 모든 인터페이스의 구현체

메서드
(..): 모든 메서드
method
(..): 메서드 명이 method로 시작하는 모든 메서드

매개변수
(..): 모든 매개변수
(*): 매개변수가 하나인 메서드
(com.ex.spring.domain.user.mode.User): 매개변수로 User를 가지는 메서드, 패키지명 전부 작성
(!com.ex.spring.domain.user.mode.User): 매개변수로 User를 가지지 않는 메서드
(String, ..): 매개변수가 여러개이고, 첫 번째 매개변수가 String 타입인 메서드
(String, *): 매개변수가 두 개이고, 첫 번째 매개변수가 String 타입인 메서드

  1. execution: 구체적인 Pointcut 생성 가능
    접근제어자, 리턴타입, 패키지경로, 클래스명, 메서드명 순으로 지정 가능
  • @Pointcut("execution(public String com.ex.spring..(..))")
    com.ex.spring 패키지에 속해있고 반환 타입이 String인 모든 public 메서드

  • @Pointcut("execution( com.ex..*.set(*))")
    com.ex 패키지 및 하위 패키지에 속해있고, 이름이 set으로 시작하는 파라미터가 1개 이상인 모든 메서드

  • @Pointcut("execution( com.ex.spring.UserService.(..))")
    com.edu.spring.UserService 인터페이스에 속한 파마리터가 0개 이상인 모든 메서드

  1. within: 특정 타입안에 속해 있는 것들을 Pointcut 지정
  • @Pointcut("within(com.ex.spring.UserService+)")
    com.ex.spring.UserService 인터페이스를 구현한 모든 메서드

  • @Pointcut("within(com.ex.spring.*)")
    com.ex.spring 패키지의 모든 메서드

  • @Pointcut("within(com.ex.spring..*)")
    com.ex.spring과 하위 패키지의 모든 메서드

  1. bean: bean 이름으로 Pointcut 지정
  • @Pointcut("bean(beanName)")
    이름이 beanName인 빈의 모든 메서드

  • @Pointcut("bean(bean*)")
    빈의 이름이 bean으로 시작하는 빈의 모든 메서드

이외 많지만 execution을 사용하면 될 것 같다!

개인 프로젝트에 적용

ExceptionLogSchema.class

package com.cheocharm.MapZ.common.log.exception;

import com.cheocharm.MapZ.common.CommonResponse;
import com.cheocharm.MapZ.common.exception.FailConvertException;
import com.cheocharm.MapZ.common.util.ObjectMapperUtils;
import lombok.Builder;
import lombok.Getter;

import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@Getter
@Builder
public class ExceptionLogSchema {
    private String api;
    private String httpMethod;
    private int statusCode;
    private String customCode;
    private String message;

    public static ExceptionLogSchema createLogSchema(HttpServletRequest request, CommonResponse response) {
        return ExceptionLogSchema.builder()
                .api(request.getRequestURI())
                .httpMethod(request.getMethod())
                .statusCode(response.getStatusCode())
                .customCode(response.getCustomCode())
                .message(response.getMessage())
                .build();
    }

    @Override
    public String toString() {
        String json = "";
        try {
            json = ObjectMapperUtils.getObjectMapper().writeValueAsString(this);
        } catch (IOException e) {
            throw new FailConvertException();
        }

        return json;
    }
}

로그에 어떤 것을 찍을 것인지 객체로 만들어준다.
toString()을 재정의하여 객체를 스트링으로 변환해준다.

ExceptionLogAspect.class

package com.cheocharm.MapZ.common.log.exception;

import com.cheocharm.MapZ.common.CommonResponse;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.Objects;

@Component
@Aspect
public class ExceptionLogAspect {
    private final Logger logger = LoggerFactory.getLogger(ExceptionLogAspect.class);

    @Pointcut("execution(* com.package1.package2.common.exception.GlobalExceptionHandler.*(..))")
    public void exceptionPointcut() {}

    @AfterReturning(pointcut = "exceptionPointcut()", returning = "result")
    public void customExceptionLog(Object result) {
        HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
        CommonResponse response = (CommonResponse) result;

        ExceptionLogSchema logSchema = ExceptionLogSchema.createLogSchema(request, response);

        logger.error("{}", logSchema.toString());

    }

}

Pointcut설정으로 어느 부분에서 로그를 찍을 것인지 설정해준다.
프로젝트에서는 GlobalExceptionHandler가 발생하는 모든 부분에서 로그를 찍기로 하였다.
요청과 응답을 가져와 만들어놨던 LogSchema에 매개변수로 넘겨주면 로그를 남길 수 있다.

결과

참고

스프링 공식 문서
[Spring] Filter, Interceptor, AOP 차이 및 정리

0개의 댓글