HTTP 요청이 @RestController 메서드에 도달하기까지 — Spring MVC의 처리 흐름

브라우저에서 POST /api/orders를 보냈다. Spring MVC 내부에서는 — DispatcherServlet이 요청을 받고, HandlerMapping이 컨트롤러 메서드를 찾고, HandlerAdapter가 매개변수를 바인딩하고, 메서드를 실행하고, 결과를 JSON으로 변환하여 응답한다. 이 전체 과정을 이해하면 — 404의 원인, @RequestBody@ModelAttribute의 차이, 예외 처리 흐름을 추적할 수 있다.

이 글은 Spring MVC의 요청 처리 파이프라인, DispatcherServlet의 역할, 컨트롤러 패턴, 그리고 Spring 7의 Servlet 6.1 기반 변경점을 풀어간다. (Spring Framework Reference - Web MVC)

DispatcherServlet — 프론트 컨트롤러 패턴

Spring MVC의 모든 요청은 DispatcherServlet을 통과한다. 이 서블릿이 요청을 적절한 컨트롤러로 분배한다. (Spring Framework Reference - DispatcherServlet)

flowchart TD
    REQ["HTTP 요청<br/>POST /api/orders"] --> DS["DispatcherServlet"]
    DS --> HM["HandlerMapping<br/>어떤 컨트롤러+메서드인가?"]
    HM --> HA["HandlerAdapter<br/>매개변수 바인딩 + 메서드 실행"]
    HA --> CTRL["Controller 메서드<br/>createOrder(@RequestBody ...)"]
    CTRL --> RV["ReturnValue 처리<br/>@ResponseBody → JSON"]
    RV --> RES["HTTP 응답<br/>201 Created + JSON body"]

요청 처리 단계

단계 컴포넌트 역할
1. 요청 수신 DispatcherServlet 모든 요청의 진입점
2. 핸들러 매핑 HandlerMapping URL + HTTP method → 컨트롤러 메서드
3. 핸들러 어댑터 HandlerAdapter 매개변수 바인딩, 메서드 실행
4. 컨트롤러 실행 @RestController 메서드 비즈니스 로직
5. 응답 처리 HttpMessageConverter 반환값 → JSON/XML
6. 예외 처리 @ExceptionHandler 예외 → HTTP 응답

@RestController와 매핑

// Spring 7 / Java 25 — REST 컨트롤러
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService service;

    public OrderController(OrderService service) { this.service = service; }

    @GetMapping("/{id}")
    public OrderDto get(@PathVariable Long id) {
        return service.findById(id);
    }

    @PostMapping
    public OrderDto create(@Valid @RequestBody CreateOrderRequest request) {
        return service.create(request);
    }

    @PutMapping("/{id}")
    public OrderDto update(@PathVariable Long id, @Valid @RequestBody UpdateOrderRequest request) {
        return service.update(id, request);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        service.delete(id);
    }
}

@RestController = @Controller + @ResponseBody. 모든 메서드의 반환값이 view 이름이 아니라 응답 본문(JSON 등)으로 직렬화된다. HttpMessageConverter(기본: Jackson)가 객체를 JSON으로 변환한다.

@RequestBody가 어떻게 JSON을 객체로 바꾸는가: 클라이언트가 {"name":"Alice","age":30} JSON을 보내면 → DispatcherServletMappingJackson2HttpMessageConverter를 선택하고 → Jackson의 ObjectMapper.readValue()가 JSON 문자열을 CreateUserRequest record로 역직렬화한다. record의 생성자 매개변수 이름과 JSON 필드 이름이 일치하면 자동 매핑된다.

비유: @RequestBody번역가다. 클라이언트는 영어(JSON)로 말하고, 서버는 한국어(Java 객체)로 이해한다. HttpMessageConverter가 그 사이에서 실시간 통역을 한다. 번역가가 모르는 단어(매핑 불가 필드)가 있으면 예외를 던진다 — HttpMessageNotReadableException.

매개변수 바인딩 어노테이션

어노테이션 용도
@PathVariable URL 경로 변수 /orders/{id}id
@RequestParam 쿼리 매개변수 ?page=1&size=10
@RequestBody 요청 본문 (JSON → 객체) POST/PUT body
@RequestHeader HTTP 헤더 Authorization: Bearer ...
@ModelAttribute 폼 데이터 → 객체 form submit

@RequestBodyJSON을 객체로 역직렬화한다 (Jackson 사용). @ModelAttributeform 데이터를 객체에 바인딩한다. REST API에서는 @RequestBody를 주로 쓴다.

