Spring MVC/02.Spring @MVC

02. Controller 작성 1

  • -


이번 시간에는 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 연결)이 수행된다.

요청 처리 절차

 

 

사용자의 요청은 여러 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의 매력이다.

요청 파라미터를 활용하기 위해서 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까지 잘 전달된다.

 

@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")))); }
더보기
너무 간편해진!!

 

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 방식도 문제는 없다.

 

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

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