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)에 해당하는 오류가 발생한다.
@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오류가 발생한다. 전달된 파라미터가 없기 때문이다. 물론 값을 입력 하면 별일 없다.
요청 처리 메서드의 파라미터
다양한 파라미터 활용 가능
요청처리 메서드의 파라미터로는 웹 프로그래밍에서 필요한 대부분의 것들을 필요에 따라 선언할 수 있다. 당연히 선언되는 순서도 무관하다.
웹 프로그래밍을 할 때 필요한 객체들은 무엇일까? 아마도 대부분 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]"));
}
@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 |
소중한 공감 감사합니다