Understanding Web Applications
Web server, Web application server
웹 서버는 정적인 파일을 서빙하기 때문에, HTML을 특정 사용자마다 좀 다르게 보여줄 수 없다.
그런데 WAS는 사용자에 따라서 그 사용자 이름이라든지, 다른 화면도 보여줄 수 있다. 프로그래밍을 할 수 있기 때문
서블릿, JSP, 스프링 MVC 등이 WAS에서 동작한다.
웹 서버는 정적 리소스 제공, WAS는 애플리케이션 로직까지 실행할 수 있다.
다른 언어쪽은 다르지만, 자바는 서블릿 컨테이너 기능을 제공하면 WAS이다. 그런데 이것도 요즘엔 애매하다. 서블릿 없이 자바 코드를 실행하는 서버 프레임워크도 있다.
HTML, CSS, JS, 이미지 같은 건 값이 싸다.
시스템적으로 보면 파일 하나 두고 그것을 잘 서빙하면 되기 때문에 단순하다.
반면, 애플리케이션 로직은 비싸다. 비싸다는 건 여러 의미가 있는데, 주문 애플리케이션 로직이 이미지 하나 보여 주는 로직보다 비싸다. 이미지, CSS 파일, JS 파일, 정적인 HTML 파일 제공하는 건 그냥 파일에 있는 거 불러서 내려 주면 된다.
그런데 애플리케이션 로직은 DB 뒤지고, 필요하면 다른 서버 호출도 해야 하는 등 복잡하다.
WAS는 잘 죽는다.
정적 리소스만 제공하는 웹 서버는 잘 안 죽는다. 단순한 파일을 읽어서 클라이언트에 제공만 하면 된다. 프로그램 로직이 들어갈 게 없다. 폴더를 놓고, 앞으로 이 URL이 오면 이 폴더에 있는 파일 제공한다고 설정만 해서 쓰면 된다. 처리 못 하는 건 WAS 호출하도록 설정만 하면 된다.
즉, 웹 서버는 계산하는 게 거의 없다. 거의 잘 안 죽는다.
화면을 제공하지 않고 API로 데이터만 제공하면 그땐 웹 서버가 굳이 없어도 된다. WAS만 구축해도 된다.
Servlet
~~~~~/hello URL이 붙으면 웹 브라우저에서 서버로 요청이 왔을 때 서블릿 코드의 service가 실행된다.
웹 브라우저가 localhost:8080/hello라고 요청하면, WAS에서 요청 메시지를 기반으로 request, response 객체를 새로 만든다. 그다음에 request, response 객체를 파라미터로 넘기면서 우리가 만든 helloServlet을 실행해 준다. 그리고 끝나고 리턴하면, response 객체 안을 뒤져서 그걸 바탕으로 HTTP 응답 메시지를 만든다. 그리고 웹 브라우저에 응답 메시지를 전달한다.
WAS 안엔 서블릿 컨테이너가 있는데, 서블릿 객체를 서블릿 컨테이너가 자동으로 생성, 호출, 관리한다.
HTTP request, response는 고객마다 데이터가 다르다. HTTP 요청이 올 때마다 request, response 객체는 항상 새로 만드는 게 맞다.
하지만 helloServlet은 항상 생성할 필요가 없다. 요청 올 때마다 새로 생성하는 건 비효율적이다.
서블릿을 지원하는 이 WAS의 큰 특징은 동시 요청을 위한 멀티 스레드 처리를 지원한다는 것이다.
Concurrent Requests - Multi-thread
스레드가 하나만 있다고 가정할 때, 요청이 하나 오면 스레드를 할당한다. 그리고 스레드를 가지고 servlet을 실행한다.
그리고 스레드를 가지고 응답까지 하고 나면 스레드가 휴식한다.
스레드는 하나인데 다중 요청인 경우, 요청1이 왔을 때 스레드를 써서 서블릿을 요청한다. 처리가 잘 되면 나가고 다른 애들도 쓸 수 있다. 그런데 만약 서블릿 안에서 여러 이유로 인해 처리가 지연되고 있다면, 이때 요청2가 오면 스레드를 기다려야 한다. 이럴 경우 요청1과 요청2 모두 타임아웃 등으로 오류가 난다.
요청이 올 때마다 스레드를 생성해도 된다. 개발자가 직접 이렇게 개발하는 게 아니라 WAS가 이런 식으로 구현해 줘도 된다는 뜻
스레드를 생성할 때 CPU도 많이 쓴다. 시간도 걸린다.
스레드를 다 쓰면 스레드를 죽이는 게 아니라 스레드 풀에 반납한다.
생성 가능한 쓰레드의 최대치가 있으므로 너무 많은 요청이 들어와도 기존 요청은 안전하게 처리할 수 있다. -> 물론 이땐 서버를 늘리긴 해야 한다.
CPU 사용률을 50% 정도는 써 줘야 하는데 설정 하나를 못해서 5%이다. 설정만 잘 해 주면 같은 서버로 10배는 처리할 수 있는데 이걸 몰라서 AWS에 인스턴스를 10배 늘리면 돈만 10배로 나가는 셈이다.
스레드 풀의 적정 숫자는 성능 테스트를 통해 알아 봐야 한다.
멀티 쓰레드 환경이므로 싱글톤 객체(서블릿, 스프링 빈)는 주의해서 사용 -> 공유 변수 조심히 쓰라는 의미
HTML, HTTP API, CSR, SSR
HTTP API는 웹 브라우저가 HTML 렌더링 할 때 쓰는 게 아니라, 다양한 시스템에서 사용한다.
HTTP API는 HTML을 보여 주는 전송을 제외한 모든 곳에서 사용된다.
백엔드 개발자가 서비스를 제공할 때 고민해야 하는 포인트는 3가지이다.
정적 리소스 어떻게 제공할 건지, 동적으로 제공되는 HTML 페이지 어떻게 제공할 건지, HTTP API 어떻게 제공할 건지
백엔드 개발자는 주로 이 세 가지를 고민해야 한다.
서버 사이드 렌더링
만약 웹 브라우저가 주문 내역 달라고 서버에 요청을 한다. 그러면 서버는 주문 DB를 조회해서 JSP나 타임리프를 가지고 HTML을 동적으로 생성한 다음에 최종적으로 HTML을 서버에서 다 만든다. 서버 사이드에서 HTML 화면을 렌더링 해서 웹 브라우저에 HTTP 응답에 HTML 코드를 실어서 응답으로 보내면 웹 브라우저는 HTML을 그대로 렌더링 해서 보여 준다.
즉, HTML을 만드는 과정은 서버에서 끝내고, 웹 브라우저는 완전히 다 생성된 것을 보여 주기만 한다.
클라이언트 사이드 렌더링
구글 지도의 경우, 지도를 움직이거나 확대해도 웹 브라우저 URL이 바뀌지 않는다.
웹 브라우저에서 서버에 /orders.html 요청하는 건 똑같다.
그런데 CSR의 경우엔 텅 비어 있는 HTML을 내려 주고, 자바스크립트 링크를 내려 준다.
그다음엔 웹 브라우저가 서버에 자바스크립트를 요청한다. 자바스크립트 코드 안에는 클라이언트 로직과, HTML 어떻게 자바스크립트로 렌더링 할지에 대한 로직도 있다.
애플리케이션 로직이 있다고 했다. 그러면 웹 브라우저가 HTTP API를 가지고 서버를 호출한다. 그러면 서버에서 JSON 데이터를 내려 준다.
그러면 클라이언트 로직에서 데이터를 API로 다 조회했기 때문에 이걸 HTML 렌더링 코드에 섞어서 예쁘게 HTML을 웹 브라우저에서 만든다.(자바스크립트 코드로 HTML을 만들어서 HTML이 보인다.)
백엔드 개발자들은 JSP, 타임리프 등을 서버 사이드 렌더링 기술로 사용한다.
SSR, CSR 다 장단점이 있다.
리액트나 뷰를 사용하더라도 CSR + SSR 동시에 지원하는 웹 프레임워크도 있다.
SSR을 사용하더라도, 자바스크립트를 사용해서 화면 일부를 동적으로 변경하는 건 가능하다.
JSP는 거의 사장되고, 타임리프가 낫다.
History of Java Backend Web Technology
서블릿은 자바 코드로 짜야 하므로, HTML을 동적으로 생성하는 것이 굉장히 어렵다. JSP는 HTML 화면도 편하게 만들 수 있고 자바 코드도 그 안에서 작성할 수 있다. 그러나 JSP나 프리마커, 벨로시티 등은 if 같은 코드가 들어가므로 HTML 파일을 열어도 JSP 코드 같은 게 다 보인다.(서버 안 띄우고 바로 파일을 열 때를 말하는 듯) 그러나 타임리프는 HTML 속성 같은 곳에 태그를 넣어서 문제를 해결하므로, HTML 모양을 유지하면서 뷰 템플릿을 적용할 수 있다.
MVC 프레임워크 춘추 전국 시대를 통일한 것이 애노테이션 기반의 스프링 MVC
원랜 스프링과 MVC 프레임워크를 붙여야 하는데, 이건 스프링이 제공하는 MVC이기 때문에 통합에 대한 고민이 없다. 그리고 애노테이션 기반이기 때문에 매우 편리하고 코드도 깔끔하다. 그래서 지금도 사용하고 있다.
그 이후의 또 다른 변화가 스프링 부트의 등장이다. 개발자들이 불편해했던 것들을 스프링 부트가 자동화해 줬다.
과거엔 톰캣 같은 WAS를 직접 서버에 설치했다. 그리고 실행해 놓고, 소스 코드는 따로 만들어서 빌드한다. jar 파일을 모아서 war 파일(이 안에 jsp도 있는 듯)을 만들어서 이것을 WAS에, 배포하는 폴더가 있는데 그 안에 넣는다. 그러면 배포가 된다.
지금은 그냥 빌드할 때 빌드 안에 톰캣 서버를 넣는다. 그러면 서버에 톰캣을 설치할 필요가 없다. 그냥 빌드된 jar를 아무 서버에 넣고 java -jar로 실행하면 된다. 그럼 서버가 뜬다. 서버가 뜨면서 내가 만든 코드도 다 실행된다.
Servlet
Create Project
Hello Servlet
스프링 부트 환경에서 서블릿을 등록하고 사용해 보겠다. 사실 서블릿은 스프링이랑 전혀 관련 없다. 환경 설정이 쉬워서 지금은 그냥 스프링 부트 환경에서 서블릿 사용하는 걸 해 보겠다.
스프링 부트에서 서블릿을 쓰려면, 이런 걸 지원한다.
@ServletComponentScan이라는 애노테이션을 지원한다.
스프링이 자동으로 현재 패키지(hello.servlet)를 포함해서 하위 패키지를 다 뒤져서 서블릿을 다 찾아서 자동으로 서블릿을 등록해서 실행할 수 있게 돕는다.
Ctrl + O
서블릿이 호출되면 service 메서드가 호출된다.
HttpServletRequest , HttpServletResponse는 인터페이스이다.
request = org.apache.catalina.connector.RequestFacade@1a230594
response = org.apache.catalina.connector.ResponseFacade@4ba26ebe
이건 톰캣쪽 라이브러리이다.
톰캣이나 제티나 Undertow 등 여러 WAS들이 서블릿 표준 스펙을 구현한다. 그 구현체들이 찍힌 것이다.
http://localhost:8080/hello?username=kim
쿼리 파라미터를 서블릿은 굉장히 편리하게 읽도록 지원한다.
String username = request.getParameter("username");
요즘엔 문자 인코딩은 옛날 시스템이 아니라면 UTF-8을 써야 한다.
EUC-KR은 지금은 쓰면 안 된다.
response.getWriter().write();를 하면 HTTP 메시지 바디에 데이터가 들어간다.
response.setContentType("text/plain");
response.setCharacterEncoding("utf-8");
이 두 개는 헤더 정보에 들어간다.
F12 누르고 확인할 때
Request Headers는 웹 브라우저가 보내는 정보
서블릿 이름이나 URL 매핑 같은 건 중복이 있으면 안 된다.
스프링 부트를 실행하면 스프링 부트가 내장 톰캣 서버를 띄워 준다. 톰캣 내부의 서블릿 컨테이너를 통해 서블릿을 다 생성해 준다. 그러면 서블릿 컨테이너 안에 helloServlet이 생성된다.
웹 브라우저가 서버에 HTTP 요청 메시지를 만들어서 보낸다. 그러면 서버는 request, response 객체를 만들어서 싱글톤으로 떠 있는 helloServlet을 호출한다. 거기의 service 메서드를 호출하면서 request, response를 넘겨준다. 그리고 우리가 썼던 필요한 작업들(Content-Type 정보들, hello + ~~ 메시지 넣기)을 하게 되면, 얘가 종료되고 나가면서 WAS 서버가 response 정보를 가지고 HTTP 응답 메시지를 만들어서 반환해 준다. 그러면 웹 브라우저에서 hello + 메시지 볼 수 있다.
톰캣 서버는 내부에 서블릿 컨테이너 기능을 가지고 있다.
웰컴 페이지는 localhost:8080 혹은 localhost:8080/index.html으로 들어오면 index.html이 자동으로 로딩된다.
내 도메인 왔을 때 첫 화면이다.
HttpServletRequest - Overview
POST /save HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
username=kim&age=20
application/x-www-form-urlencoded
이걸 보면 HTML form을 통해 전달된 거로 보인다.
바디는 HTML form에서 파라미터로 넘어올 수도 있고, HTTP 메시지 바디가 직접 오기도 한다.
HttpServletRequest 객체는 HTTP 요청 메시지를 편리하게 읽는 기능만 있는 게 아니라, 부가 기능이 더 있다.
임시 저장소 기능이다.
HTTP 요청 메시지 안에 작은 데이터 저장소가 있다. HTTP 요청 메시지가 살아 있는 동안 쓸 수 있다.
저장: request.setAttribute(name, value)
조회: request.getAttribute(name)
나중에 MVC 패턴에서도 쓰인다.
HTTP form에서 username=kim&age=20
이렇게 데이터가 오면 이거를 request.getParameter() 등으로 편리하게 읽을 수 있다.
그리고 요즘엔 메시지 바디에 json 등이 많이 오는데 그런 걸 통으로 읽을 수 있는 기능도 지원한다.
물론 json을 파싱하려면 라이브러리를 써야 한다.
HttpServletRequest - Basic Usage
method extract 기능 이용
// Enumeration<String> headerNames = request.getHeaderNames();
// while(headerNames.hasMoreElements()) {
// String headerName = headerNames.nextElement();
// System.out.println(headerName + ": " + headerName);
// }
request.getHeaderNames().asIterator()
.forEachRemaining(headerName -> System.out.println(headerName + ": " + headerName));
주석 처리 안 된 부분이 더 최신 스타일
System.out.println("[Accept-Language 편의 조회]");
request.getLocales().asIterator()
.forEachRemaining(locale -> System.out.println("locale = " +
locale));
System.out.println("request.getLocale() = " + request.getLocale());
System.out.println();
request.getLocales(): 헤더의 Locale 정보를 다 꺼낼 수 있다. (Accept-Language 정보)
가장 높은 걸 자동으로 꺼내고 싶으면 request.getLocale()
request.getContentType() = null
결과가 null인 이유: GET 방식이라서 바디가 없다. HTTP 바디에 뭔가 있어야 Content-Type이 의미가 있다.
String host = request.getHeader("host");
헤더 하나만 조회한다. 이 경우엔 원하는 헤더가 host
--- 기타 조회 start ---
[Remote 정보]
request.getRemoteHost() = 0:0:0:0:0:0:0:1
request.getRemoteAddr() = 0:0:0:0:0:0:0:1
request.getRemotePort() = 18460
[Local 정보]
request.getLocalName() = 0:0:0:0:0:0:0:1
request.getLocalAddr() = 0:0:0:0:0:0:0:1
request.getLocalPort() = 8080
--- 기타 조회 end ---
Remote 정보는 요청이 온 거에 대한 정보이다.
Local 정보는 나의 서버에 대한 정보이다.
HTTP 메시지의 정보는 아니고, 내부에서 네트워크 커넥션이 맺어진 정보들을 가지고 알 수 있는 정보들이다.
HTTP Request Data - Overview
POST - HTML form의 메시지 바디를 보면
username=hello&age=20
GET - 쿼리 파라미터와 비슷하게 생겼다.
나중에 설명하겠지만 이 두 개를 조회할 땐 방법이 호환된다.
HTTP Request Data - GET Query Parameter
http://localhost:8080/request-param?username=hello&age=20&username=hello2
이렇게 하나의 파라미터 이름에 여러 값들을 넘길 수도 있다.(잘 쓰이진 않는다.)
이렇게 하면 내부 우선순위에서 먼저 잡히는 게 나온다.
이런 경우에 조회하는 방법은
String[] usernames = request.getParameterValues("username");
위와 같은 경우는 잘 없고 실제론 getParameter()를 가장 많이 사용한다.
HTTP Request Data - POST HTML Form
HTTP Request Data - API Message Body - Plain Text
이번 시간부터는 HTTP 요청 데이터를 일반적으로 웹 브라우저에서 사용하던 방식(이전까지의 GET의 URL 쿼리 스트링 또는 POST form 데이터를 전송하는 방식)이 아니라 HTTP 요청 데이터의 메시지 바디에 내가 원하는 데이터를 직접 넣어서 서버에 전송하고 서버에서 읽는 방식을 배운다.
오래된 시스템에선 xml도 쓰지만 요즘엔 주로 json을 많이 쓴다.
ServletInputStream inputStream = request.getInputStream();
메시지 바디의 내용을 byte 코드로 바로 얻을 수 있다. 그러면 byte 코드를 String으로 바꿔야 한다. 여러 방법이 있지만 스프링의 StreamUtils라는 것이 있다.
String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
항상 byte를 문자로 변환할 때는 어떤 인코딩인지 알려 줘야 한다. 반대로 문자를 byte로 변환할 때도 어떤 인코딩인지 알려 줘야 하는데 지금은 대부분 UTF-8을 사용한다.
@WebServlet(name = "requestBodyStringServlet", urlPatterns = "/request-body-string")
public class RequestBodyStringServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
ServletInputStream inputStream = request.getInputStream();
String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
System.out.println("messageBody = " + messageBody);
response.getWriter().write("ok");
}
}
만약 Postman으로
hello world
fgdfg
sfsds
이렇게 보낸다면 인텔리제이 출력 결과는
messageBody = hello world
fgdfg
sfsds
이렇게 나온다.
+) 테스트해 보니, HTML form으로 전송한 파라미터들도 바디에 들어가기 때문에, InputStream으로 꺼낼 수 있는 듯.
messageBody = username=sdf&age=22
이런 식으로.
웹 브라우저에서 쿼리 파라미터 형식으로 보내면 안 꺼내지는 것 같다.
HTTP Request Data - API Message Body - JSON
보통 json을 그대로 쓰지 않고 객체로 바꿔서 사용한다. json 형식으로 파싱할 수 있게 객체를 만들어 보겠다.
나중에도 쓸 것이기 때문에 basic 패키지 밑에 생성
Getter, Setter가 필요하다. Jackson 라이브러리가 이것들을 기본으로 호출한다(다른 옵션도 있긴 하다.).
json도 문자이다. 특별한 것이 아니다. 그런데 json 형식이라고 Content-Type을 넣어 두면 서버에 따라서는 json 객체로 파싱하는 로직을 넣을 수 있다.
방금 만든 HelloData로 변환시키려면 json 라이브러리가 필요하다. 스프링은 json 라이브러리를 기본적으로 Jackson이라는 라이브러리를 사용한다.
나중에 스프링 MVC가 제공하는 기능을 사용하게 되면
protected void service(HelloData helloData) ...
이렇게 사용할 수 있다. 그러면
ServletInputStream inputStream = request.getInputStream();
String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
System.out.println("messageBody = " + messageBody);
HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
System.out.println("helloData.username = " + helloData.getUsername());
System.out.println("helloData.age = " + helloData.getAge());이 코드들이 없어진다.
HttpServletResponse - Basic Usage
HttpServletResponse는 응답 메시지를 생성하는 역할을 한다.
그리고 원래 쿠키는 헤더에 값을 넣으면 되는데, 직접 넣으면 번거로우므로 편리하게 객체로 넣을 수 있는 방법들을 제공한다.
3XX Redirect를 어떤 식으로 하는지도 원래 HTTP 헤더에 세팅하면 된다. HTTP 응답 코드를 3XX로 하고 헤더에 리다이렉트를 넣으면 되는데, 다 세팅하기 번거로우니 HttpServletResponse 객체에 세팅하면 된다.
response.setHeader("Content-Type", "text/plain");
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Pragma", "no-cache");
response.setHeader("my-header", "hello");
response.setHeader("my-header", "hello");
이렇게 내가 원하는 임의의 헤더를 만들 수도 있다.
response.setContentLength(2);
이건 보통 생략해서 자동으로 계산하도록 한다.
쿠키도 setHeader()로 넣을 수는 있다.
response.setHeader("Set-Cookie", "myCookie=good; Max-Age=600");
그런데 문법 맞추기 귀찮다.
Cookie cookie = new Cookie("myCookie", "good");
cookie.setMaxAge(600); //600초
response.addCookie(cookie);
이렇게 하면 똑같은 효과를 가진다.
response.setStatus(HttpServletResponse.SC_OK);
이걸 한 다음
response.setStatus(HttpServletResponse.SC_FOUND); //302
response.setHeader("Location", "/basic/hello-form.html");
이걸 하면 덮어 버린다.
HTTP Response Data - Simple Text, HTML
우선 Content-Type부터 설정해야 한다.
response.setContentType("text/html");
response.setCharacterEncoding("utf-8");
text/html이 있어야 웹 브라우저가 정상적으로 html 렌더링 한다. 물론 웹 브라우저들이 똑똑해서 이런 게 없어도 동작하긴 하지만 적어 주는 게 정석이다.
writer.println("<html>");
writer.println("<body>");
writer.println(" <div>안녕?</div>");
writer.println("</body>");
writer.println("</html>");
서블릿으로 HTML을 렌더링 할 땐 이렇게 직접 작성해야 한다.
특정 사람 이름을 넣거나 if문 등을 넣는 등, 로직을 바꾸면 동적으로 HTML을 생성할 수 있다.
HTTP response data - API JSON
응답은 크게 3가지이다.
단순 텍스트 보내는 것
HTML 보내는 것
HTTP 메시지 바디에 직접 json 데이터 보내는 것
String result = objectMapper.writeValueAsString(helloData);
객체를 가지고 값을 써서 문자로 바꾸라는 뜻
// Content-Type: application/json
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
HelloData helloData = new HelloData();
helloData.setUsername("kim");
helloData.setAge(20);
// {"username":"kim", "age":20}
String result = objectMapper.writeValueAsString(helloData);
response.getWriter().write(result);
이렇게 한 것도 나중에 스프링 MVC 쓰면
HelloData helloData = new HelloData();
helloData.setUsername("kim");
helloData.setAge(20);
return helloData;
이렇게 바꿀 수 있다.
물론 service 메서드의 리턴 타입도 HelloData
Sort
GET 방식의 쿼리 파라미터와 x-www-form-urlencoded 방식은 사실 모양이 똑같다. 그래서 서버에서 읽을 땐 request.getParameter()로 두 가지 방식을 다 읽을 수 있다.
퉁쳐서 요청 파라미터를 읽는다고 보통 말한다.
HTTP 메시지 바디에 원하는 데이터를 직접 넣는 경우, 단순히 텍스트나 json을 전송할 수 있고, 사실 바이너리 데이터 등 모든 걸 다 전송할 수 있다. 응답에서도 그렇게 쓰인다.
주로 HTTP API에서 사용하는데, 주로 json 형태로 데이터를 전달한다.
HTML form 데이터를 보낼 땐 put이나 patch로 보낼 수 없다.
다만, 스프링은 사실 POST로 전송하는데, input에 히든 필드를 넣어 둔다. 그래서 PUT이라고 하면 서버에서 이것이 마치 PUT인 것처럼 처리해 주기도 한다. 그러나 실제론 POST로 전송된다.
Servlet, JSP, MVC Pattern
Member Management Web Application Requirements
싱글톤이기 때문에
private static Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;
여기서 static 빼도 상관없다.
이번 강의에선 스프링을 쓰지 않을 것이기 때문에 싱글톤 패턴을 직접 구현한다.
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
이렇게 하는 이유는 new ArrayList에 값을 넣거나 조작해도 store에 있는 value들을 건들지 않고 싶어서이다.
물론 이렇게 해도 store에 있는 멤버를 직접 가져와서 안의 값들을 수정하면 수정이 된다.
이건 store 자체를 보호하기 위해서 한 것이다.
public class MemberRepositoryTest
JUnit5부터는 public 없어도 된다.
// given: 이런 게 주어졌을 때
// when: 이런 걸 실행했을 때
// then: 결과가 이러해야 해
assertThat(result).contains(member1, member2);
result 안에 member1과 member2가 있는지
Building a Member Management Web Application with Servlets
String age = request.getParameter("age");
age는 원래 int이지만 getParameter()의 결과는 항상 String이다. 그러므로 변환해야 한다.

여기서 age에 숫자가 아닌 문자 입력하면 오류 페이지 나온다.
hello-form.html 이런 것들은 파일이기 때문에 중간에 프로그래밍 코드를 넣을 수 없다. 그래서 정적이다.
아래는 동적이다.
w.write("<html>\n" +
"<head>\n" +
" <meta charset=\"UTF-8\">\n" +
"</head>\n" +
"<body>\n" +
"성공\n" +
"<ul>\n" +
" <li>id="+member.getId()+"</li>\n" +
" <li>username="+member.getUsername()+"</li>\n" +
" <li>age="+member.getAge()+"</li>\n" +
"</ul>\n" +
"<a href=\"/index.html\">메인</a>\n" +
"</body>\n" +
"</html>");지금은 메모리에 저장하기 때문에 무언가를 저장해도 서버를 내리면 데이터가 다 사라진다.
우리가 지금까지 했던 건 자바 코드에다 HTML을 만들어 넣는 것이어서 불편했다.
템플릿 엔진은 HTML에다 중간중간에 자바 코드를 넣는다.
그러면 HTML 작성이 쉬워진다.
템플릿이 있고, 중간중간에 값을 바꾸는 기능을 제공하는 것을 템플릿 엔진이라고 한다.
템플릿 엔진엔 JSP, Thymeleaf, FreeMarker, Velocity 등이 있다. JSP가 가장 고전적이다.
21:13 부분에서 변경 전 index.html이 나온 이유는 캐시되었기 때문이다. 새로 고침하면 된다.
F12에서 Status의 200이 회색으로 보이면 캐시되었다는 것이다.
Creating a Member Management Web Application with JSP
MVC Pattern - Overview
뷰가 꼭 HTML이 아니라 XML을 생성할 수도 있고, 액셀 파일을 생성할 수도 있고 여러 가지 용도로 사용된다. 그러나 대부분 HTML을 생성한다.
컨트롤러를 설명하기 쉽게 말하려고 위 그림처럼 비즈니스 로직이라고 써져 있지만, 보통 비즈니스 로직이라고 하면 아래 그림이 사실 맞다.

