AOP 적용, 역할 췤~!

위승현·2024년 12월 27일
0

Spring

목록 보기
9/12

trello 칸반보드 프로젝트를 진행하던 중에 문제가 발생했다.
우리 팀이 짠 테이블 구조는 이렇다.

USER - MEMBER - WORKSPACE 의 관계를 주목하자

USER 의 권한은 이 유저가 일반 유저인가? 아니면 ADMIN 유저인가를 구별할 수 있게 해준다.
이 때 ADMIN 유저로 가입한 사람만 WORKSPACE 생성이 가능하다.
즉 여기서의 권한은 워크스페이스를 생성할 수 있는가 없는가 를 구분해주는 역할이다.

이번에는 MEMBER 의 역할에 대해 알아보자.
MEMBER는 특정 WORKSPACE 에 속하게 되었을 때 부여되는 것인데
일반 USER, MEMBER, OWNER 로 나뉜다.

처음 워크스페이스를 생성한 사람은 OWNER 의 역할을 갖고
그 뒤로 다른 유저에게 초대를 보내어 그 유저가 초대를 받으면 USER 의 역할
OWNER 가 그 뒤로 역할을 변경해주면 MEMBER 의 역할을 가질 수 있다.

쉽게 말하자면 USER 는 워크스페이스 읽기 전용 이라고 생각하면 되고
MEMBER는 워크스페이스 내부에서 OWNER 와 같은 권한을 가지게 된다고 생각하면 된다.

이러한 상황에서 나는 워크스페이스를 생성할 때의 권한 체크는 인터셉터로 구현했다.
로그인할 때 세션 값에 해당 유저의 id 와 권한값을 넣어주고
인터셉터에서 권한을 확인하여 워크스페이스를 생성할 수 있는지 없는지 인가를 진행한다.

멤버는?

  1. 하지만 MEMBER의 역할은 처음부터 존재하는 것도 아니고
    특정 워크스페이스에 합류하는 시점에 생성되는 것

  2. 하나의 유저가 여러 워크스페이스에 속해있을 수 있기에 권한이 여러 개 나올 수 있음

이러한 문제들 때문에 로그인하는 과정에서 MEMBER 의 역할을 넣어주는 것은
좋지 않다고 판단했고, 따라서 처음에는 서비스 단에서 역할 체크를 진행했다.

하지만 역할 체크가 필요한 모든 메서드에서 반복적인 코드가 발생했고
반복되는 부분을 private 메서드로 분리하는 대신 해결할 수 있는 방법이 없을까 생각했다.

왜냐하면 역할 체크는 workspace 작업 뿐만 아니라 다른 곳에서도 필요했기 때문이다.


AOP로 역할 췌크~!

따라서 AOP를 적용해보기로 했다.

AOP가 뭔데??

AOP에 대해 알아보자!

적용해보기 전 AOP 가 무엇인지 알아보았다!

어노테이션 사용

Pointcut 을 적용할 때 방식은 여러가지가 있다.
하위 패키지 전체에 적용할건지, 특정 클래스 내부 모든 메서드에 적용할건지
아니면 특정 어노테이션이 적용되었을 때 만들 것인지 를 선택이 가능한데

우리는 하나의 서비스 클래스 내부에서도 권한을 체크할 메서드가 존재하고 체크하지 않아도
되는 메서드가 존재하기 때문에 어노테이션을 생성하여 적용시키는 방식으로 진행하기로 했다.


1. RoleCheck 어노테이션 생성

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RoleCheck {
    Role[] allowRoles() default {};
}
  • @Retention(RetentionPolicy.RUNTIME): 이 애너테이션이 런타임에 유지되어 실행 중에 참조 가능함을 나타냄
  • @Target(ElementType.METHOD): 이 애너테이션은 메서드에만 붙일 수 있다는 의미
  • Role[] allowedRoles(): 해당 메서드에 허용된 역할 목록을 설정

2. Aspect 클래스 선언 후 @Advice 추가

@Aspect
@Component
@RequiredArgsConstructor
public class RoleCheckAspect {

