[Java/Spring] 스프링 MVC - 1편 정리

전용본·2023년 3월 21일

Spring

목록 보기
2/5

서블릿

  • 톰캣처럼 서블릿을 지원하는 WAS를 서블릿 컨테이너라고 함
  • 서블릿 컨테이너는 서블릿 객체를 생성, 초기화, 호출, 종료하는 생명주기 관리
  • 서블릿 객체는 싱글톤으로 관리 (공유 변수 사용 조심)
  • 동시 요청을 위한 멀티 쓰레드 처리 지원
    -> 개발자가 멀티 쓰레드 관련 코드를 신경쓰지 않고 싱글 쓰레드 프로그래밍 하듯 편하게 소스코드 개발

서블릿 컨테이너의 동작 방식

  • HTTP 요청시에 WAS는 Request, Response 객체를 새로 만들어서 서블릿 객체 호출
  • 개발자는 Request 객체에서 HTTP 정보를 편하게 사용, Response 객체에 HTTP 응답 정보 편하게 입력
  • WAS는 Response 객체에 담겨있는 내용으로 HTTP 응답 정보 생성

쓰레드 풀 - 멀티 쓰레드

  • 필요한 쓰레드를 쓰레드 풀에 보관하고 관리
  • WAS의 주요 튜닝 포인트는 최대 쓰레드 수
    • 값이 너무 낮을 때 동시 요청이 많으면 서버 리소스는 여유롭지만 클라이언트는 응답 지연
    • 값이 너무 높으면 CPU, 메모리 임계점 초과로 서버 다운

HTTP 요청 데이터

HttpServletRequest

  • HTTP 요청 메세지를 개발자가 직접 파싱해서 사용하는 것은 매우 불편하고 어렵다. 서블릿이 파싱하고 그 결과를 HttpServletRequest 객체에 담아서 제공한다.

GET 쿼리 파라미터

@WebServlet(name = "RequestParamServlet", urlPatterns = "/request-param")
public class RequestParamServlet extends HttpServlet {
    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("[전체 파라미터 조회] - start");

        request.getParameterNames().asIterator().
                forEachRemaining(paramName -> System.out.println(paramName + "=" + request.getParameter(paramName)));

        System.out.println("[전체 파라미터 조회] - end");
        System.out.println();

        System.out.println("[단일 파라미터 조회] - start");
        String username = request.getParameter("username");
        String age = request.getParameter("age");

        System.out.println("username = " + username);
        System.out.println("age = " + age);
        System.out.println("[단일 파라미터 조회] - end");

        System.out.println("[이름이 같은 복수 파라미터 조회]");
        String[] usernames = request.getParameterValues("username");
        for (String name : usernames) {
            System.out.println("username = " + name);
        }

    }
}

POST HTML Form

<!DOCTYPE html>
<html>
<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>
<body>
<form action="/request-param" method="post">
	username: <input type="text" name="username" />
	age: <input type="text" name="age" /> <button type="submit">전송</button>
</form>
</body>
</html>
  • HTML Form을 전송하면 웹 브라우저는 다음 형식으로 HTTP 요청 메세지를 만든다.
    • content-type : application/x-www-form-urlencoded
    • message body : username = & age =
  • 회원가입, 상품주문 등에서 주로 사용되는 방식
  • GET 쿼리 파라미터 방식과 차이가 있어서 웹 브라우저에서는 다르지만 서버 입장에서는 형식이 동일하므로 똑같이 조회할 수 있다.
Postman 테스트 가능

  • 매번 HTML Form을 만들기 힘드므로 Postman을 통해 테스트할 수 있다.

API 메세지 바디

단순 텍스트
@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);

    }
}
  • content-type : text/plain
  • message body : hello!
  • inputStream은 byte 코드를 반환하므로 읽을 수 있는 문자로 변환해야 한다. 변환할 때는 어떻게 인코딩되어 있는지 알아야 한다.
JSON 형식
@WebServlet(name = "reqeustBodyJsonServlet", urlPatterns = "/request-body-json")
public class RequestBodyJsonServlet extends HttpServlet {

    private ObjectMapper objectMapper = new ObjectMapper();
    
    @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);

        HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);

        System.out.println("helloData.getUsername() = " + helloData.getUsername());
        System.out.println("helloData.getAge() = " + helloData.getAge());

    }
}
  • content-type : application/json
  • message body : {"username": "hello", "age": 20}
  • JSON 결과를 파싱하기 위해 변환 라이브러리가 필요하다. 스프링 MVC에서는 Jackson(ObjectMapper)을 제공한다.

HTTP 응답 데이터

HttpServletResponse

  • HttpServletResponse 객체에 응답코드, 헤더, 메세지 바디, 쿠키 등을 개발자가 편하게 등록할 수 있다.

헤더 등록 & 단순 텍스트 응답

@WebServlet(name = "responseHeaderServlet", urlPatterns = "/response-header")
public class ResponseHeaderServlet extends HttpServlet {
    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        // status-line
        response.setStatus(HttpServletResponse.SC_OK);

        // response-headers
        response.setHeader("Content-Type", "text/plain;charset=utf-8");
        response.setHeader("Cache-Control","no-cache, no-store, must-revalidate");
        response.setHeader("Pragma","no-cache");
        response.setHeader("my-header","hello");
        
        PrintWriter writer = response.getWriter();
        writer.println("안녕하세요");
    }
}

HTML 응답

@WebServlet(name = "responseHtmlServlet", urlPatterns = "/response-html")
public class ResponseHtmlServlet extends HttpServlet {
    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        response.setContentType("text/html");
        response.setCharacterEncoding("utf-8");

        PrintWriter writer = response.getWriter();
        writer.println("<html>");
        writer.println("<body>");
        writer.println(" <div>안녕?</div>");
        writer.println("</body>");
        writer.println("</html>");
    }
}
  • content-type : text/html
  • 자바 코드로 된 HTML을 반환한다.

API JSON

@WebServlet(name = "responseJsonServlet", urlPatterns = "/response-json")
public class ResponseJsonServlet extends HttpServlet {

    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        HelloData helloData = new HelloData();
        helloData.setUsername("kim");
        helloData.setAge(20);

        String result = objectMapper.writeValueAsString(helloData);
        response.getWriter().write(result);
    }
}
  • content-type : application/json
  • Jackson 라이브러리의 도움을 받아 JSON 형식으로 변환한 후 응답한다.

회원 관리 웹 어플리케이션 (by 서블릿)

회원 등록 폼

@WebServlet(name = "memberFormServlet", urlPatterns = "/servlet/members/new-form")
public class MemberFormServlet extends HttpServlet {

    private MemberRepository memberRepository = MemberRepository.getInstance();
    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        response.setContentType("text/html");
        response.setCharacterEncoding("utf-8");

        PrintWriter w = response.getWriter();

        w.write("<!DOCTYPE html>\n" +
                "<html>\n" +
                "<head>\n" +
                "    <meta charset=\"UTF-8\">\n" +
                "    <title>Title</title>\n" +
                "</head>\n" +
                "<body>\n" +
                "<form action=\"/servlet/members/save\" method=\"post\">\n" +
                "    username: <input type=\"text\" name=\"username\" />\n" +
                "    age:      <input type=\"text\" name=\"age\" />\n" +
                " <button type=\"submit\">전송</button>\n" + "</form>\n" +
                "</body>\n" +
                "</html>\n");
    }
}
  • servlet/members/save 로 HTML Form 데이터를 POST로 전송한다.

