스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 정리

Jim·2023년 6월 28일
1

스프링

목록 보기
2/10
post-thumbnail

1. 웹 애플리케이션 이해

1. 웹 서버, 웹 애플리케이션 서버(WAS)

  • 웹 서버는 정적 리소스(파일), WAS는 애플리케이션 로직
  • 둘의 용어도 경계도 모호
    • 웹 서버도 프로그램을 실행하는 기능을 포함하기도 함
    • 웹 애플리케이션 서버도 웹 서버의 기능을 제공함
  • 자바는 서블릿 컨테이너 기능을 제공하면 WAS
    • 서블릿 없이 자바코드를 실행하는 서버 프레임워크도 있음
  • WAS는 애플리케이션 코드를 실행하는데 더 특화

2. 웹 시스템 구성 - WAS, DB

  • WAS가 너무 많은 역할을 담당, 서버 과부하 우려
  • 가장 비싼 애플리케이션 로직이 정적 리소스 때문에 수행이 어려울 수 있음
  • WAS 장애시 오류 화면도 노출 불가능

3. 웹 시스템 구성 - WEB, WAS, DB

  • 효율적인 리소스 관리
    • 정적 리소스가 많이 사용되면 -> Web 서버 증설
    • 애플리케이션 리소스가 많이 사용되면 -> WAS 증설
  • WAS, DB 장애시 WEB 서버가 오류 화면 제공 가능

4. 서블릿

서버에서 처리해야 하는 업무 (서블릿을 지원하는 WAS사용시)

특징

@WebServlet(name = "helloServlet", urlPatterns = "/hello") 
public class HelloServlet extends HttpServlet { 
	@Override 
 	protected void service(HttpServletRequest request,
    						HttpServletResponse response){ 
    //애플리케이션 로직
    } 
}
  • urlPatterns(/hello)의 URL이 호출되면 서블릿 코드가 실행
  • HTTP 요청 정보를 편리하게 사용할 수 있는 HttpServletRequest
  • Http 응답 정보를 편리하게 제공할 수 있는 HttpServletResponse
  • 개발자는 HTTP 스펙을 매우 편리하게 사용

HTTP 요청, 응답 흐름

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

서블릿 컨테이너

  • 톰캣처럼 서블릿을 지원하는 WAS를 서블릿 컨테이너라고 함
  • 서블릿 컨테이너는 서블릿 객체를 생성, 초기화, 호출, 종료하는 생명주기 관리
  • 서블릿 객체는 싱글톤으로 관리
    • 최초 로딩 시점에 서블릿 객체를 미리 만들어두고 재활용
    • 공유 변수 사용 주의
    • 서블릿 컨테이너 종료시 함께 종료
  • JSP도 서블릿으로 변환 되어서 사요
  • 동시 요청을 위한 멀티 쓰레드 처리 지원

5. 동시 요청 - 멀티 쓰레드

쓰레드 풀 - 요청 마다 쓰레드 생성의 단점 보완

  • 특징

    • 필요한 쓰레드를 쓰레드 풀에 보관하고 관리한다.
    • 쓰레드 풀에 생성 가능한 쓰레드의 최대치를 관리한다. 톰캣은 최대 200개 기본 설정 (변경 가능)
  • 사용

    • 쓰레드가 필요하면, 이미 생성되어 있는 쓰레드를 쓰레드 풀에서 꺼내서 사용한다.
    • 사용을 종료하면 쓰레드 풀에 해당 쓰레드를 반납한다.
    • 최대 쓰레드가 모두 사용중이어서 쓰레드 풀에 쓰레드가 없으면?
      -> 기다리는 요청은 거절하거나 특정 숫자만큼만 대기하도록 설정할 수 있다.
  • 장점

    • 쓰레드가 미리 생성되어 있으므로, 쓰레드를 생성하고 종료하는 비용(CPU)이 절약되고, 응답 시간이 빠르다.
    • 생성 가능한 쓰레드의 최대치가 있으므로 너무 많은 요청이 들어와도 기존 요청은 안전하게 처리할 수 있다.

쓰레드 풀 - 실무 팁

  • WAS의 주요 튜닝 포인트는 최대 쓰레드(max thread) 수이다.
  • 이 값을 너무 낮게 설정하면?
    -> 동시 요청이 많으면, 서버 리소스는 여유롭지만, 클라이언트는 금방 응답 지연
  • 이 값을 너무 높게 설정하면?
    -> 동시 요청이 많으면, CPU, 메모리 리소스 임계점 초과로 서버 다운
  • 장애 발생시?
    -> 클라우드면 일단 서버부터 늘리고, 이후에 튜닝. 아니면 열심히 튜닝
  • 애플리케이션 로직의 복잡도, CPU, 메모리, IO 리소스 상황에 따라 쓰레드 풀의 적정 숫자는 모두 다름
  • 성능 테스트
    • 최대한 실제 서비스와 유사하게 성능 테스트 시도
    • 툴 : 아파치 ab, 제이미터, nGrinder

6. WAS의 멀티 쓰레드 지원

  • 멀티 쓰레드에 대한 부분은 WAS가 처리
  • 개발자가 멀티 쓰레드 관련 코드를 신경쓰지 않아도 됨
  • 개발자는 마치 싱글 쓰레드 프로그래밍을 하듯이 편리하게 소스 코드를 개발
  • 멀티 쓰레드 환경이므로 싱글톤 객체(서블릿, 스프링 빈)는 주의 해서 사용

7. HTTP API

  • 주로 JSON 형태로 데이터 통신

  • UI 클라이언트 접점

    • 앱 클라이언트 (아이폰, 안드로이드, PC 앱)
    • 웹 브라우저에서 자바스크립트를 통한 HTTP API 호출
    • React, Vue.js 같은 웹 클라이언트
  • 서버 to 서버

    • 주문 서버 -> 결제 서버
    • 기업간 데이터 통신

8. SSR, CSR

SSR - 서버 사이드 렌더링

  • 서버에서 최종 HTML을 생성해서 클라이언트에 전달

CSR - 클라이언트 사이드 렌더링

  • HTML(내용X, 자바스크립트 링크) 요청 -> 자바스크립트 요청(클라이언트 로직, HTML 렌더링 코드) -> HTTP API - 데이터 요청 -> 자바스크립트로 HTML 결과 렌더링

9. 자바 웹 최신 기술

Web Servlet - Spring MVC