개발할 땐 일반적으로 비즈니스 로직이라고 하면, 회원을 저장하는 로직, 주문 로직 등 핵심 로직이다. 일반적으로 서비스라는 클래스에 의해 비즈니스 로직이 작성된다. 서비스가 Repository를 사용한다.
사용자가 컨트롤러를 호출하면, 컨트롤러는 파라미터 꺼내고, HTTP 요청이 제대로 맞는지 스펙을 확인한다. 잘못되면 400 오류도 내주고 한다. 로직이 잘 맞으면 뒤의 서비스나 Repository를 호출해서 데이터를 저장하거나 주문하는 로직들을 실행한다. 그리고 잘 됐는지 안 됐는지 결과를 받는다. 회원 리스트 같은 건 바로 결과를 받는다. 그리고 Model에 전달한다. 예를 들어 회원 조회이면 회원 조회 결과를 Model에 담고, 뷰 로직에 넘긴다. 그러면 뷰 로직이 Model에서 값을 꺼내서 쭉 출력한다.
일반적으로 비즈니스 로직은 서비스라는 계층을 별도로 만들어서 처리한다. 그런데 로직이 거의 없으면 Repository를 바로 부르기도 한다. 우리는 지금 Repository를 바로 쓰는 중이다.
MVC Pattern - Application
컨트롤러에선 request.setAttribute()로 값을 담고
뷰에선 request.getAttribute()로 값을 꺼낸다.
public class MvcMemberFormServlet extends HttpServlet
여기선 이게 컨트롤러가 된다.
MVC 패턴에선 항상 컨트롤러를 거쳐서 뷰로 들어가야 한다. 컨트롤러로 일단 요청이 다 들어와야 한다.
String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);컨트롤러에서 뷰로 이동할 때 사용한다.
dispatcher.forward(request, response);
이걸 호출하면 진짜 서블릿에서 JSP를 호출한다.
고객의 요청이 오면 위 코드들이 호출된다. 그리고 viewPath 경로를 다시 호출한다. 그러면 서버 내부에서 서버끼리 호출한다. 제어권을 넘겨준다.
action="/save"라면 절대 경로이다.
localhost:8080/save
action="save"라면 상대 경로이다.
http://localhost:8080/servlet-mvc/members/new-form
여기에서
http://localhost:8080/servlet-mvc/members/save
이렇게 바뀐다. 보통은 절대 경로가 낫다.
dispatcher.forward() : 다른 서블릿이나 JSP로 이동할 수 있는 기능이다. 서버 내부에서 다시 호출이 발생한다.
즉, 다시 클라이언트에 왔다 갔다 하는 것이 아니다. 리다이렉트가 아니다.
dispatcher.forward(request, response); 해도 URL이 그대로인 것을 확인할 수 있다.
이전엔
http://localhost:8080/jsp/members/new-form.jsp
이렇게 경로로 그냥 부를 수 있었다.
그런데 지금은 그냥 부르고 싶지 않다. 컨트롤러를 거쳐서 부르길 원한다. 그땐 /WEB-INF 경로에 넣으면 된다. WAS 서버 룰에서 이렇게 정해져 있다. 대문자 지켜야 한다.
/WEB-INF 밑에 있는 자원들은 외부에서 호출해도 호출되지 않는다.
즉, localhost:8080/WEB-INF/views/new-form.jsp라고 하면 Whitelabel Error Page 뜬다. 항상 컨트롤러(서블릿?) 거쳐서 내부에서 forward하거나 해야 호출된다.
리다이렉트는 웹 브라우저에서 서버로 호출이 두 번 일어난다.
포워드(forward)는 웹 브라우저 입장에서 한 번 호출하고 응답 바로 받는다.
request.setAttribute("member", member);
request 객체 내부에 저장소가 있다. Map 같은 것이 있다. 거기에 저장된다.
request.getAttribute()는 반환 타입이 Object이다.
<li>id=<%=((Member)request.getAttribute("member")).getId()%></li>
<li>username=<%=((Member)request.getAttribute("member")).getUsername()%></li>
<li>age=<%=((Member)request.getAttribute("member")).getAge()%></li>
이 코드를
<li>id=${member.id}</li>
<li>username=${member.username}</li>
<li>age=${member.age}</li>
이렇게 바꿀 수 있다. 이렇게 하면 request.getAttribute()로 가져오고, setter, getter가 호출된다.(getUsername() 등)
${member.age}
이렇게 하면 조회할 땐 자동으로 getAge()
값을 넣으면 setAge()가 호출된다.
request.setAttribute("members", members);
첫 번째 값이 key, 두 번째 값이 value
jstl이라는 기능인데, JSP가 특별한 태그들을 제공한다. 이걸 쓰면 루프 같은 로직을 깔끔하게 태그로 해결할 수 있다.
보통의 뷰 템플릿은 다 이런 기능들이 있다.
<c:forEach var="item" items="${members}">
<tr>
<td>${item.id}</td>
<td>${item.username}</td>
<td>${item.age}</td>
</tr>
</c:forEach>
그냥 이런 게 있다고 생각하면 되고 외울 필요 없다. 점점 실무에서 JSP를 안 쓰고 어렵지도 않다. 하루 정도면 누구나 공부할 수 있다.
items="${members}"는 Model에서 담았던
request.setAttribute("members", members);
여기서 "members" 이름으로 가져온다.
가져온 다음에, 이게 List, 컬렉션이기 때문에 컬렉션은 이렇게 하면 자바 for 루프처럼 할 수 있다.
클라이언트의 요청이 오면 항상 컨트롤러를 먼저 거치고 뷰로 간다. 뷰로 바로 가지 않는다.
컨트롤러에서 서비스나 Repository에 회원 가입 로직을 하고, 결과를 Model에 담아서(request 객체에 setAttribute()로 데이터를 담고) 뷰는 request의 getAttribute()로 값을 꺼낼 수 있는데, 이렇게 꺼내면 불편하니 JSP가 제공하는 몇 가지 좋은 표현식들을 사용해서 바로 값을 꺼낼 수 있다. 그리고 jstl 문법이 태그도 제공한다.
서블릿과 JSP만으로 만든 MVC 패턴은 한계가 있다.
MVC Pattern - Limitations
ViewPath에도 중복이 있다.
prefix: /WEB-INF/views/
suffix: .jsp
나중에 폴더를 한 번에 바꾸거나 하게 되면, 혹은 뷰를 JSP 말고 다른 거로 바꿔서 확장자가 바뀌거나 하면 전체 코드를 다 바꿔야 한다.
이 코드에선 JSP가 뿌리기 때문에 response는 사용하지 않고 있다.
프로젝트가 커질수록 공통 처리 요소가 많아질 것이다. 예를 들면 로그 출력 등
그리고 포워드 중복, ViewPath 중복 등
공통 처리를 해도 결과적으로 공통 처리한 유틸리티 메서드를 항상 호출해야 한다. 실수로 호출 안 할 수도 있다. 이런 걸 호출하는 거 자체도 중복이라고 볼 수 있다.
가장 큰 문제는 공통 처리가 어렵다는 점이다. 이 문제를 해결하려면 컨트롤러 호출 전에, 즉 서블릿이 호출되기 전에 공통 기능을 먼저 처리해야 한다. 소위 수문장 역할이 필요하다.
지금은 아무 곳에서나 다 들어오는데, 그게 아니라 앞에서 항상 문을 지키는 수문장을 둬서 그 객체를 통해서 컨트롤러들이 호출되어야 한다. 그 문을 지키는 객체에서 공통 처리를 다 해 버려야 한다. 이런 아이디어가 옛날에 나왔었고 이게 패턴화되어서 프론트 컨트롤러 패턴이라고 한다.
컨트롤러 중에서 가장 앞에서 수문장 역할을 하는 패턴이다.
지금은 문이 여러 개가 뚫린 상황이다. 그게 아니라 항상 어떤 문을 통해서, 어떤 HTTP 요청도 이 문을 통해서 뒤의 컨트롤러들이 호출되도록 구조를 만들어 내면, 앞으로 공통적인 이슈가 있으면 이 수문장 역할이 그 일을 대신하면 된다.
스프링 MVC의 핵심도 프론트 컨트롤러에 있다.
프론트 컨트롤러는 필터랑 다르다. 필터는 정해진 스펙대로 체인을 쭉쭉 넘기는 것이고, 이것은 그게 아니라 원하는 대로 컨트롤러처럼 다 조작할 수 있다.
다음부터는 이 단점을 프론트 컨트롤러 패턴을 적용해서 이 문제들을 해결하기 시작할 것이다. 이렇게 되면 우리들만의 프레임워크를 만드는 것이다.
스프링 MVC 프레임워크도 그냥 이 프론트 컨트롤러 패턴을 구현한 것이다. 우리도 스프링 MVC와 똑같이 MVC 프레임워크를 하나 만들 것이다.
Summary
지금은 서블릿으로 컨트롤러를 구현했다.
요청이 오면 컨트롤러에서 파라미터 정보도 체크하고, 문제가 있는지 없는지 확인하고
문제가 있으면 4XX 오류 내려 주기도 하고,
문제가 없으면 비즈니스 로직 실행하고 그 결과를 Model에 담아서 뷰에 던진다.
뷰는 여러 가지가 있을 수 있다. JSP, 타임리프 등.
뷰는 Model에 담긴 정보를 꺼내서 자기를 렌더링 하는 데에만 집중하면 된다. MVC 패턴을 쓰고 나서 뷰 로직이 깔끔해졌다.
보통 MVC 프레임워크들은 다 프론트 컨트롤러 패턴을 구현한 것이다.
단순하게 앞에 컨트롤러 하나 두고, 이 컨트롤러를 통해 호출되는 컨트롤러들을 뒤에 둔다.
스프링 MVC도 똑같다.
다음 시간부터는 프론트 컨트롤러 패턴을 가지고 우리가 직접 MVC 프레임워크를 만들 것이다. 거의 스프링 MVC랑 유사한 구조로 만들 것이다.
Creating an MVC Framework
Introduction to Front Controller Pattern
이전 시간에서 서블릿을 컨트롤러로 하고 JSP를 뷰로 하는 MVC 패턴을 도입해 보니 여러 불편한 사항들이 생겼었다.
이런 것들을 개선하는 MVC 프레임워크를 서블릿을 가지고 밑바닥부터 하나씩 만들어 볼 것이다.
버전 1, 2, 3, 4, 5까지 단계적으로 MVC 프레임워크를 업그레이드해 볼 것이다. 이렇게 하다 보면 마지막엔 스프링 MVC와 유사한 구조가 된다.
스프링 MVC를 바로 배우면 왜 그런지 와닿지 않을 수 있으므로 왜 그렇게 만들어졌는지 자연스럽게 이해하기 위해 직접 개발하며 발전시킨다.
프론트 컨트롤러를 도입하면(그냥 서블릿이다.) 공통 로직을 여기에 다 몰고, 컨트롤러 A, B, C 각각 필요한 로직은 그냥 각자 처리한다.
프론트 컨트롤러도 서블릿이다.
이전엔 고객 요청이 오면 서블릿 매핑으로 직접 호출이 되었다. 이제 그게 아니라 일단 프론트 컨트롤러가 서블릿 요청을 다 받고 공통 처리를 한 후, 이 요청은 컨트롤러 A한테 갈지, 컨트롤러 B한테 갈지를 추가로 호출한다.
프론트 컨트롤러를 제외한 나머지 컨트롤러는 서블릿을 사용하지 않아도 된다. 우리가 요청 매핑을 할 때 서블릿을 사용해서 요청 매핑을 했었다.
이제 프론트 컨트롤러가 서블릿이고 나머지는 서블릿으로 굳이 만들 필요가 없다. 왜냐하면 프론트 컨트롤러가 직접 호출해 줄 것이기 때문이다.
우리가 서블릿으로 만드는 것은, URL 매핑을 해서 클라이언트에서 요청이 오면 이것을 WAS에서 처음 요청이 들어가는 곳이 서블릿이었다. 그 역할을 이미 프론트 컨트롤러가 대신 해 주기 때문에 나머지 컨트롤러 A, B, C는 서블릿을 사용하지 않아도 된다. 즉, HttpServlet 상속받을 필요도 없고, @WebServlet 애노테이션을 전혀 할 필요가 없다.
마음만 먹으면 HttpServletRequest 같은 것도 안 써도 된다.
DispatcherServlet이라는 게 있는데 이게 제일 핵심이다.
스프링도 결국 서블릿이 앞에 있는 것이다.
스프링 MVC의 경우 DispatcherServlet이 앞에서 다 처리해 준다. 이것이 프론트 컨트롤러 패턴으로 구현되어 있다.
프론트 컨트롤러를 어떤 식으로 개발하고 발전시켜야 하는지를 이해하게 되면, 그래서 스프링 MVC의 DispatcherServlet이 이런 식으로 개발되었구나라고 이해할 수 있다.
Front Controller Introduction - v1
클라이언트가 HTTP 요청을 하면, 어떤 요청이든 프론트 컨트롤러라는 서블릿이 받는다.
프론트 컨트롤러 패턴을 도입하면 프론트 컨트롤러가 뒤의 컨트롤러 A, B, C를, 이 URL은 컨트롤러 A가 호출되어야 하고, 이 URL은 컨트롤러 B가 호출되어야 한다는 것을 매핑한다.
컨트롤러를 매핑 정보에 넣어 둔다. 이 URL은 이 컨트롤러가 호출되어야 한다는 매핑 정보를 넣어 둔다. 예를 들어 /A라고 오면 컨트롤러 A가 호출되어야 하고, /B가 오면 컨트롤러 B가 호출되어야 한다는 매핑 정보.
Map으로 단순하게 넣어 둘 것이다.
요청이 오면 매핑 정보를 뒤져서 어떤 컨트롤러가 호출될지 찾은 다음에 그 컨트롤러를 호출할 것이다. 그러면 컨트롤러는 그냥 자기 로직 수행하고 JSP 포워드 로직으로 JSP 호출해서 응답이 나가는 구조로 만들 것이다.
먼저 컨트롤러를 인터페이스로 만들 것이다.
제일 중요한 게 컨트롤러 인터페이스 모양이다.
ControllerV1을 왜 만드냐 하면, 이거로 구현을 여러 개 할 것이다. 회원 폼 컨트롤러, 회원 저장 컨트롤러, 회원 리스트 컨트롤러 세 가지를 다 구현할 것이다. 그렇게 되면 요청이 왔을 때 매핑 정보에서 찾아서 호출할 때 다형성을 사용해서 일관성 있게, 프론트 컨트롤러는 인터페이스에 의존하면서 편리하게 호출할 수 있다.
로직은 일단 기존 서블릿과 똑같다. 그 대신 서블릿을 상속받는 것이 아니라 ControllerV1 인터페이스를 구현하고 있고 @WebServlet 이런 게 없다.
프론트 컨트롤러는 위치가 중요하다. controller 패키지 하위가 아니라 v1에 바로 만든다.
FrontControllerServletV1은 일단 서블릿이어야 한다.
urlPatterns가 중요하다.
urlPatterns = "/front-controller/v1/*"
이렇게 하면 /v1 하위에 어떤 URL이 들어와도, 즉 /v1/A든 /v1/B든 /v1/A/a이든 어떤 게 들어와도 일단 이 서블릿이 무조건 호출된다. /v1도 포함이다.
매핑 정보를 만들 것이다. key엔 URL을 넣고 value엔 ControllerV1
즉, 어떤 URL이 호출되면 ControllerV1을 꺼내서 호출하는 식으로 구현할 것이다.
서블릿이 생성될 때 매핑 정보를 미리 담아 놓을 것이다.
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());
}http://localhost:8080/front-controller/v1/hello
hello는 따로 안 만들었지만 이렇게 접속해 봐도
System.out.println("FrontControllerServletV1.service");
실행된다.
지금은 System.out으로 찍었지만 실제론 로그로 찍는 것이 좋다.
String requestURI = request.getRequestURI();
이러면
http://localhost:8080/front-controller/v1/members/new-form
이렇게 들어왔을 때, /front-controller/v1/members/new-form 이 부분을 그대로 받을 수 있다.
인터페이스로 꺼내게 되면
ControllerV1 controller = controllerMap.get(requestURI);
이 코드를 일관성 있게 사용할 수 있다.
ControllerV1 controller = controllerMap.get(requestURI);
get() 했는데 없어도 오류 아니다. null 반환한다.
http://localhost:8080/front-controller/v1/members
이 경로로 요청이 오면
urlPatterns = "/front-controller/v1/*" 이거를 만족하므로 이 서블릿이 호출된다.
그러면 service()가 호출되고 request.getRequestURI()가 호출되는데 /front-controller/v1/members 이것을 가져온다.
그것은 controllerMap의 key에 똑같은 게 있다. 이 key를 넣으면 new MemberListControllerV1()으로 생성했던 객체가 반환된다.
반환이 되는데 다형성에 의해 인터페이스로 받을 수 있다. 왜냐하면 MemberListControllerV1 이것은 부모가 ControllerV1이기 때문이다.
controller.process(request, response);
다형성으로 인해 오버라이드된 메서드가 호출된다.
public class MemberListControllerV1 implements ControllerV1 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
List<Member> members = memberRepository.findAll();
request.setAttribute("members", members);
String viewPath = "/WEB-INF/views/members.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
String viewPath = "/WEB-INF/views/members.jsp";
JSP가 재사용되고 있다. 여러 컨트롤러와 서블릿들이 하나의 JSP를 재사용하고 있다.
지금은 도입 단계라 이전보다 복잡할 수 있다. 점점 깔끔해지도록 바뀔 것이다.
실무에서도 개발하다 보면 아키텍쳐를 크게 개선해야 하는 경우가 있다. 그럴 때도 구조를 건들 땐 구조만 건들여야 한다. 여러 가지를 한꺼번에 개선하고 싶어도 일단 참는 게 좋다. 구조적인 큰 것을 개선할 때랑 디테일한 것을 개선할 때랑 즉, 레벨이 다른 것을 개선할 땐 같은 레벨끼리만 일단 개선한다.
구조를 개선할 땐 일단 구조적인 것만 개선하고 기존 코드는 최대한 유지해야 한다. 구조를 바꿨는데 문제가 없으면 그때 세세한 부분들을 개선하는 게 좋다.
실제론 대부분 절대 경로를 쓰는 게 낫다. 물론 상대 경로를 쓰는 게 나은 경우도 있다. 패턴이 비슷하거나 그럴 땐 상대 경로를 써도 된다. 뒤에서 더 설명할 듯
View Separation - v2
이전엔 컨트롤러에서 JSP를 직접 포워드해 줬는데, 이젠 MyView라는 객체를 만들어서 반환하면, 프론트 컨트롤러가 MyView의 render()를 호출하면 MyView가 JSP를 포워드하도록 할 것이다.
컨트롤러가 더 이상 JSP 포워드를 고민하지 않아도 된다. 단순하게 MyView만 생성해서 호출하면 된다.
JSP로 이동하고 이런 것을 우리는 렌더링 한다고 표현할 것이다.
JSP를 렌더링 하도록 포워드해서 이동을 하든, 직접 그림을 그리든 뭐든 어쨌든 뷰를 만드는 행위 자체를 그냥 render()라고 했다.
기존에도 모든 컨트롤러가 JSP로 이동했다.
기존엔 ControllerV1 인터페이스의 process 반환 타입이 void였고 컨트롤러가 알아서 다 포워드로 이동했는데, 이젠 그냥 MyView를 만들어서 넘기면 되는 식으로 인터페이스 설계했다.
MyView myView = new MyView("/WEB-INF/views/new-form.jsp");
return myView;
여기서 Ctrl + Alt + N 하면
return new MyView("/WEB-INF/views/new-form.jsp");v1에서 v2로 내부 구조만 리팩터링한 것이다.
회원 가입을 하려면
http://localhost:8080/front-controller/v2/members/new-form가 호출된다. 그러면
urlPatterns = "/front-controller/v2/*"
이것을 만족한다.
그리고 컨트롤러를 찾을 때
MemberFormControllerV2 객체를 찾는다. 찾아서 process() 호출하면
return new MyView("/WEB-INF/views/new-form.jsp");
이걸 실행해서 MyView를 반환한다.
그리고 이것의 render()를 호출한다. render()를 호출하면
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
이걸 실행한다(viewPath는 이미 들어가 있다. MyView를 생성할 때 넣었으므로).
포워드하면 JSP에서 실제 HTML 결과가 웹 브라우저(클라이언트)에게 응답으로 간다.
이제 컨트롤러는 객체만 생성해서 반환한다(MyView를 실행하는 게 아니라, 경로 등을 포함하여 생성 후 반환만 한다.). 그러면 나머지는 프론트 컨트롤러에서 이런 공통 로직을 다 직접 호출하고 처리한다.
지금까지 View가 아니라 MyView라고 한 이유는 스프링 MVC에 View라는 게 있기 때문에 겹치면 헷갈릴 수 있으므로
만약 JSP뿐만 아니라 다른 것까지 렌더링 해야 하면 이것도 다형성을 활용해서 MyView도 인터페이스로 설계하면 더 나은 설계가 될 것이다. 그러나 지금은 JSP만 하므로 그냥 클래스로 했다.
지금은 Model이 없다. 지금은 그냥 HttpServletRequest, HttpServletResponse 같은 걸 쓰고 있다. 다음 시간엔 Model 개념을 도입할 것이다.
그리고 지금은 컨트롤러에서 request, response가 필요 없다. 이런 부분도 다음 시간에 개선할 것이다.
Add Model - v3
이번 시간에 서블릿 종속성을 제거할 것이다.
지금은 컨트롤러 입장에서 생각해 보면
HttpServletRequest request, HttpServletResponse response
이 두 개가 필요 없다. 물론 필요한 게 있을 수도 있다.
MemberSaveControllerV2에서도 필요한 게
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
요청 파라미터 정보가 필요한 것이지, request, response 정보가 당장 필요한 것이 아니다.
그래서 대신 요청 파라미터 정보는 자바의 Map으로 대신 넘기도록 할 것이다. 프론트 컨트롤러에서 Map으로 바꿔서, 이 컨트롤러에 대신 넘기도록 할 것이다.
그러면 지금 구조에서 컨트롤러가 서블릿 기술을 몰라도 동작할 수 있다. 그러면 테스트하기도 더 쉽고 간결해진다. 그리고
public MyView process(HttpServletRequest request, HttpServletResponse response)이렇게 사용하지 않는 코드를 굳이 파라미터로 전달할 필요도 없어진다.
기존엔 request.setAttribute()로 데이터를 담는 식으로 Model처럼 썼었는데 이제 Model 객체를 만들어서 이것이 그 역할을 대신하도록 할 것이다.
궁극적으로 우리가 구현하는 컨트롤러가 서블릿 기술을 전혀 사용하지 않도록 변경할 것이다.
뷰 이름의 중복도 제거할 것이다.
지금은 /WEB-INF/views/나 .jsp 등이 중복되고 있다.
지금은 물리적인 경로를 다 넣고 있지만 이제 new-form만 넣는 식으로, 혹은 save-result만 넣는 식으로, 컨트롤러는 뷰의 논리 이름만 반환하고, 실제 물리 위치의 이름은 프론트 컨트롤러에서 처리하도록 단순화할 것이다.
즉, 지저분한 일은 프론트 컨트롤러가 하고, 핵심 로직은 최대한 단순하게 할 것이다. 왜냐하면 프론트 컨트롤러는 하나이지만 실제 개발하면 컨트롤러는 수십, 수백 개가 된다.
이렇게 하면 향후 뷰의 폴더 위치가 통째로 변경이 된다.
예를 들어 "/WEB-INF/views/members.jsp" 이런 것들을
"/WEB-INF/jsp/members.jsp" 이런 식으로 바꿔도, 기존엔 코드 하나하나를 다 바꿔야 했지만, 이젠 프론트 컨트롤러에서만 한 번만 바꿔 주면 된다. 각 컨트롤러에서 전혀 손댈 필요가 없다.
즉, 변경의 지점을 하나로 만들 수 있다. 이것이 좋은 설계이다. 예를 들어 jsp 파일들이 있는 전체 경로가 바뀔 때, 변경을 하나의 지점에서만 변경하면 그건 설계가 잘 된 것이다.
HTTP 요청이 오면 프론트 컨트롤러에서 매핑 정보를 가져오고 컨트롤러를 호출한다.
그런데 기존의 v2에선 컨트롤러에서 View를 반환했지만 이젠 Model이랑 View가 같이 섞여 있는 ModelView 객체를 반환할 것이다.
그리고 아까 말했듯이 new-form이라는 논리 이름만 반환할 것이다. 그러면 실제 물리 이름으로 누가 바꿔 줘야 한다. 그것을 viewResolver라는 곳에서 해결하고 MyView를 반환할 것이다. 그리고 render(model)를 호출할 것이다.
스프링 MVC엔 ModelAndView(?)라는 것이 있는데 지금 만드는 ModelView와 비슷하다.
ControllerV2 인터페이스에선
MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
서블릿 기술들이 들어갔다.
ControllerV3 인터페이스에선 그런 게 없다.
ModelView process(Map<String, String> paramMap);
ModelView도 우리가 만든 것이지 서블릿에 종속적인 것이 아니다.
public class MemberFormControllerV3 implements ControllerV3 {
@Override
public ModelView process(Map<String, String> paramMap) {
return new ModelView("new-form");
}
}
뷰의 전체 경로를 넣는 것이 아니라, 논리적인 이름만 넣는다. 논리적인 이름에 대한 기준은 우리가 정해야 한다. 우리는 그냥 new-form을 논리적인 이름으로 하겠다.
"/WEB-INF/views/new-form.jsp"에서 앞이랑 뒤의 .jsp를 뺀 거를 보낸다고 우리끼리 약속하겠다.
String username = paramMap.get("username");
HttpServletRequest에서 getParameter() 이런 거로 꺼내는 것이 아니라, 그런 건 프론트 컨트롤러에서 다 처리하고 Map에 요청 파라미터 정보를 다 넣어서 넘겨줄 것이다.
여기선 단순히 꺼내서 쓰면 된다.
@Override
public ModelView process(Map<String, String> paramMap) {
String username = paramMap.get("username");
int age = Integer.parseInt(paramMap.get("age"));
.
.
.
}
Map<String, String> 여기에 들어가는 건
username, age가 아니라
"username", username에 대한 값인 듯(age도 마찬가지)
ModelView mv = new ModelView("save-result");
mv.getModel().put("member", member);
return mv;
mv.getModel().put("member", member);
여기에 저장된 결과는 프론트 컨트롤러에서 뭔가 처리하고 JSP 쪽으로 포워드해 줄 것이다.
private Map<String, String> createParamMap(HttpServletRequest request) {
Map<String, String> paramMap = new HashMap<>();
request.getParameterNames().asIterator()
.forEachRemaining(paramName -> paramMap.put(paramName, request.getParameter(paramName)));
return paramMap;
}
이렇게 디테일한 건 메서드로 따로 추출하는 것이 좋다.
HttpServletRequest의 모든 파라미터 이름을 다 가져와서, 돌리면서 paramMap에 데이터를 다 집어넣는다.
아까 그림에서 보았듯이, 컨트롤러 호출하고 ModelView를 반환하는 것까진 성공했다. 이제 논리 이름을 물리 이름으로 바꿔야 한다. 이거로 MyView 반환해야 한다.
viewResolver라는 기능이 있다. 실제 뷰를 찾아 주는 해결자 역할을 한다. 그 기능을 만들어 볼 것이다.
왜냐하면 지금 mv에서는 mv.getViewName()으로는 new-form 같은 논리 이름만 얻을 수 있다.
실제 다 붙여서 물리 이름 만들고 뷰 객체까지 만들어 주는 viewResolver가 그걸 다 해 줄 것이다.
viewResolver를 따로 클래스를 만들진 않을 거고,
ModelView mv = controller.process(paramMap);
String viewName = mv.getViewName();
MyView view = new MyView("/WEB-INF/views/" + viewName + ".jsp");
view.render(request, response);
이렇게 하면 되는데 이 과정도 메서드 추출을 하겠다.
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}아직 안 한 게 있다. Model을 render()에 같이 넘겨줘야 한다.
view.render(mv.getModel(), request, response);이렇게 되도록 바꿔야 한다.
public void render(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
}
여기에 기존 로직이랑 똑같이 할 건데, model에 있는 값을 JSP에게.. JSP는 request.getAttribute()를 쓴다. 그렇기 때문에 다음을 해야 한다.
model에 있는 데이터를 forEach()로 다 꺼내야 한다.
model.forEach((key, value) -> request.setAttribute(key, value));
자바 8 문법이다.
그리고 request의 setAttribute()로 key, value로 값을 다 담아 둔다.
public void render(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
model.forEach((key, value) -> request.setAttribute(key, value));
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
model.forEach((key, value) -> request.setAttribute(key, value));
이것도 이름을 의미 있게 하기 위해 메서드 추출하겠다.
private void modelToRequestAttribute(Map<String, Object> model, HttpServletRequest request) {
model.forEach((key, value) -> request.setAttribute(key, value));
}이렇게 하면 render()가 오면 model에 있는 값들을 다 꺼내서 HttpServletRequest에다 setAttribute()로 다 넣는다. 그다음에 JSP 포워드가 되면 JSP가 request.getAttribute()를 한다.
회원 가입을 하면
http://localhost:8080/front-controller/v3/members/new-form
이 경로가 들어온다. 그러면 FrontControllerServletV3에 있는 urlPatterns = "/front-controller/v3/*"를 만족하고, MemberFormControllerV3 객체가 반환된다.
createParamMap()을 통해 HttpServletRequest에 있는 파라미터를 다 뽑아서 paramMap을 반환한다(물론 지금은 단순히 form이므로 아무것도 없다.).
controller.process()를 실행한다. 그러면 ModelView를 반환한다. 그리고 논리 이름 new-form을 반환해 주는데 논리 이름을 가지고 viewResolver(viewName)를 호출한다. 그러면 MyView를 반환한다. MyView를 만들면서 완성된 실제 물리 이름을 포함한 MyView를 반환하고, 이걸 가지고 render()를 호출하면서 model을 넘긴다. 왜냐하면 뷰가 렌더링 되려면 model이 필요하다. 그러면 model에 담긴 데이터를 다 꺼내서 HttpServletRequest의 setAttribute()로 다 넣는다. 왜냐하면 JSP는 이게(request?) 필요하다(여기서 값을 꺼내므로). 다른 뷰 템플릿들은 다르다. 다른 곳에다 집어넣어야 한다. 아무튼 JSP는 HttpServletRequest의 setAttribute()로 넣어야 JSP의 표현식들로 편하게 꺼내서 쓸 수 있다. 그다음에 dispatcher로 JSP로 이동한다. 그러면 JSP가 렌더링 된다.
지금까지 해서 서블릿 종속성이 제거되었다. 어디서 볼 수 있냐 하면, 컨트롤러들의 process()에서 paramMap만 가지고 코딩하면 된다.
프론트 컨트롤러는 일이 더 많아졌다. 대신 실제 구현한 컨트롤러들은 되게 편리하다.
ModelView process(Map<String, String> paramMap);
이건 서블릿에 종속적이지 않고, 순수하게 자바 코드들로만 되어 있고 우리가 만든 ModelView만 넘겨주고 있다.
이렇게 해도 좀 불편한 것들이 있다. 다음 편엔 구조적인 그림은 똑같은데, 개발자들이 좀 더 단순하고 실용적으로 개발할 수 있는 컨트롤러로 바꿔 볼 것이다.
Simple and Practical Controller - v4
v4는 v3와 기본적인 구조는 같지만 하나가 개선되었다.
v3가 구조적으로는 거의 완성된 것이다.
v4에선 ModelView 대신 뷰의 이름만 문자로 반환한다.
String process(Map<String, String> paramMap, Map<String, Object> model);
v3에선 paramMap만 넘겼었는데 이번엔 model도 파라미터로 넘어온다. 이전엔 직접 ModelView 생성하면서 거기서 가져다 쓰고 그랬었는데 이제 그게 아니라, 프론트 컨트롤러가 model까지 만들어서 넘겨준다. 물론 이건 데이터가 없는 텅 빈 model이다.
Map<String, Object> model = new HashMap<>();
controller.process(paramMap, model);
프론트 컨트롤러에서 이렇게 하면 컨트롤러 안에서 model에 값을 담는다.
v4까지 이해했으면 MVC 구조를 거의 이해했는데, 스프링 MVC를 이해하려면 마지막 남은 게 어댑터라는 개념이다.
지금까지 만든 컨트롤러는 단점이 있다. 모양이 정해져 있다.
String process(Map<String, String> paramMap, Map<String, Object> model);
v4 버전을 쓰다가 갑자기
void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;v1 버전 인터페이스를 가지고 구현하고 싶을 수 있다.
v4도 쓰면서 동시에 v1도 같이 쓰는 게 지금 구조에선 불가능하다.
왜냐하면 프론트 컨트롤러에
private Map<String, ControllerV4> controllerMap = new HashMap<>();
이 부분에 ControllerV4가 써져 있다. 이땐 V4만 된다.
인터페이스로 제약하는 것의 장점이자 단점이다. 다음 시간에 이 문제를 해결할 것이다.
어떤 컨트롤러든지 호출할 수 있는 프론트 컨트롤러를 만들 것이다. 구현할 때 v3 버전으로 구현할지, v4 버전으로 구현할지.. 이런 걸 하나의 프로젝트 안에서 자유롭게 구현할 수 있도록 해 볼 것이다.
Flexible Controller 1 - v5
하나의 프로젝트 안에서 다양한 종류의 컨트롤러를 쓰고 싶을 때는 어떻게 할까?
그걸 해결할 때 어댑터 패턴을 쓰면 된다.
개발에서도 두 개가 안 맞을 때 중간에 뭘 끼워서 맞추는 것을 어댑터 패턴이라고 한다. 어댑터 패턴을 사용해서 프론트 컨트롤러가 다양한 방식의 컨트롤러를 처리할 수 있도록 이번에 변경해 보겠다.
핸들러라는 개념은 컨트롤러라고 생각하면 된다.
클라이언트가 프론트 컨트롤러에 요청하면, 핸들러 매핑 정보에서 핸들러 조회를 한다.
그다음에 핸들러 어댑터 목록에서 핸들러 어댑터를 찾아온다. ControllerV3를 처리할 수 있는 어댑터, ControllerV4를 처리할 수 있는 어댑터를 찾아온다. 예를 들어 핸들러 매핑 정보가 ControllerV4면 V4를 처리할 수 있는 어댑터를 가져온다.
그다음에 어떻게 하냐면, 기존엔 프론트 컨트롤러에서 바로 핸들러를 호출했었다. 이젠 유연하게 쓰기 위해 중간에 어댑터를 통해 호출해야 한다. 이제 프론트 컨트롤러도 해당 컨트롤러를 직접 호출할 수 있는 게 아니라, 어댑터를 통해 호출해야 한다. 어댑터를 통해 호출할 때, 파라미터의 handler는 컨트롤러라고 보면 된다. 즉, 어댑터한테 컨트롤러를 넘겨준다. 그러면 컨트롤러(?)가 대신 호출해 준다.
어댑터가 핸들러를 호출해서 결과를 받은 다음에, ModelView를 프론트 컨트롤러에게 반환한다(ControllerV3 컨트롤러의 경우엔 process() 반환 타입이 ModelView이고, 그걸 다시 어댑터가 ModelView로 리턴하는 듯). 나머지 프로세스는 똑같다.
과거엔 무조건 컨트롤러를 처리한다, 이렇게 개발했었는데
어댑터가 사용되면 어떠한 종류든지 다 처리할 수 있다. 그래서 컨트롤러 개념뿐만 아니라 다른 것들도 다 처리할 수 있게 된다.
그래서 범위를 더 넓혀서 핸들러라고 이름을 부르겠다.
왜냐하면 핸들러 어댑터만 있으면 어떤 핸들러든지 다 처리할 수 있다.
MyHandlerAdapter라고 이름 지은 이유는 스프링 MVC에 HandlerAdapter(?)라는 비슷한 게 있기 때문이다.
MyHandlerAdapter라는 인터페이스는 두 가지 기능이 있다.
supports라는 메서드가 무엇이냐 하면, 처음에 클라이언트에게서 요청이 오고 프론트 컨트롤러가 핸들러 매핑 정보에서 즉, 컨트롤러 매핑 정보에서 핸들러를 하나 찾아온다. 그다음에 핸들러 어댑터 목록을 뒤져서 찾을 때, 예를 들어 이 컨트롤러가 V3이면 V3를 처리할 수 있는 어댑터를 꺼내야 한다. 그때 사용하는 게 supports 메서드이다.
boolean supports(Object handler)
V3 컨트롤러를 넘기면, 이 어댑터가 해당 컨트롤러를 처리할 수 있는지를 찾아본다. 처리할 수 있다고 판단하면 true를 반환하고 그렇지 않으면 false를 반환한다.
그래서 V3용 어댑터가 true를 반환하면, 걔가 꺼내진다.
ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler)
handle 메서드는 말 그대로 핸들러를 호출해 준다. 그리고 반환할 때 ModelView를 맞춰서 반환해 준다. 실제 컨트롤러가 ModelView를 반환하지 못하면, 어댑터가 ModelView를 직접 생성해서라도 반환해야 한다.
public boolean supports(Object handler) {
return (handler instanceof ControllerV3);
}
핸들러가 넘어올 텐데 이게 지원할 수 있는지 없는지를 확인한다. ControllerV3의 인스턴스인지 묻는다. ControllerV3 인터페이스를 구현한 뭔가가 넘어오게 되면 true를 리턴한다. 다른 게 오면 false가 리턴된다.
@Override
public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException {
ControllerV3 controller = (ControllerV3) handler;
return null;
}
캐스팅해도 된다. 왜냐하면 supports()에서 ControllerV3만 지원하도록 했기 때문에, 즉 이미 supports()로 걸렀기 때문에 그 이후에 걸러진 애를 찾아서 그거 가지고 handle()을 호출할 것이기 때문에 즉, handler가 V3라고 확정이 된 거기 때문에 캐스팅해서 쓰면 된다.
(프론트 컨트롤러에서 supports()도 호출하고 handle()도 호출할 것이다.)
@Override
public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException {
ControllerV3 controller = (ControllerV3) handler;
Map<String, String> paramMap = createParamMap(request);
ModelView mv = controller.process(paramMap);
return mv;
}
어댑터의 역할은 핸들러를 호출해 주고, 그 결과가 오면 그 반환 타입을 ModelView로 맞춰서 반환해 준다. V3는 원래도 ModelView를 반환하기 때문에 딱 맞다. 그런데 V4는 String을 반환한다. 그렇기 때문에 거기선 로직이 달라질 것이다.
private final Map<String, Object> handlerMappingMap = new HashMap<>();final은 넣든 안 넣든 지금은 중요하지 않다.
이전의 controllerMap에선 ControllerV4 혹은 ControllerV3가 직접 들어갔었는데, 지금은 V3든 V4든 아무 컨트롤러나 다 들어갈 수 있어야 하기 때문에 Object를 넣는다.
이전과 다르게 이전에 없던 하나를 더 만들 것이다.
private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();왜냐하면 어댑터가 여러 개 담겨 있고 그중에 내가 하나를 찾아서 꺼낼 것이기 때문이다.
private Object getHandler(HttpServletRequest request) {
String requestURI = request.getRequestURI();
return handlerMappingMap.get(requestURI);
}
요청이 오면 handlerMappingMap에서 handler를 찾는다.
private MyHandlerAdapter getHandlerAdapter(Object handler) {
for (MyHandlerAdapter adapter : handlerAdapters) {
if (adapter.supports(handler)) {
return adapter;
}
}
throw new IllegalArgumentException("handler adapter를 찾을 수 없습니다. handler = " + handler);
}
컨트롤러가 들어오면, 그게 만약 컨트롤러 V3이면 handlerAdapters를 다 뒤진다. V3 어댑터일 때 supports()를 호출해 보면 if문이 true가 되면서 어댑터를 반환한다. 만약 V3 컨트롤러가 아니었다면 다음 루프를 돈다. 계속해도 없으면 예외가 터진다.
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
Object handler = getHandler(request);
if(handler == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
MyHandlerAdapter adapter = getHandlerAdapter(handler);
ModelView mv = adapter.handle(request, response, handler);
String viewName = mv.getViewName();
MyView view = viewResolver(viewName);
view.render(mv.getModel(), request, response);
}
그림과 똑같다.
클라이언트 요청이 오면 getHandler()를 통해 일단 핸들러 매핑 정보를 뒤져서 찾아온다. 그다음에 getHandlerAdapter()에 핸들러를 던지면서, V3 컨트롤러에 대한 걸 처리할 수 있는 핸들러 어댑터를 찾아온다. 그다음에 handle()을 호출한다. 즉, 핸들러 어댑터를 호출한다. 그러면 핸들러 어댑터가 내부적으로 핸들러(컨트롤러)를 호출한다. 그리고 어댑터가 ModelView를 반환한다.
그다음에 뷰 이름 가져와서 viewResolver() 호출해 주고, 뷰를 얻은 다음에 뷰의 render()를 호출한다.
만약 회원 가입 폼에 들어가면, urlPatterns = "/front-controller/v5/*"를 만족한다.
핸들러를 찾아본다. 그러면 "/front-controller/v5/v3/members/new-form"가 찾아지고 MemberFormControllerV3 객체가 반환된다.
그다음에 어댑터를 찾아본다. getHandlerAdapter()를 호출하고 for문을 돌면서 if문에서 ControllerV3HandlerAdapter의 supports()를 호출한다. supports()를 호출하면 MemberFormControllerV3가 ControllerV3의 인스턴스라는 것을 알 수 있다. 그래서 true가 반환된다. 그러면 getHandlerAdapter()의 결과로 어댑터(ControllerV3HandlerAdapter)가 반환된다.
그 어댑터의 handle()을 호출한다. handle() 내부 로직에서 Object인 컨트롤러(MemberFormControllerV3)를 ControllerV3로 캐스팅하고, 컨트롤러(MemberFormControllerV3)의 process()를 호출한다. 컨트롤러 V3의 경우엔 ModelView를 반환한다. 그리고 그걸 그대로 handle()의 리턴값으로 한다. ModelView 반환한 거 가지고 이후 프로세스는 똑같다. viewResolver()로 뷰 찾고 렌더링 한다.
지금까지만 보면 왜 이렇게 했는지 와닿지 않을 수 있는데 다음 시간에 V4를 넣어 보면 와닿을 것이다.
Flexible Controller 2 - v5
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());
}
실제로 할 땐 MemberForm은 V4로 하고, Save는 V3를 쓰는 식으로 하겠지만 지금은 두 개를 예제로 그냥 보여 주기 위해.
@Override
public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException {
ControllerV4 controller = (ControllerV4) handler;
Map<String, String> paramMap = createParamMap(request);
HashMap<String, Object> model = new HashMap<>();
String viewName = controller.process(paramMap, model);
...
}
controller.process(paramMap, model);가 String인 viewName을 반환한다.
여기서 문제가 생긴다.
이걸 그대로 리턴하면 ModelView 리턴 타입에 안 맞는다.
여기서 어댑터의 역할이 또 나온다. 110v를 220v로 바꾸듯이.
@Override
public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException {
ControllerV4 controller = (ControllerV4) handler;
Map<String, String> paramMap = createParamMap(request);
HashMap<String, Object> model = new HashMap<>();
String viewName = controller.process(paramMap, model);
ModelView mv = new ModelView(viewName);
mv.setModel(model);
return mv;
}
setModel() 해 줘야 하는 듯(테스트해 보기)
model이 process()로 넘어가면 컨트롤러에서 model에 필요한 데이터를 다 담는다.
그걸 그대로 setModel() 한다.
회원 가입을 눌렀을 때
http://localhost:8080/front-controller/v5/v4/members/new-form
이게 호출된다. 그러면 urlPatterns = "/front-controller/v5/*"를 만족하므로 "frontControllerServletV5" 서블릿이 호출된다.
그 후 핸들러 매핑 정보를 찾는다. MemberFormControllerV3 객체가 반환된다. 그 후 어댑터를 찾는다(getHandlerAdapter()). 먼저 ControllerV3HandlerAdapter를 찾는다. 이것의 supports()를 호출한다. 그러면 false를 반환한다. 한 바퀴 더 돌아서 ControllerV4HandlerAdapter를 찾은 후엔 true를 반환하여 그 어댑터가 반환된다.
ControllerV4HandlerAdapter의 handle()을 호출한다.
그러면 handle() 내부에서 ControllerV4로 캐스팅하고, 그것에 맞춰서 process()에 paramMap과 model을 넘겨준다. 그러면 viewName을 반환한다. 그걸 가지고 ModelView를 만들고 model까지 값을 세팅해서 넣은 다음 ModelView를 반환한다.
이후 프로세스는 같다. viewName 가지고 viewResolver() 호출하고 뷰의 render()를 한다.
어댑터를 넣는 게 처음엔 헷갈릴 수 있지만 덕분에 확장이 쉬워졌다. 기능을 확장한다는 말은 기능을 추가한다는 말이다.
initHandlerMappingMap()과 initHandlerAdapters()에 V4 관련 코드를 추가한 거 말고는 메인 코드를 손대지 않았다.
(사실 이런 설정 부분을 밖에서 주입하도록 바꾸면 이 코드 자체를 건들 필요가 없어서 FrontControllerServletV5는 완벽하게 OCP를 지킬 수 있다. 기능을 확장하더라도 코드를 변경할 필요가 없다. 그런데 지금은 그게 핵심은 아니니 설명 생략.)
아무튼 코드를 거의 건들지 않았다. 대신 컨트롤러 V4 어댑터만 만들어서 넣어 줬다.
프론트 컨트롤러 입장에선 핸들러 어댑터 인터페이스에만 의존하고 있다. 그래서 구현 클래스가 뭐가 들어오든 상관없다. 프론트 컨트롤러 코드를 바꿀 필요가 없다.
ControllerV4HandlerAdapter의
@Override
public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException {
ControllerV4 controller = (ControllerV4) handler;
Map<String, String> paramMap = createParamMap(request);
HashMap<String, Object> model = new HashMap<>();
String viewName = controller.process(paramMap, model);
ModelView mv = new ModelView(viewName);
mv.setModel(model);
return mv;
}
이 부분을 보면
String viewName = controller.process(paramMap, model);
컨트롤러는 String으로 viewName을 반환해 줬는데 어댑터가 어댑팅을 해서 ModelView로 바꿔서 프론트 컨트롤러에 반환한다.
우리가 만든 MVC 프레임워크는 역할과 구현이 잘 분리가 되었다. 이걸 더 많이 설계해 나가면 핸들러 매핑에 대한 것도 더 인터페이스화할 수 있고, 핸들러 어댑터도 인터페이스화하고 viewResolver()도 인터페이스로 만들어서 구현을 꽂아 넣도록 설계할 수 있다. 뷰도 인터페이스로 설계할 수 있다. 이런 식으로 완전히 인터페이스 기반으로 다 해서 바꾸고 싶은 부분만 initHandlerMappingMap()과 initHandlerAdapters()처럼 중간중간에 구현체를 꽂아 넣으면 된다. 이걸 밖에서 주입하도록 설계를 바꾸면 깔끔해진다. 그러면 프론트 컨트롤러 코드를 전혀 고치지 않고 OCP를 다 지키면서 개발할 수 있다.
이때 새로운 아이디어가 떠오른다. 애노테이션을 넣어서 매핑 정보도 넣고 이런 식으로 하더라도 우리가 개발했던 구조를 흔들지 않고, 그대로 애노테이션 기반의 컨트롤러를 만들고, 애노테이션을 처리할 수 있는 핸들러 어댑터를 만들면 된다.
지금까지 말한 모든 게 스프링 MVC에서 똑같이 했던 거다. 스프링 MVC는 전부 인터페이스화 되어 있다.
스프링 MVC도 과거엔 이런 인터페이스 기반으로 개발하다가 애노테이션이 유행하면서 @Controller를 하면서 이 구조를 거의 바꾸지 않고, @Controller를 처리할 수 있는 핸들러 어댑터를 딱 끼워 넣으면서 그냥.. 스프링 MVC에서 새 기능이 나온다.
Tidy
v5에서 v1, v2도 할 수 있었는데 그러면 v5를 약간 손봐야 해서 그냥 v3랑 v4만 했다.
v5까지 만들어 봤다. 여기에 요즘 유행하는 애노테이션을 사용해서 컨트롤러를 더 편리하게 발전시킬 수 있다. 애노테이션 스타일을 지원하는 핸들러 어댑터만 새로 만들어서 추가하면 된다.
우리는 굳이 추가하지 않을 것이다. 스프링이 이미 다 만들어 놓았기 때문에 우린 그걸 쓰면 된다.
스프링 MVC도 우리가 만든 구조로 되어 있다. 스프링 MVC에서 어댑터가 정말 중요한 역할을 한다. 스프링 MVC도 여러 가지 컨트롤러 인터페이스가 있다. 그러면서 애노테이션을 처리할 수 있는 그런 것도 있다. 컨트롤러와 그걸 처리할 수 있는 어댑터들이 다 구현되어 있다. 물론 애노테이션을 처리할 수 있는 핸들러 어댑터도 다 만들어져 있다.
@RequestMapping("/hello") 이걸 본 적이 있을 것이다.
스프링에 이런 핸들러 어댑터도 있다.
RequestMappingHandlerAdapter
JSP는 dispatcher.forward(request, response);
이렇게 dispatcher로 포워드해서 JSP를 렌더링 해야 하는데, 뒤에서 말할 타임리프 등은 진짜 뷰에서 렌더링 한다.
스프링 MVC는 viewResolver()도 인터페이스이다. 뷰도 인터페이스인 듯
v5에서 어댑터가 컨트롤러(핸들러)랑 프론트 컨트롤러랑 안 맞는 걸 중간에서 맞춰 준다. 넘어가는 거나 반환되는 것도 다 맞춰 준다.
스프링 MVC도 지금 배운 그림에서 크게 달라지지 않는다.
단순한 건 if else가 좋을 수도 있지만, 향후 확장성을 고려하고 if else 여러 가지 쓰는 거보다 다형성으로 바꾸는 게 더 좋을 거 같으면 다형성으로 바꾸는 게 더 좋다.
이번 시간은 자바 객체 지향과 다형성에 대한 훈련이 되었을 것이다.
Spring MVC - Architecture
Spring MVC Overall Structure
이전에 직접 만든 MVC 프레임워크와 실제 스프링 MVC를 비교하면서 알아보겠다.
스프링 MVC에선 DispatcherServlet이 가장 중요하다. 이것이 프론트 컨트롤러 패턴을 구현한 프론트 컨트롤러이다.
이전엔 뷰 리졸버(viewResolver)를 메서드로 만들었었는데, 스프링은 뷰 리졸버를 인터페이스로 만들었다. 그래서 더 확장성 있게 만들어져 있다.
MyView도 스프링에선 View이고 인터페이스로 만들어져 있다. 그래서 확장성 있게 개발할 수 있다.
DispatcherServlet 구조를 간단히 살펴볼 것이다. 실제론 코드가 되게 많지만 핵심 코드는 그렇게 많지 않다.
DispatcherServlet도 서블릿이다.
public class DispatcherServlet extends FrameworkServlet {
.
.
.
}
FrameworkServlet도 쭉 올라가다 보면 HttpServlet이 있다.

