브라우저에서 /api/users를 호출하면, 요청이 컨트롤러에 도달하기까지 내부에서 어떤 경로를 거치는 걸까? 그리고 Filter와 Interceptor는 뭐가 다른 걸까?

Servlet이란

Spring MVC를 이해하려면 그 밑바닥에 있는 Servlet부터 알아야 해요.

Servlet은 클라이언트의 HTTP 요청을 받아서 처리하고 응답을 돌려주는 자바 서버 컴포넌트 입니다. 자바 웹 기술의 근간이라고 보면 돼요. 스프링 MVC도 결국 이 Servlet 위에서 돌아갑니다.

JAVA
public class MyServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        resp.getWriter().write("Hello Servlet");
    }
}

옛날에는 이런 식으로 URL마다 서블릿을 하나씩 만들었어요. /user에 UserServlet, /order에 OrderServlet... URL이 늘어날수록 서블릿도 끝없이 늘어나는 구조였습니다.

Servlet Container (Tomcat)

서블릿은 혼자 돌아갈 수 없어요. 서블릿의 생명주기를 관리하고, HTTP 요청을 파싱해서 서블릿에게 전달하는 역할을 하는 게 Servlet Container 입니다. 대표적인 게 Apache Tomcat이에요.

서블릿 컨테이너가 하는 일을 정리하면 이렇습니다.

  • 소켓 통신, HTTP 요청 파싱
  • 요청 URL에 맞는 서블릿 매핑
  • 서블릿 인스턴스 생성 및 관리
  • HttpServletRequest, HttpServletResponse 객체 생성
  • 서블릿의 service() 메서드 호출
  • 멀티스레드 처리 (요청마다 스레드 할당)

Spring Boot에서는 Tomcat이 내장되어 있어서 별도 설치 없이 java -jar로 바로 실행할 수 있어요. WAR 배포 시대는 거의 끝났습니다.

Servlet 생명주기

PLAINTEXT
컨테이너 시작 → init() → [요청마다] service() → doGet()/doPost() → destroy() → 컨테이너 종료
메서드호출 시점횟수
init()서블릿 최초 요청 시 (또는 loadOnStartup 설정 시 컨테이너 시작 시)1번
service()매 HTTP 요청마다N번
destroy()컨테이너 종료 시1번

서블릿 인스턴스는 싱글톤 이에요. 요청마다 새로 만드는 게 아니라 하나의 인스턴스를 여러 스레드가 공유합니다. 그래서 서블릿에 인스턴스 변수로 상태를 저장하면 동시성 문제가 터져요. 이건 스프링 빈이 기본적으로 싱글톤인 것과 같은 맥락입니다.


DispatcherServlet — Front Controller 패턴

Front Controller 패턴이란

URL마다 서블릿을 따로 만드는 방식의 문제점은 명확해요. 공통 로직(인코딩 처리, 인증 체크, 로깅 등)을 각 서블릿에 중복으로 넣어야 합니다. 그래서 나온 게 Front Controller 패턴 이에요.

하나의 진입점(컨트롤러)이 모든 요청을 받고, 거기서 적절한 핸들러에 분배하는 방식입니다. 스프링 MVC에서 이 Front Controller 역할을 하는 게 바로 DispatcherServlet 이에요.

PLAINTEXT
모든 HTTP 요청 → [DispatcherServlet] → 적절한 핸들러에 위임

DispatcherServlet의 위치

DispatcherServlet도 결국 HttpServlet을 상속한 서블릿이에요. 상속 구조를 보면 이렇게 됩니다.

PLAINTEXT
HttpServlet
  └── HttpServletBean
        └── FrameworkServlet
              └── DispatcherServlet

서블릿 컨테이너(Tomcat) 입장에서는 그냥 서블릿 하나일 뿐이에요. 다만 이 서블릿이 "/"에 매핑되어서 모든 요청을 다 받아들인다는 게 차이점입니다.


요청 처리 흐름 전체