Web Reactive - Spring WebFlux

  • 비동기 넌 블러킹 처리
  • 최소 쓰레드로 최대 성능 - 쓰레드 컨텍스트 스위칭 비용 효율화
  • 함수형 스타일로 개발 - 동시처리 코드 효율화
  • 서블릿 기술 사용X
  • 웹 플럭스는 기술적 난이도 매우 높음
  • 아직은 RDB 지원 부족
  • 일반 MVC의 쓰레드 모델도 충분히 빠르다.

2. 서블릿

1. 스프링 부트 서블릿 환경 구성

  • JSP를 실행하기 위해 Packaging은 Jar가 아닌 War를 선택
  • 스프링 부트는 서블릿을 직접 등록해서 사용할 수 있도록 @ServletComponentScan을 지원 한다. -> @SpringBootApplication과 같이 등록
  • @WebServlet 서블릿 애노테이션
    • name: 서블릿 이름
    • urlPatterns: URL 매핑
  • HTTP 요청을 통해 매핑된 URL이 호출되면 서블릿 컨테이너는
    protected void service(HttpServletRequest request, HttpServletResponse response) 메서드를 실행
  • application.properties 에 다음 설정을 추가하면 서버가 받은 HTTP 요청 메시지를 출력한다.
logging.level.org.apache.coyote.http11=debug
  • HTTP 응답에서 Content-Length는 웹 애플리케이션 서버가 자동으로 생성
  • webapp경로에 index.html을 두면 http://localhost:8080 호출시 index.html페이지가 열린다.

2. HttpServletRequest

  • HTTP 요청 메시지를 개발자가 직접 파싱해서 사용해도 되지만, 매우 불편할 것이다. 서블릿은 개발자가 HTTP 요청 메시지를 편리하게 사용할 수 있도록 개발자 대신에 HTTP 요청 메시지를 파싱한다. 그리고 그 결과를 HttpServletRequest객체에 담아서 제공
  • 해당 HTTP 요청이 시작부터 끝날 때 까지 유지되는 임시 저장소 기능
    • 저장: request.setAttribute(name, value)
    • 조회: request.getAttribute(name)
  • 세션 관리 기능
    • request.getSession(create: true)

start-line 정보

//http://localhost:8080/request-header?username=hello

private void printStartLine(HttpServletRequest request) {
        System.out.println("--- REQUEST-LINE - start ---");
        
        System.out.println("request.getMethod() = " +
        	request.getMethod()); //GET
        System.out.println("request.getProtocol() = " +
        	request.getProtocol()); //HTTP/1.1
        System.out.println("request.getScheme() = " +
        	request.getScheme()); //http
        
        // http://localhost:8080/request-header
        System.out.println("request.getRequestURL() = " +
        	request.getRequestURL());
        
        // /request-header
        System.out.println("request.getRequestURI() = " +
        	request.getRequestURI());
        
        // username=hello
        System.out.println("request.getQueryString() = " +
                request.getQueryString());
                
        // false
        System.out.println("request.isSecure() = " + 
        	request.isSecure()); //https 사용 유무
            
        System.out.println("--- REQUEST-LINE - end ---");
        System.out.println();
    }

헤더 정보

//Header 모든 정보
private void printHeaders(HttpServletRequest request) {
        System.out.println("--- Headers - start ---");

/*
        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 + ": "
                            	+ request.getHeader(headerName)));

        System.out.println("--- Headers - end ---");
        System.out.println();
    }
//Header 편리한 조회
private void printHeaderUtils(HttpServletRequest request) {
        System.out.println("--- Header 편의 조회 start ---");
        
        System.out.println("[Host 편의 조회]");
        System.out.println("request.getServerName() = " +
                request.getServerName()); //Host 헤더
        System.out.println("request.getServerPort() = " +
                request.getServerPort()); //Host 헤더
        System.out.println();
        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();
        System.out.println("[cookie 편의 조회]");
        if (request.getCookies() != null) {
            for (Cookie cookie : request.getCookies()) {
                System.out.println(cookie.getName() + ": " + cookie.getValue());
            }
        }
        System.out.println();
        System.out.println("[Content 편의 조회]");
        System.out.println("request.getContentType() = " +
                request.getContentType());
        System.out.println("request.getContentLength() = " +
                request.getContentLength());
        System.out.println("request.getCharacterEncoding() = " +
                request.getCharacterEncoding());
                
        System.out.println("--- Header 편의 조회 end ---");
        System.out.println();
    }

기타 정보

  • 기타 정보는 HTTP 메시지의 정보는 아니다.
private void printEtc(HttpServletRequest request) {
        System.out.println("--- 기타 조회 start ---");
        
        System.out.println("[Remote 정보]");
        System.out.println("request.getRemoteHost() = " +
                request.getRemoteHost()); //
        System.out.println("request.getRemoteAddr() = " +
                request.getRemoteAddr()); //
        System.out.println("request.getRemotePort() = " +
                request.getRemotePort()); //
        System.out.println();
        
        System.out.println("[Local 정보]");
        System.out.println("request.getLocalName() = " +
                request.getLocalName()); //
        System.out.println("request.getLocalAddr() = " +
                request.getLocalAddr()); //
        System.out.println("request.getLocalPort() = " +
                request.getLocalPort()); //
                
        System.out.println("--- 기타 조회 end ---");
        System.out.println();
    }
  • 로컬에서 테스트하면 IPv6 정보가 나오는데, IPv4 정보를 보고 싶으면 다음 옵션을 VM options에 넣어주면 된다.
    -Djava.net.preferIPv4Stack=true

3. HTTP 요청 데이터

GET 쿼리 파라미터

  • 메시지 바디 없이, URL 의 쿼리 파라미터를 사용해서 데이터 전달
    예) 검색, 필터, 페이징등에서 많이 사용
  • 쿼리 파라미터 조회 메서드
//단일 파라미터 조회
String username = request.getParameter("username");

//파라미터 이름들 모두 조회
Enumeration<String> parameterNames = request.getParameterNames();

//파라미터를 Map 으로 조회
Map<String, String[]> parameterMap = request.getParameterMap();

//복수 파라미터 조회
String[] usernames = request.getParameterValues("username");
/**
 * 1. 파라미터 전송 가능
 * http://localhost:8082/request-param?username=hello&age=20
 * 
 * 2. 동일한 파라미터 전송 가능
 * http://localhost:8080/request-param?username=hello&username=kim&age=20
 */
@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("[단일 파라미터 조회]");
        String username = request.getParameter("username");
        String age = request.getParameter("age");

        System.out.println("username = " + username);
        System.out.println("age = " + age);
        System.out.println();

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

        response.getWriter().write("ok");
    }
}

