[Spring] Spring MVC - HTTP, Web Server, WAS, Servlet
웹 기본
Spring MVC를 정리하기 이전에 웹의 기본 지식을 짚고 넘어가겠습니다.
HTTP
먼저 HTTP에 대해서 간단하게 알아보겠습니다. 자세한 내용은 추후 업로드 할 예정입니다.
HTTP(HyperText Transfer Protocol)는 HTML 문서와 같은 데이터를 주고 받기 위한 통신 규약입니다. HTTP의 가장 큰 특징은 클라이언트-서버 아키텍쳐, 비연결성, 비상태성 프로토콜이라는 것입니다.
- 클라이언트-서버: 클라이언트와 서버가 분리되어 요청(Request)/응답(Response) 메시지를 교환하며 통신합니다
- 비연결성: 클라이언트가 서버에 요청하고, 서버가 이에 응답하면 바로 연결이 끊어집니다.
- 비상태성: 연결을 끊는 순간 통신이 종료되며 서버는 클라이언트의 정보를 저장하지 않습니다.
HTTP Message
HTTP에서는 클라이언트와 서버가 HTTP 메세지를 교환하며 통신합니다. HTTP 메세지의 구조는 아래와 같고 Message Body 위에 공백 라인이 하나 있습니다.
요청 메세지에 작성되는 내용들은 다음과 같습니다.
- Start Line: method (공백) request-target (공백) HTTP-version (엔터)
- Header: Host, User-Agent, Accept, Content-Type 등
- Message Body: Form Data 등
응답 메세지에 작성되는 내용들은 다음과 같습니다.
- Start Line: HTTP-version (공백) status-code (공백) reason-phrase (엔터)
- Header: Server, Content-Type 등
- Message Body: HTML 문서, JSON 데이터 등
HTTP Header
HTTP 헤더에 대해서 좀 더 알아보겠습니다. HTTP 헤더는 공통 헤더, 요청 헤더, 응답 헤더로 나뉩니다. 공통 헤더는 요청/응답 메시지에 모두 포함되는 헤더입니다.
공통 헤더
- Date: HTTP가 만들어진 시각입니다.
Date: Thu, 12 Jul 2018 03:12:27 GMT
와 같이 작성되는데, 여기서 GMT는 그리니치 천문대를 기준으로한 시각을 의미합니다. - Connection: HTTP1.1에서 기본적으로
Connections: keep-alive
로 작성되는데 별 의미는 없다고 합니다. HTTP2.0에서는 사라졌습니다 - Cache-Control: 캐시 관련 옵션들을 작성합니다.
Cache-Control: no-store
: 캐싱을 하지 않습니다Cache-Control: no-cache
: 캐시를 사용하기 전에 서버에 유효성 확인 요청을 보냅니다Cache-Control: must-revalidate
: 만료된 캐시만 서버에 확인 요청을 보냅니다Cache-Control: [public | private]
: public이면 공유 캐시에 저장 가능, private이면 브라우저 같은 특정 사용자 환경에만 저장하라는 뜻 입니다Cach-Control: max-age=600
: 캐시의 유효시간을 나타냅니다. 단위는 초 입니다.
- Content-Length: 요청/응답 메시지 Body의 크기를 바이트 단위로 표시해줍니다.
- Content-Type: 현재 메시지 내용의 타입을 의미합니다.
요청 헤더
- Host: 서버의 도메인 네임을 나타냅니다.
- User-Agent: 현재 클라이언트가 어떤 브라우저를 사용하여 요청을 보냈는 지 나타냅니다.
- Accept: 클라이언트가 서버에게 요청하는 데이터의 형식을 나타냅니다. 예를 들어
Accept: text/html
은 클라이언트가 서버에게 HTML 문서를 요청하는 것입니다. - Authorization: 인증 토큰(JWT 등)를 서버로 보낼 때 사용하는 헤더입니다.
- Origin: POST 같은 요청을 보낼 때 요청이 어느 주소에서 시작되었는 지를 나타냅니다. 요쳥을 보낸 주소와 받는 주소가 다르면
CORS
문제가 발생할 수 있습니다.
응답 헤더
- Access-Control-Allow-Origin: 요청을 보내는 클라이언트의 주소와 받는 백엔드의 주소가 다르면
CORS
에러가 발생하는데, 이 때 서버에서 Access-Control-Allow-Origin에 프론트 주소를 작성해야 에러가 발생하지 않습니다. - Allow: 허용할 메서드를 나타냅니다.
Allow: GET
은 GET 메서드만 허용한다는 의미입니다. - Location: 리다이렉션을 나타내는 300이나 201 Created 응답일 때 이동할 페이지를 알려줍니다.
HTTP Method
HTTP 요청 메시지에는 요청 대상과 대상을 처리할 메소드를 명시합니다. 그럼 HTTP 메소드에는 어떤 것들이 있는 지 알아보겠습니다.
GETGET
메서드는 리소스에 대한 조회를 요청합니다. 가장 큰 특징은 /customer?username=Ham
와 같이 요청 메시지가 URL에 드러난다는 것 입니다. URL의 길이는 한정되어 있기 때문에 전달할 수 있는 데이터의 크기도 한정됩니다. GET
메소드는 요청과 이에 대한 응답이 브라우저에의해 캐싱이 가능해 속도가 빠릅니다.
POSTPOST
는 서버에 저장된 리소스의 값이나 상태를 바꾸기 위한 메소드입니다. GET
에서 데이터가 URL에 노출 되는 것과는 달리, POST
는 HTTP Message Body에 데이터를 숨겨 서버에 전송합니다. GET
메소드에 비해서 전송 속도는 느리지만, 데이터 크기의 제한은 비교적 여유롭습니다.
PUTPUT
은 서버에 새로운 리소스를 생성하거나 수정하는데 사용합니다. 리소스를 식별할 수 있는 식별자를 알고있어야 하고, 해당 자원이 없는 경우 새로 생성하고 있는 경우 대체합니다. PUT
을 사용할 경우 해당 식별자를 가진 리소스가 하나임이 보장되지만, POST
는 동일한 리소스가 새롭게 생겨날 수 있습니다.
DELETEDELETE
는 서버에 저장된 리소스를 삭제할 때 사용합니다. GET
과 유사하게 URL에 삭제할 데이터에 대한 정보가 드러납니다.
PATCHPATCH
는 서버에 저장된 리소스를 수정할 때 사용합니다. PUT
은 리소스 전체를 교체하지만, PATCH
는 리소스의 일부를 수정할때 사용합니다.
이렇게 HTTP 메시지, 메소드에 대해서 알아본 이유는 자바 서블릿에서 제공하는 HttpServletRequest, HttpServletResponse 객체를 사용하여 HTTP 메시지를 직접 읽고 쓸 수 있기 때문입니다. 실제 예제에서 살펴 보겠습니다.
Web Server, WAS
서버는 클라이언트의 요청에 따라서 HTTP 응답 메시지를 작성하여 전달합니다. 서버는 크게 Web Server와 WAS(Web Application Server)로 나눌 수 있습니다.
Web Server
웹 서버는 클라이언트의 정적인 요청에 응답합니다. 여기서 정적인 요청이란 정적 리소스를 요청하는 것인데, 정적 리소스는 HTML, CSS, Javascript, 이미지, 영상과 같은 데이터를 의미합니다. 대표적인 웹 서버로 NGINX
와 Apache
가 있습니다.
Web Application Server
WAS(Web Application Server)는 클라이언트의 동적인 요청을 처리하는 서버입니다. 동적인 요청이란 요청 상황에 따라 다른 결과를 응답하는 것을 의미합니다. 예를 들면, 회원 가입이나 로그인과 같은 요청이 있습니다.
WAS는 클라이언트의 동적인 요청을 처리하기 위해 애플리케이션 로직을 실행할 수 있고 데이터베이스에 접근할 수 있습니다. 즉 프로그램을 실행할 수 있는 것입니다.
'웹 서버를 사용하지 않고 WAS만 사용하면 되겠다'는 생각을 할 수 있지만 보통 웹 서버와 WAS를 모두 사용하는 경우가 많습니다. 사실 WAS는 웹 서버를 포함하는 개념이기 때문에 두 서버의 경계조차 모호합니다. 위 그림을 보면 WAS는 내부적으로 Web Container의 앞 단에 웹 서버를 두고 정적 요청에 대한 처리를 수행하도록 합니다.
Web Server + WAS
WAS가 앞단에 웹 서버를 두는 이유는 다음과 같습니다
WAS는 웹 서버에 비해 에러 발생 확률이 높습니다. WAS에 작성된 애플리케이션 로직은 결국 사람이 작성한 코드입니다. 다양한 이유에 의해서 WAS가 웹 서버에 비해 에러가 발생할 확률이 높습니다.
WAS에 에러가 발생한 경우 웹 서버를 사용하지 않으면 클라이언트에게 에러 페이지를 전송할 수 없습니다. 따라서 WAS의 애플리케이션 로직이나 DB 에서 에러가 발생한 경우 웹 서버를 통해 에러 페이지를 클라이언트에게 전송하도록 합니다.
정적인 요청까지 모두 WAS에서 담당하면 서버에 대한 부하가 커집니다. WAS의 핵심은 애플리케이션 로직을 수행하는 것입니다. 하지만 정적인 요청까지 모두 WAS가 처리한다면 핵심 기능을 수행하지 못할 수 있습니다. 따라서 비싼 애플리케이션 로직은 WAS가 담당하고 비교적 저렴한 정적인 리소스에 대한 응답은 웹 서버에서 담당하여 WAS의 부담을 덜어 주는 것입니다.
Servlet
WAS는 클라이언트와 서버가 연결되는 과정을 자동화 해주며 서블릿 컨테이너 기능을 제공합니다. 개발자는 서블릿의 요청/응답 객체를 사용하여 HTTP 메시지의 데이터를 사용하고 응답 메시지를 작성할 수 있습니다. WAS 덕분에 개발자는 비즈니스 로직에 집중할 수 있는 것입니다.
WAS는 클라이언트의 요청에 따라 HTTP 요청 메시지를 파싱하여 객체를 생성한 후 서블릿 컨테이너에서 서블릿을 호출합니다. 개발자는 서블릿이 제공하는 HttpServletRequest
, HttpServletResponse
을 사용하여 편리하게 HTTP 메시지에 작성된 데이터을 읽고, 응답 메시지를 작성할 수 있습니다.
다음으로 예제를 통해서 HttpServletRequest
, HttpServletResponse
을 사용해보겠습니다.
HttpServletResponse
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
@ServletComponentScan // 1
@SpringBootApplication
public class ServletApplication {
public static void main(String[] args) {
SpringApplication.run(ServletApplication.class, args);
}
}
1| 서블릿 자동등록 어노테이션입니다. 스프링 부트에서 제공합니다.
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet(name = "testServlet", urlPatterns = "/test") // 1
public class TestServlet extends HttpServlet { // 2
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException,// 3 IOException {
resp.getWriter().write("This is test servlet"); // 4
}
}
1| 서블릿을 나타내는 어노테이션입니다.
- name: 서블릿의 이름을 나타내는 속성입니다.
- urlPatterns: 서블릿을 실행 할 URL 목록을 나타내는 속성입니다.
- value: urlPatterns와 동일한 기능을 수행합니다. 속성 이름 없이 값 만으로 설정 가능합니다.
2| HttpServlet
을 상속 받아 서블릿이 제공하는 기능을 사용합니다.
3| 클라이언트의 요청이 있을 때 마다 매번 서블릿 컨테이너가 실행하는 메서드입니다.
4| HTTP Response Message Body에 데이터를 작성합니다.
/test
에 요청을 보내면 아래와 같이 결과가 나타납니다.
아래와 같이 HTML 형식의 데이터나 HTTP Response Header를 설정할 수도 있습니다.
/test
에서 /test-response
로 리다이렉션하고 HTML 데이터를 Body에 작성하고 헤더를 설정합니다.
@WebServlet(name = "testServlet", urlPatterns = "/test")
public class TestServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.sendRedirect("/test-response"); // 1
}
}
@WebServlet(name = "testResponseServlet", urlPatterns = "/test-response")
public class TestResponseServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("text/html"); //2
resp.setCharacterEncoding("utf-8"); //3
String html = "<html>" +
"<body>" +
"<h2>This is Html Example</h2>" +
"<h3>Hi I'm Seunghun</h3>" +
"<h3>제 이름은 가나다입니다</h3>" +
"</body>" +
"</html>";
resp.getWriter().write(html);
Cookie cookie = new Cookie("Test-Cookie", "hello"); // 4
cookie.setMaxAge(1200); // 5
resp.addCookie(cookie); // 6
}
}
1| /test-response
로 리다이렉션 합니다
2| Content-Type
을 text/html
로 설정합니다
3| 한글을 출력하기 위해서 charset을 utf-8
로 설정합니다
4| Test-Cookie=hello
쿠키를 생성합니다
5| 쿠키 유효 기간을 1200초로 설정합니다.
그리고 JSON 데이터를 전달할 수 있습니다. 자바 객체를 JSON 데이터로 변환하기 해서 Jackson에서 제공하는 ObjectMapper
를 사용합니다.
@WebServlet(name = "testResponseServlet", urlPatterns = "/test-response")
public class TestResponseServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("application/json"); // 1
resp.setCharacterEncoding("utf-8");
TestObject object = new TestObject();
object.setAge(30);
object.setEmail("test@naver.com");
object.setName("Test");
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(object); // 2
resp.getWriter().write(json);
}
}
1| Content-Type
을 json으로 설정합니다.
2| ObjectMapper
를 사용하여 데이터를 JSON 형식의 문자열로 변환합니다.
실제 웹 페이지에 렌더링된 데이터를 보면 JSON 형태로 잘 출력이 되었고, Content-Type이 application/json으로 설정된 것을 볼 수 있습니다.
HttpServletRequest
다음으로 Request를 사용한 예제를 살펴보겠습니다. Request 객체를 사용하면 HTTP Request Header 정보를 가져올 수 있다. 모든 Header 데이터를 가져오는 코드는 작성하지 않고 몇 가지로 추려서 작성하겠습니다. 자세한 내용은 공식문서로 대체합니다.
이제 HttpServletRequest
객체로 쿼리 스트링의 값을 가져오는 예제를 살펴보겠습니다.
@WebServlet(name = "testRequestServlet", urlPatterns = "/test-request")
public class TestRequestServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Enumeration<String> parameterNames = req.getParameterNames(); // 1
parameterNames.asIterator()
.forEachRemaining(paramName -> System.out.println(paramName + " = " + req.getParameter(paramName))); // 2
String[] names = req.getParameterValues("name"); // 3
for (String name : names) {
System.out.println("name = " + name);
};
}
}
1| 쿼리 스트링의 Parameter name 값들을 읽어 옵니다
2| getParameter()
메서드로 Parameter name에 매핑되는 Value를 읽어 옵니다.
3| 쿼리 스트링에서 name
에 매핑된 모든 Value를 읽어 옵니다.
- 중복된 Parameter name의 값은 첫 번째로 지정된 값을 읽어옵니다.
다음으로 Request Message Body에 저장된 값을 읽어 보겠습니다. 포스트 맨을 사용하여 요청을 전달하겠습니다.
@WebServlet(name = "testRequestServlet", urlPatterns = "/test-request")
public class TestRequestServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
ServletInputStream inputStream = req.getInputStream(); // 1
String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); // 2
System.out.println("Message Body = " + messageBody);
}
}
1| getInputStream()
메소드를 사용하여 HTTP Request Message Body에 저장된 데이터를 스트림으로 읽어 옵니다.
2| StreamUtils.copyToString()
메소드를 사용하여 스트림을 String으로 변환합니다. charset은 UTF-8로 설정합니다.
포스트맨을 사용하여 일반 텍스트 형식으로 데이터를 전달했고, 서버에서 잘 출력이 된 것을 확인할 수 있습니다.
그리고 ObjectMapper
를 통해 JSON 형식으로 전달된 데이터를 객체로 변환할 수도 있습니다. 단 필드명이 동일 해야합니다.
@WebServlet(name = "testRequestServlet", urlPatterns = "/test-request")
public class TestRequestServlet extends HttpServlet {
private ObjectMapper mapper = 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);
TestObject object = mapper.readValue(messageBody, TestObject.class);
System.out.println("name = " + object.getName());
System.out.println("email = " + object.getEmail());
System.out.println("age = " + object.getAge());
}
}
JSON 데이터를 읽어 ObjectMapper
를 통해 객체로 잘 변환되었습니다.
Servlet Container
WAS는 서블릿을 관리하는 서블릿 컨테이너 기능을 제공합니다. WAS는 컨테이너를 통해 서블릿을 생성하고 호출하며 WAS 종료시 서블릿도 종료해주는 해주어 서블릿의 라이프 사이클을 관리합니다.
예제에서는 클라이언트의 요청이 하나 밖에 없었지만, 실제 서비스에서는 무수히 많은 요청이 들어옵니다. 따라서 서로 다른 요청/응답 객체가 서블릿으로 전달됩니다. 만약 서블릿이 해당 요청마다 새로이 생성된다면 객체가 무수히 많아져 서버에 부하가 커지고 서버가 죽는 경우가 발생할 수 있을 것입니다. 따라서 서블릿 컨테이너는 서블릿을 싱글톤
으로 관리합니다. 따라서 서블릿이 공유할 수 있는 상태 값을 가지지 않게 주의 해야합니다.
Sevlet & Multi Thread
위 그림에서 알 수 있듯이 쓰레드가 서블릿을 호출합니다. 쓰레드는 프로세스에서 실행 단위를 의미합니다. 만약 싱글 쓰레드로 클라이언트의 요청을 처리 한다면 이후 요청에 대한 응답이 지연 되며, 작업 수행 시 쓰레드에 에러가 발생하면 서버에 큰 문제가 발생할 것입니다.
따라서 클라이언트의 요청을 멀티 쓰레드 환경에서 처리합니다.
단 멀티 쓰레드의 단점이 있습니다.
- 쓰레드의 생성 비용이 높다
- 쓰레드의 컨텍스트 스위칭 비용이 발생한다
- 쓰레드 생성에 제한이 없다
비록 프로세스에 비해 적을 수 있지만 쓰레드를 생성하고 쓰레드간 컨텍스트 스위칭에 비용이 발생합니다. 그리고 쓰레드가 과도하게 많이 생겨나면 서버에 부하가 크게 발생합니다. 따라서 미리 쓰레드를 일정한 수 만큼 생성하고 꺼내쓴 후 반납하는 쓰레드 풀을 사용합니다.
쓰레드 풀을 사용하여 쓰레드 생성/소멸에 대한 비용을 줄이고 응답시간이 빨라집니다. 그리고 생성 가능한 쓰레드의 수를 제한하여 서버에 큰 부하가 발생하는 것을 방지할 수 있습니다.
참고
- https://developer.mozilla.org/ko/docs/Web/HTTP/Caching
- https://www.zerocho.com/category/HTTP/post/5b594dd3c06fa2001b89feb9
- https://developer.mozilla.org/ko/docs/Web/HTTP/Messages
- https://tonylim.tistory.com/81?category=935925
- https://developer.mozilla.org/ko/docs/Web/HTTP/Methods
- https://catsbi.oopy.io/defe6c4d-1d74-4a5e-8349-ff9077dda184
- https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1/dashboard