회원 저장

@WebServlet(name = "memberSaveServlet", urlPatterns = "/servlet/members/save")
public class MemberSaveServlet extends HttpServlet {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("MemberSaveServlet.service");
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        response.setContentType("text/html");
        response.setCharacterEncoding("utf-8");

        PrintWriter w = response.getWriter();
        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>");
    }
}
  • 파라미터를 조회에서 Member 객체 생성
  • MemeberRepository에 저장
  • 결과 화면 HTML 응답

회원 목록 조회

@WebServlet(name = "memberListServlet", urlPatterns = "/servlet/members")
public class MemberListServlet extends HttpServlet {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        List<Member> members = memberRepository.findAll();

        response.setContentType("text/html");
        response.setCharacterEncoding("utf-8");

        PrintWriter w = response.getWriter();
        w.write("<html>");
        w.write("<head>");
        w.write("    <meta charset=\"UTF-8\">");
        w.write("    <title>Title</title>");
        w.write("</head>");
        w.write("<body>");
        w.write("<a href=\"/index.html\">메인</a>");
        w.write("<table>");
        w.write("    <thead>");
        w.write("    <th>id</th>");
        w.write("    <th>username</th>");
        w.write("    <th>age</th>");
        w.write("    </thead>");
        w.write("    <tbody>");

        for (Member member : members) {
            w.write("    <tr>");
            w.write("        <td>" + member.getId() + "</td>");
            w.write("        <td>" + member.getUsername() + "</td>");
            w.write("        <td>" + member.getAge() + "</td>");
            w.write("    </tr>");
        }
        w.write("    </tbody>");
        w.write("</table>");
        w.write("</body>");
        w.write("</html>");
    }
}

서블릿과 자바 코드만으로 동적으로 HTML을 생성하여 응답할 수는 있었으나 자바 코드로 HTML을 만들어 내는 것은 실수할 가능성도 높고 매우 비효율적이다. 이런 이유로 JSP, Thymeleaf 등 여러 템플릿 엔진이 등장했다.

회원 관리 웹 어플리케이션(by JSP)

회원 등록 폼

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
<form action="/jsp/members/save.jsp" method="post">
    username: <input type="text" name="username" />
    age: <input type="text" name="age" />
    <button type="submit">전송</button>
</form>
</body>
</html>

회원 저장

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
    MemberRepository memberRepository = MemberRepository.getInstance();

    System.out.println("JspClass.jsp_service_method");
    String username = request.getParameter("username");
    int age = Integer.parseInt(request.getParameter("age"));

    Member member = new Member(username, age);
    memberRepository.save(member);

%>
<html>
<head>
    <title>Title</title>
    </head>
    <body>
    성공
    <ul>
        <li>id=<%=member.getId()%></li>
        <li>username=<%=member.getUsername()%></li>
        <li>age=<%=member.getAge()%></li>
</ul>
<a href="/index.html">메인</a>
</body>
</html>

회원 목록 조회

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
  MemberRepository memberRepository = MemberRepository.getInstance();
  List<Member> members = memberRepository.findAll();
%>
<html>
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>
<a href="/index.html">메인</a>
<table>
  <thead>
  <th>id</th>
  <th>username</th>
  <th>age</th>
  </thead>
  <tbody>
  <%
    for (Member member : members) {
      out.write("    <tr>");
      out.write("     <td>" + member.getId() + "</td>");
      out.write("     <td>" + member.getUsername() + "</td>");
      out.write("     <td>" + member.getAge() + "</td>");
      out.write("    </tr>");
    }
  %>
  </tbody>
</table>
</body>
</html>

서블릿과 JSP의 한계
서블릿으로 개발할 때는 뷰(View)화면을 위한 HTML을 만드는 작업이 자바 코드에 섞여서 지저분하고 복잡했다.
JSP를 사용한 덕분에 뷰를 생성하는 HTML 작업을 깔끔하게 가져가고, 중간중간 동적으로 변경이 필요한 부분에만 자바 코드를 적용했다.
회원 저장 JSP를 보자. 코드의 상위 절반은 회원을 저장하기 위한 비즈니스 로직이고, 나머지 하위 절반만 결과를 HTML로 보여주기 위한 뷰 영역이다. JSP가 너무 많은 역할을 한다.

MVC 패턴의 등장
비즈니스 로직은 서블릿 처럼 다른곳에서 처리하고, JSP는 목적에 맞게 HTML로 화면(View)을 그리는 일에 집중하도록 하기 위해 MVC 패턴이 등장했다.

MVC(Model View Controller)

  • 하나의 서블릿, JSP로 처리하던 것을 Controller와 View 영역으로 역할을 나누는 것이다.

MVC 패턴 등장의 이유

너무 많은 역할

  • 하나의 서블릿이나 JSP로 비즈니스 로직과 뷰 렌더링까지 모두 처리하는 것은 유지보수가 굉장히 어려운 일이다. 비즈니스 로직, UI 각각을 변경을 하고 싶으면 같이 담겨 있는 파일을 수정해야 하는 문제가 있다.

변경의 라이프 사이클

  • UI, 비즈니스 로직의 변경의 라이프 사이클이 다르다. UI 수정, 비즈니스 로직 수정은 각각 다르게 발생할 확률이 높고 서로에게 영향을 주지 않기에 하나의 코드로 관리하는 것은 좋지 않다.

기능 특화

  • JSP처럼 뷰 렌더링에 최적화되어 있기에 해당 업무만 맡는 것이 효과적이다.

Controller

  • HTTP 요청을 받아서 파라미터를 검증하고 비즈니스 로직을 실행한다. 이후에 뷰에 전달할 결과 데이터를 모델에 담는다.

Model

  • 뷰에 출력할 데이터를 담아둔다. 모델에 데이터가 담겨져 있기에 뷰는 비즈니스 로직을 몰라도 되고 렌더링에만 집중할 수 있다.

View

  • 모델에 담겨 있는 데이터를 사용해 HTML을 생성하며 화면을 그린다.

회원 등록 폼

@WebServlet(name = "mvcMemberFormServlet", urlPatterns = "/servlet-mvc/members/new-form")
public class MvcMemberFormServlet extends HttpServlet {
    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String viewPath = "/WEB-INF/views/new-form.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);

    }
}
  • 서블릿을 컨트롤러로, JSP를 뷰로 사용한 구조이다.
  • Model은 HttpServletRequest 객체를 사용한다. request는 내부에 데이터 저장소를 갖고 있다.

회원 저장 폼

@WebServlet(name = "mvcMemberSaveServlet", urlPatterns = "/servlet-mvc/members/save")
public class MvcMemberSaveServlet extends HttpServlet {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        request.setAttribute("member", member);

