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을 보내면 → DispatcherServlet이 MappingJackson2HttpMessageConverter를 선택하고 → 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 |
@RequestBody는 JSON을 객체로 역직렬화한다 (Jackson 사용).@ModelAttribute는 form 데이터를 객체에 바인딩한다. 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 → 객체 자동 변환
}
}
RestClient는RestTemplate(레거시)의 현대적 대안이다 — 빌더 패턴, 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과 같다. RestClient가RestTemplate을 대체한다. Spring 6.1+에서 추가된 현대적 HTTP 클라이언트.
생각해 볼 문제
DispatcherServlet이 요청을 처리할 때, 예외가 발생하면 어떤 컴포넌트가 처리하는가? (장 08 상세)@GetMapping과@RequestMapping(method=GET)의 차이는 무엇인가?- JSON 역직렬화(
@RequestBody)에서 Jackson이 객체를 생성하는 방식은? 기본 생성자가 필요한가? RestClientvsWebClient(WebFlux) — 언제 어느 것을 써야 하는가?- Spring 7에서 Tomcat 11로 올라갔을 때,
jakarta.servletAPI의 주요 변화는 무엇인가?
참고
- Spring Framework 7 - Web MVC - 접근 2026-07-10
- Spring Framework 7 - DispatcherServlet - 접근 2026-07-10
- Spring Framework 7 - RestClient - 접근 2026-07-10
- Spring Framework 7 Release Notes - 접근 2026-07-10
'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 |