POST HTML Form

  • 주로 회원 가입, 상품 주문 등에서 사용
  • content-type: application/x-www-form-urlencoded
  • 메시지 바디에 쿼리 파라미터 형식으로 데이터를 전달. username=hello&age=20
    -> 따라서 쿼리 파라미터 조회 메서드를 그대로 사용하면 된다.

API 메시지 바디 - 단순 텍스트

  • HTTP message body에 데이터를 직접 담아서 요청
    • HTTP API에서 주로 사용, JSON, XML, TEXT
    • 데이터 형식은 주로 JSON 사용
    • POST, PUT, PATCH
@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); //byte code -> String

        System.out.println("messageBody = " + messageBody);

        response.getWriter().write("ok");
    }
}

API 메시지 바디 - JSON

@WebServlet(name = "requestBodyJsonServlet", 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.username = " + helloData.getUsername());
        System.out.println("helloData.age = " + helloData.getAge());

        response.getWriter().write("ok");

    }
}
  • JSON 결과를 파싱해서 사용할 수 있는 자바 객체로 변환하려면 Jackson, Gson 같은 JSON 변환 라이브러리를 추가해서 사용해야 한다. 스프링 부트로 Spring MVC를 선택하면 기본으로 Jackson 라이브러리(ObjectMapper)를 함께 제공한다.

4. 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");

        //[Header 편의 메서드]
//        content(response);
//        cookie(response);
//        redirect(response);
		
        //[message body]
        PrintWriter writer = response.getWriter();
        writer.println("ok");
    }

    private void content(HttpServletResponse response) {
        //Content-Type: text/plain;charset=utf-8
        //Content-Length: 2
        //response.setHeader("Content-Type", "text/plain;charset=utf-8");
        response.setContentType("text/plain");
        response.setCharacterEncoding("utf-8");
        //response.setContentLength(2); //(생략시 자동 생성)
    }

    private void cookie(HttpServletResponse response) {
        //Set-Cookie: myCookie=good; Max-Age=600;
        //response.setHeader("Set-Cookie", "myCookie=good; Max-Age=600");
        Cookie cookie = new Cookie("myCookie", "good");
        cookie.setMaxAge(600); //600초
        response.addCookie(cookie);
    }

    private void redirect(HttpServletResponse response) throws IOException {
        //Status Code 302
        //Location: /basic/hello-form.html

        //response.setStatus(HttpServletResponse.SC_FOUND); //302
        //response.setHeader("Location", "/basic/hello-form.html");
        response.sendRedirect("/basic/hello-form.html");
    }

}

5. HTTP 응답 데이터

HTML 응답

@WebServlet(name = "responseHtmlServlet", urlPatterns = "/response-html")
public class ResponseHtmlServlet extends HttpServlet {
    @Override
    protected void service(HttpServletRequest request, 
    		HttpServletResponse response) throws ServletException, IOException {
            
        //Content-Type: text/html;charset-utf-8
        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>");
    }
}

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 {
            
        //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);
    }
}
  • application/json은 스펙상 utf-8 형식을 사용하도록 정의되어 있다. 그래서 스펙에서 charset=utf-8과 같은 추가 파라미터를 지원하지 않는다. 따라서 application/json이라고만 사용해야지 application/json;charset-utf-8이라고 전달하는 것은 의미 없는 파라미터를 추가한 것이 된다. response.getWriter()를 사용하면 추가 파라미터를 자동으로 추가해버린다. 이때는 response.getOutputStream()으로 출력하면 그런 문제가 없다.

3. JSP, MVC 패턴

1. JSP

  • 스프링 부트 3.0 미만
//JSP 추가 시작
implementation 'org.apache.tomcat.embed:tomcat-embed-jasper'
implementation 'javax.servlet:jstl'
//JSP 추가 끝
  • 스프링 부트 3.0 이상
//JSP 추가 시작
implementation 'org.apache.tomcat.embed:tomcat-embed-jasper'
implementation 'jakarta.servlet:jakarta.servlet-api' //스프링부트 3.0 이상
implementation 'jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api' //스프링부트 3.0 이상
implementation 'org.glassfish.web:jakarta.servlet.jsp.jstl' //스프링부트 3.0 이상
//JSP 추가 끝
  • <%@ page contentType="text/html;charset=UTF-8" language="java" %>
    첫 줄은 JSP 문서라는 뜻이다. JSP 문서는 이렇게 시작해야 한다.
  • JSP는 자바 코드를 그대로 다 사용할 수 있다.
    <%@ page import="hello.servlet.domain.member.MemberRepository" %>
    -> 자바의 import문과 같다.
  • <% ~~ %> 자바 코드 입력
  • <%= ~~ %> 자바 코드 출력

2. MVC 패턴

  • 서블릿을 컨트롤러로 사용하고, JSP를 뷰로 사용해서 MVC패턴을 적용
  • Model은 HttpServletRequest 객체 사용
    request.setAttribute(), request.getAttribute()
//회원 등록 폼 - 컨트롤러
@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);
    }
}
  • dispatcher.forward(): 다른 서블릿이나 JSP로 이동할 수 있는 기능. 서버 내부에서 다시 호출이 발생한다.
  • /WEB-INF: 이 경로안에 JSP가 있으면 외부에서 직접 JSP를 호출할 수 없다. 항상 컨트롤러를 통해서 JSP를 호출.
  • redirect vs forawrd
    리다이렉트는 실제 클라이언트(웹 브라우저)에 응답이 나갔다가, 클라이언트가 redirect 경로로 다시 요청한다. 따라서 클라이언트가 인지할 수 있고, URL 경로도 실제로 변경된다. 반면에 포워드는 서버 내부에서 일어나는 호출이기 때문에 클라이언트가 전혀 인지하지 못한다.
//회원 등록 폼 - 뷰
// main/webapp/WEB-INF/views/new-form.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<!-- 상대경로 사용, [현재 URL이 속한 계층 경로 + /save] -->
<form action="save" method="post">
    username: <input type="text" name="username" />
    age: <input type="text" name="age" />
    <button type="submit">전송</button>
</form>

</body>
</html>
  • 여기서 form의 action을 보면 절대 경로(/로 시작)가 아니라 상대경로(/로 시작X) -> 폼 전송시 현재 URL이 속한 계층 경로 +save가 호출
    • 현재 계층 경로: /servlet-mvc/members/
    • 결과: /servlet-mvc/members/save
//회원 저장 - 컨트롤러
@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);
        
        //Model에 데이터를 보관한다.
        request.setAttribute("member", member);

        String viewPath = "/WEB-INF/views/save-result.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}
//회원 저장 - 뷰
// main/webapp/WEB-INF/views/save-result.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
성공
<ul>
    <li>id=${member.id}</li>
    <li>username=${member.username}</li>
    <li>age=${member.age}</li>
