1. 들어가며
자동차 내부 동작 원리를 모르더라도 자동차를 타고 다닐 수 있습니다. 하지만 비용과 시간을 아끼기 위해서는 반드시 내부 동작 원리를 알아야만 합니다. Spring 프레임워크를 통해 클라이언트의 요청(HTTP 스펙)이 Java 객체로 자동 변환되는 혜택을 누리고 있지만, 내부에서 어떻게 이 과정이 수행되는지를 모른다면 성능 문제나 예외 상황 발생 시 원인을 파악하고 해결하기가 어려워집니다.
이번 포스트에서는 초기화 시점과, 클라이언트의 요청 시점을 구분하여 Spring의 내부가 어떻게 동작하는지를 자세히 다뤄보고자 합니다.
2. 초기화 시점
실제 HTTP 요청을 수신하기 위해서는 Spring 내부에서 받을 준비가 되어 있어야 합니다. 이 준비는 컴파일 시점이 아닌 애플리케이션 실행 시점에 발생합니다.
2.1 WAS(Web Application Server) 기동
WAS의 역할은 클라이언트로부터 HTTP 요청을 수신하고 처리하는 서버 역할을 담당합니다. 애플리케이션이 실행되면 WAS가 기동 되면서 서블릿 컨테이너가 준비되고, 클라이언트의 HTTP 요청을 처리할 준비가 완료됩니다.
이후 Spring 프레임워크에서 기본 제공하는 DispatcherServlet 객체가 생성되고, 힙 메모리에 할당된 후 초기화 과정이 진행됩니다.
@SpringBootApplication
public class SpringAdvancedApplication {
public static void main(String[] args) {
SpringApplication.run(SpringAdvancedApplication.class, args);
}
}
2.2 DispatcherServlet 초기화
이 과정은 마치 비행기의 전체 전원을 켜고 시스템을 점검하는 단계라 할 수 있습니다.
WAS는 DispatcherServlet의 init() 메서드를 호출하고 내부적으로 FrameworksServlet.initServletBean()을 실행합니다. 이 과정은 Spring MVC의 핵심 초기화 과정으로, 이 단계에서 Spring 컨텍스트(ApplicationContext) 생성, 빈(Bean) 등록 및 의존성 주입, 핸들러 매핑(HandlerMapping) 설정 등 HTTP 요청 처리 준비가 이루어집니다.
FrameworksServlet.initServletBean() 내부 동작을 살펴보겠습니다.
@Override
protected final void initServletBean() throws ServletException {
getServletContext().log("Initializing Spring " + getClass().getSimpleName() + " '" + getServletName() + "'");
if (logger.isInfoEnabled()) {
logger.info("Initializing Servlet '" + getServletName() + "'");
}
long startTime = System.currentTimeMillis();
try {
this.webApplicationContext = initWebApplicationContext();
initFrameworkServlet();
}
catch (ServletException | RuntimeException ex) {
logger.error("Context initialization failed", ex);
throw ex;
}
if (logger.isDebugEnabled()) {
String value = this.enableLoggingRequestDetails ?
"shown which may lead to unsafe logging of potentially sensitive data" :
"masked to prevent unsafe logging of potentially sensitive data";
logger.debug("enableLoggingRequestDetails='" + this.enableLoggingRequestDetails +
"': request parameters and headers will be " + value);
}
if (logger.isInfoEnabled()) {
logger.info("Completed initialization in " + (System.currentTimeMillis() - startTime) + " ms");
}
}
DispatcherServlet은 FrameworkServlet을 상속받으므로 내부 라이브러리에 정의 된 initServletBean()를 사용할 수 있습니다. 이 메서드는 Spring 콘텍스트 초기화, MVC 구성 요소 설정, 서브 클래스 초기화, 초기화 시작 및 완료 시간을 로깅합니다.
2.3 ApplicationContext 초기화
this.webApplicationContext = initWebApplicationContext();
여기서 가장 핵심은 try 내부에 있는 코드입니다. initWebApplicationContext()은 엔진 시동 및 시스템을 부팅하는 것과 유사합니다.
Spring 애플리케이션 컨텍스트(ApplicationContext) 를 초기화하고, @Component, @Service와 같은 어노테이션을 스캔하여 빈(Bean) 객체를 생성하고, 빈 팩토리에 등록합니다. 그 후 등록된 빈들의 의존 관계를 분석하고 필요한 의존성을 주입합니다.
2.4 MVC 컴포넌트 초기화
initFrameworkServlet();
initFramworkServlet()은 비행 전 마지막 점검 및 항로 설정을 하는 단계라 할 수 있습니다. 이 단계에서 onRefresh() 메서드가 호출되며, initStrategies()를 통해 MVC 구성 요소(HandlerMapping, HandlerAdapter, ViewResolver 등)가 설정됩니다.
@Override
protected void onRefresh(ApplicationContext context) {
this.initStrategies(context);
}
3. 실제 HTTP 요청 처리 과정
3.1 요청 수신과 분석
클라이언트로부터 다음과 같은 HTTP 요청이 들어왔다고 가정해보겠습니다.
POST /api/orders
Content-Type: application/json
{
"meuId": 1,
"cart": 2
}
서블릿 컨테이너는 요청을 DispatcherServlet으로 전달하고 DispatcherServlet은 HTTP 요청 정보를 분석합니다.
3.2 핸들러 매핑과 처리 준비
HandlerMapping은 @RequestMapping 어노테이션을 기반으로 요청 URL과 매칭되는 핸들러 메서드를 찾습니다. 핸들러를 찾지 못하면 404 에러를 반환합니다.
// 내부적으로 이런 과정이 일어납니다
HandlerExecutionChain handler = handlerMapping.getHandler(request);
if (handler == null) {
noHandlerFound(request, response);
return;
}
3.3 핸들러 어댑터 선택
찾아낸 핸들러를 실행할 수 있는 적절한 어댑터를 선택합니다. @RequestMapping 기반 컨트롤러의 경우 RequestMappingHandlerAdapter가 선택됩니다.
3.4 데이터 변환 과정
HttpMessageConverter는 JSON 요청 데이터를 Java 객체로 변환합니다.
public record CreateOrderRequestDto(
@NotBlank(message = "menuId는 필수입니다.")
Long menuId,
@Min(value = 1, message = "음식은 최소 1개 이상 주문해야 합니다.")
int cart
) {
}
4. REST 응답 생성 과정
컨트롤러에서 응답이 생성되면 Spring MVC는 이를 클라이언트에게 전달하기 위한 여러 단계의 처리를 수행합니다. 이 처리 과정은 크게 ViewResolver, HttpMessageConverter 두 가지 경우로 나뉘는데 @RestController나 @ResponseBody를 사용하는 경우, ViewResolver 대신 HttpMessageConverter가 동작합니다.
4.1 ResponseEntity 생성
@GetMapping
@Operation(summary = "주문 내역 조회")
public ResponseEntity<ApiResponse<OrderListResponseDto>> findAllOrders(
@AuthenticationPrincipal Long userId
) {
OrderListResponseDto responseDto = orderService.findAllOrders(userId);
return new ResponseEntity<>(
ApiResponse.success("주문 내역:", responseDto), HttpStatus.OK);
}
Controller에서 ResponseEntity를 생성할 때 두 가지 데이터가 포함됩니다.
- Body: ApiResponse 객체
- Status: HttpStatus.OK (200)
public class ApiResponse<T> {
private String status; // "SUCCESS" 또는 "ERROR"
private String message; // "주문 내역:"
private T data; // OrderListResponseDto 객체
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>("SUCCESS", message, data);
}
}
4.2 MessageConverter 선택
기본적으로 MappingJackson2HttpMessageConverter가 선택됩니다.
4.3 응답 결과
{
"status": "SUCCESS",
"message": "주문 내역:",
"data": {
// OrderListResponseDto의 필드들
"orders": [
{
"orderId": 1,
// 기타 주문 정보
}
]
}
}
5. 마치며
Spring MVC의 내부 동작 원리를 이해하는 것은 단순히 기술적인 지식을 넘어서는 중요한 의미를 가진다는 점에 대해서는 아직 충분히 공감할 수 없지만, 자동차 정비사가 엔진의 작동 원리를 이해해야 효과적인 정비가 가능한 것처럼, 개발자도 프레임워크의 내부 동작을 이해해야 더 나은 애플리케이션을 만들 수 있을 것이라 확신합니다.
이번 포스트를 통해 WAS와 서블릿 컨테이너, DispatcherServlet이 언제 어떻게 준비되고 동작하는지 논리 순서를 배치함으로 큰 맥락을 짚었다고 생각합니다.
앞으로의 목표가 있다면 DispatcherServlet을 중심으로 한 유기적인 협력 관계를 확실히 이해하는 것입니다.