
0. 들어가기 전에
이번에 다룰 것은 저번에 예고했던 대로 스프링 MVC가 되었다. 이번주에 마침 스프링 MVC를 수업에서 다루었기 때문에, 이번 포스팅은 스프링 MVC의 모델과 패턴, DispatcherServlet 요청 등에 대해 다룰 예정이다. 이번 시간에도 실무에서 다룰 만한 TIP도 추가하여 다룬다.
잠깐, 실무 TIP에 대해
공부하는 입장에서 실무 TIP이라는 말이 좀 웃길 수도 있다. 여기서 다루는 TIP들의 내용은 정확하게 말하면 실무 TIP이라기보다는 스프링 MVC를 다루는 커뮤니티나 블로그 등에서 자주 나오는 이야기들을 모아서 정리해본 것이다. 따라서 완전히 실무에서 해당 상황이 발생되리라는 법은 없지만, 실전에서 자주 있을 법한 일들을 쓴 것이기에 유의하자.
1. Spring MVC는 아직도 유효한가?
앞선 글에서 스프링이 DI와 컨테이너로 객체를 조립하는 방식을 다뤘다. 그렇다면 그렇게 조립된 객체들이 실제로 웹 요청 앞에 서는 방식은 어떻게 되는가. 그 답이 스프링 MVC다. 이번 절에서는 Spring MVC가 어디서 왔고, 무엇이 다르며, 지금도 선택할 이유가 있는지를 짚어본다.
Spring MVC의 역사
자바 진영에서 웹은 오랫동안 서블릿과 JSP를 중심으로 확장되어 왔다. 서블릿은 HTTP 요청을 자바 코드로 처리하게 해 주었고, JSP는 HTML에 가까운 형태로 화면을 만들 수 있게 해 주었다. 그런데 프로젝트가 커질수록 공통 처리가 반복되고, URL과 클래스의 연결이 산발적으로 늘어나 유지보수 비용이 커지는 문제가 반복되었다. 스프링 프레임워크는 이런 문제를 객체 조립(DI)과 횡단 관심사(Cross-cutting Concerns)의 정리로 한 번에 건드리려는 쪽에서 출발했고, 그중 웹 요청을 다루는 모듈이 스프링 MVC에 해당한다고 보면 된다.
💡 토막 개념: 횡단 관심사 (Cross-cutting Concern)
소프트웨어 공학의 관점에서, 시스템의 여러 핵심 모듈에 걸쳐 공통적으로 나타나는 부가적인 기능을 의미한다. 애플리케이션의 핵심 비즈니스 로직(Core Concerns)과는 구분되며, 모듈화가 어려운 지점이 시스템 전반에 산재하여 나타나는 것이 특징.
스프링 MVC가 중요한 이유는 단순히 어노테이션을 제공해서가 아니라, 서블릿 스펙이 정의한 요청 처리 모델 위에서 팀이 공유할 수 있는 관례를 제공한다는 점이다. WAS는 여전히 소켓에서 HTTP를 받고 서블릿으로 넘기며, 스프링 MVC는 그 위에서 DispatcherServlet이라는 진입점을 고정해 둔다. 즉 "스프링이 WAS를 없앤다"기보다, 서블릿 컨테이너가 주는 실행 모델을 전제로 그 위에 개발자 경험을 정리한 층에 가깝다.
역사를 연표로 외울 필요는 없다. 다만 다음 질문에 답할 수 있으면 충분하다. 왜 URL 처리가 컨트롤러 메서드 중심으로 모였는가, 왜 공통 전처리를 한곳으로 모으려 했는가, 왜 뷰 기술을 바꿔도 컨트롤러의 반환 규칙을 유지하려 했는가. 이 질문들은 지금도 레거시 시스템을 읽을 때 그대로 도움이 된다.
Spring MVC가 다른 MVC와 차별된 지점
MVC라는 이름은 넓게 쓰인다. 데스크톱 UI의 MVC는 이벤트 루프와 위젯 중심의 이야기가 섞이기 쉽고, 프런트엔드의 MVC는 브라우저 안에서의 상태 관리와 렌더링 타이밍 이야기가 섞이기 쉽다. 반면 스프링 MVC에서 말하는 MVC는 기본 단위가 HTTP 요청과 응답이다. 컨트롤러는 요청을 받아 필요한 데이터를 준비하고, 모델에 담아 뷰로 넘기거나, 혹은 응답 본문으로 직렬화한다. 여기서 모델은 화면에 흩뿌릴 값의 묶음에 가깝고, 뷰는 응답을 만드는 마지막 표현 단계에 가깝다.
또 하나의 큰 차이는 프런트 컨트롤러 패턴과의 결합이다. URL마다 서블릿 클래스를 늘리고 web.xml 매핑을 늘리던 방식에서 벗어나, 중앙의 DispatcherServlet이 들어오는 요청을 받아 공통 단계를 처리한 뒤 개발자가 작성한 핸들러로 넘긴다. 개발자는 요청을 어디로 보낼지를 컨트롤러의 규칙으로 표현하고, 프레임워크는 그 규칙을 수집해 실행 시점에 연결한다. 이 구조는 팀 단위로 코드 리뷰와 장애 분석을 쉽게 만드는 방향으로 작용한다. 같은 맥락에서 "스프링 MVC가 다른 MVC와 다르다"는 말은 우월성 선언이 아니라, 문제의 단위가 HTTP라는 점과 진입점이 중앙집중이라는 점을 분명히 하려는 설명에 가깝다.
새롭게 등장한 WebFlux는 Spring MVC의 대체가 될 수 있을까?
2017년 스프링팀이 WebFlux라고 하는 것을 내놓았다. WebFlux는 스프링이 제공하는 또 다른 웹 스택이다. 핵심은 실행 모델이 서블릿 중심의 동기/스레드 모델과 같지 않을 수 있다는 점이다. 논블로킹 I/O와 리액티브 스트림에 기대는 설계는 특정 부하 패턴에서 이점을 낼 수 있다. 그러나 이점은 "프레임워크를 바꿨다"만으로는 자동으로 따라오지 않는다. 데이터 접근이 JDBC처럼 블로킹 호출로 이어지면, 중간에서 스레드가 막히는 구조가 그대로 남는 경우가 많다. 반대로 팀 전체가 리액티브 체인 디버깅에 익숙하지 않다면 운영 비용이 올라가기도 한다.
그래서 WebFlux를 Spring MVC의 대체라고 단정하기 어렵다. 둘은 같은 스프링 생태계 안에서 공존하는 선택지에 가깝다. 처음에 개발자 커뮤니티에선 WebFlux를 Spring MVC의 대체재로 여겨왔으나, 결국에는 공존하는 방식으로 선회하게 되었다. Spring MVC는 서블릿 기반 구조, 예측 가능한 7단계 요청 흐름, 다양한 뷰 기술과의 결합으로 그 예측 가능성이 크다. 이것들이 무슨 이야기인가에 대해서는 잠시 후에 다루겠다. 여하튼, WebFlux는 이벤트 루프와 논블로킹에 최적화된 경로를 택할 때 설계 의도가 분명해진다. 많은 개발자 지망생들이 간과하는 것인데, 기술 선택은 반드시 최신의 것이 아니라, 트래픽 형태, 외부 의존성의 블로킹 여부, 팀 역량, 운영 관측 가능성을 함께 본 뒤에 정리되는 편이 안전하다.

