Spring MVC/02.Spring @MVC

02. Controller 작성 1

  • -


이번 시간에는 Controller 클래스를 만드는 방법과 요청 처리 메서드 작성법에 대해 알아보자.

Controller와 요청 처리 메서드

 

@Controller

Controller는 클라이언트의 요청인 HttpServletRequest를 처리하는 클래스 Handler라고도 불린다. 스프링에서는 Controller를 구현하기 위해 @Controller라는 스테레오 타입 애너테이션을 사용한다. 

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Controller {...}

@Controller는 클래스 레벨에서 사용할 수 있는 애너테이션으로 내부적으로 @Component를 포함한다. 클래스 내부에는 일반 메서드는 물론 @RequestMapping 계열의 애너테이션을 이용하는 다수의 요청 처리 메서드들이 작성될 수 있다.

 

요청 처리 메서드

사용자가 서버로 요청을 날리면 DispatcherServlet(이하 D.S)은 적절한 HandlerAdapter에게  URL을 넘겨주고 HandlerAdapter가 URL에 의거해 특정 Controller의 @RequestMapping이 선언된 메서드(요청 처리 메서드)를 호출한다.

요청 처리 메서드에서는 전형적인 Servlet의 역할(요청 분석, 비지니스 로직 수행, 자료 저장, View 연결)이 수행된다.

요청 처리 절차

 

@RequestMapping

 

@RequestMapping

사용자의 요청은 여러 HandlerMapping과 HandlerAdapter를 통해서 처리될 수 있는데 가장 일반적으로 사용되는 것은 RequestMappingHandlerMapping과 RequestMappingHandlerAdapter이다. 여기서 RequestMapping은 @RequestMapping 애너테이션을 의미한다.

@RequestMapping 요청 처리 메서드에 선언하는 애너테이션이다. 

@Target({ElementType.METHOD, ElementType.TYPE})
public @interface RequestMapping {
  @AliasFor("path")
  String[] value() default {};
  
  @AliasFor("value")
  String[] path() default {};
  
  RequestMethod[] method() default {};     // 요청 방식: GET/POST/PUT/DELETE
  
  String [] params() default {};           // 전달되어야 하는 파라미터 목록
}

@RequestMapping은 ElementType.TYPE과 ElementType.METHOD로 사용할 수 있다.

가장 중요한 path 속성 value와 alias로 연동되어있는데 사용자가 호출하는 요청의 경로 즉 URL이 등록된다. 

클래스에 선언된 @RequestMapping의 path는 클래스에 속한 모든 요청처리 메서드의 path 앞에 추가된다. 일반적으로 Controller 클래스를 구성할 때 업무 도메인에 따라 공통의 URL을 구성하게 되는데 클래스 레벨의 @RequestMapping을 사용하면 아주 손쉽게 URL을 구성할 수 있다.

package com.doding.hellomvc.controller;


import com.doding.hellomvc.model.service.HelloService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.Random;

@Controller
@RequestMapping("/user")
public class UserController {

    @RequestMapping                         // 매핑 URL: /user
    public String showMessage(Model model) {
        String msg = "여러가지 사용자 관리 서비스 제공";
        model.addAttribute("message", msg);
        return "index";
    }


    @RequestMapping("/luckynum")            // 매핑 URL: /user/luckynum
    public String luckynum(Model model) {
        int num = new Random().nextInt();
        model.addAttribute("message", num);
        return "index";
    }
}

index.html에 다음과 같은 링크를 추가하고 동작을 테스트 해보자.

<h2 th:text="${message}">메시지 표시 영역</h2>
<ul>
    <li><a href="#" th:href="@{/user}">user</a></li>
    <li><a href="#" th:href="@{/user/luckynum}">luckynum</a></li>
</ul>
더보기

이번에는 테스트에서 반복되는 부분을 AbstractWebTest로 이동시키고 상속받아서 처리해보자.

package com.doding.hellomvc;

import com.doding.hellomvc.model.service.HelloService;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpMethod;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

@WebMvcTest
public class AbstractWebTest {
    @Autowired
    protected MockMvc mock;
    @MockBean
    protected HelloService service;

    @BeforeEach
    protected void setUpService() {
        String msg = "Hello Spring@MVC";
        Mockito.when(service.sayHello()).thenReturn(msg);
    }

    protected ResultActions getSimpleAction(String url, RequestMethod method) throws Exception {
        MockHttpServletRequestBuilder builder = switch (method.name()) {
            case GET -> MockMvcRequestBuilders.get(url);
            default -> MockMvcRequestBuilders.post(url);
        };
        return mock.perform(builder);
    }