    private final MemberRepository memberRepository;
    private final WorkspaceRepository workspaceRepository;

    @Around("@annotation(roleCheck)")
    public Object checkPermission(ProceedingJoinPoint joinPoint, RoleCheck roleCheck) throws Throwable {
        Object[] args = joinPoint.getArgs();
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();

        // Workspace 멤버 권한 체크
        if (roleCheck.allowRoles().length > 0) {
            Long workspaceId = extractWorkspaceId(args, method);
            Long userId = extractUserId(args, method);

            Workspace workspace = workspaceRepository.findById(workspaceId)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 워크스페이스입니다."));

            Member member = memberRepository.findMemberByUserIdAndWorkspace(userId, workspace);
            if (member == null) {
                throw new ResponseStatusException(HttpStatus.FORBIDDEN, "워크스페이스 멤버가 아닙니다.");
            }

            if (!Arrays.asList(roleCheck.allowRoles()).contains(member.getRole())) {
                throw new ResponseStatusException(HttpStatus.FORBIDDEN, "해당 작업에 대한 권한이 없습니다.");
            }
        }

        return joinPoint.proceed();
    }

    private Long extractWorkspaceId(Object[] args, Method method) {
        Parameter[] parameters = method.getParameters();
        for (int i = 0; i < parameters.length; i++) {
            if (parameters[i].getName().toLowerCase().contains("workspaceid") && args[i] instanceof Long) {
                return (Long) args[i];
            }
        }
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "workspaceId가 요청에 포함되지 않았습니다.");
    }

    private Long extractUserId(Object[] args, Method method) {
        Parameter[] parameters = method.getParameters();
        for (int i = 0; i < parameters.length; i++) {
            if (parameters[i].getName().toLowerCase().contains("userid") && args[i] instanceof Long) {
                return (Long) args[i];
            }
        }
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "userId가 요청에 포함되지 않았습니다.");
    }
}

내가 구현한 코드이다.
위의 AOP를 알아보자! 링크에서 다루지 않은 추가적인 부분들을 여기에 정리하였다.

1. JoinPoint, MethodSignature, Method의 역할과 차이

Spring AOP에서 ProceedingJoinPoint는 가로챈 메서드 호출의 정보를 제공하며, 이를 활용해 메서드의 구체적인 정보를 얻을 수 있다.
여기에서 MethodSignatureMethod는 각각 더 구체적인 정보를 제공하기 위한 도구로 사용된다.

1.1 JoinPoint

  • AOP에서 가로챈 특정 지점(주로 메서드 호출)의 일반적인 정보를 제공
  • 주요 메서드:
    • getArgs():
      • 메서드의 인자(파라미터) 배열을 반환한다.
      • 예: [workspaceId, userId, 기타 등등.. dto?].
    • getSignature():
      • 호출된 메서드의 서명을 반환한다.
        (이 서명은 메서드 이름, 반환 타입 등 기본적인 정보를 포함)
    • getTarget():
      • 실제 타겟 객체(가로챈 메서드를 소유한 객체)를 반환한다.
    • getThis():
      • 프록시 객체를 반환한다.

1.2 MethodSignature

  • JoinPoint.getSignature()에서 반환된 객체를
    더 구체적으로 다루기 위해 MethodSignature로 캐스팅합니다.

  • 주요 메서드:

    • getMethod():
      • Java Reflection의 Method 객체를 반환한다.
        이를 통해 어노테이션, 반환 타입, 매개변수 타입 등을 확인할 수 있다.
    • getParameterNames():
      • 메서드 매개변수의 이름을 배열로 반환한다. 예: ["workspaceId", "userId"].
    • getReturnType():
      • 메서드의 반환 타입을 반환한다. 예: String.class, void.class.

1.3 Method

  • Java Reflection의 핵심 클래스로, 실제 메서드 객체를 나타낸다.
  • MethodSignature.getMethod()를 통해 반환된다.
  • 주요 메서드:
    • getName():
      • 메서드 이름을 반환합니다. 예: "updateWorkspace".
    • getParameters():
      • Parameter 배열을 반환하여 매개변수의 이름과 타입 정보를 제공한다.
    • getAnnotation(Class):
      • 특정 애노테이션이 붙어 있는지 확인하거나 애노테이션 객체를 반환한다.

