trello 칸반보드 프로젝트를 진행하던 중에 문제가 발생했다.
우리 팀이 짠 테이블 구조는 이렇다.
USER - MEMBER - WORKSPACE 의 관계를 주목하자
USER 의 권한은 이 유저가 일반 유저인가? 아니면 ADMIN 유저인가를 구별할 수 있게 해준다.
이 때 ADMIN 유저로 가입한 사람만 WORKSPACE 생성이 가능하다.
즉 여기서의 권한은 워크스페이스를 생성할 수 있는가 없는가 를 구분해주는 역할이다.
이번에는 MEMBER 의 역할에 대해 알아보자.
MEMBER는 특정 WORKSPACE 에 속하게 되었을 때 부여되는 것인데
일반 USER, MEMBER, OWNER 로 나뉜다.
처음 워크스페이스를 생성한 사람은 OWNER 의 역할을 갖고
그 뒤로 다른 유저에게 초대를 보내어 그 유저가 초대를 받으면 USER 의 역할
OWNER 가 그 뒤로 역할을 변경해주면 MEMBER 의 역할을 가질 수 있다.
쉽게 말하자면 USER 는 워크스페이스 읽기 전용 이라고 생각하면 되고
MEMBER는 워크스페이스 내부에서 OWNER 와 같은 권한을 가지게 된다고 생각하면 된다.
이러한 상황에서 나는 워크스페이스를 생성할 때의 권한 체크는 인터셉터로 구현했다.
로그인할 때 세션 값에 해당 유저의 id 와 권한값을 넣어주고
인터셉터에서 권한을 확인하여 워크스페이스를 생성할 수 있는지 없는지 인가를 진행한다.
하지만 MEMBER의 역할은 처음부터 존재하는 것도 아니고
특정 워크스페이스에 합류하는 시점에 생성되는 것
하나의 유저가 여러 워크스페이스에 속해있을 수 있기에 권한이 여러 개 나올 수 있음
이러한 문제들 때문에 로그인하는 과정에서 MEMBER 의 역할을 넣어주는 것은
좋지 않다고 판단했고, 따라서 처음에는 서비스 단에서 역할 체크를 진행했다.
하지만 역할 체크가 필요한 모든 메서드에서 반복적인 코드가 발생했고
반복되는 부분을 private 메서드로 분리하는 대신 해결할 수 있는 방법이 없을까 생각했다.
왜냐하면 역할 체크는 workspace 작업 뿐만 아니라 다른 곳에서도 필요했기 때문이다.
따라서 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를 알아보자! 링크에서 다루지 않은 추가적인 부분들을 여기에 정리하였다.
JoinPoint
, MethodSignature
, Method
의 역할과 차이Spring AOP에서 ProceedingJoinPoint
는 가로챈 메서드 호출의 정보를 제공하며, 이를 활용해 메서드의 구체적인 정보를 얻을 수 있다.
여기에서 MethodSignature
와 Method
는 각각 더 구체적인 정보를 제공하기 위한 도구로 사용된다.
JoinPoint
getArgs()
:[workspaceId, userId, 기타 등등.. dto?]
.getSignature()
:getTarget()
:getThis()
:MethodSignature
JoinPoint.getSignature()
에서 반환된 객체를
더 구체적으로 다루기 위해 MethodSignature
로 캐스팅합니다.
주요 메서드:
getMethod()
:Method
객체를 반환한다.getParameterNames()
:["workspaceId", "userId"]
.getReturnType()
:String.class
, void.class
.Method
MethodSignature.getMethod()
를 통해 반환된다.getName()
:"updateWorkspace"
.getParameters()
:Parameter
배열을 반환하여 매개변수의 이름과 타입 정보를 제공한다.getAnnotation(Class)
:JoinPoint
, MethodSignature
, Method
를 분리하는가?JoinPoint
의 한계JoinPoint
는 가로챈 호출의 기본 정보만 제공하므로,JoinPoint
의 getSignature()
로 충분하지만,Method
가 필요하다.MethodSignature
의 역할JoinPoint.getSignature()
를 MethodSignature
로 캐스팅하면,Method
가 필요한 이유실제 메서드 객체(java.lang.reflect.Method
)는 애노테이션 정보를 확인하거나, Reflection API를 사용해 메서드를 분석할 때 필요하다.
예: RoleCheck
애노테이션 객체를 가져오려면 Method.getAnnotation(RoleCheck.class)
를 호출해야 한다.
내 코드에서는 메서드 파라미터들을 타입, 이름과 함께 가져오기 위해 사용되었다.
workspaceId
와 userId
를 추출하는 방식현재 extractWorkspaceId
와 extractUserId
메서드는 메서드의 매개변수 이름을 확인하고, 그 이름에 특정 문자열(예: "workspaceid")이 포함되었는지 확인하는 방식으로 동작한다.
Long
타입 외의 값이 들어오면 동작이 불안정해질 수 있음.더 나은 방법이 있을지 찾아보았다.
매개변수에 특정 어노테이션을 추가하여, 이를 통해 값을 추출하는 방법이 있다고한다
@Param
애노테이션 생성@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface Param {
String value();
}
public void updateWorkspace(@Param("workspaceId") Long workspaceId, @Param("userId") Long userId) {
// 메서드 로직
}
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 + "가 요청에 포함되지 않았습니다.");
}
이런 방식이 있지만 굳이 복잡하게 만들고싶지 않아 원래 구현한 방식대로 유지했다.