서서블블릿릿
HTTP에 대한 이야기가 마무리 되고 스프링 MVC에 대해 이야기 해볼 것인데 첫 번째로는 서블릿에 대해 다루어 보게에엤드아!
서블릿에 대해 이야기 하기 전에 전반적인 스토리를 이야기 하자면, 클라이언트와 서버는 인터넷을 통해 통신을 한다. 이때의 통신 규약은 HTTP! 즉, 인터넷은 전화기이고 HTTP는 나랏말이라 생각하면 된다. 이때 서버는 크게 둘로 나누는데 웹 서버와 웹 애플리케이션 서버(WAS)가 그것이다. 요새는 그 경계가 모호하지만, 굳이굳이 나누어 보자면, 예를 들어 동적인 화면이 상당히 많이 나오는 브라우저에서 하나의 서버가 이 모든 동적인 것들을 처리함과 동시에 정적인 화면들 까지 클라이언트에게 제공한다면 상당히 그 서버는 고생을 하게 된다. 그래서 둘로 나누어 역할분담을 하게 되었으니, 웹 서버는 정적 리소스 제공, WAS는 프로그램 코드를 실행해서 애플리케이션 로직을 수행하는 역할을 한다. 근데 WAS가 웹 서버 기능도 가능해서 게다가 웹 서버도 프로그램 실행 기능이 있긴 해서 경계가 모호하다. 단지 어느 쪽에 더 특화 되었는지만 생각하면 될 듯 하다.
물론 WAS가 웹 서버 역할이 가능 하다 해도 무조건 나누는게 좋다. DB 접근도 해야하는 WAS 너무 괴롭히지마! 게다가 만약 아래 그림처럼 잘 설계되어 있다면 WAS나 DB 장애시 웹 서버가 오류 화면을 내비쳐 줄 수 있어 용이하다. 실제로 이런 일이 많기도 하다.
1. 서블릿에 대하여
자 이제 돌고 돌아 본론을 시작합니다!
HTTP 글에서 다루었듯이 뭐 예를 들어 form 태그 데이터를 전송한다던지 단지 구글에 어떤 단어들을 검색 하게 되면 이 검색을 HTTP request message 형식에 맞추어 변형 되어 서버에게 도착한다. 근데! 이 변형 하는거 누가 해주지? 각 클라이언트가 직접 해준다면 열랴뽕따이~ 서버 만드는 우리에겐 겁나게 고맙지만, 그럼 누가 인터넷 쓰겠니..? 아니 그럼 서버 개발자가 일일이 다해?!
그래서 나온 것이 서블릿이다. ㅋㅋ. 서블릿이 하는 역할은 아래 그림과 같다.
서블릿이 없다면 옆에 있는 모든 순서를 우리가 해야 한다. 컴네에서 했듯이 TCP/IP, 소켓 부터 시작해서 요청 메시지 파싱 하고 메서드가 무엇인지, header 정보들은 무엇이 있는지 등등을 모두 해야 하지만 서블릿이 있다면 이 귀찮은 모든 것들은 우리의 업무가 아니고 연두색 박스 부분 처럼 비즈니스 로직을 잘 짜면 끝난다. 너무 고마워 서블릿!
서블릿 사용 코드는 아래 주구장창 예시를 들테니 마음 급하게 가지지 말도록!
1.1 서블릿 객체
오늘 글의 전반 배경으로 WAS에 대해 이야기 했다. WAS를 왜 이야기 했느냐 하면 서블릿도 하나의 객체이다. 이 객체에게 request와 response 객체가 들어오고 response 객체를 통해 대답한다. 아래 그림을 보면 아하! 할 거시야
WAS가 이 request, response와 서블릿 들의 연결을 도와준다. WAS 중에는 톰캣처럼 서블릿을 지원하는 것들이 있고 그런 경우 서블릿 컨테이너라고 부른다. 서블릿 컨테이너는 서블릿 객체를 생성, 초기화, 호출, 종료하는 생명주기를 관리 해주며 각 객체를 싱글톤으로 관리한다. 게다가 동시 요청을 위한 멀티 쓰레드 처리를 지원하니 이런이런 너무 편안한걸? 멀티 쓰레드가 뭐냐고? 나와 걱정마.
정리하자면,
- WAS는 Request, Response 객체를 새로 만들어서 서블릿 객체 호출
- 개발자는 Request 객체에서 HTTP 요청 정보를 편리하게 꺼내서 사용
- 개발자는 Response 객체에 HTTP 응답 정보를 편리하게 입력
- WAS는 Response 객체에 담겨있는 내용으로 HTTP 응답 정보를 생성
1.2 멀티 쓰레드
동시 요청을 처리하는 방식 중에 하나다. 근데 너무 좋아서 거의 뭐 독보적 1대1로 대응 되는 단어이지 않을까? 일단 클라이언트가 어떠한 요청을 하면 WAS가 그 응답을 준다는 것 까지는 알아내었다. 그런데! 클라이언트 요청의 종류는 너어어무너어무너무너무너무 다양한데 그에 해당하는 서블릿도 당연히 너어어무너어무너무너무너무 다양 할 것이야. 이를 어떻게 매칭 시켜 줄 수 있을 까?
바로 이 요청에 맞는 서블릿 객체를 호출 하기 위해 쓰레드를 부른다.
쓰레드가 무엇이냐면, 애플리케이션 코드를 하나하나 순차적으로 실행하는 단위를 의미한다. 잘 모르겠다면 검색 해보면 많은 자료가 있다. 중요한 것은 쓰레드는 한번에 하나의 코드 라인만 수행할 수 있어서, 동시 처리가 필요하다 하면 쓰레드를 추가로 생성 해야 한다.
먼저 단일 요청일 경우 클라이언트의 연결 요청이 들어오면 쓰레드가 할당되어 요청에 맞는 서블릿에게 응답하라고 시킨다. 그리고 쓰레드는 쉬면 된다.
하지만 다중 요청일 경우에 쓰레드가 하나밖에 없는 상태에서 첫 번째 요청에서 처리가 지연되면 쓰레드는 서블릿에 갇혀있게 되어 지금의 요청 뿐만 아니라 그 뒤의 요청도 무한정 대기해야 한다. 그래서 요청이 들어 올 때마다 쓰레드를 계속 만들어 비록 하나의 서블릿에서(싱글톤이니) 응답을 잘 할 수 있다.
근데 꽤나 모르겠지만, new 키워드를 이용하여 새로운 객체를 생성하는 것은 꽤나 cost가 발생하기도 느리다(kernel 모드에 진입 해야 하므로). 하고 만약 요청이 너무나 많다면 CPU, 메모리 초과 등으로 서버가 죽을 수 도 있다. 그래서 메모리 풀 처럼 쓰레드 풀이란 것이 있다.
대충 그냥(정밀하게 계산 하겠지만 ㅋㅋ) 200개 정도 미리 만들어 놓고 풀장에다가 대기 시켜 놓았다가 쓰일 녀석들은 차례로 쓰이면 된다. 만약 풀이 비어있는데 요청이 들어온다면 쓰레드를 대기하거나 거절한다. 가끔 접속자 수 몰렸다고 대기 페이지에 들어가게 하는 것이 이런 경우이다.
그래서 WAS에게 이 쓰레드 풀 용량을 잘 알려주어야 하는데 이를 위해 여러 성능 테스트 도구들이 있다. 아파치 ab, 제이미터, nGrinder가 그것이다.
암튼 중요한건 WAS가 멀티 쓰레드를 관리해주니 우린 알 필요가 없다는 것!!!
2. 코드에서의 서블릿
서블릿 코드는 아래와 같이 설정하면 된다. 하나하나 알아보자.
@WebServlet(name = "helloServlet", urlPatterns = "/hello")
public class HelloServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
System.out.println("HelloServlet.service");
System.out.println("request = " + request);
System.out.println("response = " + response);
}
}
일단 기본적으로 extends HttpServlet 을 클래스 옆에 붙여 확장한다. HttpServlet 안에는 service 함수가 있는데 이 함수가 바로 위에서 본 연두색 뽝스 부분이다. 이 곳에 우리의 핵심 로직을 짜면 되는 것. 위에 @WebServlet 어노테이션이 뜻하는 부분은 일단 아래 main 부분 코드를 보자.
package hello.servlet;
@ServletComponentScan
@SpringBootApplication
public class ServletApplication {
public static void main(String[] args) {
SpringApplication.run(ServletApplication.class, args);
}
}
@ComponentScan 어노테이션 처럼 현재 패키지를 기준으로 모든 하위 패키지까지 servlet을 싹 다 읽어서 서블릿 컨테이너에 등록 해준다. 그러하니 @Component 처럼 @WebServlet 어노테이션을 사용한다고 생각하면 된다. 게다가 name은 서블릿 컨테이너에 등록 될 이름 이겠지요?! 중요한 것은 urlPatterns 부분인데 이 객체와 URL을 매핑 시켜주는 것이다.
이제 HTTP 요청을 통해 매핑된 URL이 호출 되면 service 함수가 서블릿 컨테이너에 의해 실행된다. 위에서 설명했듯이 익숙한 이름인 response와 request 객체 인자가 있는 것을 확인 할 수 있다. 만약 ~~~~/hello를 입력하게 되면 print문 세 가지가 출력이 되겠지요.
자, 그럼 다들 꽤 감이 좀 잡히셨을 거 같은 데요! 맞아요. request에 온 정보들을 이용해서 전에 배웠던 막 의존관계 이용하고 로직 짜고 디비 접근해서 response에 그 결과를 똬악 내미는게 핵심입니다. 그럼 이제 핵심은 정보들이 어떻게 들어 가 있는 지, request에 그 정보들을 빼오는 방법, response에 새로운 정보들을 넣는 방법이 우리의 초점인 것이랍니다.
3. 요청할때!
HTTP 요청 메시지는 start line 부터 시작해 꽤나 복잡하다. 이를 개발자가 직접 일일이 파싱하기엔 너무 귀찮고 정형화 되어있는데,,, 굳이? 그래서 이러한 우리의 니즈를 충족 시켜주는 것이 바로 HttpServletRequest 이다. 이런 정보 전달 기능 뿐만 아니라 약간 임시 저장소 기능 또한 한다. request.setAttribute(name, value)를 통해 저장하고 request.getAttribute(name)을 통해 조회 할 수 있고 request.getSession(create: true) 처럼 세션 관리 기능 까지 해준다.
추가 기능 말고 일단은~ 기본 사용법에 대해 다루자면 HTTP 요청메시지에는 start line / 헤더 / 바디 이렇게 세 부분이 있다. 각 부분에 대해 다룬 것은 저번 글 참고 바람! 각 부분에 대해 쓸모 있는 함수들을 다루어 보자. 각 메서드에 . 붙어 있는거 당연히 request.~~~ 를 말하는 것이다. 당연히 바디는 메서드가 없다.. 컨텐츠 부분이니깐!
- start line
- .getMethod() : GET인지 POST 인지 등등 알려준다.
- .getProtocol() : HTTP 버전
- .getRequestURL() : 요청 들어온 URL.
- .getQueryString() : 쿼리 파라미터 문자열로 변환
- 헤더
- .getHeaderNames() : 모든 헤더들을 가져온다. 대게 .getHeaderNames().asIterator().forEachRemaining(headerName->"간단 함수"); 처럼 각각 헤더들 접근해서 출력하게 하거나 뭐... 뭐하지..? ㅋㅋ 대게 출력에 쓰이겠지. 아님 어떤 헤더가 잘 들어 왔는지 확인하는 용도? 근데 당근 하나하나 다 하는 건 말이 되느냐? 각 헤더들 가져 올 수 있다. 몇가지 예시를 들어보자.
- .getServerName(): Host 헤더
- .getServerPort(): Host 헤더
- .getLocales(): 사용가능 한 언어들 목록 가져오기. 위 처럼 asIterator()와 forEachRemaining() 조합으로 자주 쓰인다.
- getCookies(): 모든 쿠키 조회. 얘는 위 두 함수 안쓰고 for문으로 쓴다. for(Cookie cookie : request.getCookies()){} 처럼
- .getCharacterEncoding(), .getContentType(), .getContentLength() 도 있는데 직관적이라 설명 안할래.
3.1 요청 메시지 전달 하는 방법
주로 3가지 방법을 사용한다.
- GET 메서드의 쿼리 파라미터. ?(key)=(value) 형식. 메시지 바디가 없다는 것! 검색, 필터, 페이징 등에서 많이 사용하는 방식
- POST 메서드의 form태그. content-type: application/x-www-form-urlencoded 헤더가 설정 되며 메시지 바디에 쿼리 파라미터 형식으로 전달 된다. (key)=(value) 말하는 거 맞다. 이때 흥미로운 점은 사실상 GET 메서드와 함께 다 결국 쿼리 파라미터 형식을 띄게 되니 둘을 합쳐 '요청 파라미터'라고 부르기도 하고 그에 걸맞게 데이터를 조회할 때 같은 함수를 사용한다는 것이다! 회원 가입, 상품주문 등에서 자주 사용 된다.
- 마지막으로는 message body에 데이터를 직접 담는 방식인데 HTTP API에서 주로 사용 한다. 메시지 바디의 내용은 JSON, TEXT, XML이 주로 쓰이며, 대게 데이터 형식은 JSON을 사용한다. 또한 이 방식은 POST,PUT,PATCH 메서드를 이용할 수 있다.
- GET 메서드
이 블로그 URL도 보면
https://french-ice-prince.tistory.com/manage/newpost/?type=post&returnURL=%2Fmanage%2Fposts%2F
로 이루어져 있다. 보면 ?type=post&returnURL 가 있다. 뭐 이건 당연히 알 것이고 중요한 것은 이 정보를 빼내오는 방법이다.
@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");
}
}
.getParameterNames()를 이용하여 전체 조회가 가능 하기도 하고 .getParameter(<key이름>)을 통해 조회하기도 한다. 만약 이름이 같은 것이 여러개라면 .getParameter()는 맨 처음 것만 리턴 해 주므로 다 알고 싶다면 보시다 시피 .getParameterValues()를 이용하여 문자열 리스트로 싹 다 가져올 수 있다.
- POST 메서드 & HTML form
아래 코드 처럼 form 태그에 메서드 정의를 post를 한다 해보자.
<form action="/request-param" method="post">
username: <input type="text" name="username"/>
age: <input type="text" name="age"/>
<button type="submit">전송</button>
</form>
이제 button을 누르게 되면 content-type: application/x-www-form-urlencoded 헤더와 바디 부분에는 uername=hello&age=20 이 작성된다. 어랏 이것은! GET 메서드에서 보았던 쿼리 파라미터이다. 그러하므로 그냥 getParameter() 함수 쓰면 된다.
- API 메시지 바디
먼저 가장 단순한 텍스트 메시지를 HTTP 메시지 바디에 담는다고 해보자. 다른 전송 방식에도 쓰이는데 서버는 HTTP 메시지 바디의 데이터를 InputStream을 사용해서 읽는다. 여러 함수가 쓰이는데 코드를 보자!
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
ServletInputStream inputStream = request.getInputStream();
String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
System.out.println("messageBody = " + messageBody);
response.getWriter().write("ok");
}
.getInputStream() 함수는 메시지 바디 내용을 바이트 코드로 바로 얻을 수 있음. 포인트는 바이트 코드! 이므로 사람이 읽을 수 있게 바꾸고 싶다면, StreamUtils.copyToString() 함수를 사용한다. 여기서 StandardCharsets.UTF_8 은 인코딩 정보이다. 인코딩 정보를 꼭 넣어야 바이트 코드에 맞는 문자로 바꿀 수 있다. 반대로 문자를 바이트로 바꿀 때도 어떤 인코딩인지 알려주어야 한다. 근데 요새는 대게 UTF-8을 사용해서 저대로 외워도 될 것 같당.
이번엔 JSON 형식으로 전달받은 데이터를 쏙 뽑아내보자. 예를 들어 메시지 바디에 {"username": "iceprince", age: "24"} 이 있다고 해보자. 또한 HelloData라는 클래스에 String username, int age가 필드로 선언되어 있다고 가정하자.
이제 코드를 보자!
@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.getUsername() = " + helloData.getUsername());
System.out.println("helloData.getAge() = " + helloData.getAge());
response.getWriter().write("ok");
}
}
일단 먼저 보아야 할 부분은 위에서도 보았던 .getInputStream() 함수가 쓰인다는 것이다. messageBody를 출력하면 {"username": "iceprince", age: "24"} 가 그대로 나온다. {}마저 그대로!
그럼 이걸 개발자가 일일이 파싱해? 댓츠 노노. ObjectMapper 라는 클래스가 있는데 스프링이 제공하는 jackson 라이브러리이다. 이 클래스의 readValue() 함수는 messageBody 읽은 것과 key 값들에 해당하는 필드를 가진 클래스를 함께 넣어주면 (HelloData.class 부분) 잘 파싱되어 각 필드에 값들이 들어간 인스턴스를 리턴해준다. 그래서 위 처럼 사용 할 수 있다.
이 ObjectMapper 방식은 스프링에서만 할 수 있다. 이것 뿐만 아니라 더 다양한 방법이 있지만 일단 이것만 알아두자.
4. 응답할때
request에서 정보를 쏙쏙! 뽑아온 후 비즈니스 로직 잘 돌리고 나면, 이젠 service 함수의 두 번째 인자의 타입인 HttpServletResponse를 잘 채워 넣어 응답해야 할 차례이다. 응답 메시지는 HTTP 응답 코드, 헤더, 바디가 주 구성 요서이고 부가적으로 Content-Type, 쿠키, Redirect가 있다. 항상 그래왔듯 코드부터 보자!
@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-header]
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("안녕!!@!@");
}
}
일단 응답 코드와 헤더를 적어 주는 부분만 보자면, setStatus()함수가 있다. 상태 코드를 적는다. 200,303, 404 이런 식으로. 근데 그런 숫자를 적어도 되지만 SC_OK 처럼 문자로서 적으면 좀 더 직관적이고 코드들을 외우지 않아도 되니 더 낫지 않나..? 편한대로 하세용.
헤더를 적어주는 방법은 위처럼 .setHeader(헤더 이름, 헤더 값) 하면 된다. 근데,, 사실 이 헤더 이름 오타 한번 내면,, 귀찮아 지고 게다가 일일이 치는 거 자체가 이미 귀찮다. 그래서 사실 헤더마다의 함수가 따로 존재한다. 아래 코드 처럼.
response.setContentType("text/html");
response.setCharacterEncoding("utf-8");
이 코드는 위 코드의 첫번째 .setHeader()와 같은 역할을 한다고 보면 된다.
부가적인 기능들의 함수들과 주석을 적어 놓을 테니 참고하면 좋을 것 같당.
private void cookie(HttpServletResponse response){
Cookie cookie = new Cookie("myCookie","good");//쿠키 생성
cookie.setMaxAge(600);//쿠키 유효 시간 설정
response.addCookie(cookie);//쿠키 헤더 추가
}
private void redirect(HttpServletResponse response)throws IOException{
response.sendRedirect("/basic/hello-form.html");//redirect 헤더 생성. 흥미로운 점은
//상태코드를 굳이 설정해 주지 않아도 자동으로 redirect 상태 코드로 설정 해줌.
}
4.1 응답 메시지 바디
이젠 바디 부분이 필요하다! 먼저 html 을 작성 하고 싶다면,, 깜짝 놀라겠지만 아래 처럼 하는 방법이 있다.
@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>");
}
깜짝 놀랄 것이 정말 말도 안되게 귀찮게 군다. 사실 이 방식 말고 controller를 이용해 html 파일을 자동으로 넣어주는 방식을 사용하니 걱정 안해도 된다. 중요한 건 그냥 writer에 적으면 메시지 바디가 생성되는 구나~ 하고 깨달아 주길 바란다.
JSON을 작성하고 싶다! 라고 한다면, 아까 HelloData 클래스를 다시 써보자.
@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);
}
}
아까 사용했던 ObjectMapper 클래스를 사용하는 것을 볼 수 있고 writeValueAsString() 메서드를 통해 클래스를 json 스타일의 문자열로 변환 하고 응답 메시지에 적는 것을 볼 수 있다.
참고로 html과 json의 content-type 값이 다르니 두 코드에서 각각 설정 한 것을 볼 수 있다. 그리고 애초에 application/json은 utf-8을 사용하도록 지정 되어 있어서 application/json이라고만 사용 해야지 setHeader("Content-Type": "application/json"; charset=utf-8) 처럼 하게 되면 의미 없는 파라미터를 추가한 것이다. 그래서 위 코드 처럼 하거나, getOutputStream()으로 출력하여 이런 문제를 해결 해야 한다.
마마무무리리
여기까지 서블릿의 개념과 사용 법을 알았다. 근데 사실 이 방식은 꽤나 예전 방식이다. 사실 이미 서블릿이 너무 도와주어서 고맙지만, 이 방식 보다 훨씬더 간단한 방식들이 있다. MVC 패턴을 이용하는 것인데, 맨 첫 스프링 개요 글에서 조금 다루었다. 물론 다음 글에서 꽤 자세하게 다룰 것이지만 다음 글 까지 못기다리겠는 우리 팬 여러분들께서는 첫 글을 보면서 아쉬움을 달래시면 될 것 같다.
그럼 이만~