컨트롤러 Handler 메소드 호출시 사용할 수 있는 어노테이션 및 클래스

Belluga·2021년 5월 5일
0

@RequestBody, 언제 사용할까?

{
    "email" : "joy@abc.com",
    "password":"9299",
    "name":"joy"
}

회원가입을 위한 http 요청 request body에 들어있는 json 데이터는 위와 같습니다.
해당 데이터의 content-type은 application/json입니다.

@PostMapping("/members")
public void join(@RequestBody CreateMemberRequestDto createMemberRequestDto) {
        memberService.join(createMemberRequestDto);
}

서버 컨트롤러에서 클라이언트 요청 body 의 json 데이터를 Java Object로 받기 위해 @RequestBody 어노테이션을 사용해야 합니다.
즉 @RequestBody는 body가 존재하는 Post 방식의 요청 데이터를 받기위해 사용하는 어노테이션 입니다.

@RequestBody 어노테이션을 사용하면 요청 본문(body)에 들어있는 데이터를 HttpMessageConverter를 통해 변환한 자바 객체로 받아올 수 있습니다.

👀 상세 내용을 살펴보도록 하겠습니다

👆 spring-boot-starter-web에 포함되어있는 Jackson 라이브러리

정확히 표현하면 메시지 컨버터 중 MappingJackson2HttpMessageConverter를 사용해서 json을 도메인 객체로 변환합니다.

MappingJackson2HttpMessageConverter 클래스의 생성자에서 Jackson 라이브러리의 ObjectMapper2 클래스의 인스턴스를 빌더 패턴으로 생성해서 가지고 있습니다.

ObjectMapper 이용하여 json을 도메인 객체로, 도메인 객체를 json으로 변환해줍니다. 이때 ObjectMapper 사용시 기본 생성자로 Object를 생성합니다.

Jackson 라이브러리는 json ↔ 도메인 데이터 맵핑에 있어 멤버변수의 명칭이나 유무가 아닌 getter 또는 setter 메서드를 기반으로 작동합니다.3
getter 또는 setter 메서드의 'get', 'set' 부분을 때어내고 앞의 영문을 소문자로 바꿔서 body에 들어온 json 오브젝트의 field와 같을때 매핑합니다.

참고로 Jackson의 매핑을 프로퍼티가 아닌 멤버변수로 해야 하는 상황을 위해 Jackson은 @JsonProperty 어노테이션 API를 제공합니다.

⭐ 결론적으로 @RequestBody 어노테이션을 통해 맵핑할 Java Object에는 기본 생성자 (reflection으로 private 가능)와 getter(또는 setter)가 존재해야 합니다.
이 때 Lombok(@NoArgsConstructor, @Getter)을 통해 코드의 길이를 줄일 수 있습니다.


@RequestBody 어노테이션 없이 맵핑되는 경우

요청시 전달하는 data 형식에 따라 컨트롤러에서 전달받는 방식이 달라질 수 있습니다.
query parameter, form data의 경우 @ReuqestBody를 사용하지 않아도 객체에 mapping 됩니다.

form-data 형식으로 아래 data를 넘긴다고 가정해봅시다

keyvalue
email"abc@test.abc"
password"9243"
name"joy"
@PostMapping("/members")
public void join(CreateMemberRequestDto createMemberRequestDto) {
        memberService.join(createMemberRequestDto);
}

이 때 위 코드와 같이 Command1 객체만 이용하면 사용자의 입력값이 객체에 셋팅됩니다.

단 폼 데이터의 경우 input 태그나 textarea 태그의 name 속성과, 맵핑할 Object 멤버변수 이름이 같아야 바인딩 시켜줍니다.
getter, setter 메서드 명칭으로 바인딩되는 Jackson 방식과는 다르다는 것을 확인할 수 있습니다.

아래 그림을 통해 자세히 살펴보도록 하겠습니다.

(1) 요청 시 적절한 사용자 입력 정보를 form-data 형식으로 서버에 전달하면 스프링 컨테이너는 매개변수에 해당하는 Member 객체를 생성합니다.

(2) 사용자가 입력한 파라미터 값들을 추출하여 Member 객체에 저장합니다. 이때 Member 클래스의 Setter 메서드들이 호출됩니다.

(3) create() 메서드를 호출할 때, 사용자 입력값들이 설정된 member 객체가 인자로 전달됩니다.

@RequestParam

