김영한의 스프링 완전 정복 로드맵
스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술
섹션5~섹션7 정리입니다.
• FrontController → DispatcherServlet
• handlerMappingMap → HandlerMapping
• MyHandlerAdapter → HandlerAdapter
• ModelView → ModelAndView
• viewResolver → ViewResolver
• MyView → View
org.springframework.web.servlet.DispatcherServlet
📌 더 자세한 경로가 우선순위가 높음. 그래서 기존에 등록한 서블릿도 함께 동작
// 1. 핸들러 조회
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
// 3. 핸들러 어댑터 실행 -> 4. 핸들러 어댑터를 통해 핸들러 실행 -> 5. ModelAndView 반환
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
// 6. 뷰 리졸버를 통해서 뷰 찾기, 7. View 반환
view = resolveViewName(viewName, mv.getModelInternal(), locale, request);
// 8. 뷰 렌더링
view.render(mv.getModelInternal(), request, response);
스프링 MVC의 큰 강점은 DispatcherServlet 코드의 변경 없이, 원하는 기능을 변경하거나 확장할 수 있음
핸들러 매핑 : org.springframework.web.servlet.HandlerMapping
핸들러 어댑터 : org.springframework.web.servlet.HandlerAdapter
뷰 리졸버 : org.springframework.web.servlet.ViewResolver
뷰 : org.springframework.web.servlet.View
📌 Controller 인터페이스는 @Controller 애노테이션과는 전혀 다르다
다시 작성
@Controller
public class SpringMemberFormControllerV1 {
@RequestMapping("/springmvc/v1/members/new-form")
public ModelAndView process() {
return new ModelAndView("new-form");
}
}
다시 작성
다시 작성
Packaging는 : Jar 선택
📌 JSP를 사용하지 않기 때문에 Jar를 사용하는 것이 좋음
스프링 부트에 Jar를 사용하면 /resources/static/ 위치에 index.html 파일을 두면 Welcome 페이지로 처리해준다.
(스프링 부트가 지원하는 정적 컨텐츠 위치에 /index.html 이 있으면 된다
private final Logger log = LoggerFactory.getLogger(getClass());
//@Slf4j
@RestController
public class LogTestController {
private final Logger log = LoggerFactory.getLogger(getClass());
@RequestMapping("/log-test")
public String logTest() {
String name = "Spring";
log.trace("trace log={}", name);
log.debug("debug log={}", name);
log.info(" info log={}", name);
log.warn(" warn log={}", name);
log.error("error log={}", name);
//로그를 사용하지 않아도 a+b 계산 로직이 먼저 실행됨, 이런 방식으로 사용하면 X
log.debug("String concat log=" + name);
return "ok";
}
}
logging.level.root=info
logging.level.hello.springmvc=debug
@RequestMapping 에 method 속성으로 HTTP 메서드를 지정하지 않으면 HTTP 메서드와 무관하게 호출
모두 허용 GET, HEAD, POST, PUT, PATCH, DELETE
Get만 허용
(만약 POST 요청을 하면 스프링 MVC는 HTTP 405 상태코드(Method Not Allowed)를 반환)
@RequestMapping(value = "/mapping-get-v1", method = RequestMethod.GET)
public String mappingGetV1() {
log.info("mappingGetV1");
return "ok";
}
@GetMapping(value = "/mapping-get-v2")
public String mappingGetV2() {
log.info("mapping-get-v2");
return "ok";
}
@GetMapping("/mapping/{userId}")
public String mappingPath(@PathVariable("userId") String data) {
log.info("mappingPath userId={}", data);
return "ok";
}
#변수명이 같으면 생략 가능
ex)@PathVariable("userId") String userId -> @PathVariable userId
@GetMapping("/mapping/{userId}")
public String mappingPath(@PathVariable userId ) {
log.info("mappingPath userId={}", userId);
return "ok";
}
@GetMapping("/mapping/users/{userId}/orders/{orderId}")
public String mappingPath(@PathVariable String userId, @PathVariable Long orderId) {
log.info("mappingPath userId={}, orderId={}", userId, orderId);
return "ok";
}
/**
* 파라미터로 추가 매핑
* params="mode",
* params="!mode"
* params="mode=debug"
* params="mode!=debug" (! = )
* params = {"mode=debug","data=good"}
*/
@GetMapping(value = "/mapping-param", params = "mode=debug")
public String mappingParam() {
log.info("mappingParam");
return "ok";
}
파라미터 매핑과 비슷하지만, HTTP 헤더를 사용
/**
* 특정 헤더로 추가 매핑
* headers="mode",
* headers="!mode"
* headers="mode=debug"
* headers="mode!=debug" (! = )
*/
@GetMapping(value = "/mapping-header", headers = "mode=debug")
public String mappingHeader() {
log.info("mappingHeader");
return "ok";
}
HTTP 요청의 Content-Type 헤더를 기반으로 미디어 타입으로 매핑
만약 맞지 않으면 HTTP 415 상태코드(Unsupported Media Type)을 반환한다
/**
* Content-Type 헤더 기반 추가 매핑 Media Type
* consumes="application/json"
* consumes="!application/json"
* consumes="application/*"
* consumes="*\/*"
* MediaType.APPLICATION_JSON_VALUE
*/
@PostMapping(value = "/mapping-consume", consumes = "application/json")
public String mappingConsumes() {
log.info("mappingConsumes");
return "ok";
}
HTTP 요청의 Accept 헤더를 기반으로 미디어 타입으로 매핑
만약 맞지 않으면 HTTP 406 상태코드(Not Acceptable)을 반환한다
/**
* Accept 헤더 기반 Media Type
* produces = "text/html"
* produces = "!text/html"
* produces = "text/*"
* produces = "*\/*"
*/
@PostMapping(value = "/mapping-produce", produces = "text/html")
public String mappingProduces() {
log.info("mappingProduces");
return "ok";
}
• 회원 관리 API
• 회원 목록 조회: GET /users
• 회원 등록 : POST /users
• 회원 조회 : GET /users/{userId}
• 회원 수정 : PATCH /users/{userId}
• 회원 삭제 : DELETE /users/{userId}
@RequestMapping("/headers")
public String headers(HttpServletRequest request,
HttpServletResponse response,
HttpMethod httpMethod,
Locale locale,
@RequestHeader MultiValueMap<String, String> headerMap,
@RequestHeader("host") String host,
@CookieValue(value = "myCookie", required = false) String cookie ){
HttpServletRequest
HttpServletResponse
HttpMethod
@RequestHeader MultiValueMap<String, String> headerMap
모든 HTTP 헤더를 MultiValueMap 형식으로 조회
📌 MultiValueMap은 하나의 키에 대해 여러 개의 값을 가질 수 있기 때문에, 다중 값 매핑을 표현하기에 적합
@RequestHeader("host") String host
@CookieValue(value = "myCookie", required = false) String cookie
클라이언트에서 서버로 요청 데이터를 전달할 때는 주로 다음 3가지 방법을 사용
@ResponseBody
@RequestMapping("/request-param-v2")
public String requestParamV2(
@RequestParam("username") String memberName,
@RequestParam("age") int memberAge) {
log.info("username={}, age={}", memberName, memberAge);
return "ok";
}
HTTP 파라미터 이름이 변수 이름과 같으면 @RequestParam(name="xx") 생략 가능
@ResponseBody
@RequestMapping("/request-param-v3")
public String requestParamV3(
@RequestParam String username,
@RequestParam int age) {
log.info("username={}, age={}", username, age);
return "ok";
}
@ResponseBody
@RequestMapping("/request-param-v4")
public String requestParamV4(String username, int age) {
log.info("username={}, age={}", username, age);
return "ok";
}
@ResponseBody
@RequestMapping("/request-param-required")
public String requestParamRequired(
@RequestParam(required = true) String username,
@RequestParam(required = false) Integer age) {
log.info("username={}, age={}", username, age);
return "ok";
}
@ResponseBody
@RequestMapping("/request-param-default")
public String requestParamDefault(
@RequestParam(required = true, defaultValue = "guest") String username,
@RequestParam(required = false, defaultValue = "-1") int age) {
log.info("username={}, age={}", username, age);
return "ok";
}
@ResponseBody
@RequestMapping("/request-param-map")
public String requestParamMap(@RequestParam Map<String, Object> paramMap) {
log.info("username={}, age={}", paramMap.get("username"),
paramMap.get("age"));
return "ok";
}
@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@ModelAttribute HelloData helloData) {
log.info("username={}, age={}", helloData.getUsername(),
helloData.getAge());
return "ok";
}
스프링MVC는 @ModelAttribute 가 있으면 다음을 실행
프로퍼티
바인딩 오류
@PostMapping("/request-body-string-v1")
public void requestBodyString(HttpServletRequest request,
HttpServletResponse response) throws IOException {
ServletInputStream inputStream = request.getInputStream();
String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
log.info("messageBody={}", messageBody);
response.getWriter().write("ok");
}
@PostMapping("/request-body-string-v2")
public void requestBodyStringV2(InputStream inputStream,
Writer responseWriter) throws IOException {
String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
log.info("messageBody={}", messageBody);
responseWriter.write("ok");
}
@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) {
String messageBody = httpEntity.getBody();
log.info("messageBody={}", messageBody);
return new HttpEntity<>("ok");
}
@ResponseBody
@PostMapping("/request-body-string-v4")
public String requestBodyStringV4(@RequestBody String messageBody) {
log.info("messageBody={}", messageBody);
return "ok";
}
@PostMapping("/request-body-json-v1")
public void requestBodyJsonV1(HttpServletRequest request,
HttpServletResponse response) throws IOException {
ServletInputStream inputStream = request.getInputStream();
String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
log.info("messageBody={}", messageBody);
HelloData data = objectMapper.readValue(messageBody, HelloData.class);
log.info("username={}, age={}", data.getUsername(), data.getAge());
response.getWriter().write("ok");
}
@ResponseBody
@PostMapping("/request-body-json-v2")
public String requestBodyJsonV2(@RequestBody String messageBody) throws
IOException {
HelloData data = objectMapper.readValue(messageBody, HelloData.class);
log.info("username={}, age={}", data.getUsername(), data.getAge());
return "ok";
}
@ResponseBody
@PostMapping("/request-body-json-v3")
public String requestBodyJsonV3(@RequestBody HelloData data) {
log.info("username={}, age={}", data.getUsername(), data.getAge());
return "ok";
}
@ResponseBody
@PostMapping("/request-body-json-v4")
public String requestBodyJsonV4(HttpEntity<HelloData> httpEntity) {
HelloData data = httpEntity.getBody();
log.info("username={}, age={}", data.getUsername(), data.getAge());
return "ok";
}
@ResponseBody
@PostMapping("/request-body-json-v5")
public HelloData requestBodyJsonV5(@RequestBody HelloData data) {
log.info("username={}, age={}", data.getUsername(), data.getAge());
return data;
}
@RequestMapping("/response-view-v1")
public ModelAndView responseViewV1() {
ModelAndView mav = new ModelAndView("response/hello")
.addObject("data", "hello!");
return mav;
}
String을 반환하는 경우 - View or HTTP 메시지
@RequestMapping("/response-view-v2")
public String responseViewV2(Model model) {
model.addAttribute("data", "hello!!");
return "response/hello";
}
Void를 반환하는 경우
@RequestMapping("/response/hello")
public void responseViewV3(Model model) {
model.addAttribute("data", "hello!!");
}
📌 참고 이 방식은 명시성이 너무 떨어지고, 딱 맞는 경우도 많이 없어서, 권장하지 않음response.getWriter().write("ok")
@GetMapping("/response-body-string-v1")
public void responseBodyV1(HttpServletResponse response) throws IOException {
response.getWriter().write("ok");
}
@GetMapping("/response-body-string-v2")
public ResponseEntity<String> responseBodyV2() {
return new ResponseEntity<>("ok", HttpStatus.OK);
}
@ResponseBody
@GetMapping("/response-body-string-v3")
public String responseBodyV3() {
return "ok";
}
@GetMapping("/response-body-json-v1")
public ResponseEntity<HelloData> responseBodyJsonV1() {
HelloData helloData = new HelloData();
helloData.setUsername("userA");
helloData.setAge(20);
return new ResponseEntity<>(helloData, HttpStatus.OK);
}
@ResponseStatus(HttpStatus.OK)
@ResponseBody
@GetMapping("/response-body-json-v2")
public HelloData responseBodyJsonV2() {
HelloData helloData = new HelloData();
helloData.setUsername("userA");
helloData.setAge(20);
return helloData;
}
📌 참고 @ResponseBody는 클래스 레벨에 두면 전체 메서드에 적용되는데, @RestController에노테이션 안에 @ResponseBody가 적용되어 있음
데이터 처리 : byte[]
클래스 타입 : byte[]
미디어타입 : */*(아무거나)
요청 예) @RequestBody byte[] data
응답 예) @ResponseBody return byte[]
쓰기 미디어타입 : application/octet-stream
데이터 처리 : String 문자
클래스 타입 : String
미디어타입 : */*(아무거나)
요청 예) @RequestBody String data
응답 예) @ResponseBody return "ok"
쓰기 미디어타입 : text/plain
데이터 처리 : application/json
클래스 타입 : 객체 또는 HashMap
미디어타입 : application/json 관련
요청 예) ) @RequestBody HelloData data
응답 예) @ResponseBody return helloData
쓰기 미디어타입 : application/json 관련
📌참고 가능한 응답 값 목록은 다음 공식 메뉴얼에서 확인 가능
https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-annreturn-types
HTTP 메시지 컨버터를 사용하는 @RequestBody 도 컨트롤러가 필요로 하는 파라미터의 값에 사용되고, @ResponseBody의 경우도 컨트롤러의 반환 값을 이용한다
요청의 경우 :
• @RequestBody를 처리하는 ArgumentResolver가 있고, HttpEntity를 처리하는 ArgumentResolver가 있음
• ArgumentResolver들이 HTTP 메시지 컨버터를 사용해서 필요한객체를 생성
응답의 경우:
• @ResponseBody와 HttpEntity를 처리하는 ReturnValueHandler가 있다
• 여기에서 HTTP 메시지 컨버터를 호출해서 응답 결과를 만듬
스프링 MVC
• @RequestBody @ResponseBody가 있으면 RequestResponseBodyMethodProcessor(ArgumentResolver) 사용
• HttpEntity가 있으면 HttpEntityMethodProcessor(ArgumentResolver) 사용
확장
스프링은 다음을 모두 인터페이스로 제공, 필요하면 언제든지 기능을 확장할 수 있다
Packaging는 Jar를 선택
상품 도메인 모델
상품 ID
상품명
가격
수량
상품 관리 기능
상품 목록
상품 상세
상품 등록
상품 수정
private Long id;
private String itemName;
private Integer price; // 값이 null이 들어올수 있기때문에 Integer사용(Int는 null 허용X)
private Integer quantity;
public Item save(Item item) {
item.setId(++sequence);
store.put(item.getId(), item);
return item;
}
public Item findById(Long id) {
return store.get(id);
}
public List<Item> findAll() {
return new ArrayList<>(store.values());
}
public void update(Long itemId, Item updateParam) {
Item findItem = findById(itemId);
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
}
@AfterEach // test 끝날때마다 초기화 실행
void afterEach() {
itemRepository.clearStore();
}
@Test
void save() {
//given
Item item = new Item("itemA", 10000, 10);
//when
Item savedItem = itemRepository.save(item);
//then
Item findItem = itemRepository.findById(item.getId());
assertThat(findItem).isEqualTo(savedItem);
}
@Test
void findAll() {
//given
Item item1 = new Item("item1", 10000, 10);
Item item2 = new Item("item2", 20000, 20);
itemRepository.save(item1);
itemRepository.save(item2);
//when
List<Item> result = itemRepository.findAll();
//then
assertThat(result.size()).isEqualTo(2);
assertThat(result).contains(item1, item2);
}
@Test
void updateItem() {
//given
Item item = new Item("item1", 10000, 10);
Item savedItem = itemRepository.save(item);
Long itemId = savedItem.getId();
//when
Item updateParam = new Item("item2", 20000, 30);
itemRepository.update(itemId, updateParam);
Item findItem = itemRepository.findById(itemId);
//then
assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
}
}
부트스트랩 공식 사이트: https://getbootstrap.com
📌참고
/resources/static 폴더에 HTML을 넣어두면, 실제 서비스에서도 공개됨
서비스를 운영한다면 지금처럼 공개할 필요없는 HTML을 두는 것은 주의 필요
public BasicItemController(ItemRepository itemRepository) {
this.itemRepository = itemRepository;
}
@PostConstruct : 해당 빈의 의존관계가 모두 주입되고 나면 초기화 용도로 호출
@PostConstruct
public void init() {
itemRepository.save(new Item("testA", 10000, 10));
itemRepository.save(new Item("testB", 20000, 20));
}
<html xmlns:th="http://www.thymeleaf.org">
th:href="@{/css/bootstrap.min.css}"
th:href="@{/css/bootstrap.min.css}"
타임리프에서 문자와 표현식 등은 분리되어 있기 때문에 더해서 사용해야하지만, 리터럴 대체 문법을 사용하면 더하기 없이 편리하게 사용할 수 있다
location.href='/basic/items/add'
th:onclick="'location.href=' + '\'' + @{/basic/items/add} + '\''"
th:onclick="|location.href='@{/basic/items/add}'|"
<tr th:each="item : ${items}">
<td th:text="${item.price}">10000</td>
-<td th:text="${item.price}">10000</td>
th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}"
th:href="@{|/basic/items/${item.id}|}"
📌참고
타임리프는 순수 HTML 파일을 웹 브라우저에서 열어도 내용을 확인할 수 있고, 서버를 통해 뷰 템플릿을거치면 동적으로 변경된 결과를 확인할 수 있음
순수 HTML을 그대로 유지하면서 뷰 템플릿도 사용할 수 있는 타임리프의 특징을 네츄럴 템플릿(natural templates)이라 한다.
PathVariable 로 넘어온 상품ID로 상품을 조회하고, 모델에 담고 뷰 템플릿을 호출
@GetMapping("/{itemId}")
public String item(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/item";
}
단순히 뷰 템플릿만 호출
@GetMapping("/add")
public String addForm() {
return "basic/addForm";
}
상품 등록 폼의 URL과 실제 상품 등록을 처리하는 URL을 똑같이 맞추고, HTTP 메서드로 두 기능을 구분
상품 등록 폼 : GET /basic/items/add
상품 등록 처리 : POST /basic/items/add
content-type: application/x-www-form-urlencoded
@PostMapping("/add")
public String addItemV1(@RequestParam String itemName,
@RequestParam int price,
@RequestParam Integer quantity,
Model model) {
Item item = new Item();
item.setItemName(itemName);
item.setPrice(price);
item.setQuantity(quantity);
itemRepository.save(item);
// model.addAttribute("item", item);
return "basic/item";
}
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item, Model model) {
itemRepository.save(item);
model.addAttribute("item", item); //자동 추가, 생략 가능
return "basic/item";
}
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item) {
itemRepository.save(item);
return "basic/item";
}
@PostMapping("/add")
public String addItemV4(Item item) {
itemRepository.save(item);
return "basic/item";
}
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/editForm";
}
상품 등록 폼의 URL과 실제 상품 등록을 처리하는 URL을 똑같이 맞추고, HTTP 메서드로 두 기능을 구분
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
itemRepository.update(itemId, item);
return "redirect:/basic/items/{itemId}";
}
📌참고
• HTML Form 전송은 PUT, PATCH를 지원하지 않음X
• GET, POST만 사용 가능
• PUT, PATCH는 HTTP API 전송시에 사용
웹 브라우저의 새로 고침은 마지막에 서버에 전송한 데이터를 다시 전송
상품 등록 폼에서 데이터를 입력하고 저장을 선택하면 POST /add + 상품 데이터를 서버로 전송, 이 상태에서 새로 고침을 또 선택하면 마지막에 전송한 POST /add + 상품 데이터 서버로 전송한다
상품 저장 후에 뷰 템플릿으로 이동하는 것이 아니라, 상품 상세 화면으로 리다이렉트를 호출
웹 브라우저는 리다이렉트의 영향으로 상품 저장 후에 실제 상품 상세 화면으로 다시 이동
마지막에 호출한 내용이 상품 상세 화면인 GET /items/{id}가 호출 됨
이런 문제 해결 방식을 PRG Post/Redirect/Get라 한다
📌아래처럼 URL에 변수를 더해서 사용하는 것은 URL 인코딩이 안되기 때문에 위험하므로 RedirectAttributes를 사용해야한다
/**
* PRG - Post/Redirect/Get
*/
@PostMapping("/add")
public String addItemV5(Item item) {
itemRepository.save(item);
return "redirect:/basic/items/" + item.getId();
}
@PostMapping("/add")
public String addItemV6(Item item, RedirectAttributes redirectAttributes) {
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/items/{itemId}";
}
<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>
드디어 들어본 MVC패턴 1편 역시 생각했던것 처럼 흥미롭고 유익했다.
처음에 하나씩 알려주는데, 이 정보들이 조각조각 같아서 솔직히 이해하지 못했다.ㅎㅎ
엥... 이제 뭐지?😵💫 이 상태로 일단! 듣는데 의의를 두자고 생각하면서 그냥 들었다.
그런데 어느 순간부터 각 정보가 하나씩 맞춰지고 전체 그림이 완성되어가는데 정말 놀라웠다.🌊💫
아직은 완전히 이해한다고 못 하지만 그래도 전반적인 흐름을 파악하는 데에는 성공한 것 같아 기뻤다.
이제 스프링 MVC 2편 백엔드 웹 개발 활용 기술을 공부하면, 지금보다 더 익숙하게 이해하면서 활용할 수 있지 않을까 기대중이다.