Spring

[Spring] HandlerMethodArgumentResolver에 대해 알아보자

feelcoding 2023. 3. 6. 00:50
728x90

안녕하세요
오늘은 Spring에서 제공하는 정말 편리한 기능인 HandlerMethodArgumentResolver에 대해 알아보겠습니다.
HandlerMethodArgumentResolver는 무엇이고 어떤 경우에 사용하는 것이 좋을까요?
예제를 보며 함께 알아보는 시간을 가져봅시다.

❓ 대부분의 API에 필요한 공통적인 로직이 있다면 어떻게 처리하는 것이 좋을까?

공통적인 로직이 있다면 스프링에서는 interceptor, filter, AOP 등 다양한 방법으로 처리할 수 있습니다.
하지만 공통적인 로직을 처리하여 어떤 결과값을 컨트롤러에 넘겨줘야 한다면 어떻게 하는 것이 좋을까요?
 
예를 들어, 클라이언트 측에서 header에 토큰을 담아서 request를 보내는 경우가 있다고 가정해봅시다.
그리고 아래와 같이 거의 모든 API에서 해당 요청을 보낸 사용자가 누구인지 사용자 정보가 필요합니다.

package com.feelcoding.argumentresolverdemo.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@Slf4j
public class TestController {

    @GetMapping("/test1")
    public void test1(@RequestHeader String token) {
        // 이 API에서는 사용자 정보가 필요합니다!
    }

    @PostMapping("/test2")
    public void test2(@RequestHeader String token) {
        // 이 API에서도 사용자 정보가 필요합니다!
    }

    @GetMapping("/test3")
    public void test3(@RequestHeader String token) {
        // 이 API에서도 사용자 정보가 필요합니다!
    }

    @GetMapping("/test4")
    public void test4(@RequestHeader String token) {
        // 이 API에서도 사용자 정보가 필요합니다!
    }
}

 
이 경우, 사용자의 정보를 알아내는 코드가 여러 곳에서 중복될테니 토큰으로부터 사용자 정보를 알아내 User 객체를 리턴해주는 메소드를 따로 뽑는 것이 나을 것 같습니다.
 

🤨 중복되는 로직을 메소드로 만들어보자

public User getLoginUser(String token) {
    return userRepository.findByEmail(token).orElseThrow();
}

토큰으로부터 사용자 정보를 알아내서 User 객체를 리턴하는 메소드를 이렇게 만들었습니다!
(원래는 토큰을 파싱해서 사용자 정보를 알아내야 하는데 여기에서 토큰에 대한 설명까지 하면 토큰에 대한 글이 될 것 같아 토큰 부분에 이메일을 헤더에 담아 보내는 것으로 했습니다😅)
 

package com.feelcoding.argumentresolverdemo.controller;

import com.feelcoding.argumentresolverdemo.User;
import com.feelcoding.argumentresolverdemo.dto.SignUpRequestDto;
import com.feelcoding.argumentresolverdemo.dto.SignUpResponseDto;
import com.feelcoding.argumentresolverdemo.service.AuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@Slf4j
public class TestController {

    private final AuthService authService;