        String viewPath = "/WEB-INF/views/save-result.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}
  • /WEB-INF 이 경로 안에 JSP가 있으면 외부에서 직접 JSP를 호출할 수 없다.
  • servlet-mvc/members/save에 POST 방식으로 username, age가 전송되었고 HttpServletRequest 객체에 정보가 담겨져 있다.
  • HttpServletRequest 객체에서 getParameter로 데이터를 얻어 저장소에 저장하는 비즈니스 로직을 실행한다.
  • HttpServletRequest 객체에 뷰에 필요한 정보를 담아 JSP로 전송한다.

현재 MVC 패턴의 한계

포워드 중복

RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);

ViewPath 중복

String viewPath = "/WEB-INF/views/new-form.jsp";

사용하지 않는 코드

HttpServletRequest request, HttpServletResponse response

공통 처리가 어렵다

프론트(공통 처리) 컨트롤러가 필요하다!

V1 Controller - Front Controller 도입

  • 프론트 컨트롤러 서블릿 하나로 클라이언트의 요청을 받고 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아서 호출
  • 프론트 컨트롤러를 제외한 나머지 컨트롤러는 서블릿을 사용하지 않아도 된다.
  • Spring MVC에서는 Dispatcher Servlet이 프론트 컨트롤러로 구현되어 있다.
@WebServlet(name = "frontControllerServletV1", urlPatterns = "/front-controller/v1/*")
public class FrontControllerServletV1 extends HttpServlet {

    private Map<String, ControllerV1> controllerMap = new HashMap<>();

    public FrontControllerServletV1() {
        controllerMap.put("/front-controller/v1/members/new-form", new MemberFormControllerV1());
        controllerMap.put("/front-controller/v1/members/save", new MemberSaveControllerV1());
        controllerMap.put("/front-controller/v1/members", new MemberListControllerV1());
    }

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("FrontControllerServletV1.service");

        String requestURI = request.getRequestURI();

        ControllerV1 controller = controllerMap.get(requestURI);
        if(controller == null){
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return ;
        }

        controller.process(request, response);
    }
}

V2 Controller - View 분리

  • V1 Controller와 차이는 프론트 컨트롤러에서 호출된 각 컨트롤러들이 직접 View 렌더링을 하지 않고 프론트 컨트롤러에 View 정보를 반환하여 프론트 컨트롤러에서 View 렌더링을 시도하는 것이다.
@WebServlet(name = "frontControllerServletV2", urlPatterns = "/front-controller/v2/*")
public class FrontControllerServletV2 extends HttpServlet {

    private Map<String, ControllerV2> controllerMap = new HashMap<>();

    public FrontControllerServletV2() {
        controllerMap.put("/front-controller/v2/members/new-form", new MemberFormControllerV2());
        controllerMap.put("/front-controller/v2/members/save", new MemberSaveControllerV2());
        controllerMap.put("/front-controller/v2/members", new MemberListControllerV2());
    }

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("FrontControllerServletV2.service");

        String requestURI = request.getRequestURI();

        ControllerV2 controller = controllerMap.get(requestURI);
        if(controller == null){
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return ;
        }

        MyView view = controller.process(request, response);
        view.render(request, response);

    }
}

public class MyView {
    private String viewPath;

    public MyView(String viewPath) {
        this.viewPath = viewPath;
    }

    public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

V3 Controller - Model 분리, ViewResolver 도입

  • 각 컨트롤러는 HttpServletRequest, HttpServletResponse 객체가 굳이 필요하지 않다. 그 객체 안에 들어 있는 Parameter 정보만이 필요할 뿐이다.
@Getter
@Setter
public class ModelView {
    private String viewName;
    private Map<String, Object> model = new HashMap<>();

    public ModelView(String viewName) {
        this.viewName = viewName;
    }
}

@WebServlet(name = "frontControllerServletV3", urlPatterns = "/front-controller/v3/*")
public class FrontControllerServletV3 extends HttpServlet {

    private Map<String, ControllerV3> controllerMap = new HashMap<>();

    public FrontControllerServletV3() {
        controllerMap.put("/front-controller/v3/members/new-form", new MemberFormControllerV3());
        controllerMap.put("/front-controller/v3/members/save", new MemberSaveControllerV3());
        controllerMap.put("/front-controller/v3/members", new MemberListControllerV3());
    }

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("FrontControllerServletV3.service");

        String requestURI = request.getRequestURI();

        ControllerV3 controller = controllerMap.get(requestURI);
        if(controller == null){
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return ;
        }

        // paramMap
        Map<String, String> paramMap = createParamMap(request);
        ModelView mv = controller.process(paramMap);

        String viewName = mv.getViewName();
        MyView view = viewResolver(viewName);

        view.render(mv.getModel(), request, response);

    }

    private static MyView viewResolver(String viewName) {
        return new MyView("/WEB-INF/views/" + viewName + ".jsp");
    }

    private static 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에서 필요한 Parameter를 얻어 Map에 저장해서 각 컨트롤러에 주어 사용할 수 있도록 한다.
  • 각 컨트롤러에서는 View에 필요한 정보들을 담은 ModelView 객체를 만들어 반환하고 이를 render 함수에 넘긴다.
  • ViewResolver 메서드를 구현해 각 컨트롤러에서는 논리 이름만 반환할 수 있게 한다.

V4 Controller - 간단한 최적화

@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
	System.out.println("FrontControllerServletV3.service");

	String requestURI = request.getRequestURI();

	ControllerV4 controller = controllerMap.get(requestURI);
    if(controller == null){
		response.setStatus(HttpServletResponse.SC_NOT_FOUND);
		return ;
	}

	// paramMap
	Map<String, String> paramMap = createParamMap(request);
	Map<String, Object> model = new HashMap<>();

	String viewName = controller.process(paramMap, model);

	MyView view = viewResolver(viewName);

	view.render(model, request, response);
}


public class MemberSaveControllerV4 implements ControllerV4 {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public String process(Map<String, String> paramMap, Map<String, Object> model) {
        String username = paramMap.get("username");
        int age = Integer.parseInt(paramMap.get("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        model.put("member", member);
        return "save-result";
    }
}
  • 각 컨트롤러에서 ModelView 객체를 생성하고 viewPath와 view에 필요한 데이터들을 넣는 것을 수정했다.
  • 각 컨트롤러의 Parameter로 model을 넘겨 필요한 정보들을 받을 수 있게 하고 반환값으로는 논리 이름만을 받게 했다.

V5 Controller - 핸들러 어댑터 도입

  • 핸들러 어댑터 :중간에 어댑터를 추가해서 다양한 종류의 컨트롤러를 호출할 수 있다.
  • 핸들러 : 컨트롤러를 더 넓은 의미로 확장한 것이다.
  • 핸들러 어댑터를 사용하면 프론트 컨트롤러에서 다양한 버전의 컨트롤러를 전부 호출할 수 있다.

핸들러 어댑터

public interface MyHandlerAdapter {

    boolean supports(Object handler);

    ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException;

}

V3 핸들러 어댑터

public class ControllerV3HandlerAdapter implements MyHandlerAdapter {

    @Override
    public boolean supports(Object handler) {
        return (handler instanceof ControllerV3);
    }

    @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;
    }

    private static 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;
    }
}

V4 핸들러 어댑터