    protected ResultActions checkStatusIsOk(ResultActions result) throws Exception {
        return result.andExpect(MockMvcResultMatchers.status().isOk());
    }
}

위 클래스를 이용해서 /user와 /user/luckynum을 테스트 해보자.

@Test
public void requestMappintTest() throws Exception {
    // given
    String url = "/user";
    // when
    ResultActions result = getSimpleAction(url, RequestMethod.GET);
    // then
    checkStatusIsOk(result)
      .andExpect(MockMvcResultMatchers.model().attribute("message", "여러가지 사용자 관리 서비스 제공"));

    url = "/user/luckynum";
    result = getSimpleAction(url, RequestMethod.GET);
    checkStatusIsOk(result)
      .andExpect(MockMvcResultMatchers.model().attributeExists("message"));
    }
}

 

호출 방식을 이용한 매핑

요청 처리 메서드는 path 뿐만 아니라 GET/POST등 어떤 방식으로 호출 되느냐에 따라서도 매핑이 결정된다. 이를 위해 method 속성을 사용한다. method는 RequestMethod[] 타입인데 RequestMethod는 enum 타입이다.

public enum RequestMethod {
	GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE;
}

method 속성을 지정하지 않았을 경우는 모든 method에 매핑된다. 기존의 /user/luckynum 경로를 post로 호출해보자.

<form method="post" action="#" th:action="@{/user/luckynum}">
    <button>행운번호는?</button>
</form>

문제없이 잘 동작하는 것을 알 수 있다. 이제 /luckynum의 @RequestMapping에 method를 GET으로 지정해보자.

@RequestMapping(value="/luckynum", method = RequestMethod.GET)  
public String luckynum(Model model) {
    ...
    return "index";
}

이제 다시 post로 요청해보면 405(Method Not Allowed)에 해당하는 오류가 발생한다.

이제 luckynum은 GET 방식으로만 동작한다.

더보기
@Test
public void mappingMethodTest() throws Exception {
    String url = "/user/luckynum";
    ResultActions result = getSimpleAction(url, RequestMethod.POST);
    // then
    result.andExpect(MockMvcResultMatchers.status().isMethodNotAllowed());
}


method에 대한 설정을 좀 더 간단하게
 처리하기 위해 @GetMapping, @PostMapping등을 사용할 수도 있다. 사용법은 method 속성이 없는것 만 빼면 @RequestMapping과 동일하다.

@GetMapping  == @RequestMapping(method=RequestMethod.GET)
@PostMapping == @RequestMapping(method=RequestMethod.POST)


이처럼 동일한 URL에 대해서 요청 방식(GET/POST)에 따라 다른 컨트롤러를 호출할 수 있다는 점은 URL 설계에 많은 영향을 준다. login이나 join 처럼 어떤 동작을 위한 <form>을 먼저 보여주고 실제 동작으로 연결하는 경우가 많은데 이때 path를 동일하게 하고 method를 다르게 작성하는 경우에 필요한 기능이다.

다음은 @GetMapping과 @PostMapping을 이용해서 login을 처리하는 예이다.

@GetMapping("/login")                 // 사용자에게 login form 제공
public String showLoginForm(Model model) {
    return "login";
}
    
@PostMapping("/login")                // 실제 로그인 처리
public String doLogin(Model model) {
    ...
    return "main";
}

 

요청 파라미터를 이용한 매핑

path와 method외에도 params 속성을 이용하면 요청 파라미터를 이용해서 매핑을 제한할 수 있다. params에 선언된 내용은 반드시 요청 파라미터에 포함되어야 한다.

@PostMapping(value="/join", params={"id", "age", "hobby"})
public String join(Model model) {
    model.addAttribute("message", "join");
    return "index";
}

위의 메서드가 잘 동작하는지 테스트 해보자.

@Test
@DisplayName("지정한 파라미터가 전달되지 않으면 400(Bad Request)발생")
public void mappingRequestParameterTest() throws Exception {
  // given
  String url = "/user/join";
  MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
  params.add("id", "hong");
  params.add("age", "10");
  params.add("hobby", "sleep");
  params.add("hobby", "study");
  
  ResultActions result = getSimpleAction(url, RequestMethod.POST);
  result.andExpect(MockMvcResultMatchers.status().isBadRequest()); // 파라미터가 없을 경우는 400 발생
  
  MockHttpServletRequestBuilder builders = MockMvcRequestBuilders.post(url).params(params);
  result = mock.perform(builders);
  checkStatusIsOk(result).andExpect(MockMvcResultMatchers.model().attributeExists("message"));
}
더보기