실무 TIP
기술 선택을 한 줄로 줄이면 "지금 당장 팀이 운영 가능한가"가 된다. WebFlux는 장점이 있지만, JDBC처럼 블로킹 호출이 중간에 끼면 이점이 줄어드는 경우가 많다. 반대로 단순히 동시성 이슈가 있다고 해서 WebFlux가 정답은 아니고, 대개는 DB 쿼리와 외부 API 호출 구조를 먼저 본다. 또 레거시 라이브러리가 동기 API 위주라면 스택을 바꿔도 병목이 그대로 드러나는 경우가 많으니, 성능 이슈는 "프레임워크 교체" 전에 측정과 프로파일링으로 원인을 좁히는 편이 비용 대비 효과가 크다.
2. Spring MVC 패턴의 이점
위와 같이 아직도 실무에서 유효한 Spring MVC 패턴의 이점은 크게 세 가지로 나눌 수 있다. 1) 명확한 분업과 전문성, 2) 코드의 재사용성과 확장성, 3) 쉬운 테스팅. 이러한 이점들에 대해서 알아보는 것이 곧 Spring MVC 패턴의 동작에 대해서 아는 것에 도움이 될 것이다.
명확한 분업과 전문성
Spring MVC는 컨트롤러, 서비스, 리포지토리 같은 이름으로 역할을 나누기 쉬운 틀을 제공한다. 여기서 분업은 파일을 잘게 쪼갠다는 뜻만이 아니다. 웹 요청을 해석하고 입력값을 받아들이는 책임, 도메인 규칙을 실행하는 책임, DB나 외부 시스템에 접근하는 책임을 분리하려는 의도가 분명해진다. 이렇게 나누면 변경 이유가 다른 코드가 한 파일에 뒤섞일 확률이 줄어든다.
다만 레이어를 나눴다고 끝이 아니다. 컨트롤러에 비즈니스 규칙이 들어가거나, 서비스가 HttpServletRequest에 직접 의존하거나, 뷰에서 조건 분기로 규칙을 다시 만들기 시작하면 분업의 이점이 금방 사라진다. 이때 필요한 처방은 프레임워크 교체가 아니라 책임의 재배치다. 실무에서는 "이 메서드가 바뀌는 이유가 무엇인가"를 한 문장으로 말할 수 있게 만드는 연습이 구조를 살린다.
코드의 재사용성과 확장성
재사용성은 종종 라이브러리 함수를 공유하는 문제로만 들리지만, 웹에서는 공통 처리의 위치가 더 중요하다. 인코딩, 로깅, 인증 흐름의 일부, 예외의 표준 응답 같은 횡단 관심사를 컨트롤러마다 복붙하지 않게 만드는 것이 재사용의 중심에 선다. 스프링 MVC는 필터, 인터셉터, 컨트롤러 어드바이스 같은 확장 지점을 제공하고, 그 지점에 공통 로직을 올릴 수 있다.
확장성은 기능이 늘어날 때 수정 범위가 어디로 퍼지는지의 문제다. URL 규칙과 뷰 연결 규칙이 팀에서 합의된 방식으로 유지되면, 화면이 늘어나도 "어디를 봐야 하는지"가 같아진다. 반대로 규칙이 흔들리면 작은 요구사항 하나가 여러 레이어를 동시에 건드리게 되고, 리뷰 비용과 결합도가 같이 올라간다.
쉬운 테스팅
스프링은 DI를 전제로 하기 때문에, 컨트롤러와 서비스를 테스트할 때 의존 객체를 대체하기가 상대적으로 쉬운 편이다. 컨트롤러는 MockMvc 같은 도구로 HTTP 요청을 흉내 내어 매핑과 반환값을 검증할 수 있다. 서비스는 순수 자바 테스트에 가깝게 유지하려는 시도가 가능해진다. 테스트가 쉬워진다는 말은 "자동화가 즐거워진다"는 뜻만이 아니라, 설계가 테스트 가능한 방향으로 남았다는 신호이기도 하다.
다만 테스트가 어렵다면 구조만 탓하기 전에 의존성부터 본다. 정적 호출, 숨은 싱글톤, 컨트롤러에 박힌 트랜잭션 경계 같은 요소가 섞이면 테스트는 금방 비대해진다. 이때는 레이어를 나눈다는 선언보다, 의존성을 주입 가능한 형태로 되돌리는 작업이 먼저다.