public class ControllerV4HandlerAdapter implements MyHandlerAdapter {
    @Override
    public boolean supports(Object handler) {
        return (handler instanceof ControllerV4);
    }

    @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;
    }

    private static 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;
    }
}
  • supports 메서드의 파라미터로 들어온 핸들러가 V3 혹은 V4 Interface를 구현한 객체인지 확인한다.
  • handle 메서드에서는 기존 V3, V4 Controller와 같이 사용한다.

V5 프론트 컨트롤러

@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {

    private final Map<String, Object> handlerMappingMap = new HashMap<>();
    private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();

    public FrontControllerServletV5(){
        initHandlerAdapters();
        initHandlerMappingMap();
    }

    public void initHandlerMappingMap() {
        handlerMappingMap.put("/front-controller/v5/v3/members/new-form", new MemberFormControllerV3());
        handlerMappingMap.put("/front-controller/v5/v3/members/save", new MemberSaveControllerV3());
        handlerMappingMap.put("/front-controller/v5/v3/members", new MemberListControllerV3());

        handlerMappingMap.put("/front-controller/v5/v4/members/new-form", new MemberFormControllerV4());
        handlerMappingMap.put("/front-controller/v5/v4/members/save", new MemberSaveControllerV4());
        handlerMappingMap.put("/front-controller/v5/v4/members", new MemberListControllerV4());
    }

    private void initHandlerAdapters() {
        handlerAdapters.add(new ControllerV3HandlerAdapter());
        handlerAdapters.add(new ControllerV4HandlerAdapter());
    }

    @Override
    protected void service(HttpServletRequest 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);
    }

    private MyHandlerAdapter getHandlerAdapter(Object handler) {
        for (MyHandlerAdapter adapter : handlerAdapters) {
            if (adapter.supports(handler)) {
                return adapter;
            }
        }
        throw new IllegalArgumentException("handler adapter를 찾을 수 없습니다. handler = " + handler);
    }

    private Object getHandler(HttpServletRequest request) {
        String requestURI = request.getRequestURI();
        Object handler = handlerMappingMap.get(requestURI);
        return handler;
    }

    private static MyView viewResolver(String viewName) {
        return new MyView("/WEB-INF/views/" + viewName + ".jsp");
    }
}

컨트롤러의 발전 과정을 공부했다. Spring Framework의 MVC는 위의 발전된 내용과 구조를 갖고 있다.

스프링 MVC 구조

DispatcherServlet

  • 스프링 MVC에서 프론트 컨트롤러의 역할을 하는 서블릿이다.
  • 스프링 부트는 DispatcherServlet을 서블릿으로 자동으로 등록하며 모든 경로(urlPatterns="/")에 대해 매핑한다.
  • 서블릿이 호출되면 HttpServlet의 service() -> 오버라이드된 FrameworkServlet의 service() -> DispatcherServlet의 doDispatch()의 흐름으로 메서드가 호출된다.

DispatcherServlet의 doDispatch 메서드

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
	HttpServletRequest processedRequest = request;
    HandlerExecutionChain mappedHandler = null;
    ModelAndView mv = null;

	// 1. 핸들러 조회
	mappedHandler = getHandler(processedRequest); 
    if (mappedHandler == null) {
        noHandlerFound(processedRequest, response);
		return; 
	}

	//2.핸들러 어댑터 조회-핸들러를 처리할 수 있는 어댑터
	HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());

	// 3. 핸들러 어댑터 실행 -> 4. 핸들러 어댑터를 통해 핸들러 실행 -> 5. ModelAndView 반환 
    mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
    processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
}


private void processDispatchResult(HttpServletRequest request, HttpServletResponse response, HandlerExecutionChain mappedHandler, ModelAndView mv, Exception exception) throws Exception {
   
   // 뷰 렌더링 호출
   render(mv, request, response);
}
  
protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception {
    
    View view;
    //6. 뷰 리졸버를 통해서 뷰 찾기,7.View 반환
	String viewName = mv.getViewName(); 
    view = resolveViewName(viewName, mv.getModelInternal(), locale, request);
	// 8. 뷰 렌더링
    view.render(mv.getModelInternal(), request, response);
}
  • 스프링이 제공하는 컨트롤러는 애노테이션 기반으로 동작한다.
  • @RequestMapping : 스프링이 만든 매우 유연하고 실용적인 애노테이션 기반 컨트롤러이다. RequestMapping의 핸들러 어댑터와 핸들러 매핑은 스프링에서 가장 우선순위가 높다.
    • RequestMappingHandlerMapping
    • RequestMappingHandlerAdapter

V1 Controller - 스프링 MVC 기본

회원 등록 폼

@Controller
public class SpringMemberFormControllerV1 {

    @RequestMapping("/springmvc/v1/members/new-form")
    public ModelAndView process() {
        return new ModelAndView("new-form");
    }
}

회원 저장 폼

@Controller
public class SpringMemberSaveControllerV1 {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @RequestMapping("/springmvc/v1/members/save")
    public ModelAndView process(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.addObject("member", member);

        return mv;
    }
}
  • @Controller : 어노테이션을 통해 스프링이 자동으로 스프링 빈으로 등록한다. (애노테이션 기반 컨트롤러)
    = @Component + @RequestMapping (스프링 부트 3.0부터 인식하지 않음)
  • @RequestMapping : 요청 정보를 매핑한다. 해당 URL이 호출되면 이 메서드가 호출된다.
  • ModelAndView 객체에 모델 데이터와 뷰 정보를 담아서 반환한다.

V2 Controller - 컨트롤러 통합

@Controller
@RequestMapping("/springmvc/v2/members")
public class SpringMemberControllerV2 {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @RequestMapping("/new-form")
    public ModelAndView newForm() {
        return new ModelAndView("new-form");
    }

    @RequestMapping
    public ModelAndView members() {
        List<Member> members = memberRepository.findAll();

        ModelAndView mv = new ModelAndView("members");
        mv.addObject("members", members);

        return mv;
    }

    @RequestMapping("/save")
    public ModelAndView save(HttpServletRequest request, HttpServletResponse response) {
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        ModelAndView mv = new ModelAndView("save-result");
        mv.addObject("member", member);

        return mv;
    }
}
  • @RequestMapping이 클래스 단위가 아닌 메서드 단위로 적용되므로 컨트롤러 클래스를 하나로 통합할 수 있다.

V3 Controller - 훨씬 실용적으로

@Controller
@RequestMapping("/springmvc/v3/members")
public class SpringMemberControllerV3 {

    private MemberRepository memberRepository = MemberRepository.getInstance();

	//@RequestMapping("/new-form")
    @GetMapping("/new-form")
    public String newForm() {
        return "new-form";
    }

    //@RequestMapping("/save")
    @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";
    }
}
  • Model 파라미터 도입
  • ViewName(논리 이름) 직접 반환
  • @RequestParam : GET 쿼리 파라미터, POST Form 방식 모두 지원
  • @RequestMapping -> @PostMapping + @GetMapping

Welcome 페이지
스프링 부트에서 Jar를 사용하면 /resources/static/ 위치에 index.html 파일을 두면 Welcome 페이지로 처리해준다.

