자바 아키텍처·Spring 학습 노트 목차

웹 — 요청이 들어와 응답이 나가기까지

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의 보안 필터 체인이 여기서 동작한다(시큐리티 편으로 이어진다) — 모든 요청의 입출구를 감싼다. HandlerInterceptorDispatcherServlet 안쪽에 앉아 *핸들러 실행 전(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을 세션에 실어 두고, 프레임 단위 인가가 필요하면 ChannelInterceptorCONNECT·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는 브로커로 라우팅하고, 스케일은 외부 브로커 릴레이로 푼다.

애너테이션 지도 — 계층별로 무엇이 무엇을 하나Spring Security — 인증·인가·필터체인·JWT