Spring MVC의 핵심입니다. 요청이 들어왔을 때 응답이 나갈 때까지 어떤 흐름으로 처리되는지 알아볼게요.

PLAINTEXT
HTTP 요청
  → Servlet Filter
    → DispatcherServlet
      → HandlerMapping (어떤 핸들러가 처리할지 결정)
        → HandlerAdapter (핸들러를 실제로 실행)
          → Controller (비즈니스 로직)
        ← ModelAndView 또는 @ResponseBody
      → ViewResolver (뷰 이름 → 실제 뷰 객체)
        → View (렌더링)
    ← DispatcherServlet
  ← Servlet Filter
HTTP 응답

좀 더 구체적으로 단계별로 뜯어볼게요.

  1. **클라이언트 요청 **: HTTP 요청이 Tomcat에 도착합니다.
  2. **Filter 체인 **: 서블릿 컨테이너 레벨에서 필터가 먼저 실행돼요. 인코딩, CORS, 시큐리티 필터 등.
  3. DispatcherServlet: doDispatch() 메서드가 핵심이에요. 여기서 나머지 흐름을 전부 조율합니다.
  4. HandlerMapping: 요청 URL, HTTP 메서드 등을 보고 어떤 컨트롤러의 어떤 메서드가 처리할지 찾아요.
  5. HandlerAdapter: 찾은 핸들러를 실제로 호출합니다. 파라미터 바인딩, 리턴 값 처리도 여기서 해요.
  6. Controller: 비즈니스 로직 수행. Service → Repository 호출.
  7. ViewResolver: 컨트롤러가 뷰 이름을 리턴하면 실제 뷰 객체를 찾아줍니다. REST API라면 이 단계는 생략돼요.
  8. **View 렌더링 **: HTML을 생성하거나, JSON 직렬화를 합니다.
  9. ** 응답 반환 **: 역순으로 필터를 거쳐서 클라이언트에 응답해요.

@RestController를 쓰는 요즘 환경에서는 ViewResolver와 View 단계가 빠지고, HttpMessageConverter가 객체를 JSON으로 직렬화해서 바로 응답 본문에 씁니다.


HandlerMapping

HandlerMapping은 ** 요청을 어떤 핸들러(컨트롤러 메서드)가 처리할지 결정 **하는 컴포넌트입니다.

RequestMappingHandlerMapping

실무에서 거의 100% 쓰이는 구현체예요. @RequestMapping, @GetMapping, @PostMapping 같은 어노테이션을 기반으로 핸들러를 매핑합니다.

JAVA
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{id}")       // GET /api/users/1
    public UserResponse getUser(@PathVariable Long id) { ... }

    @PostMapping               // POST /api/users
    public UserResponse createUser(@RequestBody UserRequest request) { ... }
}

애플리케이션 시작 시점에 @Controller가 붙은 모든 빈을 스캔해서, 메서드에 선언된 URL 패턴과 HTTP 메서드를 조합해 매핑 정보를 레지스트리에 등록해요. 요청이 오면 이 레지스트리에서 O(1)에 가까운 속도로 핸들러를 찾아냅니다.

URL 매핑 전략

RequestMappingHandlerMapping이 지원하는 매핑 조건은 URL 패턴만이 아니에요.

조건어노테이션/속성예시
URL 패턴value, path@GetMapping("/users/{id}")
HTTP 메서드method@RequestMapping(method = RequestMethod.GET)
요청 파라미터params@GetMapping(params = "type=admin")
헤더headers@GetMapping(headers = "X-API-VERSION=2")
Content-Typeconsumes@PostMapping(consumes = "application/json")
Acceptproduces@GetMapping(produces = "application/json")

여러 핸들러가 매칭될 수 있는 경우, 가장 구체적인 패턴이 우선해요. /users/admin/users/{id} 둘 다 매칭되면 리터럴이 더 구체적이니까 /users/admin이 선택됩니다.


HandlerAdapter