2. JoinPoint, MethodSignature, Method를 분리하는가?

2.1 JoinPoint의 한계

  • JoinPoint는 가로챈 호출의 기본 정보만 제공하므로,
    메서드에 대한 구체적인 정보(매개변수 이름, 애노테이션 등)를 얻는 데 한계가 있다.
  • 예를 들어, 메서드 이름만 필요하다면 JoinPointgetSignature()로 충분하지만,
    어노테이션 정보를 확인하거나 매개변수 이름을 확인하려면 Method가 필요하다.

2.2 MethodSignature의 역할

  • JoinPoint.getSignature()MethodSignature로 캐스팅하면,
    메서드와 관련된 구체적인 정보를 얻을 수 있다..
  • 예를 들어, 매개변수 이름이나 메서드 반환 타입이 필요한 경우 유용하다.
  • 결정적으로는 Method 객체를 반환할 수 있다.

2.3 Method가 필요한 이유

  • 실제 메서드 객체(java.lang.reflect.Method)는 애노테이션 정보를 확인하거나, Reflection API를 사용해 메서드를 분석할 때 필요하다.

  • 예: RoleCheck 애노테이션 객체를 가져오려면 Method.getAnnotation(RoleCheck.class)를 호출해야 한다.

  • 내 코드에서는 메서드 파라미터들을 타입, 이름과 함께 가져오기 위해 사용되었다.


3. workspaceIduserId를 추출하는 방식

현재 extractWorkspaceIdextractUserId 메서드는 메서드의 매개변수 이름을 확인하고, 그 이름에 특정 문자열(예: "workspaceid")이 포함되었는지 확인하는 방식으로 동작한다.

현재 방식의 장점

  • 간단하고, Reflection을 활용해 동적으로 매개변수를 찾을 수 있음.
  • 특정 이름 규칙을 따르면 직관적으로 이해하기 쉬움.

현재 방식의 단점

  • 매개변수 이름 의존성:
    • 코드에서 매개변수 이름이 바뀌면 AOP 로직이 동작하지 않음.
    • 예: "workspaceId" → "id"로 이름이 변경되면 추출 실패.
  • 모든 매개변수를 순회:
    • 매개변수가 많아지면 비효율적일 수 있음.
  • 타입 검증 없음:
    • Long 타입 외의 값이 들어오면 동작이 불안정해질 수 있음.

4. 더 나은 구현 방법

더 나은 방법이 있을지 찾아보았다.

4.1 애노테이션을 활용한 매개변수 표시

매개변수에 특정 어노테이션을 추가하여, 이를 통해 값을 추출하는 방법이 있다고한다

예: 타겟이 파라미터인 @Param 애노테이션 생성
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface Param {
    String value();
}
서비스 메서드의 파라미터에 해당 어노테이션 적용
public void updateWorkspace(@Param("workspaceId") Long workspaceId, @Param("userId") Long userId) {
    // 메서드 로직
}
AOP에서 값 추출
private Object extractAnnotatedParameter(Object[] args, Method method, String paramName) {
    Parameter[] parameters = method.getParameters();
    for (int i = 0; i < parameters.length; i++) {
        Param paramAnnotation = parameters[i].getAnnotation(Param.class);
        if (paramAnnotation != null && paramAnnotation.value().equals(paramName)) {
            return args[i];
        }
    }
    throw new ResponseStatusException(HttpStatus.BAD_REQUEST, paramName + "가 요청에 포함되지 않았습니다.");
}
  • 장점:
    • 매개변수 이름 변경에 의존하지 않음.
    • 더 명시적이고 안전한 방식.
  • 단점:
    • 애노테이션 추가로 코드가 조금 더 복잡해짐.

이런 방식이 있지만 굳이 복잡하게 만들고싶지 않아 원래 구현한 방식대로 유지했다.

profile
개발일기

0개의 댓글

관련 채용 정보