</ul>
<a href="/index.html">메인</a>
</body>
</html>
  • <%= request.getAttribute("member")%>로 모델에 저장한 member 객체를 꺼낼 수 있지만, 너무 복잡해진다.
  • JSP는 ${} 문법 제공, 이 문법을 사용하면 request의 attribute에 담긴 데이터를 편리하게 조회할 수 있다.
//회원 목록 조회 - 컨트롤러
@WebServlet(name = "mvcMemberListServlet", urlPatterns = "/servlet-mvc/members")
public class MvcMemberListServlet extends HttpServlet {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(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);
    }
}
//회원 목록 조회 - 뷰
// main/webapp/WEB-INF/views/members.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<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>
    <c:forEach var="item" items="${members}">
        <tr>
            <td>${item.id}</td>
            <td>${item.username}</td>
            <td>${item.age}</td>
        </tr>
    </c:forEach>
    </tbody>
</table>
</body>
</html>
  • 모델에 담아둔 members를 JSP가 제공하는 taglib기능을 사용해서 반복하면서 출력.
  • <c:forEach> 기능 사용하려면 다음과 같이 선언
    <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>

4. 스프링 MVC - 구조

1. DispatcherServlet 구조

스프링 MVC의 프론트 컨트롤러가 바로 디스패처서블릿(DispatcherServlet)이다

DispatcherServlet 서블릿 등록

  • DispatcherServlet도 부모 클래스에서 HttpServlet을 상속 받아서 사용하고, 서블릿으도 동작한다.
    • DispatcherServlet -> FrameworkServlet -> HttpServletBean -> HttpServlet
  • 스프링 부트는 DispatcherServlet을 서블릿으로 자동으로 등록하면서 모든 경로(urlPatterns="/")에 대해서 매핑한다.
  • 더 자세한 경로가 우선순위가 높다. 그래서 직접 만든 서블릿도 함께 동작

요청 흐름

  • 서블릿이 호출되면 HttpServlet이 제공하는 service()가 호출된다.
  • 스프링 MVC는 DispatcherServlet의 부모인 FrameworkServlet에서 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;
	String viewName = mv.getViewName();

	// 6. 뷰 리졸버를 통해서 뷰 찾기, 7. View 반환
	view = resolveViewName(viewName, mv.getModelInternal(), locale, request);

	// 8. 뷰 렌더링
	view.render(mv.getModelInternal(), request, response);

}

2. 스프링 MVC 동작 순서

  • 핸들러 조회: 핸들러 매핑을 통해 요청 URL에 매핑된 핸들러(컨트롤러) 조회.
  • 핸들러 어댑터 조회: 핸들러를 실행할 수 있는 핸들러 어댑터를 조회.
  • 핸들러 어댑터 실행: 핸들러 어댑터를 실행.
  • 핸들러 실행: 핸들러 어댑터가 실제 핸들러를 실행.
  • ModelAndView 반환: 핸들러 어댑터는 핸들러가 반환하는 정보를 ModelAndView로 변환해서 반환.
  • viewResolver 호출: 뷰 리졸버를 찾고 실행.
    • JSP의 경우: InternalResourceViewResolver가 자동 등록되고, 사용된다.
  • View 반환: 뷰 리졸버는 뷰의 논리 이름을 물리 이름으로 바꾸고, 렌더링 역할을 담당하는 뷰 객체를 반환.
    • JSP의 경우 InternalResourceView(JstlView)를 반환하는데, 내부에 forword() 로직이 있다.
  • 뷰 렌더링: 뷰를 통해서 뷰를 렌더링 한다.

3. 주요 인터페이스 목록

  • 핸들러 매핑: org.springframework.web.servlet.HandlerMapping
  • 핸들러 어댑터: org.springframework.web.servlet.HandlerAdapter
  • 뷰 리졸버: org.springframework.web.servlet.ViewResolver
  • 뷰: org.springframework.web.servlet.View
  • 이 인터페이스들만 구현해서 DispatcherServlet에 등록하면 나만의 컨트롤러를 만들 수도 있다.

4. 핸들러 매핑과 핸들러 어댑터

스프링 부트가 자동 등록하는 핸들러 매핑과 핸들러 어댑터

0 = RequestMappingHandlerMapping : 애노테이션 기반의 컨트롤러인 @RequestMapping에서 사용
1 = BeanNameUrlHandlerMapping : 스프링 빈의 이름으로 핸들러를 찾는다.
0 = RequestMappingHandlerAdapter : 애노테이션 기반의 컨트롤러인 @RequestMapping에서 사용
1 = HttpRequestHandlerAdapter : HttpRequestHandler 처리
2 = SimpleControllerHandlerAdapter : Controller 인터페이스(애노테이션X, 과거에 사용) 처리
  • 실제로는 더 많다.
  • 핸들러 매핑도, 핸들러 어댑터도 모두 순서대로 찾고 만약 없으면 다음 순서로 넘어간다.

Controller 인터페이스 (과거 버전 스프링 컨트롤러)

// org.springframework.web.servlet.mvc.Controller
// @Controller 애노테이션과는 전혀 다르다.
public interface Controller {
	ModelAndView handleRequest(HttpServletRequest request, 
    						HttpServletResponse response) throws Exception;
                            
}
// 간단하게 구현
@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라는 이름의 스프링 빈으로 등록되었다.
  • 빈의 이름으로 URL을 매핑
  • 이 컨트롤러는 어떻게 호출?
  1. 핸들러 매핑으로 핸들러 조회
  • HandlerMapping을 순서대로 실행해서, 핸들러를 찾는다.
  • 이 경우 빈 이름으로 핸들러를 찾아야 하기 때문에 이름 그대로 빈 이름으로 핸들러를 찾아주는 BeanNameUrlHandlerMapping이 실행에 성공하고 핸들러인 OldController를 반환한다.
  1. 핸들러 어댑터 조회
  • HandlerAdaptersupports()를 순서대로 호출한다.
  • SimpleControllerHandlerAdapterController인터페이스를 지원하므로 대상이 된다.
  1. 핸들러 어댑터 실행
  • 디스패처 서블릿이 조회한 SimpleControllerHandlerAdapter를 실행하면서 핸들러 정보도 함께 넘겨준다.
  • SimpleControllerAdapter는 핸들러인 OldController를 내부에서 실행하고, 그 결과를 반환한다.

HttpRequestHandler