HandlerMapping이 "누가 처리할지"를 결정했으면, ** 실제로 그 핸들러를 호출하는 건 HandlerAdapter**입니다.

RequestMappingHandlerAdapter

@RequestMapping 기반 컨트롤러를 처리하는 어댑터예요. 이 녀석이 하는 일이 생각보다 많습니다.

  1. ** 파라미터 바인딩 **: @RequestBody, @PathVariable, @RequestParam, @ModelAttribute 등을 해석해서 메서드 파라미터에 값을 넣어줍니다.
  2. ** 메서드 호출 **: 리플렉션으로 컨트롤러 메서드를 실행해요.
  3. ** 리턴 값 처리 **: @ResponseBody가 있으면 HttpMessageConverter로 직렬화하고, 뷰 이름이 리턴되면 ModelAndView를 만듭니다.

왜 핸들러를 직접 호출하지 않고 어댑터를 거치는 걸까요? 핸들러 종류가 다양하기 때문이에요. @Controller 메서드, HttpRequestHandler, Controller 인터페이스 구현체 등 핸들러 타입마다 호출 방식이 다릅니다. 어댑터 패턴을 써서 DispatcherServlet은 핸들러 타입을 몰라도 되게끔 한 거예요.


@Controller vs @RestController

이 차이는 간단한데, 의외로 정확하게 대답 못 하는 사람이 많아요.

JAVA
@Controller
public class ViewController {

    @GetMapping("/hello")
    public String hello(Model model) {
        model.addAttribute("name", "Spring");
        return "hello";  // → ViewResolver가 hello.html을 찾아서 렌더링
    }
}
JAVA
@RestController  // = @Controller + @ResponseBody
public class ApiController {

    @GetMapping("/api/hello")
    public Map<String, String> hello() {
        return Map.of("message", "Hello Spring");
        // → HttpMessageConverter가 JSON으로 변환해서 응답 본문에 직접 씀
    }
}
구분@Controller@RestController
** 리턴 값**뷰 이름 (String)응답 본문 (객체)
@ResponseBody메서드마다 붙여야 함클래스 레벨에서 자동 적용
ViewResolver사용함사용 안 함
** 용도**SSR (서버 사이드 렌더링)REST API

@RestController는 결국 @Controller + @ResponseBody를 합친 거예요. 클래스에 @RestController를 붙이면 모든 메서드에 @ResponseBody가 자동으로 적용되니까, 리턴 객체가 뷰 이름이 아니라 HTTP 응답 본문으로 직렬화됩니다.

@Controller에서도 특정 메서드에 @ResponseBody를 붙이면 JSON 응답을 할 수 있어요. SSR과 API를 섞어 쓰는 경우에 이렇게 합니다.


필터(Filter) vs 인터셉터(Interceptor)

둘 다 요청을 가로채서 전/후 처리를 하는 건데, 그러면 뭐가 다를까요? ** 실행 위치가 근본적으로 다릅니다 **.

실행 위치 차이

PLAINTEXT
HTTP 요청
  → [Filter]                  ← Servlet Container 레벨 (스프링 바깥)
    → [DispatcherServlet]
      → [Interceptor preHandle]  ← 스프링 MVC 레벨 (스프링 안)
        → [Controller]
      ← [Interceptor postHandle]
    ← [DispatcherServlet]
  ← [Filter]
HTTP 응답
구분FilterInterceptor
** 소속**Servlet 스펙 (javax.servlet)Spring MVC 스펙 (HandlerInterceptor)
** 실행 위치**DispatcherServlet ** 바깥**DispatcherServlet ** 안**
** 접근 가능 정보**ServletRequest/ResponseHandler, ModelAndView, 스프링 빈
** 예외 처리**스프링 예외 처리 기능 사용 불가@ExceptionHandler 적용 가능
** 스프링 빈 접근**DelegatingFilterProxy로 우회자연스럽게 접근 가능

용도 차이