@Slf4j
Slf4j 어노테이션을 사용하면 log.info() 메서드 등 편리하게 로깅을 할 수 있다.

요청 매핑

RequestMapping

/**
* 편리한 축약 애노테이션 (코드보기) * @GetMapping
* @PostMapping
* @PutMapping
* @DeleteMapping
* @PatchMapping
*/
@GetMapping(value = "/mapping-get-v2")
public String mappingGetV2() {
	log.info("mapping-get-v2");
	return "ok";
}

PathVariable

/**
* PathVariable 사용 다중
*/
@GetMapping("/mapping/users/{userId}/orders/{orderId}")
public String mappingPath(@PathVariable("userId") String userId, @PathVariable Long orderId) {
	log.info("mappingPath userId={}, orderId={}", userId, orderId);
	return "ok";
}
  • HTTP API에서 리소스 경로에 식별자를 넣는 스타일을 선호하는데 이 때 식별자 등을 쉽게 조회할 수 있는 방식이다.
  • @PathVariable의 이름과 파라미터 이름이 같으면 생략할 수 있다.

특정 파라미터 매핑

/**
 * 파라미터로 추가 매핑
 * params="mode",
 * params="!mode"
 * params="mode=debug"
 * params="mode!=debug" (! = )
 * params = {"mode=debug","data=good"}
 */
@GetMapping(value = "/mapping-param", params = "mode=debug")
public String mappingParam() {
	log.info("mappingParam");
	return "ok";
}

특정 헤더 매핑

/**
 *특정 헤더로 추가 매핑
 * headers="mode",
 * headers="!mode"
 * headers="mode=debug"
 * headers="mode!=debug" (! = )
 */
@GetMapping(value = "/mapping-header", headers = "mode=debug")
public String mappingHeader() {
	log.info("mappingHeader");
	return "ok";
}
  • 파라미터 매핑과 비슷하지만, HTTP 헤더를 사용한다.

미디어 타입 조건 매핑

/**
 * Accept 헤더 기반 Media Type * produces = "text/html"
 * produces = "!text/html"
 * produces = "text/*"
 * produces = "*\/*"
 */
@PostMapping(value = "/mapping-produce", produces = "text/html")
public String mappingProduces() {
	log.info("mappingProduces");
	return "ok";
}

API 매핑 예시

  • 회원 관리 API
    • 회원 목록 조회: GET /users
    • 회원 등록: POST /users
    • 회원 조회: GET /users/{userId}
    • 회원수정: PATCH /users/{userId}
    • 회원 삭제: DELETE /users/{userId}
@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 String userId) {
        return "get userId=" + userId;
    }

    @PatchMapping("{userId}")
    public String updateUser(@PathVariable String userId) {
        return "update userId=" + userId;
    }

    @DeleteMapping("{userId}")
    public String deleteUser(@PathVariable String userId) {
        return "delete userId=" + userId;
    }
}

HTTP 요청 데이터 조회

GET 메서드 쿼리 파라미터 전송 & POST 메서드 HTML Form 전송

  • 두 방식 모두 형식이 같으므로 구분 없이 조회할 수 있다.

HttpServletRequest.getRequest()

@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가 제공하는 방식으로 조회할 수 있다.

@RequestParam

/**
 * @RequestParam 사용
 * - 파라미터 이름으로 바인딩
 * @ResponseBody 추가
 * - View 조회를 무시하고, HTTP message body에 직접 해당 내용 입력 
 */
@ResponseBody
@RequestMapping("/request-param-v2")
public String requestParamV2(
	@RequestParam("username") String memberName,
	@RequestParam("age") int memberAge) {
    
    log.info("username={}, age={}", memberName, memberAge);
    return "ok";
}

/**
 * @RequestParam 사용
 * HTTP 파라미터 이름이 변수 이름과 같으면 @RequestParam(name="xx") 생략 가능 
 */
@ResponseBody
@RequestMapping("/request-param-v3")
public String requestParamV3(
		@RequestParam String username,
		@RequestParam int age) {
	log.info("username={}, age={}", username, age);
	return "ok";
}

/**
 * @RequestParam 사용
 * String, int 등의 단순 타입이면 @RequestParam 도 생략 가능 
 */
@ResponseBody
@RequestMapping("/request-param-v4")
public String requestParamV4(String username, int age) {
	log.info("username={}, age={}", username, age);
	return "ok";
}

/**
 * @RequestParam
 * - defaultValue 사용 *
 * 참고: defaultValue는 빈 문자의 경우에도 적용 * /request-param-default?username=
 */
 
@ResponseBody
@RequestMapping("/request-param-default")
public String requestParamDefault(
		@RequestParam(required = true, defaultValue = "guest") String username,
		@RequestParam(required = false, defaultValue = "-1") int age) {
	log.info("username={}, age={}", username, age);
    return "ok";
}
  
/**
 * @RequestParam Map, MultiValueMap
 * Map(key=value)
 * MultiValueMap(key=[value1, value2, ...]) ex) (key=userIds, value=[id1, id2])
 */
@ResponseBody
@RequestMapping("/request-param-map")
public String requestParamMap(@RequestParam Map<String, Object> paramMap) {
      
      log.info("username={}, age={}", paramMap.get("username"), paramMap.get("age"));
      return "ok";
  }

@ModelAttribute

  • 실제 개발에서는 요청 파라미터를 받아서 필요한 객체를 만들고 그 객체에 값을 넣어서 사용할 것이다. 스프링이 제공하는 @ModelAttribute은 이 과정을 완전히 자동화해준다.
  • 객체 생성 -> 요청 파라미터의 이름으로 객체의 프로퍼티를 찾는다. -> 해당 프로퍼티의 setter 호출
객체 생성
@Data
public class HelloData {
    private String username;
    private int age;
}

-Lombok의 @Data 어노테이션은 @Getter, @Setter, @ToString, @EqualsAndHashCode, @RequiredArgsConstructor 를 자동으로 적용해준다.

@ModelAttribute V1
/**
 * @ModelAttribute 사용
 */
@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@ModelAttribute HelloData helloData) {
	log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
	return "ok";
}
  • HelloData 객체가 자동으로 생성되고 프로퍼티(getter, setter)를 통해 파라미터의 값을 입력한다.
@ModelAttribute V2
/**
 * @ModelAttribute 생략 가능
 * String, int 같은 단순 타입 = @RequestParam
 * argument resolver 로 지정해둔 타입 외 = @ModelAttribute 
 */
@ResponseBody
@RequestMapping("/model-attribute-v2")
public String modelAttributeV2(HelloData helloData) {
	log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
	return "ok";
}
  • @ModelAttribute도 생략가능하다.

규칙

  • String, int , Integer 같은 단순 타입 : @RequestParam
  • 나머지 : @ModelAttribute

HTTP 요청 데이터 - 단순 텍스트

  • 요청 파라미터와 다르게 HTTP message body를 통해 데이터가 넘어오는 경우는 @RequestParam, @ModelAttribute를 사용할 수 없다.
  • HTTP 메세지 바디의 데이터를 InputStream을 사용해서 직접 읽을 수 있다.

InputStream V1

@Slf4j
@Controller
public class RequestBodyStringController {
      
	@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");
	} 
}

InputStream V2