실무 TIP
코드 리뷰에서 자주 보이는 신호는 컨트롤러 메서드가 길어지고, DB 호출이 컨트롤러에 직접 들어가며, 같은 검증 로직이 여러 컨트롤러에 복붙되는 패턴이다. 이때는 프레임워크가 나빠서가 아니라 역할 경계가 무너진 상태로 보는 편이 수정도 빠르다. 또 신규 화면을 만들 때마다 "공통 예외 처리"를 각자 다르게 구현하고 있지 않은지도 점검하면 좋다. 공통 응답 포맷이 흔들리면 프런트엔드와의 계약도 같이 흔들리기 쉽다.
3. Spring MVC, 모델 1과 모델 2
앞서 Spring MVC의 이점이 "명확한 분업"에 있다고 했다. 그렇다면 그 분업이 생기기 전에는 어떤 방식이었을까. 모델 1과 모델 2는 바로 그 전후를 설명하는 이름이다. Spring MVC가 지금의 구조를 갖게 된 배경을 이해하는 데 이 대비만큼 직관적인 설명이 없기도 하고, 수업에서도 한 번 다루고 넘어간 내용이므로, 여기서 한 번 짚고 넘어가려 한다.
모델 1과 모델 2의 탄생 배경
초기 JSP 중심 개발에서는 화면 파일 안에 자바 코드가 섞이기 쉬웠다. 스크립트릿으로 DB 연결과 조회까지 한 파일에서 처리하면, 처음에는 빠르게 결과를 낼 수 있다. 그러나 요구사항이 늘고 화면이 많아질수록 어디를 고치면 이 화면이 바뀌는지가 파일 곳곳에 흩어진다. 변경의 이유가 화면 변경인지 규칙 변경인지 데이터 접근 변경인지 구분이 어려워지고, 동일한 조회 로직이 여러 JSP에 복제되기도 한다.
이런 비용을 줄이려고 등장한 설명이 모델 1과 모델 2의 대비다. 이름 자체보다 중요한 건 "처리의 중심이 어디에 있느냐"다. 처리의 중심이 JSP에 있으면 화면이 컨트롤러 역할까지 떠안기 쉽고, 처리의 중심이 컨트롤러에 있으면 화면은 표현에 집중할 수 있다.
모델 1의 방식
모델 1은 JSP가 요청을 받아 흐름을 주도하는 형태에 가깝다. 작은 예제에서는 단순해 보이지만, 로직이 JSP에 누적되면 유지보수가 어려워진다. 특히 조건 분기와 데이터 접근이 화면 코드에 섞이면, 화면 수정이 비즈니스 로직 수정으로 이어지기 쉽다. 테스트 관점에서도 JSP는 단위 테스트를 걸기 어렵고, 재사용 단위를 뽑기도 힘들다.

