본문 바로가기

SPRING

[Spring] HandlerMethodArgumentResolver 사용하여 Custom Annotation 구현 및 테스트하기

HandlerMethodArgumentResolver 를 이용하여 Custom Annotion 을 만들어 User 정보를 쉽게 가져오기

회원을 관리하는 API 를 만들게 되면 꼭 필요로 하게 되는 것이 HandlerInterceptorAdapter 를 이용하여 HttpServletRequest 에 정보를 저장하여 사용자 정보 필요로 하는 Controller 에서 꺼내여 사용하는 방식입니다.

만약, HttpServletRequest 을 사용하지 않고 static 변수나 공유 가능한 변수 형태로 사용하게 된다면 Thread safe 하지 않기 때문에 Multi-thread 환경에서는 다른 사용자의 정보를 노출하게 되는 위험한 상황을 만들 수 있습니다.

하지만, 매번 API 에서 HttpServletRequest 를 parameter 로 받아 get, set 하는 방식은 많은 duplicate 를 발생시킵니다.

이러한 문제를 해결하기 위한 방법이 HandlerMethodArgumentResolver 를 이용한 방법입니다.

 

 

 

HandlerMethodArgumentResolver 란?

HandlerMethodArgumentResolver 는 스프링 3.1 에서 추가된 Interface 입니다.

스프링 3. 이전에는 WebArgumentResolver 라는 Inteface 였습니다.

Spring 공식 문서에는 다음과 같이 설명되어 있습니다.

Strategy interface for resolving method parameters into argument values in the context of a given request.

주어진 요청을 처리할 때, 메서드 파라미터를 인자값들에 주입 해주는 전략 Interface.

이처럼 API 통신을 위한 Controller 에 들어오는 파라미터를 가공하거나 설정을 해주기 위해서 사용합니다.

 

ArgumentResolver 의 동작 순서는 다음과 같습니다.

  1. Client Request
  2. Dispactcher Servlet 에서 해당 요청을 처리
  3. Client Request 에 대한 Handler Mapping
    1. RequestMapping 에 대한 매칭
    2. Interceptor 처리
    3. Argument Resolver 처리
    4. Message Converter 처리
  4. Controller Method Invoke

 

위에 설명된 순서 중 Interceptor 에서 사용자 정보를 저장한 후에 Argument Resolver 에서 가공한 뒤 Contorller 의 parameter 형식의 Annotation 으로 넘겨주는 방식입니다.

 

 

 

HandlerInterceptorAdapter 를 이용하여 사용자 정보를 저장

public class JwtAuthInterceptor extends HandlerInterceptorAdapter {
        private UserRepository userRepository;
        private TokenAuthenticationService tokenAuthenticationService;

        public static final String AUTH_USER_KEY = "user";

        public JwtAuthInterceptor(UserRepository userRepository, TokenAuthenticationService tokenAuthenticationService) {
            this.userRepository = userRepository;
            this.tokenAuthenticationService = tokenAuthenticationService;
        }

        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
            String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);

            if (authorization == null) {
                throw new InvalidAccessTokenException("Header 에 token 이 존재하지 않습니다.");
            }

            if (!tokenAuthenticationService.isVerifyToken(authorization)) {
                throw new InvalidAccessTokenException("Not invalid Token!");
            }

            String socialToken = tokenAuthenticationService.getSocialTokenByJwt(jwtWithoutType);

            User user = userRepository.findBySocialTokenId(socialToken);
            request.setAttribute(AUTH_USER_KEY, user);

            return user != null;
        }
    }
    

 

TokenAuthenticationService 는 JWT 를 관리하기 위해 별도로 구현한 Service 입니다.

위의 예시를 보면 HandlerInterceptorAdapter 를 이용하여 JWT 를 검사하기 위한 preHandle 를 Override 하여 구현한 부분입니다.

우선 HttpServletRequest 존재하는 Header 에 AUTHORIZATION 값을 가져와 유효성 체크를 진행합니다.

그 후 JWT 에서 oAuth 에서 사용하는 socialToken 을 가져와 HttpServletRequest 에 저장합니다.

 

 

 

테스트 검증