HttpServlet을 DispatcherServlet이 가지고 있다. service() 이런 로직도 가지고 있다.
누군가 DispatcherServlet을 등록해 줘야 한다. 왜냐하면 아래 코드를 보면 @WebServlet 이런 게 없다.

서블릿을 등록할 수 있는 여러 방법이 있다. 스프링 부트는 자바 코드로 직접 등록하는 방법도 있다.
스프링 부트가 내장 톰캣을 띄운다. 그러면서 DispatcherServlet을 서블릿으로 등록하면서 내장 톰캣을 띄워 버린다.
스프링 부트는 DispatcherServlet을 서블릿으로 자동으로 등록하면서 모든 경로(urlPatterns="/")에 대해서 매핑한다.
다만, 모든 경로로 설정해 놓으면 우선순위가 낮다. 세세하게 세팅된 다른 것들이 우선순위가 높다. 그래서 우리가 만든 다른 서블릿들은 문제없이 호출된다. 즉, DispatcherServlet이 우선순위가 더 낮다.
FrameworkServlet.service()를 시작으로 여러 메서드가 호출되면서 최종적으로 DispatcherServlet.doDispatch()가 호출된다.
DispatcherServlet.doDispatch()가 가장 중요하다. 핸들러 찾아서 호출해 주는 역할도 한다.
processDispatchResult() 안엔 여러 코드가 있지만 결론적으로 render()가 호출된다.
protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception {
View view;
String viewName = mv.getViewName();
// 6. 뷰 리졸버를 통해서 뷰 찾기, 7. View 반환
view = resolveViewName(viewName, mv.getModelInternal(), locale, request);
// 8. 뷰 렌더링
view.render(mv.getModelInternal(), request, response);
}
resolveViewName()에 뷰의 논리 이름을 넣어서 진짜 뷰 객체를 찾는다. 그다음에 뷰의 render()를 한다.
JSP의 경우엔 뷰의 render()를 하면 우리가 했던 것처럼 JSP 포워드하는 로직이 들어 있다.
핸들러 조회: 핸들러 매핑을 통해 요청 URL에 매핑된 핸들러(컨트롤러)를 조회한다. 그런데 스프링은 URL뿐만 아니라 다른 정보도 더 활용한다. HTTP 헤더의 정보도 활용할 수 있고, 여러 가지 정보를 추가로 더 활용해서 핸들러 매핑 정보를 찾는다. Content-Type이나 Accept 이런 것도 활용해서 핸들러 매핑을 찾는다. 우리가 이전에 만든 것보다 훨씬 고도화가 되어 있다.
핸들러 어댑터에서 핸들러가 무엇을 반환하든, 어떻게든 ModelAndView로 만들어서 반환한다.
그다음에 뷰 리졸버를 찾고 실행한다. 뷰 리졸버도 돌리면서 찾는다. JSP 같은 경우 InternalResourceViewResolver라는 게 사용된다.
뷰 리졸버는 뷰의 논리 이름을 물리 이름으로 바꾸고, 렌더링 역할을 담당하는 뷰 객체를 반환한다. JSP의 경우 InternalResourceView(JstlView)를 반환하는데, 내부에 forward() 로직이 있다.
뷰를 통해 뷰를 렌더링 하면 JSP 포워드로 JSP로 가서(?) JSP가 렌더링 되고 최종적으로 HTML이 고객에게 전달된다.
나중에 배울 타임리프 같은 것들은 타임리프 내부에 뷰 렌더링 하는 자바 코드가 있다. 그건 JSP 포워드 이런 게 아니고, 진짜 자바 코드에서 바로 렌더링 해서 HTML을 HTTP response 객체에 담아서 반환한다.
스프링 MVC의 큰 강점은 DispatcherServlet 코드의 변경 없이, 원하는 기능을 변경하거나 확장할 수 있다는 점이다. 지금까지 설명한 대부분을 확장 가능할 수 있게 인터페이스로 제공한다.
이 인터페이스들만 구현해서 DispatcherServlet에 등록하면 여러분만의 컨트롤러를 만들 수도 있다. 물론 이걸 확장하는 건 쉽지 않다.
핸들러 매핑: org.springframework.web.servlet.HandlerMapping
우리가 이전에 만들 땐 단순히 Map으로 했었는데, 이것도 인터페이스로 제공된다.
이 매핑 정보 안에 어떤 URL, 어떤 헤더가 있어야 하는지 등등이 들어가 있다.
뷰 리졸버도 우리가 만들 땐 메서드로 했었지만, 인터페이스로 제공된다. 스프링용(?) 뷰 리졸버, 타임리프용 뷰 리졸버가 다 따로 제공된다.
뷰 리졸버가 뷰를 생성해서 반환해 준다. 뷰도 JSP용 뷰, 타임리프용 뷰 이런 식으로 나눠진다.
뷰도 인터페이스로 제공되고, 구현체가 따로 제공된다.
지금은 큰 그림을 본 것이고, 다음 시간엔 스프링 MVC가 제공하는 핸들러 어댑터, 핸들러 매핑에 대해 좀 더 깊이 있게 설명할 것이다. 그다음 시간엔 뷰와 뷰 리졸버에 대해 깊이 있게 배울 것이다.
Handler Mapping and Handler Adapter
지금은 전혀 사용하지 않지만 과거에 주로 사용했던, 애노테이션 기반의 @Controller가 나오기 전에 썼던 간단한 컨트롤러가 있다. 그걸 우리가 직접 등록해 보고, 그걸로 스프링이 제공하는 핸들러 매핑과 핸들러 어댑터를 이해해 보겠다.
지금은 애노테이션 기반의 @Controller 쓰지만, 스프링은 예전에 Controller라는 인터페이스를 썼었다.
Controller 인터페이스는 @Controller 애노테이션과는 전혀 다르다. 처리하는 어댑터도 다르고 다 다르다.
간단하게 구현해 보겠다. 어차피 스프링 부트가 떠 있고, 핸들러 매핑이 다 되어 있다. 그래서 바로 개발할 수 있다. 스프링 부트가 서블릿 띄워서 돌린다.
public class OldController implements Controller {
@Override
public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
System.out.println("OldController.handleRequest");
return null;
}
}
여기서 @Controller 쓰는 거 아니다. 핸들러 매핑을 따로 처리해야 한다.
여기에 @Component는 해야 한다.
아래는 핵심 원리 기본 편 강의의 컴포넌트 스캔.pdf에 있는 내용이다.