모델 1이 항상 틀렸다는 뜻은 아니다. 프로토타입이나 내부 도구처럼 수명이 짧고 변경이 드문 경우에는 속도가 이점이 될 수 있다. 다만 수업 이후 실무로 넘어가면 변경이 잦고 책임이 커지는 경우가 많아서, 모델 2 쪽이 기본 선택지로 설명되는 경우가 많다.
모델 2의 방식
모델 2는 컨트롤러(또는 그에 해당하는 서블릿)가 요청을 받고, 모델을 준비한 뒤 뷰로 넘기는 흐름에 가깝다. 화면은 표현에 집중하고, 흐름의 중심이 컨트롤러 쪽으로 이동한다. 이렇게 나누면 "요청을 해석하고 응답을 결정하는 코드"가 한곳에 모이기 쉬워진다. 또 뷰 기술을 JSP에서 다른 템플릿으로 바꾸더라도 컨트롤러의 책임은 상대적으로 안정적으로 유지할 수 있다.

모델 2가 만능은 아니다. 컨트롤러가 비대해지면 또 다른 형태의 혼잡이 생긴다. 그래서 실무에서는 컨트롤러를 얇게 유지하고 서비스로 규칙을 내리는 방식이 반복해서 등장한다. 모델 2는 "분리의 방향"을 제시하고, 그 안에서 다시 책임을 쪼개는 문제가 이어진다.
Spring MVC는 왜 모델 2를 골랐는가
Spring MVC는 기본적으로 요청을 중앙에서 받아 흐름을 조율하고, 컨트롤러가 처리한 결과를 뷰로 연결하는 모델 2의 정신에 가깝게 설계되어 있다. 여기에 프런트 컨트롤러인 DispatcherServlet을 얹어 공통 단계까지 중앙에서 처리하려는 쪽으로 발전했다. JSP를 쓰든 템플릿 엔진을 쓰든, "뷰가 로직을 끌고 가지 않게 만드는 쪽"이 유지보수에 유리하다는 판단이 반영된 결과로 이해하면 된다.

수업에서 흔히 하는 JDBC 예제나 상품 조회 흐름도 같은 맥락으로 읽을 수 있다. 컨트롤러가 요청 파라미터를 받고 서비스나 DAO를 호출한 뒤, 결과를 모델에 담아 JSP로 넘긴다면 모델 2의 흐름을 스프링 컴포넌트 이름으로 고정한 형태에 가깝다.
실무 TIP
레거시 JSP가 모델 1에 가깝게 굳어 있는 프로젝트는 "한 번에 다 고치기"보다, 새 기능부터 컨트롤러로 흐름을 모으는 방식이 현실적이다. 화면 한 장의 책임을 줄이는 것부터 시작하면 충돌이 적다. 또 화면에 남아 있는 스크립트릿을 줄이는 작업은 단순히 보기 싫아서가 아니라, 보안과 출력 이스케이프 관점에서도 이점이 있다. 출력 직전에 데이터를 정제하는 책임이 어디에 있는지 팀에서 합의해 두면 XSS 대응도 수월해진다.
4. DispatcherServlet의 동작 원리
모델 2를 스프링 컴포넌트로 고정한 것이 Spring MVC라고 했다면, 그 중심에 있는 것이 바로 DispatcherServlet이다. 이 절에서는 DispatcherServlet이 프런트 컨트롤러로서 어떻게 동작하는지, 그리고 한 번의 요청이 실제로 어떤 단계를 거치는지를 순서대로 따라가 본다.
프런트 컨트롤러(Front Controller) 패턴의 등장
프런트 컨트롤러는 모든 요청이 거치는 단일 진입점을 만든다. 인증이 필요한지, 로깅을 어떻게 남길지, 인코딩을 어디서 맞출지 같은 공통 정책을 분산된 서블릿마다 복붙하지 않고 한곳에서 정리할 수 있다. 개발자는 그 뒤에서 비즈니스에 해당하는 핸들러만 작성하면 된다.

