횡단 관심사 분리하기(feat. AOP, HandlerMethodArgumentResolver)

Mugeon Kim·2023년 7월 26일
0

1. 서론


  • 현재 내가 진행하고 있는 프로젝트를 진행하면서 전체 서비스를 이용하기 위해서 사용자의 권한을 체크하고 Write를 제한을 해야되며 해당 회원이 로그인을 하였는지 판단하고 로그인을 하였다면 사용자의 정보를 얻어오기 위하여 Security Context Holder에 접근하여 회원의 아이디를 가져오기 때문에 반복적인 코드가 필요하다.

  • 이번 게시글에는 2가지 리펙토링을 정리를 하려고 한다. (1) 특정 게시글에 Write작업을 권한을 체크를 합니다. AOP를 사용하여 권한을 체크하는 횡단 관심사를 분리하는 작업을 수행을 합니다. (2) ArgumentResolver를 통하여 해당 회원의 로그인 상태를 판단하며 만약에 해당 회원이 로그인을 하였다면 Security Context Holder에서 정보를 LoginUserDto에 저장하며 협업에 더욱 편리하게 리펙토링을 하였습니다.

2. 본론


문제 발생

  • 프로젝트를 진행을 하면서 문제를 Admin에게 요청하는 게시글을 작성을 하면서 해당 게시글은 Admin 또는 해당 회원만이 수정 및 삭제를 할 수 있게 만들어야 합니다. 그러면 해당 권한을 체크하는 로직이 공통적으로 생기게 됩니다.
    @Override
    @Transactional
    public void deleteTodoById(Long id, LoginUserDto loginUserDto) {

        Request request = requestRepository.findById(id)
                .orElseThrow(() -> new NotFoundRequest(id));

        if (!request.getMember().getId().equals(loginUserDto.getMemberId())) {
            throw new NotMatchRequestAuth(loginUserDto.getMemberId());
        }
        requestRepository.deleteById(id);
    }

    @Override
    @Transactional
    public void updateRequest(UpdateRequestRequestDto updateRequestRequestDto, LoginUserDto loginUserDto) {
        Request request = requestRepository.findById(updateRequestRequestDto.getId())
                .orElseThrow(() -> new NotFoundRequest(updateRequestRequestDto.getId()));

        if (!request.getMember().getId().equals(loginUserDto.getMemberId())) {
            throw new NotMatchRequestAuth(loginUserDto.getMemberId());
        }

        request.updateRequest(updateRequestRequestDto);
    }
  • 해당 서비스 로직을 보면 권한을 체크하는 권한 로직이 공통적으로 적용을 한다. 이때 AOP를 이용하여 문제를 해결을 하였습니다. 왜냐하면 서비스의 공통 관심사 즉. 횡단 관심사를 분리를 하여서 처리하면 이후 새로운 로직이 추가되면 권한을 체크하는 로직을 간단하게 넣을 수 있기 때문에 확장성이 더 높다고 판단을 하였습니다.
if (!request.getMember().getId().equals(loginUserDto.getMemberId())) {
      throw new NotMatchRequestAuth(loginUserDto.getMemberId());
}
  • 이 권한을 체크하는 부분을 AOP로 분리를 하였습니다.

2-2. Refactoring_1 ( AOP )


  • 해당 부분을 체크하기 위하여 Annotaion을 만들었습니다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AuthCheckAnnotation {
}
  • 해당 권한을 체크하는 로직을 Aspect로 모듈화를 만들었습니다.

  • 해당 Parameter가 다르다. delete는 Pathvariable을 사용하여 해당 문제를 요청하는 페이지를 접근을 합니다. Update 하는 부분은 Request Dto에 Id가 포함이 되어있어 Id를 기반하여 조회를 합니다. 이렇게 되었을 때 Long과 Dto의 타입이 다르기 때문에 타입의 오류가 발생한다.

  • 이러한 문제를 해결하기 위하여 joinPoint.getArgs();를 통하여 해당 Parameter를 조회하여 권한을 체크하는 로직을 정의를 했습니다.