@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 null;
}
}
@Component("/springmvc/old-controller")
이 스프링 빈의 이름이 "/springmvc/old-controller"이다.
스프링 빈의 이름을 그냥 URL 패턴으로 맞췄다. 이렇게 하면 호출된다.
이 컨트롤러가 어떻게 호출된 것일까?
http://localhost:8080/springmvc/old-controller
이 URL 호출하면 HTTP 요청이 들어간다. 그러면 핸들러 매핑에서 이 컨트롤러(OldController)를 찾아와야 한다.
'스프링 빈의 이름으로 핸들러를 찾을 수 있는 핸들러 매핑'이 필요하다.
우리는 호출할 때 스프링 빈의 이름을 넣었다. 그러니 핸들러 매핑이 스프링 빈의 이름으로 스프링 빈(?)을 찾아서 꺼낼 수 있는 특수한 핸들러 매핑 구현체가 필요하다.
핸들러를 찾아오면 어댑터가 이것을 실행할 수 있는지 확인해야 한다. 즉, Controller 인터페이스(Controller 애노테이션 아니다.)를 호출할 수 있는 핸들러 어댑터를 찾고 실행해야 한다.
스프링은 이미 필요한 핸들러 매핑과 핸들러 어댑터를 대부분 구현해 두었다. 지금도 실행이 된 것을 보면 알 수 있다.
스프링 부트를 사용하면 자동으로 핸들러 매핑과 어댑터를 여러 가지를 등록해 준다.
핸들러 매핑은 여러 가지가 등록이 되는데 대표적인 거 두 가지만 적어 놨다.
0순위, 우선순위가 가장 높은 것은
0 = RequestMappingHandlerMapping: 애노테이션 기반의 컨트롤러인 @RequestMapping에서 사용
이것을 먼저 찾아서 실행한다. 스프링으로 개발하면 거의 99% 이걸로 개발한다.
두 번째로 이것도 자동으로 등록된다.
1 = BeanNameUrlHandlerMapping: 스프링 빈의 이름으로 핸들러를 찾는다.
http://localhost:8080/springmvc/old-controller
URL이 들어오면, /springmvc/old-controller 이 URL이랑 똑같은 이름의 스프링 빈을 찾는다.
우리는 @Component("/springmvc/old-controller")
이렇게 했었다. 스프링 빈의 이름을 URL로 맞춰 놨었다.
찾아서 실행하면 처음에 뒤져 보는데 @Controller랑 @RequestMapping이 없으므로
0순위인 RequestMappingHandlerMapping은 무시된다. 그다음으로 두 번째 BeanNameUrlHandlerMapping으로 봤더니 있으므로 OldController를 핸들러로 꺼낸다.
그다음엔 이것을 처리할 수 있는 핸들러 어댑터 목록을 찾는다. 그러면 스프링 MVC가 dispatcherServlet에서 핸들러 어댑터를 찾아본다.
0 = RequestMappingHandlerAdapter: 애노테이션 기반의 컨트롤러인 @RequestMapping에서 사용
이건 아니므로 무시한다.
그다음에
1 = HttpRequestHandlerAdapter: HttpRequestHandler 처리
이것도 아니다.
2 = SimpleControllerHandlerAdapter: Controller 인터페이스(애노테이션X, 과거에 사용) 처리
public class SimpleControllerHandlerAdapter implements HandlerAdapter {
public SimpleControllerHandlerAdapter() {
}
public boolean supports(Object handler) {
return handler instanceof Controller;
}Controller 인터페이스를 서포트한다.
그래서 SimpleControllerHandlerAdapter가 나온다.
그러면 DispatcherServlet은 SimpleControllerHandlerAdapter의 handle()을 호출하면서 request, response, handler가 넘어간다.
@Nullable
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return ((Controller)handler).handleRequest(request, response);
}
이게 호출된다. 우리가 만들었던 V3 버전이랑 비슷하다.
이번엔 HttpRequestHandler를 써 보겠다.
public interface HttpRequestHandler {
void handleRequest(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException;
}
이건 리턴 타입이 void이다. 이건 서블릿이랑 거의 유사한 구조로 만들어진 핸들러이다.
이번에도 @Component 작성한다.
@Component("/springmvc/request-handler")
public class MyHttpRequestHandler implements HttpRequestHandler {
@Override
public void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
System.out.println("MyHttpRequestHandler.handleRequest");
}
}
먼저 HandlerMapping을 순서대로 찾는데, 빈 이름("/springmvc/request-handler")으로 찾는다. 그러면 MyHttpRequestHandler가 나온다.
그다음에 핸들러 어댑터를 뒤진다. RequestMappingHandlerAdapter는 패스하고, HttpRequestHandlerAdapter가 나온다.
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;
}
.
.
.
HttpRequestHandlerAdapter의 handle()이 DispatcherServlet에서 호출된다.
((HttpRequestHandler)handler).handleRequest(request, response);가 호출되면 우리가 만든 handleRequest()가 호출된다. 즉, System.out.println("MyHttpRequestHandler.handleRequest");가 호출된다.
구조적인 걸 설명할 때야 이런 옛날 걸 설명하는 것이고, 실제 우리가 개발할 땐 @RequestMapping만 거의 쓴다. 다른 것도 가끔 쓰는데 거의 안 쓴다.
RequestMappingHandlerMapping 이건 애노테이션에 있는 메타 URL 정보를 가지고 핸들러를 찾아 준다.
View Resolver
뷰 리졸버를 알아보기 위해 이전에 작성한 OldController가 ModelAndView를 반환하도록 수정하겠다.
@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");
}
}
"/WEB-INF/views/new-form.jsp"로 가도록 한다. 일단 논리적인 이름만 넣었다. 뷰 리졸버에서 물리적인 이름으로 바꿀 것이다.
이 상태에서 http://localhost:8080/springmvc/old-controller
들어가면 Whitelabel Error Page 오류 페이지가 나온다.
하지만 인텔리제이에선 OldController.handleRequest가 제대로 출력된다.
컨트롤러는 호출이 되었는데 뷰를 못 찾은 거다. 이제 뷰 리졸버를 만들어 줘야 한다.
application.properties에 다음 코드를 추가하자
spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp
다시 http://localhost:8080/springmvc/old-controller
들어가면 제대로 나올 것이다.
물론 form만 하고 save는 아직 없으므로, 값을 넣고 전송하면 또 오류가 난다.
스프링 부트는 애플리케이션이 올라올 때 여러 가지를 자동으로 등록하는데 그 중에서 InternalResourceViewResolver도 자동으로 등록한다.
등록할 때 그냥 등록하는 게 아니고, application.properties에 등록한 spring.mvc.view.prefix, spring.mvc.view.suffix 설정 정보를 가져와서 등록한다.
실제로는 스프링 부트가 해 주는 게 이런 거다.
@ServletComponentScan // 서블릿 자동 등록
@SpringBootApplication
public class ServletApplication {
public static void main(String[] args) {
SpringApplication.run(ServletApplication.class, args);
}
@Bean
ViewResolver internalResourceViewResolver() {
return new InternalResourceViewResolver("/WEB-INF/views/", ".jsp");
}
}
스프링 부트가 설정 정보를 가져와서 이걸 자동으로 해 준다. 우리가 직접 안 해도 된다.
이렇게 다 해 주기 때문에, 이 뷰 리졸버를 통해 실제 뷰가 나오고 그게 된 거다.
뷰 리졸버의 동작 방식은
요청하면 핸들러 매핑하고 어댑터 갔다가 핸들러 어댑터에서 ModelAndView를 반환하는 건 똑같다.
DispatcherServlet에서 논리적인 이름으로 viewResolver가 호출된다. 뷰 리졸버가 호출될 때..
스프링 부트가 자동으로 여러 뷰 리졸버를 등록해 주는데, 그 중에서 일단 두 가지만 설명한다.
1 = BeanNameViewResolver: 빈 이름으로 뷰를 찾아서 반환한다.(예: 엑셀 파일 생성 기능에 사용)
뷰를 인터페이스로 직접 구현할 수 있는데, 그걸 액셀 파일용 뷰 이런 걸 만들 수 있다. 직접 스프링 빈으로 등록할 수 있다. 그걸 빈의 이름으로 매칭해서 가져온다. 우리는 이건 아니다. 왜냐하면
return new ModelAndView("new-form");
new-form이라는 스프링 빈이 없다. 그러므로 이건 패스한다.
그다음에
2 = InternalResourceViewResolver: JSP를 처리할 수 있는 뷰를 반환한다.
이게 호출된다. 그러면 JSP를 처리할 수 있는 뷰를 반환한다. suffix 이런 걸 다 해서.
핸들러 어댑터를 통해 new-form이라는 논리 뷰 이름을 얻는다.
그다음에 뷰 리졸버가 호출되는데 new-form이라는 이름으로 뷰 리졸버 리스트를 순차적으로 돌려 본다.
BeanNameViewResolver는 new-form이라는 이름의 스프링 빈으로 등록된 뷰를 찾아야 하는데 없다. 그러므로 패스한다. InternalResourceViewResolver가 호출된다.
InternalResourceViewResolver는 단순하다. InternalResource라는 건 내부에서 자원이 이동하는 것이다. 서블릿에서 JSP 갔던 것(같은 것?). 그렇게 내부에서 자원을 찾을 수 있는 것을 할 때 쓴다.
InternalResourceView는 JSP처럼 포워드 forward()를 호출해서 처리할 수 있는 경우에 사용한다. 우리가 예전에 만든 MyView와 비슷하다.
InternalResourceViewResolver가 InternalResourceView를 만들어서 반환한다.
이게 반환되면 render() 호출되고 InternalResourceView는 RequestDispatcher 가져와서 forward()를 사용해서 JSP를 실행한다.
protected void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
this.exposeModelAsRequestAttributes(model, request);
this.exposeHelpers(request);
String dispatcherPath = this.prepareForRendering(request, response);
RequestDispatcher rd = this.getRequestDispatcher(request, dispatcherPath);
if (rd == null) {
throw new ServletException("Could not get RequestDispatcher for [" + this.getUrl() + "]: Check that the corresponding file exists within your web application archive!");
} else {
if (this.useInclude(request, response)) {
response.setContentType(this.getContentType());
if (this.logger.isDebugEnabled()) {
this.logger.debug("Including [" + this.getUrl() + "]");
}
rd.include(request, response);
} else {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Forwarding to [" + this.getUrl() + "]");
}
rd.forward(request, response);
}
}
}뒤에서 쓸 타임리프 이런 건 타임리프 뷰 리졸버가 있고 타임리프 뷰가 있다. 이런 식으로 매칭된다.
InternalResourceViewResolver는 만약 JSTL 라이브러리가 있으면 InternalResourceView를 상속받은 JstlView를 반환한다. JstlView는 JSTL 태그 사용 시 약간의 부가 기능이 추가된다.
우리는 jstl 라이브러리가 포함되어 있다. 그래서 JstlView가 반환된다.
다른 뷰는 실제 뷰를 렌더링 하지만, JSP의 경우 forward()를 통해서 해당 JSP로 이동(실행)해야 렌더링이 된다. JSP 서블릿을 실행하는 거다.
JSP를 제외한 나머지 뷰 템플릿들은 forward() 과정 없이 바로 렌더링이 된다. 실제 자바 코드로 바로 렌더링이 된다.
Thymeleaf 뷰 템플릿을 사용하면 ThymeleafViewResolver를 등록해야 한다. 아까 @Bean으로 만든 것처럼.
그런데 최근에는 스프링 부트가 이런 작업도 모두 자동화해 준다. 우린 그냥 설정으로 잘 쓰기만 하면 된다.
지금까지 스프링 MVC 핵심 구조를 설명한 거다.
Spring MVC - Getting Started
예전엔 스프링이 MVC 부분이 약해서 앞단은 스트럿츠 같은 MVC 프레임워크를 사용했고, 뒷단은 스프링을 쓰는 식으로 두 개를 붙여서 사용했었다.
@RequestMapping을 누군가 인식해서 찾아 줘야 한다.
요청이 오면 핸들러 매핑에서 핸들러(컨트롤러)를 찾아 줘야 한다. 그리고 그 찾은 컨트롤러를 실행해 주는 핸들러 어댑터가 필요하다.
가장 우선순위가 높은 핸들러 매핑과 핸들러 어댑터는
RequestMappingHandlerMapping
RequestMappingHandlerAdapter
애노테이션 기반의 컨트롤러도 개선되는 방향들을 보겠다.
@Controller
public class SpringMemberFormControllerV1 {
@RequestMapping("/springmvc/v1/members/new-form")
public ModelAndView process() {
return new ModelAndView("new-form");
}
}application.properties에서 설정했었다.
뷰 리졸버에서 jsp를 처리하기 위한 뷰가 찾아져서 그게 렌더가 된다.
@Controller가 있으면 RequestMappingHandlerMapping이 핸들러 정보구나하고 꺼낼 수 있는 대상이 된다.
@Controller는 두 가지 일을 한다.
컴포넌트 스캔의 대상이 되고,
RequestMappingHandlerMapping에서 쓰게 된다.
그래서 getHandler()로 꺼낼 수 있다.
스프링 3.0(스프링 부트 3.0?) 이상은 해당되지 않는 내용인 듯
RequestMappingHandlerMapping이 어떻게 동작하냐 하면, 이게 내가 인식할 수 있는 핸들러인지 아닌지를 어떻게 찾냐 하면, 스프링 빈 중에서(스프링 빈으로 등록되어 있어야 한다.) @RequestMapping 또는 @Controller(이건 안에 @Component가 있으니까 스프링 빈으로 자동으로 등록된다.)가 클래스 레벨에 붙어 있는 경우에 매핑 정보로 인식한다.
// @Controller
@Component
@RequestMapping
public class SpringMemberFormControllerV1 {
@RequestMapping("/springmvc/v1/members/new-form")
public ModelAndView process() {
return new ModelAndView("new-form");
}
}
@Controller가 없어도 @RequestMapping이 클래스 레벨에 있으면 된다. 물론 스프링 빈으로 등록은 해야 한다.
@RequestMapping이 메서드 레벨에 있는 건 인식을 못 한다.
RequestMappingHandlerMapping이 @Controller 또는 @RequestMapping이 클래스 레벨에 있으면 자기가 처리할 수 있는 핸들러라는 걸 안다.
물론 @Component 없이 스프링 빈으로 직접 등록해도 된다.
@ServletComponentScan // 서블릿 자동 등록
@SpringBootApplication
public class ServletApplication {
public static void main(String[] args) {
SpringApplication.run(ServletApplication.class, args);
}
@Bean
SpringMemberFormControllerV1 springMemberFormControllerV1() {
return new SpringMemberFormControllerV1();
}
}
그런데 그냥 @Controller 쓰는 게 제일 깔끔하다. 거의 이 방식만 쓴다.
주의! - 스프링 3.0 이상
스프링 부트 3.0(스프링 프레임워크 6.0)부터는 클래스 레벨에 @RequestMapping이 있어도 스프링 컨트롤러로 인식하지 않는다. 오직 @Controller가 있어야 스프링 컨트롤러로 인식한다. 참고로 @RestController는 해당 애노테이션 내부에 @Controller를 포함하고 있으므로 인식된다. 따라서 @Controller가 없는 위의 두 코드는 스프링 컨트롤러로 인식되지 않는다.(RequestMappingHandlerMapping에서 @RequestMapping은 이제 인식하지 않고, Controller만 인식한다.)
mv.getModel().put("member", member);
이것도 되지만
mv.addObject("member", member);
이게 깔끔하다.
Spring MVC - Controller Integration
SpringMemberControllerV2를 만들 때
@RequestMapping("/springmvc/v1/members/new-form") 이걸
@RequestMapping("/springmvc/v2/members/new-form")
이렇게 바꾸지 않으면 이전 것이랑 매핑 URL이 겹쳐서 서버 띄울 때 오류 난다.
@Controller
public class SpringMemberControllerV2 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@RequestMapping("/springmvc/v2/members/new-form")
public ModelAndView newForm() {
return new ModelAndView("new-form");
}
@RequestMapping("/springmvc/v2/members/save")
public ModelAndView save(HttpServletRequest request, HttpServletResponse response) {
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.getModel().put("member", member);
mv.addObject("member", member);
return mv;
}
@RequestMapping("/springmvc/v2/members")
public ModelAndView members() {
List<Member> members = memberRepository.findAll();
ModelAndView mv = new ModelAndView("members");
mv.addObject("members", members);
return mv;
}
}
@RequestMapping은 메서드 단위로 되기 때문에 이런 식으로 원하는 만큼 다 넣을 수 있다. 그래도 연관성이 있는 것들끼리 넣는 게 좋다. 이건 우리가 정하면 된다.
지금처럼 폼 보여 주고, 저장하고, 리스트 보여 주는 건 같은 Member에 대한 거라 SpringMemberControllerV2 하나로 제공하면 된다.
/springmvc/v2/members
이게 중복된다.
클래스 레벨에 @RequestMapping을 두면 메서드 레벨과 조합이 된다.
@RequestMapping
public ModelAndView members() {
.
.
.
}
강의 내용 X) 이건 테스트해 보니
@RequestMapping(), @RequestMapping("") 이렇게 해도 동작하는 듯. @RequestMapping("/") 이건 오류 난다.
Spring MVC - Practical Approach
애노테이션 기반의 컨트롤러는 ModelAndView를 반환해도 되고, 인터페이스로 딱 고정되어 있지 않다. 굉장히 유연하게 설계되어 있다. String을 반환해도 된다. 그러면 뷰 이름으로 알고 프로세스가 진행된다.
뒤에서 설명하겠지만 String을 다르게 인식할 수 있는 여러 가지 방법들도 있다.
애노테이션 기반의 컨트롤러가 정말 유연하다. HttpServletRequest도 받을 수 있고, HttpServletResponse도 받을 수 있고, 파라미터를 직접 받을 수도 있다. 다음처럼 할 수 있다.
@RequestParam이라는 애노테이션이 있다.
@RequestMapping("/save")
public ModelAndView save(
@RequestParam("username") String username,
@RequestParam("age") int age,
Model model) {
.
.
.
}
@RequestParam("age") int age
타입 캐스팅이나 타입 변환도 다 자동으로 스프링에서 처리해 준다.
Model model
Model도 받을 수 있다. 스프링이 제공하는 Model을 그대로 쓸 수 있다.
@Controller
@RequestMapping("/springmvc/v3/members")
public class SpringMemberControllerV3 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@RequestMapping("/new-form")
public String newForm() {
return "new-form";
}
@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";
}
@RequestMapping
public String members(Model model) {
List<Member> members = memberRepository.findAll();
model.addAttribute("members", members);
return "members";
}
}
지금까지 중 가장 깔끔하다.
지금 코드를 비롯하여 이전 코드들의 단점은 get, post를 구분하지 않았다는 것이다.
예를 들어 SpringMemberListControllerV1의 경우 get으로 요청해도 되고 post로 요청해도 되고 아무거나 요청해도 됐다.
왜냐하면 HTTP 메서드 get, post, put 이런 걸 지금까진 전혀 신경 쓰지 않았다. 이건 좋은 개발 방법은 아니다. 예를 들면 리스트의 경우 get으로 와 달라고 하고, save는 post로 와 달라는 식으로 HTTP 메서드를 넣어서 구분하는 게 좋다.
지금은 Postman으로
http://localhost:8080/springmvc/v3/members/new-form
이걸 get으로 보내든 post로 보내든 잘 나온다.
왜냐하면 스프링에서 안 막았다.
@RequestMapping(value = "/new-form", method = RequestMethod.GET)
이렇게 하면 get인 경우에만 호출된다. post로 호출하면 안 나온다. 이렇게 제약을 거는 게 더 좋은 설계이다.
구글링) 여러 메서드의 요청을 허락하고 싶으면 아래처럼 하면 되는 듯
@RequestMapping(value = "/new-form", method = {RequestMethod.GET, RequestMethod.POST})
save의 경우엔
@RequestMapping(value = "/save", method = RequestMethod.POST)
save할 때 get은 어색하다. post는 데이터를 변경하기 때문에 이것을 get으로 허용해 놓으면 앞단의 캐시 문제부터 시작해서 여러 가지.. HTTP 스펙은 여러 번 호출해도 부작용이 없다고 생각하기 때문에 여러 가지 문제가 발생할 수 있다.
그래서 기능에 맞게 단순 조회는 get, 데이터 변경은 post 이런 식으로 맞춰야 한다.
더 깔끔하게 하면
@Controller
@RequestMapping("/springmvc/v3/members")
public class SpringMemberControllerV3 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@GetMapping("/new-form")
public String newForm() {
return "new-form";
}
@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";
}
@GetMapping
public String members(Model model) {
List<Member> members = memberRepository.findAll();
model.addAttribute("members", members);
return "members";
}
}
@RequestParam("username")은 request.getParameter("username")와 거의 같은 코드라 생각하면 된다.
물론 기능이 여러 가지 더 있는데 그건 다음 시간에 설명한다. 스프링 애노테이션에 관한 디테일한 것들 하나하나 다 공부한다.
@GetMapping을 열어 보면
@RequestMapping(
method = {RequestMethod.GET}
)
이게 있다. 스프링은 이런 식으로 애노테이션을 조합한 편리한 애노테이션들을 많이 제공한다.
Order
고객 요청이 오면 DispatcherServlet이 프론트 컨트롤러 역할을 한다. 그리고 핸들러 매핑에서 핸들러 조회를 한다. 여기 핸들러들은 스프링 부트가 미리 등록해 놓는다. 그중에서 순차적으로 찾아서, 핸들러 매핑이 처리할 수 있는 핸들러를 찾아 준다.
그것을 핸들러 어댑터 목록에 던지면 그것을 처리할 수 있는 핸들러 어댑터가 나온다. 그리고 핸들러 어댑터를 통해서 실제 핸들러를 호출하고 반환해 준다. 핸들러 어댑터는 호출은 handle()을 핸들러를 넘겨서 호출하고 반환은 ModelAndView를 반환한다.
그리고 뷰 리졸버를 호출해서 실제 뷰를 찾는다. jsp면 jstl 뷰 같은 게 반환되고 뷰가 렌더가 되면서 실제 고객에게 HTTP 응답으로 나간다.
논리 이름이 반환되면
스프링이 뜨면서 JSP의 경우
spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp스프링 부트가 설정 정보를 가지고 InternalResourceViewResolver를 등록해 준다.
이 뷰 리졸버는 JSP 뷰를 처리할 수 있는 JSTL 뷰 같은 것들을 반환한다.
SpringMemberControllerV3를 보면 파라미터가 어떤 게 되고 어떤 반환값이 되는 건지 궁금할 수 있는데 그건 다음 시간에 배울 것이다.
JSP의 경우 JSP 포워드로 렌더링 되고, 타임리프 등은 내부에서 자바 코드로 HTML 코드 같은 게 바로 렌더링 된다. 그리고 response 바디에 담아 준다. 그러면 고객에게 HTTP 응답에 HTML 데이터가 브라우저로 나간다.
Spring MVC - Basic Features
Create Project
우리가 썼던 System.out.println()은 실무에서 잘 안 쓴다. 실무에선 로깅으로 남겨야 한다.
Jar로 한 이유:
War는 몇 가지 기능이 더 들어간다. War는 보통 톰캣 같은 WAS 서버를, 서블릿 컨테이너를 별도로 설치하고 거기에 빌드된 파일을 넣을 때 사용한다. 그리고 JSP를 쓰려면 War를 써야 한다.
Jar는 별도의 톰캣 서버에 설치하는 게 아니고 내장 톰캣으로 바로 돌릴 때 쓴다. 물론 War도 내장 톰캣으로 된다. 그런데 내장 톰캣 기능에 최적화해서 쓸 땐 Jar로 쓰면 된다. Jar를 사용하면 항상 내장 서버(톰캣 등)를 사용하고, webapp 경로도 사용하지 않는다. 내장 서버 사용에 최적화되어 있는 기능이다. 최근엔 이 방식을 많이 쓴다.
스프링 부트에 Jar를 사용하면 /resources/static/ 위치에 index.html 파일을 두면 Welcome 페이지로 처리해 준다.(스프링 부트가 지원하는 정적 컨텐츠 위치에 /index.html이 있으면 된다.
강의 외적으로 테스트)
index.html을
/static
/public
/resources(resources 하위에 또 resources)
/META-INF/resources
/templates(왜 되는지 모르겠다.)
여기에 둬도 인식한다.
/resources(static 상위의 첫 번째 resources)
/META-INF
여기에 두면 인식 안 된다.
다음 시간에 배울 로깅은 스프링 MVC랑은 크게 관련 없는데, 실무에 필요한 최소한의 기능만 배워 볼 것이다.
Briefly About Logging
SLF4J = Simple Logging Facade for Java
앞으로 System.out.println()은 안 쓴다. 실무에선 쓰면 안 된다. 이제 보고 싶은 결과는 로그라는 걸 통해 콘솔이든 어디든 출력해야 한다.
인텔리제이 gradle의 Dependencies를 보면, 스프링 부트 관련된 걸 쓰면 spring-boot-starter라는 걸 내부에서 갖다 쓴다.
spring-boot-starter-logging이라는 라이브러리를 자동으로 가져온다. 안에 Logback이나 Slf4j라는 게 보인다.
세상엔 로그 라이브러리들이 정말 많다. 그걸 인터페이스화한 프로젝트가 나온 것이다. Slf4j는 인터페이스이고 그 구현체 중 하나로 Logback을 우리가 선택한 것이다.
실무에선 스프링 부트가 기본으로 Logback을 제공한다. 성능도 좋고 기능도 많이 지원된다. 최근엔 대부분 Logback을 사용한다.
로그를 출력할 때 Slf4j란 인터페이스를 쓰고 구현체로 Logback을 선택한다.
로그를 쓰려면 일단 컨트롤러를 써야 하는데 @RestController를 써 보겠다.
Logger와 LoggerFactory는 org.slf4j 것을 사용한다.
private final Logger log = LoggerFactory.getLogger(getClass());getClass() 대신 LogTestController.class 해도 된다.
@RestController
public class LogTestController {
private final Logger log = LoggerFactory.getLogger(getClass());
@RequestMapping("/log-test")
public String logTest() {
String name = "Spring";
System.out.println("name = " + name);
log.info("log info = {}", name);
return "ok";
}
}
@Controller일 땐 문자를 리턴할 때 뷰 이름을 반환한다.
@RestController라고 하면(Rest API의 그 Rest이다.) 문자를 반환하면 이 String이 그냥 바로 반환된다. HTTP Message Body에 데이터를 그냥 넣는다.
자세한 건 뒤에서 더 설명한다.

System.out.println("name = " + name);라고 한 건
단순하게 name = Spring이라고 나왔다.
log.info("log info = {}", name);라고 한 건
2024-01-28T18:30:06.371+09:00 INFO 11560 --- [nio-8080-exec-1] h.springmvc.basic.LogTestController : log info = Spring
11560은 프로세스 아이디인 듯
nio-8080-exec-1는 현재 실행한 스레드. HTTP 요청이 오면 스레드가 풀에서 찾아져서 실행된다.
현재 나의 컨트롤러 이름도 출력된다. 그리고 메시지가 출력된다.
로그 하나 찍어 줬는데 이 많은 정보를 볼 수 있다. 이러면 버그 찾기도 쉽다. System.out.println()으로 찍은 거로 버그 찾는 건 오래 걸린다.
Logger의 진가는 따로 있다.
log.trace("log trace = {}", name);
log.debug("log debug = {}", name);
log.info("log info = {}", name);
log.warn("log warn = {}", name);
log.error("log error = {}", name);로그를 찍을 때 레벨을 정할 수 있다. 이 로그는 어떤 상태의 레벨인지..
예를 들어 현재 로그는 debug할 때 보는 거, 즉 개발 서버에서 보는 거다.
혹은 현재 로그는 중요한 정보(info) 즉, 비즈니스 정보거나 운영 시스템에서도 봐야 할 정보이다.
혹은 경고, 위험한 거다.
혹은 에러니까 빨리 확인해야 한다. 에러 로그가 남으면 우리가 알람을 보거나, 별도로 파일에 남겨서 보거나 등을 할 수 있다.
log.trace("trace log = {}, {}", name, name2);
{}에 치환된다. 여러 개 있으면 위에처럼 순서대로 치환된다.
log.trace("log trace = {}", name);
log.debug("log debug = {}", name);
log.info("log info = {}", name);
log.warn("log warn = {}", name);
log.error("log error = {}", name);
trace와 debug는 출력되지 않았다.
만약 내가 로컬에서 개발하고 있어서 모든 로그를 보고 싶으면 application.properties에 가서 현재 나의 프로젝트의..
hello.springmvc 패키지와 그 하위 로그 레벨을 설정할 수 있다.
logging.level.hello.springmvc=trace
이렇게 하면 로그 다 볼 수 있다.

trace, debug, info, warn, error 다 나온다.
logging.level.hello.springmvc=debug
이렇게 하면 trace는 빼고 debug 레벨부터 본다. 갈수록 점점 심각도가 높아진다.
debug를 본다는 건 debug, info, warn, error 다 본다는 거다. trace는 안 나온다.
개발 서버는 debug 정도 해 놓고, 로컬 PC에선 trace로 바꾸고 debug로 바꾸고 이렇게 본다.
그러다 실제 서비스되는 운영 서버에선 info 레벨로 세팅한다.
여러 로그가 남아 있어도 운영 서버에선 debug나 trace 로그를 안 찍는다.
logging.level.hello.springmvc=info
hello.springmvc 패키지와 이 패키지 하위에 설정한 로그에서 출력하는 정보들은 info 레벨까지만 출력된다.
그런데 System.out.println();으로 출력한 건 무조건 출력된다. 그래서 이걸 쓰면 안 된다. 개발 서버면 남길 수 있어도 운영 서버에 다 남기면 로그가 너무 많이 생긴다. 고객 요청이 수만 개 들어오면 로그들이 다 남는다. System.out.println()은 그게 다 남는다.
로그로 운영 시스템에선 고객 요청 데이터 중에 중요한 것만 남기도록 할 수 있다.
배포할 때 스프링 부트 기능을 이용해서 설정을 바꿀 수 있다.
배포할 땐, 개발 서버에선 logging.level.hello.springmvc=debug로 해서 배포하고, 운영 서버는 logging.level.hello.springmvc=info라고 바꿔서 배포하는 식으로 설정 정보를 다른 식으로 쓸 수 있다. 이런 식으로 각 서버마다 다르게 하고 내 로컬 PC에서 trace를 쓸 거야 이런 식으로 나눠서 하면 된다.
logging.level.hello.springmvc=error
이렇게 하면 error만 나온다.
개발 서버는 debug, 운영 서버는 info 레벨로 보통 출력한다.
개발 서버에 여러 군데에서 테스트하면서 남겨 놓으면 debug 정보 가지고 볼 수 있다.
운영 서버는 워낙 트래픽도 많고 운영에 중요한 정보만 남겨야 한다.
logging.level.root=info
root라고 하면 전체를 다 세팅하는 거다. 현재 나의 프로젝트 기본값을 이거로 세팅하는 거다.
#전체 로그 레벨 설정(기본 info)
logging.level.root=info
#hello.springmvc 패키지와 그 하위 로그 레벨 설정
logging.level.hello.springmvc=error
이렇게 같이 있으면 springmvc 패키지에선 logging.level.hello.springmvc=error가 우선권을 가진다.
#전체 로그 레벨 설정(기본 info)
logging.level.root=debug
#hello.springmvc 패키지와 그 하위 로그 레벨 설정
#logging.level.hello.springmvc=error
만약 이렇게 logging.level.root=debug
debug로 바꾸면 수많은 로그들이 콘솔에 나온다. 다른 모든 라이브러리들에도 로그 레벨이 세팅되어 있다. trace로 바꾸면 더 나온다.
그래서 기본은 info로 두고 본인이 원하는 거만 debug나 trace로 바꾸면 된다.
private final Logger log = LoggerFactory.getLogger(getClass());
이렇게 적기 귀찮을 것이다.
롬복이 제공하는 애노테이션인 @Slf4j를 쓰면 우리가 썼던 게 그대로 들어간다.
log.info("log info = {}", name);
이걸
log.info("log info = " + name);
이렇게 해도 오류 없이 된다. 하지만 이렇게 하면 안 된다. 그 이유는 자바 언어의 특징과 관련되어 있다.
info인 경우엔 더해져도 크게 상관없는데,
log.trace("trace my log = " + name);
이렇게 하면, 지금 로그 레벨이 logging.level.hello.springmvc=debug이기 때문에 trace는 출력이 안 된다.
log.trace("trace my log = " + name);
log.trace("log trace = {}", name);
이 두 개의 차이는 무엇일까?
log.trace 메서드를 호출하기 전에 먼저
"trace my log = " + name
문자를 더한다.
즉, "trace my log = " + "Spring"
이렇게 된다. 더하는 연산이 일어난다.
"trace my log = Spring"
이렇게 만든다. 그리고 이걸 가지고 있는다.
여기서 핵심은 연산이 일어났다는 것이다. 연산이 일어나면서 메모리도 사용하고 CPU도 사용한다. 그런데 정작 trace 로그는 출력 안 한다. 즉, 쓸모없는 리소스를 사용하게 된 것이다.
반면에
log.trace("log trace = {}", name);
이렇게 쓰면 문자 더하기가 아니고 메서드를 호출한다. 그러면서 파라미터만 넘긴다. 아무 연산이 일어나지 않는다.
log.trace("trace my log = " + name);
이건 계산할 거 다 계산해서 문자가 다 합쳐진 최종 로그 모양을 만든 후에 trace에 넘긴다. 그런데 trace는 출력되지 않는다.
log.trace("log trace = {}", name);
이건 파라미터만 넘기고 출력 안 한다.
이 두 개는 큰 차이가 있다.
옛날엔
if(log.isTraceEnabled()) {
log.trace("trace my log = " + name);
}
이렇게 했었는데 이젠 {} 이런 스타일이 나와서 이 방식을 많이 쓴다.
아무튼 + 방식은 절대 하면 안 된다.
로그 사용의 장점으로, 로그 레벨에 따라 개발 서버에서는 모든 로그를 출력하고, 운영 서버에서는 출력하지 않는 등 로그를 상황에 맞게 조절할 수 있다. 아래와 같은 애플리케이션 코드를 건들지 않고,
log.trace("trace my log = " + name);
log.trace("log trace = {}", name);
log.debug("log debug = {}", name);
log.info("log info = {}", name);
log.warn("log warn = {}", name);
log.error("log error = {}", name);application.properties 설정만으로 로그 레벨을 개발 서버에선 이만큼 출력하고, 운영 서버에선 이만큼 출력하겠다 같은 식으로 조절할 수 있다.
System.out.println()는 콘솔에 남는다. 그런데 로그는 설정을 해 주게 되면 파일에 별도로 남길 수 있다. 콘솔에 남기면서 파일로 남길 수도 있다. 네트워크로 로그를 전송할 수도 있다.
그리고 파일로 남기는데 용량이 너무 크면 장애가 날 수 있다. 디스크 다 차는 장애가 은근 많이 생긴다. 그런데 요즘 좋은 로거들은 파일이 100MB가 넘으면 파일을 분할하거나, 그래서 파일을 10개만 유지하도록 하거나 할 수 있고 심지어 압축해서 백업을 자동으로 할 수도 있다. 이런 걸 설정으로 할 수 있다.
로그는 성능도 System.out보다 좋다. 내부에 버퍼링하는 기능도 있고 멀티 스레드로 모아서 하는 기능도 있다. System.out.println()은 그냥 내보낸다. 이것도 물론 버퍼링이 있긴 하겠지만 로그는 버퍼나 이런 걸 성능 최적화에 다 맞춰져 있다. 로그가 한 번에 많이 와도 내부에서 버퍼링하고 멀티 스레드 관련된 이슈까지 해결하면서 성능을 극한으로 최적화를 해 놓았다.
실제 테스트해 보면 수십 배 이상 성능 차이가 난다.
공부할 땐 info든 debug든 아무렇게 써도 된다.
Request Mapping
요청 매핑은 요청이 왔을 때 어떤 컨트롤러가 호출될지를 매핑하는 것이다. 단순하게 URL로 매핑하는 것뿐만 아니라 여러 가지 요소들을 가지고 조합해서 매핑한다.
private Logger log = LoggerFactory.getLogger(getClass());
logger라고 해도 되지만 길면 불편해서 요즘엔 log로 많이 쓴다.
@RestController
public class MappingController {
private Logger log = LoggerFactory.getLogger(getClass());
@RequestMapping("/hello-basic")
public String helloBasic() {
log.info("helloBasic");
return "ok";
}
}
/hello-basic 이 URL이 오면 이 메서드가 호출되도록 매핑한다.
@RequestMapping("/hello-basic")
대부분의 속성을 배열[]로 제공하므로 다중 설정이 가능하다.
@RequestMapping({"/hello-basic", "/hello-go"})
or 조건이다. 두 개 중 아무거나 와도 된다.
/hello-basic/hello-go는 안 되는 것 같다.
/hello-basic
/hello-basic/
이 두 개는 다른 URL이지만 스프링 부트 3.0 이전에는 이 URL 요청들을 같은 요청으로 매핑했었다.
스프링 부트 3.0 이후부터는 다르다.
@RequestMapping(value = {"/hello-basic", "/hello-basic/"})
테스트해 보니 이렇게 하면 /hello-basic든 /hello-basic/든 매핑되는 듯
@RequestMapping(value = "/hello-basic", method = RequestMethod.GET)
이렇게 하면 Postman으로 GET으로 요청하면 ok가 반환되는데 POST로 요청하면 스프링 MVC는 HTTP 405 상태 코드를 반환한다.(그런데 HEAD로 요청하면 응답엔 당연히 ok가 없지만 콘솔엔 GET일 때랑 똑같이 나오는 듯. 이유는 모르겠다.)
POST로 보냈을 때 다음처럼 json 형식으로 반환되는데

