[spring] servlet부터 spring등장까지

2022. 4. 18. 15:58[ 백엔드 개발 ]/[ Spring ]

최근 MockMvc 기반으로 컨트롤러 단의 테스트 코드를 작성하다가 핸들러가 호출되지 않는 문제를 마주했고 이를 해결하기 위해 컨트롤러까지 요청이 들어오기까지 어떤 과정을 거치는지 알아야 할 필요성을 느껴 포스팅을 준비했다.

 

서블릿이란

 

과거에는 거의 동적인 컨텐츠만 다루는 웹 시스템이었으나 동적인 컨텐츠를 다루면서 웹 서버에 프로그램을 붙여 동적인 페이지를 제공하도록 변화했다. 그래서 등장한 것이 서블릿과 WAS이고 서블릿은 자바 Interface로 정의된 명세사항(specification)이다. (servlet은 javax 패키지에 정의된, 자바에서 정의한 스펙 사항이며 다른 플랫폼에서는 사용되지 않는다)

 

서블릿의 스펙사항을 기반으로 만들어진, 서블릿을 관리하는 서블릿 컨테이너가 WAS에서 구동된다. WAS는 OSI 7계층에서 Application Layer를 구현하는 '미들웨어'라고 생각하면 된다. WAS가 처음 구동될 때 서블릿 인스턴스를 생성하고 서블릿 컨테이너에 등록한다. 그리고 사용자의 요청을 HttpServletRequest 인스턴스로 만들어 서블릿으로 위임하고 서블릿으로부터 응답을 받아 HttpServletResponse인스턴스를 재해석해 OS로 내보내는 역할을 담당한다.

 

만약 WAS가 없다면 개발자가 직접 OS로부터 HTTP 요청과 응답을 받아내고, HTTP 헤더, 페이로드 등을 해석하는 코드를 작성해야 한다. 결과적으로 개발자는 서블릿과 이를 구현한 WAS 덕분에 비즈니스 로직에 더욱 집중해서 개발할 수 있게 된 것이다.

 

 

HttpServlet 클래스의 service() 메소드

 

 

위 사진은 서블릿 스펙을 구현한 클래스 중 HttpServlet의 service() 메소드이다. service() 메소드는 서블릿에서 요청을 처리하기 위해 핵심적인 역할을 담당하는 메소드이며 HttpServletRequest를 받아 HTTP 메소드에 맞게 요청을 처리한 뒤, HttpServletResponse로 내보내는 역할을 담당한다.

 

HttpServlet 클래스의 service() 메소드의 구현부

 

service() 메소드의 구현부를 보면 doGet(), doPost(), doPut() 등 HTTP 메소드를 처리하는 다양한 메소드들이 존재하고 있다.

 

 

 

Servlet Container

서블릿 컨테이너는 서블릿을 관리하는 대상이다. HTTP 요청이 들어오면 서블릿 컨테이너는 해당 요청과 매핑된 서블릿을 찾는다. 여기서 서블릿을 찾는 매핑 전략은 서블릿 설정 파일을 통해 지정한다. 

 

[예시]

<servlet>
	<servlet-name>MyServlet</servlet-name>
	<servlet-class>servlet.MyServlet</servlet-class>
</servlet>

<servlet-mapping>
	<servlet-name>MyServlet</servlet-name>
	<url-pattern>/users</url-pattern>
</servlet-mapping>

 

위와 같이 서블릿의 위치와 해당 서블릿이 처리한 URL을 설정해 요청과 서블릿이 매핑될 수 있도록 한다. 이러한 설정 파일을 서블릿 컨테이너가 읽어서 요청이 들어오면 해당 요청을 처리할 서블릿이 서블릿 컨테이너에 존재하는지 확인한다. 만약 서블릿 컨테이너에 해당 서블릿이 없다면 init() 메소드를 통해 해당 서블릿을 생성하고(기본적으로 싱글톤) 서블릿 컨테이너 안에서 쓰레드를 생성해 HttpServletRequest와 HttpServletResponse를 service() 메소드의 인자로 보내 호출한다.

 

기본적으로 서블릿은 싱글톤으로, init() 메소드는 단 한번 호출되며 컨테이너가 종료될 때 destory()가 호출됨으로써 서블릿이 삭제된다. 즉, 서블릿 컨테이너는 서블릿의 생명주기를 관리하는 주체이다. 

 

