자바 아키텍처·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(jcode에서 본, 한 번 맺으면 양방향으로 계속 열려 있는 연결) 위에, 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로 붙여, 모든 노드가 같은 브로커를 공유하게 해야 한다(메시징 편의 분산 브로커와 같은 맥락이다).

정리하면, 웹 요청은 DispatcherServlet이라는 단일 진입점에서 HandlerMapping → HandlerAdapter → ArgumentResolver → 핸들러 → ReturnValueHandler → MessageConverter 사슬을 거쳐 JSON이 되고, 그 입력·출력 단계는 모두 우리가 확장할 수 있다. 입력은 경계에서 @Valid로 거르고 예외는 @RestControllerAdvice + ProblemDetail로 표준화하며, 가로 관심사는 *필터(컨테이너)와 인터셉터(MVC)*가 위치에 따라 나눠 맡는다. 느린 요청은 비동기나 가상 스레드로 스레드를 아끼고, 서버가 먼저 말을 걸어야 하면 WebSocket+STOMP로 — /app은 컨트롤러로, /topic·/queue는 브로커로 라우팅하고, 스케일은 외부 브로커 릴레이로 푼다.

Spring Boot — 자동설정·스타터·프로파일Spring Security — 인증·인가·필터체인·JWT