    @GetMapping("/test1")
    public void test1(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

    @PostMapping("/test2")
    public void test2(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

    @GetMapping("/test3")
    public void test3(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

    @GetMapping("/test4")
    public void test4(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

}

메소드로 따로 뺐기 때문에 그냥 중복되는 코드를 여러 번 작성하는 것보다는 낫겠지만,
사용자 정보가 필요한 컨트롤러마다 이렇게 코드가 반복되어 들어갔습니다.
 

🤔 @LoginUser 어노테이션만 달면 컨트롤러 메소드에 User 객체를 넣어주면 얼마나 좋을까?

헤더에 있는 토큰을 읽어서 User 객체를 반환해주는 이런 공통적인 부분을 어딘가에서 처리해서 컨트롤러의 매개변수로 User 객체를 짠! 하고 넘겨주면 얼마나 좋을까요? 이렇게 말입니다.

@GetMapping("/test1")
public void test1(@LoginUser User user) {
    // 어쩌구 저쩌구
}

@PostMapping("/test2")
public void test2(@LoginUser User user) {
    // 어쩌구 저쩌구
}

@GetMapping("/test3")
public void test3(@LoginUser User user) {
    // 어쩌구 저쩌구
}

@GetMapping("/test4")
public void test4(@LoginUser User user) {
    // 어쩌구 저쩌구
}

 
 
HandlerMethodArgumentResolver를 사용하면 이것이 가능해집니다!
 

❗ HandlerMethodArgumentResolver를 사용하여 사용자 정보를 쉽게 받아오자

위와 같이 @LoginUser와 같은 어노테이션 하나로 컨트롤러에서 쉽게 User 객체를 얻어오려면 세 가지를 해야 합니다.

  1. @LoginUser 어노테이션을 만들어줍니다.
  2. HandlerMethodArgumentResolver를 구현한 클래스를 만들어야 하고
  3. 이렇게 구현한 HandlerMethodArgumentResolver를 등록해주어야 합니다.

하나씩 해볼까요?
 

1️⃣ LoginUser 어노테이션을 만들어줍니다.

 
LoginUser라는 어노테이션 이름은 제가 임의로 정한 것이고 원하는대로 변경하셔서 사용하시면 됩니다.

package com.feelcoding.argumentresolverdemo.util;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

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

어노테이션을 만들 때는 interface 앞에 @를 붙이면 됩니다.
@Target(ElementType.PARAMETER)은 해당 어노테이션이 매개변수에 쓰일 것이기 때문에 PARAMETER로 해주었습니다.
@Retention(RetentionPolicy.RUNTIME)은 해당 어노테이션이 런타임까지도 쓰일 것이기 때문에 RUNTIME으로 해주었습니다.
 

2️⃣ HandlerMethodArgumentResolver를 구현한 클래스를 만들어줍니다.

 
HandlerMethodArumentResolver를 구현한 클래스를 만들면 메소드를 구현하라고 이렇게 에러가 뜹니다.

이제 두 메소드를 구현해봅시다.

✅ supportsParameter

Whether the given method parameter is supported by this resolver.

supportsParameter 메소드는 해당 메소드의 매개변수가 해당 resolver가 지원하는지를 체크하는 것입니다.
true를 반환하면 지원한다는 것이고 false를 반환하면 지원하지 않는다는 것입니다.

@Override
public boolean supportsParameter(MethodParameter parameter) {
    boolean hasLoginUserAnnotation = parameter.hasParameterAnnotation(LoginUser.class);
    boolean isUserType = User.class.isAssignableFrom(parameter.getParameterType());
    return hasLoginUserAnnotation && isUserType;
}

저는 매개변수에 LoginUser라는 어노테이션이 있고 해당 매개변수의 타입이 User 타입인지를 체크해주었습니다.
 

✅ resolveArgument

Resolves a method parameter into an argument value from a given request.

resolveArgument 메소드는 매개변수로 넣어줄 값을 제공하는 메소드입니다.
저희에게는 User 객체가 되겠죠?
User 객체를 반환하는 resolveArgumentParemeter 메소드를 구현해봅시다.

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
    HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
    String token = request.getHeader("Authorization");
    if (token == null) {
        return null;
    }
    return authService.getLoginUser(token);
}

request의 헤더에서 토큰을 꺼내고 토큰을 통해 얻은 User 객체를 반환하였습니다.

returns: the resolved argument value, or null if not resolvable

공식 문서의 resolveArgument 메소드에 대한 설명을 보면 이렇게 resolve할 수 없으면 null을 반환하라고 나와있습니다.

따라서 토큰이 없다면 null을 반환했습니다.

 

아래는 LoginUserArgumentResolver 전체 코드입니다.

package com.feelcoding.argumentresolverdemo.util;

import com.feelcoding.argumentresolverdemo.User;
import com.feelcoding.argumentresolverdemo.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@Component
@RequiredArgsConstructor
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final AuthService authService;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasLoginUserAnnotation = parameter.hasParameterAnnotation(LoginUser.class);
        boolean isUserType = User.class.isAssignableFrom(parameter.getParameterType());
        return hasLoginUserAnnotation && isUserType;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        String token = request.getHeader("Authorization");
        if (token == null) {
            return null;
        }
        return authService.getLoginUser(token);
    }
}

LoginUserArgumentResolver를 @Component를 이용하여 빈으로 등록해준 이유는 뒤에서 설명하겠습니다.
 

3️⃣ 구현한 HandlerMethodArgumentResolver를 등록해줍니다.

 
이제 저희가 만든 LoginUserArgumentResolver를 등록해봅시다.
HandlerMethodArgumentResolver 등록을 위해서는 WebMvcConfigurer를 구현한 클래스가 필요합니다.
(저는 WebConfig라는 이름으로 클래스를 만들었습니다.)
그리고 addArgumentResolvers()라는 메소드를 구현해야 합니다.
이 메소드에서 아까 저희가 만든 LoginUserArgumentResolver를 추가해주시면 됩니다.

package com.feelcoding.argumentresolverdemo.config;

import com.feelcoding.argumentresolverdemo.util.LoginUserArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final LoginUserArgumentResolver loginUserArgumentResolver;

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

이렇게 해주시면 됩니다.
아까 LoginUserArgumentResolver를 빈으로 등록한 이유가 바로 여기에서 주입받아 사용하기 위함이었습니다.
 
드디어 완성이 되었습니다.
이제 컨트롤러에서 User 객체를 받아볼까요?
 

🔥컨트롤러에서 현재 로그인한 사용자의 User 객체를 매개변수로 받아보자