Command 객체를 이용하면 클라이언트에서 넘겨준 요청 파라미터 정보를 받을 수 있다는 것을 알아냈습니다.
그러나 이를 위해서는 요청 파라미터 변수와 매핑될 변수의 Setter 메서드가 Command 클래스에 선언되어있어야 합니다.

Command 객체에 없는 파라미터를 컨트롤러에서 받으려면 어떻게 해야할까요❓

public void example(HttpServletRequest request) {
        // 1. 요청 파라미터 정보 추출
        String email = request.getParameter("email");
        String password = request.getParameter("password");
        String name = request.getParameter("name");
}    

물론 HttpServletRequest에서 값을 꺼내올 수 있지만 코드량의 압박..😬

Spring MVC에서는 HTTP 요청 파라미터 정보를 추출하기 위한 @RequestParam을 제공합니다.

URI 패턴에 쿼리 파라미터 key, value 형태로 데이터를 받거나 (GET)
요청 바디에 form 형식으로 데이터를 받는 경우 사용할 수 있습니다. (POST)

@Controller
public class HelloController {
    @GetMapping("hello")
    @ResponseBody
    public String helloString(@RequestParam("name") String name) {
        return "hello " + name;
    }
}  

이렇게 @RequestParam 어노테이션을 통해 코드량이 늘어나지 않고 파라미터를 추출할 수 있습니다!

@RequestParam 어노테이션에 사용할 수 있는 속성에 대해 알아보고 다음 항목으로 넘어가도록 하겠습니다.

@RequestParam(value="name",
defaultValue="vanessa",
required=false) String name

value : 전달될 파라미터 이름을 나타냅니다.
defaultValue : 전달될 파라미터 정보가 없을 때, 설정할 기본값입니다.
required : 파라미터의 생략 여부입니다.

@PathVariable

@RestController
public class SampleController {
    @GetMapping("/hello/{name}")
    public String hello(@PathVariable String name) {
        return "hello" + name;
    }
}

@PathVariable 어노테이션을 통해 URI 패턴에 있는 값을 가져올 수 있습니다.
타입 변환을 지원하며 값이 반드시 존재해야 합니다.

@RestController
public class SampleController {
    @GetMapping("/hello/{id}")
    public Integer hello(@PathVariable Integer id) {
        return id;
    }
}

문자열을 Integer 타입으로 타입 변환을 자동으로 해줍니다.

Spring HandlerMethodArgumentResolver

상품 생성시 상품 정보 및 판매자 id가 상품 데이터베이스에 저장되야 한다고 생각해보겠습니다.

이 말은 즉 상품 생성시 판매자(현재 로그인한 회원) 정보를 알아야할 필요가 있다는 말이며 상품을 생성하는 로직에 Authentication 객체를 주입받아 회원 정보를 구하는 코드를 작성해야 합니다.

Authentication 객체를 주입하는 코드는 해당 이슈가 있는 모든 부분에 있어 계속 중복해서 출현하게 될 것입니다.

HandlerMethodArgumentResolver를 사용하면
AuthMember 객체를 컨트롤러 파라미터에 바인딩하여 회원 정보를 구할 수 있습니다.

@Target(value = {ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginMember {
}

LoginMember 어노테이션을 생성해줍니다.
객체에 LoginMember 어노테이션만 붙여준다면
현재 로그인한 사용자 정보를 파라미터에 바인딩 되도록 할 것 입니다.

@Component
@RequiredArgsConstructor
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {

    private final Authentication authentication;

    @Override
    public boolean supportsParameter(MethodParameter methodParameter) {
        return methodParameter.hasParameterAnnotation(LoginMember.class);
    }

    @Override
    public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
        return authentication.getLoginMember().orElseThrow(UnAuthorizedException::new);
    }
}

HandlerMethodArgumentResolver 인터페이스를 구현한 커스텀 리솔버를 만들어줍니다. 빈 등록을 위해 @Component 어노테이션을 함께 붙여줍니다.

아래 두개의 메서드를 오버라이딩하여 리솔버를 구현할 수 있습니다.
supportsParameter : 현재 파라미터를 resolver가 지원할지에 대한 여부
resolveArgument : 실제 바인딩할 객체 반환

supportsParameter 메서드에서 현재 파라미터가 @LoginMember 어노테이션을 갖고 있는지 필터링 과정을 거친 후
resolveArgument 메서드에서 현재 로그인한 회원 정보를 반환하도록 하였습니다.

@Component
@RequiredArgsConstructor
public class SessionAuthentication implements Authentication {

    private final HttpSession session;
    private final MemberMapper memberMapper;
    public static final String LOGIN = "loginUser";

    @Override
    public void login(LoginMemberRequestDto loginMemberRequestDto) {
        if (session.getAttribute(LOGIN) != null) {
            session.invalidate();
        }

        Member foundMember = memberMapper.getByEmail(loginMemberRequestDto.getEmail()).orElseThrow(NotFoundMemberException::new);

        if (StringUtils.equals(loginMemberRequestDto.getPassword(), foundMember.getPassword())) {
            AuthMember authMember = new AuthMember(foundMember);
            session.setAttribute(LOGIN, JsonUtil.toJsonString(authMember));
        } else {
            throw new UserAuthenticationFailException();
        }
    }

    @Override
    public Optional<AuthMember> getLoginMember() {
        if (session.getAttribute(LOGIN) == null)
            return Optional.empty();
        return Optional.of(JsonUtil.toObject((String) session.getAttribute(LOGIN), AuthMember.class));
    }

    @Override
    public void logout() {
        session.invalidate();
    }
}

현재 로그인한 회원 정보를 반환하도록 하는 getLoginMember 메서드는 위와 같이 구현하였습니다.

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final AuthorityInterceptor authorityInterceptor;
    private final LoginMemberArgumentResolver loginMemberArgumentResolver;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authorityInterceptor);
    }

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