이는 @RestController를 설정해서 그렇다. 이런 부분은 뒤의 예외 처리 설명할 때 다시 배워 보겠다. 지금은 그냥 스프링이 @RestController라고 하면 기본적으로 오류의 모양을 json 스타일로 보내 준다고 이해하면 된다.
@GetMapping("/mapping/{userId}")
public String mappingPath(@PathVariable("userId") String data) {
log.info("mappingPath userId = {}", data);
return "ok";
}
요즘엔 PathVariable(경로 변수) 많이 사용한다.
URL 자체에 값이 들어가 있다.
@GetMapping("/mapping/{userId}")
이렇게 템플릿 형식으로 해 놓으면
@PathVariable로 꺼낼 수 있다.
URL 경로에 값을 템플릿 형식으로 쓸 수 있고, @PathVariable이라는 걸로 값을 꺼내서 사용할 수 있다.
스프링 부트 3.2부터는
@PathVariable("userId") String userId
이렇게 생략하지 않는 걸 권장한다. 오류 날 수 있다.
public String mappingPath(String data)
이렇게 @PathVariable 자체를 생략하면 안 된다. 이러면 어떻게 되는지는 뒤에서 다시 설명한다.
+) 이제 생략하지 않는 거보다, 그냥 Gradle 쓰는 게 더 권장된다.
특정 파라미터 조건 매핑은 잘 사용하지는 않는다. 쿼리 파라미터를 조건에 매핑할 수 있다. 템플릿에서 값을 꺼내는 게 목적은 아니다.
@GetMapping(value = "/temp/mode={a}", params = "mode=debug")
public String temp(@PathVariable("a") String abc) {
log.info("temp");
return "ok";
}
http://localhost:8080/temp/mode=debug 이렇게 호출하면 에러 페이지 나온다.
@GetMapping(value = "/temp/mode={a}", params = "mode!=debug")
public String temp(@PathVariable("a") String abc) {
log.info("temp");
return "ok";
}
http://localhost:8080/temp/mode=debug 이렇게 호출하거나, http://localhost:8080/temp/mode=debuz 이렇게 호출해도 오류 없이 ok 반환한다.
@GetMapping(value = "/mapping-param", params = "mode=debug")
public String mappingParam() {
log.info("mappingParam");
return "ok";
}
http://localhost:8080/mapping-param?mode=debug
파라미터에 mode=debug가 있어야 호출된다. 특정 파라미터 정보가 있어야 호출된다는 뜻이다. URL 경로뿐만 아니라 파라미터 정보까지 추가로 더 매핑한다. (AND 조건)
params="mode" 파라미터 이름만 있어도 되고
(
http://localhost:8080/mapping-param?mode=good
http://localhost:8080/mapping-param?mode
http://localhost:8080/mapping-param?mode=
이것들은 되지만,
http://localhost:8080/mapping-param?modeq
http://localhost:8080/mapping-param?mode1
http://localhost:8080/mapping-param?good=mode 이것들은 안 된다.
)
params="!mode" mode가 없어야 한다는 것도 가능하다.
params="mode=debug"
params="mode!=debug"
params = {"mode=debug", "data=good"} mode가 debug이거나 data가 good이거나(직접 실행해 보니 and 조건인 듯)
그런데
params = {"mode=debug", "mode=good"} 이건 http://localhost:8080/mapping-param?mode=good&mode=debug 이렇게 해 보거나, http://localhost:8080/mapping-param?mode=good 이렇게 해 봐도 안 되는 듯(여러 값들을 받는 방법이 있는가?)
여러 가지 표현들이 가능하다. 그러나 요즘엔 많이 사용하지 않는 듯
@GetMapping(value = "/mapping-header", headers = "mode=debug")
public String mappingHeader() {
log.info("mappingHeader");
return "ok";
}
/mapping-header를 그냥 호출하면 not found 뜬다. 헤더를 넣어 주면 된다.

이렇게 해서 보내 보면 호출되고 ok 반환된다.
+) headers = "mode!=debug"일 땐 헤더 없이 /mapping-header로도 그냥 호출된다.
조건에 대한 건 파라미터 매핑했던 것과 똑같다.
headers="mode" (헤더 이름이 modeq나 mode12 이런 건 안 된다. 또한 헤더의 value가 mode인 건 소용없다.)
headers="!mode" mode라는 헤더 이름이 없어야 하고
headers="mode=debug" 헤더에 key, value가 다 있어야 하고
headers="mode!=debug" key랑 value가 맞으면 안 된다.(mode가 아예 없는 건 오류 안 난다.)
미디어 타입도 매핑할 수 있다. 사실 이건 위에서 배운 것처럼 headers = "Content-Type=application/json" 이렇게 해도 된다.
하지만 지금부터 하려는 거로 해야, 부가 기능들이 더 있기 때문에 스프링 MVC를 쓰면, 만약 내가 Content-Type에 따라서, 예를 들어 application/json이면 이걸 호출하고, 예를 들어 text/html이면 이걸 처리하고.. 이런 식으로 Content-Type에 따라 분리할 수 있다. 이 경우엔 headers 쓰면 안 되고 consumes를 써야 한다. 왜냐하면 스프링 MVC에서 내부적으로 이걸 가지고 처리하는 것들이 있기 때문이다.
@PostMapping(value = "/mapping-consume", consumes = "application/json")
public String mappingConsumes() {
log.info("mappingConsumes");
return "ok";
}
이렇게 하면 헤더의 Content-Type이 application/json인 경우에만 호출된다.
Postman으로 호출할 때 body에 json 데이터를 아무거나 넣고, Headers에 자동으로 추가되었는지 확인해 보자.
직접 Headers에 추가해도 상관없다.
@PostMapping(value = "/mapping-produce", produces = {MediaType.TEXT_HTML_VALUE, MediaType.APPLICATION_PDF_VALUE})
public String mappingProduces() {
log.info("mappingProduces");
return "ok";
}
이 상태에서 Postman으로 아래와 같이 보내면

application/json,text/html에서 text/html이 겹쳐서 때문인지 ok 응답을 받는다.
application/json,text/plain으로 보내면 에러 메시지 받는다.
(강의 외적으로 테스트해 본 내용)
consumes와 produces가 있다.
consumes는 서버는 소비하는 입장이다. 소비하는 입장에서 보면 요청의 Content-Type "application/json" 이 정보를 소비하는 것이기 때문에 consumes라고 한다.
consumes = {"text/plain", "application/*"} 이건 or 조건인 것 같다.
@PostMapping(value = "/mapping-produce", produces = "text/html")
public String mappingProduces() {
log.info("mappingProduces");
return "ok";
}
produces는 내가 생산한다. 이 컨트롤러가 생산하는 Content-Type이 text/html
이땐 클라이언트 HTTP 요청의 Accept랑 맞아야 한다. Accept는 클라이언트가 받아들일 수 있는 Content-Type(미디어 타입)
만약 Postman으로 Accept를 application/json으로 보내면 406 오류가 뜬다. Not Acceptable. HTTP에서 미디어 타입 안 맞을 때 나오는 오류인 듯
클라이언트 입장에서 나는 application/json만 받아들일 수 있어라고 말하는 거다. 그런데 서버에선 text/html만 생산한다. 그래서 처리를 못 한다.
만약 Accept를 text/html로 바꾸면, 클라이언트가 나는 Content-Type이 text/html인 걸 받아들일 수 있어라고 하는 거다. 그러면 잘 동작한다.
consumes는 요청 헤더의 Content-Type
produces는 요청 헤더의 Accept 기반으로 매핑된다.
produces = {"text/plain", "application/*"} or 조건인 듯
위에처럼 문자로 직접 적는 것보단
consumes = MediaType.APPLICATION_JSON_VALUE
produces = MediaType.TEXT_HTML_VALUE
이렇게 스프링에서 정해 놓은 것을 쓰는 게 더 낫다.
Request Mapping - API Example
회원 목록 조회: GET /users
회원 등록: POST /users
이렇게 하면 같은 URL인데 HTTP 메서드로 기능을 구분할 수 있다.
회원 조회: GET /users/{userId}
회원 수정: PATCH /users/{userId}
회원 삭제: DELETE /users/{userId}
이것도 URL은 똑같이 제공되는데 HTTP 메서드로 행위를 구분할 수 있다. 어떤 기능이 동작하는지를.
@RestController
public class MappingClassController {
@GetMapping("/mapping/users")
public String user() {
return "get users";
}
@PostMapping("/mapping/users")
public String addUser() {
return "post user";
}
@GetMapping("/mapping/users/{userId}")
public String findUser(@PathVariable("userId") String userId) {
return "get userId = " + userId;
}
@PatchMapping("/mapping/users/{userId}")
public String updateUser(@PathVariable("userId") String userId) {
return "patch userId = " + userId;
}
@DeleteMapping("/mapping/users/{userId}")
public String deleteUser(@PathVariable("userId") String userId) {
return "delete userId = " + userId;
}
}
Postman으로
POST로 http://localhost:8080/mapping/users 호출하면
@PostMapping("/mapping/users")
public String addUser() {
return "post user";
}
이게 호출된다. 물론 실제론 데이터도 보내야 하는데 지금은 매핑만 해 본 거다.
http://localhost:8080/mapping/users/userA를 GET으로 보내면
@GetMapping("/mapping/users/{userId}")
public String findUser(@PathVariable("userId") String userId) {
return "get userId = " + userId;
}
호출된다.
/mapping/users 이 부분이 중복된다. 이전에 배웠듯이 다음과 같이 바꿀 수 있다.
@RestController
@RequestMapping("/mapping/users")
public class MappingClassController {
@GetMapping
public String user() {
return "get users";
}
@PostMapping
public String addUser() {
return "post user";
}
@GetMapping("/{userId}")
public String findUser(@PathVariable("userId") String userId) {
return "get userId = " + userId;
}
@PatchMapping("/{userId}")
public String updateUser(@PathVariable("userId") String userId) {
return "patch userId = " + userId;
}
@DeleteMapping("/{userId}")
public String deleteUser(@PathVariable("userId") String userId) {
return "delete userId = " + userId;
}
}
요즘엔 이렇게 리소스를 계층으로 식별하는 스타일을 많이 사용한다. 이게 인지하기도 좋고 깔끔하다.
PATCH로 http://localhost:8080/mapping/users 보내면 구현하지 않았으니 오류 난다.
{
"timestamp": "2024-02-02T10:01:40.551+00:00",
"status": 405,
"error": "Method Not Allowed",
"path": "/mapping/users"
}
Accept에 따라서 오류도 json으로 받기도 하고, html로 받기도 한다. Accept가 text/html이면 오류도 html로 받는다.
Content-Type이 "application/json"이면 Accept가 "*/*"여도 json으로 오는 듯
@RestController 쓰면 기본적으로 html이 아니고 다른 걸 보내 준다.
오류 처리에 관한 건 나중에 자세히 배운다.
강의 외적으로 테스트해 본 내용)
@RestController
@RequestMapping("/temp/{users}")
public class Temp {
@GetMapping
public String temp(@PathVariable("users") String users) {
return "get temps " + users;
}
}
http://localhost:8080/temp/userA GET으로 호출하면 정상적으로 된다. 웹 브라우저에서 호출해도 된다.
@RequestMapping의 {users}와 @PathVariable의 {users}는 일치해야 한다. 일치하지 않은 채 주소를 호출하면 500 오류 뜬다.
그런데 강의에서 만들어 본 예제인
@RestController
@RequestMapping("/mapping/users")
public class MappingClassController {
.
.
.
}
여기서 @RequestMapping("/mapping/users")를 @RequestMapping("/mapping/{users}")로 바꾸면 실행 후 주소를 호출할 때 500 오류 뜬다.
@PathVariable 등 이번에 한 방식들을 요즘에 많이 쓴다.
쿼리 파라미터(요청 파라미터) 방식도 많이 쓴다.
HTTP Request - Basic, Header Query
이번 시간엔 헤더 값 같은 기본값들을 꺼내는 것을 알아보고, 다음 시간에 실제 데이터 꺼내는 것을 알아본다. 요청 파라미터라든가.
테스트할 땐 굳이 뷰가 필요 없으므로 @RestController로 하겠다.
애노테이션 기반의 컨트롤러는 다양한 파라미터를 받아들일 수 있다. 필요한 거 적으면 웬만한 건 다 있다.
HttpMethod는 GET, POST, PUT, DELETE 같은 것들
Locale은 언어 정보
@RequestHeader MultiValueMap<String, String> headerMap은 헤더를 한 번에 다 받는 것이고,
하나 받고 싶을 땐 @RequestHeader("host") String host 이런 식으로 한다. (host는 필수 헤더이다.)
쿠키도 편하게 받을 수 있다.
@CookieValue(value = "myCookie", required = false) String cookie
value는 쿠키 이름
required는 디폴트가 true인데, false면 없어도 된다는 뜻. true일 땐 쿠키가 있어야 하는 듯
@CookieValue(value = "hello") Cookie cookie
이렇게 String이 아니라 Cookie로 받을 수도 있는 듯
강의 외적으로 테스트해 본 내용)
@CookieValue(value = "myCookie", required = true, defaultValue = "temp") String cookie
이렇게 하면 쿠키가 없어도
2024-02-06T22:44:32.912+09:00 INFO 26548 --- [nio-8080-exec-1] h.s.b.request.RequestHeaderController : myCookie = temp
이렇게 출력된다.
ChatGPT에 물어본 바로는 myCookie는 쿠키의 이름이지만, temp는 쿠키의 이름이 아니라 값에 해당하는 듯. 정확하진 않으니 나중에 다시 알아볼 것
@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
) {
log.info("request = {}", request);
log.info("response = {}", response);
log.info("httpMethod = {}", httpMethod);
log.info("locale = {}", locale);
log.info("headerMap = {}", headerMap);
log.info("header host = {}", host);
log.info("myCookie = {}", cookie);
return "ok";
}
void로 해도 되긴 한다.
실행하고 Postman으로 http://localhost:8080/headers GET으로 보내면 콘솔 창에

HttpServletRequest와 HttpServletResponse
HTTP 메서드는 GET
Locale은 가장 우선순위가 높은 Locale이 지정된다.
스프링은 여러 가지 Locale이 왔을 때 어떻게 지정할지에 대한 LocaleResolver라는 게 있다. Locale을 쿠키에 저장해 놓고, 세션에 저장해 놓고 등등 여러 가지가 가능하다. 기본은 HTTP가 보내는 그걸 쓰는데, 우리가 원하는 대로 그걸 넘어서서 다른 방식으로 확장할 수도 있다.
headerMap을 보면 key, value로 들어온다.
참고로 @RequestHeader("host") String host 여기서 String 뒤의 host는 꼭 이렇게 안 지어도 된다.
myCookie는 있으면 들어오는데 없기 때문에 null이다.
같은 헤더, 같은 파라미터에 여러 값들이 들어올 수 있다. 그런 경우에 MultiValueMap으로 받으면 된다.
MultiValueMap<String, String> map = new LinkedMultiValueMap();
map.add("keyA", "value1");
map.add("keyA", "value2");
//[value1,value2]
List<String> values = map.get("keyA");
keyA를 꺼내면 배열이 반환된다.
MultiValueMap은 get()을 하면 배열로 반환되고 그 안에 있는 것들이 다 꺼내진다.
서블릿 배울 때도 같은 키에 여러 값들이 있는 경우를 배웠었다.
@CookieValue(value = "myCookie", required = false) String cookie
스프링이 단건 값을 꺼낼 땐 항상 value나 required 같은 기능을 제공한다. 그리고 defaultValue도 제공한다. 없을 때 쓰는 기본값. 뒤에서 자세히 배운다.
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
) {
.
.
.
}
여러 가지 파라미터들을 사용했다. 그럼 뭐가 되고 뭐가 안 되는가?
@Controller의 사용 가능한 파라미터 목록은 다음 스프링 공식 매뉴얼에서 확인할 수 있다.
ServletRequest, ServletResponse에 관련된 것들 다 받을 수 있다.
HttpSession
HttpMethod
Locale
TimeZone 등도 받을 수 있고
InputStream이나 Reader도 받을 수 있다.
InputStream을 받게 되면 request body 메시지 그대로 꺼낼 수 있다.
OutputStream은 response body에 값 넣고 싶을 때 쓴다.
@PathVariable도 가능하다.
@RequestParam도 가능하다. 요청 파라미터 정보를 바로 받는다.
@MatrixVariable은 잘 쓰진 않지만 링크 가서 확인하면 되는 듯
@RequestHeader
@CookieValue
@RequestBody랑 HttpEntity<B>는 뒤에서 자세히 배운다. (RequestEntity?)
@RequestPart는 multipart와 관련되어 있다.
Map, Model, ModelMap이 있는데 주로 Model을 넘기는 듯
RedirectAttributes도 뒤에서 배운다. 리다이렉트할 때 좀 더 편리하게 하는 기능
@ModelAttribute는 request parameter와 비슷한데, 객체를 바로 받을 수 있다. 이것도 뒤에서 배운다.
Errors와 BindingResult는 validation(검증) 편에서 배운다.
SessionStatus + class-level @SessionAttributes
이건 잠깐 세션에 담아 놓고 꺼내서 쓰고 할 때 사용한다.
@SessionAttribute 이것도 세션과 관련된 기능이다.
@RequestAttribute는 request attribute를 바로 꺼내는 기능이다.
요청뿐만 아니라 리턴 값 즉, String 말고도 쓸 수 있다. ModelAndView 등
@ResponseBody
HttpEntity<B>, ResponseEntity<B>
HttpHeaders 바디 없이 헤더 정보만 반환할 수 있다.
String
View 뷰를 직접 반환한다.(MVC의 뷰)
Model
ModelAndView
void도 가능하다.
DeferredResult<V>, Callable<V> 이런 것들은 비동기 처리할 때 사용하는데, 잘 사용하진 않는다.
이것들을 디테일하게 설명하지 않은 이유는 실무에서 쓰는 것들은 다 해 볼 것이다. 지금은 참고만 하면 된다.
HTTP Request Parameters - Query Parameters, HTML Form
클라이언트에서 서버로 요청 데이터를 보내는 건 딱 세 가지 방법뿐이다.
GET 방식의 쿼리 파라미터 보내는 것
POST 방식으로 HTML form 데이터 보내는 것. 이때는 Content-Type이 application/x-www-form-urlencoded이다. 그리고 HTTP 메시지 바디에 쿼리 파라미터 형식과 비슷하게 전달된다. 이렇기 때문에 전에 서블릿에서 배웠듯이, request.getParameter()로 GET의 쿼리 파라미터 값도 꺼낼 수 있고 POST의 HTML form으로 넘어온 값도 꺼낼 수 있다. 형식이 똑같으므로.
HTTP 메시지 바디에 데이터를 직접 담아서 요청하는 것. HTTP API에서 주로 사용하고, json, xml, text 등을 담아서 넘길 수 있지만 주로 json을 사용한다. 주로 POST, PUT, PATCH를 사용한다.
GET - 쿼리 파라미터랑 POST - HTML form 보내는 건 똑같기 때문에, HttpServletRequest의 request.getParameter()를 사용하면 두 가지 요청 파라미터를 조회할 수 있다.
지금부터 스프링으로 요청 파라미터 조회하는 방법을 단계적으로 알아볼 것이다. 점진적으로 편해진다.
@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가 제공하는 getParameter()를 쓰는 것이다. 차이를 확인하기 위해.
웹 브라우저에서 http://localhost:8080/request-param-v1?username=hello&age=20 호출하면 된다.
http://localhost:8080/request-param-v1이라고만 하면 오류 페이지 나오는 듯.
ok가 반환된다. void 타입이어도 response.getWriter().write("ok");로 값을 써 버리면 나온다.
이번엔 POST로 해 보겠다. Postman으로 해도 되긴 한다.
resources에 static은 외부에 자동으로 공개가 된다. index.html도 공개가 되었다.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form action="/request-param-v1" method="post">
username: <input type="text" name="username" />
age: <input type="text" name="age" />
<button type="submit">전송</button>
</form>
</body>
</html>
웹 브라우저로 http://localhost:8080/basic/hello-form.html 호출하면

이게 열린다. 여기서 값 전송하면 ok가 나온다. 물론 인텔리제이 콘솔에도 username = 김, age = 123 출력된다.
Jar로 내장 톰캣만 쓸 거면 webapp 경로를 쓸 수 없기 때문에 이 경로, static 하위로 정적 리소스를 다 놓아서 쓰면 된다. 그러면 스프링 부트가 다 서빙해 준다.
다음 시간엔 @RequestParam으로 더 편하게 해 볼 것이다.
HTTP request parameters - @RequestParam
@RequestParam("username") String memberName
변수명(memberName)은 마음대로 하면 된다.
@RequestMapping("/request-param-v2")
public String requestParamV2(
@RequestParam("username") String memberName,
@RequestParam("age") int memberAge) {
log.info("username = {}, age = {}", memberName, memberAge);
return "ok";
}
@Controller에선 String으로 반환하면 ok라는 논리 뷰 이름을 찾는다. 이런 경우 ok라는 문자를 HTTP 메시지에 바로 넣고 싶을 때 클래스 레벨에서 @Controller를 @RestController로 바꿔도 된다.
아니면 그냥 @ResponseBody를 메서드 레벨에 적어도 된다.
@ResponseBody
@RequestMapping("/request-param-v2")
public String requestParamV2(
@RequestParam("username") String memberName,
@RequestParam("age") int memberAge) {
log.info("username = {}, age = {}", memberName, memberAge);
return "ok";
}
이렇게 하면 ok라는 문자를 그대로 HTTP 응답 메시지에 넣어서 바로 반환한다. @RestController랑 같은 효과이다.
웹 브라우저로 http://localhost:8080/request-param-v2?username=hello&age=20 호출하든, HTML form으로 하든 잘 된다.
@RequestParam으로 하면 request.getParameter()로 하는 거랑 같은 효과이다.
여기서 좀 더 개선할 수 있다.
@ResponseBody
@RequestMapping("/request-param-v3")
public String requestParamV3(
@RequestParam String username,
@RequestParam int age) {
log.info("username = {}, age = {}", username, age);
return "ok";
}
이렇게 @RequestParam("username") 이런 걸 생략할 수 있지만 조건이 있다. 변수명이 파라미터 이름이랑 똑같아야 한다. memberName이 아니라 username 이런 식으로.
더 간단하게 하면
@ResponseBody
@RequestMapping("/request-param-v4")
public String requestParamV4(String username, int age) {
log.info("username = {}, age = {}", username, age);
return "ok";
}Hello, Member 같은 객체 말고 String, int, Integer 등의 단순 타입이면 @RequestParam도 생략 가능하다. 반대로 얘기하면 @RequestParam을 생략하고 위에 말한 것처럼 단순 타입이면 @RequestParam이 적용된다.
대신 username과 age가 요청 파라미터 이름과 같아야 한다.
@RequestParam 애노테이션을 생략하면 스프링 MVC는 내부에서 required = false를 적용한다. required 옵션은 바로 다음에 설명한다.
다만, 이렇게 애노테이션을 완전히 생략해도 되는데, 너무 없는 것도 약간 과하다는 의견도 있다. @RequestParam이 있으면 명확하게 요청 파라미터에서 데이터를 읽는다는 것을 알 수 있다.
스프링 부트 3.2부터는 안 된다. -parameters 옵션을 적용하면 되는데 그냥 안 하는 걸 권장하는 듯
@ResponseBody
@RequestMapping("/request-param-required")
public String requestParamRequired(
@RequestParam(value = "username", required = true) String username,
@RequestParam(value = "age", required = false) Integer age) {
log.info("username = {}, age = {}", username, age);
return "ok";
}@RequestParam(required = true) String username 이렇게 적으면 안 되는 듯. 이것도 아마 스프링 부트 3.2부터.
required 속성은 기본값이 true이다. 즉, 아무것도 안 적으면 true이다.
required가 true이면 HTTP URL에서, 예를 들면 http://localhost:8080/request-param-required?username=hello&age=20
여기서 username이 꼭 들어와야 하고, 없으면 오류이다. 그런데 required가 false이면 없어도 된다. 지금 예제의 경우엔 http://localhost:8080/request-param-required?age=20 이건 오류이다.
이건 HTTP에 대한 양방향 스펙을 정의할 때 username은 필수라고 정한 거다. 만약 백엔드 개발자가 이렇게 true라고 적었는데 클라이언트 개발자(웹 개발자이든 안드로이드 개발자이든)가 username을 안 보내면 Bad Request를 보내는 게 맞다. 왜냐하면 이건 우리가 약속한 HTTP 스펙을 준수하지 않은 것이기 때문이다. 스프링은 이런 경우 자동으로 400 Bad Request 내려 준다.
@RequestParam(value = "age", required = false) int age로 하면 username과 age 모두 썼을 땐 괜찮은데, age를 생략할 때 500 에러 생긴다. 왜냐하면 age가 없으면 null이 된다. 그런데 int엔 null을 넣을 수 없다. 그래서
@RequestParam(value = "age", required = false) Integer age 이렇게 Integer로 해야 한다. 또는 defaultValue를 써도 된다. 이러면 값이 없을 때 항상 defaultValue를 넣어 준다.
null이랑 ""는 다르다.
지금 현재 username은 required가 true이다.
http://localhost:8080/request-param-required
이건 400 Bad Request 오류이지만
http://localhost:8080/request-param-required?username=
이렇게 하면 null이 아니라 빈 문자("")이다. 그래서 오류가 아니다. 인텔리제이 콘솔 창을 보면
username = , age = null
이렇게 뜬다.
@ResponseBody
@RequestMapping("/request-param-default")
public String requestParamDefault(
@RequestParam(value = "username", required = true, defaultValue = "guest") String username,
@RequestParam(value = "age", required = false, defaultValue = "-1") int age) {
log.info("username = {}, age = {}", username, age);
return "ok";
}
여기선 Integer 대신 int로 써도 된다. 왜냐하면 값이 안 들어오면 자동으로 defaultValue의 값이 된다.
@RequestParam(value = "age", required = false, defaultValue = "-1") int age
여기서 "-1" 대신 -1로 하면 안 된다.
getParameter()의 결과는 항상 String인 거랑 비슷한 이유인 듯.
defaultValue는 null이 아닌 빈 문자("")의 경우에도 설정한 기본값이 적용된다.
http://localhost:8080/request-param-default?username=
이걸 호출하면 username = , age = -1
이렇게 출력될 거 같지만,
username = guest, age = -1
이렇게 출력된다.
defaultValue가 들어가게 되면 required 쓰는 게 의미가 없어진다.
요청 파라미터를 Map, MultiValueMap으로 조회할 수도 있다.
모든 요청 파라미터 다 받고 싶으면
@RequestParam Map<String, Object> paramMap으로 받으면 된다.
(Object일 필요가 있나?)
-> @RequestParam Map<String, String> paramMap이 더 정확하다.
MultiValueMap은 하나의 키에 여러 가지 값이 들어간 모양이다.
파라미터의 값이 1개가 확실하면 Map을 써도 되지만, 요청 파라미터 값이 여러 개 들어온다면 MultiValueMap을 쓰면 된다.
그런데 파라미터의 값은 보통 1개를 쓴다. 애매하게 2개 쓰는 경우는 많지 않다.
@ResponseBody
@RequestMapping("/request-param-map")
public String requestParamMap(@RequestParam Map<String, String> paramMap) {
log.info("username = {}, age = {}", paramMap.get("username"), paramMap.get("age"));
return "ok";
}
paramMap.get("age")은 String이다.
HTTP request parameters - @ModelAttribute
실제 개발을 하면 요청 파라미터를 받아서 필요한 객체를 만들고 그 객체에 값을 넣어 주어야 한다. 보통 다음과 같이 코드를 작성한다.
@RequestParam("username") String username;
@RequestParam("age") int age;
이렇게 username과 age를 받고,
HelloData data = new HelloData();
data.setUsername(username);
data.setAge(age);
이렇게 setter나 생성자를 통해 값을 넣어 준다.
스프링은 이 과정을 완전히 자동화해 주는 @ModelAttribute 기능을 제공한다.
HelloData 객체는 공용으로 쓸 것이므로 basic 밑에 만들겠다.
이번엔 롬복(Lombok)의 @Data를 쓰겠다. 이걸 쓰면 @Getter, @Setter, @ToString, @EqualsAndHashCode, @RequiredArgsConstructor를 자동으로 적용해 준다.
@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@RequestParam("username") String username, @RequestParam("age") int age) {
HelloData helloData = new HelloData();
helloData.setUsername(username);
helloData.setAge(age);
log.info("username = {}, age = {}", helloData.getUsername(), helloData.getAge());
log.info("helloData = {}", helloData);
return "ok";
}
log.info("helloData = {}", helloData);
이건
helloData = HelloData(username=as, age=12)가 출력된다. 왜냐하면 HelloData 클래스에 @Data를 했었다. @Data를 하면 @ToString이 자동으로 적용된다.
@ToString은 객체를 직접 찍었을 때 보기 좋게 보여 준다.
System.out.println()의 인자로 객체를 직접 넘기면 객체의 toString()이 호출된다.
HelloData 클래스에 @Data 대신 @Setter, @Getter, @ToString 적용해도 helloData = HelloData(username=as, age=12) 출력된다.
@Setter, @Getter만 적용하면
helloData = hello.springmvc.basic.HelloData@393bfa02가 출력된다.
위의 코드를 다음처럼 바꿀 수 있다.
@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@ModelAttribute HelloData helloData) {
log.info("username = {}, age = {}", helloData.getUsername(), helloData.getAge());
log.info("helloData = {}", helloData);
return "ok";
}
스프링MVC는 @ModelAttribute가 있으면 다음을 실행한다.
HelloData 객체를 생성한다.
요청 파라미터의 이름으로 HelloData 객체의 프로퍼티를 찾는다. 여기선 값을 수정해야 하니 setter를 찾는다. 그리고 해당 프로퍼티의 setter를 호출해서 파라미터의 값을 입력(바인딩)한다.
예) 파라미터 이름이 username이면 setUsername() 메서드를 찾아서 호출하면서 값을 입력한다.(helloData.setUsername(username))
개발할 때 정규 프로세스는 20~30%이고 나머지는 예외 처리에 힘써야 한다.
@ModelAttribute는 생략할 수 있다. 그런데 @RequestParam도 생략할 수 있으니 혼란이 발생할 수 있다.
스프링은 해당 생략 시 다음과 같은 규칙을 적용한다.
String, int, Integer 같은 단순 타입 = @RequestParam
나머지 = @ModelAttribute(argument resolver로 지정해 둔 타입 외)
나중에 배우겠지만 argument resolver로 지정해 둔 타입은 @ModelAttribute가 적용되지 않는다.
argument resolver로 지정해 둔 타입이라는 건 예를 들면 HttpServletResponse이다. 이런 거 빼고 나머지 내가 직접 만드는 객체들은 다 자동으로 @ModelAttribute로 처리된다. 물론 내가 만든 것도 argument resolver로 세팅하면 적용이 안 된다.
내 블로그에 적은 내용)
1. a simple value type이면서 argument resolver에 의해 처리되지 않은 인수들은 @RequestParam을 적지 않아도 마치 그게 있는 것처럼 여겨지고
2. a simple value type이 아니면서 argument resolver에 의해 처리되지 않은 파라미터들은 @ModelAttribute가 안 적혀 있더라도 있는 것처럼 여겨진다.
결론적으로, a simple value type이든 아니든, @RequestParam 혹은 @ModelAttribute가 적용되려면 argument resolver로 지정되지 않아야 한다. argument resolver로 지정한 타입을 매개 변수에 적을 시엔 @ModelAttribute와 @RequestParam 둘 다 적용되지 않는다는 것 같다.
(argument resolver에 대해 아직 제대로 배우지 않은 상태에서 쓴 글이라 이 글은 나중에 수정될 수 있다.)
@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@ModelAttribute HelloData helloData) {
log.info("username = {}, age = {}", helloData.getUsername(), helloData.getAge());
// log.info("helloData = {}", helloData);
return "ok";
}
http://localhost:8080/model-attribute-v1 이렇게 파라미터를 넘기지 않아도 오류가 안 난다.
인텔리제이 콘솔에는
username = null, age = 0
이렇게 뜬다.
@ModelAttribute 안에 name도 적을 수 있다.
public String modelAttributeV1(@ModelAttribute(name = "sdfs") HelloData helloData)
요청이 올 땐 name이랑 상관없이 무조건 HelloData 객체의 username이나 age 이것들만 파라미터로 맞으면 된다.
안에 name 넣는 건 나중에 MVC 패턴에서 뷰에 Model 넘길 때 쓰는 건데, 그건 다음 섹션에서 설명한다. 지금 설명해도 잘 이해 안 되고 MVC에서 뷰가 있어야 name 지정하는 게 의미가 있다.
지금까지 요청 파라미터까지 알아봤다.