서블릿 컨테이너는 여전히 요청을 받는 주체이다. 톰캣 같은 WAS는 연결을 받고 스레드를 배분하며, DispatcherServlet은 그중 하나의 서블릿으로 등록되어 특정 URL 패턴으로 들어오는 요청을 처리한다. 즉 "스프링이 톰캣을 대체한다"가 아니라, 톰캣이 서블릿을 실행하고 그 서블릿이 스프링 MVC의 관문 역할을 한다고 이해하면 흐름이 덜 헷갈린다.
DispatcherServlet의 7단계
7단계라는 말은 교재마다 이름이 조금씩 다를 수 있다. 여기서는 스프링 공식 문서와 수업에서 자주 다루는 흐름을 기준으로 정리한다. 단계 이름을 외우는 것보다, "어느 단계에서 어떤 결정이 내려지는가"를 잡아두는 것이 디버깅에 더 도움이 된다.
- 첫째, 요청 수신(Request)이다. 클라이언트의 HTTP 요청이 DispatcherServlet에 도달한다. 필터가 설정되어 있다면 이보다 먼저 실행된다.
- 둘째, LocaleResolver·ThemeResolver·MultipartResolver 등 보조 리졸버가 요청을 전처리한다. 한글 깨짐이나 파일 업로드가 여기서 처리된다.
- 셋째, HandlerMapping이 요청 URL과 HTTP 메서드에 맞는 핸들러(컨트롤러 메서드)를 결정한다.
- 넷째, HandlerAdapter가 선택된다. 찾아낸 핸들러를 실행할 수 있는 어댑터를 고르는 단계다.
- 다섯째, 인터셉터의 preHandle이 실행된다. 로그인 확인 같은 전처리 로직을 여기에 둘 수 있다.
- 여섯째, 핸들러(컨트롤러 메서드)가 실행되고 ModelAndView 또는 응답 객체를 반환한다.
- 일곱째, 뷰가 필요하면 ViewResolver가 논리 이름을 실제 뷰로 연결하고 렌더링한다. @ResponseBody라면 메시지 컨버터가 응답 본문을 만든다.

💡 토막 개념: 메시지 컨버터(Message Converter)
자바 객체와 HTTP 요청/응답 본문(Body) 사이의 데이터 변환을 자동으로 처리해주는 컴포넌트이다. 주로 @RequestBody나 @ResponseBody 어노테이션이 사용될 때 활성화되며, HTTP 헤더의 'Content-Type' 정보를 참조하여 JSON, XML, 문자열 등 적절한 데이터 형식으로 변환을 수행한다.
여기에 예외가 터지면 예외 처리 전략이 개입한다. 운영에서 중요한 건 숫자 7을 외우는 일이 아니라, 문제가 났을 때 매핑 전에 실패했는지, 실행 중에 실패했는지, 뷰 해석에서 실패했는지를 가르는 것이다. 같은 404라도 원인은 매핑 누락일 수 있고, 뷰 파일 누락일 수 있고, 정적 리소스 설정 문제일 수 있다.
필터와 서블릿의 순서도 이에 포함해서 생각하면 좋다. 인코딩 필터나 보안 필터가 DispatcherServlet보다 먼저 실행된다면, 컨트롤러에 도달하기 전에 요청이 바뀌거나 차단될 수 있다. 반대로 컨트롤러까지는 정상인데 응답만 이상하다면 뷰 렌더링 이후의 처리나 응답 헤더 설정을 의심한다. 즉 DispatcherServlet은 중심축이지만, 요청은 WAS의 더 바깥 레이어에서 이미 여러 번 가공될 수 있다는 점을 잊지 않는 것이 장애 분석에 도움이 된다.
💡 토막 개념: 필터(Filter)와 인터셉터(Interceptor)
필터와 인터셉터는 둘 다 횡단 관심사을 처리하기 위해서 요청을 가로채지만, 엄밀히 말해서 차이가 있다. 필터는 자바 서블릿(Servlet) 스펙의 일부로서, 스프링 밖에 있는 반면, 인터셉터의 경우 스프링 MVC 스펙의 일부로서 DispatcherServlet과 컨트롤러 사이에 존재한다. 원천적인 보안은 필터에서, 비즈니스와 관련된 세밀한 제어는 인터셉터에서 처리하는 것이 일반적이다.
실무 TIP
문제가 났을 때는 "컨트롤러 메서드에 브레이크포인트가 찍히는지"부터 확인하면 시간을 아낀다. 찍히지 않으면 매핑 단계에서 이미 어긋난 것이고, 찍히는데 화면이 이상하면 뷰 해석이나 반환 타입 쪽을 보면 된다. 로그 레벨을 올려 DispatcherServlet 주변의 매핑 로그를 보는 것도 방법이다. 개발 환경에서만 상세 로그를 켜 두고, 운영에서는 민감 정보가 섞이지 않게 출력 항목을 정하는 편이 안전하다.
5. 주요 컴포넌트와 어노테이션
앞서 DispatcherServlet이 핸들러를 찾고 실행한다고 했다. 그렇다면 그 핸들러는 어떻게 만들어지는가. 스프링 MVC에서는 어노테이션이 그 연결 고리 역할을 한다. 이번 절에서는 어노테이션이 무엇인지 짧게 짚고, Spring MVC에서 실제로 자주 쓰는 것들을 하나씩 살펴본다.
어노테이션이란?
어노테이션은 소스 코드에 부가 정보를 붙이는 문법이다. 런타임에 리플렉션으로 읽히거나, 컴파일 시점에 처리되기도 한다. 스프링은 이런 부가 정보를 읽어 빈 정의를 만들고, 웹 요청 매핑 정보를 수집한다. 개발자가 XML에 적어두던 내용을 클래스 옆으로 옮긴다고 보면 이해가 빠르다.