**Filter가 적합한 경우 **:

  • 인코딩 변환 (CharacterEncodingFilter)
  • CORS 처리
  • 스프링 시큐리티 (SecurityFilterChain)
  • XSS 방지
  • 로깅 (요청/응답 본문 로깅)

**Interceptor가 적합한 경우 **:

  • 로그인 체크, 권한 체크
  • API 요청 로깅
  • 컨트롤러 공통 모델 데이터 추가
  • 실행 시간 측정
JAVA
// Filter 구현
@Component
public class LoggingFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {
        // 전처리
        log.info("Request: {}", ((HttpServletRequest) request).getRequestURI());

        chain.doFilter(request, response);  // 다음 필터 또는 서블릿 호출

        // 후처리
        log.info("Response status: {}", ((HttpServletResponse) response).getStatus());
    }
}
JAVA
// Interceptor 구현
@Component
public class AuthInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws Exception {
        // 컨트롤러 실행 전. false를 리턴하면 요청 처리 중단
        String token = request.getHeader("Authorization");
        if (token == null) {
            response.setStatus(401);
            return false;
        }
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response,
                           Object handler, ModelAndView modelAndView) throws Exception {
        // 컨트롤러 실행 후, 뷰 렌더링 전
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                Object handler, Exception ex) throws Exception {
        // 뷰 렌더링까지 완료된 후. 리소스 정리 용도
    }
}

스프링 시큐리티가 Filter 기반인 이유가 뭘까요? 시큐리티는 DispatcherServlet에 도달하기 ** 전에** 인증/인가를 처리해야 하기 때문이에요. 인증되지 않은 요청이 컨트롤러까지 내려오면 안 되니까요.


ArgumentResolver

컨트롤러 메서드의 파라미터에 @RequestBody, @PathVariable, @ModelAttribute 같은 어노테이션을 붙이면 알아서 값이 바인딩되는데, 이 작업을 하는 게 ArgumentResolver (HandlerMethodArgumentResolver)입니다.

동작 방식

HandlerAdapter가 컨트롤러 메서드를 호출하기 전에, 등록된 ArgumentResolver들을 순회하면서 각 파라미터를 처리해요.

JAVA
public interface HandlerMethodArgumentResolver {
    // 이 리졸버가 해당 파라미터를 처리할 수 있는지
    boolean supportsParameter(MethodParameter parameter);

    // 실제 파라미터 값을 만들어서 리턴
    Object resolveArgument(MethodParameter parameter, ...);
}

주요 ArgumentResolver 매핑

어노테이션ArgumentResolver하는 일
@RequestBodyRequestResponseBodyMethodProcessorHTTP 본문을 HttpMessageConverter로 역직렬화
@PathVariablePathVariableMethodArgumentResolverURL 경로 변수 추출
@RequestParamRequestParamMethodArgumentResolver쿼리 파라미터 추출
@ModelAttributeModelAttributeMethodProcessor쿼리 파라미터/폼 데이터를 객체에 바인딩
@RequestHeaderRequestHeaderMethodArgumentResolverHTTP 헤더 값 추출

@RequestBody@ModelAttribute의 차이를 혼동하는 경우가 많은데, 핵심은 데이터가 어디에서 오느냐예요. @RequestBody는 HTTP 본문(body)에서 JSON을 읽어서 MessageConverter로 역직렬화하고, @ModelAttribute는 쿼리 파라미터나 폼 데이터를 Setter나 생성자를 통해 객체에 바인딩합니다.

커스텀 ArgumentResolver

실무에서 자주 쓰는 패턴이 있어요. 예를 들어 JWT에서 사용자 정보를 추출해서 컨트롤러 파라미터로 넘기고 싶을 때입니다.

JAVA
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {}

@Component
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CurrentUser.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ...) {
        HttpServletRequest request = // ...
        return extractUserFromToken(request.getHeader("Authorization"));
    }
}
JAVA
@GetMapping("/api/profile")
public UserProfile getProfile(@CurrentUser User user) {
    // user가 자동으로 주입됨
    return userService.getProfile(user.getId());
}

