서블릿은 웹 요청과 응답 처리를 해주는 자바 API
이다. 즉, 웹 서버처리에 필요한 요청과 응답을 관리하는 것이다.서블릿을 구현(서블릿은 인터페이스)
하는 것들이 톰캣
과 같은 WAS 서버인데 톰캣은 apache라는 웹 서버를 포함하고 있다.
즉, 우리가 HttpServlet
같은 것을 사용하여 요청 받고 응답받고 하는 코드들이 톰캣이 서블릿을 구현하여
웹 요청을 처리 해주기 때문인 것이다.
자세히 알아보도록 하자.https://dev-monkey-dugi.tistory.com/119
웹 서버에서는 정확히 무엇을 처리해야 할까?
웹 서버는 웹의 요청과 응답을 관리한다고 했다. 일단 요청이 오고 응답을 주어야 애플리케이션 로직이 당연히 의미가 있는 것이니까
**여기서 웹 서버가 처리한다고 하는 것은 정적 리소스를 전달하는 것이면 웹 서버, 동적이라면 WAS가 될 수 있다.**
웹 서버가 처리해야 하는 것
HTTP 요청과 응답 메시지 관리
- 요청을 받을 수 있도록 TCP/IP 대기를 한다.
- 브라우저가 localhost:8080으로 요청을 보내면 요청을 받아 소켓을 생성한다.
HTTP 헤더를 모두 읽는다.
- POST, GET 방식인지
- Content-Type이 뭔지
- 바디 내용은 뭔지
등 여러 내용을 읽어야 한다. 이러한 정보는 브라우저에서 만들어서 보내게 되는데
우리는 스프링을 사용하면서 톰캣이 서블릿을 지원해서 요청 정보를 모두 읽어 주는 것이다.
- 이후에 우리가 평소에 하는 개발은 비지니스 로직을 작성하는 것이다.
컨트롤러 → 서비스 → 리포지토리 등과 같은 것들 말이다. - 로직을 모두 처리하면 대부분 우리는 클라이언트에게 컨트롤러를 통해 응답을 해준다.
웹 서버는HTTP 응답 메시지를 만들어
서 주는 것이다.- HTTP 시작 라인 생성
- 헤더 생성
- 바디에 HTML 생성 등등
- TCP/IP 응답 소켓 전달하고 소켓 종료
대략 이와 같은 과정을 웹 서버가 처리 해주어야 하는 것이고, 서블릿
을 지원하게 된다.
그리고 web server가 사용될 지 was가 사용될지는 정적 리스소이냐 동적 리소스이냐에 따라 달라지게 된다.
**여기까지가 웹 서버가 해야할 일이다**
코드로 살펴 보자.
먼저 우리가 보통 보는 HttpServlet을 보자.
이런 코드를 사용할 것이다.
@WebServlet(name = "helloServlet", urlPatterns = "/hello")
public class HelloServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest request, HttpServletResponse response){
//TO DO : 애플리케이션 로직
}
}
그러면 내부적으로 웹서버는 어떻게 만들어 지길래 우리는 HttpServlet만 사용할 수 있을까?
이를 알기 위해 현재 웹 서버를 만들어 보고 있는데 간략히 코드를 살펴 보자.
톰캣을 사용하지 않고 직접 웹 서버를 구현하고 있는 코드이다. 멀티 쓰레드 코드이다. → RequestHandler requestHandler = new RequestHandler(connection);
TCP/IP 요청을 받도록 웹 서버가 대기하도록 하는 코드이다.
/**
* 역할
* 1. 웹 서버 실행
* 2. 사용자 요청 대기
* 3. 요청을 RequestHandler에 위임
*/
public class WebServer {
private static final Logger log = LoggerFactory.getLogger(WebServer.class);
private static final int DEFAULT_PORT = 8080;
public static void main(String args[]) throws Exception {
int port = 0;
if (args == null || args.length == 0) {
port = DEFAULT_PORT;
} else {
port = Integer.parseInt(args[0]);
}
// 서버소켓을 생성한다. 웹서버는 기본적으로 8080번 포트를 사용한다
try (ServerSocket listenSocket = new ServerSocket(port)) {
log.info("Web Application Server started {} port.", port);
// 클라이언트가 연결될때까지 대기한다.
Socket connection;
while ((connection = listenSocket.accept()) != null) {
// 객체를 생성하기 때문에 새로운 쓰레드를 요청 마다 생성한다. 즉, 멀티 쓰레드
RequestHandler requestHandler = new RequestHandler(connection);
requestHandler.start();
}
}
}
}
요청을 받아 처리 하고 응답을 생성하는 코드이다.
BufferedReader br = new BufferedReader(inr);
를 보면 inr이 connection된 http 요청 정보를 가지고 있는 것이다.
이제 이걸 읽어서 처리를 해주어야 한다. OutputStream
에 응답 데이터를 생성해서 반환을 해주는 코드이다.
run() 메서드가 중요한데 HttpRequestUtils.parseUrlResource(requestStr);
코드가 http 헤더의 내용 중 urlResource(/index.html)만 파싱
한 후
webapp 밑에서 파일을 찾아 바이트 코드로 응답 해주는 코드이다. 그럼 브라우저는 이 바이트 코드를 그리게 된다.
/**
* 역할
* 사용자 요청에 대한 처리와 응답에 대한 처리를 담당.
*/
public class RequestHandler extends Thread {
private static final Logger log = LoggerFactory.getLogger(RequestHandler.class);
private final Socket connection;
public RequestHandler(Socket connectionSocket) {
this.connection = connectionSocket;
}
@Override
public void run() {
log.debug("New Client Connect! Connected IP : {}, Port : {}", connection.getInetAddress(), connection.getPort());
/**
* InputStream : 클라이언트 요청 데이터
* OutputStream : 클라이언트에 응답 데이터
*/
try (InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream()) {
// TODO 사용자 요청에 대한 처리는 이 곳에 구현하면 된다.
InputStreamReader inr = new InputStreamReader(in);
BufferedReader br = new BufferedReader(inr);
List<String> requestStr = new ArrayList<>();
String line = "";
while ((line = br.readLine()) != null) {
if ("".equals(line)) {
break;
}
requestStr.add(line);
}
String urlResource = HttpRequestUtils.parseUrlResource(requestStr);
DataOutputStream dos = new DataOutputStream(out);
byte[] body = Files.readAllBytes(new File("./webapp" + urlResource).toPath());
response200Header(dos, body.length);
responseBody(dos, body);
} catch (IOException e) {
log.error(e.getMessage());
}
}
private void response200Header(DataOutputStream dos, int lengthOfBodyContent) {
try {
dos.writeBytes("HTTP/1.1 200 OK \r\n");
dos.writeBytes("Content-Type: text/html;charset=utf-8\r\n");
dos.writeBytes("Content-Length: " + lengthOfBodyContent + "\r\n");
dos.writeBytes("\r\n");
} catch (IOException e) {
log.error(e.getMessage());
}
}
private void responseBody(DataOutputStream dos, byte[] body) {
try {
dos.write(body, 0, body.length);
dos.flush();
} catch (IOException e) {
log.error(e.getMessage());
}
}
}
멀티 쓰레드인지 로그로 확인 해볼 수 있는데 run()메서드 바로 아래있는 로그 메시지를 보면 이렇게 출력 된다.
17:57:25.353 [DEBUG] [Thread-14] [webserver.RequestHandler] - New Client Connect! Connected IP : /0:0:0:0:0:0:0:1, Port : 53623
17:57:25.353 [DEBUG] [Thread-15] [webserver.RequestHandler] - New Client Connect! Connected IP : /0:0:0:0:0:0:0:1, Port : 53624
17:57:25.375 [DEBUG] [Thread-16] [webserver.RequestHandler] - New Client Connect! Connected IP : /0:0:0:0:0:0:0:1, Port : 53625
17:57:25.377 [DEBUG] [Thread-17] [webserver.RequestHandler] - New Client Connect! Connected IP : /0:0:0:0:0:0:0:1, Port : 53626
17:57:25.377 [DEBUG] [Thread-18] [webserver.RequestHandler] - New Client Connect! Connected IP : /0:0:0:0:0:0:0:1, Port : 53627
17:57:25.378 [DEBUG] [Thread-19] [webserver.RequestHandler] - New Client Connect! Connected IP : /0:0:0:0:0:0:0:1, Port : 53628
하나의 브라우저가 요청을 했는데 어떻게 쓰레드가 6개이지?
이 점이 의문이 들었는데 생각 해보니 HTTP1.1은 지속 연결의 특성이 있는데 지속 연결
을 지금까지 잘못 이해한 것 같다.
하나의 브라우저에서 요청한 것이 css파일, js파일 등 여러개를 요청하고 응답 받는 것이 한번의 요청으로 끝난다고 착각을 했다.
그게 아니라 하나의 요청이 끝날 때 까지는 서버와 연결이 유지 되어있는 것이고, 요청은 각각 하는 것이다. 그리고 작업이 모두 끝나면 연결이 종료되는 것이다.
즉, 브라우저에서 한번 요청한다고 실제 요청이 한 번 일어나는 것이 아닌 것이었다. 아래 사진을 보면 파일이 6개인데 위에 로그 개수와 동일 한 것을 보면 알 수 있다.
자바 코드로 조금 더 살펴 보면,
- 서버 프로그램을 시작한다.
- 이 단계는 그냥 메인 메서드를 실행하는 단계이다.
- 서버 소켓을 생성한다.
1. `ServerSocket serverSocket = new ServerSocket(8080)`을 실행 하여
실제 서버가 클라이언트와 통신할 서버 소켓을 여는 것이다.
- 서버 소켓이 클라이언트 프로그램의 연결 요청을 받을 수 있도록 대기상태로 만든다.
Socket connection; while ((connection = listenSocket.accept()) != null) {...}
- 클라이언트 프로그램은 소켓을 생성하여 서버 소켓에 연결을 요청한다.
1. `Socket socket = new Socket(”111.111”, 8080);`
- 서버 소켓은 클라이언트 프로그램의 연결 요청을 받고, 클라이언트와 연결할 소켓을 새로 생성한다.
1. 이와 같이 ServerSocket은 오로지 클라이언트와 연결될 Socket을 생성해서 연결해주는 역할만 있다.
2. 이후 통신은 Socket끼리 하는 것이다. 이렇게 한 쌍이 쓰레드이다.
3. `Socket socket = serverSocket.accept();`
2번에서는 대기만한 것이고 여기서 실제로 생성이 된다.
- 마지막으로 소켓끼리 연결이 되어 ServerSocket과는 이제 무관하게 통신을 한다.
대략 이런 과정이 웹 서버를 구현하는 코드라고 할 수 있다.
이 복잡한 코드들이(실제로는 훨씬 복잡) HttpServlet를 통해서 톰캣이 지원 해주고 있는 것이다.
그럼 멀티 쓰레드라면 구조가 아래와 같이 여러 소켓(쓰레드)을 생성하여 연결이 되게 된다.
톰캣이 어떻게 처리하는지 그림으로 살펴 보자.
좀 더 자세한 내용은 해당 글을 참고하자.
- 웹 브라우저는 요청을 한다. localhost:8080
- 톰캣의 앞단에 있는 웹 서버는 요청과 응답 객체를 요청 별로(멀티 쓰레드 지원) 객체를 생성하여 서블릿 컨테이너에게 요청한다.
- 브라우저가 넘겨준 데이터는 순수 문자열이다.
- 서블릿 컨테이너에 있는 것이 서블릿 객체이다.
- 서블릿 객체는 싱글톤으로 관리된다.
→서버가 뜰 때 서블릿 객체들은 이미 싱글톤으로 생성된다.
- GET localhost:8080/members와 같은 것이 하나의 서블릿 객체인 것이다. 즉, 컨트롤러에서 메서드 하나와 같다.
- 개발자는 서블릿 객체를 편리하게 사용한다.
- 서블릿 컨테이너는 작업이 종료되면 개발자가 서블릿 응답 객체에 담아 놓은 것을
앞단 웹서버에 객체를 전달하고 웹 서버는 HTTP 응답 정보를 생성하여 브라우저어게 응답 해준다.
참고
→ 서블릿 객체는 요청 마다 다른 객체를 생성한다. 싱글톤으로 관리된다고 했는데 무슨 말이지?
예를 들어 GET /hello, GET /members 라는 요청이 있다면 서블릿은 /hello 서블릿 1개, /members 서블릿 1개를
싱글톤으로 관리한다는 것이다. 즉, /hello라는 요청이 100개가 들어와도 서블릿 객체는 1개인 것이다.
멀티 쓰레드
위에서 웹 서버는 요청 별로 각각 요청과 응답 객체를 생성한다고 했다.
그리고 이 객체를 서블릿 컨테이너에 던져준다. 그런데 어떻게 각각 생성이 될까? 누가 해주는 것일까?
웹 서버가 쓰레드를 각각 생성하여 쓰레드가 서블릿 컨테이너에 요청하게 되는 것이다.
쓰레드란
- 애플리케이션 코드를 한 줄 한 줄 순차적으로 실행하는 것이다.
프로레스는 프로그램이고 프로세스에는 1개 이상의 쓰레드가 있다.
즉, 쓰레드가 없으면 프로세스는 실행될 수 없다. - 자바에서는 main이 기본 쓰레드이다.
- 동시 처리가 필요하면 쓰레드를 여러개 생성하면 되는데 이것이 멀티 쓰레드이다.
단일 쓰레드일 경우
요청이 여러명이 왔을 때 쓰레드가 작업이 끝날 때 까지 기다려야 한다.
만약 서버에서 엄청 오래 걸리는 작업을 하고 있다면 다른 요청은 무한 대기를 하게 되는 것이다.
이러다가 결국 타임 아웃이 걸린다던가 하게 될 수도 있다.
이상없이 동작을 한다면 쓰레드는 처리 완료 후 대기를 하게 된다. 자바에서 wait() 등과 같은 메서드들이 있다.
요청 마다 쓰레드 생성하는 방법
그럼 동시 요청을 위해 멀티 쓰레드 환경을 요청이 올 때 마다 생성하면 된다.
하지만 장점과 단점이 있지만 잘 사용되지 않는다.
장점
- 동시 요청 처리가 된다.
- 리소스(CPU, 메모리)가 허용할 때 까지 처리가 가능하다.
- 하나의 쓰레드가 지연 되어도, 나머지 쓰레드는 정상 작동된다.
단점
- 쓰레드 생성 비용이 비싸다.
- 고객의 요청이 올 때 마다 쓰레드를 생성하면, 응답 속도가 늦어진다.
- 쓰레드는 컨텍스트 스위칭 비용이 발생한다.
- 코어가 100개 200개가 아니고 많아야 4 ~ 8개 정도이기 때문에
100개의 요청만 들어와도 CPU에서 엄청난 컨텍스트 스위칭이 발생하게 된다.
- 코어가 100개 200개가 아니고 많아야 4 ~ 8개 정도이기 때문에
- 쓰레드가 계속 생성되기 때문에 CPU용량을 초과하면 서버가 죽어버린다.
쓰레드 풀
요청 마다 쓰레드 생성의 단점을 보완한 것
특징
- 필요한 쓰레드를 미리 쓰레드 풀에 보관해 놓는다.
- 쓰레드 풀에 들어갈 쓰레드의 최대치를 정할 수 있다. 톰캣은 200개가 기본이다.
사용되는 원리
- 요청이 들어오면 쓰레드 풀에서 쓰레드를 꺼내서 사용한다.
- 사용이 종료되면 쓰레드를 종료하지 않고 다시 풀에 반납한다. 즉, 재사용
- 쓰레드 풀에 쓰레드가 없다면 요청을 거절하거나 대기하게 할 수 있다.
장점
- 쓰레드가 미리 생성되어 있으므로, 생성 비용이 적고(CPU), 응답이 빠르다.
- 최대치 설정이 있기 때문에 요청이 많이 들어와도 CPU는 안전하게 기존 요청을 처리할 수 있다.
실무에서 어떻게 설정해야 할까?
- 쓰레드 풀의 최대 쓰레드 수를 얼마나 설정할지 고민해야 한다.
- 너무 낮다면 CPU는 넉넉하지만 클라이언트가 대기하는 현상이 발생하고
너무 높다면 CPU 용량 초과로 서버가 다운될 수도 있다. - 적정 숫자는 상황마다 너무 다르기 때문에
아파치 ab, 제이미터, nGrinder와 같은 성능 테스트 툴로 테스트가 필요하다.
정리
- 멀티 쓰레드는 그냥 WAS가 해주는 것이다. 개발자는 신경 안쓰고 개발할 수 있음
- 멀티 쓰레드는 결국 여러 요청들이 들어오는 것이므로 싱글톤 객체(서블릿, 스프링 빈) 사용 시
상태를 갖지 않도록 주의 해야 한다.
'Spring' 카테고리의 다른 글
서블릿, JSP, MVC 패턴의 차이 (0) | 2022.01.24 |
---|---|
서블릿과 톰캣의 관계 (0) | 2022.01.24 |
Web Server와 WAS (0) | 2022.01.24 |
스프링의 request 스코프란? (0) | 2022.01.14 |
스프링의 프로토타입과 싱글톤 빈 같이 사용하기 (0) | 2022.01.14 |