 컨트롤러의 매개변수로 User 정보를 잘 받아오는지 확인해봅시다.
 
우선 매개변수에 @LoginUser 어노테이션과 User 매개변수를 추가해줍니다.
그리고 사용자 정보를 잘 가져오는지 확인하기 위해 로그를 남겨봅시다.

package com.feelcoding.argumentresolverdemo.controller;

import com.feelcoding.argumentresolverdemo.User;
import com.feelcoding.argumentresolverdemo.util.LoginUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@Slf4j
public class TestController {

    @GetMapping("/test1")
    public void test1(@LoginUser User user) {
        log.debug("여기는 test1, 사용자의 이름은 {}, 이메일은 {}", user.getName(), user.getEmail());
    }

    @PostMapping("/test2")
    public void test2(@LoginUser User user) {
        log.debug("여기는 test2, 사용자의 이름은 {}, 이메일은 {}", user.getName(), user.getEmail());
    }

    @GetMapping("/test3")
    public void test3(@LoginUser User user) {
        log.debug("여기는 test3, 사용자의 이름은 {}, 이메일은 {}", user.getName(), user.getEmail());
    }

    @GetMapping("/test4")
    public void test4(@LoginUser User user) {
        log.debug("여기는 test4, 사용자의 이름은 {}, 이메일은 {}", user.getName(), user.getEmail());
    }

}

 
이제 Postman을 통해 API들을 호출해봅시다.

이렇게 헤더에는 Authorization이라는 이름으로 사용자의 이메일을 넣어주고
GET /test1, POST /test2, GET /test3, GET /test4 API들을 차례로 호출해보겠습니다.
 

이렇게 사용자 정보를 모두 다 잘 가져온 것을 볼 수 있습니다.
 
이렇게 HandlerMethodArgumentResolver를 사용하면 여러 곳에서 사용되는 공통적인 로직을 줄이고, 매개변수로 필요한 정보를 손쉽게 가져올 수 있습니다.
예시를 로그인으로 들었지만, 필요할 때 여러 방법으로 활용하시면 될 것 같습니다.
 
읽어주셔서 감사합니다 :)
 

출처: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/method/support/HandlerMethodArgumentResolver.html

728x90