@Slf4j
@Aspect
@Component
public class RequestAuthCheckAop {

    private final RequestRepository requestRepository;

    public RequestAuthCheckAop(RequestRepository requestRepository) {
        this.requestRepository = requestRepository;
    }


    @Around("@annotation(authCheckAnnotation)")
    public Object requestAuthCheckAop(ProceedingJoinPoint joinPoint, AuthCheckAnnotation authCheckAnnotation) throws Throwable {
        Object[] args = joinPoint.getArgs();

        if (args.length > 1 && args[1] instanceof LoginUserDto) {
            long id;
            if (args[0] instanceof Long) {
                id = (Long) args[0];
                log.info("Long Id :{}", id);
            } else if (args[0] instanceof UpdateRequestRequestDto) {
                id = ((UpdateRequestRequestDto) args[0]).getId();
                log.info("UpdateTodoRequestDto:{}", id);
            } else {
                throw new IllegalArgumentException("잘못된 메소드 인자입니다.");
            }

            LoginUserDto loginUserDto = (LoginUserDto) args[1];
            log.info("LoginUserDto:{}", loginUserDto.getMemberId());

            if (requestRepository.findByIdAndMemberId(id, loginUserDto.getMemberId()).isEmpty()) {
                log.info("권한 체크 error 발생");
                throw new NotMatchRequestAuth(id);
            }
        }
        return joinPoint.proceed();
    }
}
  • 이후 권한을 체크한 변경, 삭제하는 로직은 다음과 같이 리펙토링을 하였습니다.
    @Override
    @AuthCheckAnnotation
    @Transactional
    public void deleteTodoById(Long id, LoginUserDto loginUserDto) {
        requestRepository.deleteById(id);
    }

    @Override
    @AuthCheckAnnotation
    @Transactional
    public void updateRequest(UpdateRequestRequestDto updateRequestRequestDto, LoginUserDto loginUserDto) {
        requestRepository.findById(updateRequestRequestDto.getId())
                .orElseThrow(() -> new NotFoundRequest(updateRequestRequestDto.getId()))
                .updateRequest(updateRequestRequestDto);
    }

2-3. AOP

AOP란?

  • 관점 지향 프로그래밍이라고도 불린다.
    어떤 로직을 기준으로 핵심적인 관점, 부가적인 관점으로 나눠보고 그 관점을 기준으로 각각 모듈화 하겠다는 의미.
    핵심적인 관점: 개발자가 적용하고자 하는 핵심 비즈니스 로직.
    부가적인 관점: 핵심 로직을 수행하기 위해 필요한 DB연결(JDBC), 로깅, 파일 입출력 등...

다양한 AOP 적용 방법

1. 컴파일

  • Java에서 컴파일을 하였을 때 AOP를 먼저 실행을 하는 방식이다.

    A.java —-(AOP) —→ A.class(AspectJ)

    2. 바이트코드 조작

    A.java → A.class —-(AOP)—→ 메모리(AspectJ)

  • 프록시패턴과는 다르게 바이트코드 조작방식은 타깃 오브젝트를 뜯어고쳐서 부가기능을 직접 넣어주는 직접적인 방법을 택한다.
    AspectJ 는 프록시와는 다르게 좀 더 직접적인 방법으로 부가기능을 제공하는데, 컴파일된 Target의 클래스 파일 자체를 수정하거나 클래스가 JVM에 로딩되는 시점을 가로채 바이트코드를 조작하는 방법을 사용한다. 그렇기 때문에 .java파일과 .class 파일을 비교해보면 내용이 달라진걸 확인할 수 있다.

  • 해당 방법(바이트코드 조작)을 사용하는 이유는 두가지가 있다.

  1. 스프링과 같은 DI컨테이너의 도움을 받지 않아도 AOP를 적용할 수 있기 때문이다. 그렇기에 스프링과같은 컨테이너가 사용되지 않는 환경에서도 손쉽게 AOP의 적용이 가능해진다.

  2. 프록시 방식보다 강력하고 유연한 AOP가 가능하다.
    프록시를 AOP의 핵심 메커니즘으로 사용할 경우 부가기능(공통 모듈)을 부여할 대상은 클라이언트가 호출할 때 사용하는 메소드로 제한된다.
    하지만, 바이트코드 조작 방식을 사용하면, 오브젝트의 생성, 필드 값 조회및 조작, 스태틱 초기화 등 다양한 작업에 부가기능을 부여할 수 있다.
    이처럼 프록시를 사용한 AOP에서는 불가능한 부분에서까지 부가기능 부여가 가능하기 때문에 강력하고 유연하다.