@RequestBody vs @ModelAttribute — 언제 무엇을?

// Spring 7 / Java 25
// JSON API (REST) → @RequestBody
@PostMapping("/api/orders")
public OrderDto create(@RequestBody CreateOrderRequest request) { ... }

// HTML 폼 → @ModelAttribute (생략 가능)
@PostMapping("/orders")
public String submitForm(@ModelAttribute OrderForm form) { ... }

응답 — ResponseEntity로 세밀하게 제어

// Spring 7 / Java 25 — ResponseEntity로 상태 코드 + 헤더 제어
@PostMapping
public ResponseEntity<OrderDto> create(@Valid @RequestBody CreateOrderRequest request) {
    OrderDto created = service.create(request);
    return ResponseEntity
        .created(URI.create("/api/orders/" + created.id()))
        .header("X-Order-Status", "CREATED")
        .body(created);
}

Spring 7 / Servlet 6.1 — 달라진 것

Spring 7은 Jakarta Servlet 6.1(Tomcat 11)을 기반으로 한다:

항목 Spring 6 (Servlet 6.0) Spring 7 (Servlet 6.1)
서블릿 컨테이너 Tomcat 10 Tomcat 11
jakarta.servlet.* O O (동일)
새 API HttpServletRequest.getServletPath() 변경 등

Spring 7에서 jakarta.* 네임스페이스는 Spring 6과 동일하다. Servlet 6.1의 API 변경은 대부분 내부적이며, 애플리케이션 코드에 직접 영향을 주지 않는다 — @RestController 기반 코드는 Spring 6과 7에서 동일하게 동작한다. (Spring Framework 7 Release Notes)

RestClient — 동기식 HTTP 클라이언트 (Spring 6.1+)

Spring MVC는 서버 측이지만, 다른 API를 호출할 때는 RestClient(Spring 6.1+ 추가, Spring 7 강화)를 쓴다:

// Spring 7 / Java 25 — RestClient로 외부 API 호출
import org.springframework.web.client.RestClient;

@Service
public class PaymentClient {
    private final RestClient client;

    public PaymentClient(RestClient.Builder builder) {
        this.client = builder.baseUrl("https://api.payment.com").build();
    }

    public PaymentResult charge(long amount) {
        return client.post()
            .uri("/charge")
            .body(new ChargeRequest(amount))
            .retrieve()
            .body(PaymentResult.class);   // JSON → 객체 자동 변환
    }
}

RestClientRestTemplate(레거시)의 현대적 대안이다 — 빌더 패턴, fluent API, 더 직관적인 에러 처리를 제공한다. (Spring Framework Reference - RestClient)

실습 — 컨트롤러 패턴

// Spring 7 / Java 25 — ControllerPatternDemo.java
// Spring 없이 라우팅 패턴 시뮬레이션
import java.util.*;
import java.util.function.*;

public class ControllerPatternDemo {
    public static void main(String[] args) {
        Map<String, BiFunction<String, String, String>> routes = new HashMap<>();

        // 라우트 등록
        routes.put("GET /api/orders", (id, body) -> "주문 목록: []");
        routes.put("POST /api/orders", (id, body) -> "주문 생성: " + body);
        routes.put("GET /api/orders/1", (id, body) -> "주문 #1 상세");

        // 요청 처리 시뮬레이션
        System.out.println(routes.get("GET /api/orders").apply(null, null));
        System.out.println(routes.get("POST /api/orders").apply(null, "{\"item\": \"book\"}"));
        System.out.println(routes.get("GET /api/orders/1").apply("1", null));
    }
}
java ControllerPatternDemo.java
주문 목록: []
주문 생성: {"item": "book"}
주문 #1 상세

확인할 것: HTTP method + URL 경로를 키로 하여 적절한 핸들러를 찾는다. Spring MVC의 HandlerMapping이 이 과정을 자동화한다 — @GetMapping, @PostMapping 어노테이션으로 라우트를 등록한다.

HandlerInterceptor — 요청 처리 전후에 끼어들기

HandlerInterceptor는 컨트롤러 실행 전후에 공통 로직(인증, 로깅, 응답 시간 측정)을 적용한다:

// Spring 7 / Java 25 — 인터셉터
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.*;

@Component
public class AuthInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        String token = request.getHeader("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            response.setStatus(401);
            return false;   // 컨트롤러 실행 중단
        }
        return true;   // 다음으로 진행
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                 HttpServletResponse response,
                                 Object handler, Exception ex) {
        long elapsed = System.currentTimeMillis() - startTime;
        System.out.println("[" + request.getMethod() + " " + request.getRequestURI()
            + "] " + response.getStatus() + " (" + elapsed + "ms)");
    }
}

