[Spring] Web 요청 처리: 톰캣 워커에서 컨트롤러까지
요청 한 건이 거치는 길
브라우저가 GET /api/users/1 을 보냈을 때, 그 한 줄짜리 HTTP(HyperText Transfer Protocol) 요청이 컨트롤러 메서드의 첫 줄까지 도달하기까지 적지 않은 컴포넌트가 협력한다. 톰캣이 TCP(Transmission Control Protocol) 연결을 받아 워커 스레드를 하나 할당하고, 서블릿 표준에 맞게 요청 객체를 만들고, 필터 체인을 거치고, 디스패처 서블릿이 핸들러를 찾고, 인자 리졸버가 파라미터를 채우고, 메시지 컨버터가 JSON(JavaScript Object Notation) 본문을 객체로 바꾼다.
| 구성 요소 | 역할 |
|---|---|
| Servlet Container(Tomcat) | TCP 를 HTTP 로 파싱하고 워커 스레드를 배정한다 |
| Worker Thread | 요청 하나를 전담하는 스레드. 트랜잭션·SecurityContext 같은 ThreadLocal 의 토대다 |
| Filter | 서블릿 컨테이너 레벨의 가로채기. 스프링 컨텍스트 밖에서 동작한다 |
| DispatcherServlet | 모든 요청의 단일 진입점(Front Controller). 각 컴포넌트에 처리를 위임한다 |
| HandlerMapping | URL 과 컨트롤러 메서드를 매핑한 테이블을 들고 있다 |
| Interceptor | 컨트롤러 호출 전후, 완료 후 가로채기. 스프링 빈에 접근할 수 있다 |
| HandlerAdapter | 다양한 형태의 핸들러를 통일된 방식으로 호출하는 어댑터다 |
| ArgumentResolver | 요청 데이터를 컨트롤러 메서드 파라미터로 채운다 |
| HttpMessageConverter | 요청 본문과 객체, 객체와 응답 본문을 서로 변환한다 |
| HandlerExceptionResolver | 컨트롤러에서 던진 예외를 표준 응답으로 변환한다 |
MVC2 시절의 한계와 Front Controller의 등장
초기 Servlet은 하나의 클래스가 비즈니스 로직, 데이터 접근, HTML(HyperText Markup Language) 생성을 모두 처리해 유지보수가 어려웠다. MVC1 패턴은 JSP(JavaServer Pages)가 컨트롤러와 뷰를 함께 맡아 로직과 뷰가 강하게 결합됐다. MVC2 패턴은 Controller(Servlet), View(JSP), Model(Java Bean)을 분리했지만, URL(Uniform Resource Locator)마다 서블릿을 하나씩 만들어야 했다. 기능이 늘수록 서블릿이 수십 개로 불어나고, 인증과 로깅 같은 공통 로직이 서블릿마다 중복되고, 모든 서블릿을 web.xml 에 등록해야 했다.
Spring MVC는 이 한계를 Front Controller 패턴으로 풀었다. 모든 요청을 받는 단 하나의 진입점인 DispatcherServlet을 두고, 그 안에서 요청 분석과 컨트롤러 위임을 일관되게 처리한다.
이제 web.xml에는 DispatcherServlet 하나만 등록되고, URL과 컨트롤러 매핑은 어노테이션 기반으로 런타임에 결정된다. 그 매핑을 들고 있는 컴포넌트가 HandlerMapping이다.
요청 하나의 전체 경로
아래 그림은 정상 흐름과 예외 흐름을 한 장에 담은 전체 처리 경로다. 실선은 정상 흐름, 점선은 예외가 발생했을 때 우회하는 경로를 나타낸다.
톰캣 커넥터와 워커 스레드
요청이 처음 도착하는 곳은 톰캣의 Connector다. Connector는 특정 프로토콜(HTTP/1.1, HTTP/2, AJP 등)을 처리하며, TCP 소켓을 열어 두고 클라이언트의 접속을 받는다.
server:
tomcat:
threads:
max: 200 # maxThreads. 동시에 처리 가능한 최대 스레드 수
min-spare: 10 # minSpareThreads. 유지할 최소 유휴 스레드 수
accept-count: 100 # 큐 사이즈. max 에 도달한 뒤 대기할 요청 수
max-connections: 8192
connection-timeout: 20000연결을 받은 Connector는 HTTP 메시지를 파싱해 HttpServletRequest, HttpServletResponse 객체를 만들고, 워커 스레드 풀에서 유휴 스레드를 하나 할당해 그 위에서 요청을 처리하게 한다. 워커 풀이 모두 사용 중이면 요청을 acceptCount큐에 넣고 대기시키고, 큐도 가득 차면 연결을 거부한다.
스레드 풀이 고갈되기 시작하면 처음에는 500 에러가 아니라 응답 지연으로 나타난다. maxThreads 가 200인데 평균 처리 시간이 200 밀리초에서 갑자기 2초로 늘면 초당 처리량이 5분의 1로 줄고, 나머지 요청은 acceptCount큐에서 대기한다. 큐마저 가득 차면 그제서야 클라이언트는 Connection Refused를 받는다. HikariCP 풀이 작아서 DB 커넥션 대기가 늘어나는 경우도 결국 워커 스레드 점유 시간을 늘려 같은 증상으로 이어진다.
CPU(Central Processing Unit) 바운드 작업은 스레드 수를 코어 수와 비슷하게, I/O(Input/Output) 바운드 작업은 코어 수 곱하기 (1 + 대기시간/처리시간) 정도로 잡는 것이 일반적인 기준이다. 스레드당 약 512KB에서 1MB의 스택 메모리를 점유하므로 무작정 늘리면 메모리와 컨텍스트 스위칭 비용이 함께 늘어난다.
톰캣은 요청 객체를 HttpServletRequest로 만들지만 Filter에 넘길 때는 상위 타입인 ServletRequest로 넘긴다. 서블릿 API가 HTTP 전용이 아니라 웹 요청 처리 전반을 다루기 위한 표준이기 때문이다. 지금은 HTTP 가 사실상 표준이므로 실제 객체는 HttpServletRequest이고, 필요할 때 다운캐스트해서 쓴다.
Filter, 서블릿 컨테이너 레벨의 가로채기
DispatcherServlet에 도달하기 직전, 요청은 필터 체인을 통과한다. 필터는 서블릿 컨테이너 레벨에서 동작하므로 스프링 컨텍스트의 도움 없이도 호출된다.
public class CharacterEncodingFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
req.setCharacterEncoding("UTF-8");
chain.doFilter(req, res); // 다음 필터 또는 DispatcherServlet 으로 진행
}
}전형적인 용도는 문자 인코딩 강제, CORS(Cross-Origin Resource Sharing) 처리, XSS(Cross-Site Scripting) 차단, Spring Security 의 필터 체인, 요청과 응답 로깅이다. Filter는 정적 리소스까지 포함해 모든 요청을 가로채야 하는 작업이나 스프링 빈에 접근할 필요가 없는 작업에 적합하다. 스프링 빈에 접근하고 싶다면 DelegatingFilterProxy로 빈으로 등록된 필터를 쓸 수 있지만, 인증·인가처럼 빈 의존이 많은 로직은 뒤에서 다룰 Interceptor나 Spring Security 필터 체인 쪽이 더 자연스럽다. Filter 단에서 발생한 예외는 스프링 컨텍스트 밖에서 일어나므로 @ControllerAdvice가 처리할 수 없다.
DispatcherServlet과 HandlerMapping: 요청을 컨트롤러로 잇기
필터를 통과한 요청이 도달하는 곳이 DispatcherServlet이다. 이름 그대로 디스패처(dispatcher) 이며, 자기가 모든 일을 하지 않고 각 책임을 전문 컴포넌트에 위임한다.
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) {
HandlerExecutionChain mappedHandler = getHandler(request);
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
if (!mappedHandler.applyPreHandle(request, response)) return;
ModelAndView mv = ha.handle(request, response, mappedHandler.getHandler());
mappedHandler.applyPostHandle(request, response, mv);
processDispatchResult(request, response, mappedHandler, mv, dispatchException);
}DispatcherServlet은 어떤 URL이 어느 컨트롤러 메서드로 가야 하는지를 직접 알지 못한다. 대신 HandlerMapping에게 묻는다. 가장 흔한 구현체인 RequestMappingHandlerMapping은 애플리케이션이 시작될 때 @Controller, @RestController 가 붙은 모든 빈을 스캔하고, 그 안에서 @RequestMapping 계열 어노테이션이 붙은 메서드를 찾아 URL 패턴과 HTTP 메서드, 헤더 조건의 조합을 키로 HandlerMethod를 값으로 매핑 테이블을 구성한다. 요청이 들어오면 이 테이블을 조회해 매칭되는 HandlerMethod를 찾고, 적용해야 할 인터셉터들과 함께 HandlerExecutionChain 객체로 반환한다.
Interceptor부터 컨트롤러 호출까지: HandlerAdapter와 ArgumentResolver
Interceptor, 컨트롤러 직전과 직후의 가로채기
인터셉터는 컨트롤러가 호출되기 직전(preHandle), 직후(postHandle), 응답이 완료된 후(afterCompletion)에 끼어들 수 있다. 필터와 달리 스프링 컨텍스트 안에서 동작하므로 빈을 자유롭게 주입받을 수 있다.
@Component
class AuthInterceptor(
private val tokenService: TokenService,
) : HandlerInterceptor {
override fun preHandle(req: HttpServletRequest, res: HttpServletResponse, handler: Any): Boolean {
val token = req.getHeader("Authorization") ?: throw UnauthorizedException()
val user = tokenService.verify(token)
req.setAttribute("currentUser", user)
return true
}
}| 항목 | Filter | Interceptor | AOP |
|---|---|---|---|
| 관리 주체 | 서블릿 컨테이너 | 스프링 컨테이너 | 스프링 컨테이너 |
| 실행 시점 | DispatcherServlet 이전과 이후 | Controller 이전과 이후 | 메서드 실행 전후 |
| 사용 객체 | ServletRequest, Response | HttpServletRequest, Response | JoinPoint |
| 적합한 용도 | 인코딩, XSS, CORS | 인증, 인가, 로깅 | 트랜잭션, 캐시, 권한 |
HandlerAdapter, 컨트롤러 호출의 추상화
HandlerAdapter는 디스패처와 컨트롤러 사이의 어댑터다. @RequestMapping 기반 컨트롤러, 함수형 라우터 등 다양한 형태의 핸들러를 동일한 인터페이스로 호출하기 위한 추상화다. 가장 많이 쓰이는 RequestMappingHandlerAdapter는 메서드 시그니처를 분석해 어떤 ArgumentResolver가 각 파라미터에 맞는지 결정하고, 값을 채운 뒤 컨트롤러 메서드를 리플렉션으로 호출하고, 반환값을 ReturnValueHandler가 응답 본문 또는 뷰 이름으로 변환한다.
ArgumentResolver, 요청 데이터를 메서드 파라미터로
컨트롤러 메서드가 다음과 같다면
@GetMapping("/{id}")
fun getUser(
@PathVariable id: Long,
@RequestParam(required = false) format: String?,
@RequestHeader("Authorization") token: String,
@RequestBody body: UpdateUserRequest,
): UserResponse { /* ... */ }각 파라미터에 맞는 ArgumentResolver가 동작한다.
| 어노테이션 또는 타입 | Resolver | 동작 |
|---|---|---|
| @PathVariable | PathVariableMethodArgumentResolver | URL 경로 변수 추출 |
| @RequestParam | RequestParamMethodArgumentResolver | 쿼리 스트링과 폼 파라미터 |
| @RequestHeader | RequestHeaderMethodArgumentResolver | 헤더 값 |
| @RequestBody | RequestResponseBodyMethodProcessor | 본문을 객체로 역직렬화 |
| @ModelAttribute | ModelAttributeMethodProcessor | 폼과 쿼리 데이터를 객체로 바인딩 |
파라미터가 잘못 매칭되거나 필수 값이 누락되면 MethodArgumentNotValidException, MissingServletRequestParameterException 같은 예외가 이 단계에서 던져진다. @RequestBody 파라미터에 @Valid를 붙이면 이 리졸버가 검증까지 함께 수행하고, 위반 시 같은 예외로 400이 응답된다. 클라이언트 입장에서는 컨트롤러 코드가 한 줄도 실행되지 않은 채 400을 받는 셈이라, 로그에 컨트롤러 진입 흔적이 없다고 당황할 필요는 없다.
HttpMessageConverter: JSON과 객체 사이의 변환
@RequestBody와 @ResponseBody가 붙은 파라미터의 반환값은 HttpMessageConverter를 통해 변환된다. 가장 많이 쓰이는 구현체는 Jackson 기반 MappingJackson2HttpMessageConverter다.
| 구현체 | Content-Type | 용도 |
|---|---|---|
| MappingJackson2HttpMessageConverter | application/json | JSON 직렬화와 역직렬화 |
| MappingJackson2XmlHttpMessageConverter | application/xml | XML 처리 |
| StringHttpMessageConverter | text/plain | 문자열 |
| ByteArrayHttpMessageConverter | application/octet-stream | 바이트 배열 |
| FormHttpMessageConverter | application/x-www-form-urlencoded | 폼 데이터 |
운영에서 자주 만나는 변환 오류는 세 가지다. 클라이언트가 Content-Type을 명시하지 않으면 컨버터를 선택하지 못해 415 Unsupported Media Type이 돌아온다. JSON 키와 프로퍼티명이 다르거나 타입이 어긋나면 400 Bad Request다. Kotlin data class에 기본 생성자가 없어 InvalidDefinitionException이 나는 경우도 있는데, jackson-module-kotlin 의존성이 빠졌을 때다.
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")어떤 컨버터가 선택될지를 암묵적으로 두지 않고 명시하면, 잘못된 요청을 컨트롤러 진입 전에 415로 거절할 수 있다.
// 잘못된 패턴: consumes/produces 미명시. 어떤 컨버터가 선택될지 암묵적으로 결정된다
@PostMapping("/users")
fun create(@RequestBody req: CreateUserRequest): UserResponse { /* ... */ }
// 올바른 패턴: 명시적으로 지정해 415 여부를 컨트롤러 진입 전에 결정한다
@PostMapping(
"/users",
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE],
)
fun create(@RequestBody req: CreateUserRequest): UserResponse { /* ... */ }날짜와 시간은 @JsonFormat으로 직렬화 패턴을 명시한다.
data class OrderResponse(
val id: Long,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssXXX")
val createdAt: ZonedDateTime,
)큰 컬렉션을 @ResponseBody로 그대로 반환하면 컨버터가 전체를 메모리에 올려 직렬화하므로 응답이 커질수록 메모리 사용량도 함께 커진다. 페이지네이션을 강제하거나 StreamingResponseBody 로 바꾸는 편이 안전하고, 큰 업로드는 multipart/form-data로 받되 톰캣과 스프링의 최대 파일·요청 크기 설정을 한 쌍으로 맞춘다.
Controller와 @RestController
@Controller와 @RestController 의 차이는 응답 처리 방식에 있다.
// @Controller. 뷰 이름을 반환하면 ViewResolver 가 HTML 을 그려 응답
@Controller
class HomeController {
@GetMapping("/home")
fun home(): String = "home"
}
// @Controller + @ResponseBody. 메서드 단위로 직렬화 응답
@Controller
class UserController {
@GetMapping("/{id}")
@ResponseBody
fun getUserById(@PathVariable id: Long): UserDto = UserDto(/* ... */)
}
// @RestController 는 @Controller 와 @ResponseBody 의 클래스 레벨 결합
@RestController
class UserApiController {
@GetMapping("/{id}")
fun getUserById(@PathVariable id: Long): UserDto = UserDto(/* ... */)
}| 구분 | @Controller | @RestController |
|---|---|---|
| 주요 목적 | View 또는 데이터 반환 | 데이터 반환 |
| 반환 타입 | String(뷰 경로), ModelAndView | DTO, ResponseEntity |
| @ResponseBody | 데이터 반환 시 메서드별로 명시 | 클래스 레벨에 자동 적용 |
| 사용 시나리오 | Thymeleaf, JSP(서버 사이드 렌더링) | REST API(SPA, 모바일) |
@RestController는 결국 @ResponseBody다. 메서드 반환값을 뷰 이름이 아닌 HTTP 응답 본문으로 바꾼다. 어떤 컨버터가 동작할지는 클라이언트의 Accept 헤더와 등록된 컨버터들로 결정된다.
요청 처리 시퀀스 한눈에 보기
sequenceDiagram autonumber participant C as Client participant T as Tomcat participant F as Filter Chain participant DS as DispatcherServlet participant HM as HandlerMapping participant IC as Interceptor participant HA as HandlerAdapter participant AR as ArgumentResolver participant MC as HttpMessageConverter participant Ctrl as Controller participant ER as HandlerExceptionResolver C->>T: HTTP Request T->>T: HttpServletRequest 생성, 워커 스레드 할당 T->>F: doFilter F->>DS: chain.doFilter 로 DispatcherServlet 진입 DS->>HM: getHandler(request) HM-->>DS: HandlerExecutionChain DS->>IC: preHandle IC-->>DS: true DS->>HA: handle(handler) HA->>AR: 파라미터 resolve AR->>MC: @RequestBody 를 Object 로 변환 HA->>Ctrl: 메서드 호출 alt 정상 처리 Ctrl-->>HA: 반환값 HA->>MC: Object 를 JSON 으로 직렬화 HA-->>DS: ModelAndView DS->>IC: postHandle DS-->>F: 응답 본문 else 예외 발생 Ctrl-->>DS: Exception DS->>ER: resolveException ER-->>DS: 변환된 응답 DS-->>F: 에러 응답 end DS->>IC: afterCompletion F-->>T: chain 종료 T-->>C: HTTP Response
예외는 어디서 잡히는가: @ControllerAdvice의 경계
요청 처리 중 발생하는 예외는 발생 위치에 따라 처리되는 곳이 다르다. Controller, Service, Repository, Interceptor 에서 빠져나온 예외는 DispatcherServlet이 잡아 HandlerExceptionResolver에게 위임한다. ExceptionHandlerExceptionResolver가 @ControllerAdvice와 @ExceptionHandler를 적용해 표준 에러 응답으로 바꾼다.
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(EntityNotFoundException::class)
fun handleNotFound(e: EntityNotFoundException): ResponseEntity<ErrorResponse> =
ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse("NOT_FOUND", e.message))
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(e: MethodArgumentNotValidException): ResponseEntity<ErrorResponse> {
val message = e.bindingResult.fieldErrors.joinToString { "${it.field}: ${it.defaultMessage}" }
return ResponseEntity.badRequest()
.body(ErrorResponse("BAD_REQUEST", message))
}
@ExceptionHandler(Exception::class)
fun handleAny(e: Exception): ResponseEntity<ErrorResponse> =
ResponseEntity.internalServerError()
.body(ErrorResponse("INTERNAL_ERROR", "Unexpected error"))
}반면 Filter와 DispatcherServlet 진입 이전에서 발생한 예외는 스프링 컨텍스트 밖이므로 @ControllerAdvice가 적용되지 않는다. Spring Boot의 ErrorController(BasicErrorController) 또는 컨테이너 설정에 따른 에러 페이지가 응답을 만든다. 이 경계 때문에 OncePerRequestFilter 안에서 던진 인증 예외, 컨트롤러 매핑이 안 된 URL, DispatcherServlet이 아직 핸들러를 결정하지 못한 시점의 예외는 @ControllerAdvice로 잡히지 않는다. 필터에서 발생한 예외를 일관된 JSON 응답으로 바꾸고 싶다면 별도 필터에서 catch해서 직접 HttpServletResponse에 쓰거나, Spring Security의 AuthenticationEntryPoint 같은 후크를 활용한다.