본문 바로가기

TIL

TIL 240617 과제 피드백 AOP

AOP

 

AOP란? 관점 지향 프로그래밍이다. 하나의 로직을 기준으로 공통 관심 사항(cross-cutting concern)과 핵심 관심 사항(core concern)으로 관점을 분리하여 이 관점들을 기준으로 모듈을 분리한다.

 

AOP는 언제 사용하나?

이번 과제 요구사항처럼, 모든 Controller에 Request 정보를 Log로 출력해야하는 상황이 있다면, 각각의 Controller마다 모두 Log를 추가해주어야 합니다.

하지만 이런 경우

1. 모든 Controller에 중복 코드를 작성해야 합니다.

2. Log에 출력할 값을 변경하게 된다면 Controller 개수만큼 해당 코드를 수정해야 합니다.

 

예시 요구사항인 Controller는 절대적인 수가 적지만, 만약 이게 Service Method 단위였다면?

수백 수천번 반복하여 수정해주어야 합니다.

 

결론적으로 유지보수가 매우 어려워집니다.

여기서 Log를 출력하는 메서드는 핵심 관심 사항이 아닌 공통 관심 사항 입니다.

이런 경우 AOP를 활용할 수 있습니다.

 

AOP는 Controller 앞에서 모든 공통 관심 사항을 처리하는 Filter와 비슷한 역할을 수행합니다.

하지만 AOP의 경우 Filter처럼 다양한 작업을 수행할 수 있는 부가적인 기능이 많이 없고, Request, Response(요청, 응답)을 쉽게 다룰 수 있습니다.

즉, 요청과 응답에 특화되어 있습니다. 공통 관심 사항 처리라는 공통점을 이용해 AOP로 인증/인가 기능 또한 구현할 수 있습니다.

 

Spring Filter 적용 후 동작 순서

 

  1. Web과 관련된 공통 관심사는 Servlet Filter 혹은 Spring Intercepter 를 주로 사용합니다.
  2. System.out.println()이 아닌, Logging 을 사용하는 이유?
    1. 실무에서는 Log를 작성할 때, 주로 로깅툴을 사용합니다.
      1. System.outprintln() 메서드는 IO 리소스를 많이 사용한다.
      2. 로그를 파일로 저장할 수 있다.
      3. 로컬, 개발, 운영 각각 환경에 맞게 Log Level을 조절할 수 있다.
      4. 원하는 Log Level의 Log만 골라서 확인할 수 있다.(검색)

 

Spring AOP 적용

💡 요구사항 모든 API(Controller)가 호출될 때, Request 정보(Request URL, HTTP Method)를 @Slf4J Logback 라이브러리를 활용하여 Log로 출력해주세요.

  • 컨트롤러 마다 로그를 출력하는 코드를 추가하는것이 아닌, AOP로 구현해야만 합니다. 

AOP 구현

코드

어라운드 어드바이스로 @Around, @Before, @After 중 어떤 것을 사용해도 무방하다

구현 예시는 @Around가 사용됨

- 메서드 실행 전후에 특정한 동작을 수행하도록 하는 Advice를 정의하는데 사용됨

return type Object : 관심사가 실행되고 난 결과값

ProceedingJoinPoint 

- @Before, @After 어노테이션의 JoinPoint인터페이스를 상속한 인터페이스로, proceed() 라는 메서드가 추가되어 있다. 이것은 핵심관심사의 실행을 뜻하고, 반환 값으로 위의 Object를 받는다.

 

구현 예시의 methodName, params를 Log로 출력하는 부분은 필수 구현은 아니지만, 참고차 추가

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;

@Slf4j // Lombok을 사용하여 로그를 사용하도록 설정한다.
@Aspect // 해당 클래스가 AspectJ를 사용한 AOP 클래스임을 나타낸다. AOP 구현을 위한 Proxy 생성 등을 자동으로 해준다.
@Component // Spring에서 관리하는 Bean으로 설정한다.
public class RequestLogAop {

    // @PointCut : 실행 시점을 설정하는 어노테이션
    // 첫번째 * : 메서드의 반환 타입을 지정한다, 반환 타입에 상관없이 포인트컷을 적용한다는 뜻
    // com.thesun4sky.todoparty : 패키지 경로를 의미한다.
    // .. : 하위 디렉토리를 뜻한다.
    // *Controller : 클래스 이름을 지정한다. Controller로 끝나는 모든 클래스를 의미한다.
    // . : 클래스 내의 메서드를 뜻한다.
    // * : 메서드 이름을 지정한다. 즉, 모든 메서드 이름을 뜻한다.
    // (..) : 메서드의 매개변수를 지정한다. ".."은 0개 이상의 모든 매개변수를 의미한다.
    
    // com.thesun4sky.todoparty 패키지 및 하위 패키지에 있는, 이름이 Controller로 끝나는 모든 클래스의 모든 메서드(매개변수와 상관없이)를 대상으로 한다
    @Pointcut("execution(* com.thesun4sky.todoparty..*Controller.*(..))")
    private void controller() {}

    // 포인트컷으로 설정된 메서드 실행 전후에 로그를 출력하는 Around Advice
    @Around("controller()")
    public Object loggingBefore(ProceedingJoinPoint joinPoint) throws Throwable {
        
        // 현재 HTTP 요청을 가져온다
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

				// null 일 경우를 대비하여 null인 경우 경고 로그를 출력한다.
        if (requestAttributes == null) {
            log.warn("RequestAttributes가 null 입니다!");
            return joinPoint.proceed();
        }

				// HttpServletRequest 객체를 가져온다.
        HttpServletRequest request = requestAttributes.getRequest();
        
        // 현재 실행 중인 메서드 이름을 가져온다.
        String methodName = joinPoint.getSignature().getName();
        
	      // 요청 URI를 UTF-8로 디코딩하여 가져온다.
        String requestUri = URLDecoder.decode(request.getRequestURI(), StandardCharsets.UTF_8);
        
        // HTTP 메서드(GET, POST 등)를 가져온다.
        String httpMethod = request.getMethod();
        
        // 요청 파라미터를 가져와 문자열로 변환한다.
        String params = getParams(request); 

        log.info("[{}] {}", httpMethod, requestUri);
        log.info("method: {}", methodName);
        log.info("params: {}", params);

				// 원래 호출되어야할 메서드를 실행한다.
        return joinPoint.proceed(); 
    }

    // HTTP 요청의 파라미터를 문자열로 변환하여 반환한다
    private static String getParams(HttpServletRequest request) {
				
				// parameter를 Map 형태로 가져온다.
        Map<String, String[]> parameterMap = request.getParameterMap();
        
        // 맵의 Entry들을 Stream으로 변환한다.
        return parameterMap.entrySet().stream()
                .map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue())) // 각 엔트리를 "key=[value]" 형태의 문자열로 변환한다.
                .collect(Collectors.joining(", ")); // 변환된 문자열들을 ", "로 구분하여 하나의 문자열로 합친다.
    }
}

 

실행결과

회원가입

POST      localhost:8080/api/users/signup

 

로그인

POST     localhost:8080/api/user/login

 

Todo

POST localhost:8080/api/todos/1

 

Comment

PUT    localhost:8080/api/comments/1

 

 

'TIL' 카테고리의 다른 글

TIL 240624 Cookie  (0) 2024.06.25
TIL 240621 Validation  (0) 2024.06.24
TIL 240614 Mockito, 통합테스트  (0) 2024.06.17
TIL 240613 단위 테스트  (1) 2024.06.14
TIL 240612 카카오 로그인  (1) 2024.06.13