3. 프록시 패턴(스프링 AOP가 사용하는 방법)

  • 공통 모듈을 프록시로 만들어서 DI 로 연결된 빈 사이에 적용해 Target의 메소드 호출 과정에 참여애 부가기능(공통 모듈)을 제공해준다.

  • 그렇기의 JDK 와 Spring Container 외에 특별한 기술 및환경을 요구하지 않는다.
    Advice 가 구현하는 MethodInterceptor 인터페이스는 다이내믹 프록시의 InvocationHandler와 마찬가지로 프록시부터 메소드 요청정보를 전달받아 타깃 오브젝트의 메소드를 호출하는데, 이렇게 메소드를 호출하는 전/후로 부가기능(공통 모듈)을 제공할 수 있다.

  • 이런식으로 독립적으로 개발한 부가기능 모듈을 다양한 타깃 오브젝트의 메소드에 다이내믹하게 적용해주기 위해 가장 중요한 역할을 맡고 있는게 프록시고, 스프링 AOP는 프록시 방식의 AOP라 할 수 있다.

주요 키워드

Aspect

  • 여러곳에서 쓰이는 공통 부분 코드를 모듈화한 것

Target

  • Aspect가 적용되는 곳(Ex: Class, Method ...)

Advice

  • Aspect 에서 실질적인 기능에 대한 구현체

Joint point

  • Advice 가 Target에 적용되는 시점
  • 메서드 진입할 때, 생성자 호출할 때, 필드에서 값을 꺼낼 때 등

Point cut

  • Joint Point 의 상세 스펙을 정의한 것

Proxy

  • 클라이언트와 타겟 사이에 투명하게 존재하며 부가기능을 제공하는 오브젝트.
    DI를 통해 타겟 대신 클라이언트에게 주입되며 클라이언트의 메소드 호출을 대신 받아서 타겟에 위임하며 이 과정에서 부가기능을 부여한다.

AOP 적용 전/후 의존관계

  • Controller와 Service 간에 프록시 객체인 Proxy가 생성을 합니다. Controller는 이제 실제 객체를 바로 접근하지 않고 프록시 객체를 거쳐 프록시 객체에서 실제 객체에 접근하여 로직을 수행을합니다.

  • 모든 객체들에게 프록시 객체가 생성되었고 의존관계도 프록시 객체를 통해서 이루어진다.

  • 각각의 객체들의 기능을 수행하려 메서드를 호출하면 요청을 프록시 객체가 전달받아 전/후처리 등 추가적인 작업을 수행하면서 실제 객체의 로직을 수행을 합니다.

AOP 주의사항

  • 스프링에서 사용하는 AOP는 Proxy 방식을 사용을 합니다.
  • 스프링에서는 Proxy를 상속하여 Proxy를 구현을 하는데 주의사항이 있습니다.
  • Private, Final 메소드는 AOP가 불가능하다.
  • 타겟 클래스 내에서 호출하는 타겟 메소드 Self Invocation이 불가능하다.