public interface HttpRequestHandler {

void handleRequest(HttpServletRequest request,
			HttpServletResponse response) throws ServletException, IOException;
            
}
// 간단하게 구현
@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");
    }
}
  • 위와 똑같은 컨트롤러 호출 과정을 거친다.
  • MyHttpRequestHandler를 실행하며 BeanNameUrlHandlerMapping , HttpRequestHandlerAdapter 객체가 사용된다.

5. 뷰 리졸버

// View를 사용할 수 있도록 return new ModelAndView("new-form"); 추가
@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");
    }
}
# application.proterties에 추가

spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp
  • 스프링 부트는 InternalResourceViewResolver라는 뷰 리졸버를 자동으로 등록하는데, 이때 application.properties에 등록한 spring.mvc.view.prefix, spring.mvc.view.suffix 설정 정보를 사용해서 등록한다.
  • return new ModelAndView("/WEB-INF/views/new-form.jsp"); 설정 없이 이렇게 해도 동작하기는 한다.

스프링부트가 자동 등록하는 뷰 리졸버

// 실제로는 더 많다
1 = BeanNameViewResolver : 빈 이름으로 뷰를 찾아서 반환한다. (예: 엑셀 파일 생성 
기능에 사용)
2 = InternalResourceViewResolver : JSP를 처리할 수 있는 뷰를 반환한다.

뷰 리졸버 동작 방식

  1. 핸들러 어댑터 호출
  • 핸들러 어댑터를 통해 new-form이라는 논리 뷰 이름 획득
  1. ViewResolver 호출
  • new-form이라는 뷰 이름으로 viewResolver를 순서대로 호출
  • BeanNameViewResolvernew-form이라는 이름의 스프링 빈으로 등록된 뷰를 찾아야 하는데 없다.
  • InternalResourceViewResolver가 호출된다.
  1. InternalResourceViewResolver
  • 이 뷰 리졸버는 InternalResourceView를 반환
  1. 뷰 - InternalResourceView
  • InternalResourceView는 JSP처럼 forward()를 호출해서 처리할 수 있는 경우에 사용한다.
  1. view.render()
  • view.render()가 호출되고 InternalResourceViewforward()를 사용해서 JSP를 실행
  • InternalResourceViewResolver는 만약 JSTL 라이브러리가 있으면 InternalResourceView를 상속받은 JstlView를 반환한다. JstlView는 JSTL 태그 사용시 약간의 부가 기능이 추가된다.
  • 다른 뷰는 실제 뷰를 렌더링하지만, JSP의 경우 forward()를 통해서 해당 JSP로 이동(실행)해야 렌더링이 된다. JSP를 제외한 나머지 뷰 템플릿들은 forward()과정 없이 바로 렌더링 된다.
  • Thymeleaf 뷰 템플릿을 사용하면 ThymeleafViewResolver를 등록해야 한다. 최근에는 라이브러리만 추가하면 스프링 부트가 이런 작업도 모두 자동화해준다.

6. 스프링 MVC - 시작하기

@RequestMapping

  • 스프링은 애노테이션을 활용한 매우 유연하고, 실용적인 컨트롤러를 만들었는데 이것이 바로 @RequestMapping 애노테이션을 사용하는 컨트롤러이다.
  • 가장 우선순위가 높은 핸들러 매핑과 핸들러 어댑터는 RequestMappingHandlerMapping, RequestMappingHandlerAdapter

@RequestMapping 기반의 스프링 MVC 컨트롤러

  • @controller:
    • 스프링이 자동으로 스프링 빈으로 등록한다. (내부에 @Component)
    • 스프링 MVC에서 애노테이션 기반 컨트롤러로 인식한다.
  • @RequestMapping: 요청 정보를 매핑한다. 해당 URL이 호출되면 이 메서드가 호출된다. 애노테이션을 기반으로 동작하기 때문에, 메서드의 이름은 임의로 지으면 된다.
  • ModelAndView: 모델과 뷰 정보를 담아서 반환하면 된다.

스프링 부트 3.0 이전

  • RequestMappingHandlerMapping은 스프링 빈 중에서 @RequestMapping 또는 @Controller가 클래스 레벨에 붙어 있는 경우에 매핑 정보로 인식
  • 따라서 클래스에 @Compenent + @RequestMapping 또는 수동 빈 등록 후 @RequestMapping만 있어도 동작한다.

스프링 부트 3.0 이상

  • 스프링 부트 3.0(스프링 프레임워크 6.0)부터는 클래스 레벨에 @RequestMapping이 있어도 스프링 컨트롤러로 인식하지 않는다. 오직 @Controller가 있어야 스프링 컨트롤러로 인식한다. (RequestMappingHandlerMapping에서 @RequestMapping은 이제 인식하지 않고, 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("/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
    public ModelAndView members() {

        List<Member> members = memberRepository.findAll();

        ModelAndView mv = new ModelAndView("members");
        mv.addObject("members", members);
        return mv;
    }
}
//실용적인 방식
@Controller
@RequestMapping("/springmvc/v3/members")
public class SpringMemberControllerV3 {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    //    @RequestMapping(value = "/new-form", method = RequestMethod.GET)
    @GetMapping("/new-form")
    public String newForm() {
        return "new-form";
    }

//    @RequestMapping(value = "/save", method = RequestMethod.POST)
    @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";
    }

//    @RequestMapping(method = RequestMethod.GET)
    @GetMapping
    public String members(Model model) {

        List<Member> members = memberRepository.findAll();

        model.addAttribute("members", members);
        return "members";
    }
}

5. 스프링 MVC - 기본 기능

1. Welcome 페이지

  • 스프링 부트에 Jar을 사용하면 /resources/static/위치에 index.html파일을 두면 Welcome 페이지로 처리해준다. (스프링 부트가 지원하는 정적 컨텐츠 위치에 /index.html이 있으면 된다.)

2. 로깅

로깅 라이브러리

  • 스프링 부트 라이브러리를 사용하면 스프링 부트 로깅 라이브러리(spring-boot-starter-logging)가 함께 포함된다.
  • 스프링 부트 로깅 라이브러리는 기본으로 다음 로깅 라이브러리를 사용한다.
    • SLF4J
    • Logback
  • 로그 라이브러리는 Logback, Log4J, Log4J2 등등 수 많은 라이브러리가 있는데, 그것을 통합해서 인터페이스로 제공하는 것이 바로 SLF4J 라이브러리다.
  • SLF4J는 인터페이스이고, 그 구현체로 Logback 같은 로그 라이브러리를 선택하면 된다.
  • 실무에서는 스프링 부트가 기본으로 제공하는 Logback을 대부분 사용

로그 선언

  • private Logger log = LoggerFactory.getLogger(getClass());
  • private static final Logger log = LoggerFactory.getLogger(Xxx.class)
  • @Slf4j : 롬복 사용 가능