/**
 * InputStream(Reader): HTTP 요청 메시지 바디의 내용을 직접 조회 
 * OutputStream(Writer): HTTP 응답 메시지의 바디에 직접 결과 출력 
 */
@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");
}
  • 스프링 MVC가 다음 파라미터를 지원
  • InputStream(Reader): HTTP 요청 메시지 바디의 내용을 직접 조회
  • OutputStream(Writer): HTTP 응답 메시지의 바디에 직접 결과 출력

HttpEntity

/**
 * HttpEntity: HTTP header, body 정보를 편리하게 조회
 * - 메시지 바디 정보를 직접 조회(@RequestParam X, @ModelAttribute X)
 * - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용 *
 * 응답에서도 HttpEntity 사용 가능
 *
 * - 메시지 바디 정보 직접 반환(view 조회X)
 *- HttpMessageConverter 사용 -> StringHttpMessageConverter 적용
 */
@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) {
	String messageBody = httpEntity.getBody();
	log.info("messageBody={}", messageBody);
	return new HttpEntity<>("ok");
}

@RequestBody

/**
 * @RequestBody
 * - 메시지 바디 정보를 직접 조회(@RequestParam X, @ModelAttribute X)
 * - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용 *
 * @ResponseBody
 * - 메시지 바디 정보 직접 반환(view 조회X)
 * - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용 */
@ResponseBody
@PostMapping("/request-body-string-v4")
public String requestBodyStringV4(@RequestBody String messageBody) {
	log.info("messageBody={}", messageBody);
	return "ok";
}

HTTP 요청 데이터 - JSON

기본적인 형태

/**
 * {"username":"hello", "age":20}
 * content-type: application/json
 */
@Slf4j
@Controller
public class RequestBodyJsonController {
	private ObjectMapper objectMapper = new ObjectMapper();
	
    @PostMapping("/request-body-json-v1")
	public void requestBodyJsonV1(HttpServletRequest request, HttpServletResponse response) throws IOException {
		ServletInputStream inputStream = request.getInputStream();
		String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
		log.info("messageBody={}", messageBody);
		HelloData data = objectMapper.readValue(messageBody, HelloData.class);
		log.info("username={}, age={}", data.getUsername(), data.getAge());
		response.getWriter().write("ok");
	}
}

@RequestBody V1

/**
 * @RequestBody
 * HttpMessageConverter 사용 -> StringHttpMessageConverter 적용 *
 * @ResponseBody
 * - 모든 메서드에 @ResponseBody 적용
 * - 메시지 바디 정보 직접 반환(view 조회X)
 * - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용 */
@ResponseBody
@PostMapping("/request-body-json-v2")
public String requestBodyJsonV2(@RequestBody String messageBody) throws IOException {
	HelloData data = objectMapper.readValue(messageBody, HelloData.class);
	log.info("username={}, age={}", data.getUsername(), data.getAge());
	return "ok";
}

@RequestBody V2

/**
 * @RequestBody 생략 불가능(@ModelAttribute 가 적용되어 버림)
 * HttpMessageConverter 사용 -> MappingJackson2HttpMessageConverter (content-type: application/json)
 *
 */
@ResponseBody
@PostMapping("/request-body-json-v3")
public String requestBodyJsonV3(@RequestBody HelloData data) {
	log.info("username={}, age={}", data.getUsername(), data.getAge());
	return "ok";
}

@RequestBody & ResponseBody

/**
 * @RequestBody 생략 불가능(@ModelAttribute 가 적용되어 버림)
 * HttpMessageConverter 사용 -> MappingJackson2HttpMessageConverter (content-type: application/json)
 *
 * @ResponseBody 적용
 * - 메시지 바디 정보 직접 반환(view 조회X)
 * - HttpMessageConverter 사용 -> MappingJackson2HttpMessageConverter 적용 (Accept: application/json)
 */
@ResponseBody
@PostMapping("/request-body-json-v5")
public HelloData requestBodyJsonV5(@RequestBody HelloData data) {
	log.info("username={}, age={}", data.getUsername(), data.getAge());
	return data;
}
  • @RequestBody : JSON 요청 -> HTTP 메세지 컨버터 -> 객체
  • @ResponseBody : 객체 -> HTTP 메세지 컨버터 -> JSON 응답

HTTP 응답

정적 리소스

뷰 템플릿

  • 뷰 템플릿을 거쳐서 HTML이 생서되고, 뷰가 응답을 만들어서 전달한다.
  • 뷰 템플릿 경로 : src/main/resources/templates
@Controller
public class ResponseViewController {

    @RequestMapping("/response-view-v1")
    public ModelAndView responseViewV1() {
        ModelAndView mav = new ModelAndView("response/hello").addObject("data", "hello!");

        return mav;
    }

    @RequestMapping("/response-view-v2")
    public String responseViewV2(Model model) {
        model.addAttribute("data", "hello!");

        return "response/hello";
    }

    @RequestMapping("/response/hello")
    public void responseViewV3(Model model) {
        model.addAttribute("data", "hello!");
    }
}

Thymeleaf 스프링 부트 설정

  • 스프링 부트가 자동으로 ThymeleafViewResolver와 필요한 스프링 빈들을 등록한다.
    spring.thymeleaf.prefix=classpath:/templates/
    spring.thymeleaf.suffix=.html
  • 위 설정이 기본값이고 변경할 수 있다.

String 반환

  • @ResponseBody가 있으면 뷰 리졸버를 실행하지 않고 HTTP 메세지 바디에 직접 String 입력
  • 논리 이름을 통해 뷰 리졸버를 실행하고 뷰를 찾고 렌더링한다.

HTTP API, 메세지 바디에 직접 입력

@Slf4j
@Controller
public class ResponseBodyController {

    @GetMapping("/response-body-string-v1")
    public void responseBodyV1(HttpServletResponse response) throws IOException {
        response.getWriter().write("ok");
    }

    @GetMapping("/response-body-string-v2")
    public ResponseEntity<String> responseBodyV2() throws IOException {
        return new ResponseEntity<>("ok", HttpStatus.OK);
    }

    @ResponseBody
    @GetMapping("/response-body-string-v3")
    public String responseBodyV3(){
        return "ok";
    }

    @GetMapping("/response-body-json-v1")
    public ResponseEntity<HelloData> responseBodyJsonV1(){
        HelloData helloData = new HelloData();
        helloData.setUsername("userA");
        helloData.setAge(20);

        return new ResponseEntity<>(helloData, HttpStatus.OK);
    }

    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    @GetMapping("/response-body-json-v2")
    public HelloData responseBodyJsonV2(){
        HelloData helloData = new HelloData();
        helloData.setUsername("userA");
        helloData.setAge(20);

        return helloData;
    }
}

HTTP 메세지 컨버터

  • 스프링 MVC는 다음의 경우에 HTTP 메세지 컨버터를 적용한다.
  • HTTP 요청 : @RequestBody, HttpEntity(RequestEntity)
  • HTTP 응답 : @ResponseBody, HttpEntity(ResponseEntity)
