[MindDiary] 이슈 4. 반복되는 사용자 권한 확인을 AOP로 리팩토링하기

Dayeon myeong·2021년 9월 5일
1

Mind Diary

목록 보기
4/10

일기 관련 API에서 사용자 토큰을 통해 접근 권한을 확인하거나, 사용자의 id 값을 가져오는 로직이 중복되는 문제가 발생했습니다.

리팩토링 전 코드

리팩토링 전에는 아래와 같이 사용자의 권한을 확인하고, 정보를 가져오는 부가 기능이 대부분의 메소드에서 반복되었습니다.
1. 토큰이 유효한지 확인 -> 유효하지않다면 401 Unauthorized 응답 반환
2. 토큰의 사용자 권한이 User인지 확인 -> 권한이 없다면 403 Forbbidden 응답 반환
3. 토큰의 UserId를 가져와서 로직 수행

	@GetMapping
    public ResponseEntity<List<DiaryDTO>> readDiaries(
       @RequestHeader(name = "Authorization") @Valid String token) {

     tokenStrategy.validateToken(token);
     if (!tokenStrategy.getUserRole(token) == Role.User) {
     	...
     }
     int userId = tokenStrategy.getUserId(token);
    ...
     
   }

예를 들어 내가 작성한 일기 목록을 조회하거나 일기를 작성할 때에는 사용자 권한으로 로그인이 되어있어야 하기 때문에 이러한 체크 로직이 필요합니다.
이러한 불필요한 반복을 줄이고 핵심 비즈니스 로직과 분리하여 관리하는 것이 좋은 방법이 될 것입니다.

매주 토비의 스프링을 읽으며 회고 시간을 진행하면서 AOP 파트를 읽게 되었습니다. 문제를 해결 할 수 있음을 알게되어 Spring AOP를 적용했습니다.

AOP

⇒ aspect : one part of a situation, problem, subject
⇒ aspect : 어떤 기능의 한 부분
애스팩트란 애플리케이션의 핵심을 담고 있지는 않지만, 애플리케이션을 구성하는 한 요소이고, 핵심기능에 부가적인 의미를 갖는 모듈을 가르킨다.

AOP는 부가기능 코드인 어드바이스와, 어디에 부가기능을 적용할지 결정하는 포인트컷을 갖고 있다.

이렇게 핵심기능과 부가 기능을 분리함으로써 개발하는 방법은 AOP라고 부른다. 핵심 기능은 순수하게 핵심만을 담은 코드로만 존재하게 된다.

스프링에서 AOP는 다이내믹 프록시 방식을 사용해서 프록시 객체를 생성한다.

프록시란, 자신이 클라이언트가 사용하려고 하는 실제 대상 타겟 객체인 것처럼 위장해서 클라이언트의 요청을 받아주는 대리자/대리인의 역할을 하는 객체를 얘기한다.
프록시로 요청을 받고 프록시가 요청을 타깃 실제 객체에 요청을 위임한다. 프록시를 사용해서 부가기능을 타깃에게 부여할 수 있다.

기본 설정

@SpringBootApplication
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class MindDiaryApplication {

	public static void main(String[] args) {
		SpringApplication.run(MindDiaryApplication.class, args);
	}

}

자동 프록시 생성 방식을 사용하기 위해서는 @EnableAspectJAutoProxy 어노테이션을 사용해야 합니다.
(사실 @SpringBootApplication 안에있는 @EnableAutoConfiguration에 AOP 설정이 되어있다고 합니다. 따라서, @EnableAspectJAutoProxy 생략이 가능하나 공부를 위해 설정했습니다.)

또한, proxyTargetClass를 true로 설정해 타깃 클래스를 상속받아 프록시를 구현하도록 강제해야 합니다.

false로 설정을 하면 프록시 객체를 생성할 때 인터페이스를 구현한 방식으로 프록시를 생성합니다. 이 경우, 실제 타깃 클래스를 프록시 객체로 감싸버려서 만약 타깃 클래스를 다른 곳에서 DI해야 할 경우 타깃 빈을 찾지 못하기 때문입니다.

( 사실 SpringBootApplication이 proxyTargetClass를 기본 옵션으로 true로 사용한다고 합니다..)