로그 레벨 설정

  • LEVEL: TRACE > DEBUG > INFO > WARN > ERROR
  • 개발 서버는 debug 출력
  • 운영 서버는 info 출력
  • 로그가 출력되는 포맷
    -> 시간, 로그 레벨, 프로세스 ID, 쓰레드 명, 클래스 명, 로그 메시지
#전체 로그 레벨 설정(기본 info)
logging.level.root=info

#hello.springmvc 패키지와 그 하위 로그 레벨 설정
logging.level.hello.springmvc=debug

올바른 로그 사용법

  • log.debug("data="+data)
    • 로그 출력 레벨을 info로 설정해도 해당 코드에 있는 "data="+data가 실제 실행이 되어 버린다. 결과적으로 문자 더하기 연산이 발생한다.
  • log.debug("data={}", data)
    • 로그 출력 레벨을 info로 설정하면 아무일도 발생하지 않는다. 따라서 앞과 같은 의미없는 연산이 발생하지 않는다.

로그 사용시 장점

  • 쓰레드 정보, 클래스 이름 같은 부가 정보를 함께 볼 수 있고, 출력 모양을 조정할 수 있다.
  • 로그 레벨에 따라 개발 서버에서는 모든 로그를 출력하고, 운영서버에서는 출력하지 않는 등 로그를 상황에 맞게 조절할 수 있다.
  • 시스템 아웃 콘솔에만 출력하는 것이 아니라, 파일이나 네트워크 등, 로그를 별도의 위치에 남길 수 있다. 특히 파일로 남길 대는 일별, 특정 용량에 따라 로그를 분할하는 것도 가능하다.
  • 성능도 일반 System.out보다 좋다. (내부 버퍼링, 멀티 쓰레드 등등) 그래서 실무에서는 꼭 로그를 사용해야 한다.

3. 요청 매핑

@RestController

  • @Controller는 반환 값이 String이면 뷰 이름으로 인식. 뷰를 찾고 뷰가 렌더링.
  • @RestController는 반환 값으로 뷰를 찾는 것이 아니라, HTTP 메시지 바디에 바로 입력.

@RequestMapping

  • URL 호출이 오면 이 메서드가 실행되도록 매핑한다.
  • 대부분의 속성을 배열[]로 제공하므로 다중 설정이 가능
    -> {"/hello-basic", "/hello-go"}

스프링 부트 3.0 이전

  • URL 요청: /hello-basic, /hello-basic/ -> 매핑: /hello-basic

스프링 부트 3.0 이후

  • 기존에는 마지막에 있는 /(slash)를 제거했지만, 스프링 부트 3.0부터는 마지막의 /(slash)를 유지한다.
  • URL 요청: /hello-basic -> 매핑: /hello-basic
  • URL 요청: /hello-basic/ -> 매핑: /hello-basic/

HTTP 메서드

  • @RequestMappingmethod 속성으로 HTTP 메서드를 지정하지 않으면 HTTP 메서드와 무관하게 호출된다.

PathVariable(경로 변수) 사용

//최근 HTTP API는 리소스 경로에 식별자를 넣는 스타일을 선호
@GetMapping("/mapping/{userId}")
    public String mappingPath(@PathVariable("userId") String data) {
        log.info("mappingPath userId={}", data);
        return "ok";
    }
  • @PathVariable의 이름과 파라미터 이름이 같으면 생략할 수 있다.

PathVariable 사용 - 다중

@GetMapping("/mapping/users/{userId}/orders/{orderId}")
    public String mappingPath(@PathVariable String userId, 
    								@PathVariable Long orderId) {
        log.info("mappingPath userId={}, orderId={}", userId, orderId);
        return "ok";
    }

특정 파리미터 조건 매핑

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

특정 헤더 조건 매핑

/**
 * 특정 헤더로 추가 매핑
 * headers="mode",
 * headers="!mode"
 * headers="mode=debug"
 * headers="mode!=debug" (! = )
 */
@GetMapping(value = "/mapping-header", headers = "mode=debug")
public String mappingHeader() {
	log.info("mappingHeader");
    return "ok";
}

미디어 타입 조건 매핑 - HTTP 요청 Content-Type, consume

/**
 * Content-Type 헤더 기반 추가 매핑 Media Type
 * consumes="application/json"
 * consumes="!application/json"
 * consumes="application/*"
 * consumes="*\/*"
 * MediaType.APPLICATION_JSON_VALUE
 */
@PostMapping(value = "/mapping-consume", consumes = "application/json")
public String mappingConsumes() {
	log.info("mappingConsumes");
    return "ok";
}

미디어 타입 조건 매핑 - HTTP 요청 Accept, produce