인터셉터는 AOP(장 06)와 유사하지만 HTTP 요청 수준에서 동작한다 — 컨트롤러 진입 전 인증, 로깅, CORS 등에 적합하다. 인터셉터는 WebMvcConfigurer.addInterceptors()로 등록한다.

Content Negotiation — JSON vs XML

Spring MVC는 Accept 헤더에 따라 응답 포맷을 자동 선택한다:

// Spring 7 / Java 25 — JSON과 XML 동시 지원
@GetMapping(value = "/orders/{id}", produces = { "application/json", "application/xml" })
public Order getOrder(@PathVariable Long id) {
    return service.findById(id);
    // Accept: application/json → JSON 응답
    // Accept: application/xml  → XML 응답 (Jackson XML 필요)
}

ResponseStatusException — 간단한 에러 응답

// Spring 7 / Java 25 — ResponseStatusException
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
    return service.findById(id)
        .orElseThrow(() -> new ResponseStatusException(
            HttpStatus.NOT_FOUND, "주문을 찾을 수 없습니다: " + id));
}

ResponseStatusException은 한 번의 throw로 HTTP 상태 코드와 메시지를 설정한다. @ControllerAdvice 없이 간단한 에러 응답을 만들 때 유용하다.

@CrossOrigin — CORS 처리

// Spring 7 / Java 25 — CORS 허용
@RestController
@CrossOrigin(origins = "https://myapp.example.com")
@RequestMapping("/api")
public class ApiController {
    @GetMapping("/data")
    public Data getData() { ... }   // 다른 도메인에서 접근 허용
}

// 전역 CORS 설정
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://myapp.example.com")
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(3600);   // preflight 캐시 1시간
    }
}

CORS(Cross-Origin Resource Sharing)는 브라우저 보안 정책이다. 백엔드 API를 다른 도메인의 프론트엔드에서 호출하려면 CORS 헤더가 필요하다. Spring MVC가 자동으로 preflight(OPTIONS) 요청을 처리한다.

Multipart 파일 업로드

// Spring 7 / Java 25 — 파일 업로드
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) {
    String originalName = file.getOriginalFilename();
    String contentType = file.getContentType();
    long size = file.getSize();
    byte[] bytes = file.getBytes();

    // 저장
    Path dest = Path.of("/uploads", originalName);
    Files.write(dest, bytes);
    return "업로드 완료: " + originalName + " (" + size + " bytes)";
}

요약 — 이 글의 결론

  • DispatcherServlet이 모든 요청의 진입점이다. 프론트 컨트롤러 패턴으로 요청을 매핑, 바인딩, 실행, 응답 변환까지 처리한다.
  • @RestController = @Controller + @ResponseBody. 반환값이 view 이름이 아니라 JSON 응답 본문으로 직렬화된다.
  • @RequestBody는 JSON → 객체, @ModelAttribute는 폼 데이터 → 객체. REST API에서는 @RequestBody를 주로 쓴다.
  • ResponseEntity로 상태 코드와 헤더를 세밀하게 제어한다. @ResponseStatus보다 유연하다.
  • Spring 7은 Servlet 6.1(Tomcat 11) 기반이지만, @RestController 코드는 동일하다. jakarta.* 네임스페이스는 Spring 6과 같다.
  • RestClientRestTemplate을 대체한다. Spring 6.1+에서 추가된 현대적 HTTP 클라이언트.

생각해 볼 문제

  1. DispatcherServlet이 요청을 처리할 때, 예외가 발생하면 어떤 컴포넌트가 처리하는가? (장 08 상세)
  2. @GetMapping@RequestMapping(method=GET)의 차이는 무엇인가?
  3. JSON 역직렬화(@RequestBody)에서 Jackson이 객체를 생성하는 방식은? 기본 생성자가 필요한가?
  4. RestClient vs WebClient(WebFlux) — 언제 어느 것을 써야 하는가?
  5. Spring 7에서 Tomcat 11로 올라갔을 때, jakarta.servlet API의 주요 변화는 무엇인가?

참고

'Develop Artifacts > Spring' 카테고리의 다른 글

Spring - 06. aop  (0) 2026.07.12
Spring - 05. bean scope profile  (0) 2026.07.12
Spring - 04. configuration component-scan  (0) 2026.07.12
Spring - 03. dependency-injection  (0) 2026.07.12
Spring - 02. container bean lifecycle  (0) 2026.07.12