@SpringBootTest
    public class JwtAuthInterceptorTest {
        private JwtAuthInterceptor jwtAuthInterceptor;
        private TokenAuthenticationService tokenAuthenticationService;

        @MockBean
        private UserRepository userRepository;

        @BeforeEach
        void setUp() {
            this.tokenAuthenticationService = new TokenAuthenticationService();
            this.jwtAuthInterceptor = new JwtAuthInterceptor(userRepository, tokenAuthenticationService);
        }

        @Disabled
        @DisplayName("사용자 로그인 시 토큰 검증을 진행하는지")
        @Test
        public void preHandle(SoftAssertions softly) {
            //given
            when(userRepository.findBySocialTokenId(TEST_USER_EMAIL)).thenReturn(TEST_USER);
            MockHttpServletRequest request = jwtAuthHttpRequest(TEST_USER_EMAIL);

            //when
            boolean isAuthorization = jwtAuthInterceptor.preHandle(request, null, null);

            //then
            softly.assertThat(isAuthorization).isTrue();
            softly.assertThat(request.getAttribute(AUTH_USER_KEY).getClass()).isEqualTo(User.class);
        }

        private MockHttpServletRequest jwtAuthHttpRequest(String email) {
            String jwt = tokenAuthenticationService.toJwtBySocialTokenId(email);

            MockHttpServletRequest request = new MockHttpServletRequest();
            request.addHeader(HttpHeaders.AUTHORIZATION, jwt);
            return request;
        }
    }
    

 

MockHttpServletRequest 를 이용하여 테스트를 위한 가짜의 HttpServletRequest 객체를 만들어 요청한 사용자 정보가 HttpServletRequest 에 저장되어 있는지 검증합니다.

HttpServletRequest 에 사용자 정보가 저장되는 것을 확인할 수 있습니다.

 

MockHttpServletRequest : https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/mock/web/MockHttpServletRequest.html

 

 

 

LoginUser Annotion 만들기

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

 

사용자 정보를 저장하여 Controller 에 paramter 형식으로 사용할 수 있도록 Custom Annotion 을 만듭니다.

Target 과 Retention Annotation 관련하여 다음 글을 참고 바랍니다.

https://sanghye.tistory.com/39

 

 

 

HandlerMethodArgumentResolver 의 구현부

public class UserHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
        @Override
        public boolean supportsParameter(MethodParameter parameter) {
            return parameter.hasParameterAnnotation(LoginUser.class);
        }

        @Override
        public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                      NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
            HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
            User user = (User) request.getAttribute(AUTH_USER_KEY);

            LoginUser loginUser = parameter.getParameterAnnotation(LoginUser.class);

            if (loginUser == null || user == null) {
                throw new IllegalArgumentException("사용자 정보가 존재하지 않습니다.");
            }

            return user;
        }
    }
    

 

위의 UserHandlerMethodArgumentResolverHandlerMethodArgumentResolver 의 Interface 를 구현한 구현체입니다.

HandlerMethodArgumentResolver Interface 는 Controller 에서 Parameter 를 Binding 해주는 역할을 합니다.

supportsParameter method 는 MethodParameter type 을 받아 LoginUser.class 의 Parameter 가 존재하면 true 를 리턴해줍니다.

따라서, Controller 에 LoginUser.class type 의 Parameter 가 존재한다면 true 를 리턴하여 resolveAragumet 를 실행시키는 구조입니다.

resolveArgument 는 실제 LoginUser 의 파라미터를 받아 실행시킬 method 입니다.

NativeRequest class 를 이용하여 HandlerInterceptorAdapter 에서 저장한 User 정보를 가져옵니다.

MethodParameter 에 저장된 LoginUser.class 형태의 값이 존재하는지 확인한 후 존재하지 않으면 예외처리를 진행합니다.

그 후, 해당 User 의 값을 리턴하면 해당 Controller 에서 사용할 수 있게됩니다.

 

 

 

Controller 에서 LoginUser Parameter 사용하는 예시

@LoginUser 를 사용하지 않은 경우

@RestController
    @RequestMapping("/boards")
    public class BoardController {
         private BoardService boardService;

         public BoardController(BoardService boardService) {
             this.boardService = boardService;
         }

         @PostMapping
         public ResponseEntity create(@RequestBody CreateBoardRequestView board, HttpServletRequest request) { 
             User user = (User) request.getAttribute(AUTH_USER_KEY);
             CreateBoardResponseView savedBoard = boardService.create(board, user);
             return ResponseEntity.ok().body(savedBoard);
         }
    }
    

@LoginUser 를 사용한 경우

@RestController
    @RequestMapping("/boards")
    public class BoardController {
         private BoardService boardService;

         public BoardController(BoardService boardService) {
         this.boardService = boardService;
     }

         @PostMapping
         public ResponseEntity create(@RequestBody CreateBoardRequestView board, @LoginUser User user) {
             CreateBoardResponseView savedBoard = boardService.create(board, user);
             return ResponseEntity.ok().body(savedBoard);
         }
    }
    

 