서블릿은 싱글톤이기 때문에 Heap 메모리에 상주함으로써 여러 요청(쓰레드)을 동시에 처리될 수 있다. 따라서 여러 쓰레드 간 공유하는 자원이기 때문에 stateless 하게 구현하는 것이 좋다. 이와 더불어, 보통 서블릿을 사용하는 쓰레드는 요청을 처리하고 응답을 만드는 과정까지 새로운 쓰레드를 만들지 않는다. 새로운 쓰레드를 만드는 작업과 쓰레드 간 context switching이 상당한 오버헤드를 가지기 때문이다.

 

 

Front Controller Pattern

기존의 웹 컨테이너는 HTTP 요청 경로마다 서블릿 객체를 정의했다. 하지만 HTTP 요청이 많아짐에 따라 수많은 서블릿 객체를 만들어야 했고, 이로써 공통로직을 분리할 필요성이 생겼다. 이렇듯 서블릿마다 공통 로직을 갖는 비효율성을 개선하고자 적용한 것이 Front Controller Pattern이다. 요청과 응답을 만드는 과정에서 필요한 예외처리, request, response 객체를 받아 HTTP 응답에 맞게 인코딩/디코딩(content-type에 맞는 뷰 생성) 등 각 서블릿마다 갖는 공통 로직을 한 곳에 모아 처리하기 위해 적용된 패턴이다.

 

front controller pattern 적용 전

 

아래와 같이 Front Controller Pattern을 적용한 서블릿을 하나를 두고 이 서블릿을 Dispatcher Servlet이라고 부른다. Dispatcher Servlet은 모든 요청을 받아 각 핸들러(컨트롤러)로 요청을 delegate(위임)한다.

front controller pattern 적용 후

 

각 요청마다 서블릿을 정의하고 각 요청마다 쓰레드를 생성했던 이전의 모델과 달리 하나의 서블릿이 모든 요청을 수행할 수 있는 구조가 된 것이다. 스프링 MVC 구조도 Front Controller Pattern을 따른다.

 

 

 

Dispatcher Servlet이 컨트롤러로 요청을 위임하는 과정

 

Dispatcher Servlet이 요청을 받고 처리하기 위한 세부적인 역할은 크게 3가지로 나뉜다.

 

- Handler Mapping 

- Handler Adapter

- View Resolve

 

Handler Mapping은 해당 요청에 맞는 컨트롤러를 찾는 역할을 담당하고 Handler Adapter는 Handler Mapping가 찾은 컨트롤러 메소드를 호출하기 위해 argument를 타입에 맞게 정리하며 실질적으로 핸들러를 호출한다. Handler Adapter는 핸들러가 반환하는 값을 ModelAndView로 만들고 반환한다. Dispatcher Servlet에서는 ModelAndView를 받아 적용할 view를 선택해 해당 뷰에 모델(동적인 컨텐츠를 만들기 위해 적용할 데이터)을 적용시킨다.

 

Handler Mapping, Handler Adapter은 인터페이스로 구현되어 있고 이를 구현하는 다양한 구현체가 스프링 프레임워크에 정의되어 있다. 이들의 구현체는 스프링 컨테이너에서 관리되며 bean으로 등록되어 있다. Dispatcher Servlet은 스프링 컨테이너로부터 해당 bean 들을 주입받아 사용하는 구조가 된다. 즉, Dispatcher Servlet이 요청을 처리할 수 있게끔 핸들러, HandlerMapping등 필요한 bean들을 SevletWebApplicationContext에 주입만 해주면 rootWebApplicationContext를 활용해 개발을 할 수 있게 되는 것이다.

 

한 가지 예를 들자면 위에서 설명한 기존 서블릿 모델에서는 XML 형식으로 핸들러를 직접 서블릿 설정 파일에 등록해줘야 했다. 하지만 스프링에서는 사용될 핸들러를 호출하기 위한 Handler Mapping / Handler Adapter 인터페이스 구현체를 ServletContainer에 주입함으로써 (프레임워크의 역할, IoC가 발생한 상황을 말한 것임) 개발자가 보다 편리하게 개발할 수 있도록 한다.

 

따라서 스프링이 이러한 서블릿을 활용한 bean들을 제공하고 SevletWebApplicationContext에 주입해줌으로써 개발자가 보다 비즈니스 로직에 집중해 개발할 수 있게 도와준다.

 

 

아래와 같이 DispatcherServlet은 HandlerMapping, HandlerAdapater 인터페이스를 기반으로 리스트를 가지고 있으며 initHandlerAdapters()와 initHandlerHandler() 를 통해 스프링으로부터 주입받는다.

 

 