단위테스트를 하지 않고 위 메서드를 테스트 하려면 form이 있어야 한다. Controller를 테스트 하기 위해서 가야할 길이 참 멀다. 어차피 전체적으로 동작시키려면 필요하긴 하지만..

위의 요청 처리 메서드가 매핑되기 위해서는 path는 /join이어야 하며 POST 방식으로 호출되고 요청 파라미터에 반드시  id, pass, hobby가 포함되어야 한다. index에 위 요청 처리 메서드를 호출하기 위한 <form>을 추가해보자.

<form method="post" action="#" th:action="@{/user/join}">
    <fieldset>
        <input type="text" name="id" placeholder="id">
        <input type="text" name="age" placeholder="age"><br>
        <fieldset>
            <legend>취미</legend>
            <label>study<input type="checkbox" name="hobby" value="study"></label>
            <label>reading<input type="checkbox" name="hobby" value="reading"></label>
            <label>running<input type="checkbox" name="hobby" value="running"></label>
        </fieldset>
        <button>회원가입</button>
    </fieldset>
</form>

위 상황에서 field에 값을 입력하지 않고 [회원가입]을 클릭하면 다음과 같이 400오류가 발생한다. 전달된 파라미터가 없기 때문이다. 물론 값을 입력 하면 별일 없다.

전달된 파라미터가 없다: 400 오류

 

요청 처리 메서드의 파라미터

 

다양한 파라미터 활용 가능

요청처리 메서드의 파라미터로는 웹 프로그래밍에서 필요한 대부분의 것들을 필요에 따라 선언할 수 있다. 당연히 선언되는 순서도 무관하다.

웹 프로그래밍을 할 때 필요한 객체들은 무엇일까? 아마도 대부분 HttpServletRequest, HttpServletResponse, HttpSession을 떠올릴 것이다. 여기에 Model, ModelAttribute, SessionStatus, Locale 등의 객체들이 필요에 따라 선언될 수 있다. 

@GetMapping("/usewebobject")
public String useWebObject(HttpServletRequest req, HttpServletResponse resp, HttpSession session) {
    req.setAttribute("message", "use web object");  // request scope에 데이터 저장
    resp.addCookie(new Cookie("some", "cookie"));   // response를 통해 cookie 전달
    session.setAttribute("some", "attr");           // session scope에 데이터 저장
    return "index";
}

위의 예에서 확인해봤듯이 단순히 필요한 객체들을 선언만하면 메서드가 호출될 때 할당되고 사용하는데도 전혀 문제가 없다. 이 객체들만 사용할 수 있으면 대부분 웹 관련 처리가 가능하다. 다만 HttpServletRequest에 setAttribute를 하는 대신 Model에 addAttribute 하는 식으로 사용법이 좀 더 간소화된다고 이해하면 좋다. 이런 부분이 Spring의 매력이다.

@RequestParam

요청 파라미터를 활용하기 위해서 HttpServletRequest가 제공하는 getParameter() 메서드를 이용한다. 네트워크를 통해 전달되는 파라미터는 오로지 문자열만 가능하기 때문에 사용 전에 원하는 타입으로의 형 변환은 필수였다.

SpringMVC에서는 요청 파라미터의 값을 사용하려는 경우 @RequestParam을 사용한다.

@Target(ElementType.PARAMETER)
public @interface RequestParam {
  String value() default "";                                 // 요청 파라미터의 이름
  boolean required() default true;                           // 객체형일 경우 필수 여부
  String defaultValue() default ValueConstants.DEFAULT_NONE; // 기본 값
}

@RequestParam은 메서드의 파라미터에 선언할 수 있는 애너테이션이다. name 속성에는 요청에 담긴 파라미터의 이름을 명시해주면 된다. 신기한 점은 변수의 타입을 선언할 때 원하는 타입을 적어주면 자동으로 형변환 처리해준다는 점이다. 만약 동일한 이름으로 전달되는 값이 여러 개일 경우 배열이나 List/Set 형태로 받을 수 있다. 

메서드의 파라미터이름과 요청 파라미터의 이름이 동일할 경우 Spring Framework 6.1이전에는 @RequestParam을 생략할 수 있었으나 6.1부터는 기본적으로 생략할 수 없다. 다만 compiler 옵션으로 -params를 추가하면 생략할 수 있다. 
@PostMapping(value="/join", params={"id", "pass", "hobby"})
public String join(Model model, @RequestParam(name="id") String id,
                                @RequestParam(name="age") int age,
                                @RequestParam(name="hobby") List<String> hobby ) {
    model.addAttribute("message", id+" : "+age+" : "+hobby);
    return "index";
}