어노테이션의 등장 배경
과거에는 bean 정의와 매핑 정보가 XML에 몰리는 경우가 많았다. 파일이 길어질수록 이 URL이 어떤 클래스와 연결되는지를 추적하는 비용이 커진다. 어노테이션은 코드 옆에 의도를 붙인다는 장점이 있다. 다만 어노테이션이 많아지면 오히려 시야를 가릴 수 있으니, 팀 규칙으로 패키지 구조와 네이밍을 정리하는 경우가 많다.
또 어노테이션은 마법이 아니다. 스프링 컨테이너가 초기화될 때 스캔과 메타데이터 수집이 끝나야 실제 매핑이 완성된다. 그래서 어노테이션을 붙였는데 반응이 없다면 컴포넌트 스캔 범위, 설정 파일 로딩, 빈 등록 여부를 먼저 의심하는 편이 빠르다.
@Controller
컨트롤러 역할의 빈임을 표시한다. 웹 요청 처리의 진입점으로 쓰이는 클래스에 붙인다. 스테레오타입의 일종으로, 내부적으로는 스프링이 컴포넌트로 인식해 등록한다는 점을 기억하면 DI 파트와 연결된다.
import org.springframework.stereotype.Controller;
@Controller
public class ProductController {
}
@RestController
@RestController는 @Controller에 API 응답을 기본으로 실어 두는 편의다. 클래스에 붙이면 메서드 반환이 뷰 이름이 아니라 메시지 컨버터를 통해 응답 본문으로 직렬화되는 흐름이 기본이 된다. 내부적으로는 @Controller와 @ResponseBody의 조합으로 이해하는 설명이 흔하다.
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProductApiController {
}
@RequestMapping
클래스나 메서드에 URL과 HTTP 메서드 조건을 붙일 때 사용한다. 클래스 레벨에 공통 prefix를 두고 메서드 레벨에 세부 경로를 두는 방식이 자주 쓰인다. GET만 받고 싶다면 @GetMapping 같은 축약 어노테이션으로 표현할 수 있다.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/product")
public class ProductController {
@GetMapping("/list")
public String list() {
return "product/list";
}
}
컨트롤러 메서드의 시그니처는 스프링이 인자를 채워 넣을 수 있게 설계할 수 있다. 예를 들어 @RequestParam으로 쿼리를 받거나, @PathVariable로 경로 변수를 받는 식이다. 이 바인딩은 컨트롤러 코드가 짧아지게 만들지만, 검증 규칙이 커지면 별도 계층으로 분리하는 논의가 따라온다.
실무 TIP
JSON을 기대했는데 HTML이 나오거나, 뷰 이름이 그대로 텍스트로 보이는 경우는 종종 @Controller와 @RestController의 역할을 혼동해서 생긴다. 이때는 반환 타입과 메시지 컨버터 흐름을 같이 보면 원인이 빨리 좁혀진다. 또 같은 프로젝트 안에서 화면용 컨트롤러와 API용 컨트롤러를 패키지로 나누면 리뷰 시 혼선이 줄어든다. URL 설계에서 복수형과 단수형, trailing slash 같은 규칙을 팀에서 고정해 두면 매핑 누락도 줄어든다.
6. HandlerMapping / HandlerAdapter
앞에서 DispatcherServlet이 핸들러를 찾고 실행한다는 큰 그림을 잡았다면, 이번 절에서는 그 "찾기"와 "실행하기"가 실제로 어떤 객체에 의해 이루어지는지를 더 들여다본다. 스프링 내부에서 이 역할을 나눠 맡는 것이 HandlerMapping과 HandlerAdapter다.