주요 메세지 컨버터
  • ByteArrayHttpMessageConverter : byte[] 데이터를 처리한다.

    • 클래스 타입: byte[] , 미디어타입: /
    • 요청 예) @RequestBody byte[] data
    • 응답 예) @ResponseBody return byte[] 쓰기 미디어타입 application/octet-stream
  • StringHttpMessageConverter : String 문자로 데이터를 처리한다.

    • 클래스 타입: String , 미디어타입: /
    • 요청 예) @RequestBody String data
    • 응답 예) @ResponseBody return "ok" 쓰기 미디어타입 text/plain
  • MappingJackson2HttpMessageConverter : application/json

    • 클래스 타입: 객체 또는 HashMap , 미디어타입 application/json 관련
    • 요청 예) @RequestBody HelloData data
    • 응답 예) @ResponseBody return helloData 쓰기 미디어타입 application/json 관련

HTTP 요청 데이터 읽기

  • HTTP 요청이 오고, 컨트롤러에서 @RequestBody , HttpEntity 파라미터를 사용한다.
  • 메시지 컨버터가 메시지를 읽을 수 있는지 확인하기 위해 canRead() 를 호출한다.
    • 대상 클래스 타입을 지원하는가. 예) @RequestBody 의 대상 클래스 ( byte[] , String , HelloData )
    • HTTP 요청의 Content-Type 미디어 타입을 지원하는가. 예) text/plain , application/json , /
  • canRead() 조건을 만족하면 read() 를 호출해서 객체 생성하고, 반환한다.

HTTP 응답 데이터 생성

  • 컨트롤러에서 @ResponseBody , HttpEntity 로 값이 반환된다.
  • 메시지 컨버터가 메시지를 쓸 수 있는지 확인하기 위해 canWrite() 를 호출한다.
    • 대상 클래스 타입을 지원하는가. 예) return의 대상 클래스 ( byte[] , String , HelloData )
    • HTTP 요청의 Accept 미디어 타입을 지원하는가.(더 정확히는 @RequestMapping 의 produces ) 예) text/plain , application/json , /
  • canWrite() 조건을 만족하면 write() 를 호출해서 HTTP 응답 메시지 바디에 데이터를 생성한다.

요청 매핑 핸들러 어댑터 구조

  • HTTP 메세지 컨버터는 스프링 MVC 구조 어디에서 사용되는가 ?

  • 애노테이션 기반 컨트롤러를 처리하는 RequestMappingHandlerAdapter 는 바로 이 ArgumentResolver 를 호출해서 컨트롤러(핸들러)가 필요로 하는 다양한 파라미터의 값(객체)을 생성한다. 그리고 이렇게 파리미터의 값이 모두 준비되면 컨트롤러를 호출하면서 값을 넘겨준다.

  • 요청의 경우 @RequestBody 를 처리하는 ArgumentResolver 가 있고, HttpEntity 를 처리하는 ArgumentResolver 가 있다. 이 ArgumentResolver 들이 HTTP 메시지 컨버터를 사용해서 필요한 객체를 생성하는 것이다.

  • 응답의 경우 @ResponseBody 와 HttpEntity 를 처리하는 ReturnValueHandler 가 있다. 그리고 여기에서 HTTP 메시지 컨버터를 호출해서 응답 결과를 만든다.

  • 스프링 MVC는 @RequestBody @ResponseBody 가 있으 RequestResponseBodyMethodProcessor (ArgumentResolver)
    HttpEntity 가 있으면 HttpEntityMethodProcessor (ArgumentResolver)를 사용한다.

스프링 MVC 구조로 만드는 웹 페이지

HTML, css 파일

정적 리소스

/resources/static/css/bootstrap.min.css
/resources/static/html/items.html
/resources/static/html/item.html
/resources/static/html/addForm.html
/resources/static/html/editForm.html

  • resources/static에 넣어두었기 때문에 스프링 부트가 정적 리소스를 제공한다.
  • 정적 리소스이기에 탐색기를 통해 직접 열어도 동작한다. http://localhost:8080/html/item.html로 접근할 수 있다.

View Templates

/resources/templates/html/items.html
/resources/templates/html/items.html
/resources/templates/html/item.html
/resources/templates/html/addForm.html
/resources/templates/html/editForm.html

-/templates 에 넣어두어 서버 사이드에서 뷰를 렌더링할 수 있다. (SSR)

요구사항

  • 상품 도메인
    • 상품 ID
    • 상품명
    • 가격
    • 수량
  • 상품 관리 기능
    • 상품 목록
    • 상품 상세
    • 상품 등록
    • 상품 수정

상품 도메인

@Getter
@Setter
public class Item {
    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

상품 Repository

@Repository
public class ItemRepository {

    private static final Map<Long, Item> store = new HashMap<>();
    private static long sequence = 0L;

    public Item save(Item item) {
        item.setId(++sequence);
        store.put(item.getId(), item);

        return item;
    }

    public Item findById(Long id) {
        return store.get(id);
    }

    public List<Item> findAll() {
        return new ArrayList<>(store.values());
    }

    public void update(Long itemId, Item updateParam) {
        Item findItem = findById(itemId);
        findItem.setItemName(updateParam.getItemName());
        findItem.setPrice(updateParam.getPrice());
        findItem.setQuantity(updateParam.getQuantity());
    }

    public void clearStore() {
        store.clear();
    }
}

BasicItemController

상품 전체 출력

@GetMapping
public String items(Model model) {
	List<Item> items = itemRepository.findAll();
	model.addAttribute("items",items);

	return "basic/items";
}

basic/itmes.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="utf-8">
  <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
</head>
<body>

<div class="container" style="max-width: 600px">
  <div class="py-5 text-center">
    <h2>상품 목록</h2> </div>
  <div class="row">
    <div class="col">
      <button class="btn btn-primary float-end" onclick="location.href='addForm.html'"
              th:onclick="|location.href='@{/basic/items/add}'|"
              type="button">상품 등록</button> </div>
  </div>
  <hr class="my-4">
  <div>
    <table class="table">
      <thead>
      <tr>
        <th>ID</th> <th>상품명</th> <th>가격</th> <th>수량</th>
      </tr>
      </thead>
      <tbody>
      <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/${item.id}|}" th:text="${item.itemName}">상품명</a></td>
        <td th:text="${item.price}">10000</td>
        <td th:text="${item.quantity}">10</td>
      </tr>
      </tbody>
    </table>
  </div>
</div> <!-- /container -->
</body>
</html>
  • items.html에서 th:each 타임리프 문법을 통해 model의 'items'에 들어있는 정보들을 화면에 출력해준다.

상품 개별 출력

@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
	Item item = itemRepository.findById(itemId);
	model.addAttribute("item", item);
	return "basic/item";
}
  • @PathVariable을 통해 itemId를 받고 DB(현재는 메모리)에서 해당 item을 찾아 model에 넣은 후 basic/item.html 뷰를 렌더링하러 간다.
<!DOCTYPE HTML>
<html>
<head>
  <meta charset="utf-8">
  <link th:href="@{/css/bootstrap.min.css}"
        href="../css/bootstrap.min.css" rel="stylesheet">
  <style>
    .container {
      max-width: 560px;
    } </style>
