컨트롤러 메서드 하나를 만들었을 뿐인데, HTTP 요청은 이미 여러 번 해석되고 변환되고 검증된 뒤에 도착해요.지난 글에서는 Spring Boot 4.x와 3.x의 기준선을 봤어요. 버전 하나가 Spring Framework, Jakarta EE, Servlet 컨테이너, starter 이름, JSON 처리 기준까지 같이 움직인다는 이야기였죠. 이제 다시 애플리케이션 안으로 들어와볼게요. Spring Boot MVC 프로젝트에서 이런 코드를 자주 봐요.
“맞아요. 하지만 그 한 문장 안에는 꽤 많은 일이 숨어 있어요.GET /orders/1요청이 오면findOrder(1)이 실행되는 거죠?”
“누가오늘은 이 길을 한 번에 따라가볼게요. Spring MVC 요청은 Servlet 컨테이너에서 들어와/orders/{id}와 실제 요청을 비교하죠?"
"문자열1은 누가Long으로 바꾸죠?"
"@RequestBodyJSON은 언제 Java 객체가 되죠?"
"@Valid는 컨트롤러 앞에서 실행되나요, 안에서 실행되나요?"
"컨트롤러가 객체를 return하면 누가 JSON으로 바꾸죠?"
"예외가 나면 왜@ExceptionHandler가 대신 응답을 만들 수 있죠?”
DispatcherServlet을 지나고, HandlerMapping이 컨트롤러 메서드를 찾고, HandlerAdapter가 인자를 준비해서 호출하고, 반환값은 message converter나 view resolver를 통해 HTTP 응답으로 바뀌어요. 예외가 나면 HandlerExceptionResolver가 중간에 다른 응답으로 바꿀 기회를 가져요.
이 글은 Spring Boot 4.1.0과 Spring Framework 7.0.8 공식 문서의 Spring MVC 설명을 기준으로 작성했어요. 요청 흐름의 큰 구조는 Spring MVC에서 오래 이어진 개념이지만, starter 이름, Jackson 세대, 일부 기본 설정은 사용 중인 Spring Boot 버전에 따라 달라질 수 있어요.
먼저 요청은 컨트롤러로 바로 가지 않아요
브라우저나 API client가 요청을 보낸다고 해볼게요.OrderController예요. 하지만 요청이 처음 만나는 것은 컨트롤러가 아니에요.
Spring MVC는 Servlet 기반 웹 프레임워크예요. 그래서 요청은 먼저 Servlet 컨테이너, 예를 들면 Tomcat 같은 서버로 들어와요. Spring Boot MVC 앱이라면 Boot가 내장 서버와 MVC 기본 설정을 준비해주고, 그 안에서 Spring MVC의 중심 Servlet인 DispatcherServlet이 요청을 받아요.
이 그림에서 핵심은 DispatcherServlet이에요. Spring MVC는 front controller 패턴을 써요. 요청마다 컨트롤러가 제각각 직접 입구가 되는 게 아니라, 공통 입구인 DispatcherServlet이 요청 처리 알고리즘을 들고 있고 실제 작업을 여러 구성요소에 위임해요.
그래서 “컨트롤러 메서드가 호출됐다”는 말은 사실 이렇게 읽어야 해요.
DispatcherServlet이 요청을 받고, 여러 후보 중 실행할 handler를 찾고, 그 handler를 실행할 방법을 골라서 컨트롤러 메서드를 호출했다.
처음에는 DispatcherServlet을 “교통정리 담당자”처럼 생각해도 좋아요. 하지만 비유에서 멈추면 안 돼요. 실무에서 중요한 건 DispatcherServlet이 혼자 모든 일을 하는 게 아니라, HandlerMapping, HandlerAdapter, HandlerExceptionResolver, ViewResolver, HttpMessageConverter 같은 Spring MVC 구성요소에게 일을 나눠 맡긴다는 점이에요.
Spring Boot가 준비한 것과 MVC가 처리하는 일을 나눠볼게요
Spring Boot 4.x 기준으로 MVC 웹 앱을 만들면 이런 의존성을 보게 돼요.build.gradle
이 구분은 디버깅할 때 특히 중요해요.
처음에는 다 “Spring Boot가 안 돼요”처럼 보이지만, 실제로는 요청이 어느 경계까지 갔는지에 따라 봐야 할 곳이 달라져요.
전체 흐름을 먼저 한 장으로 볼게요
요청이 정상적으로 처리되는 큰 흐름은 이래요. 이 그림에서Controller method는 가운데 한 칸이에요. 우리가 작성한 코드는 중요하지만, HTTP 요청을 Java 메서드 호출로 바꾸고 다시 HTTP 응답으로 바꾸는 앞뒤 작업이 더 넓게 펼쳐져 있어요.
@RestController를 쓰는 API에서는 반환값이 보통 HttpMessageConverter를 거쳐 JSON 같은 body로 바뀌어요. 반대로 @Controller에서 view 이름을 반환하는 서버 렌더링 화면이라면 ViewResolver가 어떤 template을 렌더링할지 찾는 흐름으로 이어져요.
1단계: HandlerMapping이 “누가 처리할지” 찾아요
DispatcherServlet이 요청을 받으면 먼저 “이 요청을 누가 처리할 수 있지?”를 찾아요. 이 일을 하는 대표 구성요소가 HandlerMapping이에요.
예를 들어 이런 컨트롤러가 있다고 해볼게요.
HandlerMapping은 URL만 보지 않아요. HTTP method, path pattern, consumes, produces, header 조건 같은 request mapping 정보를 함께 볼 수 있어요.
그래서 404와 405는 느낌이 달라요.
2단계: HandlerAdapter가 “어떻게 호출할지” 준비해요
Handler를 찾았다고 바로 Java 메서드를 호출할 수는 없어요. HTTP 요청은 문자열과 header와 body로 들어와요. 그런데 controller method는 이렇게 생겼죠.Long id는 그냥 생긴 값이 아니에요. Spring MVC가 /orders/1에서 path variable 문자열 "1"을 꺼내고, Long 타입으로 변환해서 넣어준 값이에요.
이 일을 크게 보면 HandlerAdapter가 맡아요. Annotation 기반 controller method를 실행할 수 있는 adapter가 선택되고, 그 안에서 여러 argument resolver와 converter가 협력해요.
대표적인 인자들은 이렇게 읽으면 돼요.
그래서 컨트롤러 메서드의 signature는 단순한 Java 문법이 아니에요. “이 HTTP 요청에서 어떤 조각을 꺼내 메서드 인자로 받을 것인가”를 선언한 모양이에요.
이 그림에서 중요한 건 “요청 전체가 한 번에 객체 하나로 바뀐다”가 아니라는 점이에요. path, query, header, body가 각각 다른 규칙으로 읽히고, controller method signature가 그 규칙을 드러내요.
3단계: 변환과 검증은 controller 호출 직전에 중요해져요
이번에는 POST 요청을 볼게요.@RequestBody때문에 HTTP body를 Java 객체로 읽어요.@Valid때문에 만들어진 객체가 validation 규칙을 통과하는지 확인해요.
HttpMessageConverter가 중요해요. JSON 요청이라면 Jackson 기반 converter가 body를 CreateOrderRequest로 바꾸는 식이에요. Boot 4.x 흐름에서는 Jackson 3가 기본 방향이에요.
검증은 “컨트롤러 안에서 내가 직접 if문을 쓰기 전”에 일어날 수 있어요. quantity가 0이면 controller method 본문까지 들어가기 전에 binding 또는 validation 예외가 발생할 수 있어요.
실무에서는 이 경계가 설계를 바꿔요.
4단계: Controller는 HTTP를 모두 처리하는 곳이 아니에요
컨트롤러는 요청의 application entry point예요. 하지만 모든 일을 컨트롤러가 직접 하면 금방 커져요. 좋은 컨트롤러는 보통 이렇게 얇아요.
컨트롤러가 얇아야 하는 이유는 단순히 “깔끔해서”가 아니에요. 요청 흐름의 앞뒤를 Spring MVC가 이미 맡고 있기 때문이에요. controller method 안에는 “HTTP를 Java 호출로 바꾼 뒤, 내 애플리케이션이 실제로 해야 할 일”이 남아야 해요.
반대로 이런 코드 냄새가 나면 요청 흐름을 다시 나눠봐야 해요.
물론 아주 작은 앱에서는 간단히 시작할 수 있어요. 하지만 Spring MVC의 요청 경계를 이해하면 “컨트롤러에 둘 일”과 “밖으로 빼야 할 일”이 더 선명해져요.
5단계: 반환값은 다시 HTTP 응답으로 바뀌어요
컨트롤러가 return한 값은 아직 HTTP 응답이 아니에요.@RestController는 @Controller와 @ResponseBody가 합쳐진 흐름으로 생각하면 돼요. 반환값을 view 이름으로 보지 않고, 응답 body로 쓰겠다는 뜻이에요.
그러면 Spring MVC는 반환값을 처리할 handler를 고르고, HttpMessageConverter를 통해 body를 만들어요.
1
컨트롤러가 Java 객체를 반환해요
createOrder()가 반환한 OrderResponse는 아직 JSON도, HTTP response body도 아니에요. 컨트롤러 메서드 호출을 맡은 Spring MVC 처리 과정으로 Java 객체가 넘어가요.2
반환값 처리기가 응답 방식을 결정해요
Spring MVC는 반환 타입과 Annotation을 보고 알맞은 return value handler를 골라요.
@RestController에는 @ResponseBody 의미가 포함되어 있으므로, OrderResponse를 view 이름으로 해석하지 않고 response body에 쓸 값으로 처리해요.3
응답 body를 직렬화해요
선택된 handler는 요청의
Accept header와 controller가 만들 수 있는 media type을 비교한 뒤 알맞은 HttpMessageConverter를 찾아요. JSON을 쓸 수 있는 converter가 선택되면 OrderResponse의 field를 JSON으로 직렬화하고 Content-Type도 정해요.4
HTTP 응답을 완성해요
Spring MVC는
@ResponseStatus 같은 규칙에서 정한 status, response headers, 변환된 body를 Servlet response에 기록해요. 이제 Java 객체가 client가 받을 수 있는 HTTP 응답으로 바뀌어요.
처음에는
ResponseEntity를 모든 곳에 쓰고 싶어질 수 있어요. 하지만 status와 header를 세밀하게 제어할 필요가 없는 단순 조회 API라면 객체를 바로 반환해도 충분한 경우가 많아요.
중요한 건 “return한 Java 객체가 곧 wire format은 아니다”예요. JSON 필드 이름, 날짜 형식, enum 표현, null 처리 같은 문제는 다음 JSON 글에서 더 자세히 볼 거예요.
6단계: 예외는 요청 흐름 밖으로 튀는 게 아니라 다른 경로로 처리돼요
컨트롤러나 그 아래 service에서 예외가 날 수 있어요.orderService.findOrder(id)에서 OrderNotFoundException이 발생하면 어떻게 될까요?
처음에는 “그냥 서버 에러가 나겠지”라고 생각하기 쉬워요. 하지만 Spring MVC에는 예외를 HTTP 응답으로 바꿀 수 있는 경로가 있어요. DispatcherServlet 수준에서 HandlerExceptionResolver들이 예외를 처리할 기회를 가져요. 우리가 자주 쓰는 @ExceptionHandler와 @ControllerAdvice도 이 흐름 위에 있어요.
filter, interceptor, AOP는 어디쯤 있을까요?
요청 흐름을 공부하다 보면 filter, interceptor, AOP도 같이 나와요. 셋은 모두 “중간에 끼어든다”는 느낌이 있어서 헷갈리기 쉬워요. 간단히 위치부터 잡아볼게요. 이 그림은 단순화한 지도예요. 핵심은 경계가 다르다는 점이에요.
예를 들어 인증/보안 필터는 MVC controller를 찾기 전에도 요청을 볼 수 있어요. 반면 interceptor는 어떤 handler가 선택됐는지 알고 움직일 수 있어요. AOP는 HTTP 요청 자체보다 Spring bean method 호출에 붙는 경우가 많아요.
지난 AOP 글에서 봤던 self-invocation 함정도 여기서 다시 이어져요. 요청이 controller proxy나 service proxy의 바깥 경계를 통과해야 부가 동작이 붙을 수 있어요. “Annotation을 붙였는데 왜 동작하지 않지?”라는 질문은 MVC 요청 경계와 AOP proxy 경계를 함께 봐야 풀리는 경우가 많아요.
실무에서 요청 흐름을 따라 디버깅하는 순서
API가 기대와 다르게 동작할 때는 요청 흐름 순서대로 좁혀가면 좋아요.
Actuator를 붙인 프로젝트라면 나중에
mappings, conditions, httpexchanges 같은 endpoint도 중요한 단서가 돼요. 지금은 요청이 어느 단계에서 멈췄는지 보는 감각만 잡아두면 충분해요.
예를 들어 이런 식으로 생각해볼 수 있어요.
처음에는 여기까지만 잡아도 충분해요
Spring MVC 요청 흐름을 처음부터 완벽히 외울 필요는 없어요. 지금은 이 문장만 남겨도 좋아요.
요청은 DispatcherServlet으로 들어오고, handler를 찾고, 인자를 만들고, controller를 호출하고, 반환값을 응답으로 바꾸고, 예외가 나면 예외 처리 경로로 응답을 만든다.
조금 더 깊게 보면 이런 원칙이 남아요.
이 차이를 알면 다음 글들이 훨씬 덜 헷갈려요. REST API 설계, error contract, Jackson, validation, Security filter chain, MVC test는 모두 이 요청 흐름 위에 올라가거든요.
자, 정리해볼까요?
-
Spring MVC 요청은 controller로 바로 가지 않고
DispatcherServlet을 공통 입구로 지나가요. -
HandlerMapping은 어떤 controller method가 요청을 처리할지 찾고,HandlerAdapter는 그 method를 호출할 준비를 해요. - path variable, query parameter, header, body는 각각 다른 argument resolver와 converter 규칙으로 controller 인자가 돼요.
-
@RestController의 반환 객체는HttpMessageConverter를 거쳐 JSON 같은 HTTP response body로 바뀌어요. -
예외도 MVC 요청 흐름 안에서
HandlerExceptionResolver,@ExceptionHandler,@ControllerAdvice를 통해 HTTP 응답으로 바뀔 수 있어요.