실제 Controller 의 사용 예시를 보면, 간단한 게시글을 생성하는 API 입니다.

게시글을 생성할 때 사용자 정보를 검증하거나, 함께 저장하기 위해 필요한 경우가 대부분입니다.

만약 @LoginUser 를 사용하지 않는다면 User user = (User) request.getAttribute(AUTH_USER_KEY); 와 같은 HttpServletRequest 에서 사용자 정보를 가져오는 로직이 매번 중복되어 들어갔을 것입니다.

또한, 어떤 정보를 가져오는지는 request 에서 get 을 하기 전까지 알 수 없기 때문에 명시적이지 않습니다.

하지만, @LoginUser 를 사용하면 바로 User 라는 Domain 에 저장된 정보를 사용할 수 있으며, 중복도 제거되고, Custom 된 명시적인 Parameter 를 사용하기 때문에 한눈에 알 수 있습니다.

 

 

 

테스트 검증

@ExtendWith(MockitoExtension.class)
    public class LoginUserHandlerMethodArgumentResolverTest {
        @Mock
        private MethodParameter parameter;

        @Mock
        private NativeWebRequest request;

        @Mock
        private LoginUser annotedLoginUser;

        private UserHandlerMethodArgumentResolver userHandlerMethodArgumentResolver;

        @BeforeEach
        public void setup() {
            userHandlerMethodArgumentResolver = new UserHandlerMethodArgumentResolver();
        }

        @Test
        public void resolveArgument_가_사용정보를_가져오는지() {      
        	//given
	        when(request.getAttribute(getAttribute(AUTH_USER_KEY)).thenReturn(TEST_USER);
            
                //when
                User loginUser = (User) userHandlerMethodArgumentResolver.resolveArgument(parameter, null, request, null);

		//then
    	        assertThat(loginUser).isEqualTo(TEST_USER);
        }

	@Test
        public void resolveArgument_에_사용자가_존재하지않는경우() {        
        	//given
                when(parameter.getParameterAnnotation(LoginUser.class)).thenReturn(annotedLoginUser);
                when(request.getAttribute(AUTH_USER_KEY)).thenReturn(null);

		//then
		assertThrows(IllegalArgumentException.class, () -> {
        	        userHandlerMethodArgumentResolver.resolveArgument(parameter, null, request, null);
		});
        }
    }

    

 

첫번 째 테스트는 사용자 정보가 정상적으로 입력받은 경우의 테스트입니다.

NativeWebRequest 가 User 정보를 정상적으로 저장하고 있는 경우에는 resolveArgument 에서 사용자 정보를 가져오는 것을 볼 수 있습니다.

두번 째 테스트는 사용자 정보가 존재하지 않는 경우 예외 테스트입니다.

NativeWebRequest 가 User 정보를 null 로 리턴한 경우 resloveArgument 를 실행했을 경우 UnAuthenticationException Exception 을 던지기 때문에 테스트가 성공하게 됩니다.

 

 

 

 

 

결론

기존에는 HttpServeltRequest 를 이용하여 반복적으로 로직이 추가되어 사용자 정보를 가져왔었습니다.

하지만, 사용자의 정보를 저장하고, 가져오는 로직이 Controller 의 역할이 맞는지 고민을 해보고 또한, 중복 로직으로 인한 기능이 변경되었을 때의 문제를 생각하면 bad smell 을 느낄 수 있습니다.

TDD 를 적용한다면, 사용자 정보를 가져오는 부분을 매번 Test 할 때마다 분리의 필요성을 느끼게 됩니다 .

만약 사용자 정보를 가져오는 로직이 분리되어 있을 때 만약 사용자 정보를 가져오는 로직을 전부 Test code 를 작성하지 않는다면 기능이 온전히 동작한다는 보장이 존재하지 않습니다.

하지만, 두 번째 테스트 검증 예시인 LoginUserHandlerMethodArgumentResolverTest 만 검증이 완벽히 된다면 나머지의 사용자 정보를 가져오는 로직은 검증할 필요가 없게 됩니다.

즉, 사용자 정보를 가져오고 저장하는 로직의 책임과 역할이 명확해지는 것입니다.

이처럼 Spring 에서 제공되는 유용한 기능을 사용하여 Bad smell 을 제거해 나가고 SRP 를 지키며 Test Coverage 를 높여 마음의 안식(?) 의 편안을 유지할 수 있습니다.