이러면 컨트롤러마다 토큰 파싱 로직을 반복할 필요가 없어요. 깔끔합니다.


예외 처리

@ExceptionHandler

컨트롤러에서 발생한 예외를 잡아서 처리하는 메서드를 정의할 수 있어요.

JAVA
@RestController
public class UserController {

    @GetMapping("/api/users/{id}")
    public UserResponse getUser(@PathVariable Long id) {
        return userService.findById(id)
                .orElseThrow(() -> new UserNotFoundException(id));
    }

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(UserNotFoundException e) {
        return ResponseEntity.status(404)
                .body(new ErrorResponse("USER_NOT_FOUND", e.getMessage()));
    }
}

이렇게 하면 해당 컨트롤러에서 UserNotFoundException이 발생했을 때 이 메서드가 잡아서 처리해요. 문제는 컨트롤러마다 이걸 중복으로 넣어야 한다는 겁니다.

@ControllerAdvice

** 모든 컨트롤러에 공통으로 적용되는 예외 처리를 한 곳에서 관리 **할 수 있어요.

JAVA
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(UserNotFoundException e) {
        return ResponseEntity.status(404)
                .body(new ErrorResponse("NOT_FOUND", e.getMessage()));
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException e) {
        return ResponseEntity.badRequest()
                .body(new ErrorResponse("BAD_REQUEST", e.getMessage()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAll(Exception e) {
        log.error("Unhandled exception", e);
        return ResponseEntity.internalServerError()
                .body(new ErrorResponse("INTERNAL_ERROR", "서버 내부 오류"));
    }
}

@RestControllerAdvice@ControllerAdvice + @ResponseBody예요. 패턴이 보이죠? @RestController와 같은 구조입니다.

예외 처리 우선순위는 이렇게 돼요.

  1. ** 컨트롤러 내부 @ExceptionHandler** — 가장 높은 우선순위
  2. @ControllerAdvice@ExceptionHandler — 전역
  3. ** 구체적 예외 타입이 일반적 예외보다 우선** — UserNotFoundExceptionException보다 먼저 매칭

ResponseEntityExceptionHandler

Spring이 제공하는 기본 예외 처리 클래스예요. MethodArgumentNotValidException, HttpRequestMethodNotSupportedException 같은 스프링 내부 예외들의 기본 처리 로직이 이미 구현되어 있습니다.

JAVA
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    // 스프링 기본 예외들은 부모 클래스가 알아서 처리
    // 커스텀 예외만 추가로 정의하면 된다

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
        return ResponseEntity.status(e.getStatus())
                .body(new ErrorResponse(e.getCode(), e.getMessage()));
    }
}

실무에서는 ResponseEntityExceptionHandler를 상속받고, 비즈니스 예외만 추가하는 방식을 많이 써요. 바인딩 에러나 미지원 메서드 같은 건 부모가 알아서 처리해주니까요.


HttpMessageConverter

@RequestBody로 JSON을 받거나, @ResponseBody로 JSON을 내보낼 때 실제로 변환 작업을 수행하는 게 HttpMessageConverter 입니다.

동작 흐름

PLAINTEXT
요청: JSON (HTTP Body) → HttpMessageConverter → 자바 객체
응답: 자바 객체 → HttpMessageConverter → JSON (HTTP Body)

스프링 부트는 Jackson 라이브러리가 기본으로 포함되어 있어서, MappingJackson2HttpMessageConverter가 자동으로 등록돼요. 별도 설정 없이 바로 JSON을 쓸 수 있는 이유입니다.

Jackson 직렬화/역직렬화

JAVA
// 요청 JSON → 자바 객체 (역직렬화)
@PostMapping("/api/users")
public UserResponse createUser(@RequestBody UserRequest request) {
    // { "name": "홍길동", "email": "hong@example.com" }
    // → UserRequest(name="홍길동", email="hong@example.com")
}