마지막으로 생성한 Custom Resolver를 자바 설정 파일에 등록해주면 됩니다.

@RestController
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @Authority(target = {Role.SELLER})
    @PostMapping("/products")
    @ResponseStatus(HttpStatus.CREATED)
    public void createProduct(@Valid @RequestBody CreateProductRequestDto createProductRequestDto, @LoginMember AuthMember member) {
        productService.createProduct(createProductRequestDto, String.valueOf(member.getId()));
    }
}

AuthMember 객체에 LoginMember 어노테이션만 붙여
현재 로그인한 사용자 정보를 바인딩 되도록 하였습니다.

Formatter

앞서 배워본 @RequestParam, @PathVariable 어노테이션과 함께 사용할 수 있는 Formatter 에 대해 알아보도록 하겠습니다.
Formatter 인터페이스를 사용하면 위와 같이 문자열을 객체로 받을 수 있습니다.
예시 코드로 살펴보시죠 😃

public class Person {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

예제를 위해 Person 클래스를 만들어줍니다.

@RestController
public class SampleController {
    @GetMapping("/hello/{name}")
    public String hello(@PathVariable("name") Person person) {
        return "hello" + person.getName();
    }
}  

URI 패턴에 있는 문자열 값을 Person 객체로 받고 있습니다.

@RestController
public class SampleController {
    @GetMapping("/hello")
    public String hello(@RequestParam("name") Person person) {
        return "hello" + person.getName();
    }
}

이번에는 요청 파라미터 값 "name" 문자열을 객체로 받고 있습니다.

포매터 인터페이스를 사용하면 위와 같이 문자열을 객체로 받을 수 있습니다.
객체를 문자열로 변환도 가능합니다.

@Component
public class PersonFormatter implements Formatter<Person> {
    @Override
    public Person parse(String s, Locale locale) throws ParseException {
        Person person = new Person();
        person.setName(s);
        return person;
    }

    @Override
    public String print(Person person, Locale locale) {
        return person.toString();
    }
}

문자열을 객체로 변환하기 위해서는 parse 메서드를,
객체를 문자열로 변환하기 위해서는 print 메서드를 구현합니다.

스프링부트의 경우 포매터를 빈으로만 등록해주면 자동으로 포매터를 설정해주기 때문에 별도 설정파일에 등록할 필요가 없습니다. 😊

주석

1: Controller 메서드 매개변수로 받은 객체이다
2: https://docs.spring.io/spring-framework/docs/current/javadoc-api/index.html?org/springframework/http/converter/json/MappingJackson2HttpMessageConverter.html
3: http://tutorials.jenkov.com/java-json/jackson-objectmapper.html#how-jackson-objectmapper-matches-json-fields-to-java-fields

References

https://codinghack.tistory.com/

https://kim-jong-hyun.tistory.com/60

https://mommoo.tistory.com/83

https://velog.io/@conatuseus/RequestBody%EC%97%90-%EA%B8%B0%EB%B3%B8-%EC%83%9D%EC%84%B1%EC%9E%90%EB%8A%94-%EC%99%9C-%ED%95%84%EC%9A%94%ED%95%9C%EA%B0%80

https://sanghye.tistory.com/36

0개의 댓글