요즘 알고리즘 풀이 포스팅 하는 시간을 줄이고, 문제를 더 푼다고 한동안 포스팅을 쉬었는데, 마침 어제부로 내가 수강하고 있는 Spring MVC 강의를 마친 기념으로 내가 이때까지 배운 것. 그리고 공부를 하면서 "어떻게 이게 동작되는 거지?"와 같은 의문이 든 많은 것들에 대해 정리를 해보도록 하겠다.
Servlet Container
아주 단편적이게 얘기를 하자면, ServletContainer 는 클라이언트의 request가 들어오면, ThreadPool에 대기하고 있는 Thread를 배치하거나, 서블릿 객체의 life-cycle을 기본적으로 싱글톤 패턴으로 관리하는게 핵심 컨셉이다.그리고, ServletContainer(Tomcat)는 동시에 여러 클라이언트의 요청을 받아들일 수 있도록 멀티 쓰레드를 지원하고 있다.
여기서 주의 해야할 점은 싱글톤 객체를 멀티쓰레드에서 동시에 접근할 수 있으므로 Thread-safe하지 않으므로, 반드시 최대한 stateless(무상태) 설계를 하여야 한다.
근데 나는 여기서 한번 의문이 들었는데, 도대체 그게 어떻게 가능한가?
나는 이때까지 멀티쓰레드로 코드를 짜본적이 거의 없었던 터라, 여러곳에서 동시에 하나의 메서드를 호출할 수 있나? 아무튼 알수 없는 생각들이 많이 들었다.
어떻게 다중 요청 처리를 가능하게 하는걸까?
이 질문을 좀 더 깊이 있게 이해하기 위해선 나는 조금 더 깊이있게 들어가봤다.
위에서 말했듯이, Mutiple Client Request를 처리하는 것은 서블릿 컨테이너의 역할이다.
스프링 부트에 기본적으로 톰캣이라는 embeded Tomcat을 넣어놓고 있다는 것을 여러분 모두 알 것이다.
이 ServletContainer도 어떤 기술과 마찬가지로 처음부터 효율적이고, 깔끔하게 동작하지 않았다. 주관적이지만, 개발 공부를 하면서 느낀 건, 어떤 기술이든 장점과 단점은 대부분 공존했다고 느꼈다.
먼저 ServletContainer의 문제점을 이야기 위해서 ThreadPool 을 알아보자.
이전에 ServletContainer는 새로운 ClientRequest가 올 때 마다, 즉각적으로 쓰레드를 새로 생성하고, Task를 처리했다.
그리고 Task가 끝나면, ServletContainer는 해당 쓰레드를 파괴하는 방식으로 돌아갔는데, 이는 문제점이 여럿 있다.
- 모든 요청에 대해 일일히 Thread를 생성하고, 파괴하는 것은 OS와 JVM의 과부하를 안겨준다.
- 동시에 일정 이상의 다수 요청이 들어올 경우 서버가 죽어버릴 수 있다. Thread는 CPU와 메모리 자원을 이용하는데, 이렇게 매번 생성하는 것은 현재 서버에서 받아들일 수 있는 Limit이 없기에 이런 형상이 발생할 수 있는 것이다. 비유를 하자면 풍선에 순간적으로 뭔가를 확 넣어버리면 터지는 것처럼 말이다.
이 문제를 해결하기 위한 방안이 ThreadPool이다.
다음은 ThreadPool의 기본 플로우이다.
- 쓰레드 풀에서는 미리 생성할 쓰레드의 수를 정해놓는다. 이때, 이 값은 maxThreads와 같거나 더 작은 값.
- 클라이언트가 요청(request)을 보낸다.
- 쓰레드 풀은 요청을 받으면, 해당 요청을 처리할 WorkerThread를 찾는다. 만약 쓰레드 풀에서 사용 가능한 WorkerThread 가 있다면, 해당 쓰레드에게 요청을 할당.
- 만약 쓰레드 풀에서 사용 가능한 WorkerThread가 없다면, 쓰레드 풀은 Request를 대기 큐(waiting queue)에 추가하고, 쓰레드 풀은 요청을 처리할 새로운 WorkerThread를 동적으로 생성하여 할당한다. 이때, 생성된 WorkerThread는 쓰레드 풀에서 WorkerThread의 수를 넘지 않는다.
- WorkerThread의 수가 maxThreads 값보다 크다면, 쓰레드 풀은 Thread를 생성하지 않고, 새로운 요청을 거부하거나 대기시킴
- Waiting queue에서 대기 중인 요청은 WorkerThread가 할당될 때까지 대기.
- WorkerThread가 작업을 완료하고 반환되면, 해당 쓰레드는 쓰레드 풀에 반환.
- 만약 Waiting Queue가 비어있고, maxThreads보다 더 많은 WorkerThread가 생성 되어있다면 Thread를 Destroy
참고 : maxThreads란 흔히 다들 아시는 Tomcat일 때 아래와 같이 maxThreads를 Customizing할 수 있다.
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443"
maxThreads="200" />
이렇게, ThreadPool을 이용해서, 일정량 정도의 WorkerThread를 ThreadPool에 생성해놓고 있으며, maxThreads 한도 내, 더 많은 쓰레드가 필요하다면 동적으로 생성해서 할당한다. 또한 maxThreads 자체를 넘어버리면 Request를 튕기거나 대기시키도록 할 수 있다. 정도로 이해하면 된다. TMI 하나 들어가자면, 나는 신발을 참 좋아하는데, 매번 나코에서 조던 응모만 하려고 하면, 페이지가 넘어가지 않고, 대기자가 몇천명씩 있다고 하던데, 아 WorkerThread가 없어서 내 Request가 WaitingQueue에 쌓여서 Thread를 할당 받기까지 대기중이였던거구나 하고 생각했다.
하지만, 여기서 Blocking I/O인지에 따라서 위에 이야기 했던 것들이 달라질 수 있다. 자세한 건 이 포스팅을 참고하자.
여기에서 Connector에 대해서만 간단 정리하겠다. 왜냐하면 HttpServletRequest와 관련이 있기 때문이다.
Connector
Connector는 WAS의 바로 아래층에서 동작하는 컴포넌트로, 웹 서버와 ServletContext 사이의 통신을 담당하는 역할을 한다
따라서, 일반적으로, WAS의 일부분으로 취급되고, WAS 내부에서 동작한다. 예를 들면, Apache Tomcat의 Connector는 Coyote 패키지에 위치하고 있다.
따라서,
1. 웹서버가 변환한 데이터 패킷을 Connector가 획득하면, 이를 파싱하여 HttpServletRequest 객체를 생성하며, 동시에 HttpServletResponse 객체도 생성한다.
2. ServletContainer에 이 두 객체를 전달한다. 이후는 ServletContainer의 매커니즘에 따라 동작된다.
나는 공부할 때 계속 궁금했던 것은, "HttpServletRequest/Response 이 두 객체를 ServletContainer가 생성해서 서블릿에 넘겨준다는것은 알겠는데, 그래서 ServletContainer의 누가 생성하는데?" 였다. 이를 통해서 진상 확인?이 가능해졌으니 답답했던 무언가가 하나 날아간것 같다.
이렇게 Thread Pool에 대해서 전반적으로 얘기를 해봤고, 이제 다시 돌아가서 어떻게 다중 요청 처리가 가능한지 정리해보겠다.
이 궁금점을 해결하기 위해 이 링크의 질문글이 매우 흥미로웠다.
객체는 Heap 영역에 생성되고, 메서드의 정보는 Class Area에 저장된다.
따라서, 동기화고 뭐고 블락이 되고 어쩌고 생각할 필요 없이, 메서드를 호출하는 입장에선 이런거 신경 쓸 필요없이 Class class에 해당 메서드의 Binary Code를 갖다 쓰기만 하면 된다. 또한 이 Binary Code엔 메서드의 처리 로직만 들어있기 때문에 이 binaryCode를 여러 Thread에서 공유해도 상관이 없다.
즉 이 메서드를 호출할 때 어떤 쓰레드가 어떤 쓰레드의 메서드를 다 쓸때까지 기다려야 하니 마니 생각할 필요가 없다.
그냥 누구든지 이 메서드의 처리로직만을 동시에 공유해서 로직을 처리해버리면 된다.
하지만, 이는 무상태 설계와는 완전 별개의 얘기임을 꼭 기억하자. 여전히 해당 컨트롤러를 stateless 설계 하여야 함에는 전혀 변함이 없다.
싱글톤은 단 하나의 인스턴스만 생성되는 디자인 패턴으로, 여러 곳에서 동일한 객체를 참조하여 사용하고자 할 때 유용하게 사용된다.
하지만, 멀티스레드 환경에서 싱글톤 객체를 공유하다 보면 문제가 발생할 수 있는데, 예를 들어, 두 개 이상의 스레드에서 동시에 싱글톤 객체의 메서드를 호출한다면, 각각의 스레드에서 싱글톤 객체의 상태를 변경할 수 있으므로 예측할 수 없는 결과를 초래할 수 있다.
이 때문에 멀티스레드 환경에서 싱글톤 객체를 안전하게 사용하기 위해서는 동기화를 고려해야 한다. 그러나 동기화를 하면, 여러 스레드에서 동시에 접근하여 사용하는 이점이 사라지게 된다.
즉, 싱글톤 객체의 장점 중 하나인 동일한 객체를 여러 곳에서 참조하여 사용할 수 있는 이점이 사라지게 되는 것. 따라서, 싱글톤 객체를 동기화하여 사용하면, 스레드 안전성은 확보할 수 있지만, 동시성과 성능은 저하될 수 있다. 물론 상황에 따라 다르겠지만, 무상태 설계를 최대한 해내는 것이 단점이 없는 유일한 방법이다.
다음으로, 이번 포스팅에서 핵심이 될 주제인 스프링 컨테이너와 서블릿 컨테이너에 대해서 정리해보겠다.
Servlet
Servlet은 Java EE의 표준 중 하나로 javax.servlet Package를 기반으로 Server에서 동작하는 Class들을 의미한다.
각 Servlet은 init(), service(), destroy() 3개의 method를 구현해야 한다.
- init() : 이 메서드는 Servlet 객체의 생성 시, 초기화를 담당하고, Servlet이 이용하는 자원을 할당하는 동작을 수행한다.
- service() : 클라이언트에서 서버로 요청을 보내면 해당 요청을 처리할 할당된 쓰레드는 Servlet의 service() 메서드를 호출한다. 그리고 Parameter로 HttpServletRequest/Response를 넣어준다. 이 Method는 이름 그대로, 비즈니스 로직이 들어있다.
- destroy () : Servlet 객체가 삭제될 때 호출된다. Servlet에서 이용하는 자원을 안전하게 해지할 수 있도록 도와준다.
Servlet은 HTTP를 기반으로 하는 웹 애플리케이션에서 동작하는데, HTTP 요청과 응답 처리를 담당한다.
Servlet은 위를 통해 알 수 있듯이, Life-cycle을 가지고 있으며, 이를 이용하여 Servlet의 초기화 및 종료작업을 수행할 수 있다.
따라서, ServletContainer는 이러한 생명주기를 이용하여 Servlet 인스턴스의 Life-cycle을 관리하며, 새로운 요청이 들어올 때 마다 Task를 처리할 WorkerThread를 할당하는 역할을 한다.
참고 : thread per connection vs thread per request
ServletContainer의 기본 플로우에 대해서 알아보자
- 클라이언트에서 요청을 보내면 Web browser는 요청 메세지를 생성하여, Web Server로 HTTP Request를 보낸다.
- Web Server는 받은 HTTP Request를 WAS Server 내부에 있는 Web Server로 전달한다.
- WAS Server의 Web Server는 이를 Servlet Container에 전달.
- Servlet Container는 httpServletRequest/Response 객체를 생성한다. Request 객체는 클라이언트의 Request를 토대로 만들어지며, 요청을 매핑할 Servlet의 service()를 호출하면서 생성한 두 객체를 전달한다.
- WAS는 이를 전달 받고, 비즈니스 로직을 처리하고 난 후, 응답 결과를 HttpServletResponse 객체에 넣어서 반대의 과정을 거치며 클라이언트에게 전달한다.
여기에서, Servlet은 싱글톤 패턴으로 사용되며, 새로운 요청에 대해 기존의 Servlet 인스턴스를 이용한다. 따라서, Thread-safe 하지 않으므로, Stateless 설계 하여야 함에 주의하자.
공부할 때 많이 쓰이는 ServletContainer는 대표적으로 Tomcat이다.
Tomcat application server는 기본적으로 모든 webapp들에 하나의 JVM을 배치한다. 또한 여러 개의 Tomcat 인스턴스를 가질 수 있으며, 각각 자체 JVM을 실행하고 별도의 설정을 가질 수 있으며 독립적으로 시작/중지할 수 있다.
Spring Container는 Bean 생명주기를 IoC를 이용하여 관리합니다. 최상위 조상인 BeanFactory를 기반으로 하며, ApplicationContext는 이를 상속합니다.
- Web Application이 실행되면, WAS(Tomcat)에 의해 web.xml이 로딩된다.
- 이 때, web.xml에 등록되어 있는 ContextLoaderListener가 Java Class 파일로 생성. ContextLoaderListener는 ServletContextListener 인터페이스를 구현한 것으로서, root-content.xml 또는 ApplicationContext.xml에 등록되어 있는 설정에 따라 Spring Container가 구동되며, 이 때 개발자가 작성한 비즈니스 로직과 DAO, VO 등의 객체가 생성.
- DispatcherServlet은 getHandler()를 통해 HandlerMapping을 통해 요청에 맞는 핸들러를 찾음. 스프링 부트는 다양한 핸들러 매핑 클래스를 구현해 놨으며, 이를 리스트로 저장하여 하나씩 돌려보면서 support가 되는 핸들러를 매핑. (애너테이션 기반 컨트롤러 매칭, 빈 이름 매칭 등)
- 기본적으로 HandlerMethod는 컨트롤러의 모든 매핑 후보 메서드의 정보를 저장해놓고 있는데, Request가 오면 key로 요청 정보를 value를 HandlerMethod로 하여 HandlerExecutionChain으로 감싸서 DispatchServlet에 반환한다.
- HandlerMapping에서 찾은 핸들러를 실행할 수 있는 HandlerAdapter를 조회하고, 이후 인터셉터의 preHandle()을 호출.
- preHandle()에서 false 가 반환됐다면 더 이상 진행하지 않고, true가 반환되었다면 HandlerAdapter는 호출할 Controller의 메서드 parameter 정보를 ArgumentResolver에게 전달하고 이는 Controller가 필요로 하는 객체를 생성(바인딩)하고 이후 HandlerAdapter에게 반환하여, 이를 Controller의 메서드를 호출하면서 parameter로 넣어줍니다.
- 만약 HttpEntity 또는 이를 상속한(RequestEntity) 클래스나 @RequestBody가 파라미터로 받고 있다면, ArgumentResolver는 HttpMessageConverter를 사용하여 객체를 생성함.
- 컨트롤러의 실제 비즈니스 로직을 실행함.
- ModelAndView 형태로 변환하여 반환합니다. 이후, DispatcherServlet은 ViewResolver를 통해 해당 View를 찾아 Client에게 반환한다,
- 당연히, ModelAndVIew 말고도 다양한 타입으로 반환할 수 있다. 자세한 내용은 공식 문서인 여기로 들어가면 된다.
- 해당 링크엔, Return type 말고도 다양한 정보가 포함되어 있기 때문에 뭔가 이런 것들에 대해 정리가 되어 있지 않다면 한번쯤은 읽어두는 것이 좋다고 생각한다.
*필터,인터셉터,예외처리 등에 대한 내용은 자세하게 따로 포스팅 할 예정이고, 기본적인 플로우만 작성해봤습니다. 너무 길어져서ㅠㅠ
스프링 MVC 패턴이 도입 되기 이전엔, 요청 URL을 매핑하기 위하여 일일히 서블릿 객체를 생성하고 Web.xml로 Servlet을 관리했다. 또한 중앙 집중적 처리가 불가능해서 코드의 중복성이 증가하고, 유지보수에 어려운 측면이 있다. 또한 Interceptor를 적용하기도 힘들어서, 보안이 필요한 url에 매번 보안 코드를 일일히 작성하고, 보안의 일관성을 유지하기 어려울거 같다고 생각했다. 스프링 MVC 패턴의 핵심은 난 역시 DispatcherServlet이라고 생각한다. 이는 앞서 말한 이러한 단점들을 중앙 집중적 처리를 가능하게 함으로써 해결하였고, View를 강제로 분리하는 효과도 볼 수 있게 되었다.
Servlet Container and Spring Container
다음은 내가 많이 궁금했던건데, 바로 스프링 컨테이너와 서블릿 컨테이너가 하는 역할 말고, 어떤 상관관계에 있는지 아리쏭할 때가 있었다.
왜냐하면, 이 둘은 클라이언트의 요청을 처리하기 위한 공통의 목적을 가지고 있지만 핵심 컨셉은 달랐다.
위의 그림과 같이 Spring MVC 란 결국 ServletContainer 내에 존재하고 있는 하나의 서블릿 객체라고 볼수 있었다. Spring MVC는 서블릿 기반이고, 이로 들어가는 모든 요청과 응답은 Front Controller인 DispatcherServlet이 모두 관리하고 있다.(Spring Container는 Spring의 자체 Configuration에 의해 생성됨)
아무튼 중요한 것은 Servlet Container는 Process 하나에 배정되어 있고, 이에 따른 모든 요청들은 Thread가 각각 처리하도록 Thread Pool에서 역할을 배정시키는 것이다. 서블릿 객체의 생성 시점은 서버 설정에 따라 다르다. 애플리케이션 로딩 시점에 생성할 수도 있고, 클라이언트 최초 요청 시점에 생성할 수도 있다. 생성된 이후엔 기본적으로 서블릿 객체를 Singleton으로 life-cycle을 관리하고, 요청이 들어오면 service()를 호출하여 해당 요청을 처리한다. 또한 애플리케이션이 종료되는 시점에 destroy()를 호출하는데, 이렇게 개발자가 아닌 프로그램에 의해 객체들의 life-cycle이 관리 되는 것을 IoC(Inversion of Control)이라고 한다.
Spring Boot의 동작 방식
1. DispatchServlet이 스프링에 @Bean으로 등록됨
2. DispatchServlet을 컨택스트에 등록한다.
3. 서블릿 컨테이너 필터에 등록설정 해놓은 필터들을 등록한다.
4. DispatcherServlet에 각종 핸들러 매핑(자원 url)들이 등록된다. (컨트롤러 빈들이 다 생성되어 싱글톤으로 관리되어 진다.)
- 서블릿 컨테이너에서 DispatcherServlet이 요청을 받으면, 이는 이미 스프링 컨테이너에 등록된 @Controller 빈으로 매핑되어 처리된다.
- 이때 DispatcherServlet은 FrameworkServlet을 상속하고, FrameworkServlet.service()가 호출되면 dispatch.doService()를 호출하고, dispatch.doService()는 dispatch.doDispatch()를 실행한다.
- doDispatch()에서는 AbstractHandlerMapping에서 Handler(Controller)를 가져와서, Interceptor를 지나 해당 Controller Method로 이동한다. 해당 Handler는 ModelAndView를 리턴하는데, @RestController일 경우에는 RetrunValueHandler는 HttpMessageConverter를 이용하여 바로 반환값을 바로 요청 바디부에 작성하여 결과값을 리턴한다.
- View에 대한 정보가 있으면 ViewResolver에 들려 View 객체를 얻고, View를 통해 렌더링을 한다.
- 즉, DispatcherServlet은 스프링 컨테이너에 등록된 @Controller 빈을 찾아서 해당 요청을 처리하고, 이를 위해 FrameworkServlet을 상속하여 HttpServlet 기반으로 동작하며, 요청을 처리하기 위해 여러 과정을 거치는 것이다
*서블릿의 필터부터 스프링의 예외처리까지 그리고 최종적으로 응답 메세지를 만들어 클라이언트에게 반환되기까지 어떠한 작업을 걸치고, 흘러가는지에 대해선 복습을 하면서 포스팅을 할 예정이므로 많이들 읽어주시면 감사하겠습니다 ^&^
'스프링 > 스프링 MVC' 카테고리의 다른 글
스프링 MVC 기본 기능 (0) | 2023.05.16 |
---|---|
스프링 MVC 구조 (0) | 2023.05.15 |
MVC 프레임워크 (0) | 2023.05.14 |
Servlet의 한계, 템플릿 엔진, MVC 패턴 (0) | 2023.05.13 |
서블릿 (0) | 2023.05.12 |