← all posts
DEV 2026.05.02 · 12 min read Intermediate

Spring MVC는 어떻게 요청 하나를 처리하는가

비동기 처리부터 SSE, 파일 업로드, 정적 리소스, HTTP 캐싱, WebMvcConfigurer까지 — Spring MVC 내부 처리 경로의 공통 설계 원칙을 추적한다.


Spring MVC는 DispatcherServlet 하나로 모든 HTTP 요청을 처리한다. 비동기 응답, 실시간 스트림, 파일 업로드, 정적 파일, HTTP 캐싱 — 겉으로는 전혀 달라 보이는 이 메커니즘들이 사실 하나의 공통 패턴 위에 세워져 있다는 걸 알고 있는가?

요청 스레드를 얼마나 오래 잡을 것인가

Spring MVC의 가장 중요한 설계 결정은 스레드 점유 시간을 어떻게 제어하는가다. 동기 처리에서는 외부 API 호출 100ms 동안 스레드가 묶인다. 동시 요청 1000개면 1000개 스레드가 필요하고, 메모리가 고갈된다.

Callable, DeferredResult, CompletableFuture 세 비동기 방식은 모두 같은 목표를 향한다 — request.startAsync()로 서블릿 컨테이너가 커넥션을 유지하는 동안 요청 스레드는 즉시 풀로 돌아간다. 핵심 컴포넌트는 WebAsyncManager다. 비동기 작업이 완료되면 asyncContext.dispatch()를 호출해 DispatcherServlet에 재진입한다. 처음 요청과 다른 스레드가 결과를 JSON으로 직렬화하고 응답을 전송한다.

세 방식의 차이는 누가 결과를 완성하는가에 있다. Callable은 Spring의 AsyncTaskExecutor 스레드에서 실행되고, DeferredResult는 메시지 큐나 이벤트 리스너 같은 외부 트리거가 setResult()를 호출한다. CompletableFuture는 자체 Executor를 지정할 수 있어 체이닝에 유리하지만, 지정하지 않으면 ForkJoinPool.commonPool()을 사용해 Spring의 스레드 풀 모니터링 밖으로 벗어난다.

SseEmitter와 장기 커넥션의 구조

SseEmitter는 같은 패턴의 극단적 형태다. Controller가 SseEmitter를 반환하는 순간 ResponseBodyEmitterReturnValueHandlerstartAsync()를 호출하고, 응답에 Content-Type: text/event-streamTransfer-Encoding: chunked 헤더를 설정한 뒤 요청 스레드를 반환한다. 이후 커넥션은 서블릿 컨테이너가 소유한다.

실시간 알림, 주식 가격, 진행 상황 보고처럼 서버가 데이터를 밀어주는 단방향 스트림에 적합하다. 클라이언트가 연결을 끊어도 서버는 다음 send() 시도에서 IOException이 발생할 때까지 알 수 없다. 따라서 emitter.onCompletion(), emitter.onTimeout(), emitter.onError() 콜백으로 죽은 연결을 레지스트리에서 제거하는 패턴이 필수다.

emitter.onCompletion(() -> remove(userId, emitter));
emitter.onTimeout(() -> { remove(userId, emitter); emitter.complete(); });
emitter.onError(e -> remove(userId, emitter));

15초 간격 하트비트(SseEmitter.event().comment("heartbeat"))는 프록시와 방화벽이 유휴 커넥션을 끊는 것을 막는다. 스케일 아웃 환경에서는 각 서버 인스턴스가 자체 메모리에 SseEmitter 맵을 갖기 때문에 Redis Pub/Sub 같은 브로커로 인스턴스 간 이벤트를 전파해야 한다.

파일 업로드와 정적 리소스의 공통 구조

파일 업로드는 DispatcherServlet.checkMultipart()multipart/form-data 요청을 StandardServletMultipartResolver로 위임해 Servlet Part API로 파싱한다. 크기 제한은 request.getParts() 호출 시점, 즉 HandlerMapping보다 먼저 적용된다. MaxUploadSizeExceededException이 발생하면 @ExceptionHandler로 잡을 수 있다.

