반응형

서블릿은 웹 요청과 응답 처리를 해주는 자바 API이다. 즉, 웹 서버처리에 필요한 요청과 응답을 관리하는 것이다.
서블릿을 구현(서블릿은 인터페이스)하는 것들이 톰캣과 같은 WAS 서버인데 톰캣은 apache라는 웹 서버를 포함하고 있다.

즉, 우리가 HttpServlet 같은 것을 사용하여 요청 받고 응답받고 하는 코드들이 톰캣이 서블릿을 구현하여
웹 요청을 처리 해주기 때문인 것이다.

자세히 알아보도록 하자.https://dev-monkey-dugi.tistory.com/119

웹 서버에서는 정확히 무엇을 처리해야 할까?


웹 서버는 웹의 요청과 응답을 관리한다고 했다. 일단 요청이 오고 응답을 주어야 애플리케이션 로직이 당연히 의미가 있는 것이니까

**여기서 웹 서버가 처리한다고 하는 것은 정적 리소스를 전달하는 것이면 웹 서버, 동적이라면 WAS가 될 수 있다.**

웹 서버가 처리해야 하는 것

HTTP 요청과 응답 메시지 관리

  1. 요청을 받을 수 있도록 TCP/IP 대기를 한다.
  2. 브라우저가 localhost:8080으로 요청을 보내면 요청을 받아 소켓을 생성한다.
  3. HTTP 헤더를 모두 읽는다.
    1. POST, GET 방식인지
    2. Content-Type이 뭔지
    3. 바디 내용은 뭔지

등 여러 내용을 읽어야 한다. 이러한 정보는 브라우저에서 만들어서 보내게 되는데
우리는 스프링을 사용하면서 톰캣이 서블릿을 지원해서 요청 정보를 모두 읽어 주는 것이다.

  1. 이후에 우리가 평소에 하는 개발은 비지니스 로직을 작성하는 것이다.
    컨트롤러 → 서비스 → 리포지토리 등과 같은 것들 말이다.
  2. 로직을 모두 처리하면 대부분 우리는 클라이언트에게 컨트롤러를 통해 응답을 해준다.
    웹 서버는 HTTP 응답 메시지를 만들어서 주는 것이다.
    1. HTTP 시작 라인 생성
    2. 헤더 생성
    3. 바디에 HTML 생성 등등
  3. 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. 서버 프로그램을 시작한다.
    1. 이 단계는 그냥 메인 메서드를 실행하는 단계이다.
  2. 서버 소켓을 생성한다.

1. `ServerSocket serverSocket = new ServerSocket(8080)`을 실행 하여
실제 서버가 클라이언트와 통신할 서버 소켓을 여는 것이다.
  1. 서버 소켓이 클라이언트 프로그램의 연결 요청을 받을 수 있도록 대기상태로 만든다.
  2. Socket connection; while ((connection = listenSocket.accept()) != null) {...}
  3. 클라이언트 프로그램은 소켓을 생성하여 서버 소켓에 연결을 요청한다.

1. `Socket socket = new Socket(”111.111”,  8080);`
  1. 서버 소켓은 클라이언트 프로그램의 연결 요청을 받고, 클라이언트와 연결할 소켓을 새로 생성한다.

1. 이와 같이 ServerSocket은 오로지 클라이언트와 연결될 Socket을 생성해서 연결해주는 역할만 있다.
2. 이후 통신은 Socket끼리 하는 것이다. 이렇게  한 쌍이 쓰레드이다.
3. `Socket socket = serverSocket.accept();`
2번에서는 대기만한 것이고 여기서 실제로 생성이 된다.
  1. 마지막으로 소켓끼리 연결이 되어 ServerSocket과는 이제 무관하게 통신을 한다.

대략 이런 과정이 웹 서버를 구현하는 코드라고 할 수 있다.
이 복잡한 코드들이(실제로는 훨씬 복잡) HttpServlet를 통해서 톰캣이 지원 해주고 있는 것이다.

그럼 멀티 쓰레드라면 구조가 아래와 같이 여러 소켓(쓰레드)을 생성하여 연결이 되게 된다.

톰캣이 어떻게 처리하는지 그림으로 살펴 보자.

