Nick Dev

[Outstagram] AOP를 통해 횡단 관심사(cross-cutting concern) 걷어내기 본문

Outstagram

[Outstagram] AOP를 통해 횡단 관심사(cross-cutting concern) 걷어내기

Nick99 2024. 12. 11. 12:40
반응형

고민하게 된 계기

  • 프로젝트 전반에서 현재 로그인된 유저의 정보를 가져오는 일이 많다

    • '나의 게시물', '게시물 작성' 등 많은 로직에서 현재 세션에 있는 유저 정보를 필요로 함
  • 필요로 할 때마다 세션에서 유저 정보 가져오는 것은 중복된 코드가 너무 많아짐 -> 한 곳에서 처리하자!!


초기 해결 방식

ArgumentResolver 를 통해 공통 관심사를 걷어내자

@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {

    /**
     * @Login 애노테이션 붙어있으면서 UserDTO 타입이면 해당 ArgumentResolver 사용
     */
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        log.info("supportsParameter 실행");
        boolean hasLoginAnnotation =
            parameter.hasParameterAnnotation(Login.class);
        boolean hasUserType =
            UserDTO.class.isAssignableFrom(parameter.getParameterType());
        return hasLoginAnnotation && hasUserType;
    }


    /**
     * 컨트롤러 호출되기 직전에 호출되서 세션에서 유저 정보 찾아서 반환해준다. 없으면 null 리턴
     */
    @Override
    public Object resolveArgument(MethodParameter parameter,
        ModelAndViewContainer mavContainer, NativeWebRequest webRequest,
        WebDataBinderFactory binderFactory) throws Exception {
        log.info("resolveArgument 실행");
        HttpServletRequest request = (HttpServletRequest)
            webRequest.getNativeRequest();
        HttpSession session = request.getSession(false);
        if (session == null) {
            return null;
        }
        return session.getAttribute(LOGIN_USER);
    }
}
@Configuration
public class WebConfig implements WebMvcConfigurer {

    /**
     * LoginMemberArgumentResolver 등록
     */
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberArgumentResolver());
    }

}

Argument Resolver

  • 컨트롤러 메서드가 호출될 때, 필요한 매개변수를 해석해주는 역할

supportsParameter 메서드 구현

  • 해당하는 애노테이션타입이 맞는지 확인해서 특정 행위를 할지 말지 결정

  • 즉, resolveArgument 수행 여부를 결정하는 메서드임

  • 애노테이션이 @Login이고, 애노테이션을 받는 타입이 UserDTO라면 resolveArgument 를 수행함

resolveArgument 메서드 구현

  • supportsParametertrue 라면 실행되는 메서드로, 실제 세션에서 유저 정보를 조회해 리턴해준다

  • 현재 요청의 세션에서 유저 세션 정보를 가져옴

    • 이때, getSession(false)로 가져옴
    • 디폴트가 true인데, 만약 세션이 없다면 새로 생성해서 가져오기 때문에, 이를 방지하기 위해 false로 가져옴

📌문제점

  • Argument Resolver로 구현한 @Login컨트롤러 메서드의 매개변수를 동적으로 처리해줌

  • 그래서 서비스 계층 등의 다른 계층에서는 @Login 애노테이션을 사용할 수 없음

  • 그럼 어디서든지 사용할 수 있는 애노테이션으로 만들기 위해서는 Argument Resolver가 아니라 AOP로 구현하는게 맞다!


AOP 도입

AOP란?

  • AOP는 로직(code) 주입이라고 생각하며 편함

  • 특정 메서드 실행을 기점으로 앞 뒤로 로직을 주입하고 메서드를 진행

  • 이때, 주입될 코드(Advisor)들을 @Aspect에 정의해놓고, 어떤 메서드(Pointcut)에 어떤 시점(Advice)에 주입할지 명시하면 된다

  • AOP를 도입함으로써, 자연스럽게 SRP를 지키게 된다

    • 하나의 메서드가 하나의 역할을 할 수 있도록 만들어줌

    • 핵심 관심사만 남겨두고 공통 관심사는 AOP를 통해 코드 주입해주면 되니까

      AOP를 도입한 이유

  • 컨트롤러 메서드의 매개변수뿐만 아니라 서비스 계층 등 다른 계층에서도 @Login 애노테이션을 사용하기 위해서 AOP 도입