여기서 GET - 쿼리 파라미터와 POST - HTML Form을 알아본 거다.
이제 다음 시간부터 HTTP message body에 직접 데이터가 넘어오는 거에 대해 알아본다.
HTTP Request Message - Simple Text
HTTP message body에 데이터를 직접 담아서 요청하는 경우, JSON, XML, TEXT뿐 아니라 다른 형식도 다 가능하다. 다만 주로 사용하는 데이터 형식은 JSON이다.
요청 파라미터와 다르게, HTTP 메시지 바디를 통해 데이터가 직접 넘어오는 경우 즉, GET 방식의 URL 쿼리 파라미터 혹은 POST로 HTML form 방식의 전송을 제외한 나머지의 경우엔 @RequestParam이나 @ModelAttribute를 쓸 수 없다.
물론 HTML form 형식으로 전달되는 경우엔 HTTP 바디에 데이터가 오는데, Content-Type: application/x-www-form-urlencoded로 오면서 form 형식처럼 데이터가 있는 경우엔 요청 파라미터로 인정된다. 다만 그 경우를 제외하고 데이터가 전달되는 경우엔 HTTP 메시지 바디를 직접 조회해야 한다.
@Slf4j
@Controller
public class RequestBodyStringController {
@PostMapping("/request-body-string-v1")
public void requestBodyString(HttpServletRequest request, HttpServletResponse response) {
...
}
}데이터가 넘어와야 하니 GET을 쓰면 안 되고 POST를 쓰겠다.
HTTP 강의에서 설명했듯이, 최근 스펙에선 GET에도 바디에 데이터를 넣을 수는 있다. 그런데 실무에서 그렇게 잘 안 쓴다.
스트림은 byte 코드이다. byte 코드를 문자로 받을 때는 어떤 인코딩으로 해서 문자로 바꿀지를 항상 지정해 줘야 한다. 지정 안 하면 디폴트를 쓴다. OS에 기본 설정된 거라든가, 자바 실행할 때 기본으로 뜬 거라든가.
@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");
}
이 코드를 V2로 바꿔 보겠다. InputStream이나 Reader를 직접 받을 수 있다. response도 OutputStream이나 Writer로 바꾸겠다.
지금은 서블릿에 대한 코드가 굳이 필요 없다. HttpServletRequest가 통으로 필요하진 않다.
@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-v2")
public void requestBodyStringV2(InputStream inputStream, OutputStream outputStream) throws IOException {
String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
log.info("messageBody = {}", messageBody);
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(outputStream));
bw.write("ok");
}
이렇게 바꾸고 Postman으로 호출해 보면 인텔리제이 콘솔에는 잘 출력되지만, ok 리턴을 못 받는다. 이유는 모르겠다.
+) write 아래에 bw.flush(); 추가하면 ok 받는다.
V2도 스트림으로 받고, String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); 이런 코드도 써야 하는데 이런 것도 스프링이 알아서 하도록 할 수 있다.
그게 바로 HttpMessageConverter라는 기능이다. 그거에 대한 자세한 내용은 뒤에서 따로 설명할 것이다. 지금은 그냥 스프링이 이런 걸 제공한다고 이해하면 된다.
public void requestBodyStringV3(HttpEntity<String> httpEntity) throws IOException {
.
.
.
}
이렇게 하면 스프링이 HTTP 바디에 있는 걸 문자로 바꿔서 너에게 넣어 줄게, String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); 이런 코드를 대신 실행해 줄게라고, HttpMessageConverter라는 게 동작한다.
String messageBody = httpEntity.getBody();
HTTP 메시지에 있는 바디를 꺼낸다.
반환하는 것도 비슷하다.
@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) throws IOException {
String messageBody = httpEntity.getBody();
log.info("messageBody = {}", messageBody);
return new HttpEntity<>("ok");
}
return new HttpEntity<>("ok");
이 안에 첫 번째 파라미터에 바디 메시지를 넣을 수 있다.
마치 HTTP 메시지 자체를 그대로 주고받는 형식으로 만들 수 있다.
스프링 MVC는 HttpEntity를 지원한다. HttpEntity는 HTTP header, body 정보를 편리하게 조회할 수 있는 객체이다.
HttpHeaders headers = httpEntity.getHeaders();
이렇게 헤더에 대한 정보들도 얻을 수 있다.
요청 파라미터를 조회하는 기능과는 아무 상관없다.
요청 파라미터는 GET에 쿼리 스트링(쿼리 파라미터)이 오는 거 또는 POST 방식으로 HTML form 데이터 전송하는 방식인 경우이고, 이 경우에만 @RequestParam이나 @ModelAttribute를 사용하는 거고, 그 외엔 다 HttpEntity를 사용하거나... 이런 식으로 데이터를 직접 꺼내야 한다.
HttpEntity는 응답에도 사용 가능하다. 메시지 바디 정보를 직접 반환한다. 헤더 정보도 포함할 수 있다.
이걸 쓰게 되면 당연히 뷰는 아예 조회 안 한다. 그냥 바디의 데이터를 가지고 그냥 HTTP 응답 메시지에 바로 넣는다.
HttpEntity를 상속받은 RequestEntity, ResponseEntity는 기능이 더 있다.
ResponseEntity엔 HTTP 상태 코드를 넣을 수 있다.
@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(RequestEntity<String> httpEntity) throws IOException {
String messageBody = httpEntity.getBody();
log.info("messageBody = {}", messageBody);
return new ResponseEntity<>("ok", HttpStatus.CREATED);
}
RequestEntity도 몇 가지 기능을 더 제공한다.
스프링 MVC 내부에서 HTTP 메시지 바디를 읽어서 문자나 객체로 변환해서 전달해 주는데, 이때 HTTP 메시지 컨버터(HttpMessageConverter)라는 기능을 사용한다. 이것은 조금 뒤에 HTTP 메시지 컨버터에서 자세히 설명한다.
HttpEntity 쓰는 것도 귀찮다. 그래서 애노테이션이 제공된다.
@PostMapping("/request-body-string-v4")
public HttpEntity<String> requestBodyStringV4(@RequestBody String messageBody) {
log.info("messageBody = {}", messageBody);
return new HttpEntity<>("ok");
}
@RequestBody String messageBody 이렇게 하면 HTTP 메시지 바디 읽어서 여기에다 그냥 넣어 준다.
응답도 @ResponseBody로 하면 된다.
@ResponseBody
@PostMapping("/request-body-string-v4")
public String requestBodyStringV4(@RequestBody String messageBody) {
log.info("messageBody = {}", messageBody);
return "ok";
}
@ResponseBody 애노테이션이 있으면 "ok"를 HTTP 응답에 넣어서 반환한다.
요청이 오는 건 @RequestBody, 응답이 나가는 건 @ResponseBody로 짝이 맞는다.
@RequestBody를 실무에서 많이 쓴다.
아까 HttpEntity는 헤더 정보를 조회할 수 있었다. 헤더 정보가 필요하면 HttpEntity를 쓰거나 @RequestHeader 쓰면 된다. @RequestBody 옆에 @RequestHeader 써서 원하는 값 받으면 된다.
이렇게 메시지 바디를 직접 조회하는 기능은 요청 파라미터를 조회하는 @RequestParam, @ModelAttribute와는 전혀 관계가 없다. 그냥 메시지 바디에 있는 걸 바로 가져온다.
그리고 중간에 HttpMessageConverter(이름 그대로 HTTP 메시지를 바꿔 준다.)라는 전혀 다른 메커니즘이 동작한다.
HTTP 메시지 그대로 읽어서 조작해서 처리해 준다.
우리가 수동으로 작성했던 String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); 이런 걸 스프링이 자동으로 해 주는데, 그 자동으로 해 주는 게 HttpMessageConverter이다.
@ResponseBody를 사용하면 응답 결과를 HTTP 메시지 바디에 직접 담아서 전달할 수 있다. 물론 이 경우에도 뷰를 사용하지 않는다.
HTTP Request Message - JSON
@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 helloData = objectMapper.readValue(messageBody, HelloData.class);
log.info("username = {}, age = {}", helloData.getUsername(), helloData.getAge());
response.getWriter().write("ok");
}
이건 기존 서블릿에서 사용했던 방식이다.
더 편리해진 방식이 아래이다.
@ResponseBody
@PostMapping("/request-body-json-v2")
public String requestBodyJsonV2(@RequestBody String messageBody) throws IOException {
log.info("messageBody = {}", messageBody);
HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
log.info("username = {}, age = {}", helloData.getUsername(), helloData.getAge());
return "ok";
}
그런데 objectMapper로 바꾸는 게 불편하다.
@ResponseBody
@PostMapping("/request-body-json-v3")
public String requestBodyJsonV3(@RequestBody HelloData helloData) {
log.info("username = {}, age = {}", helloData.getUsername(), helloData.getAge());
return "ok";
}
throws IOException도 지웠다.
@RequestBody에 단순한 String 말고 객체도 파라미터로 넘길 수 있다. 즉, @RequestBody에 직접 만든 객체를 지정할 수 있다.
HttpEntity<HelloData> 이렇게 써도 된다. 그러면 getBody() 하면 저 객체 타입이 반환된다.
HttpEntity나 @RequestBody를 사용하면 HttpMessageConverter가 HTTP 메시지 바디의 내용을 우리가 원하는 문자나 객체 등으로 변환해 준다.
지금 HTTP 바디에 {"username":"hello", "age":20}가 넘어오는 상황이다.(content-type: application/json)
그러면 HttpMessageConverter가 Content-Type을 보고 json인 걸 확인하고 {"username":"hello", "age":20}를 읽어서 우리 객체에 맞는 그걸로 반환해 주는데, 그때...
복잡하게 설명하자면 MappingJackson2HttpMessageConverter라는 게 동작한다. 그걸 열어 보면
HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
이걸 대신 해 준다.
뒤에서 배우겠지만 HttpMessageConverter가 json이면 HelloData helloData = objectMapper.readValue(messageBody, HelloData.class); 이 코드를 우리 대신 실행해 준다. 그렇게 해서 생성된 HelloData를 매개 변수에 있는 helloData에 대신 넣어 준다.
문자를 변환하는 HttpMessageConverter도 있고,
json을 변환하는 HttpMessageConverter도 따로 있다.
HttpMessageConverter는 문자뿐만 아니라 JSON도 객체로 변환해 주는데, 우리가 방금 V2에서 했던 작업을 대신 처리해 준다.
@RequestBody는 생략하면 안 된다. 생략하고 실행해 보면 ok는 반환되지만 인텔리제이 콘솔 창에
username = null, age = 0
이렇게 나온다. 즉, 값이 안 들어가 있다.
@RequestBody를 생략하면 @ModelAttribute가 적용되어 버린다. HelloData data -> @ModelAttribute HelloData data
요청 파라미터를 꺼내려고 하는데 없으니깐 그냥 저렇게 두는 거다. 따라서 생략하면 HTTP 메시지 바디가 아니라 요청 파라미터를 처리하게 된다.
참고로 @RequestParam에서랑 @ModelAttribute일 때랑 primitive 타입 처리하는 게 약간 다르다. @ModelAttribute는 좀 더 관대하게 기본값 0을 넣어 준다.(사실 아무것도 안 넣어 줬다. setter를 호출해야 하는데 없으니깐 호출을 안 한 거다.)
@Data
public class HelloData {
private String username;
private int age;
}
그냥 처음부터 null이랑 0이 들어가 있다. 즉, 객체만 생성하고 값은 세팅하지 않았다.
HTTP 요청 시에 content-type이 application/json인지 꼭! 확인해야 한다. 그래야 JSON을 처리할 수 있는 HttpMessageConverter가 실행된다. 뒤에서 다시 설명한다.
앞에서 배운 것처럼 HttpEntity를 써도 된다.
@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";
}
HttpEntity에서 바디를 꺼내면 제네릭으로 선언된 HelloData 타입이 반환된다.
V3를 다시 보면 json이 그대로 helloData에 들어온다.
반환은 String으로 했었다. 그런데 반환도 String이 아닌 helloData를 해 보겠다.
@ResponseBody
@PostMapping("/request-body-json-v5")
public HelloData requestBodyJsonV5(@RequestBody HelloData helloData) {
log.info("username = {}, age = {}", helloData.getUsername(), helloData.getAge());
return helloData;
}
HttpMessageConverter는 들어올 때도 적용되지만, @ResponseBody가 있으면 나갈 때도 적용된다.
예전에 문자도 @ResponseBody로 적용되었다고 했었다.
문자가 HTTP 응답 메시지 바디에 바로 들어간다.
지금처럼 하면 HelloData라는 객체가 HttpMessageConverter에 의해 json으로 바뀐다. json으로 바뀐 json 문자가 HTTP 메시지 응답에 들어가서 고객 응답으로 나간다.
http://localhost:8080/request-body-json-v5를 Postman으로 호출해 보면

json이 객체가 되었다가, 객체가 나갈 때 다시 json이 되어서 우리 눈에 응답으로 보인 거다.
응답의 경우에도 @ResponseBody를 사용하면 해당 객체를 HTTP 메시지 바디에 직접 넣어줄 수 있다. 물론 이 경우에도 HttpEntity를 사용해도 된다.
@RequestBody는 json으로 요청한 게 HttpMessageConverter(그중에서도 json을 처리하는)가 실행돼서 객체로 바꾸고, 그게 매개 변수의 helloData로 넘어온다.
반대로 응답할 땐 helloData 객체가 HttpMessageConverter(그중에서도 json을 처리하는)가 동작하면서 json 응답으로 나간다.
public HelloData requestBodyJsonV5(@RequestBody HelloData helloData) { ... }
이때 HelloData가 json으로 나가야 한다는 건 HTTP 요청 메시지 보낼 때 Accept: application/json도 확인해 봐야 한다. 그것이 어떤 HttpMessageConverter를 선택할지 영향을 준다. 뒤에서 또 설명할 것이다.
Response - Static Resources, View Template
스프링(서버)에서 응답 데이터를 만드는 방법은 크게 3가지이다. 요청도 3가지, 응답도 3가지이다.
정적 리소스: 파일을 그대로 전달하는 경우이다.
뷰 템플릿 사용: 서버가 웹 브라우저에 동적인 HTML을 제공할 때는 뷰 템플릿을 사용한다. 사용자마다 이름이 다르게 보인다든지, 그런 동적인 HTML을 제공하는 서버 사이드 렌더링 하는 걸 뷰 템플릿을 사용한다고 한다.
HTTP 메시지 사용: HTTP API를 사용하는 경우에는 HTML을 전달하는 게 아니라 HTTP 메시지 바디에 JSON 같은 형식으로 데이터를 실어 보낸다.
먼저 정적 리소스에 대해 알아보겠다. 스프링 부트는 보통 웹 프로젝트인 webapp 이런 경로를 제공하지 않는다. 그래서 클래스패스의 다음 디렉터리에 있는 정적 리소스를 제공한다.
/static, /public, /resources, /META-INF/resources
main 아래의 java는 자바가 컴파일되는 부분이고, 사실 java랑 resources 둘 다 빌드되면 같은 데 올라간다.
resources에 있는 static에 넣어 둔 자원은 다 정적으로 스프링 부트가 내장 톰캣 통해서 자동으로 서빙해 준다.
resources 하위에 static 말고 public을 따로 만들어도 된다.
src/main/resources 하위(?)에 /static, /public, /resources, /META-INF/resources들을 넣어 주면 거기서부터 해서 스프링 부트가 정적 리소스로 제공한다.
웹 브라우저에서 다음과 같이 실행하면 정적 리소스가 열린다. http://localhost:8080/basic/hello-form.html
basic에 이미지 등 아무거나 둬도 다 읽어서 동작한다.
뷰 템플릿은 일반적으로 HTML을 동적으로 생성하는 용도로 사용하지만, HTML뿐만 아니라 다른 것들도 가능하다. 뷰 템플릿이 렌더링 할 수 있는 것이라면 다양한 모양들을 생성할 수 있다.
스프링 부트는 기본으로 뷰 템플릿 경로를 src/main/resources/templates 여기로 제공한다.
타임리프는
<html xmlns:th="http://www.thymeleaf.org">
이걸 넣어 줘야 편하다. 기본으로 넣는다고 생각하면 된다.
<p th:text="${data}">empty</p>
Model에서 data라고 있는 key의 값을 꺼내서, 이 값을.. th:text라고 하면 empty 있는 부분을 data에 온(?) 변수를 치환해서 넣어 준다.(렌더링 되면)
이걸 쓰는 컨트롤러를 만들어 보겠다.
@RequestMapping("/response-view-v1")
public ModelAndView responseViewV1() {
ModelAndView mav = new ModelAndView("response/hello")
.addObject("data", "hello!");
return mav;
}이게 ResponseViewController에 있는 코드이고
이게 hello.html인데
http://localhost:8080/response-view-v1 실행한 후 페이지 소스 보기를 누르면

p 태그가 hello!로 바뀌어 있다. 렌더링 될 때 th:text="${data}" 이 부분이 empty 이 부분을 hello!로 바꾼다.
text라고 하면 empty 부분을 말한다.
지금은 타임리프가 핵심 설명이 아니다.
이번엔 String으로 반환해 보겠다. 그러면 Model이 필요하다.
@RequestMapping("/response-view-v2")
public String responseViewV2(Model model) {
model.addAttribute("data", "hello!");
return "response/hello";
}
@Controller이면서 String을 반환하면 뷰의 논리적인 이름이 된다.
http://localhost:8080/response-view-v2
호출하면 결과도 똑같고, 페이지 소스 보기를 해도 잘 렌더링이 된 걸 확인할 수 있다.
만약 위 코드에 @ResponseBody(혹은 클래스 레벨에 @RestController)를 적용했다면
http://localhost:8080/response-view-v2를 호출할 때
뷰로 안 가고 response/hello 문자가 웹 페이지에 그냥 보인다.
페이지 소스 보기 눌러도 response/hello라고만 보인다.
한 가지 방법이 더 있는데 별로 권장되지 않는 방법이다.
// 권장되지 않는 방법
@RequestMapping("/response/hello")
public void responseViewV3(Model model) {
model.addAttribute("data", "hello!");
}
컨트롤러의 경로 이름과 뷰의 논리적인 이름이 똑같으면.. 아무것도 반환을 안 하면
http://localhost:8080/response/hello
요청 온 /response/hello에서 앞의 /는 아마 빼 주고, 그게 논리적인 뷰의 이름으로 된다.
페이지 소스 보기를 하면
정상적으로 뷰 불러졌다.
@ResponseBody가 없으면 response/hello로 뷰 리졸버가 실행되어서 뷰를 찾고, 렌더링 한다.
@ResponseBody가 있으면 뷰 리졸버를 실행하지 않고, HTTP 메시지 바디에 직접 response/hello라는 문자가 입력된다. HttpMessageConverter가 동작한다고 얘기했었다.
void를 반환하는 경우엔 조건이 있다.
@Controller를 사용하고, HttpServletResponse나 OutputStream(Writer) 같은 HTTP 메시지 바디를 처리하는 파라미터가 없으면 요청 URL을 참고해서 논리 뷰 이름으로 사용한다.
요청 URL: /response/hello
실행: templates/response/hello.html
참고로 이 방식은 명시성이 너무 떨어지고 이렇게 딱 맞는 경우도 많이 없어서, 권장하지 않는다.
V2 정도가 적당하다.
Thymeleaf 스프링 부트 설정
다음 라이브러리를 build.gradle에 추가하면(이미 추가되어 있다.)
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
스프링 부트가 자동으로 ThymeleafViewResolver와 필요한 스프링 빈들을 등록한다. 그리고 다음 설정도 사용한다. 이 설정은 기본값이기 때문에 변경이 필요할 때만 설정하면 된다.
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
이걸 바꾸고 싶으면 application.properties에다가 이 두 줄을 넣고 직접 바꾸면 된다. 웬만하면 건들 일이 없다.
HTTP Response - HTTP API, Direct Input to Message Body
정적 리소스든 뷰 템플릿을 쓰든 결국 HTTP 응답 메시지 바디에 HTML 데이터가 담겨서 나간다. 그런데 여기서 말하는 내용은 정적 리소스나 뷰 템플릿 이런 얘기가 아니라, HTML 데이터를 보내는 게 아니고 직접 HTTP 응답 메시지에 데이터를 담아서 전달하는 경우를 말한다.
@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);
}
이건 HTTP 상태 코드를 바꿀 수 있는데
@ResponseBody
@GetMapping("/response-body-json-v2")
public HelloData responseBodyJsonV2() {
HelloData helloData = new HelloData();
helloData.setUsername("userA");
helloData.setAge(20);
return helloData;
}
이건 못 바꾼다. 그래서 애노테이션이 제공된다.
@ResponseStatus(HttpStatus.OK)
@ResponseBody
@GetMapping("/response-body-json-v2")
public HelloData responseBodyJsonV2() {
HelloData helloData = new HelloData();
helloData.setUsername("userA");
helloData.setAge(20);
return helloData;
}
ResponseEntity인 경우엔 상태 코드를 지정할 수 있는데, @ResponseBody만 있으면 지정을 못 하므로 이렇게 애노테이션을 쓰면 원하는 걸로 나갈 수 있다.
이전에 공부했던 내용들을 그냥 정리해 본 것이다.
responseBodyV3
@ResponseBody를 사용하면 view를 사용하지 않고, HttpMessageConverter를 통해서 HTTP 메시지를 직접 입력할 수 있다. 물론 HttpEntity나 ResponseEntity도 마찬가지이다.
responseBodyJsonV1
ResponseEntity를 반환한다. HTTP 메시지 컨버터를 통해서 JSON 형식으로 변환되어서 반환된다. 물론 HttpEntity 써도 된다.
물론 애노테이션이기 때문에 응답 코드를 동적으로 변경할 수는 없다. @ResponseStatus(HttpStatus.OK) 이렇게 지정해 버리기 때문.
프로그램 조건에 따라서 동적으로 변경하려면 ResponseEntity를 사용하면 된다.
ResponseEntity<>(helloData, HttpStatus.OK)
이건 어떤 경우엔 OK, 어떤 경우엔 CREATED 할 수 있다.
지금까지는 계속 @ResponseBody를 적었는데, 계속 적기 귀찮다. 이런 경우엔 @ResponseBody를 클래스 레벨에 적을 수 있다. 이렇게 하면 전체가 다 @ResponseBody가 붙게 된다.
@GetMapping("/response-body-string-v1")
public void responseBodyV1(HttpServletResponse response) throws IOException {
response.getWriter().write("ok");
}
이런 거야 어차피 response.getWriter().write()를 개인적으로 한 거니 상관없고
@GetMapping("/response-body-string-v2")
public ResponseEntity<String> responseBodyV2() {
return new ResponseEntity<>("ok", HttpStatus.OK);
}
이건 어차피 ResponseEntity이기 때문에 상관없다.
// @ResponseBody
@GetMapping("/response-body-string-v3")
public String responseBodyV3() {
return "ok";
}
이런 것들이 걸린다.
@Controller와 @ResponseBody를 합치면 편할 것이다.
그것이 @RestController이다.
API 만들 땐 거의
@ResponseStatus(HttpStatus.OK)
//@ResponseBody
@GetMapping("/response-body-json-v2")
public HelloData responseBodyJsonV2() {
HelloData helloData = new HelloData();
helloData.setUsername("userA");
helloData.setAge(20);
return helloData;
}
이런 스타일을 많이 쓴다. 그래서 @RestController 넣으면 @ResponseBody가 다 자동으로 적용되기 때문에 반환 타입으로 HelloData 그냥 넣으면 된다.
이 방식을 많이 쓴다.
HTTP Message Converter

스프링 입문 강의에서 설명한 내용을 다시 보자면, 웹 브라우저에서 localhost:8080/hello-api 요청이 오면 helloController가 호출되고, @ResponseBody가 있으면 HttpMessageConverter라는 게 동작하는데 HttpMessageConverter 중에 return으로 나가는 hello가 String으로 나가야 할지, json으로 나가야 할지 선택이 되어서 그거에 의해 나가게 된다.
이 경우엔 객체가 나가고 JsonConverter가 동작했다.
String을 반환하면 StringHttpMessageConverter가 동작하고,
객체를 반환하면 더 디테일하게는 있지만, 기본적으로 MappingJackson2HttpMessageConverter, 그니깐 객체가 json으로 바뀌어서(이전에 ObjectMapper 했던 것을 기억해 보자) 응답 메시지에 넣어져서 나간다.
byte를 그대로 반환할 수도 있다.
굉장히 많은 HttpMessageConverter가 기본으로 등록되어 있다.
참고: 응답의 경우 클라이언트가 보낸 HTTP Accept 헤더와 서버의 컨트롤러 반환 타입 정보 둘을 조합해서(사실 여기에 몇 개 더 들어간다.) HttpMessageConverter가 선택된다.
스프링 MVC는 다음의 경우에 HTTP 메시지 컨버터를 적용한다.
HTTP 요청의 경우엔 @RequestBody, HttpEntity(RequestEntity)
위 그림에서 helloController가 호출되기 전에 HttpMessageConverter가 적용돼서, @RequestBody라는 게 있으면 HTTP 메시지 바디의 데이터를 꺼내서 뭔가 변환한 다음에 넘겨준다.
HTTP 응답의 경우엔 @ResponseBody, HttpEntity(ResponseEntity)
HttpMessageConverter는 인터페이스다. 왜냐하면 json을 처리해 주는 컨버터, String을 처리해 주는 컨버터 등 여러 가지가 있다. 결국 부모 인터페이스가 있는데 그게 HttpMessageConverter이다.
HTTP 메시지 컨버터는 HTTP 요청, HTTP 응답 둘 다 사용된다.
HTTP 요청에 있는 메시지 바디를 읽어서 객체로 바꿔서 컨트롤러의 파라미터로 넘겨주는 역할도 하고,
@ResponseBody로 되어 있으면 컨트롤러에서 리턴 값을 가지고 HTTP 응답 메시지에도 넣는 두 가지 역할을 다 한다. 즉 컨버터가 양방향이다.
canRead()와 canWrite()는 이 HttpMessageConverter가 해당 클래스와 미디어 타입을 지원하는지 체크한다.
read()와 write()는 canRead()를 통과하면 실제 메시지를 읽는 것, write()는 실제 메시지를 쓰는 기능이다.(메시지 컨버터를 통해서)
스프링 부트는 기본적으로 메시지 컨버터를 스프링 부트가 올라올 때 몇 개를 등록한다. 기본적으로 아래가 등록된다.
(일부 생략)
0 = ByteArrayHttpMessageConverter 바이트로 바꿔 주는 것인 듯
1 = StringHttpMessageConverter String으로 바꿔 주는 것
2 = MappingJackson2HttpMessageConverter 객체를 json으로 바꾸는 것.. 또는 HTTP 요청 메시지 바디에 json이 온 것을 객체로 바꾸는 것.. 양방향이다.
스프링 부트는 다양한 메시지 컨버터를 제공하는데, 대상 클래스 타입과 미디어 타입(여기서 미디어 타입이라는 것은 HTTP 요청의 경우엔 Content-Type) 둘을 체크해서 사용 여부를 결정한다.
만약 만족하지 않으면 즉, canRead(), canWrite()를 해서 만족하지 않으면 다음 메시지 컨버터로 우선순위가 넘어간다.
ByteArrayHttpMessageConverter: byte[] 데이터를 처리한다. 컨트롤러에서 @RequestBody라고 하고 바이트 배열 선언해서 받으면 HTTP 메시지 바디에 있는 바이트를 그대로 받을 수 있다. 이 컨버터가 동작해서.
이건 아무 미디어 타입이나 다 받아들일 수 있다.
사실 기본이 바이트 배열로 오기 때문에 그냥 그대로 넣는 것일 것이다.
응답 같은 경우엔 @ResponseBody가 있고 바이트 배열 데이터를 리턴하면 ByteArrayHttpMessageConverter가 동작해서 HTTP 응답 메시지 바디에 바이트를 그대로 넣는다.
참고로 이렇게 그냥 반환하면 쓸 때는 미디어 타입이 application/octet-stream으로 반환된다. 즉, HTTP 응답 헤더에 application/octet-stream 이게 자동으로 들어간다.
StringHttpMessageConverter: String 문자로 데이터를 처리한다.
클래스 타입이 String일 때 처리되는데, 예를 들어 요청에서 @RequestBody String data라고 하면, 바이트로 오는 걸 String으로 컨버팅해서 우리 쪽에 넣어 준다.
미디어 타입은 다 된다. 미디어 타입이 뭘로 오든 String 타입으로 하면 다 들어온다.
@ResponseBody에서 return "ok"를 반환하게 되면 이때도 StringHttpMessageConverter가 동작해서 응답 메시지를 쓰는데 이때는 미디어 타입이 text/plain으로 자동으로 나간다.
요청 메시지든 응답 메시지든 HTTP 메시지 바디에 데이터가 있으면 항상 Content-Type을 지정해 줘야 한다.(Postman으로 테스트해 보니 Content-Type을 빼고 보내면 오류인 경우도, 오류는 아닌 경우도 있다.)
MappingJackson2HttpMessageConverter는 application/json을 주로 처리한다. 클래스 타입은 객체 또는 HashMap일 때 처리된다. 미디어 타입이 'application/json 관련'일 때 들어온다. 거의 application/json 쓰는데 이거 말고도 몇 가지 더 있어서 관련이라고 적었다.
@RequestBody HelloData data 이렇게 되어 있고 미디어 타입이 application/json으로 오면, 즉 요청 메시지의 경우 Content-Type이 application/json이면 바이트도 아니고 String도 아니다. 그러면 MappingJackson2HttpMessageConverter가 적용된다. (물론 Content-Type이 application/json 관련이어야 한다.)
응답의 경우 @ResponseBody에서 바이트나 String이 아닌 객체를 반환하면 자동으로 MappingJackson2HttpMessageConverter가 동작하고 그때 쓰기 관련 미디어 타입은 application/json 관련이다.
이때는 Accept가 기본적으로, HTTP 메시지 요청을 보낼 땐 Content-Type인데 응답할 땐 Accept가 미디어 타입에 영향을 준다.
HTTP 요청 데이터 읽기)
HTTP 요청이 오고, 컨트롤러에서 @RequestBody나 HttpEntity 파라미터를 사용하면, 메시지 컨버터가 작동한다. 그러면서 메시지 컨버터를 아래 순서대로 돈다.
0 = ByteArrayHttpMessageConverter
1 = StringHttpMessageConverter
2 = MappingJackson2HttpMessageConverter
0 돌려 보고, 1 돌려 보고, 2 돌려 본다.
canRead()를 호출한다. 만약 바이트 배열이면 OK이고 아니면 다음으로 넘어간다. canRead()를 호출해서 대상 클래스 타입을 지원하는지 물어본다. byte[], String, HelloData에 따라 다르다. 그다음에 HTTP 요청의 Content-Type 미디어 타입을 지원하는가도 체크한다. 예를 들면 text/plain, application/json, 별/별 등이 있다.
canRead() 조건을 만족하면 read()를 호출해서 객체를 생성하고 컨트롤러의 파라미터로 넘겨준다.
HTTP 응답 데이터 생성)
컨트롤러에서 @ResponseBody이거나 HttpEntity로 값이 반환되면, 메시지 컨버터가 메시지를 쓸 수 있는지(write할 수 있는지) 확인하기 위해 canWrite()를 호출한다.
대상 클래스 타입을 지원하는가, 먼저 바이트 배열이면(미디어 타입도 같이 본다.) ByteArrayHttpMessageConverter로 들어간다. byte[], String, HelloData 이 세 가지를 하나씩 본다. 그다음에 HTTP 요청에 Accept 미디어 타입을 지원하는가도 같이 본다.
요청일 땐 Content-Type 미디어 타입을 요청하는가고, 응답이 나갈 땐 HTTP 요청 메시지에 있는 Accept 미디어 타입을 지원하는가도 추가적으로 체크한다. 사실 더 정확히는 @RequestMapping의 produces를 세팅하지 않으면 그냥 Accept 미디어 타입을 쓸 것이고, 세팅하면 produces를 쓰게 된다.
canWrite() 조건을 만족하면 write()를 호출해서 HTTP 응답 메시지 바디에 데이터를 생성한다.
예를 들어
@RequestMapping
void hello(@RequestBody String data) {}
이렇게 있고
Content-Type: application/json 이렇게 온다고 하자.
일단 ByteArrayHttpMessageConverter가 먼저 canRead()를 호출하는데 ByteArrayHttpMessageConverter는 바이트 배열인 경우에 지원한다. 그런데
@RequestMapping
void hello(@RequestBody String data) {}
이건 String이므로 패스한다. 그다음으로 StringHttpMessageConverter인데 이건 String 타입이면 OK이다. 이거 체크한 다음에 미디어 타입도 체크한다. Content-Type: application/json이다. StringHttpMessageConverter는 미디어 타입은 아무거나 다 된다. 그러므로 이 경우엔 StringHttpMessageConverter가 동작한다.
Content-Type: application/json
@RequestMapping
void hello(@RequestBody HelloData data) {}
이 경우엔 HelloData가 바이트도 아니고 String도 아니므로 앞의 2개의 컨버터는 탈락하고(루프를 돌면서 canRead() 하다가 탈락한다.) MappingJackson2HttpMessageConverter가 동작한다. 이건 객체 또는 HashMap이면 다 된다. 그리고 Content-Type: application/json도 해당하므로 이 컨버터가 선택된다.
그래서 json 데이터를 읽어서 HelloData 객체로 만들어서 넣어 준다.
Content-Type: application/json
@RequestMapping
void hello(@RequestBody String data) {}
우선순위상 String을 넣으면 Content-Type이 뭐든 상관없이 MappingJackson2HttpMessageConverter는 우선순위에서 밀린다.
이번엔 안 되는 케이스이다.
Content-Type: text/html
@RequestMapping
void hello(@RequestBody HelloData data) {}
이 경우엔 ByteArrayHttpMessageConverter 탈락, StringHttpMessageConverter 이것도 String 아니니깐 탈락한다. MappingJackson2HttpMessageConverter의 경우 객체 타입은 맞는데 미디어 타입이 application/json 관련이 아니다. 그러면 이건 탈락이다.
ChatGPT에서 찾은 내용)
핸들러 어댑터의 handle() 메서드는 반환 타입이 ModelAndView지만, null을 반환할 수도 있다. 즉, 언제나 ModelAndView 객체를 반환해야 하는 건 아니다.
@RestController 또는 @ResponseBody를 사용하면 뷰를 렌더링 하지 않고, JSON 같은 데이터를 직접 반환한다. 이 경우 ModelAndView는 null이며, HttpMessageConverter가 응답을 처리한다.
또는 컨트롤러가 직접 HttpServletResponse를 사용하여 응답을 작성하면 ModelAndView가 필요 없다. response.getWriter().write("Hello, file!"); 이런 걸 쓸 때다.
Request Mapping Handler Adapter Structure
이번 시간엔 요청 매핑 핸들러 어댑터의 구조에 대해 공부한다.
@Controller 처리해 주는 RequestMappingHandlerAdapter 공부한다는 뜻
HTTP 메시지 컨버터는 스프링 MVC 어디쯤에서 사용되는 것일까?