</head>
<body>
<div class="container">
  <div class="py-5 text-center">
    <h2>상품 상세</h2> </div>
  <div>

    <h2 th:if="${param.status}" th:text="'저장 완료'"></h2>

    <label for="itemId">상품 ID</label>
    <input type="text" id="itemId" name="itemId" class="form-control"
           value="1" th:value="${item.id}"readonly>
  </div> <div>
  <label for="itemName">상품명</label>
  <input type="text" id="itemName" name="itemName" class="form-control"
         value="상품A" th:value="${item.itemName}"readonly> </div>
  <div>
    <label for="price">가격</label>
    <input type="text" id="price" name="price" class="form-control"
           value="10000" th:value="${item.price}" readonly>
  </div> <div>
  <label for="quantity">수량</label>
  <input type="text" id="quantity" name="quantity" class="form-control"
         value="10" th:value="${item.quantity}" readonly>
</div>
  <hr class="my-4">
  <div class="row">
    <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>
    <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>
  </div>
</div> <!-- /container -->
</body>
</html>
  • th:value="${item.id}", 모델에 있는 item 정보를 획득하고 프로퍼티 접근법(item.getId()}으로 출력한다.
  • value 속성을 th:value 속성으로 변경한다.

상품 등록

@GetMapping("/add")
public String addForm() {
	return "/basic/addForm";
}
<!DOCTYPE HTML>
<html>
<head>
  <meta charset="utf-8">
  <link th:href="@{/css/bootstrap.min.css}"
        href="../css/bootstrap.min.css" rel="stylesheet">
  <style>
    .container {
      max-width: 560px;
    } </style>
</head>
<body>
<div class="container">
  <div class="py-5 text-center">
    <h2>상품 등록 폼</h2> </div>
  <h4 class="mb-3">상품 입력</h4>

  <form action="item.html" th:action="@{/basic/items/add}" method="post">
    <div>
      <label for="itemName">상품명</label>
      <input type="text" id="itemName" name="itemName" class="form-control" placeholder="이름을 입력하세요"> </div>
    <div>
      <label for="price">가격</label>
      <input type="text" id="price" name="price" class="form-control" placeholder="가격을 입력하세요">
    </div>
    <div>
      <label for="quantity">수량</label>
      <input type="text" id="quantity" name="quantity" class="form-control" placeholder="수량을 입력하세요"> </div>
    <hr class="my-4">

    <div class="row">
      <div class="col">
        <button class="w-100 btn btn-primary btn-lg" type="submit">상품 등록</button>
      </div>
      <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>
    </div>
  </form>
</div> <!-- /container -->
</body>
</html>
  • 상품 등록을 누르면 상품 등록과 같은 URL이 호출되지만 POST 방식이기에 다른 컨트롤러를 호출할 수 있어 하나의 URL로 등록 폼과, 등록 처리를 깔끔하게 처리할 수 있다.

상품 등록 처리

@PostMapping("/add")
public String addItemV1(@RequestParam String itemName,
                       @RequestParam int price,
                       @RequestParam Integer quantity,
                       Model model){
	Item item = new Item(itemName, price, quantity);

	itemRepository.save(item);

	model.addAttribute("item", item);

	return "basic/item";
}
  • 가장 기본적으로 @RequestParam을 통해 넘어온 요청 파라미터들을 받아 DB(메모리)에 저장한다.
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item, Model model){
	itemRepository.save(item);

	model.addAttribute("item", item);

	return "basic/item";
}
  • @ModelAttribute는 Item 객체를 생성하고, 요청 파라미터의 값을 프로퍼티 접근법(Set)으로 입력해준다.
@PostMapping("/add")
public String addItemV3(@ModelAttribute("item") Item item){
	itemRepository.save(item);

	//model.addAttribute("item", item);

	return "basic/item";
}
  • @ModelAttribute의 중요한 기능 중 하나는 Model에 @ModelAttribute로 지정한 객체를 자동으로 넣어준다는 것이다. model.addAttribute가 생략되어도 잘 동작한다.
@PostMapping("/add")
public String addItemV4(Item item){
	itemRepository.save(item);
	return "basic/item";
}
  • @ModelAttribute가 생략되어도 잘 동작한다.

상품 수정

@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
	Item item = itemRepository.findById(itemId);
	model.addAttribute("item", item);
	return "basic/editForm";
}
  • 상품 등록과 유사하게 진행된다.
<!DOCTYPE HTML>
<html>
<head>
  <meta charset="utf-8">
  <link th:href="@{/css/bootstrap.min.css}"
        href="../css/bootstrap.min.css" rel="stylesheet">
  <style>
    .container {
      max-width: 560px;
    }
  </style>
</head>
<body>
<div class="container">
  <div class="py-5 text-center"> <h2>상품 수정 폼</h2>
  </div>
  <form action="item.html" th:action method="post">
    <div>
      <label for="id">상품 ID</label>
      <input type="text" id="id" name="id" class="form-control" value="1" th:value="${item.id}" readonly>
    </div>
    <div>
      <label for="itemName">상품명</label>
      <input type="text" id="itemName" name="itemName" class="form-control" value="상품A" th:value="${item.itemName}">
    </div> <div>
    <label for="price">가격</label>
    <input type="text" id="price" name="price" class="form-control" value="10000" th:value="${item.price}">
  </div> <div>
    <label for="quantity">수량</label>
    <input type="text" id="quantity" name="quantity" class="form-control" value="10" th:value="${item.quantity}">
  </div>
    <hr class="my-4">
    <div class="row">
      <div class="col">
        <button class="w-100 btn btn-primary btn-lg" type="submit">저장
        </button>
      </div>
      <div class="col">
        <button class="w-100 btn btn-secondary btn-lg"
                onclick="location.href='item.html'"
                th:onclick="|location.href='@/{/basic/items/{itemId}(itemId=${item.id})}'|"
                type="button">취소</button> </div>
    </div>
  </form>
</div> <!-- /container -->
</body>
</html>

상품 등록 처리

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
	itemRepository.update(itemId, item);
	return "redirect:/basic/items/{itemId}";
}
  • redirect는 마지막에 뷰 템플릿을 호출하지 않고 상품 상세 화면으로 바로 이동할 수 있게 도와준다.

PRG : Post/Redirect/Get

  • 웹 브라우저의 새로고침은 마지막에 서버에 전송한 데이터를 다시 전송한다. 상품 등록 폼에서 데이터를 입력하고 저장을 선택하면 POST /add + 상품 데이터를 서버에 전송하는데 이 때 새로고침을 하면 계속 서버로 데이터를 전송하고 내용이 같은 상품 데이터가 계속 쌓이게 된다.

  • 리다이렉트의 영향으로 마지막에 호출한 내용이 상품 상세 화면인 GET /items/{id}가 되므로 이젠 새로고침을 해도 상품 상세 화면으로 이동하게 되므로 새로고침 문제를 해결할 수 있다.
@PostMapping("/add")
public String addItemV5(Item item){
	itemRepository.save(item);
	return "redirect:/basic/items/" + item.getId();
}
@PostMapping("/add")
public String addItemV6(Item item, RedirectAttributes redirectAttributes){
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/basic/items/{itemId}";
}
profile
서강대학교 컴퓨터공학과

0개의 댓글