파일명 보안

file.getOriginalFilename()을 그대로 파일 시스템에 사용하면 Path Traversal 취약점이 생긴다. StringUtils.cleanPath()로 정규화하고 .. 포함 여부를 검사한 뒤, UUID로 새 파일명을 생성하라. 확장자 화이트리스트 검증도 필수다.

정적 리소스는 ResourceHttpRequestHandler가 처리한다. ResourceResolver 체인이 CachingResourceResolverEncodedResourceResolverWebJarsResourceResolverPathResourceResolver 순으로 탐색한다. addResourceLocations()에서 경로 끝의 /를 빠뜨리면 파일을 찾지 못한다 — "classpath:/static/" 이 맞고 "classpath:/static"은 틀리다.

HTTP 캐싱 — 네트워크를 아끼는 두 가지 방법

HTTP 캐싱 전략은 두 축으로 나뉜다. 만료 기반Cache-Control: max-age=N으로 N초 동안 클라이언트가 서버에 요청조차 하지 않는다. 검증 기반은 만료 후 If-None-Match / If-Modified-Since로 “변경됐는가?”를 확인하고, 변경이 없으면 304를 받아 로컬 캐시를 그대로 쓴다.

ShallowEtagHeaderFilter는 응답 본문의 MD5 해시로 ETag를 자동 생성한다. 편리하지만 핵심 제약이 있다 — Controller는 항상 실행된다. 네트워크 전송만 줄어들고 서버 부하는 그대로다. 진정한 서버 부하 절감은 WebRequest.checkNotModified()로 DB 버전 필드 기반 ETag를 Controller 입구에서 검사해 본문 실행 전에 304를 반환하는 방식으로만 가능하다.

트레이드오프

no-cache는 “캐시하지 마라”가 아니다. “저장은 하되 사용 전 반드시 재검증하라”는 의미다. 캐시 금지는 no-store다. 해시 버전 정적 리소스(app.a1b2c3.js)에는 max-age=365d, immutable로 장기 캐싱하고, 변경 시 URL 자체가 바뀌어 캐시가 자동으로 무효화된다.

WebMvcConfigurer — 최소 침습의 설계

Spring Boot에서 @EnableWebMvc를 붙이면 WebMvcAutoConfiguration이 비활성화된다. @ConditionalOnMissingBean(WebMvcConfigurationSupport.class) 조건 때문이다 — @EnableWebMvc가 등록하는 DelegatingWebMvcConfigurationWebMvcConfigurationSupport의 하위 클래스이므로 조건이 실패한다. Jackson 자동 구성, 기본 오류 처리, 정적 리소스 핸들러가 전부 사라진다.

WebMvcConfigurer는 이 문제 없이 필요한 부분만 바꾸는 인터페이스다. 여러 Bean이 있어도 WebMvcConfigurerComposite가 동일한 registry에 누적해 자동으로 병합한다. 단, configureMessageConverters()에 하나라도 추가하면 기본 Converter 목록이 통째로 교체된다. 기존 목록을 유지하면서 추가할 때는 반드시 extendMessageConverters()를 써야 한다.

정리

  • Spring MVC의 모든 처리 경로는 DispatcherServlet → Handler → ReturnValueHandler → 응답으로 수렴한다. 비동기든 SSE든 정적 리소스든, 요청 스레드 점유 시간을 줄이기 위한 변주일 뿐이다.
  • request.startAsync()는 커넥션 유지 책임을 서블릿 컨테이너에 위임하는 핵심 전환점이다. Callable, DeferredResult, SseEmitter 모두 이 위에서 동작한다.
  • HTTP 캐싱의 목표는 네트워크 제거(max-age)와 서버 실행 제거(checkNotModified)다. ShallowEtagHeaderFilter는 전자만 달성한다.
  • @EnableWebMvc 없이 WebMvcConfigurer만으로 커스터마이징하라. configure*는 교체, extend*add*는 추가다.