// 자바 객체 → 응답 JSON (직렬화)
@GetMapping("/api/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
    return new UserResponse(1L, "홍길동", "hong@example.com");
    // → { "id": 1, "name": "홍길동", "email": "hong@example.com" }
}

Jackson이 기본적으로 Getter 메서드를 기반으로 직렬화하고, 기본 생성자 + Setter 또는 @JsonCreator를 통해 역직렬화해요. Lombok의 @Data를 쓰면 둘 다 자동으로 만들어지니까 별다른 설정 없이 동작합니다.

자주 쓰는 Jackson 어노테이션도 알아두면 좋아요.

어노테이션용도
@JsonProperty("user_name")JSON 필드명과 자바 필드명이 다를 때
@JsonIgnore직렬화/역직렬화에서 제외
@JsonFormat(pattern = "yyyy-MM-dd")날짜 형식 지정
@JsonInclude(NON_NULL)null인 필드는 JSON에 포함 안 함

Content-Type 기반 선택

HttpMessageConverter는 여러 개 등록되어 있고, 요청의 Content-Type이나 Accept 헤더를 보고 적절한 컨버터를 선택해요.

Content-TypeConverter처리 결과
application/jsonMappingJackson2HttpMessageConverterJSON ↔ 객체
application/xmlMappingJackson2XmlHttpMessageConverterXML ↔ 객체
text/plainStringHttpMessageConverter문자열 그대로
application/octet-streamByteArrayHttpMessageConverter바이트 배열

주의할 점

Spring Boot 내장 톰캣

"Spring Boot는 Tomcat을 어떻게 내장하고 있나요?"

스프링 부트는 spring-boot-starter-web에 Tomcat이 의존성으로 포함되어 있어요. 애플리케이션 시작 시 TomcatServletWebServerFactory프로그래밍 방식으로 내장 Tomcat을 생성하고, DispatcherServlet을 등록한 뒤 서버를 시작합니다.

전통적인 방식이 "Tomcat 위에 WAR를 올린다"면, 내장 톰캣은 "애플리케이션이 Tomcat을 띄운다"는 점에서 주객이 전도된 셈이에요. 덕분에 java -jar로 어디서든 실행할 수 있고, 도커 컨테이너 배포도 한결 간편해졌습니다.

Tomcat 대신 Jetty나 Undertow를 쓰고 싶으면 의존성만 바꿔주면 돼요.

GRADLE
implementation('org.springframework.boot:spring-boot-starter-web') {
    exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
}
implementation 'org.springframework.boot:spring-boot-starter-undertow'

DispatcherServlet은 몇 개인가

기본적으로 ** 하나 **예요. Spring Boot의 auto-configuration이 DispatcherServlet을 하나 생성해서 "/"에 매핑합니다.

그런데 반드시 하나여야 하는 건 아니에요. ServletRegistrationBean을 통해 DispatcherServlet을 추가로 등록할 수 있습니다. 예를 들어 관리자 API와 일반 API를 완전히 분리된 컨텍스트로 운영하고 싶을 때 이런 구성을 할 수 있어요.

JAVA
@Bean
public ServletRegistrationBean<DispatcherServlet> adminServlet(ApplicationContext context) {
    DispatcherServlet servlet = new DispatcherServlet();
    AnnotationConfigWebApplicationContext adminContext = new AnnotationConfigWebApplicationContext();
    adminContext.register(AdminConfig.class);
    servlet.setApplicationContext(adminContext);

    return new ServletRegistrationBean<>(servlet, "/admin/*");
}

실무에서 이렇게 쓰는 경우는 드물지만, "DispatcherServlet은 무조건 하나입니다"라고 답하면 감점이에요.

비동기 처리 (@Async, DeferredResult)

"Spring MVC에서 비동기 처리는 어떻게 하나요?"

Spring MVC는 기본적으로 동기 블로킹 모델이에요. 서블릿 컨테이너의 스레드 풀에서 스레드를 하나 가져와서, 요청 처리가 끝날 때까지 그 스레드를 점유합니다.