HandlerMapping
HandlerMapping은 들어온 요청의 URL, HTTP 메서드, 헤더 조건 같은 특성을 기반으로 "이번 요청을 처리할 핸들러가 누구인가"를 결정하는 인터페이스다. DispatcherServlet은 초기화 시점에 등록된 HandlerMapping 목록을 순서대로 조회하고, 첫 번째로 매칭에 성공한 결과를 사용한다. 매핑은 단일 테이블이 아니라 여러 전략이 체인처럼 연결된 구조이기 때문에, 등록된 순서와 우선순위가 의도한 핸들러가 선택되는지에 영향을 준다.
실무에서 가장 자주 쓰이는 구현체는 RequestMappingHandlerMapping이다. @RequestMapping, @GetMapping 같은 어노테이션이 붙은 메서드를 애플리케이션 시작 시점에 스캔해 내부 매핑 테이블로 구성해 둔다. 즉 어노테이션을 붙이는 순간 매핑이 완성되는 게 아니라, 컨테이너 초기화가 끝나야 비로소 테이블이 완성된다. "어노테이션을 붙였는데 반응이 없다"면 이 초기화 단계를 먼저 의심해야 한다.
@Controller
@RequestMapping("/product")
public class ProductController {
@GetMapping("/{id}")
public String detail(@PathVariable Long id, Model model) {
// /product/1 요청 → 이 메서드가 선택된다
return "product/detail";
}
}
"더 구체적인 매핑이 이긴다"는 직관은 대부분 통하지만, 충돌이 생기면 매핑 우선순위를 확인해야 한다. 특히 정적 리소스 서빙과 컨트롤러 매핑이 같은 경로를 공유할 때 의도치 않은 핸들러가 선택되는 경우가 있다.
HandlerAdapter
HandlerMapping이 "무엇을 실행할지"를 결정했다면, 다음 문제는 "어떻게 실행할지"다. 스프링 MVC의 역사에서 컨트롤러 형태는 여러 번 바뀌었다. Controller 인터페이스를 구현하는 방식, HttpRequestHandler, 그리고 지금의 어노테이션 기반 @RequestMapping 메서드까지 다양한 형태가 공존해 왔다. DispatcherServlet이 이들을 직접 호출하려 하면, 형태가 바뀔 때마다 DispatcherServlet 코드도 같이 수정해야 하는 문제가 생긴다.
HandlerAdapter는 이 문제를 어댑터 패턴으로 해결한다. DispatcherServlet은 실행을 어댑터에 위임하고, 어댑터가 실제 핸들러 호출 방식을 내부에서 처리한다. 어댑터는 supports() 메서드로 자신이 처리할 수 있는 핸들러 타입인지 확인하고, handle() 메서드로 실행한 뒤 ModelAndView를 반환한다. DispatcherServlet은 이 약속만 지키면 되고, 핸들러가 어떤 형태인지는 알 필요가 없어진다.
💡 토막 개념: 어댑터 패턴(Adapter Pattern)
호환되지 않는 인터페이스를 가진 객체들이 함께 작동할 수 있도록 연결해 주는 구조적 디자인 패턴이다. 클라이언트가 기대하는 인터페이스와 실제 구현체의 인터페이스가 다를 때 중간에서 이를 맞춰주는 역할을 한다.
가장 자주 쓰이는 구현체는 RequestMappingHandlerAdapter다. @RequestMapping 메서드의 파라미터 바인딩, 반환값 처리, 메시지 컨버터 적용이 모두 이 어댑터 안에서 이루어진다. @RequestParam, @PathVariable, Model 인자, @ResponseBody 같은 편의 기능이 아무런 설정 없이 동작하는 이유가 여기 있다. 새로운 형태의 핸들러가 필요해지면 어댑터만 추가하는 확장이 가능하고, DispatcherServlet은 건드릴 필요가 없다.
실무 TIP
404는 "매핑이 없다"에 가깝고, 405는 "매핑은 비슷하지만 HTTP 메서드가 맞지 않다"에 가까운 경우가 많다. 증상만 보면 비슷해 보여서, 개발자 도구에서 요청 메서드와 URL을 먼저 확인하는 습관이 도움이 된다. 또 스프링 부트가 아닌 전통적인 XML 설정에서는 서블릿 매핑과 스프링 MVC 설정이 분리되어 있어 실수가 나기도 한다. 이때는 web.xml의 DispatcherServlet url-pattern과 실제 요청 URL이 일치하는지부터 확인한다.
7. 컨트롤러 · Model · 반환
지금까지 요청이 DispatcherServlet에 도달하고, HandlerMapping이 핸들러를 고르고, HandlerAdapter가 실행하는 흐름을 따라왔다. 이번 절은 그 흐름의 마지막 단계다. 핸들러, 즉 컨트롤러 메서드가 실행된 뒤 데이터를 어디에 담고, 어떤 방식으로 응답이 만들어지는지를 Model·View·ViewResolver의 관계로 정리한다.
로직 후에는 어떻게 해야할까?
컨트롤러는 보통 서비스 호출로 로직을 끝낸 뒤, 화면에 필요한 값을 모델에 담고 뷰 이름을 반환한다. API라면 응답 본문으로 직렬화할 객체를 반환한다. 즉 로직 이후는 응답 형태를 결정하는 단계다. 이 지점에서 흔한 실수는 컨트롤러가 성공 응답만 만들고 예외 상황을 화면까지 일관되게 전달하지 않는 경우다. 예외 처리 전략을 팀에서 정하면 공통 응답 포맷이 안정된다.
Model의 역할과 그 위치
Model은 컨트롤러에서 뷰로 데이터를 넘기기 위한 저장소 역할에 가깝다. 요청 스코프에서 의미가 분명해지도록 키 이름을 일관되게 쓰는 것이 중요하다. ModelMap이나 ModelAndView를 쓰는 경우도 있지만, 역할은 같다. "뷰는 어떤 이름으로 어떤 객체를 읽는가"가 계약이 되므로, 키 이름이 흔들리면 JSP나 템플릿 수정이 불필요하게 커진다.
import org.springframework.ui.Model;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ProductController {
@GetMapping("/product/view")
public String view(Model model) {
model.addAttribute("title", "상품");
return "product/view";
}
}
View의 역할과 그 위치
뷰는 응답을 만드는 마지막 표현 단계다. JSP라면 템플릿이 렌더링되고, 다른 뷰 기술이라도 역할은 같다. 중요한 건 뷰가 비즈니스 규칙을 새로 만들지 않게 경계를 유지하는 것이다. 화면에서 조건 분기가 늘어날수록 화면이 규칙을 떠안기 쉬우므로, 가능한 규칙은 서비스 계층에서 결정하고 화면은 표현만 담당하게 만드는 쪽이 유지보수에 유리하다.
ViewResolver?
ViewResolver는 컨트롤러가 반환한 논리 뷰 이름을 실제 뷰 구현으로 연결한다. 접두어와 접미어 설정이 여기서 의미를 가진다. 예를 들어 논리 이름이 product/result이고 접두어가 /WEB-INF/views/, 접미어가 .jsp라면 두 값을 조합해 /WEB-INF/views/product/result.jsp로 완성된다. 수업에서 사용하는 XML 설정 기준으로는 다음과 같은 형태가 된다.
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/views/" />
<property name="suffix" value=".jsp" />
</bean>
이 설정 덕분에 컨트롤러는 뷰 파일의 전체 경로를 직접 다룰 필요가 없다. 환경별로 접두어만 바꾸면 개발과 운영의 뷰 위치를 분리할 수도 있다.
@ResponseBody와 RestController의 사용
@ResponseBody는 반환값을 뷰 이름이 아니라 HTTP 응답 본문으로 직렬화하라는 뜻에 가깝다. @RestController는 이런 성격을 클래스에 기본값으로 실어 두는 편의다. 직렬화는 객체의 필드와 Content-Type, 메시지 컨버터 선택에 영향을 받는다.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.stereotype.Controller;
@Controller
public class ProductController {
@GetMapping("/product/api/ping")
@ResponseBody
public String ping() {
return "ok";
}
}
화면 이동이 필요한 경우 redirect: 접두어를 쓰는 패턴도 자주 등록한다. PRG(Post-Redirect-Get)처럼 POST 처리 후 GET으로 화면을 분리하면 새로고침으로 동일 POST가 반복되는 문제를 줄일 수 있다. 이 흐름은 모델 2와 서블릿 시절부터 이어지는 실무 관례이기도 하다고.
실무 TIP
가장 흔한 혼란은 JSON을 기대했는데 뷰 리졸버가 이름을 해석해 JSP를 찾으려 한다든지, 반대로 화면을 기대했는데 문자열이 그대로 응답 본문으로 나가는 경우다. 이때는 컨트롤러 타입과 반환 어노테이션, 메시지 컨버터 여부를 한 묶음으로 보면 원인이 정리된다. 또 뷰 파일이 실제로 존재하는지, 대소문자가 운영 서버와 다른지도 점검한다. 윈도우 개발 환경과 리눅스 배포 환경에서 경로 민감도가 달라 문제가 드러나는 경우가 있다.
8. 마무리하며
이번 포스팅에서는 Spring MVC가 왜 등장했는지부터 시작하여 모델 1과 모델 2의 차이, DispatcherServlet이 요청을 처리하는 7단계, HandlerMapping과 HandlerAdapter의 역할 분리, 그리고 컨트롤러가 로직을 끝낸 뒤 Model과 ViewResolver를 거쳐 응답이 완성되기까지의 흐름을 따라가 보았다. 중요한 파트라서 평소보다 양이 많아졌다. Spring MVC는 이름이나 어노테이션보다 "왜 이런 구조로 설계되어 있는가"를 이해하는 쪽이 훨씬 오래 남을 거라고 생각한다.
다음 포스팅은 아마 MyBatis가 될 것 같다. 컨트롤러가 서비스를 호출하고, 서비스가 DAO를 부르고, 그 DAO가 DB와 어떻게 대화하는지를 다루는 내용이 될 거 같은데 이번에도 상황에 따라서 달라질 수도 있어보인다.
이미지 출처
※ 모든 이미지는 직접 제작하거나 저작권 문제가 없는 이미지로 제작되었습니다.
'부트캠프 일지 > 멀티캠퍼스 TIL' 카테고리의 다른 글
| [Java 풀스택 개발자] 백엔드 프로그래밍 편:백엔드 트랜잭션 (0) | 2026.04.21 |
|---|---|
| [Java 풀스택 개발자] 백엔드 프로그래밍 편:스프링 부트 (0) | 2026.04.14 |
| [Java 풀스택 개발자] 백엔드 프로그래밍 편: 스프링 편 (0) | 2026.03.30 |
| [Java 풀스택 개발자] 백엔드 프로그래밍 편: 백엔드의 기초 파악하기 (0) | 2026.03.24 |
| [Java 풀스택 개발자] 시큐어 코딩편: 개발자가 알아야 할 웹 보안 (0) | 2026.03.17 |