/**
 * 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";
}

4. 요청 매핑 - API 예시

회원 관리 API

  • 회원 목록 조회: GET /users
  • 회원 등록: POST /users
  • 회원 조회: GET /users/{userId}
  • 회원 수정: PATCH /users/{userId}
  • 회원 삭제: DELETE /users/{userId}

5. HTTP 요청 - 기본, 헤더 조회

애노테이션 기반 스프링 컨트롤러 파라미터

  • HttpServletRequest
  • HttpServletResponse
  • HttpMethod: HTTP 메서드 조회. org.springframework.http.HttpMethod
  • Locale: Locale 정보 조회
  • @RequestHeader MultiValueMap<String, String> headerMap
    • 모든 HTTP 헤더를 MultiValueMap 형식으로 조회
  • @RequestHeader("host") String host
    • 특정 HTTP 헤더를 조회
    • 필수 값 여부: required
    • 기본 값 속성: defaultValue
  • @CookieValue(value = "myCookie", required = false) String cookie
    • 특정 쿠키를 조회
    • 필수 값 여부: required
    • 기본 값 속성: defaultValue

6. HTTP 요청 파라미터 - 쿼리 파라미터, HTML Form

@RequestParam

  • 파라미터 이름으로 바인딩
  • HTTP 파라미터 이름이 변수 이름과 같으면 @RequestParam(name="xx") 생략 가능
  • String, int, Integer등의 단순 타입이면 @RequestParam도 생략 가능
  • @RequestParam을 생략하면 스프링 MVC는 내부에서 required=false를 적용 (기본값이 true)
  • required=true일 때 요청 파라미터가 없으면 400 예외 발생
  • 파라미터 이름만 있고 값이 없는 경우(/request-param?username=)
    -> 빈 문자로 통과
  • @RequestParam(required = false) int age
    • null을 기본형(primitive)인 int에 입력하는 것은 불가능(500 예외 발생)
    • 따라서 null을 받을 수 있는 Integer로 변경하거나, defaultValue 사용
  • @RequestParam(required = true, defaultValue = "guest") String username
    • 이미 기본 값이 있기 때문에 required는 의미가 없다.
    • 빈 문자의 경우에도 설정한 기본 값이 적용 (/request-param-default?username=)

파라미터를 Map으로 조회

@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";
}
  • @RequestParam Map
  • @RequestParam MultiValueMap

@ModelAttribute

@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@ModelAttribute HelloData helloData) {
	log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
    log.info("helloData={}", helloData); //toString
    return "ok";
}
  • HelloData 객체를 생성
  • 요청 파라미터 이름으로 HelloData객체의 프로퍼티를 찾는다. 그리고 해당 프로퍼티의 setter를 호출해서 파라미터의 값을 입력(바인딩) 한다.
    (파라미터 이름이 username이면 setUsername()메서드를 찾아서 호출하면서 값을 입력한다.)
  • @ModelAttribute는 생략 가능

생략시

  • String, int, Integer 같은 단순 타입 = @RequestParam
  • 나머지 = @ModelAttribute (argument resolver 로 지정해둔 타입 외)

7. HTTP 요청 메시지 - 단순 텍스트

InputStream(Reader), OutputStream(Writer)

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");
}
  • HttpEntity를 상속받은 다음 객체들도 같은 기능 제공
    • RequestEntity: HttpMethod, url 정보가 추가, 요청 에서 사용
    • ResponseEntity: HTTP 상태 코드 설정 가능, 응답에서 사용

@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";
}
  • 헤더 정보가 필요하다면 HttpEntity를 사용하거나 @RequestHeader를 사용

8. HTTP 요청 메시지 - JSON

HTTP 요청시에 content-type이 application/json인지 꼭 확인! 그래야 JSON을 처리할 수 있는 HTTP 메시지 컨버터가 실행

@RequestBody 문자 변환

  • @RequestBody를 사용해서 HTTP 메시지에서 데이터를 꺼내고 messageBody에 저장
  • 문자로 된 JSON 데이터인 messageBodyobjectMapper를 통해서 자바 객체로 변환

@RequestBody 객체 변환

  • @RequestBody HelloData data (@RequestBody에 직접 만든 객체를 지정할 수 있다.) <- HTTP 메시지 컨버터
  • @RequestBody생략 불가능 (생략시 @ModelAttribute)
  • JSON 요청 -> HTTP 메시지 컨버터 -> 객체

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

@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;
}
  • 응답의 경우에도 HttpEntity를 사용해도 된다.
  • 객체 -> HTTP 메시지 컨버터 -> JSON 응답

9. HTTP 응답 - 정적 리소스, 뷰 템플릿

정적 리소스

  • 스프링 부트는 클래스패스의 다음 디렉토리에 있는 정적 리소스를 제공
    • /static, /public, /resources, /META-INF/resources
  • src/main/resources는 리소스를 보관하는 곳이고, 또 클래스패스의 시작 경로
  • src/main/resources/static/basic/hello-form.html
    -> http://localhost:8080/basic/hello-form.html

뷰 템플릿

  • Thymeleaf 라이브러리 사용 시 아래 설정이 기본 값
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
  • 뷰 템플릿을 거쳐서 HTML이 생성되고, 뷰가 응답을 만들어서 전달한다.
  • 일반적으로 HTML을 동적으로 생성하는 용도로 사용하지만, 뷰 템플릿이 만들 수 있는 것이라면 뭐든지 가능하다.
  • 뷰 템플릿 경로
    src/main/resources/templates
<!-- src/main/resources/templates/response/hello.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<p th:text="${data}">empty</p>
</body>
</html>
//뷰 템플릿 렌더링
@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!");
    }
}
  • void를 반환하는 경우
    • @Controller를 사용하고, HttpServletResponse, OutputStream(Writer) 같은 HTTP 메시지 바디를 처리하는 파라미터가 없으면 요청 URL을 참고해서 논리 뷰 이름으로 사용
    • 권장하지 않는 방법

10. HTTP 응답 - HTTP API, 메시지 바디에 직접 입력

  • ResponseEntity는 HTTP 응답 코드를 설정할 수 있는데, @ResponseBody를 사용하면 이런 것을 설정하기 까다롭다. @ResponseStatus(HttpStatus.ok) 애노테이션을 사용하면 된다.
  • 조건에 따라서 동적으로 응답 코드를 변경하려면 ResponseEntity를 사용해야 한다.

11. HTTP 메시지 컨버터

스프링 MVC는 다음의 경우에 HTTP 메시지 컨버터를 적용한다.

  • HTTP 요청: @RequestBody, HttpEntity(RequestEntity)
  • HTTP 응답: @ResponseBody, HttpEntity(ResponseEntity)

HTTP 메시지 컨버터 인터페이스

package org.springframework.http.converter;

public interface HttpMessageConverter<T> {

	boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);
	boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);

	List<MediaType> getSupportedMediaTypes();
	
    T read(Class<? extends T> clazz, HttpInputMessage inputMessage) 
    	throws IOException, HttpMessageNotReadableException;

	void write(T t, @Nullable MediaType contentType, 
    	HttpOutputMessage outputMessage) 
                throws IOException, HttpMessageNotWritableException;
}
  • canRead(), canWrite() : 메시지 컨버터가 해당 클래스, 미디어타입을 지원하는지 체크
  • read(), write() : 메시지 컨버터를 통해서 메시지를 읽고 쓰는 기능

스프링 부트 기본 메시지 컨버터

//일부 생략
0 = ByteArrayHttpMessageConverter
1 = StringHttpMessageConverter 
2 = MappingJackson2HttpMessageConverter
  • 대상 클래스 타입과 미디어 타입 둘을 체크해서 사용여부를 결정. 만약 만족하지 않으면 다음 메시지 컨버터로 우선순위가 넘어간다.
  • 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 관련

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

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

ArgumentResolver

//정확히는 HandlerMethodArgumentResolver
public interface HandlerMethodArgumentResolver {
	
    boolean supportsParameter(MethodParameter parameter);
	
    @Nullable
	Object resolveArgument(MethodParameter parameter, 
    		@Nullable ModelAndViewContainer mavContainer,
        	NativeWebRequest webRequest, 
        	@Nullable WebDataBinderFactory binderFactory) throws Exception;

}
  • ArgumentResolversupportsParameter()를 호출해서 해당 파라미터를 지원하는지 체크하고, 지원하면 resolveArgument()를 호출해서 실제 객체를 생성한다. 이렇게 생성된 객체가 컨트롤러 호출시 넘어가는 것이다.
  • 직접 이 인터페이스를 확장해서 원하는 ArgumentResolver를 만들 수 있다.

ReturnValueHandler

  • 정확히는 HandlerMethodReturnValueHandler
  • ArgumentResolver와 비슷한데, 응답 값을 변환하고 처리
  • 컨트롤러에서 String으로 뷰 이름을 반환해도, 동작하는 이유

HTTP 메시지 컨버터 위치

  • 요청 : @RequestBody를 처리하는 ArgumentResolver가 있고, HttpEntity를 처리하는 ArgumentResolver가 있다. 이 ArgumentResolver들이 HTTP 메시지 컨버터를 사용해서 필요한 객체를 생성.
  • 응답 : @ResponseBodyHttpEntity를 처리하는 ReturnValueHandler가 있다. 그리고 여기에서 HTTP 메시지 컨버터를 호출해서 응답 결과를 만든다.
  • 스프링 MVC는 @RequestBody, @ResponseBody가 있으면 RequestResponseBodyMethodProcessor (ArgumentResolver)
    HttpEntity 가 있으면 HttpEntityMethodProcessor (ArgumentResolver)

확장

  • 스프링은 다음을 모두 인터페이스로 제공. 따라서 확장 가능.
    • HandlerMethodArgumentResolver
    • HandlerMethodReturnValueHandler
    • HttpMessageConverter
  • 기능 확장은 WebMvcConfigurer을 상속 받아서 스프링 빈으로 등록하면 된다.

6. 스프링 MVC - 웹 페이지 만들기

1. 타임리프 (네츄럴 템플릿)

타임리프 사용 선언

  • <html xmlns:th="http://www.thymeleaf.org">

속성 변경 - th:href, th:value, th:action

  • th:href="@{/css/bootstrap.min.css}"
    -> href="value1"th:href="value2"의 값으로 변경
  • HTML form에서 action에 값이 없으면 현재 URL에 데이터를 전송.
    • 상품 등록 폼: GET /basic/items/add
    • 상품 등록 처리: POST /basic/items/add
  • 타임리프 뷰 템플릿을 거치게 되면 원래 값을 th:xxx값으로 변경. 만약 값이 없다면 새로 생성.
  • 대부분의 HTML속성을 th:xxx로 변경할 수 있다.

URL 링크 표현식 - @{...}

  • URL 링크 표현식을 사용하면 서블릿 컨텍스트를 자동으로 포함한다.

리터럴 대체 문법 - |...|

  • th:onclick="'location.href=' + '\'' + @{/basic/items/add} + '\''"
    -> th:onclick="|location.href='@{/basic/items/add}'|"
  • 문자와 표현식 등은 분리되어 있기 때문에 더해서 사용해야 하지만 리터럴 대체 문법을 쓰면 편리하게 사용.
    <span th:text="'Welcome to our application, ' + ${user.name} + '!'">
    -> <span th:text="|Welcome to our application, ${user.name}!|">

반복 출력 - th:each

  • <tr th:each="item : ${items}">
  • 모델에 포함된 items 컬렉션 데이터가 item 변수에 하나씩 포함되고, 반복문 안에서 item 변수를 사용할 수 있다.

변수 표현식 - ${...}

  • <td th:text="${item.price}">10000</td>
  • th:value="${item.id}"
  • 모델에 포함된 값이나, 타임리프 변수로 선언한 값을 조회.
  • 프로퍼티 접근법 사용( item.getPrice() )

내용 변경 - th:text

URL 링크 표현식2 - @{...}

  • th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
  • th:onclick="|location.href='@{/basic/items/{itemId}/edit(itemId=${item.id})}'|"
  • 경로 변수( {itemId} ) 뿐만 아니라 쿼리 파라미터도 생성한다.
    • 예) th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}"
    • 생성 링크: http://localhost:8080/basic/items/1?query=test

URL 링크 간단히

  • th:href="@{|/basic/items/${item.id}|}" (리터럴 대체 문법 활용)

2. @ModelAttribute

요청 파라미터 처리

  • @ModelAttributeItem 객체를 생성하고, 요청 파라미터 값을 프로퍼티 접근법(setXxx)으로 입력.

Model 추가

  • 모델(Model)에 @ModelAttribute로 지정한 객체를 자동으로 넣어준다.
  • 이름으로 @ModelAttribute에 지정한 name(value) 속성을 사용
  • @ModelAttribute의 이름을 생략하면 모델에 저장될 때 클래스명 사용. 클래스의 첫글자만 소문자로 변경해서 등록.
  • @ModelAttribute 자체도 생략 가능. 대상 객체는 모델에 자동 등록

3. 상품 수정 개발

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
	itemRepository.update(itemId, item);
 	return "redirect:/basic/items/{itemId}";
}
  • GET /items/{itemId}/edit : 상품 수정 폼
  • POST /items/{itemId}/edit : 상품 수정 처리
  • 컨트롤러에 매핑된 @PathVariable의 값은 redirect에도 사용 할 수 있다.

4. PRG Post/Redirect/Get

  • 웹 브라우저의 새로 고침은 마지막에 서버에 전송한 데이터를 다시 전송한다.
  • 상품 등록 폼에서 데이터를 입력하고 저장을 선택하면 Post /add + 상품 데이터를 서버로 전송한다.
  • 이 상태에서 새로 고침을 또 선택하면 마지막에 전송한 Post /add + 상품 데이터를 서버로 다시 전송하게 된다.
  • 그래서 내용은 같고, ID만 다른 상품 데이터가 계속 쌓이게 된다.
  • 따라서 상품 저장 후에 뷰 템플릿으로 이동하는 것이 아니라, 상품 상세 화면으로 리다이렉트를 호출해주면 된다. -> 새로 고침 문제 해결
/**
 * 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}";
}
  • "redirect:/basic/items/" + item.getId()처럼 URL에 변수를 더해서 사용하는 것은 URL 인코딩이 안되기 때문에 위험하다. RedirectAttributes를 사용하자.
  • RedirectAttributes를 사용하면 URL 인코딩도 해주고, PathVariable, 쿼리 파라미터까지 처리해준다.
    • pathVariable 바인딩: {itemId}
    • 나머지는 쿼리 파라미터로 처리: ?status=true
  • 뷰 템플릿에 <h2 th:if="${param.status}" th:text="'저장 완료!'"></h2> 추가
    • ${param.status}: 타임리프에서 쿼리 파라미터를 편리하게 조회하는 기능

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술

profile
You never fail until you stop trying.

0개의 댓글