2-4. Refactoring_2 ( Argument Resolver + @IfLogin)

  • 프로젝트를 진행하면서 Spring Security를 사용을 하였습니다. Security를 적용하면서 로그인 회원의 정보를 SecurityContextHolder에 해당 정보를 저장을 할 수 있습니다. Controller에서 Security Context Holder에 접근하여 해당 정보를 접근할 수 있지만 반복적인 코드가 작성되며 코드의 가독성이 떨어진다.

  • 해당 방식을 리펙토링 하기 위하여 ArgumentResolver를 통하여 로그인 회원의 정보를 쉽게 접근이 가능하게 리펙토링을 하였습니다.

public class IfLoginArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterAnnotation(IfLogin.class) != null
                && parameter.getParameterType() == LoginUserDto.class;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        Authentication authentication = null;
        try {
            authentication = SecurityContextHolder.getContext().getAuthentication();
        } catch (Exception ex) {
            return null;
        }
        if (authentication == null) {
            return null;
        }

        JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication;
        LoginUserDto loginUserDto = new LoginUserDto();

        Object principal = jwtAuthenticationToken.getPrincipal();
        if (principal == null)
            return null;

        LoginInfoDto loginInfoDto = (LoginInfoDto) principal;
        loginUserDto.setMemberId(loginInfoDto.getMemberId());

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        for (GrantedAuthority grantedAuthority : authorities) {
            String role = grantedAuthority.getAuthority();
            loginUserDto.addRole(role);
        }

        return loginUserDto;
    }
}
  • 해당 코드는 HandlerMethodArgumentResolver를 사용을 하였습니다. 코드를 살펴보면 @IfLogin 어노테이션을 검증하고 SecurityContextHolder에 접근하여 해당 정보를 LoginUserDto에 넣어주는 코드를 작성을 하였습니다.

  • 이후 Config 부분에 ArgumentResolver를 스프링에 등록해준다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:3000")
                .allowedMethods("GET", "POST", "PATCH", "PUT", "OPTIONS", "DELETE");
    }

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new IfLoginArgumentResolver());
    }
}
  • 이후 로그인을 성공적으로 수행한 회원의 정보를 접근하기 위하여 @IfLogin 어노테이션을 사용하여 사용자의 접근을 할 수 있습니다.
 @Operation(summary = "단일 문제 정답 선택하기", description = "question Id를 이용하여 단일 문제 정답 선택하기")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "201", description = "단일 문제 정답 선택하기 성공"),
            @ApiResponse(responseCode = "400", description = "단일 문제 정답 선택하기 실패", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))

    })
    @PostMapping("question/{questionId}")
    @ResponseStatus(HttpStatus.CREATED)
    public QuestionAnswerDto choiceQuestion(
            @Parameter(name = "questionId", description = "문제 번호")
            @PathVariable Long questionId,
            @Parameter(name = "ChoiceAnswerRequestDto", description = "정답 선택 번호")
            @RequestBody ChoiceAnswerRequestDto choiceNumber,
            @Parameter(name = "LoginUserDto", description = "로그인 회원 정보")
            @IfLogin LoginUserDto loginUserDto

    ) {
        questionService.choiceQuestion(loginUserDto, questionId, choiceNumber);
        return memberQuestionService.isCorrectAnswer(loginUserDto.getMemberId(), questionId, choiceNumber);
    }
  • 기존에는 Security Context Holder에 접근을 Controller에 개발자가 원하는 객체에 바인딩하여 사용을 할 수 있게 리펙토링을 하여 가독성 및 관심사를 분리를 하였습니다.

참고


https://sg-choi.tistory.com/290

https://hudi.blog/spring-transaction-synchronization-and-abstraction/

https://velog.io/@backtony/Spring-AOP-%EC%B4%9D%EC%A0%95%EB%A6%AC

profile
빠르게 실패하고 자세하게 학습하기

0개의 댓글