핸들러 어댑터에서 컨트롤러 호출하는 부분에 비밀이 있다. 애노테이션 기반의 컨트롤러의 여러 파라미터를 만들어서 호출할 수 있는 거.. 바로 이 핸들러 어댑터에서 뭔가 컨버터랑 관련이 있다. 즉, HttpMessageConverter는 핸들러 어댑터와 관련이 있다.
모든 비밀은 애노테이션 기반의 컨트롤러, 그러니까 @RequestMapping을 처리하는 핸들러 어댑터인 RequestMappingHandlerAdapter(요청 매핑 핸들러 어댑터)에 있다.
RequestMappingHandlerAdapter가 어떤 식으로 동작하냐 하면, DispatcherServlet이 이것저것 해서 결국 RequestMappingHandlerAdapter가 호출된다. 그러면 이제 컨트롤러 호출해 줘야 한다.
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");
}
.
.
.
}
requestParamV1이 호출되려면 누군가가 HttpServletRequest, HttpServletResponse가 있네 하고 던져 줘야 한다.
그뿐 아니라 다른 메서드의 경우 @RequestParam도 계산해서 로직을 처리한 다음에 누군가 이 파라미터를 던져 줘야 한다. 마찬가지로 @ModelAttribute가 있는 다른 메서드일 때도 HelloData와 관련된 걸 다 만들어서 던져 줘야 한다.
여기서 끝나는 게 아니다. InputStream이 있는 메서드의 경우도 InputStream도 던져 줘야 한다. HttpEntity나 @RequestBody가 있는 경우도, public String requestBodyStringV4(@RequestBody String messageBody) {...}
누군가 messageBody를 만들어서 던져 줘야 한다.
RequestMappingHandlerAdapter는, 메서드의 매개 변수들이 올 때마다 처리해 주는 ArgumentResolver라는 게 있다. 여기에 비밀이 있다.
생각해 보면, 애노테이션 기반의 컨트롤러는 매우 다양한 파라미터를 사용할 수 있었다. HttpServletRequest, Model은 물론이고, @RequestParam, @ModelAttribute 같은 애노테이션 그리고 @RequestBody, HttpEntity 같은 HTTP 메시지를 처리하는 부분까지 매우 큰 유연함을 보여 주었다. 이렇게 파라미터를 유연하게 처리할 수 있는 이유가 바로 ArgumentResolver 덕분이다.
애노테이션 기반 컨트롤러를 처리하는 RequestMappingHandlerAdapter는 바로 이 ArgumentResolver를 호출해서 컨트롤러(핸들러)가 필요로 하는 다양한 파라미터의 값(객체)을 생성한다. 그리고 이렇게 파리미터의 값이 모두 준비되면 컨트롤러를 호출하면서 값을 넘겨준다.
예를 들어
@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");
}
이건 HttpServletRequest와 HttpServletResponse 두 개 있다. 그러면 RequestMappingHandlerAdapter가 ArgumentResolver에게 HttpServletRequest 객체 줄 수 있는지 물어본다. 그리고 HttpServletResponse 객체도 가져다 줄 수 있는지 물어보면 거기에 맞는 ArgumentResolver가 또 선택이 된다. 즉, ArgumentResolver를 통해 객체가 다 나온다. 특히 @RequestBody String messageBody 같은 것들. ArgumentResolver에서 이걸 다 만들어서 넘겨준다.
그러면 핸들러 어댑터는 이제 파라미터로 넘겨줄 데이터가 다 준비되어서 컨트롤러 호출한다.

핸들러 어댑터가 컨트롤러 딱 보고 필요한 파라미터 타입들을 확인하고 ArgumentResolver한테 물어본다. 그럼 얘가 2개를 생성해 준다. 생성해 주고 나면 그때 핸들러 어댑터가 실제 컨트롤러를 호출하면서, ArgumentResolver를 통해 생성된 파라미터를 넣어 준다.
스프링은 30개가 넘는 ArgumentResolver를 기본으로 제공한다. 어떤 종류들이 있는지 살짝 코드로 확인만 해 보자.
정확히는 HandlerMethodArgumentResolver인데 줄여서 ArgumentResolver라고 부른다.
HandlerMethodArgumentResolver 코드를 보면 인터페이스이다.
boolean supportsParameter(MethodParameter parameter);
supportsParameter(MethodParameter parameter)는 이 파라미터 지원할 수 있는지..
MethodParameter 열어 보면 컨트롤러가 받아야 할 파라미터 정보들이 있다.
supportsParameter()를 호출해서 해당 파라미터를 지원하는지 체크하고, 만약 지원하면 resolveArgument()를 호출해서 실제 객체(Object)를 생성한다. 그리고 이렇게 생성된 객체가 컨트롤러 호출 시 넘어가는 것이다.

ArgumentResolver는 30개가 넘는다. ArgumentResolver도 List 형식으로 구현되어 있다. 그래서 하나씩 너 읽을 수 있어? 이런 식으로 물어보면서 읽을 수 있으면 OK이고 만들어 준다.
애노테이션 기반 요청 파라미터 목록은
https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/arguments.html
여기서 볼 수 있는데 이전에 본 것이다. 그 목록에 있는 것들을 하나하나 다 해 주는 게 ArgumentResolver이다. 이렇게 인터페이스화해서 설계해야 나중에 기능이 확장되어도, 스프링이 기능을 확장하거나, 우리가 ArgumentResolver와 관련된 기능을 더 추가할 수 있다.
supportsParameter()도 루프를 돌면서 호출한다.
그리고 원한다면 직접 이 HandlerMethodArgumentResolver 인터페이스를 확장해서 우리가 원하는 특별한 컨트롤러에 넘어가는 파라미터를 만들 수 있다.
실제 확장하는 예제는 향후 로그인 처리에서 진행하겠다.
HandlerMethodReturnValueHandler를 줄여서 ReturnValueHandler라 부른다. ArgumentResolver와 비슷한데, 이것은 응답 값을 변환하고 처리한다.
ModelAndView로 반환한 기억이 있을 것이다. @ResponseBody의 String을 반환하기도 하고, @ResponseBody의 HelloData 반환하기도 하고 void인 적도 있다.
이걸 처리해 주는 게 인터페이스화되어 있는데 그게 ReturnValueHandler이다. 컨트롤러의 반환 값을 변환하는 것이다. ModelAndView, @ResponseBody, HttpEntity 등이 있을 때 얘가 그런 컨버팅 작업을 다 해 준다.
HandlerMethodReturnValueHandler를 줄여서 ReturnValueHandler라 부른다.
HandlerMethod-라고 써져 있는 것은.. 컨트롤러들이 메서드 단위로 동작하기 때문에 Method라는 단어가 붙은 것이다.

스프링은 10여 개가 넘는 ReturnValueHandler를 지원한다.
예) ModelAndView, @ResponseBody, HttpEntity, String
컨트롤러에서 String으로 뷰 이름을 반환해도, 동작하는 이유가 바로 ReturnValueHandler 덕분이다.
가능한 응답 값 목록은 여기서 확인할 수 있다.
https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/return-types.html
여기에 있는 하나하나가 다 ReturnValueHandler에 대응이 된다.
강의 외적 내용)
https://www.inflearn.com/questions/180213/returnvaluehandler
ReturnValueHandler와 ViewResolver 비교
그러면 HttpMessageConverter는 어디에 있는 건가?
ArgumentResolver는 파라미터로 넘어가는 값을 만들어 준다고 했다. HttpMessageConverter는 바로 ArgumentResolver가 사용한다. 물론 반환할 때도 사용한다.
HTTP 메시지 컨버터는 어디쯤 있을까? HTTP 메시지 컨버터를 사용하는 @RequestBody도 결국 컨트롤러가 필요로 하는 파라미터의 값에 사용된다. 즉, ArgumentResolver가 해결해야 한다.
@ResponseBody의 경우도 컨트롤러의 반환 값을 이용한다. 한마디로 ReturnValueHandler가 필요하다.
요청의 경우 @RequestBody를 처리하는 ArgumentResolver가 있고, HttpEntity를 처리하는 ArgumentResolver가 있다. 이 ArgumentResolver들은 그냥 사용되는 것이 아니고, HTTP 메시지 컨버터를 사용해서 필요한 객체를 생성하는 것이다. ArgumentResolver는 일반적으로 자기가 다 처리하는데, HttpMessageConverter를 사용하는 즉, @RequestBody가 있거나 HttpEntity가 있으면, ArgumentResolver 중에 걔는 아까 얘기했듯이 등록되어 있는 몇 개 HttpMessageConverter들을 순서대로 호출한다. 즉 순서대로 호출하는 걸 ArgumentResolver가 한다.
예를 들어 HttpEntityMethodProcessor는 HandlerMethodArgumentResolver를 부모로 둔다. 즉 HttpEntity를 처리하는 ArgumentResolver이다.
HttpEntityMethodProcessor의 코드에 이게 있다.
public boolean supportsParameter(MethodParameter parameter) {
return HttpEntity.class == parameter.getParameterType() || RequestEntity.class == parameter.getParameterType();
}
HttpEntity이거나 RequestEntity가 들어오면, 이게 내 거라고 인식하고 HttpEntityMethodProcessor(ArgumentResolver의 이름)라는 게 동작한다. 그래서 실제 로직을, resolveArgument()를 해서 객체를 만들어서 준다.
@Nullable
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws IOException, HttpMediaTypeNotSupportedException {
ServletServerHttpRequest inputMessage = this.createInputMessage(webRequest);
Type paramType = this.getHttpEntityType(parameter);
if (paramType == null) {
String var10002 = parameter.getParameterName();
throw new IllegalArgumentException("HttpEntity parameter '" + var10002 + "' in method " + parameter.getMethod() + " is not parameterized");
} else {
Object body = this.readWithMessageConverters(webRequest, parameter, paramType);
return RequestEntity.class == parameter.getParameterType() ? new RequestEntity(body, inputMessage.getHeaders(), inputMessage.getMethod(), inputMessage.getURI()) : new HttpEntity(body, inputMessage.getHeaders());
}
}
Object body = this.readWithMessageConverters(webRequest, parameter, paramType);
객체를 만들어 낸다. 그리고 HttpEntity나 RequestEntity를 리턴할 때 body를 넣어 준다.
body에는 컨버터에서 튀어나온 게 들어 있다.
readWithMessageConverters() 코드 내부에 메시지 컨버터들을 하나씩 순서대로 돌리는 로직이 있다. canRead()와 read()도 보인다.
컨버터를 하나씩 돌리면서 하는 게 바로 ArgumentResolver 중에 HttpEntity를 처리하는 ArgumentResolver 또는 @RequestBody를 처리하는 ArgumentResolver들이 HttpMessageConverter를 루프를 돌리면서 처리한다.
강의 외적으로 확인)
ReturnValueHandler에서 메시지 컨버팅할 땐 writeWithMessageConverters 쓰는 듯
응답의 경우에도 @ResponseBody와 HttpEntity를 처리하는 ReturnValueHandler가 있다. 그리고 여기에서 HTTP 메시지 컨버터를 호출하는데 이땐 write()를 호출한다. 왜냐하면 HTTP 응답 메시지에 값을 써야 하기 때문이다.(이전에 설명한 요청일 땐 read()를 호출한다.)

스프링 MVC는 @RequestBody나 @ResponseBody가 있으면 RequestResponseBodyMethodProcessor로 합쳐서 처리한다. 아래 코드가 있다.
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(RequestBody.class);
}
public boolean supportsReturnType(MethodParameter returnType) {
return AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseBody.class) || returnType.hasMethodAnnotation(ResponseBody.class);
}
요청 오는 것과 응답 오는 것 둘 다 여기서 한 번에 처리하도록 객체를 만들었다. 같은 클래스로 두 가지를 다 처리한다. 그리고 RequestResponseBodyMethodProcessor에도 resolveArgument()나 readWithMessageConverters() 있다.(ReturnValueHandler는 handleReturnValue(), writeWithMessageConverters() 쓰는 듯)
HttpEntity가 있으면 HttpEntityMethodProcessor를 사용한다.
강의 외적으로 코드로 확인한 내용)
HttpEntityMethodProcessor도 ArgumentResolver인 동시에 ReturnValueHandler인 듯
HttpMessageConverter랑 ArgumentResolver랑 헷갈리면 안 된다.
ArgumentResolver는 argument를 찾는 것이고, 그 ArgumentResolver들 중에서 HTTP 메시지 바디에 있는 걸 바로 뭔가 처리해야 하면 HttpMessageConverter를 호출한다.
HttpMessageConverter의 구현체는 정말 많다. xml과 관련된 것도 있고 json이랑 관련된 것도 있고 정말 많다. 그래서 우리가 구현할 일은 거의 없다. 이미 제공되고 있는 걸 쓰면 된다.
스프링은 다음을 모두 인터페이스로 제공한다.
HandlerMethodArgumentResolver
HandlerMethodReturnValueHandler
HttpMessageConverter
따라서 필요하면 언제든지 구현체를 넣어서 기능을 확장할 수 있다. OCP 원칙을 지키면서.
스프링이 필요한 대부분의 기능을 제공하기 때문에 실제 기능을 확장할 일이 많지는 않다. 기능 확장은 WebMvcConfigurer를 상속받아서 스프링 빈으로 등록하면 된다.
예를 들면 아래처럼 쓴다.
@Bean
public WebMvcConfigurer webMvcConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
//...
}
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
//...
}
};
}
위를 스프링 빈으로 등록하면 된다. 더 편하게 쓰는 방법도 있다. 필요한 걸 오버라이드하면 된다. addArgumentResolvers()로 ArgumentResolver를 더 추가할 수도 있고, extendMessageConverters()로 메시지 컨버터를 더 추가할 수 있다.
지금까지 배운 핸들러 매핑, 핸들러 어댑터, ArgumentResolver.. ReturnValueHandler, 뷰 리졸버.. 등을 나만의 것으로 수정하려면 WebMvcConfigurer를 확장하면 된다. 여기에 그 메서드가 다 있다. addArgumentResolvers(), addReturnValueHandlers(), extendMessageConverters() ... 코드 확인해 보면 더 있다. 확장 포인트가 다 되어 있다.
위 코드를 가져와서 스프링 빈으로 등록하면, 스프링이 자동으로 이거에 대한 설정을 인식한다.
필요하면 이와 관련된 예제를 검색해 보자.
추가적으로 궁금한 내용)
@ResponseBody를 쓰더라도 어댑터가 ModelAndView를 반환하는가?
https://www.inflearn.com/questions/422763/responsebody%EC%9D%B8-%EA%B2%BD%EC%9A%B0%EC%9D%98-%EC%8B%A4%ED%96%89%ED%9D%90%EB%A6%84%EC%9D%B4-%EA%B6%81%EA%B8%88%ED%95%A9%EB%8B%88%EB%8B%A4
ArgumentResolver가 단순하게 처리하는 건 그냥 여기서 알아서 처리하고, HTTP 메시지 바디를 처리해야 하면 여기서 또 HTTP 메시지 컨버터를 통해서 처리한다.
여기까지 봤으면 스프링 MVC의 전체 구조를 본 것이다. 이제부턴 활용하고 확장할 일만 남았다.
Order
HTTP 요청 데이터를 조회하는 것은 항상 세 가지이다.
GET - 쿼리 파라미터
POST - HTML form
HTTP message body에 데이터를 직접 담아서 요청
1, 2는 항상 request.getParameter()로 똑같이 처리할 수 있다.
3은 데이터 바디에 직접 오기 때문에 메시지 컨버터를 사용해서 보통 처리한다.
스프링은 해당 생략 시 다음과 같은 규칙을 적용한다.
String, int, Integer 같은 단순 타입 = @RequestParam
나머지 = @ModelAttribute(argument resolver로 지정해 둔 타입 외)
argument resolver로 지정해 둔 타입은 argument resolver가 동작해서 거기서 생성된 게 넘어오고, 그 외의 경우엔 @ModelAttribute로 인정된다.
요청이 넘어오는 건 Content-Type만 보는데, 나가는 건 두 가지를 같이 본다. Accept랑 consumes(?)(produces인 듯)
@ResponseBody를 사용하면 HTTP 상태 코드를 변환하기 어려운데, @ResponseStatus를 쓰면 된다. 그런데 조건문에 따라 프로그래밍해야 하면 ResponseEntity를 쓰면 된다.
@RestController를 쓰면 해당 컨트롤러의 모든 메서드에 전부 @ResponseBody가 붙는다고 생각하면 된다.
0 = ByteArrayHttpMessageConverter
1 = StringHttpMessageConverter
2 = MappingJackson2HttpMessageConverter
지금은 거의 안 하는데 레거시 프로젝트에서 xml을 지원해 줘야 하면 이와 관련된 메시지 컨버터를 검색해 보면 자료를 찾을 수 있다. 메시지 컨버터 추가하면 xml도 쓸 수 있다. 그러면 Accept가 xml로 오게 되면 xml 메시지 컨버터가 선택된다.
ArgumentResolver들 중에서 @RequestBody를 처리해야 하는 ArgumentResolver랑, HttpEntity를 처리해야 하는 ArgumentResolver인 경우에 한해서, ArgumentResolver에서 로직을 다 처리하는 게 아니라 HTTP 메시지 컨버터들에게 물어본다. 예를 들어 지금 @RequestBody를 처리해야 하는데 HTTP 메시지 바디에 있는 걸 꺼내서 json이든 뭐든 처리해 줄 수 있는지를 물어보고 처리할 수 있으면 반환해 주고, 반환해 주고 넘어간다.
ReturnValueHandler도 마찬가지이다.
Spring MVC - Making a Web Page
Create Project
Requirements Analysis

검은색이 컨트롤러이다. MVC 패턴이므로 항상 컨트롤러 통해서 뷰가 호출된다.
클라이언트가 먼저 상품 목록에 들어가면, 상품 목록 컨트롤러에 들어간다. 그러면 컨트롤러가 뷰로 가서 상품 목록 뷰를 렌더링 한다.
뷰에 갔다고 치고, 상품 목록 뷰에선 상품 등록 폼으로 이동할 수 있다. 상품 등록 폼 컨트롤러에서 뷰로 상품 등록 폼을 보여 준다. 상품 등록 폼에서 값을 입력하고 등록 버튼을 누르면 상품 저장 컨트롤러로 이동한다. 그리고 상품 저장 컨트롤러에서는 상품 상세로 이동할 건데 어차피 내부에 뷰가 있으니까 내부적으로 이동할 거다. 상품 상세 뷰를 재사용한다.
상품 목록에서 상품 상세 컨트롤러로 가면 당연히 상품 상세 뷰로 간다. 그리고 상품 상세에서 상품 수정 폼 컨트롤러로 이동하게 되면 상품 수정 폼 뷰로 이동하게 되고, 수정 폼에서 값을 수정하게 되면, 즉 저장 버튼을 누르면 상품 상세로 리다이렉트할 것이다.
상품 상세는 상품을 등록해도 가고, 그냥 상품 상세로 가도 그 화면으로 간다.
Product Domain Development
private Integer price;
private Integer quantity;
Integer를 쓴 이유는 price가 안 들어갈 때도 있다고 가정한 거다. quantity도 마찬가지로 수량이 null로 들어갈 가능성도 있기 때문이다. int로 쓰게 되면 0이라도 들어가야 한다. 수량이 0인 건 그럴 수 있는데 가격이 0이면 애매하다. 상황에 맞게 하면 된다.
@Data를 쓰겠다. 이걸 쓰면 @Getter, @Setter, @ToString, @EqualsAndHashCode, @RequiredArgsConstructor를 자동으로 적용해 준다.
이게 되게 위험하다. 위험하다는 걸 강조하려고 썼다. 그냥 @Getter나 @Setter 정도만 사용하는 게 좋고 필요하면 직접 분리해서 찍는 게 낫다.
@Data는 이런 핵심 도메인 모델에 사용하기 되게 위험하다. 예측하기 어렵게 동작할 수 있다.
일반적으로 DTO라고 부르는, 단순하게 데이터를 그냥 왔다 갔다 할 때 쓰는 DTO라고 하는 것들이 있는데 그 경우엔 @Data를 써도 된다. 근데 그것도 좀 확인을 해 보고 써야 한다. 결론은 @Data는 약간 주의할 필요가 있다.
근데 지금은 그냥 공부하는 예제니깐 @Data 쓰겠다.
ItemRepository도 Item과 마찬가지로 item 패키지 하위에 만들겠다. 패키지를 분리할 수도 있지만 지금 프로젝트가 작아서 그냥 같이 하겠다.
@Repository
public class ItemRepository {
}
@Repository 안에 @Component가 있다. 컴포넌트 스캔의 대상이 된다.
이게 DB랑 실제 연동이 되는 저장소면 몇 가지 데이터베이스 예외를 예쁘게 바꿔 주거나, 스프링에 맞게 고쳐 주거나 하는 게 있는데 그건 참고하면 된다.
private static final Map<Long, Item> store = new HashMap<>();
실제론 HashMap 쓰면 안 된다.
private static final Map<Long, Item> store = new HashMap<>(); // static
private static long sequence = 0L; // static
스프링 컨테이너 안에서 쓰면 어차피 싱글톤이므로 static 안 써도 되는데 , 따로 new 해서 쓰거나 할 땐 static을 안 하면 객체 생성하는 만큼 따로 생성이 된다.
실무에서 HashMap 쓰면 안 된다. 멀티 스레드 환경에서 여러 개가 동시에 store에 접근할 땐 HashMap 쓰면 안 된다.
지금 ItemRepository가 싱글톤이고 static이다. 여러 스레드가 동시에 접근하면 HashMap 쓰면 안 되고 ConcurrentHashMap을 써야 한다.
private static long sequence = 0L;
이것도 이렇게 쓰면 안 된다. 동시에 접근하면 값이 꼬일 수 있다. 그땐 AtomicLong 등 다른 걸 써야 한다.
public List<Item> findAll() {
return new ArrayList<>(store.values());
}
store.values()를 그대로 반환해도 되는데(리턴 타입은 Collection인 듯) ArrayList로 감싸서 반환하게 되면 ArrayList에 값을 넣어도 더 이상 store엔 변함이 없기 때문에 안전하게 감쌌다. 타입을 바꿔야 하는 문제도 있다.
강의 외적으로 테스트)
public class Temp {
public static Map<Integer, String> map = new HashMap<>();
public static Map<Integer, String> map2 = new HashMap<>();
public static Collection<String> temp() {
return map.values();
}
public static List<String> temp2() {
return new ArrayList<>(map2.values());
}
public static void main(String[] args) {
map.put(1, "a");
map.put(2, "b");
map.put(3, "c");
System.out.println(temp());
Collection<String> list = temp();
list.remove("a");
System.out.println(temp());
System.out.println("----------------------------");
map2.put(1, "a");
map2.put(2, "b");
map2.put(3, "c");
System.out.println(temp2());
List<String> list2 = temp2();
list2.remove("a");
System.out.println(temp2());
}
}
이 코드를 실행하면
[a, b, c]
[b, c]
----------------------------
[a, b, c]
[a, b, c]
이렇게 출력된다.
update는 이렇게 해 보겠다.
public void update(Long itemId, Item updateParam) {
Item findItem = findById(itemId);
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
}
itemId를 넣고, item과 관련된 update 파라미터를 넣으면 업데이트되도록 하겠다. 간단해서 이렇게 한 거다.
이런 게 좀 애매하다. 사실 정확히 말하면 별도로 updateParam 값 3개 있는 객체를 만드는 게 맞다. 왜냐하면 id가 사용되지 않고 있다.
정석으로 하려면 ItemParamDto 같은 객체를 만들고 거기에 저 파라미터 3개만 넣어 놓는 게 맞다. 왜냐하면 개발자가 updateParam에 있는 setId()를 사용 안 하는지 혼란스러울 수 있다. 지금은 프로젝트가 작고 내가 인식할 수 있는 범위 안에 있으니까 이렇게 한 거고, 프로젝트가 크면 깔끔하게 ItemParamDto 같은 걸 만드는 게 훨씬 낫다. 귀찮지만 설계상 명확한 게 낫다. 중복 같아도 중복과 명확성 중에선 명확성을 따르는 게 낫다.
public void clearStore() {
store.clear();
}
HashMap 데이터가 다 날아간다. 테스트할 때 쓰려고 만들었다.
지금까지 만든 거로 테스트해 보면 된다.
public class ItemRepositoryTest {
ItemRepository itemRepository = new ItemRepository();
}
최근 JUnit5에선 public 없어도 된다.
지금은 스프링 없이 테스트하는 거다.
@AfterEach
void afterEach() {
itemRepository.clearStore();
}
각 테스트 하나하나가 끝날 때마다 이게 실행된다. 그래야 다음 테스트에 영향이 없다. 첫 번째 테스트에서 값 1개 추가했는데 두 번째 테스트할 때 그 값이 있으면 테스트 실패할 수 있다.
@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);
}
Item findItem = itemRepository.findById(item.getId());
save() 할 때 id가 세팅된다. 그래서 getId() 할 수 있다.
@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);
}
findAll()이 테스트 목적이기 때문에 when 절에선 findAll()을 해야 한다.
Product Service HTML
핵심 비즈니스 로직을 개발하는 동안, 웹 퍼블리셔는 HTML 마크업을 완료했다고 가정한다.
HTML 마크업이란 디자인된 것을 HTML로 돌아가도록 HTML 파일로 만들어 준 것이다.
백엔드 개발자들이 HTML이나 CSS 잘 못 다루는 경우 부트스트랩 사이트에 가서 여기 있는 거 많이 사용한다.
bootstrap.min.css을 resources/static/css/bootstrap.min.css 이 경로로 넣는다.
넣고 조심해야 할 것이 있다. 복사해서 넣으면 인텔리제이에서 인식이 안 될 때가 있다.
잘 열리면 상관이 없는데 혹시 안 열리면 out 폴더를 보면 여기에 빌드된 게 나오는데 이게 복사해서 넣으면 안 보일 때가 있다. 그땐 out 폴더를 지우고 서버를 다시 실행한다. 그러면 다시 컴파일 하면서 파일들을 만들어 낸다.
이제 HTML 파일을 하나씩 넣어 볼 것이다.
이제 동작하는 걸 확인해야 하는데, 동작하는 걸 확인하는 방법이 2가지가 있다.
첫 번째, 이건 정적인 HTML이다. 서버 띄울 필요 없이 파일로 열어도 된다. 서버 끈 상태에서 인텔리제이의 Copy Path를 통해 Absolute Path 이거로 전체 경로 복사한다. 그리고 웹 브라우저에서
D:\study\item-service\src\main\resources\static\html\addForm.html
이렇게 열어 보면 열린다. 버튼들 누르면 다른 페이지들로도 이동된다. 링크까지 다 걸어 둔 상태이다.
css는
<link href="../css/bootstrap.min.css" rel="stylesheet">이렇게 css 정보를 넣었는데 ../가 있다.
현재 나의 위치가
file:///D:/study/item-service/src/main/resources/static/html/addForm.html
여기서 상위에서
file:///D:/study/item-service/src/main/resources/static/css/bootstrap.min.css
두 번째 방법은, 지금 우리는 스프링 부트가 정적 리소스를 제공하는 경로에 넣어 놓았다. 서버를 띄워서도 확인할 수 있다.
정적 리소스이기 때문에 굳이 이렇게 확인할 필요는 없다. 그냥 이렇게도 된다는 걸 보여 주기 위함이다.
http://localhost:8080/html/items.html
이렇게 열면 열린다.
css도 적용된다.
http://localhost:8080/css/bootstrap.min.css
위 노트에 이어서)
페이지 소스 보기 하면 아래처럼 그대로 보인다.