좀 더 자세한 내용은 해당 글을 참고하자.

  1. 웹 브라우저는 요청을 한다. localhost:8080
  2. 톰캣의 앞단에 있는 웹 서버는 요청과 응답 객체를 요청 별로(멀티 쓰레드 지원) 객체를 생성하여 서블릿 컨테이너에게 요청한다.
    1. 브라우저가 넘겨준 데이터는 순수 문자열이다.
    2. 서블릿 컨테이너에 있는 것이 서블릿 객체이다.
    3. 서블릿 객체는 싱글톤으로 관리된다.
      서버가 뜰 때 서블릿 객체들은 이미 싱글톤으로 생성된다.
    4. GET localhost:8080/members와 같은 것이 하나의 서블릿 객체인 것이다. 즉, 컨트롤러에서 메서드 하나와 같다.
  3. 개발자는 서블릿 객체를 편리하게 사용한다.
  4. 서블릿 컨테이너는 작업이 종료되면 개발자가 서블릿 응답 객체에 담아 놓은 것을
    앞단 웹서버에 객체를 전달하고 웹 서버는 HTTP 응답 정보를 생성하여 브라우저어게 응답 해준다.

참고 → 서블릿 객체는 요청 마다 다른 객체를 생성한다. 싱글톤으로 관리된다고 했는데 무슨 말이지?
예를 들어 GET /hello, GET /members 라는 요청이 있다면 서블릿은 /hello 서블릿 1개, /members 서블릿 1개를
싱글톤으로 관리한다는 것이다. 즉, /hello라는 요청이 100개가 들어와도 서블릿 객체는 1개인 것이다.

멀티 쓰레드


위에서 웹 서버는 요청 별로 각각 요청과 응답 객체를 생성한다고 했다.
그리고 이 객체를 서블릿 컨테이너에 던져준다. 그런데 어떻게 각각 생성이 될까? 누가 해주는 것일까?
웹 서버가 쓰레드를 각각 생성하여 쓰레드가 서블릿 컨테이너에 요청하게 되는 것이다.

쓰레드란

  • 애플리케이션 코드를 한 줄 한 줄 순차적으로 실행하는 것이다.
    프로레스는 프로그램이고 프로세스에는 1개 이상의 쓰레드가 있다.
    즉, 쓰레드가 없으면 프로세스는 실행될 수 없다.
  • 자바에서는 main이 기본 쓰레드이다.
  • 동시 처리가 필요하면 쓰레드를 여러개 생성하면 되는데 이것이 멀티 쓰레드이다.

단일 쓰레드일 경우

요청이 여러명이 왔을 때 쓰레드가 작업이 끝날 때 까지 기다려야 한다.
만약 서버에서 엄청 오래 걸리는 작업을 하고 있다면 다른 요청은 무한 대기를 하게 되는 것이다.
이러다가 결국 타임 아웃이 걸린다던가 하게 될 수도 있다.

이상없이 동작을 한다면 쓰레드는 처리 완료 후 대기를 하게 된다. 자바에서 wait() 등과 같은 메서드들이 있다.

요청 마다 쓰레드 생성하는 방법

그럼 동시 요청을 위해 멀티 쓰레드 환경을 요청이 올 때 마다 생성하면 된다.

하지만 장점과 단점이 있지만 잘 사용되지 않는다.

장점

  • 동시 요청 처리가 된다.
  • 리소스(CPU, 메모리)가 허용할 때 까지 처리가 가능하다.
  • 하나의 쓰레드가 지연 되어도, 나머지 쓰레드는 정상 작동된다.

단점

  • 쓰레드 생성 비용이 비싸다.
    • 고객의 요청이 올 때 마다 쓰레드를 생성하면, 응답 속도가 늦어진다.
  • 쓰레드는 컨텍스트 스위칭 비용이 발생한다.
    • 코어가 100개 200개가 아니고 많아야 4 ~ 8개 정도이기 때문에
      100개의 요청만 들어와도 CPU에서 엄청난 컨텍스트 스위칭이 발생하게 된다.
  • 쓰레드가 계속 생성되기 때문에 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
복사했습니다!