웹 애플리케이션 이해
client와 client, client와 server 모든 것을 HTTP 기반으로 통신한다.
웹서버 vs 웹 애플리케이션 서버
웹서버
http기반으로 동작
정적 리소스 제공
정적 파일 html,css,js,이미지,영상 제공
웹 애플리케이션 서버(WAS)
웹서버 기능 포함 + 프로그램 코드를 실행해서 애플리케이션 로직 수행 - 동적 HTML,HTTP API,서블릿 등등
그럼 와스만 쓰면 되냐? No -> 정적리소스까지 was가 담당해버리면, 정적 리소스때문에 애플리케이션 로직이 수행이 안될 수 있고, was가 죽어버리면 오류화면 노출도 불가능하기때문
웹 시스템 구성: Web,Was,Db
서블릿이란?
클라이언트가 localhost:8080/api/hello 요청을 웹서버로 보냈다 치자.
웹서버는 톰캣과 같은 WAS에 요청을 위임한다.
WAS는 각 요청에 해당하는 서블릿을 실행한다. 서블릿은 요청에 대한 기능을 수행하고 결과를 반환하여 클라이언트에게 전송한다.
과정
1. 요청이 들어온다.
2. 요청을 바탕으로 HttpServletRequest,HttpServletResponse객체를 생성한다.(만약 서블릿 안쓰면 요청은 단순히 문자열이므로 개발자가 직접 다 파싱하고 데이터 추출하고 이런 과정을 다 코드로 짜야함)
3. 해당 객체를 파라미터로 서블릿 컨테이너에 넘겨준다.
4. 서블릿 컨테이너에서 해당 URL을 바탕으로 서블릿을 찾아서 실행시킨다.
web.xml파일 또는 @WebServlet을 통해 url이 어떤 서블릿과 매핑되어있는지 확인한다.
(만약 내가 만든 서블릿에 service()가 있으면, service()메서드를 실행한다.
service메서드가 모든 HTTP요청을 처리하기 때문이다.
서블릿에는 doPost, doGet도 있는데, 서비스메서드 내부에서 doPost()와 doGet()메서드를 호출하는 것이 유지보수에 좋긴하다.
또한 서블릿에는 init과 destory도 있다.
서블릿이 처음 요청될때 톰캣이 서블릿 인스턴스를 생성하고 init()메서드를 호출하여 초기화작업을 수행한다. 이 단계는 서블릿이 처음 호출될때만 발생한다.)
결국 톰캣이 java 서블릿을 지원하는 WAS인데,
이 WAS에서 서블릿 생명주기를 관리해준다.
만약 우리가 서블릿을 지원하는 와스를 사용하지 않는다면
tcp/ip 핸드쉐이킹부터, 소켓 연결, 컨텐트 타입확인 바디내용 파싱 부터 response까지 다 일일히 개발자가 작성해야한다.
와스를 사용하면 우리는 service()에 해당하는 비즈니스 로직만 짜면되고, 나머지는 와스가 전부 처리해준다.
실제로 우리가 Controller 코드를 짤때 애노테이션 하나만 띡 붙이기만했지 서블릿에 등록하고, ini, distory, 컨트롤러에서 요청 URL과 메서드를 뒤져서 매핑하는 코드등을 일체 작성하지 않았다.
이걸 전부 와스가 대신해주는거다.
와스와 쓰레드
클라이언트가 요청을 보내면 -> was랑 연결한 후에 -> 해당 요청에 맞는 서블릿 객체를 호출해줄것이다.
해당 서블릿 객체를 누가 호출해주냐? 쓰레드가 호출해준다.
요청이 들어올 때마다 쓰레드를 생성해서 서블릿을 호출하는건 비용이 너무 비싸므로,
쓰레드 풀에다가 쓰레드를 만들어 놓고 그다음에, 요청이 들어오면 쓰레드풀에서 가져다가 쓰고 해당 쓰레드를 지우는게 아니라 쓰레드 풀에 반납하는것이다.
또한 최대 쓰레드가 모두 사용중이어서 쓰레드 풀에 쓰레드가 없으면, 기다리는 요청은 거절하거나, 특정 숫자만큼 대기하도록 설정이 가능하다.
톰캣은 최대 200개까지 설정이 가능하다.
was의 주요 튜닝 포인트는 이 최대 쓰레드 수를 설정하는 것이다.
너무 낮게 설정하면, 서버는 여유롭지만, 클라이언트는 응답이 지연되고,
너무 많이 설정하면, 동시 요청이 많아지면, cpu와 메모리 임계점 초과로 서버가 뻗어버린다.
그래서 이런 쓰레드 풀의 적정 숫자를 찾으려면,
애플리케이션 로직의 복잡도, CPU, 메모리, IO리소스 상황에 따라 구분지어서 해결해야한다.
성능테스트툴: 아파치 ab,제이미터,nGrinder
핵심은 우리는 WAS의 멀티 쓰레드 설정같은걸 짜주지 않아도 된다는것이다. 실제로 여러명의 클라이언트가 서블릿으로 요청을 보낼시 어떻게 처리할지 이런 코드를 우리는 작성하지 않는다.
마치, 싱글 쓰레드 프로그래밍을 하듯이 편리하게 소스코드 개발을 하면된다.
그러나 멀티 쓰레드 풀 최대치 설정이다, 멀티쓰레드 환경이므로 싱글톤 객체를 주의해서 사용하는것만 신경쓰면된다.(싱글톤 빈에 의존적인 필드가 있다던지 - 지역변수로해결)
SSR-서버사이드 렌더링
HTML 최종결과를 서버에서 만들어서 웹 브라우저에 전달.
주로 정적인 화면에 사용
CSR- 클라이언트 사이드 렌더링
HTML 결과를 자바스크립트를 사용해 웹 브라우저에서 동적으로 생성해서 적용
실제로 구글 맵 같은게, 우리가 지도를 이동시키거나, 늘리거나 줄여도, URL이 바뀌고 그런게 아니라, 그 상태에서 필요한 부분부분 변경 된다,
주로 동적인 화면에서 생성해서 UI에 적용 가능하다.
서블릿에대한 이해
서블릿을 사용하려면 스프링 부트가 지원하는 서블릿 컴포넌트 스캔(@SevletComponentScan)을 사용해야한다.
-> 서블릿 컨테이너에 서블릿을 등록하고, URL 요청이 들어오면 URL에 해당하는 서블릿을 컨테이너에서 찾기 위함이다.
서블릿 등록하기 방법
WebServlet: 애노테이션을 통해서 서블릿등록
name이 서브릿 이름, urlPatterns가 url 설정
해당 서블릿이 호출되면 service()메서드가 호출된다.
이전에 앞에서 Client가 요청을 보내면 해당 요청에 맞게 HttpRequest,HttpResponse객체가 생성된다고 하였는데 해당 객체들이, service메서드의 파라미터로 들어가게 된다.
서블릿을 사용하는 이유
Client가 Http요청메시지를 보내면,
이건 단순히 문자열이다. 만약 서블릿이 없다면, 개발자가 일일히 파싱해서 사용해야한다.
그러나 서블릿을 통해 Http메시지를 쉽게 이해할 수 있고, 또한 해당 요청에 맞춰서 data를 가져오기위한 HttpRequest,Response객체도 서블릿이 자동으로 만들어준다.
클라이언트가 서버로 Http요청을 보낼때 Data를 포함시키는 방법
Get-쿼리파라미터
/url?username=hello&age=20
메시지 바디 없이, URL의 쿼리파라미터에 데이터를 포함해서 전달
검색,필터,페이징에 이용
서블릿에서
request.getParameterNames()로 전체 파라미터 조회가능
request.getParemeter("username") 단일 파라미터 조회가능
request.getParmeterVlaues("username") 이름이 같은 복수파라미터 조회가능
Post-HTML Form
Data가 HTML 폼형식으로 전달됨
HTML Form에 username,age를 입력받는곳이 있고, 이 HTML Form에서 입력받은 값으로 HTTP메시지를 만들어 전송
content-type:application/x-wwww-form-urlencoded
메시지 바디에 쿼리파라미터 형식으로 전달 username=hello&age=20
content-type적는이유 -> 바디에 데이터가 들어가므로 JSON형식과 구분하기 위해서
클라이언트 입장에서는 HTML Form을 사용하나, URL에 데이터를 포함해서 넘기느냐 차이가 있지만,
서버 입장에서는 쿼리파라미터 형식이 동일하므로, request.getParameter()로 구분없이 조회가 가능하다.
서버에스 클라이언트로 응답하는 방법
단순 텍스트 응답
writer.println("ok");로 단순 텍스트 출력가능
HTML Form 응답
Content-type: text/html -> 이걸보고 클라이언트가 렌더링해줌
인코딩 버전, utf-8 한글 안깨지게 하기위해
JSON 응답
Content-type: application/json
ObjectMapper를 사용해서 writeValueAsString 메서드를 호출하면 객체를 Json문자로 변경이 가능하다.
지금까지 서블릿에 대해서 알아봤는데 그럼 서블릿에서 MVC로 변하게 되었는가?
만약에 Member를 등록하는 서블릿을 만든다고 해보자.
@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);
System.out.println("member = " + member);
memberRepository.save(member);
//저장한 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>");
}
}
이렇게하면 문제가 있다.
너무많은 역할
하나의 서블릿에 비즈니스 로직과 뷰 렌더링까지 처리하면, 너무 많은 역할을 하게되고, 유지보수가 어려워진다.
만약, 비즈니스 로직을 고쳐야한다면, 해당 비즈니스 로직 수정 뿐만아니라, HTML코드까지 바꿔야하면 유지보수가 장난이 아니다. usename -> UserName
변경의 라이프 사이클
UI를 일부 수정하는 것과, 비즈니스 로직을 수정하는 일은 라이프 사이클이 다르다.
서로 각각 다르게 발생할 가능성이 매우 높고, 서로 영향을 주지않는다.
변경의 라이프 사이클이 다른 부분을 하나의 코드로 관리하는것은 유지보수 하기 좋지 않다.
그래서 MVC패턴은 서블릿과 다르게 Controller View를 나눈것을 말한다.
이전에는 비즈니스 로직과 view를 한번에 다때려 박았다면,
MVC에서는 컨트롤러 뷰 모델을 나누어서 Model을 통해서 data를 전달하게 된다.
서블릿을 MVC로 바꾼다면,
@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);
System.out.println("member = " + member);
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);
}
}
해당 서블릿에는 Member를 저장하는 비즈니스 로직만 존재하고,
HttpServletRequest를 Model로 사용하여 request가 제공하는 setAttribute()를 사용하면 request객체에 데이터를 보관해서 뷰로 전달할 수 있다.
뷰는 request.getAttribute()를 사용해서 데이터를 꺼내면된다.
foward메서드를 통해서 /WEB-INF/views/save-result.jsp 경로에 있는 jsp페이지로 요청을 포워딩 할 수 있다.
포워딩
포워딩은 서버 내에서 요청을 전달하는 방식입니다. 클라이언트의 브라우저는 요청이 다른 리소스로 전달되는 것을 인식하지 못합니다.
요청 및 응답 객체 공유,URL 변경 없음
해당 과정을 통해 Controller,model(HttpServletRequest),view가 나뉘어져 있다.
MVC패턴의 한계
포워드 중복
view로 이동하는 코드가 항상 중복 호출되어야한다.
RequestDispatcher dispatcher = request.getRequestDispatcher(viewpath);
dispatcher.forward(request,response);
viewPath중복
prefix:/WEB-INF/views/중복
suffix: .jsp중복
사용하지 않는 코드
HttpServletResponse response사용하지 않았음
공통처리가 어려움
기능이 많아질수록 컨트롤러에서 공통처리를 해야할 부분이 많아진다.
ex) 요청마다 로그를 찍거나, 요청마다 처리하는 시간을 계산
단순히 이런 기능들을 메서드를 만들어, 서블릿 내부에서 호출하는 방식을 사용해도 되지만, 항상 메서드를 호출해야하고, 실수로 호출을 안할 수도 있다.
따라서 이런 문제를 해결하기위해 컨트롤러 호출전에 공통기능을 처리해야한다.
프론트 컨트롤러 패턴을 도입하면, 입구에서 공통 로직을 처리하고, 그다음 원하는 컨트롤러에 맞게 다시 매핑을 해주면된다.
MVC 프레임워크 만들기
서블릿 하나로 클라이언트 요청을 받는다.
프론트 컨트롤러가 공통처리 후에 요청에 맞는 컨트롤러를 찾아서 호출해준다.
프론트 컨트롤러를 제외한 나머지 컨트롤러는 서블릿을 사용하지 않아도 된다.
컨트롤러에다가 RequestMapping으로 url만 적어주면, 요청 보낸 URL에 맞게 알아서 매핑되어서 컨트롤러가 호출되었다.
앞단인 디스페쳐 서블릿이 처리를 해서 컨트롤러로 매핑해준거다.
즉, Spring MVC프레임워크에서는 프론트 컨트롤러인 DisPatcherServlet이 모든 웹 요청을 받아서 적절한 컨트롤러로 요청을 전달한다.
따라서 개별 컨트롤러는 직접 서블릿을 구현할 필요가 없다.
이제 순서에따라서 설명을 하나하나 해보겠다.
우선 핸들러 어댑터를 사용한 이유를 알아야한다.
핸들러에서 반환형이 어떤건 ModelView이고 어떤 핸들러는 viewPath만 반환할수 있다.
핸들러 어댑터를 사용하면 어떤 반환형이던 ModelView로 반환이 가능하여 유연성을 높일 수 있다.
@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front
controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {
private final Map<String, Object> handlerMappingMap = new HashMap<>();
private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();
public FrontControllerServletV5() {
//핸들러 등록
initHandlerMappingMap();
//핸들러 어댑터 등록
initHandlerAdapters();
}
//핸들러 등록
private void initHandlerMappingMap() {
handlerMappingMap.put("/front-controller/v5/v3/members/new-form", new
MemberFormControllerV3());
handlerMappingMap.put("/front-controller/v5/v3/members/save", new
MemberSaveControllerV3());
handlerMappingMap.put("/front-controller/v5/v3/members", new
MemberListControllerV3());
handlerMappingMap.put("/front-controller/v5/v4/members/new-form", new
MemberFormControllerV4());
handlerMappingMap.put("/front-controller/v5/v4/members/save", new
MemberSaveControllerV4());
handlerMappingMap.put("/front-controller/v5/v4/members", new
MemberListControllerV4());
}
//핸들러 어댑터 등록
private void initHandlerAdapters() {
handlerAdapters.add(new ControllerV3HandlerAdapter());
handlerAdapters.add(new ControllerV4HandlerAdapter());
}
//핸들러 호출시 무조건 service()메서드 호출됨
@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);
//어댑터에서 handle메서드 호출, 그럼 handle메서드 내부에서 핸들러의 process()메서드 호출됨
//viewPath를 반환하여도 ModelView반환
ModelView mv = adapter.handle(request, response, handler);
//viewResolver호출로 전체 경로 만듬
MyView view = viewResolver(mv.getViewName());
//render메서드 호출시 forward()됨
view.render(mv.getModel(), request, response);
}
private Object getHandler(HttpServletRequest request) {
String requestURI = request.getRequestURI();
return handlerMappingMap.get(requestURI);
}
private MyHandlerAdapter getHandlerAdapter(Object handler) {
for (MyHandlerAdapter adapter : handlerAdapters) {
if (adapter.supports(handler)) {
return adapter;
}
}
throw new IllegalArgumentException("handler adapter 를 찾을 수 없습니다.
handler=" + handler);
}
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
}
생성자를 통해서 핸들러 매핑과 어댑터를 초기화 한다.
핸들러 매핑
getHandler(request) 호출을 통해 요청으로 들어온 URL에 맞춰 이미 등록해놓은 handlerMappingMap에서 해당 핸들러를 반환받는다.
핸들러 어댑터 조회
해당 핸들러를 처리할 수 있는 핸들러 어댑터를 조회한다.
getHandlerAdapter(handler) 호출을 통해 이미 등록해놓은 handlerAdapters에서 각 핸들러 어댑터에서 suports메서드를 호출해서 해당 핸들러를 처리할 수 있으면 핸들러 어댑터를 가져온다.
핸들러 어댑터에서 handle메서드 호출
handler는 ControllerV4를 구현한 구현체이므로 캐스팅이 가능하다. 그다음에 handle메서드를 호출하면 핸들러의 process()메서드를 호출한다. 여기서 viewName을 String으로 반환하여도 ModelView로 어댑터가 만들어주기 때문에 유연성이 상승하였다.
다만, 항상 핸들러 어댑터가 ModelAndView를 반환해야하는건 아니다.
여기서는 viewPath와 ModelView를 반환하는 두 경우에 ModelView로 통일되게 처리하여 유연성을 보여주는거지
뒤에서 핸들러가 객체를 반환하는경우에는 ReturnValueHandler가 HTTP메시지 컨버터를 호출하여 객체를 JSON형식으로도 반환할 수 있게 한다.
뷰리졸버 호출
viewResolver(mv.getViewName());호출을 통해 상대경로를 절대 경로로 만들어준다.
render호출
반환된 Myview를 통해 view.render(mv.getModel(), request, response);호출을 하여 렌더링을한다.
실제 MVC도 동일한 과정을 거친다.
핸들러 조회
핸들러를 처리할 수 있는 어댑터 조회
핸들러 어댑터에서 handle메서드 호출
절대경로만들기
렌더링
그렇다면 우리가 만든게 아닌 실제 스프링 부트는 핸들러 매핑과 핸들러 어댑터를 어떻게 가져올까?
HandlerMapping
요청된 URL을 보고 핸들러를 찾아야한다.
@Controller내부에 @Component가 있다. 그래서 해당 컨트롤러가 빈으로 등록되면, Spring은 요청 URL과 HTTP 메서드에 기반하여 적절한 핸들러 메서드를 찾게 된다. 이 과정에서 우선적으로 @RequestMapping 애노테이션이 붙은 메서드를 찾는다.
그게 아니면, Controller인터페이스를 사용한다.
Component로 빈으로 등록하면, 요청이 들어오면 해당 빈을 찾아서 handleRequest를 호출하게 된다. handleRequest가 우리가 만든 process메서드와 동일하다고 보면된다.
HandlerAdaptorMapping
맵핑된 핸들러에 맞는 핸들러 어댑터를 가져와야하는데 @RequestMapping으로 매핑했다면, RequestMappingHandlerAdaptor를 우선적으로 가져오게된다.
앞에서 OldController는 Controller인터페이스를 사용하였는데,
HandlerAdaptor는 supprots를 오버라이딩 해야하고 instanceof에 Controller인터페이스가 맞는지 확인하는 과정을 거쳤다.
만약 OldController를 등록하였다면, 해당 supports가 true가 되어서 SimpleControllerHandlerAdaptor를 가져오게 될것이다.
그리고 핸들러 어댑터에서는 handle메서드를 호출할때, OldController의 handleRequest를 호출하게 하면된다.
뷰 리졸버
ModelView에는 상대경로만 들어있으므로, 절대경로 정보를 추가하여 상대경로를 절대경로로 만들어줘야한다.
우리는 따로 메서드를 통해서 절대경로를 만들어 주었지만,
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
스프링 부트에서는 InternalResouceViewResolver라는 뷰리졸버를 자동으로 등록하는데, 이때 application.properties에 등록한 spring.mvc.view.priefix,suffic정보를 사용해서 등록한다.
application.properties
여기에서 5번 ModelAndView까지 반환되었다고 치면, 해당 ModelView에 상대경로가 들어있고, 우리는 뷰리졸버를 호출해야한다.
우리는 new-form이라는 빈 이름으로 뷰를 만든게 없으므로,
InternalResourceViewResolver가 호출된다.
여기서 호출된 뷰 리졸버를 통해서 전체 경로를 얻게 된다.
그러면, internalResourceViewResolver가 View에 해당하는 internalResoucreView를 반환한다.
마지막으로 InternalResouceView에서 jsp처럼 forward를 호출해서 jsp를 실행한다.
왜 컨트롤러라 하지 않고 핸들러라고 한 이유를 알 수 있을것이다.
핸들러에는 Controller인터페이스 방식 HttpRequesthandler방식, RequestMapping 애노테이션 방식등 여러가지 방식이 있다.
우리는 컨트롤러에서 @RequestMapping을 사용하는 방식을 주로 사용하지만, 다른 방식이 있으므로 컨틀롤러가 아닌 핸들러라고 하는것이다.
정리해보자면
1. URL패턴으로 요청이 들어온다.
2. URL 패턴과 동일한 핸들러 매핑을 실행한다.
요청매핑
@RestController
@Controller같은 경우에는 반환형이 String이면 뷰이름으로 인식하고 뷰를 찾고 렌더링한다.
RestController를 사용하면 반환값으로 뷰를 찾는게 아니라 HTTP메시지 바디에 바로 입력한다.
즉, return되는 문자열을 렌더링 할 수 있다.
@RequestMapping에 method = RequestMethod.GET을 지정하지 않으면 HTTP메서드와 무관하게 전부 호출되므로 method지정이 필수다.
이걸 @GetMapping으로 줄일 수 있다.
@RequestMapping은 URL경로를 템플릿화 할 수 있는데 @PathVariable을 사용하면 매칭되는 부분을 편리하게 조회 가능하다.
@PostMapping(value = "/mapping-consume", consumes = "application/json")
미디어 타입을 가지고 조건매핑할 수 있다.
만약 HTTP 요청의 Content-Type헤더가 application/json이 아니면 매핑되지 않는다.
@PostMapping(value = "/mapping-produce", produces = "text/html")
이건 Accept헤더를 기반으로 매핑한다.
참고: Content-Type헤더는 클라이언트가 서버로 보내는 타입을 말하고, Accept헤더는 서버가 클라이언트로 보낼때 클라이언트가 받을 수 있는 type이다.
클라이언트가 서버로 데이터를 보낼때
이 두가지 방식으로 전달할때,
@RequestParam
@ResponseBody
@RequestMapping("/request-param-required")
public String requestParamRequired(
@RequestParam(required = true, defaultValue = "guest") String username,
@RequestParam(required = false, defaultValue = "-1") Integer age) {
log.info("username={}, age={}", username, age);
return "ok";
}
required와 defaultValue를 설정할 수 있고,
@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";
}
Map으로 받을 수 있다.
만약 key 1개에 여러개의 값이 들어온다면, username=kim&username=go Map이 아니라 MultiValueMap을 써야한다.
요청 파라미터로 객체를 받을 수 는 없다. 그러나 요청파라미터를 객체로 바인딩을 자동적으로 해줄 수 있는데 이게 @ModelAttribute이다.
@ModelAttribute애노테이션이 있으면, helloData 객체를 생성한다.
요청파라미터 이름으로 HelloData객체 프로퍼티를 찾는다.
요청 파라미터 username을 가지고 HelloData의 useranme 프로퍼티를 찾고 해당 프로퍼티의 setter를 호출해서 파라미터 값을 바인딩한다.
HTTP 메시지 바디를 통해서 직접 데이터가 넘어오는경우
@RequestBody
HTTP메시지 바디 정보를 직접 가져와서 조회하는 @RequestBody와
HTTP메시지 바디에 직접 정보를 담아서 전달하는 @ResponseBody가 세트임
HTTP메시지 바디에 data를 전달하므로 view를 사용하지 않음.
그래서 @Controller는 String이 반환형이면 viewPath를 찾아서 가니까 @ResponseBody를 사용해 메시지 바디에 data를 콱넣어서 반환하는거임
또한 RequestBody를 사용하면 직접 만든 객체를 지정할 수 있다.
HTTP메시지 바디에 들어있는 JSON형식은 어차피 문자열이다. 그러나 HTTP메시지 컨버터가 객체로 변환을 해준다.
또한 ResponseBody를 적으면 문자열을 그대로 반환할 수 있다고 했는데
객체를 반환해도 HTTP메시지 컨버터가 JSON응답으로 가능하게 문자열 변환도 해준다.
참고: HTTP메시지 바디를 직접 조회함과 더불어 헤더 정보도 필요하면, HTTPEntity사용.
HTTP응답
정적리소스
src/main/resources/static/basic/hello-form.html 경로에 파일이있으면
http://localhost:8080/basic/hello-form.html을 실행하면 그대로 띄워줌
뷰 템플릿
뷰 템플릿을 거쳐서 HTML이 생성되고, 뷰가 응답을 만들어서 전달한다.
스프링 부트는 기본 템플릿 경로를 src/main/resources/templates로 제공한다.
HTTP API,메시지 바디에 직접입력
@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);
}
@ResponseBody를 쓰고 HelloData를 반환하면, HTTP메시지 컨버터가 문자열로 바꿔서 메시지 바디에 콱넣어서 반환해주는데, 이건 ResponseBody가 없어도 ResponseEntity를 반환하면 HTTP컨버터가 변환해준다.
참고: RestController를 사용하면 모든 컨트롤러에 ResponseBody가 적용된다.
HTTP메시지 컨버터
@ResponseBody애노테이션이 달려있으면, viewResolver대신에 HttpMessageConverter가 동작한다.
HttpMessageConverter는 인터페이스로 되어있는데 구현체가 JsonConverter,StringConverter등 여러개가 존재하기 때문이다.
스프링 MVC는 다음의 경우에 HTTP메시지 컨버터를 적용한다.
HTTP메시지 컨버터는 HTTP요청과 응답 둘다 사용되고, canRead와 canWrite메서드가 있다.
여기서 MediaType파라미터가 있는데, 해당 클래스와 미디어 타입을 지원하는지 두가지를 체크한다.
boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);
boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);
스프링 부트 기본 메시지 컨버터 우선순위
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관련
미디어타입: 클라이언트가 보내는 HTTP메시지 바디에있는 것의 type
쓰기 미디어타입: 서버가 보내는 응답 HTTP메시지 바디에 있는것의 Type
HTTP요청 데이터 읽기 과정
@RequestMapping
void hello(@RquestBody String data){
}
content-type: application/json
@RequestMapping
void hello(@RequestBody HelloData data) {}
만약 이 형식이면 mappingJackson2HttpMessageConverter가 작동
HTTP 응답 데이터 생성
그렇다면 HTTP메시지 컨버터는 스프링 MVC에서 어디에서 사용하는것일까?
이전의 서블릿에서 보면
createParamMap()메서드 호출을 통해서 username:kim,age:20을 만들어서 process메서드에 건내주었다.
이 파라미터를 ArgumentResolver가 해준다.
RequestMapping핸들러 어댑터가 handle메서드를 호출할때 ArgumentResolver를 호출해서 핸들러(컨트롤러)가 필요로하는 다양한 파라미터값을 생성하고 넘겨준다.
ArgumentResolver는 정확히 말하면, HandlerMethodArgumentResolver인데
supports메서드로 해당 파라미터를 지원하는지 체크하고, 지원하면 resolveArgument()를 호출해서 실제 객체를 생성하는것이다. 그리고 생성된 객체가 컨트롤러 호출시 넘어가는것이다.
반환도 마찬가지이다. 핸들러에서 ModelAndView,@ResponseBody,HttpEntity등의 타입으로 반환하게되면, ReturnValueHandler에서 객체를 반환하면 Json형식으로 변환하여 클라이언트에 응답을 하게 되고, ModelAndView를 반환하면 뷰를 렌더링해준다.
그렇다면 HTTP 메시지 컨버터 위치는 도대체 어디냐?
바로 ArgumentResolver와 ReturnValueHandler가 HTTP메시지 컨버터를 사용한다.
ArgumentResolver가 파라미터에 해당하는 객체를 만들때, 단순하게 String이라던지 처리할 수 있는것들은 그냥쓰고, 만약 @RequestBody,HttpEntity 같은 메시지 바디에서 데이터를 꺼내서 객체를 만들어야하면, HTTP메시지 컨버터를 사용해서 만들어진 객체를 핸들러한테 넘겨주게된다.
ReturnValueHandler도 마찬가지다. String이나 상태코드를 반환하는건 그냥 쓰면되는데, 응답 데이터를 HTTP메시지에 입력하는 @ResponseBody나 HTTP엔티티같은것을 처리하기 위해서는 HTTP메시지 컨버터를 사용해서 HelloData객체를 만들어서 반환하는것이다.
결국 종합하자면, 요청파라미터에 @RequestBody가 있으면 항상 ArgumentResolver가 동작하여 JSON문자열을 객체로 바꿔주는 HTTP메시지 컨버터가 동작한다.
만약 @ResponseBody가 없으면 그냥 이전에 MVC패턴 만든것처럼 뷰를 반환하므로 ReturnValueHandler가 동작하지 않는다.
그러나 @ResponseBody가 있다면 핸들러가 뱉은 객체를 JSON형식의 문자열로 바꿔야하므로 ReturnValueHandler가 호출되고, 여기서 Http메시지 컨버터를 사용하여 변환해준다.
PRG
상품등록후 상품 상세 페이지로 이동한다고 치자.
return basic/item이므로 상품등록후 item페이지로 가게된다.
문제는 여기서 새로고침을 누르면 상품등록이 또 된다는것이다.
그이유는 새로고침은 이전 요청을 다시 보내는건데,
마지막 요청이 바로 Post 상품등록 이기 때문이다.
그러므로 Post를 하면 redirect 요청을 하게 하는것이다. 그러면 클라이언트 쪽에서 Redirect를 받으면 다시 서버측에 해당 REdirect url로 요청하므로,
클라이언트는 Post로 요청을 보냈으나 Redirect로 인해 마지막으로 보낸 요청이 Get/items가 된다.
고로 새로고침을 눌러도 get요청을 다시 보내게 된다.
Validation
Bean Validation 특정 필드에 대한 검증 로직을 애노테이션을 사용하여서 검증하는하는것이다.
참고로 BeanValidation은 특정한 구현체가 아니다. BeanValidation 2.0이라는 표준기술이다.
마치 JPA가 표준기술이고 구현체로 하이버네이트가 있는것과 같은데
BeanValidation 자체가 실제로 작업을 수행하는 코드는 아니고, 검증을 위해 따라야할 규칙과 구조를 정의한 것이다.
구현체는 하이버네이트 Validaitor를 사용한다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult
bindingResult, RedirectAttributes redirectAttributes) {...}
우리는 @Valid애노테이션을 통해서 검증이 가능하다.
스프링 MVC는 어떻게 Bean Validator를 사용하는 것일까?
스프링부트가 spring-boot-starter-validation 라이브러리를 넣으면, 자동으로 BeanValidator를 인지하고 스프링에 통합한다.
스프링부트는 자동으로 LocalValidatorFactoryBean을 글로벌 Validator로 등록한다.
해당 Validator가 @NotNull같은 애노테이션을 보고 검증을 수행한다.
검증순서
즉 바인딩에 성공한 필드만 BeanValidation에 적용한다. 당연히 price에 문자 'A'를 입력하면 이걸 10부터 10000까지 검증할 이유가 없으니까 말이다.
그리고 검증오류가 발생하면, FieldError,ObjectError등을 생성해서 BindingResult에 담아준다.
FiedlError
만약 필드에 @NotBlank, @Range를 붙였는데 에러가 발생하면,
이런식으로 오류가 발생하면 된다.
에러코드를 수정하고 싶으면 다음의 우선순위를 따르면 된다.
errros.properties
NotBlank.item.itemName = 상품이름은 꼭 적어주세요
NotBlank.item.username = 사용자이름은 필수입니다.
NotBlank.item = 공백은 불가합니다.(itemName,username은 세부적인 오류 코드, 나머지는 item선에서 처리)
애노테이션의 message속성사용
@NoBlank(message = "상품이름은 꼭 적어주세요")
private String itemName;
라이브러리가 제공하는 기본 값 사용 -> 공백일 수 없습니다.
Object 오류
필드에러가 아니라, 뭐 수량 * 가격은 100000을 넘을 수 없는 경우를 말한다.
이런경우는 다양한 경우가 있으므로 자바 코드로 작성하는게 깔끔하다.
저장과 수정에서 Item의 요구사항이 다를수 있으므로 Item에다가는 검증 애노테이션을 전부 삭제한다.
수정과 등록에 각각 다른 요구사항들을 검증 애노테이션을 필드에 등록한다.
상품을 등록할때의 요구사항을 자바코드로 작성한다.
만약 오류가 있다면 BindingResult에 담는다.
그리고 해당 BindingResult의 hasError()메서드를 호출하여서 저장로직을 수행할지 말지를 결정한다.
수정도 마찬가지이다.
BindingResult는 타임리프를 통해서 오류내용을 뿌려줘도 되고, JSON형식으로 반환해도 된다.
@Model Attribute vs @RequestBody
Http요청 파라미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용된다. 필드를 자바 프러퍼티 접근법으로 하나하나 접근하기 때문에 특정 필드에 타입이 맞지않는 오류가 발생해도 나머지 필드는 정상 처리가 가능하다.
다만, HttpMessageConverter는 @ModelAttribute와는 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용되므로, 따라서 메시지 컨버터의 작동이 성공하여서 ItemSaveForm 객체를 만들어야 @Validated 가 적용된다.
로그인처리 - 세션,쿠키
서블릿은 세션을 위해 HttpSession이라는 기능을 제공한다.
HttpSession을 생성하면 JSESSIONID라는 이름의 쿠키를 생성하고 값은 추정불가능한 랜덤값으로 생성한다.
로그인시에 id,password로 Member가 존재함을 검증하면 세션에다가 저장한다.
그리고 response에다가 sessionId로 만든 쿠키를 넣어서 반환한다.
//로그인 성공 처리
//세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
HttpSession session = request.getSession();
//세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
request.getSession(true): 세션이 있으면 기존세션반환, 없으면 생성해서 반환, default가 true
request.getSession(false): 세션이 있으면 기존세션반환, 세션이 없어도 생성하지 않고, null반환
로그아웃
@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
//세션을 삭제한다.
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
return "redirect:/";
}
로그아웃시 Member뿐만 아니라 해당 Member에 연관된 다른 세션들도 저장되어있다. 고로 전부다 지우는게 맞다.
로그인 확인
@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model) {
//세션이 없으면 home
HttpSession session = request.getSession(false);
if (session == null) {
return "home";
}
Member loginMember = (Member)
session.getAttribute(SessionConst.LOGIN_MEMBER);
//세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
TrackingModes
로그인 처음 시도시 jessionId를 url에 포함하고 있음.
http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872
웹브라우저가 쿠키를 지원하지 않는경우, url을 통해서 세션을 유지하는 방법이다.
서버측에서 웹 브라우저가 쿠키를 지원할지 안할지 모르니까, 일단 최초에는 쿠키값도 전달하고, URL에 Jessionid도 함께 전달하는것이다.
URL전달방식을 끄고, 항상 쿠키를 통해서만 세션을 유지하고 싶다면
server.servlet.session.tracking-modes=cookie
타임아웃
로그아웃을 누르고, session.invalidate()가 호출되면 세션삭제가 된다.
사용자가 로그아웃을 누르지 않고, 브라우저 종료시 HTTP가 ConnectionLess상태이므로 서버입장에서는 해당 사용자가 웹브라우저를 종료했는지 안했는지 알 수 없다.
만약 남아있는 세션을 무한정 보관한다면,
세션과 관련된 쿠키를 탈취한 경우 시간이 지나도 해커가 이 쿠키를 통해서 요청을 보낼 수 있다.
세션은 일단 메모리에 생성되므로 크기가 유한하므로, 지우지 않으면 장애가 발생한다.
server.servlet.session.timeout=1800(30분)
마지막 요청으로부터 세션사용시간이 30분이 지나면 was가 내부에서 해당 세션을 제거한다.
요청을 30분내에 다시하면 수명이 30분 연장된다.
예제처럼 key:sessionId, value:Member로 넣어두면 안된다. 데이터 용량 * 사용자수로 세션의 메모리 사용량이 엄청나게 늘어나서 장애가 발생할 수 있다.
고로, Member의 핵심적인 부분만 저장하는게 좋다. 예를들어 member_id정도로 설정하는것이 좋다.
로그인 필터와 인터셉터
로그인을 한 사용자만 상품 관리 페이지에 들어가려면 어떻게 해야할까?
컨트롤러의 메서드마다 전부, 요청이 왔을때 로그인이 되어있는지 안되어있는지 검사를 하면 될것이다.
그러나, 이렇게 하면 까먹을 수도 있고, 이런 애플리케이션 여러 로직에서 공통으로 관심이 있는것을 공통 관심사라고 한다.
이런 공통 관심사는 스프링 AOP로도 해결할 수 있지만, 웹과 관련된 공통 관심사를 처리할 때는 서블릿 필터, 또는 스프링 인터셉터를 사용하는것이 좋다.
서블릿 필터
필터의 흐름
HTTP요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러
WAS로 요청이 들어오면, 필터를 거친후에 서블릿이 호출됨.
스프링을 사용하는 경우 서블릿이 디스페처 서블릿이다.
필터에서 적절하지 않은 요청이라고 판단하면, 서블릿을 호출하지 않고 끝냄
필터는 체인으로 구성되어있으므로 필터1 -> 필터 2 -> 필터 3 으로 체인 연결가능
필터 인터페이스
init(): 필터 초기화 메서드, 서블릿 컨테이너가 생성될때 호출됨
doFilter(): 고객의 요청이 올때 마다 해당 메서드가 호출된다. 필터의 로직을 구현하면 된다.
destory(): 필터 종료 메서드, 서블릿 컨테이너가 종료될때 호출된다.
@Slf4j
public class LoginCheckFilter implements Filter {
private static final String[] whitelist = {"/","/members/add","/login","/logout","/css/*"};
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
HttpServletResponse httpResponse = (HttpServletResponse) response;
try{
log.info("인증 체크 필터 시작 {}",requestURI);
if(isLoginCheckPath(requestURI)){
log.info("인증체크로직 실행{}",requestURI);
HttpSession session = httpRequest.getSession(false);
if(session==null||session.getAttribute(SessionConst.LOGIN_MEMBER)==null) {
log.info("미인증 사용자 요청 {}", requestURI);
httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
return;
}
}
chain.doFilter(request,response);
}catch (Exception e){
throw e;
}finally {
log.info("인증체크필터 종료{}",requestURI);
}
}
private boolean isLoginCheckPath(String requestURI) {
return !PatternMatchUtils.simpleMatch(whitelist,requestURI);
}
}
webConfig에 LoginCheckFilter를 추가한다.
@Bean
public FilterRegistrationBean logCheckFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
스프링 인터셉터
서블릿 필터 - 서블릿이 제공
스프링 인터셉터 - 스프링 MVC가 제공
스프링 인터셉터 흐름
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
인터셉터는 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출된다.
왜냐하면 인터셉터는 스프링 MVC가 제공하는 기능이기 때문이다.
해당 서블릿이 디스패처 서블릿인데, 스프링 MVC의 시작점이 디스패처 서블릿으로 생각해보면 이해가 된다.
스프링 인터셉터도 체인이라 여러개의 인터셉터를 체인형식으로 구성할 수 있다.
스프링 인터셉터 인터페이스
public interface HandlerInterceptor {
default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
return true;
}
default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable ModelAndView modelAndView) throws Exception {
}
default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable Exception ex) throws Exception {
}
}
인터셉터에서는 서블릿 필터와 다르게 컨트롤러 호출전, 호출후, 요청 완료 이후와 같이 단계적으로 세분화가 잘 되어있다.
스프링 인터셉터 호출 흐름
정상 요청의 경우
prehandle: 컨트롤러 호출 전에 호출된다.(정확히는 핸들러 어댑터 호출전에 호출)
false라면 나머지 인터셉터는 물론이고, 핸들러 어댑터 또한 호출되지 않는다.
posthandle: 컨트로러 호출 후에 호출된다.
afterCompletion: 뷰가 렌더링 된 후에 호출
스프링 인터셉터 예외
만약 핸들러에서 예외가 발생되면,
prehandle은 컨트롤러 호출전에 호출되므로, 호출됨
posthandle은 호출안됨
afterCompletion은 예외의 발생여부과 관계없이 항상 호출됨, 이 경우 예외를 파라미터로 받으므로 어떤 예외가 발생했는지 로그를 찍을 수 있음.
스프링 인터셉터 - 인증체크
요청을 보냈을때 핸들러 어댑터로 보내주기전에, 로그인이 안되어있으면 false를 반환하고, 핸들러 어댑터로 안어 가면 되기 때문이다.
session을 확인하는 로직을 서블릿 필터와 비슷하다.
등록
서블릿 필터에서는 whitelabel 배열을 만들어서 넣고 이랬는데 여기서는 그냥 .excludePathPatterns에다 추가하여서 제외할 수 있다.
ArgumentResolver 활용
@Login애노테이션을 만들고, 각 컨트롤러의 메서드에서 확인한다면 인터셉터를 만들 필요 없이 더 편한 부분이 있을 수도 있다.
@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model
model) {
//세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
메서드 파라미터에 @Login 애노테이션을 붙였다.
그리고 null이면 home으로 이동하였다.
1. Login애노테이션 생성
파라미터에만 사용할 수 있게 하였고, 리플랙션등을 활용할 수 있게 런타임까지 애노테이션 정보가 남아있게 했다.
2. LoginMemberArgumentResolver
supportsParameter에서 @Login파라미터가 아규먼트에 있는지, @Login 애노테이션에 해당하는 객체 type이 member가 맞는지 검증한다.
resolveArgument메서드에서는 파라미터로 NativeWebRequest를 지원하므로 HttpServletRequest로 캐스팅하고, request.getSession(false)로 세션을 가져온다음에, getAttribute로 key에대한 value를 반환하면 된다.
3. WebMVCConfigurer에 설정추가
ArgumentResolver활용은 이러한 단점이 있다.
1. 애노테이션 등록이 번거롭다.
2. 로그인 유무를 검증하는 메서드의 파라미터에 항상 Member 파라미터가 존재해야만 한다.
만약 로그인을 하고 접근해야하는 메서드에 Member파라미터가 없는경우는 검증이 불가능하다.
스프링부트와 서블릿이 어떻게 예외를 처리하는지 알아보자.
서블릿 컨테이너
스프링이 아닌 순수한 서블릿 컨테이너는 예외를 Exception, response.sendError 두가지 방식으로 처리한다.
웹 애플리케이션에서는 사용자 요청별로 쓰레드가 별도로 할당되고, 서블릿 컨테이너 안에서 실행된다.
만약 오류가 터졌는데 try-catch로 잡으면 괜찮은데 애플리케이션 내부에서 예외를 잡지 못하고 서블릿 밖으로까지 예외가 전달되면, 톰캣과 같은 WAS까지 예외가 전달된다.
WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
톰캣과 같은 WAS까지 예외가 전달되면 WAS는 어떻게 할까?
해당 url로 get요청을 보내면, Exception을 던지던가, response.sendError를 호출한다.
response.sendError를 호출한다고, 당장 예외가 발생했다는것이 아니라, 서블릿 컨테이너한테 예외가 발생했다는것을 단순히 전달하는 역할을한다.
즉, 컨트롤러에서 response.sendError()를 호출하면 response내부에 오류가 발생했다는 상태를 저장한다.
서블릿 오류페이지 등록
component가 달려있으므로 톰캣이 뜰때 에러페이지를 등록해준다.
404,500,RuntimeException에 해당하는 오류가 발생시 해당 경로로 이동하게 해준다.
해당 오류를 처리할 컨트롤러
/error-page/404경로로 요청이 들어오면 view를 반환한다.
결국 WAS까지 에러가 전파가 되면, 이 예외를 처리하는 ErrorPage가 있는지 확인하고, 해당하는 경로를 호출하게 된다.
그렇다면 해당경로를 처리하는 컨트롤러에서 return에 해당하는 view path를 가지고 뷰를 반환하게 된다.
즉, 서블릿에서 오류 페이지 작동원리를 다시 정해보면
예외 발생 흐름
WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
sendError() 흐름
WAS(sendError 호출 기록 확인) <- 필터 <- 서블릿 <- 인터셉터 <- 컨틀롤러(response.sendError())
WAS가 등록된 오류페이지 정보를 확인한다. -> 확인한 뒤에 오류페이지에 해당하는 경로를 다시 요청한다.
WAS는 오류 페이지를 단순히 다시 요청하는게 아니라, 오류정보를 request.setAttribute 형식으로 추가해서 넘겨준다.
그래서 request.getAttribute형식으로 오류를 확인할 수 있다.
핵심은 웹브라우저(클라이언트)는 서버 내부에서 이런일이 일어나는지 모른다는 것이다. 오직 서버 내부에서 오류 페이지를 찾기위해서 추가적인 호출을한다.
이렇게 WAS까지 왔다 갔다 하는 시점에 필터와 인터셉터를 두번씩 거치게 된다.
클라이언트로 부터 발생한 정상 요청인지(1번), 아니면 오류 페이지를 출력하기 위한 내부 요청인지 구분할 수 있어야한다(2번).
서블릿은 DispatcherType을 통해서 추가정보를 제공한다.
필터
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new
FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST,
DispatcherType.ERROR);
return filterRegistrationBean;
}
}
Log를 찍는 LogFilter를 만들엇고, DispatcherTypes가 REQUEST와 ERROR 두개를 설정하였다.
고로, 클라이언트 요청은 물론이고, 오류페이지 요청에서도 해당 LogFilter가 호출될 것이다.
아무것도 넣지 않으면, default가 request라 클라이언트 요청이 있는경우에만 필터가 적용된다.
localhost:8080/error-page/400 요청시 -> REQUEST이므로 LogFilter 한번 찍히고 -> 컨트롤러에서 예외발생하므로 WAS까지 전파 -> WAS에서 다시 ErrorPage경로로 요청보냄 -> DispatchType이 Error라 LogFilter 다시 찍힘
그런데 당연히 우리는 클라이언트 요청만 로그를 찍히게 하고 싶을것이다.
이번엔, 인터셉터로 확인하는 방법을 알아보자.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error","/error-page/**");
}
}
exclude 패턴으로 /error,/error-page/**를 등록했으므로, WAS에서 ErrorPage로 다시 요청을 보낼때는 exclude 패턴에 해당되므로 해당 인터셉터가 작동하지 않는다.
그렇다면 서블릿이 아닌 스프링 부트에서는 오류 페이지를 어떻게 처리할까?
우리는 서블릿을 사용하면, WebServerCustomizer를 통해서 예외 종류에 따라서 ErrorPage를 등록하고, 예외 처리용 컨트롤러 ErrorPageController를 만들었다.
그러나 스프링 부트는 이런 과정을 모두 기본으로 해준다.
ErrorPage를 자동으로 등록하고, /error를 기본 오류 페이지로 설정한다.
상태코드와 예외를 설정하지 않으면 모두 기본 오류페이지로 가게된다.
그렇다면 개발자는 다른것 필요없이, 오류 페이지만 등록하면 된다.
뷰선택 우선순위
우리는 뷰만 등록하면 해당 뷰로 ErrorPage를 자동으로 등록해준다.
지금까지는 오류페이지를 보여준것이다. 그렇다면, API예외처리는 어떻게 할까? 오류페이지는 단순히 고객에게 오류 화면을 보여주면 끝이지만, API는 오류 상황에 맞게 오류 응답 스펙을 정하고, JSON으로 데이터를 내려줘야한다.
서블릿 - API 처리
서블릿에서 API를 처리하기 위해서는 이전에는
이런식으로 WAS가 다시 /error-page/500으로 요청했을때 return 으로 view를 반환하였지만,
해당 RequestMapping을 추가한다.
@RequestMapping(value = "/error-page/500",produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String,Object>> errorPage500Api(HttpServletRequest request, HttpServletResponse response){
log.info("API errorPage 500");
Map<String,Object> result = new HashMap<>();
Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);
result.put("status",request.getAttribute(ERROR_STATUS_CODE));
result.put("message",ex.getMessage());
Integer statusCode = (Integer) request.getAttribute(ERROR_STATUS_CODE);
return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}
/ex경로로 요청을 보내면 -> RuntimeException 발생 -> WAS까지 오류 전파 -> Error-page에서 RuntimeException에 대한 내용은 /error-page/500으로 요청을 다시보내야함을 확인 -> client가 Accept헤더를 application/json으로 설정하였을 경우 errorPage500Api메서드를 호출해서 Json형식으로 반환하게 된다.
스프링 API 처리
스프링 부트는 똑똑하게 API 예외 처리도 기본적으로 제공을 해준다.
여기서 Accept를 application/json으로 설정한 것을 볼 수 있는데,
/ex경로로 요청을 보내면 RuntimeException을 던지고, 스프링 부트 입장에서는 WAS가 /error경로로 요청을 보내게 된다.
이 /error경로를 처리해주는게 BasicErrorController이고
내부에 produce type에 따라 메서드가 이미 정의되어있다.
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse
response) {}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
/error라는 동일한 경로를 처리하는 메서드 중에서도 Accept type을 보고 메서드를 호출해 주는 것이다.
그런데 문제가 있다. WAS에서 Error 요청을 보낼때는 항상 500으로 보내기 때문이다.
예를 들어, IllegalArgumentException같은 4로 시작하는 Client Error가 발생했다고 치자.
그렇다고 하더라도, WAS까지 전파된후에 WAS가 /error로 요청을 보낼때는 서버 내부의 오류라고 판단해서 500에러를 던지게 된다.
ExceptionResolver를 적용하자.
ExceptionResolver를 사용하면 컨트롤러에서 예외가 발생하더라도, ExceptionResolver에서 예외를 처리하기 때문에 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외처리가 끝나게 된다.
결과적으로 WAS입장에서는 정상처리가 된것이다.
서블릿 컨테이너까지 예외가 전달되면 다시 요청을 보내는 과정이 복잡하고 지저분하므로 ExceptionResolver에서 해결하였다.
API 예외 처리 - 스프링이 제공하는 ExcpetionResolver
ResponseStatusExceptionResolver
이 두가지의 경우에 해당 리졸버가 처리한다.
@ResponseStatus
@ResponseStatus(code = HttpStatus.BAD_REQUEST,reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException{
}
컨트롤러에서 Bad_REQUEST 예외가 발생
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1(){
throw new BadRequestException();
}
-> ResponseStatusExcpetionResolver가 해당 애노테이션을 확인 -> 오류코드를 HttpStatus.BadRequest로 변경하고 메시지도 담는다.
ResponseStatusExcpetionResolver의 내부코드에는 response.sendError를 호출하는데, 이러면 WAS에서 다시 오류페이지 /error를 내부요청하게 된다.
@ResponseStatus와 같이 개발자가 직접 변경할수없는 라이브러리에는 적용이 불가능하다.
그러므로, @ResponseStatus 애노테이션을 이용한 예외 클래스를 직접 만드는 것이 아니라, ResponseStatusException을 직접 이용한다.
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2(){
throw new ResponseStatusException(HttpStatus.NOT_FOUND,"error.bad",new IllegalArgumentException());
}
DefaultHandlerExceptionResolver
Integer타입의 data에 qqq를 넣었는데, 파라미터 바인딩 시점에 타입이 맞지않으면, TypeMismatchException이 발생하고 이걸 try catch안하면 WAS까지 올라가서 500에러가 발생해야하는 것이 맞다.
그러나 스프링의 DefaultHandlerExceptionResolver가 해결해주게된다.
발생할수있는 에러에 대한 코드와 내용을 전부 담아둬서 여기서 instanceof로 확인해서 반환한다.
ExceptionHandlerExceptionResolver
결국 이 애노테이션을 배우기 위해서 많은 내용을 배웠는데
HTML 화면 오류를 보여주고싶은경우
BasicErrorController를 사용하여서 /errors 디렉토리 하위에 파일로 5xx,4xx등등 HTML을 만들면 알아서 WAS에서 재요청하는것 까지 다 해준다.
API 오류 응담
이게 문제가 뭐냐면 앞서서 말했던 것처럼, 예외에 따라서 각각 다른 데이터를 출력해줘야하는데 이때 사용되는것이 ExcpetionHandler이다.
@ExceptionHandler
@Slf4j
@RestController
public class ApiExceptionV2Controller {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e){
log.error("[exceptionHandle] ex",e);
return new ErrorResult("BAD",e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e){
log.error("[exception Handle] ex",e);
ErrorResult errorResult = new ErrorResult("USER-EX",e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e){
log.error("[exceptionHandle] ex",e);
return new ErrorResult("EX","내부 오류");
}
@GetMapping("/api2/members/{id}")
public MemberDto getMember(@PathVariable("id") String id){
if(id.equals("ex")){
throw new RuntimeException("잘못된 사용자");
}
if(id.equals("bad")){
throw new IllegalArgumentException("잘못된 값 입력");
}
if(id.equals("user-ex")){
throw new UserException("사용자 요류");
}
return new MemberDto(id,"hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto{
private String memberId;
private String name;
}
}
컨트롤러에서 IllegalArgumentException이 터지면, ExcpetionResolver를 통해서 오류 해결을 시도한다.
그러면 우선순위가 가장높은 ExceptionHandlerExceptionResolver가 @ExceptionHandler가 있는지 확인한다.
그리고, ExceptionHandler가 있으면 오류를 WAS까지 다시 보내서 /error로 요청하고 이게 아니라, 정상흐름 처럼 작동하여 서블릿 컨테이너로 에러가 전파되지 않는것이다.
만약 IllegalArugmentException이 터진경우 @ExcpetionHandler의 IllegalArgumentException.class를 확인하고, illegalExHandle메서드가 실행되게 된다.
그러면 왜 여기서 @ResponseStatus(HttpStatus.BAD_REQUEST)가 붙어있는지 알 수 있다.
왜냐하면, ErrorResult가 반환은 당연히 되는데, ResponseStatus가 없으면 400오류가 아니라 200 정상이 될것이다.
왜냐하면 우리는 정상 흐름처럼 작동하게 오류 예외 처리를 해주었기 때문이다.
그러므로, 상태코드를 통해서 오류임을 명시해줘야한다.
@RestController의 @ResponseBody가 적용되어서 HTTP메시지 컨버터가 사용되고, JSON형식으로 변환해서 반환된다.
userExHandle처럼 당연히 ResponseEntity를 사용할 수도 있다. 여기서는 상태코드를 지정할 수 있으니까 ResponseStatus를 사용하지 않았음을 확인 할 수 있다.
API 예외처리 @ControllerAdvice
@ExceptionHandler를 사용해서 예외처리를 했지만 컨트롤러 내부에 정상코드와 예외 처리 코드가 섞여있어서 불편하다.
@ControllerAdvice와 @RestControllerAdvice를 사용하면 분리할 수 있다.
@Slf4j
@RestController
public class ApiExceptionV2Controller {
@GetMapping("/api2/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값");
}
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
컨트롤러는 동일하게 두고
예외 처리 코드만 빼와서 ExControllerAdvice를 만들었다.
@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
log.error("[exceptionHandle] ex", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
}
@RestControllerAdvice는 @ControllerAdvice와 같고, @ResponseBody가 추가되어있다.
타입 컨버터
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data){
System.out.println("data = " + data);
return "ok";
}
URL경로, 쿼리파라미터, 스프링 MVC요청 파라미터 @RequestParam, @ModelAttribute,@PathVariable등등 이 모든것들은 그냥 문자이다.
이걸 중간에서 스프링이 문자를 해당 타입으로 변환해주는것이다.
타입컨버터를 직접 사용하려면 Converter인터페이스를 구현하면된다.
@Slf4j
public class StringToIpPortConverter implements Converter<String, IpPort> {
@Override
public IpPort convert(String source) {
log.info("convert source = {}",source);
String[] split = source.split(":");
String ip = split[0];
int port = Integer.parseInt(split[1]);
return new IpPort(ip,port);
}
}
Converter<A,B>이면 A를 입력해서 B로 만들어야한다는것이다.
A: String 127.9.9.1:8080
B: IpPort 객체를 반환
스프링에 Converter적용
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry){
registry.addConverter(new IpPortToStringConverter());
registry.addConverter(new StringToIpPortConverter());
}
}
사용
@GetMapping("/ip-port")
public String ipPort(@RequestParam IpPort ipPort){
System.out.println("ipPort IP = " + ipPort.getIp());
System.out.println("ipPort PORT = " + ipPort.getPort());
return "ok";
}
@RequestParam을 사용할때 Converter를 적용하여서 변환해준다.
포멧터
컨버터는 일반적인 객체 -> 객체로 변환할때 사용한다.
포멧터는 1000이라는 숫자를 문자 1,000로 바꾸거나
날짜 객체를 문자인 "2024-08-21"등으로 출력하거나 그 반대의 상황같이, 특정한 형식으로 바꿀때 많이 사용된다.
즉 문자로 바꾸는데 특화된 기능이다.
@Slf4j
public class MyNumberFormatter implements Formatter<Number> {
@Override
public Number parse(String text, Locale locale) throws ParseException {
log.info("text={}, locale={}",text,locale);
NumberFormat format = NumberFormat.getInstance(locale);
return format.parse(text);
}
@Override
public String print(Number object, Locale locale) {
log.info("object={}, locale = {}",object,locale);
return NumberFormat.getInstance(locale).format(object);
}
}
Fomatter를 구현할때는 parse와 print를 오버라이딩 해주면된다.
parse는 문자 -> 숫자
print는 숫자 -> 문자
로 변환해주는것이다.
직접 숫자 1,000을 1000으로 바꾸는 로직을 구현하지 않고, NumberFormat이라는 클래스를 사용하였다.
포멧터 등록
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry){
registry.addConverter(new IpPortToStringConverter());
registry.addConverter(new StringToIpPortConverter());
registry.addFormatter(new MyNumberFormatter());
}
}
스프링이 제공하는 기본 포멧터
결국, 우리는 만들어져 있는 기본 포멧터를 사용하면된다.
@NumberFormat: 숫자 관련 형식 지정 포멧터 사용,
@DateTimeFormat: 날짜 관련 형식 지정 포멧터 사용
@Controller
public class FormatterController {
@GetMapping("/formatter/edit")
public String formatterForm(Model model){
Form form = new Form();
form.setNumber(10000);
form.setLocalDateTime(LocalDateTime.now());
model.addAttribute("form",form);
return "formatter-form";
}
@PostMapping("/formatter/edit")
public String formatterEdit(@ModelAttribute Form form){
return "formatter-view";
}
@Data
static class Form{
@NumberFormat(pattern = "###,###")
private Integer number;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime localDateTime;
}
}
formatterForm메서드에서 숫자와 시간을 주면 Form에서 @NumberFormat과 @DateTimeFormat에 맞춰서 변환해준다.
formatterEdit에서 ModelAttribute도 Form형식으로 담아야하는데, 해당 포멧팅형식에 맞춰서 변환하고 바인딩해준다.
주의!!
JSON형식으로 응답을 받을때, 만약 Integer 1000을 받는데 Json도 String형식이니까 객체를 String으로 바꿀때 1,000으로 바꾸고 싶어, 이거 @NumberFormat적용안된다.
컨버젼 서비스는 @RequestParam,@ModelAttribute,@PathVariable,뷰템플릿등에서 사용되는것이지,
JSON결과로 만들어지는 숫자나 날짜 포멧을 컨버젼 서비스로 사용하는게 아니다.
JSON결과로 만들어지는 포멧을 컨버젼하고싶다면, Jackson같은 라이브러리가 지원하는 설정을 통해서 포멧을 지정해야한다.