현재 내가 진행하고 있는 프로젝트를 진행하면서 전체 서비스를 이용하기 위해서 사용자의 권한을 체크하고 Write를 제한을 해야되며 해당 회원이 로그인을 하였는지 판단하고 로그인을 하였다면 사용자의 정보를 얻어오기 위하여 Security Context Holder에 접근하여 회원의 아이디를 가져오기 때문에 반복적인 코드가 필요하다.
이번 게시글에는 2가지 리펙토링을 정리를 하려고 한다. (1) 특정 게시글에 Write작업을 권한을 체크를 합니다. AOP를 사용하여 권한을 체크하는 횡단 관심사를 분리하는 작업을 수행을 합니다. (2) ArgumentResolver를 통하여 해당 회원의 로그인 상태를 판단하며 만약에 해당 회원이 로그인을 하였다면 Security Context Holder에서 정보를 LoginUserDto에 저장하며 협업에 더욱 편리하게 리펙토링을 하였습니다.
@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);
}
if (!request.getMember().getId().equals(loginUserDto.getMemberId())) {
throw new NotMatchRequestAuth(loginUserDto.getMemberId());
}
@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);
}
Java에서 컴파일을 하였을 때 AOP를 먼저 실행을 하는 방식이다.
A.java —-(AOP) —→ A.class(AspectJ)
A.java → A.class —-(AOP)—→ 메모리(AspectJ)
프록시패턴과는 다르게 바이트코드 조작방식은 타깃 오브젝트를 뜯어고쳐서 부가기능을 직접 넣어주는 직접적인 방법을 택한다.
AspectJ 는 프록시와는 다르게 좀 더 직접적인 방법으로 부가기능을 제공하는데, 컴파일된 Target의 클래스 파일 자체를 수정하거나 클래스가 JVM에 로딩되는 시점을 가로채 바이트코드를 조작하는 방법을 사용한다. 그렇기 때문에 .java파일과 .class 파일을 비교해보면 내용이 달라진걸 확인할 수 있다.
해당 방법(바이트코드 조작)을 사용하는 이유는 두가지가 있다.
스프링과 같은 DI컨테이너의 도움을 받지 않아도 AOP를 적용할 수 있기 때문이다. 그렇기에 스프링과같은 컨테이너가 사용되지 않는 환경에서도 손쉽게 AOP의 적용이 가능해진다.
프록시 방식보다 강력하고 유연한 AOP가 가능하다.
프록시를 AOP의 핵심 메커니즘으로 사용할 경우 부가기능(공통 모듈)을 부여할 대상은 클라이언트가 호출할 때 사용하는 메소드로 제한된다.
하지만, 바이트코드 조작 방식을 사용하면, 오브젝트의 생성, 필드 값 조회및 조작, 스태틱 초기화 등 다양한 작업에 부가기능을 부여할 수 있다.
이처럼 프록시를 사용한 AOP에서는 불가능한 부분에서까지 부가기능 부여가 가능하기 때문에 강력하고 유연하다.
공통 모듈을 프록시로 만들어서 DI 로 연결된 빈 사이에 적용해 Target의 메소드 호출 과정에 참여애 부가기능(공통 모듈)을 제공해준다.
그렇기의 JDK 와 Spring Container 외에 특별한 기술 및환경을 요구하지 않는다.
Advice 가 구현하는 MethodInterceptor 인터페이스는 다이내믹 프록시의 InvocationHandler와 마찬가지로 프록시부터 메소드 요청정보를 전달받아 타깃 오브젝트의 메소드를 호출하는데, 이렇게 메소드를 호출하는 전/후로 부가기능(공통 모듈)을 제공할 수 있다.
이런식으로 독립적으로 개발한 부가기능 모듈을 다양한 타깃 오브젝트의 메소드에 다이내믹하게 적용해주기 위해 가장 중요한 역할을 맡고 있는게 프록시고, 스프링 AOP는 프록시 방식의 AOP라 할 수 있다.
Aspect
Target
Advice
Joint point
Point cut
Proxy
Controller와 Service 간에 프록시 객체인 Proxy가 생성을 합니다. Controller는 이제 실제 객체를 바로 접근하지 않고 프록시 객체를 거쳐 프록시 객체에서 실제 객체에 접근하여 로직을 수행을합니다.
모든 객체들에게 프록시 객체가 생성되었고 의존관계도 프록시 객체를 통해서 이루어진다.
각각의 객체들의 기능을 수행하려 메서드를 호출하면 요청을 프록시 객체가 전달받아 전/후처리 등 추가적인 작업을 수행하면서 실제 객체의 로직을 수행을 합니다.
AOP 주의사항
프로젝트를 진행하면서 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);
}
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