AOP 코드

@Slf4j
@Component
@Aspect
public class LoginAspect {

    @Pointcut("execution(* *(.., @com.outstagram.outstagram.common.annotation.Login (*), ..))")
    public void loginRequired() {
    }


    @Around("loginRequired()")
    public Object checkSession(ProceedingJoinPoint joinPoint) throws Throwable {

        log.info("AOP - @Login Check Started");
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = Objects.requireNonNull(attributes).getRequest();
        HttpSession session = request.getSession(false);

        if (session == null) {
            log.info("AOP - @Login Check Result - session empty");
            throw new ApiException(ErrorCode.UNAUTHORIZED_USER);
        }

        UserDTO user = (UserDTO) session.getAttribute(LOGIN_USER);
        if (user == null) {
            log.info("AOP - @Login Check Result - user empty");
            throw new ApiException(ErrorCode.UNAUTHORIZED_USER);
        }

        /*
         joinPoint 안는 현재 실행중인 메서드에 대한 정보들을 담고 있음(메소드 이름, 타입, 파라미터)
         getArgs() : 현재 메서드에 전달된 파라미터들을 객체 배열 형태로 리턴
         이 파라미터들 중에서 타입이 UserDTO인 것을 찾아 현재 세션의 유저 정보를 넣어줄 것임
        */
        Object[] args = joinPoint.getArgs();
        for (int i = 0; i < args.length; i++) {
            if (args[i] instanceof UserDTO) {
                args[i] = user;
            }
        }

        /*
         @LoginSession UserDto user -> 이 user에 현재 session에 있는 유저를 넣어준다
         위에서 변경한 파라미터를 적용하려면 파라미터 배열을 proceed() 메서드에 전달해야 함
        */
        return joinPoint.proceed(args);

    }

}

@Aspect

  • 해당 클래스가

@Pointcut

  • 어떤 메서드가 Advice의 대상이 되어야 하는지 알려주는 애노테이션
@Pointcut("execution(* *(.., @com.outstagram.outstagram.common.annotation.Login (*), ..))")
public void loginRequired() {
}
  • @Login 애노테이션이 적용된 파라미터를 포함하는 모든 메서드가 loginRequired() 메서드(Advice)의 대상이 되도록 지정함
  • 즉, @Login 이 달려있는 모든 메서드는 loginRequired()를 실행하게 한다
    (실제 로직은 checkSession에 정의되어 있음)

@Around

  • 실제 Advice를 정의하는 부분으로 지정된 JoinPoint 주위에 추가적인 로직을 실행할 수 있게 함
@Around("loginRequired()")
public Object checkSession(ProceedingJoinPoint joinPoint) throws Throwable {
    // 세션 검사 로직 구현
}
  • loginRequired()에 정의된 메서드들이 실행될 때마다 checkSession() 메서드가 호출된다
    => 즉, @Login 애노테이션이 포함된 메서드를 실행할 때마다 checkSession() 메서드가 실행된다는 말

checkSession() 메서드

  • 현재 requestsession을 꺼내서 유저가 로그인했는지 확인한다

    • 세션 꺼낼 때, false로 꺼내기 (true로 꺼내면 세션 없다면 새로 생성해서 반환함)
    • 만약 유저가 최근에 로그인을 했다면, 세션에 유저 정보가 남아 있을 것
    • 세션 만료가 지났거나, 세션에 유저 정보가 없다면 예외 발생시킴
  • joinPoint

    • 실제 호출된 메서드에 대한 정보(어떤 객체 소유의 메서드인지, 호출된 메서드의 파라미터는 무엇인지...)를 확인할 수 있음
    • 이 정보들을 순회하면서 type이 UserDTO라면 해당 파라미터에 세션에서 찾은 user 객체를 넣어준다
  • proceed() 메서드를 통해 위에서 변경한 파라미터를 실제 메서드에 적용

    • 이 메서드 호출하지 않으면 위에서 주입한 user 객체가 실제 메서드에 반영되지 않음

정리

Argument Resolver -> AOP로 리팩토링한 이유

  • @Login 애노테이션을 컨트롤러 메서드이 매개변수로만 사용하지 않고, 다른 여러 계층의 메서드의 매개변수로도 사용하기 위해서 AOP로 리팩토링 진행
반응형