웹 — 요청이 들어와 응답이 나가기까지
HTTP 요청 하나가 들어와 JSON 응답이 되어 나가기까지, 그 길에서 Spring MVC가 무엇을 자동으로 해 주는지를 알면 어디에 손을 대야 하는지가 보인다. 그리고 보통의 요청-응답으로는 안 되는 일 — 서버가 먼저 말을 거는 실시간 양방향(WebSocket·STOMP) — 까지 한 흐름으로 따라가 본다.
모든 요청의 단일 진입점 — DispatcherServlet
들어오는 모든 요청은 DispatcherServlet 하나로 모인다. Spring Boot가 DispatcherServletAutoConfiguration으로 자동 등록하고 /에 매핑하므로, 사실상 모든 요청이 이 *프론트 컨트롤러*를 거친다. 그 안에서 일어나는 일을 단계로 펼치면 이렇다.
HandlerMapping이 URL과 HTTP 메서드를 보고 어느 컨트롤러 메서드인지 찾으면(@GetMapping 등으로), HandlerAdapter가 그 메서드를 호출할 준비를 한다. 이때 **HandlerMethodArgumentResolver**가 요청의 각 부분(경로·쿼리·본문·헤더)을 메서드 인자로 변환하고, 메서드가 돌려준 값은 **HandlerMethodReturnValueHandler**가 받아 — @ResponseBody라면 HttpMessageConverter(보통 Jackson)로 JSON으로 직렬화해 내보낸다(뷰 이름이면 ViewResolver로 가지만, REST에서는 거의 안 쓴다). 우리가 직접 쓰는 건 이 사슬에서 컨트롤러 하나뿐이고, 나머지 단계는 Spring이 확장 지점으로 열어 두되 기본 구현으로 알아서 처리한다. (출처: Baeldung — DispatcherServlet.)
컨트롤러 — 바인딩과 그 확장
@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping("/{id}")
public OrderResponse get(@PathVariable Long id) { ... }
@PostMapping
public OrderResponse create(@RequestBody @Valid OrderRequest req) { ... }
}
@RestController는 @Controller에 @ResponseBody를 더한 것이라, 반환값이 뷰가 아니라 *바로 응답 본문(JSON)*이 된다. 요청의 각 부분은 위에서 본 *ArgumentResolver*가 인자로 꽂아 준다 — 경로의 변수는 @PathVariable, 쿼리스트링은 @RequestParam, 본문 JSON은 @RequestBody, 헤더는 @RequestHeader다. 그런데 같은 추출 코드가 컨트롤러마다 반복된다면 — 예컨대 인증 사용자를 매번 꺼내 쓴다면 — 직접 커스텀 ArgumentResolver를 만들거나(시큐리티라면 @AuthenticationPrincipal이 그 방식이다) 등록해, 보일러플레이트를 인자 하나로 줄일 수 있다. 즉 ④의 입력과 ⑤의 출력 모두 우리가 끼어들 수 있는 확장 지점이다.
검증은 경계에서 — @Valid
들어온 입력은 안쪽으로 들어가기 전, 곧 컨트롤러 경계에서 검증하는 게 좋다.
public record OrderRequest(
@NotBlank String item,
@Min(1) int quantity) {}
@RequestBody에 @Valid를 붙이면 Bean Validation(@NotBlank·@Min·@Email…)이 바인딩 직후 자동으로 검증되고, 실패하면 MethodArgumentNotValidException이 난다. 이렇게 경계에서 잘못된 입력을 걸러 내면 안쪽 도메인 코드가 깨끗하게 유지된다 — jcode에서 "매개변수 검증을 일찍 하라"던 것과 같은 원리다. 같은 DTO를 생성과 수정에서 다른 규칙으로 검증해야 하면, 검증 그룹(@Validated(OnCreate.class))으로 시나리오를 가른다.
예외는 한 곳에서, 표준 형식으로
예외를 컨트롤러마다 try-catch로 처리하면 코드가 중복되고 응답 형식도 제각각이 된다. 그래서 **@RestControllerAdvice**로 한곳에 모아 처리한다.
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(OrderNotFound.class)
ProblemDetail handle(OrderNotFound e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
}
여기서 반환 타입에 주목할 만하다. Spring 6·Boot 3은 에러 응답의 표준으로 **ProblemDetail(RFC 9457)**을 지원한다 — type·title·status·detail 같은 표준 필드를 application/problem+json으로 내보내, 클라이언트가 어느 API의 에러든 같은 방식으로 파싱할 수 있게 한다(spring.mvc.problemdetails.enabled=true면 표준 예외도 이 형식으로 나온다). API마다 제멋대로인 커스텀 에러 JSON보다 표준이 연동·문서화에 훨씬 유리하다. 간단히는 서비스에서 ResponseStatusException(HttpStatus.NOT_FOUND, ...)을 던져 상태 코드만 빠르게 낼 수도 있다. 그리고 응답을 더 세밀히 — 201 Created에 Location 헤더, 304, 캐시 헤더 — 제어하려면 ResponseEntity를 반환한다.
어디까지가 누구의 일인가 — 콘텐츠 협상, 필터 vs 인터셉터
응답을 JSON으로 줄지 XML로 줄지는 콘텐츠 협상이 정한다 — 요청의 Accept 헤더를 보고 *적절한 HttpMessageConverter*를 골라 직렬화하는 것이다. 그래서 같은 핸들러가 클라이언트 선호에 따라 다른 표현을 낼 수 있다. 한편 요청 처리에 가로로 끼어드는 장치가 두 종류인데, 어디에 앉느냐가 다르다. 서블릿 필터는 DispatcherServlet 바깥(서블릿 컨테이너 레벨)에 앉아 — 그래서 Spring Security의 보안 필터 체인이 여기서 동작한다(시큐리티 편으로 이어진다) — 모든 요청의 입출구를 감싼다. HandlerInterceptor는 DispatcherServlet 안쪽에 앉아 *핸들러 실행 전(preHandle)·후(postHandle)·완료(afterCompletion)*로 끼어드는데, 어느 핸들러가 선택됐는지·ModelAndView가 무엇인지 같은 MVC 맥락을 알 수 있다. "컨테이너 레벨이냐 MVC 레벨이냐"로 둘을 가르면 된다.
느린 요청과 스레드 — 비동기와 가상 스레드
기본 Spring MVC는 요청 하나당 서블릿 스레드 하나를 붙인다(thread-per-request). 그런데 그 요청이 느린 외부 호출을 기다리는 동안 스레드를 붙잡고 있으면, 동시 요청이 몰릴 때 스레드 풀이 마른다. 고전적 해법은 컨트롤러가 DeferredResult·Callable·CompletableFuture를 반환해, 결과가 준비될 때까지 서블릿 스레드를 풀어 주는 것이다. 더 최신 해법은 가상 스레드(jcode·Java 21)다 — Boot 3.2+에서 spring.threads.virtual.enabled=true를 켜면 요청마다 가상 스레드가 붙어, 블로킹 코드를 그대로 쓰면서도 스레드가 싸져 높은 동시성을 낸다(리액티브 편에서 본 "I/O 동시성이면 가상 스레드가 더 단순"의 그 지점이다).
서버가 먼저 말을 걸어야 할 때 — WebSocket + STOMP
지금까지는 클라이언트가 묻고 서버가 답하는 요청-응답이었다. 그런데 채팅·알림·실시간 시세처럼 서버가 먼저 보내야 하는 일은 이 모델로는 안 된다. 그래서 WebSocket을 쓴다 — 평범한 HTTP 요청에 Upgrade: websocket을 실어 보내면 서버가 **101 Switching Protocols**로 답하고, 그 순간부터 같은 TCP 연결에서 HTTP를 버리고 양방향으로 프레임을 주고받는다. 다만 WebSocket 자체는 바이트를 나르는 통로일 뿐이라 "이 메시지가 어느 방 것인지" 같은 개념이 없다. 그래서 그 위에 **STOMP**라는 메시징 프로토콜을 얹어 목적지·구독·헤더 같은 구조를 더한다(WebSocket이 막힌 환경을 위해 SockJS로 폴백하기도 한다).
STOMP는 SEND·SUBSCRIBE 같은 명령과 목적지 헤더, 본문으로 이뤄져, raw WebSocket보다 라우팅과 구독이 구조화된다. 핵심은 목적지 규칙이다. 클라이언트가 /app으로 시작하는 곳에 보내면(SEND /app/chat), 앞의 /app을 떼고 @MessageMapping 컨트롤러 메서드로 라우팅되며, 그 메서드의 반환값이 다시 메시지가 되어 응답 목적지로 간다. 반면 /topic(브로드캐스트, 한 명이 여럿에게)이나 /queue(1:1)로 구독하면, 이건 컨트롤러가 아니라 메시지 브로커가 구독자들에게 직접 밀어 준다.
@MessageMapping("/chat") // 클라이언트가 /app/chat 으로 SEND
@SendTo("/topic/room") // 반환값을 /topic/room 구독자에게 브로드캐스트
public ChatMessage handle(ChatMessage in) { return in; }
구독은 세션별로 관리되어 연결이 끊기면 정리되니, 죽은 클라이언트에게 보내는 일이 없다. 컨트롤러 바깥(예: 어떤 이벤트 핸들러)에서 능동적으로 푸시하고 싶으면 **SimpMessagingTemplate**으로 convertAndSend("/topic/room", msg)나, 특정 사용자에게 보내는 convertAndSendToUser(...)(누가 어느 세션인지는 SimpUserRegistry가 안다)를 부르면 된다. 다만 스케일에는 함정이 하나 있다 — 기본 내장 SimpleBroker는 단일 인스턴스 메모리라 여러 노드로 확장하면 한 노드의 구독자에게만 닿는다. 여러 인스턴스로 키우려면 RabbitMQ·ActiveMQ 같은 외부 브로커를 enableStompBrokerRelay로 붙여, 모든 노드가 같은 브로커를 공유하게 해야 한다(메시징 편의 분산 브로커와 같은 맥락이다).
운영에서 실제로 걸리는 것
내장 브로커에서 외부 브로커로 넘어가는 순간, 메시지 흐름이 두 갈래로 갈라진다는 점을 알아 둬야 한다. /app으로 온 것은 여전히 애플리케이션이 직접 처리하고, /topic·/queue는 브로커가 팬아웃한다. 그래서 브로커가 죽으면 구독 전달만 멈추고 컨트롤러는 멀쩡히 도는 어중간한 상태가 된다 — 릴레이 연결 상태를 지표로 뽑아 두지 않으면 "메시지가 안 온다"는 신고를 받고서야 알게 된다.
인증은 HTTP와 다르게 흐른다. 핸드셰이크는 평범한 HTTP 요청이라 필터 체인을 타지만, 그 뒤의 STOMP 프레임은 필터를 거치지 않는다. 그래서 연결 시점의 Principal을 세션에 실어 두고, 프레임 단위 인가가 필요하면 ChannelInterceptor로 CONNECT·SUBSCRIBE를 가로채 검사한다. 특히 SUBSCRIBE 검사를 빠뜨리면 남의 목적지를 구독하는 구멍이 생긴다 — /topic/room.{id}처럼 목적지에 식별자가 들어갈 때 실제로 자주 나온다.
@Override
public void configureClientInboundChannel(ChannelRegistration reg) {
reg.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
var acc = StompHeaderAccessor.wrap(message);
if (StompCommand.SUBSCRIBE.equals(acc.getCommand()))
assertCanSubscribe(acc.getUser(), acc.getDestination());
return message;
}
});
}
convertAndSendToUser도 인스턴스가 여럿이면 그냥은 안 된다. 어느 사용자가 어느 노드에 붙었는지는 각 노드의 SimpUserRegistry만 알기 때문이다. 노드 간에 사용자 레지스트리를 공유하도록 브로드캐스트 목적지를 열어 주거나(setUserDestinationBroadcast·setUserRegistryBroadcast), 애초에 사용자별 목적지를 쓰지 않고 구독 키를 도메인 식별자로 설계하는 편이 단순하다.
나머지는 연결이 상태를 가진다는 사실에서 파생된다. 배포할 때마다 전 연결이 한꺼번에 끊겼다가 동시에 재연결하므로, 클라이언트에 지수 백오프와 지터가 없으면 새 파드가 뜨자마자 재연결 폭주로 다시 넘어간다. 중간 장비가 유휴 연결을 끊는 것도 그대로 겪으니 STOMP 하트비트를 켜 두고, SockJS 폴백은 필요할 때만 쓴다 — 폴백으로 내려가면 롱 폴링이 되어 스레드·연결 비용이 원래 이야기로 되돌아간다.
정리하면, 웹 요청은 *DispatcherServlet*이라는 단일 진입점에서 HandlerMapping → HandlerAdapter → ArgumentResolver → 핸들러 → ReturnValueHandler → MessageConverter 사슬을 거쳐 JSON이 되고, 그 입력·출력 단계는 모두 우리가 확장할 수 있다. 입력은 경계에서 @Valid로 거르고 예외는 @RestControllerAdvice + ProblemDetail로 표준화하며, 가로 관심사는 *필터(컨테이너)와 인터셉터(MVC)*가 위치에 따라 나눠 맡는다. 느린 요청은 *비동기나 가상 스레드*로 스레드를 아끼고, 서버가 먼저 말을 걸어야 하면 WebSocket+STOMP로 — /app은 컨트롤러로, /topic·/queue는 브로커로 라우팅하고, 스케일은 외부 브로커 릴레이로 푼다.