이 코드에 대해 조금 설명을 하자면, 어떤 상품 등록을 하는 폼이 있고 그 폼에서 상품 등록 버튼을 클릭하면 호출되는 @PostMapping이다. 그럼 상품 등록을 폼으로 한다고 하면 폼으로부터 전달되는 데이터가 있을 것인데 그 데이터가 저 @ModelAttribute로 담기게 된다.
그리고 타입은 Item 이라는 클래스인데 다음과 같이 생겼다.
Item
package hello.itemservice.domain.item;
import lombok.Data;
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
그래서 폼으로부터 들어오는 데이터가 이 클래스의 필드로 다 만족을 하면 스프링 MVC는 자동으로 폼으로 들어오는 데이터를 저렇게 바인딩 할 수 있다. 그래서 정확히 저 코드는 사실 이렇게 생긴것이다.
@PostMapping("/add")
public String save(@RequestParam String itemName,
@RequestParam Integer price,
@RequestParam Integer quantity,
Model model) {
// @ModelAttribute가 대신 해주는 작업
Item item = new Item();
item.setItemName(itemName);
item.setPrice(price);
item.setQuantity(quantity);
model.addAttribute("item", item);
// @ModelAttribute가 대신 해주는 작업 끝
itemRepository.save(item);
return "basic/item";
}
"어? Item 객체를 만들어서 데이터를 넣어주는 것까진 알겠는데 model.addAttribute("item", item); 까지 해준다고?!" 그렇다. 저 @ModelAttribute는 Model 객체에 데이터를 담아주는 것까지 해준다. 그리고 그때 key값은 클래스의 앞글자만 소문자로 바꾼 형태가 된다. (Item → item)
Redirect
이번엔 리다이렉트를 하는 방법이다. 간단하다.
@PostMapping("/{itemId}/edit")
public String edit(@ModelAttribute Item item, @PathVariable Long itemId) {
itemRepository.update(itemId, item);
return "redirect:/basic/items/{itemId}";
}
저렇게 "redirect:/redirect할 경로" 를 입력하면 된다. 그리고 리다이렉트 할 경로에 PathVariable이 있는 경우 코드처럼 문자열로 {pathVariable}를 입력하면 된다. 그럼 스프링이 알아서 이 메서드의 @PathVariable에 있는 값을 매핑시켜준다.
당연히 이렇게도 가능하다. 오히려 @PathVariable을 받지 않는 메서드에서 itemId가 있어야 하는 경로로 리다이렉트 할 땐 아래 코드처럼만 해야한다.
이렇게 파라미터로 RedirectAttributes를 받으면 이 녀석을 사용할 수가 있는데, addAttribute()로 원하는 key/value를 넣으면 그 key를 가지고 {PathVariable}을 사용할 수가 있다.
그리고 "status"라는 키도 있는데 이렇게 addAttribute()로 key/value를 저장하고 PathVariable로 사용하지 않는건 쿼리 파라미터로 들어간다. 그리고 이 키는 저장이 잘 됐다면 리다이렉트된 화면에서 뭔가 잘 저장됐다는 표시를 보여주고 싶어서 플래그를 사용했다고 생각하면 된다. 그리고 그 플래그를 Thymeleaf랑 같이 사용할 때 이렇게 param이라는 키로 받을수가 있다.
이런 방식이 훨씬 더 깔끔하고 인코딩도 다 해주기 때문에 더 좋은 접근방법이다. 이렇게 사용하자.
PRG - Post/Redirect/Get
이건 실무에서도 자주 사용되는 방식인데, 폼을 통해 POST 요청을 하고 보여지는 화면에서 사용자가 새로 고침을 누르면 POST 요청이 계속 들어간다. 그런 경우에 POST 요청이 계속 들어오면 만약 그게 상품 저장 기능이었다면 새로 고침한만큼 상품 저장이 되는 문제가 발생한다. 그 것을 방지하기 위해 폼을 통해 POST 요청을 처리하는 컨트롤러에서는 그 요청의 반환으로 Redirect를 해서 GET으로 최종 목적지를 변경해줘야 한다.
그래서 위 코드도 POST로 요청이 들어온 상품 저장 기능에 반환으로 리다이렉트를 통해 저장된 상품의 상세 목록으로 페이지를 이동시킨다. 그래야 사용자는 저장한 후 보여지는 화면에서 새로고침을 눌러도 POST 요청이 계속 발생하지 않는다. 그러니까 새로고침은 가장 마지막에 한 행위를 다시 하는것이다. 그래서 새로고침을 누르더라도 POST 요청이 다시 일어나지 않도록 리다이렉트로 마지막에 요청한 행위는 그저 상품 상세 화면을 보고 있는 GET 요청으로 바꿔줘야 한다.
이제 Spring MVC는 어떤걸 편리하게 해주고 어떤 효율성이 있는지 하나씩 파악해보자. 우선 그 전에 Welcome 페이지가 하나 있으면 편리할 거 같아서 Welcome 페이지를 만들자. 근데! Welcome 페이지를 만들기 전에 프로젝트가 있어야 한다. 스프링 프로젝트를 만들자.
여기로 가서 스프링 프로젝트를 만들면 된다. Dependencies는 Lombok, Spring Web, Thymeleaf 세 가지를 선택하자.
참고로, src/main/resources/static/index.html 경로에 있는 index.html 파일이 스프링 웹의 기본 Welcome 페이지가 된다.
src/main/resources/static/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<ul>
<li>로그 출력
<ul>
<li><a href="/log-test">로그 테스트</a></li>
</ul>
</li>
<!-- -->
<li>요청 매핑
<ul>
<li><a href="/hello-basic">hello-basic</a></li>
<li><a href="/mapping-get-v1">HTTP 메서드 매핑</a></li>
<li><a href="/mapping-get-v2">HTTP 메서드 매핑 축약</a></li>
<li><a href="/mapping/userA">경로 변수</a></li>
<li><a href="/mapping/users/userA/orders/100">경로 변수 다중</a></li>
<li><a href="/mapping-param?mode=debug">특정 파라미터 조건 매핑</a></li>
<li><a href="/mapping-header">특정 헤더 조건 매핑(POST MAN 필요)</a></li>
<li><a href="/mapping-consume">미디어 타입 조건 매핑 Content-Type(POST MAN 필요)</a></li>
<li><a href="/mapping-produce">미디어 타입 조건 매핑 Accept(POST MAN 필요)</a></li>
</ul>
</li>
<li>요청 매핑 - API 예시
<ul>
<li>POST MAN 필요</li>
</ul>
</li>
<li>HTTP 요청 기본
<ul>
<li><a href="/headers">기본, 헤더 조회</a></li>
</ul>
</li>
<li>HTTP 요청 파라미터
<ul>
<li><a href="/request-param-v1?username=hello&age=20">요청 파라미터 v1</a></li>
<li><a href="/request-param-v2?username=hello&age=20">요청 파라미터 v2</a></li>
<li><a href="/request-param-v3?username=hello&age=20">요청 파라미터 v3</a></li>
<li><a href="/request-param-v4?username=hello&age=20">요청 파라미터 v4</a></li>
<li><a href="/request-param-required?username=hello&age=20">요청 파라미터 필수</a></li>
<li><a href="/request-param-default?username=hello&age=20">요청 파라미터 기본 값</a></li>
<li><a href="/request-param-map?username=hello&age=20">요청 파라미터 MAP</a></li>
<li><a href="/model-attribute-v1?username=hello&age=20">요청 파라미터 @ModelAttribute v1</a></li>
<li><a href="/model-attribute-v2?username=hello&age=20">요청 파라미터 @ModelAttribute v2</a></li>
</ul>
</li>
<li>HTTP 요청 메시지
<ul>
<li>POST MAN</li>
</ul>
</li>
<li>HTTP 응답 - 정적 리소스, 뷰 템플릿
<ul>
<li><a href="/basic/hello-form.html">정적 리소스</a></li>
<li><a href="/response-view-v1">뷰 템플릿 v1</a></li>
<li><a href="/response-view-v2">뷰 템플릿 v2</a></li>
</ul>
</li>
<li>HTTP 응답 - HTTP API, 메시지 바디에 직접 입력
<ul>
<li><a href="/response-body-string-v1">HTTP API String v1</a></li>
<li><a href="/response-body-string-v2">HTTP API String v2</a></li>
<li><a href="/response-body-string-v3">HTTP API String v3</a></li>
<li><a href="/response-body-json-v1">HTTP API Json v1</a></li>
<li><a href="/response-body-json-v2">HTTP API Json v2</a></li>
</ul>
</li>
</ul>
</body>
</html>
이렇게 파일 하나를 만들어 두고 서버를 재실행한 후 `localhost:8080`으로 접속하면 이 페이지가 보일것이다.
이제 요청 매핑부터 하나하나 살펴보자.
요청 매핑
우선 아주 간단한 요청 매핑 하나를 만들어보자.
MappingController
package net.cwchoiit.springmvc.requestmapping;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
public class MappingController {
@RequestMapping("/hello-basic")
public String hello() {
log.debug("hello!");
return "Hello Basic";
}
}
여기서 @RestController는 @Controller랑 무엇이 다르냐? @Controller는 메서드의 반환값이 String이면 해당 값이 뷰의 이름이 된다. 그래서 해당 이름으로 된 뷰를 찾아 뷰가 렌더링된다. 근데 @RestController를 사용하면 반환값으로 뷰를 찾는게 아니라 HTTP 메시지 바디에 바로 입력한다. 따라서 실행 결과로 "Hello Basic"이라는 메시지를 응답으로 받을 수가 있다. 이게 @ResponseBody와 관련이 있는데 뒤에 더 자세히 설명한다.
@RequestMapping("/hello-basic")은 URL이 `/hello-basic`으로 요청이 들어오면 이 메서드가 실행되도록 한다.
참고로, `/hello-basic`과 `/hello-basic/`은 다른 URL이다. 그러므로 당연히 스프링도 서로 다르게 인식한다.
그리고 이렇게 @RequestMapping으로만 해놓으면 GET, POST, PUT, DELETE 다 가능하다. 그래서 이를 딱 지정하기 위해 다음과 같은 애노테이션을 사용할 수 있다.
@GetMapping("/mapping-get-v2")
public String mappingGetV2() {
log.debug("mappingGetV2!");
return "ok";
}
@GetMapping 애노테이션을 사용하면, GET Method만 허용한다.
이렇게 사용하면 되는데 그리고 99%는 이렇게만 사용할텐데 아래같이 사용해도 가능하긴 하다.
URL 형식에 `{userId}` 이렇게 중괄호가 있으면 이게 PathVariable이다. 즉, URL로부터 특정 값을 userId라는 키로 받아온다는 의미가 된다. 그래서 만약 `/mapping/userA` 이렇게 요청했다고 하면 userId는 userA가 된다. 그리고 파라미터 이름이 PathVariable과 같다면 다음과 같이 더 축약할 수 있다.
위 코드를 보면 파라미터에서 굉장히 이것 저것 많이 받을 수 있게 되어 있다. HttpServletRequest, HttpServletResponse, HttpMethod, Locale, @RequestHeader, @RequestHeader("host"), @CookieValue까지.
@RequestHeader로 MultiValueMap으로 가져오는 경우는 헤더 정보 전체를 가져오는 것이다. 근데 왜 MultiValueMap이냐? 그나저나 MultiValueMap은 뭘까? 이건 원래 Map은 키가 유일무이 해야 한다. 근데 헤더는 같은키로 여러 데이터가 들어올 수 있다. 그래서 같은 키라고 할지라도 그 값들 모두 다 가져올 수 있는 방법인 MultiValueMap을 사용한다.
참고로, @Controller의 사용 가능한 파라미터 목록은 아래 공식 매뉴얼에서 모두 확인 가능하다.
HTTP 요청 파라미터 - 쿼리 파라미터, HTML Form
이번엔 HTTP 요청 시 전송하는 쿼리 파라미터, HTML Form으로 전송하는 데이터를 어떻게 받는지 알아볼 차례다.
우선 가장 기본적인 서블릿에서 어떻게 받는지 다시 상기시키자.
RequestParamController
package net.cwchoiit.springmvc.request;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
@Slf4j
@RestController
public class RequestParamController {
@RequestMapping("/request-param-v1")
public void requestParamV1(HttpServletRequest request, HttpServletResponse response) throws IOException {
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
log.info("username = {}, age = {}", username, age);
response.getWriter().write("ok");
}
}
서블릿은 이렇게 파라미터로 HttpServletRequest, HttpServletResponse 객체를 받을 수 있고, 그 객체를 통해 데이터를 받아올 수 있다. 테스트 해보면 아주 잘 받아온다.
근데 이렇게 전송할 때 전달하는 데이터의 생김새가 HTML Form이나 쿼리 파라미터나 동일하다.
`username=cwchoi&age=10`둘 다 이렇게 들어오기 때문에 동일한 방식으로 데이터를 받을 수가 있다.
실제로 그런지 HTML Form을 만들어보자. 간단하게 `src/main/resources/static`이 경로에 만들자. 이 경로는 스프링 부트는 기본이 다 외부로 내보내게 되어 있는 파일들만 모아놓은 곳이기 때문에 그냥 여기에 파일을 만들면 기본적으로 외부에서 접근이 가능하다.
원하는 쿼리 파라미터 또는 HTML Form 데이터의 키를 @RequestParam("") 안에 적는다. 그럼 그 값을 찾아서 변수에 넣어준다.
근데, 만약 변수명과 쿼리 파라미터의 키가 같은 이름으로 된다면 생략 가능하다. 다음 코드처럼.
@ResponseBody
@RequestMapping("/request-param-v3")
public String requestParamV3(@RequestParam String username,
@RequestParam int age) {
log.info("V3 username = {}, age = {}", username, age);
return "ok";
}
둘 다 아주 잘 받아온다. 그나저나, 저 @ResponseBody 애노테이션은 뭐냐면 만약 클래스 레벨에 @RestController가 아니라 @Controller라면 메서드가 String 타입으로 반환하는 메서드일때 기본적으로 뷰의 이름으로 판단하고 뷰를 찾는다.
그래서 스프링한테 알려주는 것이다. "이건 뷰의 이름이 아니라 너가 응답 메시지로 반환할 값이야!"라고.
근데 귀찮게 애노테이션을 하나 더 붙이기가 싫다면 그냥 @RestController를 사용하면 된다.
다시 돌아와서, 사람의 욕심은 끝이 없어서 @RequestParam 마저도 생략할 수 있다. 다음 코드처럼.
@ResponseBody
@RequestMapping("/request-param-v4")
public String requestParamV4(String username, int age) {
log.info("V4 username = {}, age = {}", username, age);
return "ok";
}
물론 이 경우도 당연히 파라미터 이름과 쿼리 파라미터의 키가 동일한 이름이어야 한다.
근데, 나는 @RequestParam 애노테이션을 붙이는 걸 선호한다. 그래야 한 눈에 바로 파악이 쉽기 때문에.
그런데 이 쿼리 파라미터는 기본적으로 무조건 있어야 동작한다. 요청하는 쪽에서 보내지 않으면 에러 화면이 보일것이다. 근데 이 필수라는 옵션을 변경할 수도 있다.
@ResponseBody
@RequestMapping("/request-param-required")
public String requestParamRequired(@RequestParam(required = false) String username,
@RequestParam(required = false) int age) {
log.info("Required username = {}, age = {}", username, age);
return "ok";
}
이 코드처럼 "required = false"를 주게 되면 이 쿼리 파라미터를 요청 시 던지지 않아도 동작한다. 근데 주의할 점이 있다. `age`와 같이 Primitive Type인 경우 `null`이라는 값을 받을 수 없기 때문에 쿼리 파라미터를 주지 않으면 에러가 발생한다. 그래서 `age`가 필수값이 아니고 싶다면 Integer로 받아야 한다. 아래처럼.
뭐 파라미터 하나하나 다 나열하기 귀찮다하면 이렇게 Map으로 받아오면 된다. 근데 난 개인적으로 좋아하지 않는다.
그리고 더 나아가서 MultiValueMap도 사용할 수 있다. 만약, 쿼리 파라미터의 키가 같은데 값이 여러개가 들어갈 수 있는 경우 MultiValueMap을 사용해야 한다. 이런 URL : `/request-param-map?userId=1&userId=2` 근데 이런 경우는 거의 없다.
이런식으로 요청 시 쿼리 파라미터와 HTML Form 데이터를 간단하게 받아올 수 있다. (아직 바디에 JSON으로 데이터 보내는 경우는 어떤식으로 처리하는지 작성 안했다) 그럼 이렇게 받아온 데이터를 실제 업무에서는 당연히 객체로 변환하고 사용할 것이다 일반적으로. 그런 경우에 어떻게 하면 될까? 스프링이 이것도 간단하게 도와준다.
HTTP 요청 파라미터 - @ModelAttribute
실제 개발을 하면 요청 파라미터를 받아서 필요한 객체를 만들고 그 객체에 값을 넣어주어야 한다. 우선 DTO를 먼저 만들어보고 말해보자.
HelloData
package net.cwchoiit.springmvc.basic;
import lombok.*;
@Data
@AllArgsConstructor
public class HelloData {
private String username;
private int age;
}
가장 단순하고 원초적인 방법은 이렇게 사용할 것이다.
@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@RequestParam String username, @RequestParam int age) {
HelloData helloData = new HelloData(username, age);
log.info("helloData = {}", helloData);
return "ok";
}
그럼 헷갈릴 수 있다. 어떤게 @RequestParam을 생략한거고 어떤게 @ModelAttribute를 생략한 것인지. 스프링은 Primitive Type, Primitive Type의 Wrapper(String, Integer, ..) 클래스를 파라미터로 받을땐 @RequestParam을 생략한다고 간주한다. 그리고 직접 만든 객체처럼 HelloData이런 것들을 @ModelAttribute로 간주한다.
주의할 점은, 요청에서 파라미터를 받았을 때 파라미터가 username, age라면 이 프로퍼티의 Setter를 먼저 찾는다. 만약 Setter가 없다면 final필드로 된 전체 파라미터를 받는 생성자를 찾는다. 이 두가지가 다 없으면 바인딩 되지 않는다.
또한, 위에서 말한것처럼 HelloData와 같은 직접 만든 객체를 @ModelAttribute로 간주한다 했는데 여기서 Argument Resolver는 제외이다. Argument Resolver라는 건 예를 들어 HttpServletRequest, HttpServletResponse 이런것들을 말한다.
지금까지는 요청 파라미터를 받는 방법에 대해 알아보았다. 이제 파라미터가 아니라 메시지(요청 바디에 넣는 데이터)는 어떻게 처리하는지 알아보자.
HTTP 요청 메시지
메시지는 크게 세 가지로 받을 수 있다.
단순 텍스트
JSON
XML
XML은 요즘은 거의 사용하는 추세가 아니기 때문에 따로 다루지 않는다. 그럼 요청 시 바디에 단순 텍스트 또는 JSON을 던져서 보낼 때 어떻게 받는지 하나씩 알아보자.
HttpServletRequest, HttpServletResponse 객체를 파라미터로 받아서, InputStream을 얻어온다. 그리고 그 안에 있는 데이터를 String으로 받아오면 끝난다. 근데 스프링이 파라미터로 받을 수 있는 것들 중에 InputStream과 Writer가 있다. 굳이 HttpServletRequest, HttpServletResponse 전체를 다 받을 필요 없이 딱 필요한것만 받는 방법이 된다.
근데 당연히 여기서 끝날 스프링이 아니다. 그냥 이 과정 자체를 아예 자동화 해준다. 그래서 무엇을 받을 수 있냐? 다음과 같이 HttpEntity를 받을 수 있다.
@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) {
String body = httpEntity.getBody();
log.info("body: {}", body);
return new HttpEntity<>("ok");
}
내가 HttpEntity<String> 이라고 선언을 하면 자동으로 바디에 있는 값을 String으로 변환해서 넣어준다.
그리고 반환도 마찬가지로 HttpEntity<String>이라고 반환을 하면 응답 메시지에 내가 넣을 데이터를 문자열로 받아 반환해준다.
그리고 이 HttpEntity를 상속받는 좀 더 구체적인 객체가 있다. 바로 RequestEntity, ResponseEntity
@PostMapping("/request-body-string-v3-2")
public ResponseEntity<String> requestBodyStringV3_2(RequestEntity<String> requestEntity) {
String body = requestEntity.getBody();
log.info("body: {}", body);
return new ResponseEntity<>("ok", HttpStatus.CREATED);
}
ResponseEntity는 이렇게 상태 코드도 넣어줄 수 있다.
그러나, 애노테이션 기반이 대세로 자리잡은 지금 당연히 이것도 애노테이션이 있다.
@PostMapping("/request-body-string-v4")
public ResponseEntity<String> requestBodyStringV4(@RequestBody String messageBody) {
log.info("body: {}", messageBody);
return new ResponseEntity<>("ok", HttpStatus.CREATED);
}
이렇게 @RequestBody라고 해주면 끝난다. 알아서 요청 바디의 데이터를 내가 선언한 타입(String)으로 변환해서 넣어준다.
이 방식이 가장 많이 사용되고 실제로 편리한 방식이다.
참고로 @RequestBody는 @RequestParam, @ModelAttribute와는 아무런 관련이 없다. @RequestParam, @ModelAttribute는 요청 파라미터를 받아오는 방법들 중 하나이다. 반면, @RequestBody 요청 바디를 받아오는 방법이다. 절대 구분!
이렇게 요청 바디의 단순 메시지를 받아오는 방법을 알아봤다. 거의 95%는 JSON으로 데이터를 주고 받는다. 그래서 이제 알아볼 JSON을 받아오는 방법에 집중해보자!
우선 단순 텍스트를 어떤 특정 객체로 변환하기 위해 사용되는 라이브러리인 ObjectMapper를 새로 만들자. 당연히 이 ObjectMapper는 실제 개발에서는 여러 설정이 곁들어진 상태로 빈으로 등록해서 여기저기서 주입되는 방식으로 사용될테지만 지금은 그런 경우는 아니니까.
그리고 서블릿에서 했던것처럼 HttpServletRequest, HttpServletResponse 객체를 받아서 InputStream을 가져와서 스트링으로 변환한다. 그리고 변환된 문자열을 ObjectMapper를 통해 객체로 변환한다.
그 다음은 @RequestBody 애노테이션을 사용해서 좀 더 편리하게 문자열로 가져오는 방법이다.
이게 이제 가장 최신 방식이고 편리한 방식이다. 그리고 주의할점은 이 @RequestBody를 통해서 요청 바디의 데이터를 특정 객체로 변환할 때는 @RequestBody를 생략할 수 없다! 왜냐하면 이미 @ModelAttribute에서 생략 가능하기 때문에 @RequestBody를 생략해버리면 @ModelAttribute처럼 동작하게 된다. 즉, 파라미터에서 데이터를 찾게 된다는 말이다. 그래서 안된다!
참고로, 이렇게 @RequestBody 애노테이션으로 JSON을 특정 객체로 변환해 주려면 반드시 요청 헤더에 Content-Type이 `application/json`이어야 한다. 이래야만 이후에 다룰 HTTP 메시지 컨버터가 "아 이 값이 지금 JSON이고 이런 객체로 변환하길 원하는구나!?"로 이해하고 바꿔주기 때문이다.
그리고 HttpEntity로도 받을 수 있다. 단순 텍스트가 가능했듯 이 특정 객체도 HttpEntity를 사용해 받아올 수 있다.
@ResponseBody
@PostMapping("/request-body-json-v2-1")
public String requestBodyJsonV2_1(HttpEntity<HelloData> httpEntity) {
HelloData body = httpEntity.getBody();
log.info("helloData: {}", body);
return "ok";
}
근데 그러려면 이렇게 getBody()를 호출해야 하는 번거로움 때문에 거의 사용하지 않는다.
그래서 요청 바디에 JSON을 태울 때 어떻게 받아오는지도 알아보았다. 결국 단순 텍스트든 JSON이든 @RequestBody를 통해 깔끔하고 쉽게 받아올 수가 있다. 이제 요청 관련 처리를 쭉 알아봤으니 응답 관련 처리를 쭉 알아보자!
HTTP 응답 - 정적 리소스, 뷰 템플릿
스프링에서 응답 데이터를 만드는 방법은 크게 세가지이다.
정적 리소스 예) 웹 브라우저에 정적인 HTML, CSS, JS를 제공할 때는 정적 리소스를 사용한다.
뷰 템플릿 예) 웹 브라우저에 동적인 HTML을 제공할 때는 뷰 템플릿을 사용한다.
HTTP 메시지 예) HTTP API를 제공하는 경우에는 HTML이 아니라 데이터를 전달해야 하므로, HTTP 메시지 바디에 JSON과 같은 형식으로 데이터를 실어 보낸다
물론 실제로 이 파일을 열었을 때 저 두줄이 보이는게 아니라 자동으로 등록해준다는 의미이다. 그리고 저 값을 변경하고 싶을때만 이 파일을 수정하면 된다. 여기서 `classpath`는 `src/main/resources`를 말한다.
아마 Thymeleaf와 스프링 부트를 사용하는 것은 이후에도 해 볼 것이기 때문에 이 정도로만 하고 가장 중요한(?) HTTP API에 대한 응답 처리를 알아보자!
HTTP 응답 - HTTP API
HTTP API를 제공하는 경우에는 HTML이 아니라 데이터를 전달해야 하므로, HTTP 메시지 바디에 JSON과 같은 형식으로 데이터를 실어 보낸다.
참고로, HTML이나 뷰 템플릿을 사용해도 HTTP 응답 메시지 바디에 HTML 데이터가 담겨서 전달된다. 여기서 설명하는 내용은 정적 리소스나 뷰 템플릿을 거치지 않고, 직접 HTTP 응답 메시지를 전달하는 경우를 말한다.
말로 장황하게 설명할 것 없이 바로 코드로 들어가자.
ResponseBodyController
package net.cwchoiit.springmvc.response;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import net.cwchoiit.springmvc.basic.HelloData;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import java.io.IOException;
@Slf4j
@Controller
public class ResponseBodyController {
@GetMapping("/response-body-string-v1")
public void responseBodyStringV1(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.getWriter().write("ok");
}
@GetMapping("/response-body-string-v2")
public ResponseEntity<String> responseBodyStringV2() {
return new ResponseEntity<>("ok", HttpStatus.OK);
}
@ResponseBody
@GetMapping("/response-body-string-v3")
public String responseBodyStringV3() {
return "ok";
}
@GetMapping("/response-body-json-v1")
public ResponseEntity<HelloData> responseBodyJsonV1() {
HelloData helloData = new HelloData();
helloData.setUsername("A");
helloData.setAge(30);
return new ResponseEntity<>(helloData, HttpStatus.OK);
}
@ResponseStatus(HttpStatus.OK)
@ResponseBody
@GetMapping("/response-body-json-v2")
public HelloData responseBodyJsonV2() {
HelloData helloData = new HelloData();
helloData.setUsername("B");
helloData.setAge(30);
return helloData;
}
}
단순 스트링의 경우
첫번째 메서드의 경우, 간단하게 서블릿 방식을 사용해서 파라미터로 들어온 HttpServletResponse 객체의 Writer를 사용해서 응답 메시지를 보낸다.
두번째 메서드의 경우, ResponseEntity를 반환타입으로 주면 알아서 HTTP 응답 바디에 메시지를 넣어 보내고 상태 코드도 설정할 수 있다.
세번째 메서드의 경우, 그냥 단순 String을 반환하는 경우 뷰 논리 이름이 기본이지만 @ResponseBody 애노테이션이 붙었기 때문에 반환값 자체가 응답 바디에 넣어지는 값이 된다.
JSON의 경우
네번째 메서드의 경우, 반환값으로 ResponseEntity<HelloData>로 지정했다. 이 말은 응답 메시지에 HelloData 타입의 데이터를 실어 내보내겠다는 의미가 되고 상태 코드도 지정할 수 있게 된다.
다섯번째 메서드의 경우, 반환값이 HelloData이다. 이 경우도 이 HelloData 객체가 응답 메시지에 들어간다. 그러나 이 경우 상태 코드를 지정하지 못하기 때문에 @ResponseStatus(HttpStatus.OK) 애노테이션을 사용해서 상태코드를 추가해준다.
근데 이렇게 계속 @ResponseBody 애노테이션을 붙이기가 상당히 귀찮다. 그리고 실제로 개발을 해보면 이렇게 사용하지도 않는다.
이 @ResponseBody + @Controller가 합쳐진 애노테이션이 바로 @RestController이다.
그래서 @RestController가 클래스 레벨에 붙고, 단순 String을 반환하는 메서드가 있다면 이 메서드는 무조건 응답 바디에 반환값을 넣는다는 의미다. 뷰를 찾는게 아니라.
ResponseBodyController - @RestController 사용
package net.cwchoiit.springmvc.response;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import net.cwchoiit.springmvc.basic.HelloData;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
@Slf4j
@RestController
public class ResponseBodyController {
@GetMapping("/response-body-string-v1")
public void responseBodyStringV1(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.getWriter().write("ok");
}
@GetMapping("/response-body-string-v2")
public ResponseEntity<String> responseBodyStringV2() {
return new ResponseEntity<>("ok", HttpStatus.OK);
}
@GetMapping("/response-body-string-v3")
public String responseBodyStringV3() {
return "ok";
}
@GetMapping("/response-body-json-v1")
public ResponseEntity<HelloData> responseBodyJsonV1() {
HelloData helloData = new HelloData();
helloData.setUsername("A");
helloData.setAge(30);
return new ResponseEntity<>(helloData, HttpStatus.OK);
}
@ResponseStatus(HttpStatus.OK)
@GetMapping("/response-body-json-v2")
public HelloData responseBodyJsonV2() {
HelloData helloData = new HelloData();
helloData.setUsername("B");
helloData.setAge(30);
return helloData;
}
}
HTTP 메시지 컨버터
이제 HTML을 생성해서 응답하는게 아니라 HTTP API처럼 JSON 데이터를 HTTP 메시지 바디에서 직접 읽거나 쓰는 경우 그래서 그것을 반환할 때 HTTP 메시지 컨버터를 사용하면 편리하다. 원래는 어떻게 했나? 원래는 서블릿 방식으로 HttpServletResponse 객체를 받아와서 response.getWriter().write("OK"); 이런식으로 Writer를 가져왔었다. 이건 쓰기 방식이고 읽을땐? HttpServletRequest 객체를 받아와서 InputStream을 받아와서 데이터를 읽어왔다. 매우매우 불편하다.
스프링 부트는 다양한 메시지 컨버터를 제공하는데, 대상 클래스 타입과 미디어 타입 둘을 체크해서 사용여부를 결정한다. 만약 만족하지 않으면 다음 메시지 컨버터로 우선순위가 넘어간다.
몇가지 주요한 메시지 컨버터를 알아보자.
ByteArrayHttpMessageConverter: byte[]데이터를 처리한다.
클래스 타입: byte[], 미디어타입: */*
요청 예) @RequestBody byte[] data
응답 예) @ResponseBody, return byte[]; 쓰기 미디어 타입 application/octet-stream
StringHttpMessageConverter: String 문자로 데이터를 처리한다.
클래스 타입: String, 미디어타입: */*
요청 예) @RequestBody String data
응답 예) @ResponseBody, return "ok"; 쓰기 미디어 타입 text/plain
MappingJackson2HttpMessageConverter: JSON 관련 데이터 처리
클래스 타입: 객체 또는 HashMap, 미디어 타입: application/json
요청 예) @RequestBody HelloData data
응답 예) @ResponseBody, return data; 쓰기 미디어타입: application/json
그러니까, 쉽게 생각해서 이전에 요청 바디의 메시지를 가져올땐 HttpServletRequest 객체를 파라미터로 받아서 객체의 InputStream을 가져왔다. InputStream으로부터 데이터를 꺼내오는 작업을 했었는데 이 작업의 자동화를 해준 애노테이션이 @RequestBody 애노테이션이었다. 그럼 이 @RequestBody 애노테이션이 어떻게 이 작업을 해주는 것인가?에 대한 비밀이 이 HttpMessageConverter가 되는것이다.
첫번째로, ByteArrayHttpMessageConverter에게 읽을 수 있는 데이터인지 물어본다. (canRead())
ByteArrayHttpMessageConverter 이 녀석은 클래스 타입이 byte[]여야 하고, 미디어 타입은 */*여야 한다. 클래스 타입이 HelloData이기 때문에 넘어가게 된다.
두번째로, StringHttpMessageConverter에게 읽을 수 있는 데이터인지 물어본다. (canRead())
StringHttpMessageConverter 이 녀석은 클래스 타입이 String 이어야 하고 미디어 타입은 */*여야 한다. 클래스 타입이 HelloData이기 때문에 넘어가게 된다.
세번째로, MappingJackson2HttpMessageConverter에게 읽을 수 있는 데이터인지 물어본다. (canRead())
MappingJackson2HttpMessageConverter 이 녀석은 클래스 타입이 객체 또는 HashMap 이어야 하고, 미디어 타입이 application/json이어야 한다. 우선, HelloData는 객체라서 조건을 만족하고, 요청 시 Content-Type을 application/json으로 보냈기 때문에 미디어타입도 만족한다. 그럼 이 MappingJackson2HttpMessageConverter 녀석이 이 데이터를 가지고 HelloData 객체의 각 프로퍼티에 데이터를 바인딩 해주게 된다. 이게 바로 HTTP 메시지 컨버터가 하는 일이다.
요청과 일치하게 응답도 동일하다. 만약 다음 코드가 있다고 했을 때,
@ResponseStatus(HttpStatus.OK)
@GetMapping("/response-body-json-v2")
public HelloData responseBodyJsonV2() {
HelloData helloData = new HelloData();
helloData.setUsername("B");
helloData.setAge(30);
return helloData;
}
반환값이 HelloData로 되어 있다. 그리고 이 메서드를 가진 클래스는 @RestController 애노테이션을 달았다. 그럼 메시지 컨버터가 동작하고, 클래스 타입이 HelloData라는 객체이고 미디어 타입은 요청 시 Accept를 application/json으로 던졌다고 가정하면 클래스 타입과 미디어 타입을 모두 만족하는 MappingJackson2HttpMessageConverter녀석이 동작하여 응답 시 알아서 잘 해주게 되는 것이다. 참고로 여기서 컨버터들에게 너 이거 쓸 수 있어? 라고 물어보는 메서드는 canWrite() 메서드가 되겠지!
MappingJackson2HttpMessageConverter 이 녀석 내부적으로 Writer를 가져와서 write()를 하겠지만 우리가 직접 하지 않아도 되니 얼마나 편한가?
그럼 HTTPMessageConverter가 뭐하는건지 이해를 했다. 그럼 스프링 MVC의 어디쯤에 존재하고 사용되는 것일까?
요청 매핑 핸들러 어댑터(RequestMappingHandlerAdapter) 구조
그래서 다시 위의 원초적인 질문으로 돌아와서 그렇다면 HTTP 메시지 컨버터는 스프링 MVC 어디쯤에서 사용되는 것일까?
이전에 봤던 이 그림을 다시 보자.
이 그림에서 HTTP 메시지 컨버터는 보이지 않는다. 그럼 어디에?
애노테이션 기반의 컨트롤러, 그러니까 @RequestMapping을 처리하는 핸들러 어댑터인 RequestMappingHandlerAdapter(요청 매핑 핸들러 어댑터)에 있다.
한번 이 핸들러 어댑터의 동작 방식을 다시 한번 상기해보자.
1. 외부로부터 요청이 들어온다.
2. 이 요청과 매핑된 핸들러(컨트롤러)를 찾는다.
3. 찾은 핸들러를 처리할 수 있는 핸들러 어댑터를 찾는다
4. 찾은 핸들러 어댑터는 RequestMappingHandlerAdapter가 된다. 이 핸들러 어댑터가 본인이 가지고 있는 handle()을 호출해서 실제로 핸들러가 실행된다.
5. 실행된 핸들러의 결과를 반환 받은 핸들러 어댑터는 뷰를 반환해야 하면 뷰 리졸버를 호출하고, 그대로 응답 메시지에 반환해야 하면 그 작업을 또 하게 된다.
저기서 4번이 중요하다. 생각해보면 컨트롤러에 엄청 많은 Arguments를 받을 수 있었다. 대표적인 예로,
HttpServletRequest, HttpServletResponse
@ModelAttribute
Model
@RequestParam
@RequestBody
등등
그럼 결국 이 arguments들에 실제 어떤 값이 담겨서 컨트롤러를 호출하는 handle()을 호출해야 한다. 그 값들이 채워지는 시점은 바로 4번이다. 그래서 다음 그림을 보자.
핸들러 어댑터가 필요한 Arguments들을 보고 이 Arguments들을 처리할 수 있는 ArgumentResolver를 호출한다.
호출해서 "너가 이 Argument 처리할 수 있어?"라고 물어보고 처리할 수 있는 녀석이 해당 Argument를 처리해서 실제 값을 담아주게 된다. 값을 그렇게 하나씩 차곡차곡 담아서 핸들러 어댑터가 모든 Arguments들이 완성이 되면 그제서야 핸들러를 호출하게 되는것이다.
참고로, ArgumentResolver의 정확한 명칭은 HandlerMethodArgumentResolver이고, 생김새는 이렇게 생겼다.
HandlerMethodArgumentResolver
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package org.springframework.web.method.support;
import org.springframework.core.MethodParameter;
import org.springframework.lang.Nullable;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
public interface HandlerMethodArgumentResolver {
boolean supportsParameter(MethodParameter parameter);
@Nullable
Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;
}
그래서 딱 봐도 supportsParameter()를 호출해서 이 Argument를 처리할 수 있는 구현체를 찾을 것이다. 참고로 구현체 겁나 많다..
그래서 찾았다면 resolveArgument를 호출해서 실제 값을 객체에 차곡차곡 담아준다.
그리고 이렇게 다 생성된 객체를 핸들러 어댑터가 받아서 실제 핸들러를 호출하게 되는것이다.
그리고 스프링은 이렇게 모든게 인터페이스 - 구현체 구조로 되어 있기 때문에 확장에 너무나 용이하다. OCP 원칙을 고수하고 있다는 의미이고 새로운 기술을 도입해도 클라이언트 코드는 변경에 영향을 받지 않는다. 그리고 그 말은! 내가 원하는 나만의 Argument를 받을 수 있게 나만의 ArgumentResolver를 만들수도 있다는 얘기다.
"아니 그래서 HTTP 메시지 컨버터는 어디 있는데요!?"
맞다. 아직도 그래서 이 HTTP 메시지 컨버터의 비밀을 밝혀지지 않았다. 이 메시지 컨버터가 사용되는 지점은 바로 ArgumentResolver가 사용한다. 당연히 그렇겠지? 왜냐하면 @RequestBody는 파라미터로 들어오니까 그 파라미터를 처리할 수 있는 ArgumentResolver가 있고 거기서 사용될것이다. 다음 그림을 보자.
저렇게 ArgumentResolver가 HTTP 메시지 컨버터를 사용한다. 그리고 핸들러가 결국 응답을 하게 되는데 그 응답도 또한 인터페이스 - 구현체 구조로 되어 있다. 그 때 인터페이스는 ReturnValueHandler라는 녀석인데 뷰를 다루는 핸들러가 아니라 응답 데이터를 HTTP 메시지에 입력해야 하는 핸들러는 이 인터페이스를 구현한 구현체를 핸들러 어댑터에게 응답한다. 그리고 이 ReturnValueHandler가 또 HTTP 메시지 컨버터를 사용한다. HTTP 메시지 컨버터는 요청과 응답 둘 다 사용된다고 말했던 바 있다.
좀 더 깊이 들어가보자.
ArgumentResolver는 요청의 경우, @RequestBody를 처리하는 ArgumentResolver가 있고 HttpEnttiy를 처리하는 ArgumentResolver가 있다. (파라미터로 @RequestBody, HttpEntity를 둘 다 받을 수 있었다. 기억해보자!) 그래서 둘 중에 어떤게 사용됐는지 확인하여 적절한 ArgumentResolver가 선택되면 거기서 HTTP 메시지 컨버터가 실행된다.
응답의 경우도 동일하게 @ResponseBody와 HttpEntity를 처리하는 ReturnValueHandler가 있다. 그리고 이 ReturnValueHandler 녀석이 HTTP 메시지 컨버터를 사용하는 것이다.
그래서 정말 신기하고 새로웠던 @RequestBody로 데이터를 자동으로 바인딩 해주고 @ResponseBody로 반환값을 자동으로 반환해주는 비밀의 열쇠인 HttpMessageConverter에 대해 알아봤다.
결론
여기까지 하면 스프링 MVC의 핵심 구조들은 다 이해해 본 것이다. 이젠 이걸 활용하고 사용하는 것만이 남았다.
스프링이 제공하는 컨트롤러는 애노테이션 기반으로 동작한다. 그래서 매우 유연하고 실용적이다.
@RequestMapping
이 애노테이션이 바로 스프링이 사용하는 애노테이션 기반 컨트롤러이다. 이 애노테이션을 기반으로 핸들러 매핑과 핸들러 어댑터가 존재한다. 핸들러 매핑과 핸들러 어댑터가 뭔지 모른다면 이전 포스팅을 꼭 읽고 오길 바란다. 핸들러 매핑을 통해 URL과 매핑된 컨트롤러를 찾고 그 컨트롤러를 처리할 수 있는 핸들러 어댑터를 찾아내는게 핸들러 어댑터이다. 그게 스프링은 굉장히 여러 형태의 구현체로 존재하는데 이 애노테이션 기반은 다음 두 개이다.
RequestMappingHandlerMapping
RequestMappingHandlerAdapter
가장 우선순위가 높은 핸들러 매핑과 핸들러 어댑터이다. 애노테이션의 이름을 따서 만든 이름이다.
지금까지 쭉 만들어왔던 스프링 MVC 구조를 이해하기 위해서 작업했던 것들을 스프링 MVC로 바꿔보자.
참고로, 이 글은 이전 포스팅을 의존하기 때문에 이전 포스팅을 읽지 않았다면 먼저 읽고 오는 것을 권장한다.
package org.example.servlet.web.springmvc.v1;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
@Controller
public class SpringMemberFormControllerV1 {
@RequestMapping("/springmvc/v1/members/new-form")
public ModelAndView process() {
return new ModelAndView("new-form");
}
}
클래스 레벨에 @Controller 애노테이션이 붙었다. 이건 뭘까? 스프링이 자동으로 스프링 빈으로 등록하게 해주는 애노테이션이다. 저 애노테이션 내부에 @Component 애노테이션이 있어서 컴포넌트 스캔의 대상이 된다. 또한, 스프링 MVC에서 애노테이션 기반 컨트롤러로 인식한다. 그래서 @Controller 하나만 있어도 스프링 빈으로 자동 등록해주고 스프링 MVC에서 애노테이션 기반 컨트롤러로 인식하게 된다. @RequestMapping은 요청 정보를 매핑한다. 해당 URL이 호출되면 이 메서드가 호출된다.
RequestMappingHandlerMapping은 스프링 빈 중에서 @RequestMapping 또는 @Controller가 클래스 레벨에 붙어 있는 경우에 매핑 정보로 인식한다. 그래서 다음과 같은 코드도 동일하게 동작한다.
package org.example.servlet.web.springmvc.v1;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
@Component
@RequestMapping
public class SpringMemberFormControllerV1 {
@RequestMapping("/springmvc/v1/members/new-form")
public ModelAndView process() {
return new ModelAndView("new-form");
}
}
또는 컴포넌트 자동 스캔을 사용하지 않고 직접 빈으로 등록한다면 이런 코드도 가능하다.
package org.example.servlet.web.springmvc.v1;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
@RequestMapping
public class SpringMemberFormControllerV1 {
@RequestMapping("/springmvc/v1/members/new-form")
public ModelAndView process() {
return new ModelAndView("new-form");
}
}
근데 굳이 그럴 필요없이 @Controller를 사용한다면 모든게 충족된다.
주의! 스프링 부트 3.0이상부터는 클래스 레벨에 @RequestMapping이 있어도 스프링 컨트롤러로 인식하지 않는다. 오직 @Controller가 있어야 스프링 컨트롤러로 인식한다. 참고로 @RestController는 해당 애노테이션 내부에 @Controller가 있으므로 인식이 된다. 따라서 위에 설명한 두개의 코드는 이제 스프링 컨트롤러로 인식되지 않는다. RequestMappingHandlerMapping이 이제 @Controller만 인식을 한다.
SpringMemberListControllerV1
package org.example.servlet.web.springmvc.v1;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
import java.util.List;
@Controller
public class SpringMemberListControllerV1 {
private final MemberRepository memberRepository = MemberRepository.getInstance();
@RequestMapping("/springmvc/v1/members")
public ModelAndView process() {
List<Member> members = memberRepository.findAll();
ModelAndView mv = new ModelAndView("members");
mv.addObject("members", members);
return mv;
}
}
모두 반환타입이 ModelAndView 타입이다. 이는 이 컨트롤러는 어떤 특정 뷰를 보여줄것을 의미한다. 그리고 반드시 그 뷰의 이름을 넣어주게 되어 있고 필요하다면 해당 뷰에서 사용할 데이터를 ModelAndView의 Model에 담는다. 담을땐 mv.addObject("key", "value")로 넣으면 된다. 그리고 이 ModelAndView를 반환하면 끝이다.
근데, 지금 코드는 불편한 부분이 있다. 이 세개의 컨트롤러가 모두 나뉘어져 있는것이 상당히 불편하고 번잡하다. 하나로 합칠 수 있다. 그것을 해보자. 그리고 사실 이전 포스팅에서 배웠지만 단순 문자열만 반환해도 뷰를 보여줄 수 있었다. 그것 또한 차근차근 알아보자.
SpringMemberControllerV2
package org.example.servlet.web.springmvc.v2;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
import java.util.List;
@Controller
public class SpringMemberControllerV2 {
private final MemberRepository memberRepository = MemberRepository.getInstance();
@RequestMapping("/springmvc/v2/members/new-form")
public ModelAndView newForm() {
return new ModelAndView("new-form");
}
@RequestMapping("/springmvc/v2/members")
public ModelAndView members() {
List<Member> members = memberRepository.findAll();
ModelAndView mv = new ModelAndView("members");
mv.addObject("members", members);
return mv;
}
@RequestMapping("/springmvc/v2/members/save")
public ModelAndView save(HttpServletRequest request, HttpServletResponse response) throws Exception {
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
Member member = new Member(username, age);
memberRepository.save(member);
ModelAndView mv = new ModelAndView("save-result");
mv.addObject("member", member);
return mv;
}
}
@RequestMapping 애노테이션이 메서드 레벨에 붙기 때문에 연관성 있는 메서드들끼리 묶어서 한 컨트롤러에서 모두 처리가 가능하다.
파일3개가 파일1개가 돼버리니 훨씬 기분이 좋아진다. 그리고 지금 @RequestMapping의 URL 정보 중 `/springmvc/v2/members/`까지는 모두 동일하다. 이것 또한 하나로 줄일 수 있다.
SpringMemberControllerV2
package org.example.servlet.web.springmvc.v2;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
import java.util.List;
@Controller
@RequestMapping("/springmvc/v2/members")
public class SpringMemberControllerV2 {
private final MemberRepository memberRepository = MemberRepository.getInstance();
@RequestMapping("/new-form")
public ModelAndView newForm() {
return new ModelAndView("new-form");
}
@RequestMapping("")
public ModelAndView members() {
List<Member> members = memberRepository.findAll();
ModelAndView mv = new ModelAndView("members");
mv.addObject("members", members);
return mv;
}
@RequestMapping("/save")
public ModelAndView save(HttpServletRequest request, HttpServletResponse response) throws Exception {
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
Member member = new Member(username, age);
memberRepository.save(member);
ModelAndView mv = new ModelAndView("save-result");
mv.addObject("member", member);
return mv;
}
}
위 코드처럼 아예 클래스 레벨에 동일하게 들어가는 Path를 고정시키고 변경되는 부분만 메서드의 @RequestMapping으로 지정해주면 된다.
근데 여전히 불편한 부분이 있다. 위에서 잠깐 얘기했지만 모든것들이 다 ModelAndView를 반환해야 하고, 또 요청 URL에서 파라미터를 받아오는 부분이 굉장히 거슬린다. 이 부분을 실무에서 많이 사용하는 방식으로 변경해보자.
SpringMemberControllerV3
package org.example.servlet.web.springmvc.v3;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@Controller
@RequestMapping("/springmvc/v3/members")
public class SpringMemberControllerV3 {
private final MemberRepository memberRepository = MemberRepository.getInstance();
@RequestMapping("/new-form")
public String newForm() {
return "new-form";
}
@RequestMapping("")
public String members(Model model) {
List<Member> members = memberRepository.findAll();
model.addAttribute("members", members);
return "members";
}
@RequestMapping("/save")
public String save(@RequestParam("username") String username,
@RequestParam("age") int age,
Model model) {
Member member = new Member(username, age);
memberRepository.save(member);
model.addAttribute("member", member);
return "save-result";
}
}
우선 반환타입은 이제 단순 문자열이고 그 문자열이 곧 뷰 이름이 된다. 그리고 필요한 경우 뷰에 전달할 데이터를 모델에 담기 위해 ModelAndView를 사용했는데 파라미터에 그냥 Model을 받을 수가 있다. 그래서 이 녀석의 메서드 중 addAttribute()를 사용하면 알아서 반환할 뷰에게 데이터가 전달된다. 그리고 또한, URL로부터 가져와야 할 파라미터를 @RequestParam을 사용해서 아주 간편하게 가져올 수 있을뿐 아니라 보면 알겠지만 int 타입으로 알아서 형변환까지 해준다. 원래 URL은 무조건 모든게 다 문자열이다. 그래서 문자열이 아닌 경우를 원할땐 다 형변환을 해줘야했다(V2를 생각해보면 된다). 근데 그럴 필요 없이 알아서 타입을 맞춰준다.
참고로, @RequestParam은 GET 요청의 URL 파라미터도 가져오지만 POST 요청의 바디 FormData도 가져올 수 있다.
그리고 마지막으로, 지금의 경우 HTTP Method가 구분이 전혀 안 된 상태이다. 그니까 모든 멤버들을 조회하는 members()를 호출할때 GET, POST, PUT, PATCH, DELETE 다 사용이 가능한 상태이다. 아주 좋지 않다. 이 점도 깔끔하게 수정이 가능하다.
아래처럼 @RequestMapping()에는 Method라는 값을 전달할 수가 있다. 그래서 딱 원하는 Method로 결정할 수 있다.
@RequestMapping(value = "", method = RequestMethod.GET)
public String members(Model model) {
List<Member> members = memberRepository.findAll();
model.addAttribute("members", members);
return "members";
}
근데! 이것마저 귀찮다고 이런걸 만들었다.
SpringMemberControllerV3
package org.example.servlet.web.springmvc.v3;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
@RequestMapping("/springmvc/v3/members")
public class SpringMemberControllerV3 {
private final MemberRepository memberRepository = MemberRepository.getInstance();
@GetMapping("/new-form")
public String newForm() {
return "new-form";
}
@GetMapping
public String members(Model model) {
List<Member> members = memberRepository.findAll();
model.addAttribute("members", members);
return "members";
}
@PostMapping("/save")
public String save(@RequestParam("username") String username,
@RequestParam("age") int age,
Model model) {
Member member = new Member(username, age);
memberRepository.save(member);
model.addAttribute("member", member);
return "save-result";
}
}
이게 가장 최신의 깔끔한 방식이다. V1과 비교해보면 정말 군더더기 없이 깔끔하다. 그러나 V1에서 이 V3로의 역사를 알면 더 깊은 이해를 할 수가 있다.
구조 이해하기 1편에서는 직접 MVC 구조와 거의 유사한 구조를 만들어서 이 구조가 어떻게 동작하는지 알아봤다.
이번엔 실제 스프링 MVC 구조를 직접 보고 1편에서 만든 구조와 어떻게 같고 어떤 부분은 다른지를 좀 더 자세히 알아보자.
우선 1편에서 만든 구조와 스프링 MVC 구조의 전체적인 그림을 보자.
스프링 MVC 구조
어떤가? 스프링 MVC 구조와 직접 만든 구조가 거의 똑같다. 이름만 다른거 아닌가? 싶을 정도로 똑같다.
즉, 이 구조를 이해하기 위해 이 구조를 직접 만들어보고 어떻게 동작하는지 직접 해 본 것이다.
비교를 해보자면,
직접 만든 구조 <-> 스프링 MVC 구조
FrontController <-> DispatcherServlet
handlerMappingMap <-> HandlerMapping
MyHandlerAdapter <-> HandlerAdapter
ModelView <-> ModelAndView
viewResolver <-> ViewResolver
MyView <-> View
1편에서 말했던 내용인데 handlerMappingMap을 외부에서 주입받게 구현하면 FrontController는 아예 변경 사항이 없을 것이다라고 말했었다. 그게 스프링 MVC는 하고 있는 일이다. HandlerMapping으로 딱 봐도 타입(클래스, 인터페이스)형이다. 즉, 외부에서 주입받아 사용한다는 의미가 된다. ViewResolver도 마찬가지.
그건 그렇고, 우리가 만든 구조의 핵심은 FrontController였다. 결국 스프링 MVC 구조도 핵심은 DispatchServlet이다. 왜냐? 여기서 적절한 컨트롤러도 다 매핑해주고 외부로의 요청을 받는것도 다 이곳이 최초이기 때문에. 이 녀석을 살짝만 알아보자.
DispatcherServlet
우리가 만든 구조에서 FrontControllerServlet은 서블릿이었다. 스프링 MVC의 DispatcherServlet 역시 서블릿이고 결국 HttpServlet을 상속 받아서 사용한다. 스프링 부트는 DispatcherServlet을 서블릿으로 자동으로 등록하면서 모든 경로(urlPatterns = "/")에 대해서 매핑한다. 그래야 어떤 경로로 사용자가 요청하던, 이 DispatcherServlet을 먼저 통할테니까.
코드가 꽤 길지만 내가 직접 주석 처리한 1번, 2번, 3번을 보자. 결국 직접 만든 FrontControllerServlet에서 가장 핵심인 부분과 유사하다. 1. 핸들러(컨트롤러)를 조회하고 2. 그 핸들러를 다룰 수 있는 어댑터를 찾아서 3. 어댑터의 handle() 메서드를 호출한다.
그리고 그 하단에는 processDispatchResult()를 호출한다. 여기에 핸들러 어댑터의 handle() 메서드를 호출해서 받은 ModelAndView 객체와 핸들러를 넘기는 것을 알 수 있다. 이 메서드는 뭘할까?
이렇게 직접 만든 구조와 거의 동일하다. 물론 스프링 MVC가 훨씬 더 안정적이고 버그에 덜 취약하며 이것 저것 유효성 검사도 많고 잘 만들었지만 흐름이 유사하다는 것이다.
그럼 저 render()는 뭘할까?
DispatcherServlet.render()
protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception {
Locale locale = this.localeResolver != null ? this.localeResolver.resolveLocale(request) : request.getLocale();
response.setLocale(locale);
String viewName = mv.getViewName();
View view;
if (viewName != null) {
// 뷰 리졸버를 통해서 뷰 찾고 뷰를 반환받는다.
view = this.resolveViewName(viewName, mv.getModelInternal(), locale, request);
if (view == null) {
String var10002 = mv.getViewName();
throw new ServletException("Could not resolve view with name '" + var10002 + "' in servlet with name '" + this.getServletName() + "'");
}
} else {
view = mv.getView();
if (view == null) {
throw new ServletException("ModelAndView [" + mv + "] neither contains a view name nor a View object in servlet with name '" + this.getServletName() + "'");
}
}
if (this.logger.isTraceEnabled()) {
this.logger.trace("Rendering view [" + view + "] ");
}
try {
if (mv.getStatus() != null) {
request.setAttribute(View.RESPONSE_STATUS_ATTRIBUTE, mv.getStatus());
response.setStatus(mv.getStatus().value());
}
// 뷰 렌더링
view.render(mv.getModelInternal(), request, response);
} catch (Exception var8) {
Exception ex = var8;
if (this.logger.isDebugEnabled()) {
this.logger.debug("Error rendering view [" + view + "]", ex);
}
throw ex;
}
}
마찬가지로 내가 직접 넣은 주석을 보면, 뷰 리졸버를 통해 뷰를 찾아 결국 마지막 즈음에 뷰를 렌더링한다. 결국은 같은 흐름으로 이어진다.
그러니까 직접 만든 구조와 흐름이 100% 동일하다.
그리고 스프링 MVC는 확장에 유연하고 변경에 닫혀있는 OCP원칙을 훨씬 더 잘 고수하며 만든 프레임워크라서 이 DispatcherServlet의 코드 변경 없이 원하는 기능을 변경하거나 확장할 수 있다. 지금까지 말했던 대부분의 것들을 확장 가능할 수 있게 인터페이스로 제공한다.
이 인터페이스들만 구현해서, DispatchServlet에 등록하면 나만의 컨트롤러를 만들수도 있다. (만들라는 얘기는 절대 아니다.)
이렇게 큰 맥락에서 스프링 MVC 구조와 직접 만든 구조를 비교해 보았다. 이미 직접 만들어봤기 때문에 이해하는데 어렵지 않았다. 이런 과정을 통해 스프링 MVC가 동작하는구나를 이해하면 된다. 그럼 DispatcherServlet을 알아봤는데 핸들러와 핸들러 어댑터는 어떻게 만들었을까? 요새 스프링으로 개발하는 거의 99%는 애노테이션 기반의 컨트롤러를 사용한다. 그래서 RequestMappingHandlerAdapter라는걸 스프링이 만들어서 사용하는데 그 전 세대 사람들은 어떻게 개발했을까?
핸들러 매핑과 핸들러 어댑터
지금은 전혀 사용되지 않지만, 과거에 주로 사용했던 스프링이 제공하는 간단한 컨트롤러로 핸들러 매핑과 어댑터를 이해해보자.
@Component 애노테이션을 활용해서 빈으로 자동 주입을 했다. 그리고 빈의 이름을 지정했는데 꼭 보면 빈의 이름이 URL같이 생겼다. 맞다. 빈의 이름을 URL로 매핑한다.
그래서 한번 URL에 다음 경로로 들어가보자. `http://localhost:8080/springmvc/old-controller`
저 시스템 로그가 찍히면 정상적으로 동작하는 것이다.
그럼 이 URL이 호출될 때 이 컨트롤러를 실행하는 과정이 어떻게 될까? 우선 이 컨트롤러가 실행되려면 두가지가 필요하다.
HandlerMapping
HandlerAdapter
가장 먼저, 핸들러 매핑을 통해서 이 컨트롤러를 찾을 수 있어야 한다. 스프링은 핸들러 매핑에 어떻게 등록할까?
스프링 부트가 자동 등록하는 핸들러 매핑과 핸들러 어댑터가 있다.
스프링 부트가 자동 등록해주는HandlerMapping
0 = RequestMappingHandlerMapping
1 = BeanNameUrlHandlerMapping
우선 0번부터 우선순위가 더 높은거라고 생각하면 된다. RequestMappingHandlerMapping은 애노테이션 기반으로 컨트롤러를 찾는 핸들러 매핑이다. 이건 아니고 두번째 거를 보자. BeanNameUrlHandlerMapping이다. 이름만 봐도 너무 이거일것같다. 맞다.
= 빈의 이름이 곧 URL이 되는 컨트롤러를 찾는 핸들러 매핑이다.
이 핸들러 매핑을 통해 적절한 컨트롤러 (위의 예시에선 내가 만든 OldController)를 찾아서 이 컨트롤러를 처리할 수 있는 어댑터를 찾는다. 스프링 부트가 역시 마찬가지로 자동으로 등록해주는 HandlerAdapter가 있다.
스프링 부트가 자동 등록해주는 HandlerAdapter
0 = RequestMappingHandlerAdapter
1 = HttpRequestHandlerAdapter
2 = SimpleControllerHandlerAdapter
우선, 마찬가지로 0번이 제일 우선순위가 높은것이다. 그리고 RequestMappingHandlerAdapter는 애노테이션 기반의 핸들러를 처리할 수 있는 어댑터이고 HttpRequestHandlerAdapter는 이후에 살펴볼 핸들러 타입의 어댑터이다. 즉, 마지막 SimpleControllerHandlerAdapter가 바로 Controller라는 인터페이스를 구현한 컨트롤러를 처리할 수 있는 어댑터이다. 실제로 이 코드를 한번 봐보자.
SimpleControllerHandlerAdapter
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package org.springframework.web.servlet.mvc;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.web.servlet.HandlerAdapter;
import org.springframework.web.servlet.ModelAndView;
public class SimpleControllerHandlerAdapter implements HandlerAdapter {
public SimpleControllerHandlerAdapter() {
}
public boolean supports(Object handler) {
return handler instanceof Controller;
}
@Nullable
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return ((Controller)handler).handleRequest(request, response);
}
public long getLastModified(HttpServletRequest request, Object handler) {
if (handler instanceof LastModified lastModified) {
return lastModified.getLastModified(request);
} else {
return -1L;
}
}
}
굉장히 익숙하게 생긴 supports()가 있고 보면 Controller 타입인지를 체크한다. 그 Controller는 위에서 다뤄본 Controller 인터페이스이다. 이게 바로 과거의 방식이었다. 하나 더 알아보자.
이번엔 Controller 인터페이스 말고 HttpRequestHandler를 알아보자.
스프링 부트가 자동으로 등록해주는 HttpRequestHandler
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package org.springframework.web;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@FunctionalInterface
public interface HttpRequestHandler {
void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
이것도 역시 위 Controller 인터페이스와 유사한데 얘는 리턴 타입도 void이다. 즉 저 메서드 안에서 전부 다 처리해주는 방식이다.
이 또한 역시 빈의 이름으로 URL을 매핑하는 핸들러를 만든다. 그래서 핸들러를 찾을때 빈의 이름과 URL이 똑같은 컨트롤러를 찾는다. (바로 이 MyHttpRequestHandler) 그럼 핸들러 매핑을 통해 핸들러를 찾았으면 이 핸들러가 어떤 어댑터에 적용될 수 있는지 핸들러 어댑터를 찾는다. 위에 말했던 1번 어댑터인 HttpRequestHandlerAdapter에 걸리는 것이다.
보면 결국은 1편에서 직접 만들어본 구조랑 완전 똑같다. 스프링이 어떻게 MVC 구조를 만들었는지 아니까 이게 어렵지가 않다. 그리고 이 어댑터도 실제 코드를 보면 이렇게 생겼다.
HttpRequestHandlerAdapter
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package org.springframework.web.servlet.mvc;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.web.HttpRequestHandler;
import org.springframework.web.servlet.HandlerAdapter;
import org.springframework.web.servlet.ModelAndView;
public class HttpRequestHandlerAdapter implements HandlerAdapter {
public HttpRequestHandlerAdapter() {
}
public boolean supports(Object handler) {
return handler instanceof HttpRequestHandler;
}
@Nullable
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
((HttpRequestHandler)handler).handleRequest(request, response);
return null;
}
public long getLastModified(HttpServletRequest request, Object handler) {
if (handler instanceof LastModified lastModified) {
return lastModified.getLastModified(request);
} else {
return -1L;
}
}
}
supports()는 HttpRequestHandler 타입인지를 판단한다. 그리고 handle()은 결국 그 핸들러가 가지고 있는 (구현해야만 하는) handleRequest()를 호출한다. 너무너무 이해가 잘된다. 그리고 이젠 이런 방식을 사용하지 않는다는 것도 알고 있다.
지금은 거의 100%에 가깝게 애노테이션 기반의 컨트롤러(핸들러)를 사용하기 때문에 나도 이 방식으로 개발을 할거지만 이런 역사가 있었다는 사실을 알면 개발하는데 무조건 도움이 된다. 어떤게 불편해서 지금의 스프링이 있는지 이해를 할 수 있기 때문에.
그럼 핸들러와 핸들러 어댑터를 알아봤으니 뷰 리졸버도 한번 알아보자.
뷰 리졸버
우리가 직접 만든 구조에서 뷰 리졸버를 통해 논리 이름을 가지고 전체 이름을 가질 수 있게 만들었다.
스프링 부트를 사용하면 어떻게 해야 할까? 우선 위에서 만들어본 완전 과거 버전의 컨트롤러인 OldController를 이렇게 변경해보자.
OldController
package org.example.servlet.web.springmvc.old;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.Controller;
@Component("/springmvc/old-controller")
public class OldController implements Controller {
@Override
public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
System.out.println("OldController.handleRequest");
return new ModelAndView("new-form");
}
}
반환값을 변경했다. 기존에는 null을 리턴했는데 이제 new ModelAndView("new-form")을 리턴한다. 그리고 이 컨트롤러를 호출하면 어떻게 될까? 결과는 다음과 같다. 아무것도 나타나지 않는다. 분명 우리 프로젝트에는 JSP 파일이 있다.
그러나 아무것도 보이진 않는다. 그치만 로그는 찍히고 있다. 컨트롤러 호출은 됐다는 이야기이다.
어떻게 동작하는 걸까? 스프링 부트는 InternalResourceViewResolver 라는 뷰 리졸버를 자동으로 등록하는데, 이때 application.yml 파일에 등록한 spring.mvc.view.prefix, spring.mvc.view.suffix 설정 정보를 사용해서 등록한다.
그러니까 스프링은 뷰 리졸버도 인터페이스로 등록하고 그 인터페이스를 구현한 많은 구현체 중 하나로부터 이 뷰를 보여주는 방법을 사용하는 것이다. 그리고 스프링 부트가 자동으로 등록하는 뷰 리졸버는 여러개가 있고 그 중 일부는 다음과 같다.
1 = BeanNameViewResolver: 빈 이름으로 뷰를 찾아서 반환 (예: 엑셀 파일 생성 기능에 사용)
2 = InternalResourceViewResolver: JSP를 처리할 수 있는 뷰를 반환
저 중 BeanNameViewResolver는 현재 위 코드의 해당 사항이 아니다. 이는 빈 이름으로 뷰를 찾아서 반환하는데 우리가 호출하는 뷰 이름이 `new-form`인데 이런 빈이 없기 때문에 자기는 처리해줄 수 없다고 다음으로 넘긴다. 그리고 이 뷰 리졸버는 보통 엑셀 파일 생성 기능에 사용한다.
참고로, Thymeleaf 뷰 템플릿을 사용하면 ThymeleafViewResolver를 등록해야 하는데 최근 스프링 부트는 라이브러리만 추가하면 스프링 부트가 이런 작업도 모두 자동으로 해준다.
그래서 전체적인 구조를 다시 보자.
결국 핸들러 어댑터 목록을 통해 찾은 핸들러를 처리할 수 있는 핸들러 어댑터를 찾고 핸들러 어댑터가 가진 handle()을 호출하면 결과적으로 뷰의 논리 이름을 얻게 된다. 뷰의 논리 이름을 얻은 DispatchServlet은 뷰 리졸버를 통해 뷰를 찾아내는데 수많은 뷰 리졸버 중 우리는 InternalResourceViewResolver를 통해 뷰를 찾아서 렌더링 하게 된다.
결론
이게 바로 스프링 MVC의 전체 구조가 된다. 굉장히 복잡한 구조인데 직접 이 구조를 만들어보고 나니 그렇게 어렵게 느껴지지 않는다. 제대로 배운 느낌이 든다. 이제 구조도 다 이해했으니 진짜 Spring MVC를 사용해보자.
package org.example.servlet.web.frontcontroller.v1.controller;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.v1.ControllerV1;
import java.io.IOException;
public class MemberSaveControllerV1 implements ControllerV1 {
private final MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
Member member = new Member(username, age);
memberRepository.save(member);
// Model에 데이터를 보관
request.setAttribute("member", member);
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
이제 이 각각의 컨트롤러의 앞에서 공통 부분을 처리하고 필요한 컨트롤러를 호출해주는 FrontController를 만들면 된다.
FrontControllerServletV1
package org.example.servlet.web.frontcontroller.v1;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.v1.controller.MemberFormControllerV1;
import org.example.servlet.web.frontcontroller.v1.controller.MemberListControllerV1;
import org.example.servlet.web.frontcontroller.v1.controller.MemberSaveControllerV1;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@WebServlet(name = "frontControllerServletV1", urlPatterns = "/front-controller/v1/*")
public class FrontControllerServletV1 extends HttpServlet {
private final Map<String, ControllerV1> controllerMap = new HashMap<>();
public FrontControllerServletV1() {
controllerMap.put("/front-controller/v1/members/new-form", new MemberFormControllerV1());
controllerMap.put("/front-controller/v1/members/save", new MemberSaveControllerV1());
controllerMap.put("/front-controller/v1/members", new MemberListControllerV1());
}
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String requestURI = req.getRequestURI();
ControllerV1 controller = controllerMap.get(requestURI);
if (controller == null) {
resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
controller.process(req, resp);
}
}
이 FrontController는 Servlet이다. 이 서블릿의 URL Pattern은 "/front-controller/v1/*"이다. 이게 의미하는 건 /front-controller/v1/으로 시작하는 모든 URL에 대해 이 서블릿이 처리하겠다는 의미가 된다. 그리고 이 서블릿은 Map을 가진다. 이 Map엔 각 URL에 상응하는 컨트롤러가 담겨있고 이 컨트롤러들은 모두 타입이 ControllerV1이다. 왜냐? 다형성 덕분에 가능하다.
그래서 이 Front Controller는 받은 요청의 URI를 통해 어떤 컨트롤러를 호출할지 찾아내서 그 컨트롤러가 구현한 메서드인 process()를 호출한다.
"어? 더 불편해 보이는데요..?"
맞다. 여전히 지금은 각 컨트롤러마다 어떤 뷰를 보여줘야 하는지에 대한 코드가 중복으로 남아있다. 이런 코드들.
바뀌는 부분은 FrontController가 요청으로 들어온 URI를 보고 Controller를 찾는데, 찾고 그 Controller가 가진 process() 메서드를 호출하고 끝나는게 아니라 호출하면 반환하는 MyView라는 객체의 render() 메서드를 호출하는 것까지 FrontController가 하게 된다.
위에서도 말했지만, 공통으로 처리될 부분을 앞에서 다 해주는 것이 원래 기대값이다. 그 방향으로 하나씩 나아가는 중인 것.
우선 MyView 라는 뷰를 렌더링 하는것을 담당하는 클래스를 만든다.
MyView
package org.example.servlet.web.frontcontroller;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
public class MyView {
private final String viewPath;
public MyView(String viewPath) {
this.viewPath = viewPath;
}
public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
이 클래스는 viewPath 필드를 가지고 있다. 컨트롤러에서 이 객체를 만들 때 viewPath를 넣어주면 이 객체의 render() 메서드는 viewPath를 통해 JSP를 호출한다.
이제 ControllerV2를 구현할 세가지의 컨트롤러(Form, Save, List)를 만든다.
MemberFormControllerV2
package org.example.servlet.web.frontcontroller.v2.controller;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v2.ControllerV2;
import java.io.IOException;
public class MemberFormControllerV2 implements ControllerV2 {
@Override
public MyView process(HttpServletRequest request, HttpServletResponse response) throws
ServletException, IOException {
return new MyView("/WEB-INF/views/new-form.jsp");
}
}
MemberListControllerV2
package org.example.servlet.web.frontcontroller.v2.controller;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v2.ControllerV2;
import java.io.IOException;
import java.util.List;
public class MemberListControllerV2 implements ControllerV2 {
private final MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public MyView process(HttpServletRequest request, HttpServletResponse response) throws
ServletException, IOException {
List<Member> members = memberRepository.findAll();
// Model에 보관
request.setAttribute("members", members);
return new MyView("/WEB-INF/views/members.jsp");
}
}
MemberSaveControllerV2
package org.example.servlet.web.frontcontroller.v2.controller;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v2.ControllerV2;
import java.io.IOException;
public class MemberSaveControllerV2 implements ControllerV2 {
private final MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public MyView process(HttpServletRequest request, HttpServletResponse response) throws
ServletException, IOException {
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
Member member = new Member(username, age);
memberRepository.save(member);
// Model에 데이터를 보관
request.setAttribute("member", member);
return new MyView("/WEB-INF/views/save-result.jsp");
}
}
보다시피 이제 뷰에 대한 중복 코드가 컨트롤러에서 사라지고 깔끔하게 정리됐다. 이 작업만으로도 컨트롤러의 코드가 더 깔끔하고 보기 좋아졌다. 이제 이 컨트롤러의 process() 메서드를 호출하는 FrontController를 만들어야한다. 왜냐하면 이제 이것만 호출하고 끝이 아니라 얘가 반환하는 MyView 객체를 가지고 render() 메서드를 호출해줘야 한다.
이제 이 FrontController는 꽤나 어깨가 무거워졌다. URI에 따라 어떤 컨트롤러를 호출할지 정해줘야하고, 컨트롤러가 호출해서 돌려준 MyView 객체로 뷰를 요청에 대한 응답으로 돌려줘야한다. 그래도 한 곳에서 관리하기 때문에 중복이 많이 제거됐다.
그렇지만, 여전히 뭔가 아쉽다. 예를 들면 다음 코드.
new MyView("/WEB-INF/views/save-result.jsp");
저 경로의 위치가 컨트롤러마다 전부 중복으로 쓰여지고 있다. 그리고 HttpServletRequest, HttpServletResponse가 필요가 없는 컨트롤러도 있다. MemberFormControllerV2를 보면 아예 사용자체가 안되지만 파라미터로 넘겨받고 있다.
그리고 다른 컨트롤러도 파라미터 가져오거나, setAttribute() 메서드로 모델에 데이터를 담는 작업 외엔 하는것도 없다. 그러니까 HttpServletResponse는 진짜로 하는게 없다. 그래서, 아예 HttpServletRequest, HttpServletResponse를 사용하지 말아보자.
필요한 파라미터나 모델을 담는것은 서블릿에 종속적이지 않아도 될 것 같다.
V3
그래서 ModelView라는 클래스를 만들자. 이 클래스는 모델과 뷰를 동시에 다루는 클래스이다.
어떤 뷰를 보여줄지에 대한 정보와 그 뷰에서 사용할 모델을 동시에 가지고 있는 것이다.
ModelView
package org.example.servlet.web.frontcontroller;
import lombok.Getter;
import lombok.Setter;
import java.util.HashMap;
import java.util.Map;
@Getter
@Setter
public class ModelView {
private String viewName;
private Map<String, Object> model = new HashMap<>();
public ModelView(String viewName) {
this.viewName = viewName;
}
}
어떤 뷰를 보여줘야 하는지 알고 있는 viewName, 그 뷰에서 사용될 데이터를 담은 model이 있다.
이번엔 HttpServletRequest, HttpServletResponse가 필요가 없다. 받는 파라미터는 그저 해당 뷰에서 사용될 파라미터 정보뿐이다.
이 ControllerV3를 구현할 세 가지 컨트롤러(Form, Save, List)를 만들자
MemberFormControllerV3
package org.example.servlet.web.frontcontroller.v3.controller;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v3.ControllerV3;
import java.util.Map;
public class MemberFormControllerV3 implements ControllerV3 {
@Override
public ModelView process(Map<String, String> paramMap) {
return new ModelView("new-form");
}
}
MemberSaveControllerV3
package org.example.servlet.web.frontcontroller.v3.controller;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v3.ControllerV3;
import java.util.Map;
public class MemberSaveControllerV3 implements ControllerV3 {
private final MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public ModelView process(Map<String, String> paramMap) {
String username = paramMap.get("username");
int age = Integer.parseInt(paramMap.get("age"));
Member member = new Member(username, age);
memberRepository.save(member);
ModelView mv = new ModelView("save-result");
mv.getModel().put("member", member);
return mv;
}
}
MemberListControllerV3
package org.example.servlet.web.frontcontroller.v3.controller;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v3.ControllerV3;
import java.util.List;
import java.util.Map;
public class MemberListControllerV3 implements ControllerV3 {
private final MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public ModelView process(Map<String, String> paramMap) {
List<Member> members = memberRepository.findAll();
ModelView mv = new ModelView("members");
mv.getModel().put("members", members);
return mv;
}
}
이제는 각각의 컨트롤러가 서블릿에 대한 종속이 전혀 없다. 보다시피 파라미터로 HttpServletRequest, HttpServletResponse를 받지도 않고 있다. 그리고 ModelView라는 직접 만든 클래스를 사용해서 보여줄 뷰의 이름과 그 뷰에서 사용할 모델을 처리한 후 이 ModelView 객체를 반환한다.
그럼 FrontController는 이제 이 반환값을 가지고 공통적으로 또 처리해 줄 것들을 처리하면 된다.
그럼 끝인가? 아쉽지만 아니다. 어떤게 남았나면 이제 우리가 직접 만든 모델을 사용하기 때문에 그 모델에 담긴 데이터를 다시 서블릿의 request에 넣어줘야 한다. JSP는 HttpServletRequest 객체인 request의 getAttribute()를 통해서 데이터를 꺼내오기 때문에 꼭 해줘야 한다. 그게 새롭게 만든 MyView의 render()메서드이다.
이렇게 하면, FrontController가 하는 일이 많아진 대신 각각의 세부 컨트롤러는 하는일이 더더욱 적어졌다. 그리고 FrontController가 중복적인 부분을 혼자 담당하기 때문에 변경이 필요하면 이 부분만 변경하면 된다. 예를 들면 뷰의 경로가
"/WEB-INF/views/" + mv.getViewName() + ".jsp" 여기서 "/WEB-INF/jsp/" + mv.getViewName() + ".jsp" 이렇게 변경되더라도 말이다.
그러나, 만든 V3 컨트롤러는 잘 설계된 컨트롤러는 맞지만 (서블릿 종속성을 제거하고, 뷰 경로의 중복을 제거하는 등) 실제 컨트롤러 인터페이스를 구현하는 개발자 입장에서 보면, 항상 ModelView 객체를 생성하고 반환해야 하는 부분이 조금은 번거롭다. 좋은 프레임워크는 아키텍쳐도 중요하지만, 그와 더불어 실제 개발하는 개발자가 단순하고 편리하게 사용할 수 있어야 한다. 소위 실용성이 있어야 한다.
이번에는 V3를 조금 변경해서 실제 구현하는 개발자들이 매우 편리하게 개발할 수 있는 V4 버전을 만들어보자!
V4
스프링은 ModelView를 반환하는 컨트롤러를 만들수도 있지만, 그냥 단순 스트링을 반환하고 그 스트링이 뷰의 이름이되는 반환을 하기도 한다. 이 V4는 그것을 똑같이 만들어보고 싶은것이다.
여기서 변경되는 부분은 service()에서 적절한 컨트롤러의 process()를 실행할 때 model을 추가적으로 넘겨주는 것과 받는 반환타입이 바로 viewResolver()의 파라미터로 들어간다는 점이다. 그리고 view.render()의 첫번째 파라미터가 model이 되면 된다.
이러면 모든게 기존과 동일하게 동작한다. 어떤 부분에서 더 유연해졌냐?
실제 컨트롤러들(FrontController 말고)이 굳이 ModelView를 매번 새로운 인스턴스로 만들어내지 않아도 된다. 특히 모델이 아예 필요없고 뷰의 논리 이름만을 위해서 만드는 MemberFormController의 경우 정말 비효율적인 방식이었는데 이를 깔끔하게 해결해준다.
이 방식이 바로 스프링이 컨트롤러를 만들때 ModelView를 반환해도 가능하고 단순 String을 반환해도 상관없는 이유이다.
"어?! 근데 지금 코드는 단순 String만 반환 가능할 거 같은데요?" 맞다. 왜냐하면, 이 FrontControllerV4는 컨트롤러를 확정짓기 위해 사용되는 controllerMap의 Value가 ControllerV4로 한정되어 있다. 그러나 스프링은 두 가지 경우 모두 지원한다. 즉, 더 유연하다는 소리고 그 방법을 V5에서 알아보자!
V5
지금까지의 구조는 다음과 같다.
위 구조를 흐름대로 설명하면 다음과 같은 흐름이 발생한다.
사용자로부터 요청이 들어온다.
요청을 최초에 FrontControllerV4가 받는다.
요청 URL에 따라 처리 가능한 컨트롤러를 FrontControllerV4는 찾고 그 컨트롤러를 호출한다.
해당 컨트롤러에서 필요한 수행 작업을 모두 마친 후 보여줄 화면에 대한 뷰 이름을 가진 반환값을 FrontControllerV4에게 돌려준다.
받은 뷰 이름을 전체 이름으로 변경해주는 viewResolver()를 FrontControllerV4가 호출한다.
호출해서 받은 전체 뷰 경로를 가지고 MyView 객체의 render()를 호출해서 사용자에게 최종 화면을 보여준다.
여기서 개선될 부분은 개발자가 "나는 V4 형태 말고 V3 형태로 컨트롤러를 만들고 싶어!"라고 할 때 그러지 못한다는 점이다. 이 점을 가능하게 변경해보자.
보면, ControllerV3HandlerAdapter라는 구현체가 있다. 이 구현체가 하는 역할은 다음과 같다.
supports()에서 들어온 파라미터가 ControllerV3 타입인지를 판단한다. 만약 그렇다면, 참을 반환한다.
handle()은 supports()가 참을 반환했을 때 유효한 메서드이다. 들어온 handler가 ControllerV3 타입이라면 이 handle()은 ControllerV3가 했던 동작을 그대로 할 뿐이다.
이 둘만 가지고는 이해가 제대로 되지 않는다. FrontControllerV5를 만들어보자.
FrontControllerServletV5
package org.example.servlet.web.frontcontroller.v5;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v3.controller.MemberFormControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberListControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberSaveControllerV3;
import org.example.servlet.web.frontcontroller.v5.adapter.ControllerV3HandlerAdapter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {
private final Map<String, Object> handlerMappingMap = new HashMap<>();
private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();
public FrontControllerServletV5() {
initHandlerMappingMap();
initHandlerAdapters();
}
private void initHandlerMappingMap() {
handlerMappingMap.put("/front-controller/v5/v3/members/new-form", new MemberFormControllerV3());
handlerMappingMap.put("/front-controller/v5/v3/members/save", new MemberSaveControllerV3());
handlerMappingMap.put("/front-controller/v5/v3/members", new MemberListControllerV3());
}
private void initHandlerAdapters() {
handlerAdapters.add(new ControllerV3HandlerAdapter());
}
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Object handler = getHandler(req);
if (handler == null) {
resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
MyHandlerAdapter handlerAdapter = getHandlerAdapter(handler);
ModelView mv = handlerAdapter.handle(req, resp, handler);
MyView view = viewResolver(mv.getViewName());
view.render(mv.getModel(), req, resp);
}
private MyHandlerAdapter getHandlerAdapter(Object handler) {
for (MyHandlerAdapter handlerAdapter : handlerAdapters) {
if (handlerAdapter.supports(handler)) {
return handlerAdapter;
}
}
throw new IllegalArgumentException("handler not found for class " + handler.getClass().getName());
}
private Object getHandler(HttpServletRequest req) {
String requestURI = req.getRequestURI();
return handlerMappingMap.get(requestURI);
}
private static MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
}
handlerMappingMap은 URL에 따라 처리하는 컨트롤러를 모두 저장해 놓는 Map이다.
handlerAdapters는 모든 MyHandlerAdapter 타입의 객체를 담는 List이다.
생성자에서 두 가지 메서드를 호출한다. initHandlerMappingMap(), initHandlerAdapters().
initHandlerMappingMap()은 요청 URL에 따라 처리를 할 수 있는 컨트롤러를 모두 Map에 추가해주는 메서드이다. 위 코드를 보면 `/front-controller/v5/v3/members`로 들어온 URL은 MemberListControllerV3로 Key/Value를 가진다.
initHandlerAdapters()는 MyHandlerAdapter 타입의 모든 인스턴스를 추가한다. 위 코드를 보면 ControllerV3HandlerAdapter를 추가했음을 알 수 있다.
이제 여기서부터 실제 흐름이다. 어떤 흐름을 통해 동작하는지 이해해보자.
첫번째로 FrontControllerV5로 모든 사용자의 요청이 들어오게 된다.
사용자의 요청에 따라 첫번째로 할 일은 요청 URL과 매핑해둔 컨트롤러(핸들러)를 찾는다. -> `getHandler()`
만약 핸들러를 찾지 못했다면, NOT_FOUND 에러를 내보낸다.
핸들러를 찾았다면, 그 핸들러를 통해 핸들러 어답터를 찾는다. 즉, 찾은 핸들러가 만약 MemberListControllerV3였다면, 이 핸들러를 통해 handlerAdapters에서 쭉 루프를 돌면서 핸들러 어답터를 찾는다. 어떻게 찾을까? handlerAdapters에는 모든 MyHandlerAdapter를 구현한 구현체가 들어있게 된다. 각 구현체는 supports()를 구현해야 하는데 이 메서드는 들어온 파라미터가 ControllerV3, ControllerV4 타입인지를 체크한다. 찾았다면 해당 핸들러어답터를 가져오고 찾지 못했다면 에러를 던진다.
가져온 핸들러 어답터의 handle()을 호출한다. 이 handle()은 V3 버전인지 V4 버전인지에 따라 처리하는 로직을 구분지어 각 버전에 맞게 컨트롤러가 처리하는 로직이 담겨있다.
이렇게 어떤 버전의 컨트롤러라도 처리할 수 있는 FrontControllerV5가 만들어지게 된다. 스프링도 이렇게 구현해 두었다. 그래서 스프링도 컨트롤러의 메서드 중 반환타입이 단순 문자열인 메서드가 존재하고 ModelAndView 타입의 메서드가 존재해도 상관이 없는 것이다.
마찬가지로 이 V4 핸들러 어댑터는 MyHandlerAdapter를 구현한다. 그리고 구현 내용이 V3가 아닌 V4일 뿐이다.
그리고 FrontControllerV5를 보자.
FrontControllerServletV5
package org.example.servlet.web.frontcontroller.v5;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v3.controller.MemberFormControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberListControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberSaveControllerV3;
import org.example.servlet.web.frontcontroller.v4.controller.MemberFormControllerV4;
import org.example.servlet.web.frontcontroller.v4.controller.MemberListControllerV4;
import org.example.servlet.web.frontcontroller.v4.controller.MemberSaveControllerV4;
import org.example.servlet.web.frontcontroller.v5.adapter.ControllerV3HandlerAdapter;
import org.example.servlet.web.frontcontroller.v5.adapter.ControllerV4HandlerAdapter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {
private final Map<String, Object> handlerMappingMap = new HashMap<>();
private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();
public FrontControllerServletV5() {
initHandlerMappingMap();
initHandlerAdapters();
}
private void initHandlerMappingMap() {
handlerMappingMap.put("/front-controller/v5/v3/members/new-form", new MemberFormControllerV3());
handlerMappingMap.put("/front-controller/v5/v3/members/save", new MemberSaveControllerV3());
handlerMappingMap.put("/front-controller/v5/v3/members", new MemberListControllerV3());
handlerMappingMap.put("/front-controller/v5/v4/members/new-form", new MemberFormControllerV4());
handlerMappingMap.put("/front-controller/v5/v4/members/save", new MemberSaveControllerV4());
handlerMappingMap.put("/front-controller/v5/v4/members", new MemberListControllerV4());
}
private void initHandlerAdapters() {
handlerAdapters.add(new ControllerV3HandlerAdapter());
handlerAdapters.add(new ControllerV4HandlerAdapter());
}
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Object handler = getHandler(req);
if (handler == null) {
resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
MyHandlerAdapter handlerAdapter = getHandlerAdapter(handler);
ModelView mv = handlerAdapter.handle(req, resp, handler);
MyView view = viewResolver(mv.getViewName());
view.render(mv.getModel(), req, resp);
}
private MyHandlerAdapter getHandlerAdapter(Object handler) {
for (MyHandlerAdapter handlerAdapter : handlerAdapters) {
if (handlerAdapter.supports(handler)) {
return handlerAdapter;
}
}
throw new IllegalArgumentException("handler not found for class " + handler.getClass().getName());
}
private Object getHandler(HttpServletRequest req) {
String requestURI = req.getRequestURI();
return handlerMappingMap.get(requestURI);
}
private static MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
}
변경되는 부분은 handlerAdapters에 V4HandlerAdapter가 추가되고, handlerMappingMap에 V4관련 컨트롤러를 추가한 것뿐이다. 그 외에는 아무런 변경사항이 없다. 심지어 이 handlerAdapters와 handlerMappingMap도 밖에서 주입받게 해 놓으면 아예 이 FrontCotrollerV5는 변경 사항이 아예 없어진다. 이게 변경에는 닫혀있고 확장에는 열려있는 OCP 원칙이다.
스프링도 이와 유사한 (거의 똑같다) 구조를 가지고 있고 이렇게 만들어 놓다가 애노테이션 기반의 컨트롤러가 대세가 되면서 애노테이션 기반의 컨트롤러를 처리할 수 있는 어댑터를 하나 만들어서 이 핸들러 어댑터에 추가만 해줄뿐이다. 그러니까 확장이 너무 유연해지고 간결해지는 것이다.
결론
그레서 최종 V5의 모습은 이와 같다.
중간에 핸들러 어댑터라는게 추가됐다. 이 핸들러 어댑터 덕분에 여러 버전의 컨트롤러를 만들어도 아무런 문제없이 해당 버전에 맞는 컨트롤러 처리를 할 수 있게 됐다.
핸들러는 그저 컨트롤러의 다른말 일뿐이다.
이 구조가 바로 Spring MVC 구조이다. 그리고 잠깐 위에서 말했지만 애노테이션 기반의 컨트롤러가 대세가 되면서 이 애노테이션 기반의 컨트롤러를 처리할 수 있는 핸들러 어댑터 하나만 만들어주면 되는 식으로 확장이 용이하다고 했는데 스프링에서 이 핸들러 어댑터 이름이 바로 "RequestMappingHandlerAdapter"이다. 느낌이 바로 오지 않는가? V5 구조에서 만든 핸들러 어댑터 이름은 ControllerV4HandlerAdapter, ControllerV5HandlerAdapter였다. 이제 @RequestMapping("/hello") 이런 애노테이션 기반의 컨트롤러를 처리할 수 있는 핸들러 어댑터가 필요하니까 핸들러 어댑터를 만들었는데 그 이름이 저것인거다.
서블릿, JSP를 사용해보니 여러 불편한 점이 많았고 그 중 JSP는 서블릿보단 HTML을 만들어내기가 쉽지만 담당하고 있는게 너무 많아져버린다. 화면과 비즈니스 로직을 전부 담당하고 나니 지저분해지고 보기가 힘들어진다. 이는 곧 유지보수가 어려워진다.
그래서 화면은 딱 화면을 담당하는 쪽에서만, 비즈니스 로직은 비즈니스 로직을 담당하는 쪽에서만 관리하고 처리하게 하고 싶은것이다.
그리고 또 하나는 둘 간의 변경 사이클이 다를 확률이 높다. 무슨 말이냐면 화면에 보이는 버튼의 위치를 바꾸고 싶다는 요구사항이 생길 때 비즈니스 로직을 건들 필요가 없다. 반대로 비즈니스 로직을 해결한 기술을 바꾸고 싶을 때 화면을 구성하는 어떤 부분도 변경할 필요가 없다. 근데 두 코드가 같은 파일에 있다는 것은 유지보수하기 좋지 않다.
그래서 MVC 패턴이 등장한다. Model, View, Controller로 영역을 나눈것이다.
컨트롤러: HTTP 요청을 받아서 파라미터를 검증하고, 비즈니스 로직을 실행한다(호출한다). 그리고 뷰에 전달할 결과 데이터를 조회해서 모델에 담는다.
모델: 뷰에 출력할 데이터를 담아둔다. 뷰가 필요한 데이터를 모두 모델에 담아서 전달해주는 덕분에 뷰는 비즈니스 로직이나 데이터 접근을 몰라도 되고, 화면을 렌더링 하는 일에 집중할 수 있다.
뷰: 모델에 담겨있는 데이터를 사용해서 화면을 그리는 일에 집중한다. 여기서는 HTML을 생성하는 부분을 말한다.
참고: 컨트롤러가 비즈니스 로직을 실행할 수도 있다. 근데 사이즈가 조금만 크다면 실행하지 말고 호출해라. 비즈니스 로직을 수행하는 부분을 컨트롤러로부터 떼어내는 것이다. 일반적으로 잘 알려진 '서비스'라는 레이어로 말이다.
그래서, 지금부터 할 내용은 작성한 JSP 파일에서 비즈니스 로직과 뷰 부분을 떼어낼 것이다. 서블릿을 컨트롤러로 사용하고 JSP를 뷰로 사용해서 MVC 패턴을 적용해보자.
여기서 보면, 화면으로 뿌려줄 JSP파일의 경로를 작성해서 RequestDispatcher 객체로 만들었다. 이 객체는 서버로 이 요청이 들어오면 forward()를 통해 JSP파일로 요청을 전달해버리는 기능을 가진다. 리다이렉트와 유사한것 같지만 리다이렉트는 클라이언트와 통신을 두번한다.
최초의 요청 -> 서버는 리다이렉트 경로를 알려주는 정보를 가지고 응답 -> 응답 데이터에 있는 리다이렉트 확인 -> 다시 해당 정보로 서버에 요청 -> 서버가 응답.
이게 리다이렉트라면 이 forward()는 요청이 들어와서 서버 내부에서 호출을 해서 최종 결과를 클라이언트에게 전달해준다. 그래서 클라이언트와 서버 간 통신은 한번뿐이다.
그리고 WEB-INF는 뭐냐면 기존에는 webapp안에 jsp/members/new-form.jsp 이렇게 경로를 지정해서 JSP 파일을 만들었다. 그리고 이 경로 그대로 URL에 입력하면 JSP 파일이 딱 브라우저에 뜬다. 근데 그걸 못하게 하는 것이다. WEB-INF 내부에 있는 자원들은 외부에서 직접적으로 접근하지 못하고 항상 컨트롤러를 통해 호출된다.
여기서는 action의 경로를 유심히 봐야한다. 단순히 "save"가 끝이다. 즉, 상대경로로 POST 요청이 날라가게 설정했다. 이렇게 해두면 현재 URL에 뒤에 "/save"가 붙은 경로로 보낸다.
예를 들어 이 new-form.jsp를 보여주기 위한 URL은 "http://localhost:8080/servlet-mvc/members/new-form"이다. 그럼 이제 전송 버튼을 클릭하면 경로가 "http://localhost:8080/servlet-mvc/members/save"인 곳으로 POST 요청이 날라간다.
그럼 이제 멤버를 저장할 서블릿과 JSP를 만들어야한다.
MvcMemberSaveServlet
package org.example.servlet.web.servletmvc;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import java.io.IOException;
@WebServlet(name = "mvcMemberSaveServlet", urlPatterns = "/servlet-mvc/members/save")
public class MvcMemberSaveServlet extends HttpServlet {
private final MemberRepository memberRepository = MemberRepository.getInstance();
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String username = req.getParameter("username");
int age = Integer.parseInt(req.getParameter("age"));
Member member = new Member(username, age);
memberRepository.save(member);
// Model에 데이터를 보관
req.setAttribute("member", member);
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
dispatcher.forward(req, resp);
}
}
여기서 중요한 건 모델을 만들어서 뷰에게 전달해줘야한다. 그 부분이 바로 req.setAttribute("member", member);이다.
근데 이거 진짜 그냥 이런게 있구나하고 넘어가도 된다. 필요하면 찾아보면 되는거고 JSP 쓸 일 거의 없다. 이렇게 하고 리스트를 보면 잘 나온다.
결론
이렇게 서블릿과 JSP를 사용해서 한 곳에서 작성되던 뷰와 비즈니스 로직을 쪼개서 각자가 자기것만 잘 담당할 수 있도록 해봤다. 그러다보니 코드가 이전보다 깔끔해졌다. 그러나 여전히 100% 만족스럽지 않다. 왜냐하면 서블릿에서 지금 중복 코드가 계속 반복되고 있기 때문이다. 다음 코드를 보자.
viewPath의 실제 경로 말고 모든게 똑같다. 이런 공통적으로 처리될 부분들이 계속해서 중복되고 있다. 이걸 처리하는 유틸성 메서드를 만들면 조금 더 나아지겠지만 그것을 매번 호출하는 것 역시 중복이다. 그래서 이것을 더 개선하고 싶어진다.
공통된 부분들은 앞에서 미리 다 처리하고 들어오는 방식으로 말이다. 수문장 하나가 맨 앞에서 모든 요청을 받고 그 요청마다 공통적으로 처리되는 부분들을 거기서 전부 해결하고난 후 각각의 컨트롤러로 요청을 전달해주는 것이다. 이런 패턴을 Front Controller 패턴이라고 하고 스프링 MVC도 이 패턴을 잘 구현한 방식이다.
package org.example.servlet.domain.member;
import lombok.Data;
@Data
public class Member {
private Long id;
private String username;
private int age;
public Member(String username, int age) {
this.username = username;
this.age = age;
}
}
MemberRepository
package org.example.servlet.domain.member;
import lombok.Getter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class MemberRepository {
private static Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;
private static final MemberRepository instance = new MemberRepository();
public static MemberRepository getInstance() {
return instance;
}
private MemberRepository() {}
public Member save(Member member) {
member.setId(++sequence);
store.put(member.getId(), member);
return member;
}
public Member findById(Long id) {
return store.get(id);
}
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
public void clearStore() {
store.clear();
}
}
데이터베이스는 없지만 메모리 상에서 회원 정보를 저장하게 해보자. 우선 그럴려면 멤버를 저장할 자료구조가 필요한데 그 HashMap을 이용해보자. 동시성 문제에 대해선 고려하지 않은채로 진행하자.
그리고, 이 MemberRepository는 딱 한 개의 인스턴스만 존재하는 싱글톤이다. 그래서 getInstance()로만 이 클래스의 인스턴스에 접근이 가능하도록 만들었다.
save(Member member), findById(Long id), findAll(), clearStore() 이렇게 4개의 public 메서드가 있다.
테스트 코드도 작성하자.
MemberRepositoryTest
package org.example.servlet.domain.member;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
class MemberRepositoryTest {
MemberRepository memberRepository = MemberRepository.getInstance();
@AfterEach
void afterEach() {
memberRepository.clearStore();
}
@Test
public void save() {
Member member = new Member("hello", 20);
Member savedMember = memberRepository.save(member);
Member findMember = memberRepository.findById(savedMember.getId());
assertEquals(savedMember, findMember);
}
@Test
void findAll() {
Member member1 = new Member("member1", 20);
Member member2 = new Member("member2", 20);
memberRepository.save(member1);
memberRepository.save(member2);
List<Member> members = memberRepository.findAll();
assertEquals(members.size(), 2);
assertThat(members).contains(member1, member2);
}
}
Member, MemberRepository를 만들고 이를 이용한 CRUD에 대한 간단한 테스트 코드를 작성했다. 이제 서블릿을 사용해서 클라이언트와 서버간 통신을 해서 간단한 애플리케이션을 만들어보자.
POST Method로 들어온 요청에 담긴 username, age를 받아와서 MemberRepository를 통해 멤버를 저장하는 로직이 있다.
그리고 저장된 멤버를 보여주는 HTML을 응답으로 돌려준다.
여기서 문제는, 비즈니스 로직과 응답에 대한 뷰를 처리하는게 동시에 있다는 사실이다. 즉, 이 서블릿이 하고 있는 업무가 너무 많다. 여하튼 서블릿을 이용해서 멤버를 만들어내는 폼을 보여주는 화면과 폼 데이터를 전송해서 멤버를 저장하고 저장된 멤버를 보여주는 작업을 완료했다. 아래 화면은 폼 화면에서 username에 "choi", age에 "30"을 넣고 전송버튼을 눌렀을 때 결과 화면이다.
딱히 설명할 내용은 없는것같다. 멤버 전체를 보여주는 화면이다. 바로 서블릿이 보여주는 화면을 봐보자.
이렇게 멤버를 생성하고 저장된 멤버들을 보여주는 화면을 만들어봤다. 사용을 해보니 서블릿으로 동적인 HTML 파일도 만들수 있고 화면을 사용자에게 뿌려주는것도 잘되고 좋은것 같지만 서블릿의 가장 큰 단점은 HTML을 작성해내기가 너무 힘들다는 것이다. 이것을 해결하기 위해 템플릿 엔진이 나왔다. 대표적인 것이 JSP, Thymeleaf이다. JSP를 먼저 해보고 JSP가 서블릿의 문제를 어떻게 해결했고 어떤 불편함이 있길래 Thymeleaf가 나왔는지 또 알아보자.
Spring MVC를 배우기 전에 어디서부터 시작되어 Spring MVC까지 도착했는지를 공부하고 싶어졌다. Spring MVC가 어떤 것을 나 대신해주고 어떤 것 때문에 사용하는지 좀 더 자세히 이해하기 위해 Servlet부터 시작해 보고자 한다.
프로젝트 만들기
우선 프로젝트를 만들자. 만들 때 따로 톰캣을 설치하고 띄운 상태가 아니라면 스프링 부트의 도움을 받아서 바로 WAS 서버를 실행할 수 있게 스프링 프레임워크를 사용할거다. 서블릿은 스프링이고 스프링부트고 아무것도 없어도 되는데 딱 이 이유때문에 스프링 프레임워크를 사용할 거다.
IntelliJ > New Project > Spring Boot
중요: Packaging을 Jar말고 War로 선택한다. JSP를 사용해야 하기 때문이다. Jar와 War의 차이는 서버가 따로 있고 그 서버에 따로 톰캣을 설치하고 그 톰캣 위에 배포하는 경우 War를 사용하는데 꼭 그렇게 안하더라도 스프링 부트를 사용하면 War에 WAS서버가 내장되어 있다. 근데 JSP를 사용하기 위해선 War를 사용해야 한다.
Dependencies는 저 두개만 있으면 된다. Spring Web이 필요한 이유는 Apache Tomcat을 내장시키려면 저게 필요하기 때문.
우선 먼저 실행해봐야 한다. 잘 실행된다면 다음과 같이 로그가 찍힌다.
톰캣이 실행됐다는 로그가 찍힌다. 스프링 부트가 내장하고 있는 WAS가 띄워지는 것이다.
다시 말하지만 이거 때문에 서블릿으로 만들건데도 스프링 부트를 사용했다. 딱 이 이유뿐이다. 서블릿은 스프링과 아무런 관련이 없다. 서블릿은 톰캣 같은 웹 애플리케이션 서버를 직접 본인의 서버위에 설치하고 그 위에 서블릿 코드를 클래스 파일로 빌드해서 올린 다음 톰캣 서버를 실행하면 된다. 근데 이 과정이 번거롭기 때문에 톰캣 서버가 내장되어 있는 스프링 부트를 사용한다.
스프링에서 서블릿을 사용하려면 @ServletComponentScan 애노테이션을 붙여야 한다. 애노테이션을 붙이면 저 패키지 org.example.servlet 하위에 있는 모든 패키지에서 서블릿을 다 뒤지고 찾아서 자동으로 등록해준다.
응답 데이터에 Content-Type, Encoding, Body값을 넣어준다. getWriter().println()을 사용하면 Response Body에 데이터를 넣어줄 수 있다. 이 상태로 다시 요청을 해보면 다음과 같이 보인다.
이렇게 서블릿을 이용해서 HTTP 프로토콜을 통해 요청과 응답을 할 수 있게 됐다.
정리를 하자면
스프링 부트를 이용해서 프로젝트를 만들었다. 스프링 부트를 사용한 이유는 스프링 부트에 톰캣과 같은 WAS가 내장되어 있기 때문이다. 그래서 서블릿을 만들고 서블릿을 스캔하도록 설정하면 스프링 부트가 띄워질 때 서블릿 컨테이너에 만든 서블릿을 다 넣어둔다. 그리고 외부에서 이 서버로부터 요청이 들어올 때 요청을 처리하는 서블릿을 찾아 적절한 응답을 보내줬다.
HttpServletRequest
서블릿에서 요청 정보를 가져오려면 파라미터로 들어오는 HttpServletRequest 객체로부터 가능하다. 이 녀석은 말 그대로 HTTP 요청에 대한 모든 정보를 다 가지고 있어서 원하는 정보를 내가 쏙쏙 뽑아올 수 있다.
간단한 Form 하나를 만들어서 테스트 해보자. 이 폼을 웹 브라우저에서 열려면 저 webapp 경로 아래의 경로 그대로를 브라우저에 입력하면 된다. 그러니까 http://localhost:8080/basic/hello-form.html 이라고 입력하면 된다.
여기에 입력을 하고 전송버튼을 누르면 서버에 어떻게 들어올까?
실행결과:
[전체 파라미터 조회] - start
username=choi
age=20
[전체 파라미터 조회] - end
[단일 파라미터 조회] - start
username = choi
age = 20
[단일 파라미터 조회] - end
[이름이 같은 복수 파라미터 조회] - start
username = choi
[이름이 같은 복수 파라미터 조회] - end
GET 방식으로 받았던 코드 그대로인 상태로 수행했는데 잘 가져와진다. 그 이유는 Form으로 보내는 데이터 형식은 GET 방식의 쿼리 파라미터랑 형식이 같기 때문이다. 다음은 개발자 도구에서 네트워크 탭에서 요청 시 보여지는 Form Data이다. 쿼리 파라미터랑 형식이 똑같기 때문에 getParameter()로 여전히 가져올 수가 있다.
그리고 한가지 더 HTML Form으로 데이터를 보낼 때 Content-Type을 보면 application/x-www-form-urlencoded로 되어 있다. 이게 Form을 통해 데이터를 전달할 때 Content-Type이다.
그럼 GET은 Content-Type이 어떻게 될까? Null이다. 왜냐하면 GET은 바디에 데이터를 태우지 않기 때문이다. 그래서 GET - 쿼리 파라미터와 POST - HTML Form 으로 데이터를 전송할 때 받는 방법은 둘 다 형식이 같아서 Parameter 받아오는 방법을 사용하면 된다는 것을 알았다. 물론, POST의 HTML Form으로 전송하는 데이터는 바디로 들어오는 데이터를 읽어서 가져와도 된다. 상관없다. 근데 좀 귀찮다 그 작업이 InputStream으로 가져와서 변환하고 어쩌구 해야해서 그럴 필요가 없이 getParameter()를 호출하면 된다.
HttpServletRequest 객체에서 getInputStream()을 호출하면 Body의 데이터를 가져올 수 있다. 그 데이터가 바이트 형식으로 되어 있는데 이것을 스트링으로 변환할 때 여러 방법이 있는데 지금은 스프링 부트를 사용중이니까 (앞으로도 그럴거니까) 스프링이 제공하는 StreamUtils라는 클래스가 있다. 이 녀석을 사용하면 매우 편리하다.
한번 실행해보자. Body에 데이터를 태워야 하니까 Postman으로 테스트 해보자.
우선 POST로 설정하고 Body탭에 raw - Text를 선택하면 단순 스트링으로 바디에 데이터를 보낸다.
이렇게 전송을 하면 다음과 같이 보낸 데이터를 받아올 수 있다.
그리고 이렇게 단순 텍스트로 데이터를 보내면 Request Headers에는 Content-Type이 어떻게 되어 있을까? text/plain이다.
그러니까 Request Headers에 Content-Type이 text/plain으로 되어있는건 "아 요청할 때 단순 텍스트로 바디에 값을 넣었구나!"라고 생각하면 된다.
하지만, 이렇게 보내는 경우는 거의 없다. 그래서 JSON으로 데이터를 주고 받는 방법에 대해 자세히 알아야 한다. 이제 JSON으로 데이터를 주고 받을 때 어떻게 하는지 보자.
들어오는 JSON 데이터를 객체로 변환하려고 한다. 자바는 모든게 객체이기 때문에.
그래서 변환할 객체에 대한 클래스를 하나 만들자.
HelloData
package org.example.servlet.basic.model;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class HelloData {
private String username;
private int age;
}
JSON이라고 뭐 엄청 다른게 아니다. 단순 스트링으로 변환할 때처럼 똑같이 할 수 있다. JSON도 텍스트다. 저 상태로 한번 실행해보자.
Postman으로 다음과 같이 요청한다. JSON으로 바디에 데이터를 넣으려면 raw - JSON을 선택하면 된다.
이렇게 JSON으로 바디에 데이터를 넣으면 Request Headers에서 Content-Type은 application/json이 된다.
요청해보면 서버에서는 다음과 같이 스트링으로 잘 받아진다.
근데 원하는건 이렇게 문자열로 들어오는걸로 끝이 아니라 들어오는 데이터를 객체로 변환해서 객체로 다루고 싶은 것이다. 객체로 변환하려면 라이브러리를 사용해야 한다. 스프링에서는 Jackson 라이브러리가 공식적으로 지원해주고 있다. 이 라이브러리에서 ObjectMapper라는 녀석이 있는데 이 녀석을 사용하면 된다. 가장 좋은 방식은 빈으로 딱 하나만 등록해놓고 가져다가 사용하는 것이다. 그래서 우선 빈으로 등록하자.
ServletApplication
package org.example.servlet;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.context.annotation.Bean;
@ServletComponentScan
@SpringBootApplication
public class ServletApplication {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
public static void main(String[] args) {
SpringApplication.run(ServletApplication.class, args);
}
}
빈으로 등록하는 방법은 여러가지가 있는데, 가장 간단한 건 @SpringBootApplication 애노테이션이 붙은 클래스에서 저렇게 @Bean 애노테이션으로 등록하는 것이다. 등록하면 이제 주입(DI)을 할 수 있다.
자, 흐름을 한번 짚고 넘어가보면 HttpServletRequest 객체에서 바디의 데이터를 꺼내와서 스트림에 바이트를 스트링으로 변환하고 그 스트링 형태의 데이터를 ObjectMapper를 통해 객체로 변환해내는 과정을 직접 해봤다. 이거 Spring MVC 사용하면 파라미터에 service(HelloData helloData)로 끝낼 수 있다. 그럼 스프링은 저 과정을 다 대신해준다. 근데 이런 과정을 스프링이 해준다는 것을 알고 쓰는거랑 모르고 쓰는거랑은 천지차이라고 누가 그러더라. 맞는 말인거 같고. 그래서 직접 이 과정을 한번은 해보자는 취지에서 공부중이다.
이제 HttpServletRequest 객체에 대해서 알아봤으니 HttpServletResponse에 대해서도 알아보자.
HttpServletResponse
요청에 대한 응답을 줄 때도 역시나 여러 정보들이 포함된다. 응답 코드, Response Body, Response Headers 요청과 똑같은 스펙에 필요한 데이터를 넣어서 응답해준다. 그래서 하나씩 살펴보자.
그리고 Content-Length 라는건 말 그대로 바디에 있는 데이터의 길이를 의미한다. Request, Response 둘 다 헤더에 넣을 수 있는데 안 넣으면 자동으로 계산이 된다. 그래서 지금 나의 코드는 헤더에 저 Content-Length를 넣지 않았지만 응답 정보를 보면 이렇게 자동으로 계산해서 보여준다.
우선 응답 메시지를 JSON으로 돌려줄거니까 Content-Type을 application/json으로 설정한다.
그리고 객체를 하나 만들어서 그 객체를 JSON으로 만들어서 돌려줄거다. 간단하다. JSON을 객체로 변환했다면 객체를 JSON으로 변환하는것도 가능하다. ObjectMapper를 사용해서 변환한다.
실행 결과는 이렇다. 응답 메시지에 JSON 데이터로 잘 들어왔다.
이제 이 코드가 스프링에서는 그냥 반환 타입이 HelloData고 객체 만들어서 리턴만 해주면 끝난다. 이거 이렇게 된다는 걸 알고 스프링을 써도 써야지 모르면 안된다고 생각한다. 이렇게까지 하면 이제 응답 메시지를 HTML, 단순 텍스트, JSON으로 보내는거까지 다 알아본것이다. 이제 진짜 MVC패턴을 스프링없이 만들어보자.
저번 포스팅에선 서블릿에 대해 알아봤다. 서블릿은 HTTP 프로토콜을 통해 클라이언트와 서버간 통신이 가능하게 해주는 것이었다. 개발자 대신 HTTP 요청정보와 응답정보를 만들어주고, 외부 요청이 들어오면 그 요청을 처리하는 서블릿을 서블릿 컨테이너에서 호출하면서 진행된다.
이제 서블릿으로 직접 웹 애플리케이션을 만들어 볼건데 그 전에 웹 기술의 역사와 HTML, HTTP API, CSR, SSR과 같은 용어 정리를 한 번 하고 넘어가자. 우선 반드시 알아두어야 할 키워드는 다음과 같다.
정적 리소스 - 고정된 HTML 파일, CSS, JS, 이미지, 영상 등을 제공
HTML 페이지 - 동적으로 필요한 HTML 파일을 생성해서 전달
이때 브라우저는 HTML 페이지를 해석하는 역할을 한다.
HTTP API - HTML이 아니라 데이터를 전달한다. 주로 JSON형식을 사용하고 다양한 시스템(앱, 웹, 서버)에서 호출할 수 있다.
그래서 앱, 웹, 서버 어디서나 요청을 할 수 있고 요청에 따라 적절한 응답값을 화면이 아닌 데이터로 돌려주는 것이 HTTP API이다.
서버사이드 렌더링(SSR)
서버 사이드 렌더링은 서버에서 HTML 최종 결과물을 만들어서 웹 브라우저에 전달하는 방식이다. 주로 정적인 화면에 사용한다. 대표적인 기술로는 Thymeleaf가 있다.
클라이언트 사이드 렌더링(CSR)
HTML 결과를 자바스크립트를 사용해 웹 브라우저에서 동적으로 생성해서 적용한다. 주로 동적인 화면에 사용한다. 그러니까 서버에서는 이 화면을 만들기 위해 필요한 스크립트와 디자인을 클라이언트에 주고 클라이언트가 그 코드를 서버에 요청하는 방식이다. 대표적인 기술로는 React, Vue가 있다.
자바 웹 기술 역사
서블릿 - 1997
HTML 생성이 어려움
JSP - 1999
HTML 생성은 편리하지만 비즈니스 로직까지 너무 많은 역할 담당
서블릿, JSP 조합 MVC 패턴 사용
모델, 뷰, 컨트롤러로 역할을 나누어 개발
다수의 MVC 프레임워크가 출몰(2000 - 2010)
애노테이션 기반의 스프링 MVC 등장
@Controller
스프링 부트의 등장
스프링 부트는 서버를 내장하고 있다. 무슨 말이냐면 과거에는 서버에 WAS를 직접 설치하고, 소스는 War 파일을 만들어서 설치한 WAS에 배포했는데 스프링 부트는 빌드 결과(Jar)에 WAS 서버를 포함하고 있다. 그래서 빌드, 배포의 단순화가 됐다.
스프링 부트는 스프링 프레임워크로 개발할 때 필요한 여러 설정을 개발자 대신 해준다. 그리고 최적의 버전 호환성도 맞춰준다.
그래서 스프링 MVC, 스프링 부트가 최종 병기가 된 상태이다.
여기에 스프링이 공식적으로 지원하는 Thymeleaf라는 뷰 템플릿과 같이 사용하면 된다.
Spring MVC를 제대로 공부하려면, Spring MVC가 없던 시절로 돌아가서 어떤 웹 기술로부터 출발했고 어떻게 발전했는지 이해하는게 중요하다는 것을 깨닫고 맨 밑바닥부터 잡고 올라와 볼 생각이다. 그 시작엔 서블릿이 있다.
서블릿
간단한 HTML Form에서 데이터를 서버로 전송한다고 가정해보자.
유저는 Form에 username, age를 입력하고 전송 버튼을 누르면, HTTP 프로토콜을 이용하여 POST 방식으로 서버로 요청이 전달된다.
그럼 서버입장에선 날라온 요청(Request)을 분석하고 적절한 처리를 해야할 것이다.
근데 만약 아무런 도움을 받지 않고 0부터 100까지 전부 다 직접 구현해야 한다면 어떨까?
우선 TCP/IP 연결 대기부터 시작해서 소켓을 연결하는 코드를 작성하고, HTTP 요청 메시지를 파싱하고, 어떤 Method이고 어떤 URL로의 호출인지 파악하고, 바디를 읽어서 데이터를 가져오고, .... 응답 메시지를 생성하고, 응답을 전달하는 이 모든 과정을 직접 구현해야 한다면 정말 복잡한 일이 될 것이다. 그리고 이 안에서 의미있는 비즈니스 로직은 바디에 있는 데이터를 가져와서 비즈니스 로직에 맞게 데이터를 어떻게 정제하고 데이터베이스에 저장하는 딱 이 부분밖에 없다.
이 모든 일련의 과정을 전세계 모든 개발자가 다 일일이 하고 있다면 너무나 비효율적이지 않을까? 여기서 서블릿이 등장한다.
최신 스타일의 서블릿 코드이지만 충분하다. 저렇게 urlPatterns로 "/hello"라고 설정하고 웹 브라우저에서 "서버URL/hello" 라고 입력하면 이 서블릿 코드가 실행이 된다. 그래서 실제로 요청과 응답에 필요한 모든 부분은 이 서블릿이 대신 다 해주고 저 코드에서처럼 service()에서 작성한 애플리케이션 로직(비즈니스 로직)에 관련된 코드만 개발자가 작성하면 되는것이다.
저 service()가 호출되면 두 개의 파라미터가 들어온다.
HttpServletRequest request: HTTP 요청 정보를 편리하게 사용할 수 있는 객체
HttpServletResponse response: HTTP 응답 정보를 편리하게 제공할 수 있는 객체
위 그림처럼 클라이언트로부터 요청이 들어오면 그 요청 정보를 직접 파싱하지 않고 서블릿이 다 해준다고 했는데 예를 들면, Method 정보라던가, Parameter 정보라던가, Content-Type 정보라던가 등 요청 시 필요한 모든 정보를 서블릿이 우리대신 다 만들어서 가져다주고 그게 HttpServletRequest이다.
그리고, 위 그림처럼 응답을 해줘야 하는데 이 때 응답 메시지를 직접 만드는게 아니라 서블릿이 제공해주는 HttpServletResponse 객체를 통해 내가 원하는 것을 넣어서 응답만 해주면 된다.
서블릿과 HTTP 요청, 응답 흐름
그래서 이 내용을 그림으로 살펴보면 다음과 같다.
HTTP 요청이 딱 들어오면 웹 애플리케이션 서버(WAS)와 개발자가 다음과 같은 작업을 한다.
WAS는 Request, Response 객체를 새로 만들어서 서블릿 객체 호출
개발자는 Request 객체에서 HTTP 요청 정보를 편리하게 꺼내서 사용
개발자는 Response 객체에 HTTP 응답 정보를 편리하게 입력
WAS는 Response 객체에 담겨있는 내용으로 HTTP 응답 정보를 생성
저 그림에서 서블릿 컨테이너라는 게 보이는데 쟤는 뭘까?
서블릿 컨테이너
서블릿을 지원하는 WAS를 서블릿 컨테이너라고 함
서블릿 컨테이너는 서블릿 객체를 생성, 초기화, 호출, 종료하는 생명주기를 관리
서블릿 객체는 싱글톤으로 관리
고객의 요청이 올 때 마다 계속 객체를 생성하는 것은 비효율
최초 로딩 시점에 서블릿 객체를 미리 만들어두고 재활용
모든 고객 요청은 동일한 서블릿 객체 인스턴스에 접근
공유 변수 사용 주의
서블릿 컨테이너 종료시 함께 종료
동시 요청을 위한 멀티 스레드 처리 지원
동시 요청을 위한 멀티 스레드 처리를 지원한다는 건 어떤 의미인지 살펴보자.
동시 요청과 멀티 스레드
지금까지 배운 내용은 웹 애플리케이션 서버가 있고 클라이언트가 이 WAS에 HTTP 요청을 보내면 그에 상응하는 미리 만들어 둔 서블릿 객체를 서블릿 컨테이너로부터 꺼내서 요청과 응답 데이터를 편리하게 꺼내쓰고 작성하여 다시 응답을 돌려준다는 것이다.
이런 그림이라고 보면 되는데 여기서 궁금한 점이 있다. 저 호출단계에서 서블릿 객체는 누가 호출할까?
서블릿 객체를 호출하는 것은 스레드이다.
스레드
애플리케이션 코드를 하나하나 순차적으로 실행하는 것은 스레드
자바 메인 메서드를 처음 실행하면 main이라는 이름의 스레드가 실행
스레드가 없다면 자바 애플리케이션 실행이 불가능
스레드는 한번에 하나의 코드 라인만 수행
동시 처리가 필요하면 스레드를 추가로 생성
스레드가 서블릿 객체를 호출하고 사용한다는 것을 알았고 이 일련의 과정을 또 그림으로 이해해보자.
최초의 상태
요청이 들어옴
요청이 들어왔으니 해당 요청에 대해 스레드를 할당한 다음 스레드는 서블릿 객체를 호출한다.
요청을 처리하고 응답
모든 작업이 끝남
단일 요청일 땐 이렇게 간단하게 처리가 된다. 이제 다중 요청을 봐보자.
요청이 들어옴
요청1이 들어온 상태에서 또 다른 요청2가 들어옴
요청1이 이미 스레드를 사용중이기 때문에 요청2는 대기해야한다.
그런데, 요청1이 처리가 지연되면 결국 요청1, 요청2 모두 작업을 처리하지 못하는 상태가 일어날 수 있다.
요청1과 요청2 모두 처리 불능 상태로 빠짐
이런 문제를 해결하기 위해서 가장 간단한 방법은 스레드를 하나 더 생성하면 된다.
요청1이 스레드를 사용하고 있는 중에 요청2가 들어와서 새로운 스레드를 만듦
이렇게 요청이 들어올때마다 스레드를 생성하면 어떤 장단점이 있을까?
장점
동시 요청을 처리할 수 있다.
리소스(CPU, 메모리)가 허용할 때까지 처리 가능
하나의 스레드가 지연되어도, 나머지 스레드는 정상 동작한다.
단점
스레드 생성 비용은 매우 비싸다. 고객의 요청이 올 때 마다 스레드를 생성하면 응답 속도가 늦어진다.
스레드는 컨텍스트 스위칭 비용이 발생한다.
스레드 생성에 제한이 없다. 즉, 고객 요청이 너무 많이 오면 CPU, 메모리 임계점을 넘어서서 서버가 죽을 수 있다.
그래서 이런 문제를 해결하기 위해 보통의 WAS는 스레드 풀이라는 개념을 사용한다.
풀 안에 적절한 수의 스레드를 미리 만들어서 넣어두고 요청이 들어올 때마다 스레드 풀에서 하나씩 꺼내서 서블릿 객체를 호출해서 요청을 처리한다. 그래서 풀에 있는 모든 스레드가 다 사용중일 때 요청이 들어오면 다음과 같이 그 요청부턴 대기상태로 기다리거나 또는 거절하게 된다.
이렇게 풀을 만들어서 사용하면 요청이 들어올 때마다 스레드를 생성하는 단점을 보완할 수 있다.
스레드 풀 특징
필요한 스레드를 스레드 풀에 보관하고 관리한다.
스레드 풀에 생성 가능한 스레드의 최대치를 관리한다. 톰캣은 최대 200개 기본 설정 (변경 가능)
스레드 풀 사용
스레드가 필요하면, 이미 생성되어 있는 스레드를 스레드 풀에서 꺼내서 사용한다.
사용을 종료하면 스레드 풀에 해당 스레드를 반납한다.
최대 스레드가 모두 사용중이어서 스레드 풀에 스레드가 없으면 기다리는 요청을 거절하거나 특정 숫자만큼만 대기하도록 설정할 수 있다.
스레드 풀 장점
스레드가 미리 생성되어 있으므로 스레드를 생성하고 종료하는 비용이 절약되고 응답 시간이 빠르다.
생성 가능한 스레드의 최대치가 있으므로 너무 많은 요청이 들어와도 기존 요청은 안전하게 처리할 수 있다.
그럼, 이 최대 스레드 수는 어떻게 설정하면 좋을까?
WAS의 주요 튜닝 포인트는 최대 스레드(max thread)수이다.
이 값을 너무 낮게 설정하면? - 동시 요청이 많으면 서버 리소스는 여유로워도 클라이언트는 금방 응답 지연
이 값을 너무 높게 설정하면? - 동시 요청이 많으면 CPU, 메모리 리소스 임계점 초과로 서버 다운
이 값을 너무 낮게 설정한다면 사용가능한 리소스(CPU, 메모리)는 넘쳐나는데도 고객은 응답을 지연받으니 매우 안좋은 결과가 생기고 이 값을 너무 높게 설정하면 동시 요청이 많아질수록 사용하는 리소스도 많아지다가 임계점 초과로 서버가 다운된다. 그래서 적절한 수의 최대 스레드 수가 필요하다.
그럼 적절한 수는 어떻게 찾아요?
정답은 없고 최대한 실제 서비스와 유사하게 성능 테스트를 해가면서 적절한 숫자를 찾아야 한다. (JMeter, nGrinder와 같은)
가장 중요한 핵심은,
멀티 스레드에 대한 부분은 WAS가 다 처리해준다는 것.
개발자는 멀티 스레드 관련 코드를 신경쓰지 않고 아까 저 위에 코드처럼 service() 코드만 작성하면 된다.
그리고 최대 스레드 개수만 적절하게 설정해주면 된다.
멀티 스레드 환경이므로 싱글톤 객체(서블릿, 스프링 빈)의 공유변수만 주의해서 사용하면 된다.