비동기로 처리하고 싶으면 몇 가지 방법이 있어요.

@Async: 메서드를 별도 스레드에서 실행합니다. 다만 이건 HTTP 요청-응답 자체를 비동기로 만드는 건 아니고, 내부적으로 스레드를 분리해서 작업을 위임하는 거예요.

JAVA
@Async
public CompletableFuture<UserResponse> findUserAsync(Long id) {
    UserResponse user = userRepository.findById(id);
    return CompletableFuture.completedFuture(user);
}

DeferredResult: 서블릿 스레드를 즉시 반환하고, 나중에 다른 스레드에서 결과를 세팅할 수 있어요. 서블릿 스레드 풀을 효율적으로 쓸 수 있습니다.

JAVA
@GetMapping("/api/notifications")
public DeferredResult<ResponseEntity<String>> getNotification() {
    DeferredResult<ResponseEntity<String>> result = new DeferredResult<>(30000L);

    // 다른 스레드에서 결과를 세팅
    eventService.waitForEvent(event -> {
        result.setResult(ResponseEntity.ok(event.getMessage()));
    });

    return result;  // 서블릿 스레드는 바로 반환됨
}

Callable: 컨트롤러가 Callable을 리턴하면 스프링이 별도 스레드에서 실행해줘요. 서블릿 스레드를 빨리 풀어줄 수 있습니다.

진짜 비동기 논블로킹을 원한다면 Spring WebFlux를 고려해야 해요. Spring MVC의 비동기 기능은 서블릿 스레드를 효율적으로 관리하는 정도지, 근본적으로 논블로킹은 아닙니다.


파생 개념 — 여기서 더 파면 좋은 것들

Spring MVC를 이해했으면 자연스럽게 연결되는 주제들이 있어요.

  • ** 스프링 핵심 원리 (IoC/DI/AOP)**: DispatcherServlet이 HandlerMapping, HandlerAdapter 등을 IoC 컨테이너에서 가져와서 씁니다. 인터셉터는 AOP와 유사한 개념이에요. 이미 별도 글로 정리했습니다.
  • ** 스프링 시큐리티 **: SecurityFilterChain이 서블릿 필터 체인 위에 동작해요. 필터 vs 인터셉터의 차이를 알면 시큐리티 아키텍처가 왜 그렇게 생겼는지 이해됩니다.
  • **Servlet 스펙 **: Servlet 3.0의 비동기 지원, Servlet 3.1의 Non-blocking I/O 등 스펙 변화가 Spring MVC의 비동기 기능에 직접적인 영향을 줘요. DeferredResultCallable이 가능한 것도 Servlet 3.0의 AsyncContext 덕분입니다.

정리

Spring MVC를 이해하려면 결국 ** 요청이 들어와서 응답이 나가기까지의 흐름 **을 구체적으로 그릴 수 있어야 해요.

구분한 줄 정리
DispatcherServlet모든 요청의 진입점. Front Controller 패턴
HandlerMapping요청 → 핸들러 매핑. @RequestMapping 기반
HandlerAdapter핸들러 실제 호출. 파라미터 바인딩, 리턴 값 처리
Filter서블릿 컨테이너 레벨. DispatcherServlet 바깥
Interceptor스프링 MVC 레벨. DispatcherServlet 안
ArgumentResolver컨트롤러 파라미터에 값 주입
MessageConverterJSON ↔ 객체 변환. Jackson 기반
@ExceptionHandler예외를 잡아서 적절한 응답으로 변환

단순히 "DispatcherServlet이 요청을 받아서 컨트롤러에 전달합니다"가 아니라, HandlerMapping이 뭘 하고, HandlerAdapter가 왜 존재하고, 필터와 인터셉터가 어디서 동작하는지까지 설명할 수 있어야 합니다. 동작 원리를 알면 꼬리 질문이 와도 흔들리지 않아요.

댓글 로딩 중...