이제 파라미터를 입력하고 값이 잘 전달되는지 확인해보자.

@Test
public void requestParamTest() throws Exception {
  // given
  String url = "/user/join";
  MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
  params.add("id", "hong");
  params.add("age", "10");
  params.add("hobby", "reading");
  params.add("hobby", "running");
  
  MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.post(url).params(params);
  ResultActions result = mock.perform(builder);
  checkStatusIsOk(result)
    .andExpect(MockMvcResultMatchers.model().attribute("message", "hong : 10 : [reading, running]"));
}
더보기
문자열은 물론 int, List까지 잘 전달된다.

 

@ModelAttribute

@RequestParam만 해도 요청 파라미터 처리가 쉬워지는데 @ModelAttribute는 파라미터 처리의 끝판왕이라고 볼 수 있다.

@ModelAttribute 전달된 파라미터를 DTO의 속성으로 바로 연결해서 DTO 객체를 만들어 준다. 당연히 파라미터의 name과 DTO의 속성 이름은 동일해야 한다. 예를 들어 다음과 같은 User DTO를 생각해보자.

package com.doding.hellomvc.model.dto;

import lombok.Data;

import java.util.List;

@Data
public class User {
    private String id;
    private int age;
    private List<String> hobby;
}


그리고 전달된 파라미터를 이용해서 User 객체를 구성하고 싶다면 join을 다음과 같이 수정할 수 있다.

@PostMapping(value="/join", params={"id", "age", "hobby"})
public String join(Model model, @ModelAttribute User user) {
    model.addAttribute("message", user);
    return "index";
}


이제 적절한 파라미터로 호출해보면 서버 단에서는 개별 파라미터가 아닌 하나의 DTO 객체로 바로 처리가 가능하다. 기존 테스트를 다음과 같이 수정해보자.

@Test
public void requestParamTest() throws Exception {
  // given
  String url = "/user/join";
  ...
  ResultActions result = mock.perform(builder);
  checkStatusIsOk(result)
  .andExpect(MockMvcResultMatchers.model()
             .attribute("message", new User("hong", 10, List.of("reading", "running"))));
  }
더보기
너무 간편해진!!

 

@CookieValue

Cookie는 세션과 함께 웹에서 정보 저장/유지를 위해 자주 사용되는 방식이다. 기존에는 Cookie 값을 사용할 때 일단 HttpServletRequest에서 Cookie의 배열을 얻어온 후 원하는 이름의 Cookie가 있는지 탐색해야 했다.

하지만 @CookieValue Cookie의 name을 변수 명으로 선언하면 직접 값을 할당해준다. 물론 자동 형변환은 기본이다.

@RequestMapping("/cookiemaker")
public String setCookie(Model model, HttpServletResponse res) {
    ResponseCookie cookie = ResponseCookie.from("userName", "HongGilDong")
            .maxAge(60*5)
            .sameSite("Strict")
            .build();
    res.addHeader("Set-Cookie", cookie.toString());
    model.addAttribute("message", "쿠키 설정 완료: "+cookie);
    return "index";
}

@RequestMapping("/cookieconsumer")
public String getCookie(Model model, @CookieValue String userName, Cookie[] cookies) {
    model.addAttribute("message", userName+" : "+ cookies.length);
    return "index";
}
쿠키기술이 발전하면서 sameSite 등의 특성들이 추가되었는데 기존의 Cookie 클래스를 관련 설정이 없어서 별도로 header에 설정했어야 했다. ResponseCookie의 경우는 builder 패턴으로 쿠키를 만드는 방식으로 sameSite등까지 편리하게 설정할 수 있다. 단 쿠키를 내려보내기 위해서 단지 response에 담으면 되는건 아니고 response의 header에 Set-Cookie를 키로 ResponseCookie의 문자열 값을 내려보내주면 된다. 굳이 sameSite등이 필요 없으면 기존의 Cookie 방식도 문제는 없다.

 

'Spring MVC > 02.Spring @MVC' 카테고리의 다른 글

06. 파라미터의 formatting  (0) 2020.07.07
05. Handler Interceptor  (1) 2020.07.03
04. 웹과 관련된 설정  (0) 2020.07.02
03. Controller 작성 2  (2) 2020.07.01
01. Spring MVC 개요  (5) 2020.06.29
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.