실행하고 버튼 누르면 다른 페이지로 이동되는데 저장 누르면 Whitelabel Error Page가 뜬다.
저장할 때 안 되는 이유는
There was an unexpected error (type=Method Not Allowed, status=405).
POST 방식이기 때문에 그렇다. 정적 리소스에 있는 건 GET일 때 받는 거다. 그래서 POST에선 지금 스프링에서 막아 놓았다.(서버 안 띄우고 첫 번째 방법으로 했을 땐 오류 없이 다 되는 듯)
이렇게 정적 리소스가 공개되는 /resources/static 폴더에 HTML을 넣어 두면, 실제 서비스에서도 공개된다. 서비스를 운영한다면 지금처럼 공개할 필요 없는 HTML을 두는 것은 주의하자.
이거를 이따가 뷰 템플릿 타임리프 파일로 수정할 것이다. 아무튼 이것들은 특정 폴더에서 외부에 공개하면 안 된다. static에 넣어 두면 안 된다. 이러면 서비스를 오픈할 때 누군가 이걸 열 수 있다.
다음 시간부터 본격적으로 컨트롤러도 만들고 뷰 템플릿으로 타임리프를 하면서 동적으로 동작하도록 개발할 것이다.
Product List - Thymeleaf
@Controller
@RequestMapping("/basic/items")
public class BasicItemController {
private final ItemRepository itemRepository;
@Autowired
public BasicItemController(ItemRepository itemRepository) {
this.itemRepository = itemRepository;
}
}
이렇게 하면 BasicItemController가 스프링 빈에 등록되면서 생성자 주입으로 ItemRepository(이것도 스프링 빈으로 등록된다.)가 주입된다.
그래서
private final ItemRepository itemRepository;
여기에 들어간다.
스프링에서 생성자가 딱 하나만 있으면 @Autowired 생략해도 된다.
롬복의 @RequiredArgsConstructor 이걸 쓰면 final이 붙은 걸 가지고 생성자를 만들어 준다.
그래서 다음과 같이 써도 된다.
@Controller
@RequiredArgsConstructor
@RequestMapping("/basic/items")
public class BasicItemController {
private final ItemRepository itemRepository;
}테스트용 데이터를 추가해 보겠다. 아이템 목록을 보고 싶은데 데이터가 전혀 없으면 제대로 안 나오므로.
그리고 실행해도 아직 안 된다. 뷰를 templates에 만들어야 한다.
static에 있던 items.html을 복사한다. 하지만 이건 그냥 html이므로 이걸 타임리프로 고쳐야 한다.
타임리프 문법을 몇 가지만 알면 나머지는 매뉴얼에서 금방 찾는다.
<html xmlns:th="http://www.thymeleaf.org">
일단 이렇게 선언해 둬야 한다. 앞으로 th로 쓸 수 있도록
<link href="../css/bootstrap.min.css" rel="stylesheet">
이 css 경로도 나쁘진 않은데, 상대 경로로 되어 있는 걸 타임리프로 절대 경로로 넣겠다. 상대 경로로 해도 열리긴 하는데 폴더가 바뀌면 안 열린다.
th:href="@{/css/bootstrap.min.css}"
템플릿이 렌더링 되면 프로젝트에 있는 절대 경로 /css/bootstrap.min.css 가져온다.
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
기본은
href="../css/bootstrap.min.css"
이게 실행되는데
뷰 템플릿을 거치면
th:href="@{/css/bootstrap.min.css}"
이게 실행된다.
위 노트 이어서)
원래는 이랬는데
이렇게 바뀌었다.
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
th라는 게 있으면 타임리프가 어떻게 하냐 하면 href 속성을 "@{/css/bootstrap.min.css}"로 바꾼다. 기존 href="../css/bootstrap.min.css"를 날리고 덮어 버린다. 항상 타임리프는 이렇게 동작한다.
http://localhost:8080/basic/items에서 상품 등록 버튼 누르면 http://localhost:8080/basic/addForm.html로 가는데 지금 오류가 난다.
<button class="btn btn-primary float-end" onclick="location.href='addForm.html'" type="button">상품 등록</button>
이걸 타임리프를 통해서 제대로 된 addForm으로 가도록 바꾸겠다.
th:onclick="|location.href='@{}'|"
타임리프에서 링크 걸 땐 항상 @{} 넣어 주면 된다.
th:onclick="|location.href='@{/basic/items/add}'|"
이렇게 하면 타임리프가 안에 있는 걸 문자 더하기 빼기 할 필요 없이 그대로 인식해서 계산 다 해 준다.
다시 상품 등록 눌러 보면
http://localhost:8080/basic/items/add
여기로 간다. 물론 아직 오류 페이지 뜬다.

http://localhost:8080/basic/items에서 F12 눌러서 상품 등록 부분을 보면 위처럼 나온다.
페이지 소스 보기 누르면
onclick="location.href='/basic/items/add'"
이렇게 보이는데 그래도 잘 동작한다.
이제 루프 돌리는 부분을 해 보겠다.
<tr>
<td><a href="item.html">1</a></td>
<td><a href="item.html">테스트 상품1</a></td>
<td>10000</td>
<td>10</td>
</tr>
<tr>
<td><a href="item.html">2</a></td>
<td><a href="item.html">테스트 상품2</a></td>
<td>20000</td>
<td>20</td>
</tr>
이 부분을 빼고 실제 우리 것으로 루프를 돌릴 것이다.
<tr th:each="item : ${items}">
</tr>
model에 있는 items를 꺼내서 item에 넣어 준다. 그러면 이 안에서는 item을 쓸 수 있다.
<td><a href="item.html" th:text="${item.id}">회원id</a></td>
th:text라고 하면 지금의 회원id 부분에다 데이터가 바로 들어간다.
item.id 이것도 프로퍼티 접근법을 쓴다. item.id라고 하면 item에 있는 getId()를 호출해서 가져온다.
<tr th:each="item : ${items}">
<td><a href="item.html" th:text="${item.id}">회원id</a></td>
<td><a href="item.html" th:text="${item.itemName}">상품명</a></td>
<td th:text="${item.price}">10000</td>
<td th:text="${item.quantity}">10</td>
</tr>
위의 2개는 링크가 들어가야 해서 href가 있고 아래 2개는 링크를 안 넣을 거다.

실행하면 정상적으로 이렇게 나온다. 물론 링크 누르면 아직 오류 난다.
<td><a href="item.html" th:text="${item.id}">회원id</a></td>
이제 링크도 고쳐 보겠다. 정적일 땐 item.html이 열려야 하지만 렌더링이 됐을 땐 이제부터 뒤에 작성할 th:href로 치환이 된다.
<td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">회원id</a></td>
(itemId=${item.id}) 괄호를 넣으면 변수를 넣을 수 있다.
itemId에 item.id 렌더링 된 걸 넣고 그걸 {itemId}에 넣을 수 있다.
마치 @PathVariable처럼 쓰면 된다.
<tr th:each="item : ${items}">
<td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">회원id</a></td>
<td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.itemName}">상품명</a></td>
<td th:text="${item.price}">10000</td>
<td th:text="${item.quantity}">10</td>
</tr>
두 번째도 id로 이동하기 때문에 똑같다.
실행하고 눌러 보면
http://localhost:8080/basic/items/1
http://localhost:8080/basic/items/2
이렇게 이동한다.(물론 아직은 오류 페이지이다.)
아이템이 여러 개 있으면 늘어난다.
타임리프를 왜 내추럴 템플릿이라고 하냐 하면, 지금은 프로그래밍 언어를 넣어서 루프를 돌렸다. JSP의 경우 for문 들어가는 순간 그걸 열면 HTML이 아니다. 이상한 JSP 문법이 들어가 있다. 다른 템플릿 언어도 마찬가지이다. 그런데 타임리프는 HTML 태그를 그대로 쓴다. 서버 안 띄우고 Copy Path로 Absolute Path 그냥 열어도 다음 사진처럼 열린다.

지금은 css가 잘 안 열리지만 이것도 경로를 잘 맞춰 주면 다 보인다. 페이지 소스 보기 누르면

HTML이다.
th 부분 th:href="@{/css/bootstrap.min.css}" 이건 그냥 무시한다. 왜냐하면 웹 브라우저 입장에서 th 모르므로 무시한다. 그러면 당연히 href="../css/bootstrap.min.css"가 호출된다.
onclick도 마찬가지이다.
그래서 내추럴 템플릿이라고 한다. 보통 JSP나 프로그래밍 언어 쓰면 완전히 깨지는데, 타임리프는 화면을 HTML 모양으로 그대로 살리면서, 뷰 템플릿으로 렌더링 될 때만 조금씩 치환한다. 화면을 크게 깨뜨리지 않는다.
th:href="@{/css/bootstrap.min.css}"
th:href라고 하면 href 속성을 타임리프 뷰 템플릿 렌더링 되는 순간 얘를 갈아 끼운다.
서버 사이드에서 렌더링 되면 기존 속성을 치환하거나 기존 값들을 치환하는 게 타임리프 핵심이다.
URL 링크 표현식 - @{...}
URL 링크 표현식을 사용하면 서블릿 컨텍스트를 자동으로 포함한다. 요즘엔 서블릿 컨텍스트를 잘 사용 안 한다.
옛날엔 서블릿 컨텍스트라는 경로가 더 있었다.
지금은 그냥
http://localhost:8080/basic/items
이렇게 여는데, 옛날엔 애플리케이션 경로를, applicationA 밑에 이거다라는,
http://localhost:8080/applicationA/basic/items
옛날엔 지정해 줬다. 지금은 할 필요가 없다. 지금은 스프링 부트의 내장으로 쓰면서 이런 기능 거의 안 쓴다. 옛날엔 한 톰캣 서버에 여러 war 파일을 넣어서 돌릴 땐 경로에 이름을 지정해 줬어야 했는데 지금은 거의 안 쓴다.
타임리프에서 문자와 표현식 등은 분리되어 있기 때문에 더해서 사용해야 한다. 그런데 리터럴 대체 문법을 쓰면 마치 템플릿 치환하듯이 치환된다.
반복은 th:each를 사용한다. 이렇게 하면 모델에 포함된 items 컬렉션 데이터가 item 변수에 하나씩 포함되고, 반복문 안에서 item 변수를 사용할 수 있다. 밖에선 못 쓴다. 태그 벗어나면 안 된다.
변수 표현식 - ${...}
모델에 포함된 값이나, 타임리프 변수로 선언한 값을 조회할 수 있다.
<tr th:each="item : ${items}">
여기서 item도 변수로 선언한 것인 듯
th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}"
itemId=${item.id} 이건 치환하는 데 사용되고, 나머지는 쿼리 파라미터로 쓸 수 있다. 여러 개가 있으면 &가 붙어서 계속 붙는다.
<td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.itemName}">상품명</a></td>
이거를
<td><a href="item.html" th:href="@{|/basic/items/${item.id}|}" th:text="${item.itemName}">상품명</a></td>
이렇게 써도 된다.
+)
th:href="@{|/basic/items/${item.id}|}"
이렇게 하고 페이지 소스 보기 누르면
<td><a href="/basic/items/1">itemA</a></td>
이런 식으로 제대로 뜨지만
th:href="|@{/basic/items/${item.id}}|"
이렇게 하고 페이지 소스 보기를 누르면
<td><a href="/basic/items/${item.id}">itemA</a></td>
이렇게 제대로 안 뜬다.
타임리프는 순수 HTML 파일을 웹 브라우저에서 열어도 내용을 확인할 수 있고, 서버를 통해 뷰 템플릿을 거치면 동적으로 변경된 결과를 확인할 수 있다. JSP를 생각해 보면, JSP 파일은 웹 브라우저에서 그냥 열면 JSP 소스 코드와 HTML이 뒤죽박죽되어서 정상적인 확인이 불가능하다. 오직 서버를 통해서 JSP를 열어야 한다. 이렇게 순수 HTML을 그대로 유지하면서 뷰 템플릿도 사용할 수 있는 타임리프의 특징을 내추럴 템플릿(natural templates)이라 한다.
Copy Path로
D:\study\item-service\src\main\resources\templates\basic\items.html 이렇게 직접 열어도 잘 열린다. 페이지 소스 보기 하면 th에 있는 게 사용 안 되고 그냥 onclick이 사용된다.
css도 경로 맞추면 된다.

이렇게 하면 css 적용된다.
이렇게 하면 장점이, 화면 수정할 땐 이 HTML 파일만 열어서 수정하면 된다. 동적인, 서버 관련된 건 서버 띄워서 확인하면 된다. 복잡해지면 쉽지는 않다.
Product Details
<div class="col">
<button class="w-100 btn btn-primary btn-lg"
onclick="location.href='editForm.html'"
th:onclick="|location.href='@{/basic/items/{itemId}/edit(itemId=${item.id})}'|"
type="button">상품 수정</button>
</div>
상품 상세 페이지에서 상품 수정을 누르면
http://localhost:8080/basic/items/1/edit으로 가도록 했다. 아직 페이지가 없으니까 오류 화면 뜬다.
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/basic/items}'|"
type="button">목록으로</button>
</div>
이건 상품 상세에서 목록으로 가는 거다.
Product Registration Form
타임리프를 더 공부해 보면 include? 이런 것도 된다. 공통적인 건 include 해서 쓸 수 있다. 활용 1편에서 include 해서 쓴다.
상품 등록 폼은 /basic/items/add인데 상품 등록도 /basic/items/add로 만들 것이다. 대신 폼을 열 땐 get, 저장할 땐 post를 쓸 것이다.
th:action="@{/basic/items/add}" method="post"
(th:action="/basic/items/add"라고 쓰면 상품 등록 누를 때 오류 페이지 나온다.)
사실 같은 URL이기 때문에
th:action method="post"
이렇게 비워도 된다. 비우게 되면 현재 URL에다 POST 방식으로 보낸다. 같다는 걸 강조하기 위해 지금은 비우겠다.

페이지 소스 보기 눌러도 action이 비워져 있다.
상품 등록 폼 페이지에서 상품 등록 버튼 눌러도 지금 URL http://localhost:8080/basic/items/add 그대로 간다.
Handling Product Registration - @ModelAttribute
@PostMapping("/add")
public String save() {
return "xxx";
}
이제 위 코드에서 @RequestParam을 추가하겠다. 이름을 어떻게 확인하냐 하면

여기의 name으로 넘어온다.

이렇게 해서 상품 등록 누르면

이렇게 나오고, 폼 데이터는 아래처럼 나온다.


@PostMapping("/add")
public String save(@RequestParam("itemName") String itemName,
@RequestParam("price") int price,
@RequestParam("quantity") Integer quantity,
Model model) {
return "xxx";
}
int, Integer 아무거나 써도 된다.
@PostMapping("/add")
public String save(@RequestParam("itemName") String itemName,
@RequestParam("price") int price,
@RequestParam("quantity") 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";
}
Item(String itemName, Integer price, Integer quantity) 생성자를 써도 되지만 지금은 그냥 setter로 해 보겠다. 조금 뒤에 쓸 @ModelAttribute를 쓰면 setter를 호출하므로 그거랑 비교하기 위해.
model.addAttribute()로 item을 넣었다. 왜 넣었냐 하면 상품 저장하고 나면 상세 화면에서 저장된 결과를 보여 주고 싶다. 그러면 뷰를 또 직접 만들어야 할까? 그럴 필요 없다. 왜냐하면 item.html 이미 만들어 놨다. 여기에 값들만 넘겨주면 된다.
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item, Model model) {
itemRepository.save(item);
model.addAttribute("item", item);
return "basic/item";
}
지금 보면 @ModelAttribute에 name 속성을 넣어 줬다. 그러면 model.addAttribute("item", item);를 주석 처리해도 된다. 자동으로 추가가 되므로 생략해도 된다.
@ModelAttribute에서 담은 객체는 보통 뷰에서 쓰이는데, 자동으로 Model에 넣어 준다. 그때 name에 지정해 준 이름을 가지고 넣어 준다.
만약 @ModelAttribute("item2")라고 하면 model.addAttribute("item2", item); 이렇게 된다. 그러면 지금은 안 된다. 왜냐하면 화면에서 지금 th:value="${item.price}" 이런 식으로 item이란 이름을 쓴다.
그러면 매개 변수에 있는 Model model도 지워도 된다. 밖에서 스프링이 Model 만든다. 자동으로 들어간다. 그러면 다음처럼 써도 된다.
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item) {
itemRepository.save(item);
// model.addAttribute("item", item);
return "basic/item";
}
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item) {
itemRepository.save(item);
//model.addAttribute("item", item);
return "basic/item";
}
@ModelAttribute("item") Item item에서 name 속성을 지워서
@ModelAttribute Item item 이렇게 하면 어떻게 될까?
default 룰이 있다. 지금 클래스가 Item인데 이걸 첫 글자만 소문자로 바꿔서 그게 이름이 된다.
만약 @ModelAttribute HelloData item 이 경우라면
helloData 이게 담긴다.
model.addAttribute("helloData", item); 이렇게 된다.
@PostMapping("/add")
public String addItemV4(Item item) {
itemRepository.save(item);
//model.addAttribute("item", item);
return "basic/item";
}
@ModelAttribute도 생략할 수 있다. 이전에 배웠다.
이 경우에도 클래스 이름인 Item의 첫 글자를 소문자로 바꿔서 item이란 이름으로 Model에 담긴다.
Edit Product

지금 이 상태에서 서버 실행 후 상품 수정 폼에 들어가 보면 어떤 상품을 누르든지

이렇게 1, 상품A, 10000, 10으로 보인다.
이걸 수정해 보겠다.
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable("itemId") Long itemId, @ModelAttribute Item item) {
itemRepository.update(itemId, item);
return "xxx";
}
저장하고 나면 어디로 가야 할까? 상품 상세로 이동할 건데 그냥 이동하는 게 아니고 리다이렉트로 이동할 것이다. 그러면 URL 자체가 바뀐다.
상품 수정 폼 http://localhost:8080/basic/items/8/edit에서 저장을 누르면 위 경로가 남아 있는데, 리다이렉트를 하게 되면 http://localhost:8080/basic/items/8 경로로 이동하게 된다.
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable("itemId") Long itemId, @ModelAttribute Item item) {
itemRepository.update(itemId, item);
return "redirect:/basic/items/{itemId}";
}
스프링에선 리다이렉트를 위처럼 한다.
return "redirect:/basic/items/{itemId}";
이렇게 하면 @PathVariable에 있는 걸 여기서도 쓸 수 있게 해 준다.
이제 상품 수정 폼인 http://localhost:8080/basic/items/2/edit에서 저장을 누르면 http://localhost:8080/basic/items/2로 간다. 즉 경로가 바뀐다.

상품 수정 폼에서 저장 버튼을 누르면 F12로 봐도 상태 코드 302가 나온다. Location을 보면 http://localhost:8080/basic/items/2이다.
이렇게 하면 웹 브라우저가 edit의 결과가 리다이렉트인 걸 확인하고 나서 웹 브라우저가 URL을 Location에 써진 대로 실제 이동한다. 그래서 상품 상세로 완전히 처음부터 이동한 것이다. 상품 상세가 컨트롤러부터 다시 호출되었다.
왜 등록을 할 땐 리다이렉트를 안 쓰고 수정할 때는 리다이렉트를 썼을까? 다음 시간에 PRG 패턴을 설명하기 위해 일부러 상품 등록할 때 뷰 템플릿으로 이동하게 했다.
PRG Post/Redirect/Get
@PostMapping("/add")
public String addItemV4(Item item) {
itemRepository.save(item);
//model.addAttribute("item", item);
return "basic/item";
}
사실 지금까지 했던 상품 등록 처리 컨트롤러는 심각한 문제가 있다. 상품 등록 버튼을 누르고 웹 브라우저의 새로 고침 버튼을 클릭하면 상품이 계속 중복 등록이 된다.

새로 고침 누를 때마다 위처럼 상품 ID가 계속 증가한다.

상품 목록에서 상품 등록 폼으로 이동하면(상품 등록 폼 컨트롤러를 통해서 상품 등록 폼 뷰가 보인다.) 그다음에 상품 등록 폼 데이터를 넘기면 저장 컨트롤러에서 상품 상세 뷰를 호출한다. 그러면 URL은 상품 저장 URL이 남아 있다.

get으로 상품 등록 폼(http://localhost:8080/basic/items/add)을 가져온다.
데이터를 입력하고 상품 등록 버튼을 누르면 post로 서버에 넘어간다. post로 상품 저장이 되고 상품 저장의 결과가 있는 상품 상세 화면이 나에게로 넘어온다. 아래는 상품 등록 버튼을 누른 직후이다.

post로 요청한 결과물이 온 거다. 상품 저장하는 컨트롤러로 가 보면
@PostMapping("/add")
public String addItemV4(Item item) {
itemRepository.save(item);
//model.addAttribute("item", item);
return "basic/item";
}
그냥 뷰 템플릿을 호출한다. 웹 브라우저 입장에선 post로 add 요청한 게 마지막 요청이다.
새로 고침이라는 건 뭘까? 내가 마지막에 했던 행위를 다시 하는 거다. 그러면 이 상태에서 새로 고침 버튼을 누르면 내가 마지막에 했던 post 요청이 그대로 또 요청되고 데이터도 보냈던 데이터가 그대로 들어간다.(물론 id는 서버에서 새로 해 준다.) 이것이 문제이다. 상품이 계속 저장될 수 있다. 이 문제를 해결하려면 리다이렉트를 하면 된다.

상품 등록 폼에 가서 상품 등록 폼이 온다. 그다음에 상품 저장을 한다. 그리고 리다이렉트를 한다. 리다이렉트를 하면 웹 브라우저 입장에서 완전히 새로 다시 요청한다. 리다이렉트 응답을 주면 웹 브라우저의 URL이 바뀌면서 상품 상세로 다시 요청한다. 리다이렉트를 상품 상세로 보낸다. 그러면 이 상태에서 새로 고침을 하면, 마지막에 요청한 게 상품 상세이므로 post를 요청하는 게 아니라 상품 상세를 요청하게 된다. 이것을 PRG라고 한다.
post로 보내는데 redirect를 해서 get으로 다시 보낸다.
웹 브라우저의 새로 고침은 마지막에 서버에 전송한 데이터를 다시 전송한다. 그런데 웹 브라우저 입장에서 리다이렉트를 받으면 URL 자체가 바뀐다. 상품 상세로 다시 들어간다. 그러면 사용자 입장에서 새로 고침을 하면 마지막 것인 get이 호출된다.
새로 고침 문제를 해결하려면 상품 저장 후에 뷰 템플릿으로 이동하는 것이 아니라, 상품 상세 화면으로 리다이렉트를 호출해 주면 된다.
웹 브라우저는 리다이렉트의 영향으로 상품 저장 후에 실제 상품 상세 화면으로 다시 이동한다. 마치 고객이 URL 창에 URL을 get 방식의 /items/{itemId}를 쳐서 엔터 누른 것처럼 이동한다. 실제 URL도 바뀐다.
@PostMapping("/add")
public String addItemV5(Item item) {
itemRepository.save(item);
//model.addAttribute("item", item);
return "redirect:/basic/items/" + item.getId();
}
상품 등록 폼(http://localhost:8080/basic/items/add)에서 상품 등록을 누르면 http://localhost:8080/basic/items/3으로 간다.
F12를 통해 확인해 보면 처음에 add를 호출한다. post로 보내긴 했는데 응답 코드가 302로 온다. 그리고 응답 헤더에 Location 헤더가 있다.(Location: http://localhost:8080/basic/items/3)
웹 브라우저가 이 응답을 받으면 이 URL로 바꿔서 다시 요청한다. 다시 요청한 게 아래 사진의 두 번째에 있는 3이다.

이 상태에서 새로 고침을 하면

상품이 더 이상 등록이 안 된다.
이런 문제 해결 방식을 PRG(Post/Redirect/Get)라 한다.
주의할 것이 있다.
return "redirect:/basic/items/" + item.getId();
지금은 문자 그대로 더했다. 이게 id니까 상관없는데 한글이나 띄어쓰기가 있거나 그러면... URL은 띄어쓰기나 한글 같은 게 들어가면 안 된다. 항상 인코딩을 해서 넘겨야 한다. 인코딩을 직접 해 줘서 넣으면 괜찮은데 그냥 위처럼 넣으면 위험하다. 이렇게 하면 URL 인코딩이 안 된다.
이런 문제를 다음 시간에 설명하는 RedirectAttributes라는 걸 사용하면 URL 인코딩부터 여러 가지 문제가 다 해결된다.
리다이렉트할 때 여러 속성 넣을 수 있는 RedirectAttributes를 다음 시간에 배운다.
RedirectAttributes
상품 상세 화면으로 갈 때 저장이 잘 되었습니다. 메시지 하나 보여 주도록 해 보겠다. RedirectAttributes를 통해 간단히 해결할 것이다.
아이디어는 간단한다. 리다이렉트를 할 때 파라미터를 붙여서 보내는 것이다. 저장이라는 플래그에 파라미터가 있으면 저장되었다는 메시지 보여 주게 할 것이다.
@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}";
}
이번엔 저장된 결과를 다음처럼 가져와 보겠다.
Item savedItem = itemRepository.save(item);
그리고 리다이렉트와 관련된 속성들을 넣겠다.
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
status가 true이면 저장에서 넘어온 것이라고 생각할 것이다.
return "redirect:/basic/items/{itemId}";
이렇게 하면 redirectAttributes에 넣은 itemId 값이 {}에 치환된다. 결론적으로 savedItem.getId()가 들어간다.
status처럼 남는 것들은 ?status=true 이런 식으로 쿼리 파라미터 형식으로 들어간다.
그리고 URL 인코딩 같은 거 다 해결된다.
상품 등록 폼에서 상품 등록을 누르면

이렇게 나온다.
http://localhost:8080/basic/items/3?status=true
{itemId}가 3으로 치환되었다. 남은 status=true는 쿼리 파라미터로 넘어갔다.
return "redirect:/basic/items/{itemId}";
이걸 찾아가 보면
@GetMapping("/{itemId}")
public String item(@PathVariable("itemId") Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/item";
}
이거이고 이게 호출되면 basic/item 이 뷰에다 작업을 해 줘야 한다.
<h2 th:if="${param.status}" th:text="'저장 완료'"></h2>
HTTP 요청 파라미터는 워낙 많이 쓰이기 때문에 타임리프에서 파라미터 값을 그냥 꺼내서 쓸 수 있게 기능을 지원한다.
th:if는 이 조건이 만족하면 여기가 성공적으로 실행된다는 것이다.
th:text="'저장 완료'"
작은 따옴표 넣으면 타임리프에서 그냥 문자를 쓸 수 있다.
파라미터의 조건 값이 참이면 저장 완료!라는 게 h2로 나온다.
상품 등록 폼에서 데이터를 입력하고 상품 등록을 누르면

이렇게 뜬다. status=true이고 저장 완료!라고 써져 있다.
페이지 소스 보기 누르면 <h2>저장 완료!</h2>가 써져 있다.
이번엔 다시 목록으로 간 후, 목록에서 aa를 누르면

이렇게 뜬다. 페이지 소스 보기 누르면 위에 있던 내용(<h2>저장 완료!</h2>)이 없다. th:if 조건이 참이어야 저장 완료!를 렌더링 한다.
물론 내가 직접 저장으로 온 게 아니어도 그냥 URL로
http://localhost:8080/basic/items/3?status=true
이렇게 하면(3으로 기존에 저장했을 경우)

이게 보인다. 그런데 사용자 입장에서 굳이 이걸 쓰진 않을 것이다. 이런 거 정도는 그냥 파라미터로 처리해도 된다.
@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}";
}
itemId 같은 경우 PathVariable처럼 경로상의 URL 템플릿에 있으면 치환이 되고, 없는 건 쿼리 파라미터로 넘어간다.
<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>
param 자체는 타임리프에서 쿼리 파라미터를 편리하게 조회하도록 기능을 제공하는 것이다. 원래는 Model에 직접 담아서 해야 하는데 그럼 귀찮고 쿼리 파라미터를 워낙 많이 쓰므로 이런 걸 제공한다.
강의 외적으로 확인)
<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>
param.status에서 꺼낸 것이 true가 아닌 0이나 1이나 false나 "sdf"나 ""나 " "라고 해도, 상품 등록 폼에서 상품 등록을 누르면 저장 완료!가 뜬다.
해당 파라미터가 존재하면 참인 것 같다.
<h2 th:unless="${param.status}" th:text="'저장 완료!'"></h2>
이렇게 unless로 하면 반대이다. 그냥 목록에서 조회하면 저장 완료!가 뜨는데 상품 등록 폼에서 상품 등록 버튼을 누르면 저장 완료!가 안 뜬다.
Summary
타임리프는 순수 HTML을 그대로 유지하면서 뷰 템플릿도 사용할 수 있다. JSP나 다른 템플릿들은 제대로 열리지 않는다. 다 깨진다. 왜냐하면 JSP의 경우에도 if 같은 게 들어가므로.
@ModelAttribute는 두 가지 기능을 한다.
첫 번째는 요청 파라미터로 객체 생성하고 setter로 값 넣어 주는 것.
두 번째는 Model에도 넣어 준다.
<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>
param은 예약어이다. 기본적으로 제공하는 표현이다. param이라고 하면 HTTP 요청 파라미터 값을 그대로 가져다 쓴다. 이런 건 타임리프에서 그냥 지원해 준다.
Next
Next
HTTP 메시지 컨버터는 요청이 올 때도 메시지 컨버터가 필요하니까 Argument Resolver가, @RequestBody 또는 HttpEntity를 처리하는 ArgumentResolver는 내부적으로 HTTP 메시지 컨버터들을 또 다 찾아 봐서 맞는 컨버터를 호출해서 동작한다.
ReturnValueHandler 중에서도 @ResponseBody나 HttpEntity를 처리하는 ReturnValueHandler의 경우에는 HTTP 메시지 컨버터를 사용해서 응답을 만들어서 반환한다.
Others
Deleted Units
ResponseEntity는 HTTP 응답 전체를 객체로 표현한다. 응답 본문 데이터 외에 HTTP 상태 코드, 헤더 정보 등을 프로그래밍적으로 설정하여 동적으로 제어할 수 있는 강력한 반환 타입이다.

왜인지 질문하기