( 관련 자료 : https://mangkyu.tistory.com/175?category=761302 )

파라미터 조작

이후에 리플렉션을 사용해 Aspect 클래스에서 타깃 메서드의 파라미터를 직접 조작할 예정입니다. gradle에서는 파라미터 조작 시 다음과 같은 설정을 해야 정확한 파라미터 변수명을 받을 수 있습니다. (그렇지 않을 경우 파라미터가 args0, args1 과 같은 형태로 받아집니다.)

// build.gradle

gradle.projectsEvaluated {
     tasks.withType(JavaCompile) {
         options.compilerArgs << "-parameters"
     }
}

Role enum

현재 사용자 권한은 NOT_PERMMITTED, USER, ADMIN으로 나눠져 있습니다.
(NOT_PERMITTED의 경우는 이메일 인증을 거치지 않은 사용자의 권한입니다.)


public enum Role {
  NOT_PERMITTED, USER, ADMIN;

  public static boolean isUser(String role) {
    return valueOf(role) == USER;
  }

  public static boolean isAdmin(String role) {
    return valueOf(role) == ADMIN;
  }
}

LoginCheck 어노테이션 설정

우선, AOP를 사용하기 위해서는

  • 부가 기능을 적용할 대상을 정하는 포인트컷
  • 부가 기능 코드인 어드바이스
    위 두가지가 정해져야 합니다.

포인트컷 대상을 선정할 때 일종의 정규식과 같은 포인트컷 표현식을 사용할 수 있습니다만, 어노테이션을 사용하여 선정하는 방식을 사용했습니다.

@LoginCheck 어노테이션을 타겟 클래스의 메소드에 붙여주기만 하면 AOP를 적용할 수 있습니다.

컨트롤러 클래스의 API 메서드 위에 적용할 것이기 때문에 Target은 Method로, 스프링 AOP는 런타임 시에 프록시를 동적 생성하기 때문에 Retention을 Runtime으로 적용했습니다.

(실제로는 Retention이 CLASS만으로 지정해도 런타임 위빙이 가능합니다. Spring에서는 자동 프록시 생성 방식으로 설정하면, 프록시 객체를 생성할 때 CGLib이라는 라이브러리를 통해 타겟 빈의 클래스 정보(바이트코드)를 직접 조작하여 프록시를 만들기 때문입니다.)

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface LoginCheck {
  Role checkLevel() default Role.USER;
}

@LoginCheck 어노테이션에 Role enum 타입으로 사용자 권한을 지정할 수 있습니다. 이런 식으로 어노테이션을 만들면 아래와 같이 사용할 수 있습니다.

@LoginCheck(checkLevel = Role.USER)

@Aspect 클래스

그다음 @Aspect 클래스를 빈으로 등록하고 포인트컷과 어드바이스를 설정해야 합니다.
(스프링 IoC 컨테이너가 AOP 기능을 하기 위해서는 @Aspect 클래스도 빈으로 등록되어야 한다고 합니다.)

그 다음 @Around 포인트컷을 사용하여 커스텀 어노테이션 @LoginCheck가 달린 메소드 시작 전,후(Around)에 어드바이스가 적용되도록 합니다.

Around를 사용한 이유는 타깃 메서드의 파라미터를 조작하여 토큰의 userId를 전달하고, 타깃 메서드를 호출해야 하기 때문입니다.

현재 Aspect 클래스에서는 토큰 권한을 확인한 후에 토큰 안에 담긴 userId를 타깃 메서드에 전달해줘야 합니다. 그러기 위해서는 자바의 리플렉션을 사용하여 직접 파라미터들을 조작해야 합니다.

Around는 다른 포인트컷과 달리 ProceedingJointPoint라는 구현체를 사용하는데, 이 구현체를 통해 자바의 리플렉션 기능을 사용하고 타깃 메소드를 호출할 수 있습니다. 그렇기 때문에 Around를 사용했습니다.

로직은 다음과 같습니다.

  1. 토큰의 유효성을 검증한다.
  2. @LoginCheck 어노테이션에 지정된 checkLevel이 USER일 경우, 토큰 안에 담긴 role이 USER인지 확인한다. (Admin일 경우에도 같은 방식)
  3. 만약 아니라면 커스텀 예외 PermissionDeniedException(403 Forbbidden) 응답을 반환한다.
  4. 만약 타깃 메서드에 userId가 필요할 경우 타깃 메서드 인자를 조작해서 userId를 전달한다.
  5. 타깃 메서드 호출
@Aspect
@Component
@RequiredArgsConstructor
public class LoginCheckAspect {

  @Value("${jwt.header}")
  private String requestHeaderKey;
  
  private static final String USER_ID = "userId"; 
  
  private final TokenStrategy tokenStrategy; 
  
  @Around("@annotation(loginCheck)")
  public Object loginCheck(ProceedingJoinPoint proceedingJoinPoint, LoginCheck loginCheck)
      throws Throwable {

	//request header에 담긴 token을 가져온다
    ServletRequestAttributes requestAttributes =
        (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
    HttpServletRequest request = requestAttributes.getRequest();

    String token = request.getHeader(requestHeaderKey);

	//토큰의 유효성 검증
    tokenStrategy.validateToken(token);

	//LoginCheck에 설정된 level에 따라 권한 확인을 진행한다.
    String role = tokenStrategy.getUserRole(token);
    if (loginCheck.checkLevel() == Role.ADMIN) {
      checkAdmin(role);
    }

    if (loginCheck.checkLevel() == Role.USER) {
      checkUser(role);
    }

	// 타겟 메소드 인자에 userId가 필요할 경우 userId를 파라미터에 넣어준다.
    Object[] modifiedArgs = modifyArgsWithUserID(tokenStrategy.getUserId(token),proceedingJoinPoint);

	//변경된 파라미터들과 함께 타깃 메서드를 실행한다.
    return proceedingJoinPoint.proceed(modifiedArgs);

  }

  private Object[] modifyArgsWithUserID(int id, ProceedingJoinPoint proceedingJoinPoint) {
    Object[] parameters = proceedingJoinPoint.getArgs();

    MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature();
    Method method = signature.getMethod();

    for (int i = 0; i < method.getParameters().length; i++) {
      String parameterName = method.getParameters()[i].getName();
      if (parameterName.equals(USER_ID)) {
        parameters[i] = id;
      }
    }
    return parameters;
  }

  private void checkAdmin(String role) {
    if (!Role.isAdmin(role)) {
      throw new PermissionDeniedException();
    }
  }

  private void checkUser(String role) {
    if (!Role.isUser(role)) {
      throw new PermissionDeniedException();
    }
  }
}

리팩토링 후 코드

@GetMapping
  @LoginCheck(checkLevel = Role.USER)
  public ResponseEntity<List<DiaryResponseDTO>> readDiaries(Integer userId) {

    List<Diary> diaries = diaryService.readDiaries(userId);
    ...
  }

리팩토링을 거쳐 컨트롤러 메서드 위에 @LoginCheck를 붙혀 메소드 실행 전 사용자 권한을 체크하는 로직이 실행 되도록 만들었습니다. 인자에 userId가 포함될 경우 userId도 반환됩니다.

위 과정을 통해 핵심 로직과 관련없고 중복되었던 부가 기능을 분리할 수 있게 되었습니다.

리플렉션

리플렉션이란 런타임시에 클래스, 생성자 메소드, 인자 등등 정보를 직접 접근할 수 있는 기능이다.

단점으로는
1. 컴파일 타임 타입 체크가 주는 이점을 누릴 수 없다
2. 코드가 지저분해진다
3. 성능이 떨어진다

현재는 런타임 위빙으로 AOP가 적용되고 인자를 확인하는 경우에만 사용되기 때문에 컴파일 타입 체크를 할 필요는 없는 것 같지만
리플렉션 에러의 경우를 컴파일 타임시에 잡지 못한다. 리플렉션의 동작 흐름을 파악하는 데 어려울 수 있다.

AOP 단점 : Self Invocation 문제

프록시 방식의 AOP에서는 프록시를 통한 부가기능의 적용은 클라이언트로부터 호출이 일어날 때만 가능하다.
타깃 오브젝트에서 다른 메서드를 호출하는 경우에는 프록시를 거치지 않고 직접 타기싀 메서드가 호출된다.그래서 타깃 오브젝트에서 AOP가 적용된 다른 메서드를 호출할 경우 부가기능이 적용되지 않는다. 이를 Self Invocation이라고 한다.

AOP 단점 : 유지보수 문제

명시적으로 부가 기능이 메서드 시그니처에 나와있지 않다. 암묵적으로 부가 기능이 적용되는 것이다. 다른 개발자들은 그래서 해당 코드를 보고도 무슨 부가 기능이 있는지 모르고 메서드 수정 시에 부가 기능이 제대로 작동하는지 모른다. 따라서 유지보수의 어려움이 있다.

참고 링크

이펙티브 자바

https://woodcock.tistory.com/30

토비의 스프링 6장 AOP

https://mangkyu.tistory.com/175?category=761302

https://www.baeldung.com/spring-aop-vs-aspectj

https://www.concretepage.com/java/jdk-8/java-8-reflection-access-to-parameter-names-of-method-and-constructor-with-maven-gradle-and-eclipse-using-parameters-compiler-argument

profile
부족함을 당당히 마주하는 용기

0개의 댓글