티스토리 뷰

AOP를 적용하여 부가 로직 제거하기                                                                                (feat. MethodHandlerArgumentResolver)

Overview

현재 내가 진행하고 있는 sns 서비스 개발 프로젝트는 '전체 서비스를 이용하기 위해서는 회원가입을 해야한다.' 는 비즈

니스 룰이 정해져있다. 그렇기 때문에 대부분의 기능이 로그인된 상태로 진행되어야 했다. 그래서 각 기능을 실행하기 전

에 다음과 같이 로그인 여부를 확인하는 로직이 꼭 포함되어야 했다.

@PutMapping("/my-account")
public ResponseEntity<Void> updateUser(UserUpdateParam userUpdateParam,
                                       @RequestPart("profileImage") MultipartFile profileImage,
                                       HttpSession httpSession) {

    String currentUserId = (String) httpSession.getAttribute("userId");
    // 로그인 여부를 확인 - 부가 로직
    if (currentUserId == null) {
        return RESPONSE_UNAUTHORIZED;
    } else {
        try {
            // 회원 정보 업데이트 - 핵심 로직
            userService.updateUser(currentUser, userUpdateParam, profileImage);

            return RESPONSE_OK;
        } catch (FileUploadException e) {
            return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }
}

해당 코드는 대부분의 기능에 중복되어 작성되어 있기 때문에 유지보수성 측면에서 매우 좋지 않았다. 만약 해당 로직이

변경된다면 모든 기능마다 찾아가서 코드를 수정해줘야 하기 때문이다. 그리고 위의 updateUser 기능을 예로 들자면 여기

서는 사용자의 정보를 수정하는 것이 주요 기능인데 로그인 여부까지 확인해야하는 2개의 역할을 부담하고 있다. 위의 코

드는 단순해서 어떤 코드가 주요 기능을 담당하고 있는지 빠르게 파악할 수 있지만 코드가 조금만 더 복잡해진다면 이는

굉장히 어려워질 것이다. 따라서, 로그인 체크 로직을 분리하여 중복 코드를 제거하기 위해 다음과 같은 과정을 거쳤다.

 

Refactoring-1

이러한 문제를 해결하기 위해 Spring에서는 AOP라는 기능을 제공한다. AOP를 적용하면 전체 소스 코드에 퍼져 있는 반

복되고 있는 코드를 한 곳에 모아 별도의 기능으로 분리시켜 주고 이를 사용자가 원하는 지점에서 실행할 수 있도록 해준

다. 이 기능과 커스텀 어노테이션을 활용하여 해당 어노테이션이 명시된 메소드를 실행하기 전 자동으로 로그인 체크를

확인하도록 만들 것이다.

 

1. AOP를 사용하기 위한 준비 작업

먼저 AOP를 사용하기 위해서는 spring-boot-starter-aop 의존성을 추가해주어야 한다.

pom.xml

<!--
spring-boot-starter-aop
: Spring AOP와 AspectJ를 활용한 관점 지향 프로그래밍 스타터
-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

 

그 다음 애플리케이션의 최상위 클래스에 @EnableAspectAutoProxy 붙여 AOP가 적용된 클래스를 인지할 수 있도록 해야한다.

@EnableAspectJAutoProxy
@SpringBootApplication
public class SnsServerApplication {
    public static void main(String[] args) {
        ...
    }
}

 

2. AOP를 적용할 로직 작성

위에서 설명한 것처럼 특정 어노테이션이 붙은 메소드에서만 로그인 체크를 할 것이기 때문에 이를 위한 어노테이션을 작성한다.

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckLogin {
}

 

그리고 이 어노테이션을 기준으로 하여 실행시킬 로그인 체크 로직을 작성한다.

@Component
@Aspect
@RequiredArgsConstructor
public class CheckLoginAspect {

    private final LoginService loginService;

    // CheckLogin 어노테이션이 붙은 메소드가 실행하기 전 해당 로직을 실행
    @Before("@annotation(me.liiot.snsserver.annotation.CheckLogin)")
    public void checkLogin() throws HttpClientErrorException {

        // 세션에 저장된 사용자의 ID를 가져온다.
        String currentUserId = loginService.getCurrentUserId();

        // 세션에 사용자의 ID가 없는 경우, 권한이 없다는 에러 발생
        if (currentUserId == null) {
            throw new HttpClientErrorException(HttpStatus.UNAUTHORIZED);
        }
    }
}

 

After Refactoring-1

AOP를 적용한 후 updateUser에 섞여 있던 로그인 여부 확인에 관한 코드들이 모두 사라졌다. 이전보다 핵심 코드를 파악

하기 훨씬 수월해졌고 코드의 양도 줄었다.

@PutMapping("/my-account")
@CheckLogin
public ResponseEntity<Void> updateUser(UserUpdateParam userUpdateParam,
                                       @RequestPart("profileImage") MultipartFile profileImage,
                                       HttpSession httpSession) {

    // 세션에 저장된 사용자 정보 가져오기 - 부가 로직
    String currentUserId = (String) httpSession.getAttribute("userId");

    try {
        userService.updateUser(currentUser, userUpdateParam, profileImage);

        return RESPONSE_OK;
    } catch (FileUploadException e) {
        return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

하지만 반복되는 구간이 한 곳 더 남아있다. 바로 세션에 저장된 사용자 아이디를 가져오는 부분이다. 언뜻보면 코드가 한

줄이라 리팩토링이 불필요할 것 같다. 하지만 이것도 각 메서드에서 바라보는 핵심 기능에 해당되지 않고 만약 세션이 아

닌 다른 곳에서 사용자 아이디를 가져오거나 세션에 저장되는 키 값이 달라지는 등 다양한 이유로 로직이 변경된다면 이

를 사용하는 모든 메서드를 찾아 일일이 수정해야하기 때문에 리팩토링의 대상이 된다.

 

Refactoring-2

위의 문제를 해결하기 위해 Controller Layer에서 @PathVariable이나 @RequestParam이 인자를 주입하는 것과 동일하게

동작하는 커스텀 어노테이션을 만들 것이다. 즉, Controller 메소드 파라미터에 어노테이션이 붙은 사용자 객체가 있는 경

우 세션에 저장된 사용자 객체를 리턴하는 것이다.

 

그러기 위해선 먼저 @PathVariable이나 @RequestParam이 어떻게 동작하는지 살펴봐야 한다. Spring은 Controller

Layer에서 특정 조건에 맞는 파라미터가 있을 때 원하는 값을 바인딩할 수 있는 HandlerMethodArgumentResolver 인터페

이스를 제공한다. 이 인터페이스에서는 다음의 두 가지 메서드를 구현해야 한다.

HandlerMethodArgumentResolver.class

public interface HandlerMethodArgumentResolver {
    boolean supportsParameter(MethodParameter var1);

    @Nullable
    Object resolveArgument(MethodParameter var1, @Nullable ModelAndViewContainer var2, NativeWebRequest var3, @Nullable WebDataBinderFactory var4) throws Exception;
}
  • supportsParameter(MethodParameter var1)

    : 주어진 메서드 파라미터가 해당 resolver에서 수행할 수 있는지 확인

  • resolveArgument(...)

    : supportsParameter() 가 true인 파라미터에 대해서 어떤 객체를 Client Request의 아규먼트에 바인딩하여 돌려줄 것인지에 대한 로직 수행

 

1. 어노테이션 작성

나는 커스텀 어노테이션을 기준으로 객체 바인딩을 진행할 것이기 때문에 HandlerMethodArgumentResolver 를 구현하기

전에 어노테이션을 작성한다.

@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {
}

 

2. HandlerMethodArgumentResolver 작성

이를 바탕으로 커스텀 HandlerMethodArgumentResolver 를 작성한다.

@Component
@RequiredArgsConstructor
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final LoginService loginService;

    private final UserMapper userMapper;

    @Override
    public boolean supportsParameter(MethodParameter methodParameter) {
        // @CurrentUser 어노테이션이 붙은 메서드에 대해서만 resolver 로직 수행
        return methodParameter.hasParameterAnnotation(CurrentUser.class);
    }

    @Override
    public Object resolveArgument(MethodParameter methodParameter,
                                  ModelAndViewContainer modelAndViewContainer,
                                  NativeWebRequest nativeWebRequest,
                                  WebDataBinderFactory webDataBinderFactory) throws Exception {

        try {
            // 세션에 저장된 사용자의 ID를 가져온다.
            String currentUserId = loginService.getCurrentUserId();

            // 사용자 객체를 가져와 리턴
            return userMapper.getUser(currentUserId);
        } catch (IllegalArgumentException e) {
            throw new HttpClientErrorException(HttpStatus.UNAUTHORIZED);
        }
    }
}

 

3. 커스텀 HandlerMethodArgumentResolver 등록

Java Config 파일에 작성한 ArgumentResolver를 스프링에 등록해준다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new CurrentUserArgumentResolver());
    }
}

 

After Refactoring-2

Resolver가 등록되면 Controller에서 @CurrentUser 어노테이션이 붙은 파라미터에는 CurrentUserArgumentResolver

의 로직을 수행한 데이터가 바인딩 된다.

@PutMapping("/my-account")
@CheckLogin
public ResponseEntity<String> updateUser(UserUpdateParam userUpdateParam,
                                         @RequestPart("profileImage") MultipartFile profileImage,
                                         @CurrentUser User currentUser) {

    try {
        userService.updateUser(currentUser, userUpdateParam, profileImage);

        return RESPONSE_OK;
    } catch (FileUploadException e) {
        return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

이를 통해 현재 세션에 저장된 값을 가져온다는 의미는 명확하게 하면서 코드는 더 깔끔하게 유지할 수 있었다.

 

 


진행 프로젝트

github.com/f-lab-edu/sns-itda

 

f-lab-edu/sns-itda

Contribute to f-lab-edu/sns-itda development by creating an account on GitHub.

github.com

 

댓글