initHandlerAdapters()와 initHandlerHandler() 메소드의 인자로 ApplicationContext(스프링 컨텍스트)를 받고 스프링 컨텍스트에서 HandlerMapping과 HandlerAdpater를 찾아와 리스트 객체를 생성한다.

 

 

Handler Mapping

Handler Mapping 인터페이스는 요청을 처리할 핸들러(컨트롤러)를 결정한다.

Handler Mapping을 구현한 구현체 중 스프링에서 제공하는 RequestMappingHandlerMapping 클래스는 컨트롤러에 @RequestMapping을 사용할 때 적용된다. 코드를 보면 알 수 있듯이 RequestMappingHandlerMapping 구현체는 모든 컨트롤러 bean을 Map 형식으로 관리한다.

 

실제로 디버깅해본 결과 다양한 HandlerMapping 구현체들이 적용되어있음을 알 수 있다. 이러한 구현체들은 경우에 따라 바뀔 수 있으며 스프링 프레임워크가 서블릿 컨텍스트의 Dispatcher Servlet에 경우에 맞게 주입해준다.

 

Handler Adapter

Handler Adapter에서는 실질적으로 핸들러를 실행시킨다. @PostMapping, @GetMapping 등 @RequestMapping 기반의 어노테이션을 달아준 컨트롤러 메소드를 호출할 때에는 RequestMappingHandlerAdapter 구현체가 선택된다. 

 

다양한 HandlerAdpater 구현체

 

RequestMappingHandlerAdapter

 

 

실질적으로 핸들러를 호출하는 부분을 보면 동기화 설정 여부에 따라 요청을 순서대로 또는 그렇지 않게 처리하기도 한다. 핸들러를 호출하고 나서는 ModelAndView를 반환하고 있음을 확인할 수 있다. synchronizeOnSession의 값은 기본적으로 false이다.

 

HandlerMapping과 마찬가지로 HandlerAdapter도 스프링 프레임워크가 경우에 맞게 주입해주었음을 알 수 있다.

 

 

 

 

Handler Interceptor



Dispatcher Servlet이 Adapter를 통해 컨트롤러를 호출하는 이유는 공통적인 전후처리 과정이 필요하기 때문이다. 대표적으로 요청 시에 @RequestParam, @RequestBody 등을 처리하기 위한 ArgumentResolver들과 응답 시에 ResponseEntity의 Body를 Json으로 직렬 화하는 ReturnValueHandler들이 어댑터를 통해 처리된다.

 

이러한 역할을 Handler Interceptor가 담당하며 실질적인 핸들러를 호출하기 전 가로챈다는 의미에서 Interceptor라는 이름이 붙여졌다. Handler Interceptor는 스프링 컨텍스트 안에서 동작하기 때문에 스프링 bean과 의존적으로 처리된다.

 

cf. Interceptor 예시

1) Auth Interceptor

- 현재 요청을 한 사용자가 로그인한 사용자인지 확인

- 만약 로그인한 사용자라면 특정 로직 수행(로그인페이지 이동 등)

2) CORS Interceptor

 

 

또한 다양한 Interceptor들이 존재할 수 있기 떄문에 Interceptor는 Chin 형식으로 실행되며 HandlerExecutionChain이 다양한 HandlerInterceptor를 가지는 구조이다. 모든 HandlerInterceptor을 거쳤다면 실질적인 핸들러가 호출되며 핸들러가 응답할 때에도 HandlerInterceptor들을 거치게 된다.

 

HandlerExecutionChain 구현체 일부

 

HandlerExecutionChain의 구현체를 보면 interceptorList를 통해 다양한 interceptor가 적용될 수 있도록 한다. 핸들러를 호출하기 전뿐만 아니라 핸들러가 반환한 뒤에도 interceptor를 거친다. 단, preHandle()에서 모든 Interceptor를 통과하지 못했다면 postHandle()은 실행되지 않는다.

 

preHandle()

 

 

postHandle()

 

핸들러 반환 뒤에 거치는 Interceptor는 ModelAndView를 기반으로 진행하고 있음을 확인할 수 있다.

 

 

[Interceptor 장점]

- 공통 로직 재사용성 증가

- 코드 중복 회피를 통한 메모리 사용 감소

- 비즈니스 로직 상에서 처리할 부분을 미리 처리(예를들어 존재하지 않는 유저가 게시글 생성 요청을 하거나)

- 요청에 따라 Interceptor를 다르게 적용시킬 수 있음