* 해당 포스팅은 김영한 강사님의 스프링 MVC 강의 1편을 보고 정리한 것입니다.
MVC 패턴이란?
MVC는 Model, View, Controller를 줄인 말이다. 그럼 Model, View, Controller는 또 뭘까?
Model
- 뷰에 출력할 데이터를 담아둔다. 모델은 뷰에게 필요한 모든 데이터를 담아서 전달해주고 이 덕분에 뷰는 비즈니스 로직이나 데이터 접근에 신경쓰지 않고 화면 렌더링에 집중할 수 있다.
View
- 모델에 담겨 있는 데이터를 사용해서 화면을 그리는 일에 집중한다.
Controller
- HTTP 요청을 받아서 파라미터를 검증하고, 비즈니스 로직을 실행한다. 그리고 뷰에 전달할 결과 데이터를 조회해서 모델에 담아두는 역할을 한다. 즉 HTTP 요청을 받고 파라미터를 검증한 다음 비즈니스 로직 실행 후에 모델에 데이터를 전달하는 역할을 하는 것이다.
MVC 패턴을 이해하기 위해서 스프링을 사용하지 않고 서블릿을 이용해서 MVC를 구현해본다고 생각하자.
우리가 만들고자 하는 웹 애플리케이션은 회원을 등록하면 이를 저장시키고 회원 목록에서 이를 조회할 수 있는 것이다.
가장 기초적인 MVC 패턴부터 해당 패턴의 문제점을 파악하고 발전시켜 보면서 어떻게 현재 스프링 MVC 패턴이 만들어졌는지 알아보도록 하자.
MVC 패턴이 왜 필요하게 된 것일까?
가장 주된 이유는 유지보수를 위해서이다. 한 곳에 비즈니스 로직부터 뷰 렌더링까지 모두 처리하게 된다면, 그곳에서 너무 많은 역할을 수행하게 되고 결과적으로는 유지보수하기가 힘들어진다. 또한 UI 수정과 같이 뷰와 관련된 로직과 비즈니스 로직의 수정이 서로 다르게 발생할 가능성이 높다는 것이다. 즉 변경의 라이프 사이클이 다르다는 것인데, 이렇게 라이프 사이클의 변경이 다른 부분을 하나의 코드로 관리하는 것은 유지보수에 좋지 않다.
1.

가장 기초적인 MVC 패턴은 위와 같다. 클라이언트가 호출하면, 이를 컨트롤러 로직에서 인식하고 비즈니스 로직에 접근한뒤 데이터를 모델에 넘겨둔다. 그리고 뷰 로직에서 모델에 있는 데이터를 가지고 응답하는 형태이다.
그런데 이 MVC 패턴에는 문제가 있다. 각 컨트롤러마다 중복된 로직이 존재한다는 것이다. 즉 공통으로 처리해야할 로직이 존재하지만 각각의 컨트롤러에서 이를 다루고 있다.
컨트롤러마다 존재하는 공통된 로직을 처리하는 곳이 필요하다.
2.

Front Controller는 HTTP 요청을 가장 먼저 받아서 요청에 맞는 컨트롤러를 매핑 정보를 통해 호출해준다. 그리고 호출된 컨트롤러는 JSP를 호출하고 HTML 형태로 응답한다.
하지만 여기서는 각각의 컨트롤러가 직접 view path를 설정하고 포워딩하는 로직을 수행한다. 이 부분도 중복되고 있다는 것이다.
컨트롤러가 직접 view로 포워딩시키고 있기 때문에 이 로직을 수행할 존재가 필요하다.
3.

그래서 MyView라는 view가 만들어졌다. 컨트롤러가 바로 JSP로 포워딩하는 것이 아니라 MyView를 반환하고, MyView에서 JSP로 포워딩시키는 것이다.
어느 정도 문제가 사라진 것 처럼 보이지만 Controller들이 서블릿을 사용하는 것이 아닌데도 아직 서블릿에 종속되어 있다. 사실 HTTP 요청을 받는 Front Controller만 실질적으로 서블릿을 사용하고 있기 때문에 Controller에 서블릿 객체가 아닌 다른 객체를 사용해서 매핑 정보를 전달하고 받는다면 어떨까? 그리고 viewpath를 보다 쉽게 관리하기 위해 논리적 주소를 컨트롤러로 부터 반환받고, 이를 물리적 주소로 변환해주는 객체를 만들어준다면 유지 보수가 더 편해지지 않을까?
4.

컨트롤러는 이제 Map 자료구조를 사용해서 데이터를 저장할 모델을 만들고 관리한다. 또한 viewResolver를 통해서 논리적 주소를 실제 주소로 바꿔주도록 하였다. ModelView 객체는 컨트롤러의 데이터를 전달하고 viewName을 저장할 수 있는 필드 값을 가지고 viewResolver에게 전달해주는 역할을 수행해준다. 그런데 컨트롤러가 ModelView를 항상 반환하는 것보다 ViewName을 바로 반환한다면 더 편하지 않을까?
5.

Controller가 ModelView를 반환하지 않고 바로 ViewName을 반환해서 이를 ViewResolver를 통해 실제 주소로 변환시키고 랜더링을 통해 뷰를 호출하게 했다. Front Controller에서 Controller를 호출할 때 모델 자체를 파라미터로 전달해 더 사용하기 편리하게 만든 것이다.
6.

지금까지 만든 Front Controller는 사용할 수 있는 컨트롤러 인터페이스가 한가지였다. FrontController에서 바로 Controller로 접근했기 때문에 당연한 것인데, 이를 더 다양한 컨트롤러 인터페이스를 사용할 수 있도록 하면 편하지 않을까?
그래서 컨틀롤러 매핑 정보를 조회해 다양한 컨트롤러를 조회할 수 있는 어댑터 패턴을 접목해보도록 하겠다. FrontController가 컨트롤러로 바로 연결되는 것이 아니라 어댑터를 거치게 된다. 먼저 핸들러 매핑 정보를 통해 핸들러를 조회하고, 해당 핸들러를 처리할 수 있는 핸들러 어댑터를 조회한다. 조회된 핸들러 어댑터를 거쳐 핸들러(컨트롤러)가 호출되게 된다. 핸들러 어댑터는 ModelVeiw를 반환하기 때문에 ViewName을 반환하는 컨트롤러와 ModelView를 반환하는 컨트롤러 모두 사용할 수 있다. 핸들러는 컨트롤러의 개념을 포함하면서 다른 종류의 일을 추가로 수행한다.
이렇게 직접 MVC 패턴이 어떻게 발전되었는지 간단하게 알아보았다. 그럼 진짜 스프링 MVC는 어떻게 생겼을까?
스프링 MVC 구조

스프링 MVC는 위와 같이 이루어져 있다. 우리가 직접 만들어봤던 MVC 모델과 굉장히 유사하다는 것을 알 수 있다.
DispatcherServlet이 FrontController의 역할을 수행한다.
- 스프링 부트가 서블릿으로 자동 등록하고 모든 경로 urlPatter = “/”에 대해서 매핑하기 때문에 맨 처음 요청을 받게 된다.
동작 순서
- 핸들러 조회
- 요청 URL에 매핑된 핸들러(컨트롤러)를 핸들러 매핑을 통해 조회한다
- 핸들러 어댑터 조회
- 핸들러를 실행할 수 있는 핸들러 어댑터를 조회한다.
- 핸들러 어댑터 실행
- 핸들러 실행 (핸들러 어댑터가 핸들러를 실행)
- ModelAndView 반환
- 핸들러 어댑터는 핸들러가 반환하는 정보를 ModelAndView로 변환해서 반환한다.
- veiwResolver 호출
- View 반환
- 뷰 렌더링
스프링은 이 MVC 패턴을 더 편리하게 사용할 수 있도록 다양한 기능들을 제공한다. 지금부터 이에 대해 알아보겠다.
핸들러 매핑과 핸들러 어댑터
핸들러 매핑과 핸들러 어댑터가 어떤 역할을 수행하는지 조금 더 자세히 알아보도록 하겠다.
이 두 과정은 컨트롤러가 호출되기 위해 필요한 절차라고 할 수 있다. 스프링 부트는 자동으로 핸들러 매핑과 핸들러 어댑터를 등록해두고 사용할 수 있게 한다.
HandlerMapping은
1. 애노테이션 기반의 컨트롤러인 `@RequestMapping` 에서 사용
2. 스프링 빈의 이름으로 핸들러를 찾음
HandlerAdapter는
1. 애노테이션 기반의 컨트롤러인 `@RequestMapping` 에서 사용
2. HttpRequestheader 처리
3. Controller 인터페이스 처리
의 역할을 번호순서대로 우선순위를 매겨 실행한다.
@RequestMapping은 요청 정보를 매핑해준다. 해당 URL이 호출되면 이 애노테이션이 붙은 메서드가 자동 호출되게 된다.
@RequestMapping
package hello.servlet.web.springmvc.v2;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
import java.util.List;
@Controller
@RequestMapping("/springmvc/v2/members")
public class SpringMemberControllerV2 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@RequestMapping("/new-form")
public ModelAndView newForm() {
return new ModelAndView("new-form");
}
@RequestMapping
public ModelAndView save() {
List<Member> members = memberRepository.findAll();
ModelAndView mv = new ModelAndView("members");
mv.addObject("members", members);
return mv;
}
@RequestMapping("/save")
public ModelAndView members(HttpServletRequest request, HttpServletResponse response) {
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
Member member = new Member(username, age);
memberRepository.save(member);
ModelAndView mv = new ModelAndView("save-result");
mv.addObject("member", member);
return mv;
}
}
이와 같이 메서드 위에 애노테이션과 함께 URL을 작성해줄 수 있다. 클래스 위에 작성해 공통적으로 들어가는 URL 정보를 작성해 중복해서 URL을 작성하는 것을 막을 수 있다.
또한 메서드 위에 하나 개별적으로 붙일 수 있어서 메서드를 하나의 컨트롤러 클래스에 모아둬 사용하기 용이하다.
Controller V2에서는 요청 파라미터를 서블릿으로 받아주었다. 그리고 ModelAndView 객체를 직접 반환시키고 있다.
@RequestParam, @ResponseBody, @RequestController
package hello.servlet.web.springmvc.v3;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
@RequestMapping("/springmvc/v3/members")
public class SpringMemberControllerV3 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@GetMapping("/new-form")
public String newForm() {
return "new-form";
}
@PostMapping("/save")
public String save(
@RequestParam("username") String username,
@RequestParam("age") int age,
Model model) {
Member member = new Member(username, age);
memberRepository.save(member);
model.addAttribute("member", member);
return "save-result";
}
@GetMapping
public String members(Model model) {
List<Member> members = memberRepository.findAll();
model.addAttribute("members", members);
return "members";
}
}
하지만 ControllerV3는 요청 파라미터를 받을 때 `@RequestParam`을 사용한다. 그리고 Model을 사용해서 String 을 반환해준다.
@RequestParam 을 사용하면 요청 파라미터를 편리하게 사용할 수 있다. 파라미터 이름으로 바로 바인딩할 수 있게 도와주기 때문이다.
@RequestParam의 name(value) 속성이 파라미터 이름으로 사용 된다. 다음과 같이 말이다. @RequestParam("username") String memberName 서블릿으로 표현하면 request.getParameter("username") 이렇게 표현할 수 있다. 마찬가지로 HTTP 파라미터 이름이 변수 이름과 같으면 @RequestParam(name="xx") 생략 가능
또한 @RequestMapping 대신에 @GetMapping과 @PostMapping을 통해서 HTTP 메서드에 방식에 따라 다르게 호출되도록 해주었다. 이렇게하면 URL이 같아도 HTTP 메서드가 무엇인지에 따라서 메서드가 호출된다.
@Contoller는 반환 값이 String일 때 이를 뷰이름으로 인식하게 해준다. 그러면 이를 기반으로 뷰를 찾고 랜더링해준다.
그런데 @RequestController는 반환 값으로 뷰를 찾는게 아니라 HTTP 메서드 바디에 바로 값을 입력하도록 해준다. 이는 @ResponseBody 애노테이션과 관련되어 있다. @ResponseBody는 View 조회를 무시하고, HTTP message body에 직접 해당 내용을 입력하게 해주는 애노테이션이다.
@ModelAttribute
/**
* @ModelAttribute 사용
* 참고: model.addAttribute(helloData) 코드도 함께 자동 적용됨, 뒤에 model을 설명할 때 자세히
설명
*/
@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@ModelAttribute HelloData helloData) {
log.info("username={}, age={}", helloData.getUsername(),
helloData.getAge());
return "ok";
}
HelloData
@Data
public class HelloData {
private String username;
private int age;
}
스프링은 @ModelAttribute 가 있으면 다음과 같은 과정을 실행한다.
- HelloData 생성
- 요청 파라미터 이름으로 HelloData 객체의 프로터피를 찾음, 그리고 해당 프로퍼티의 setter를 호출해서 파라미터 값을 입력(바인딩)함
- 프로퍼티란? 객체에 getUsername(), setUsername()과 같은 getter, setter가 있으면 이 객체는 username 이라는 프로퍼티를 가지고 있음
@ModelAttribute 는 생략할 수 있다. 그런데 @RequestParam 도 생략할 수 있으니 혼란이 발생할 수 있다.
- 스프링은 해당 생략시 다음과 같은 규칙을 적용한다.
String , int , Integer 같은 단순 타입 = @RequestParam
나머지 = @ModelAttribute (argument resolver 로 지정해둔 타입 외)
HTTP 요청 메시지
HTTP 요청 메시지 - 단순 텍스트
HTTP 메시지 바디를 통해 데이터가 직접 날아오는 경우는 @RequestParam , @ModelAttribute를 사용하지 않고 InputStream 을 사용한다.
InputStream
/**
* InputStream(Reader): HTTP 요청 메시지 바디의 내용을 직접 조회 * OutputStream(Writer): HTTP 응답 메시지의 바디에 직접 결과 출력 */
@PostMapping("/request-body-string-v2")
public void requestBodyStringV2(InputStream inputStream, Writer responseWriter)
throws IOException {
String messageBody = StreamUtils.copyToString(inputStream,
StandardCharsets.UTF_8);
log.info("messageBody={}", messageBody);
responseWriter.write("ok");
}
- InputStream(Reader): HTTP 요청 메시지 바디의 내용을 직접 조회
- OutputStream(Writer): HTTP 응답 메시지의 바디에 직접 결과 출력
HttpEntity
/**
* HttpEntity: HTTP header, body 정보를 편리하게 조회
* - 메시지 바디 정보를 직접 조회(@RequestParam X, @ModelAttribute X)
* - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용 *
* 응답에서도 HttpEntity 사용 가능
* - 메시지 바디 정보 직접 반환(view 조회X)
* - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용
*/
@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) {
String messageBody = httpEntity.getBody();
log.info("messageBody={}", messageBody);
return new HttpEntity<>("ok");
}
- HttpEntity: HTTP header, body 정보를 편리하게 조회하게 해준다
- 메시지 바디 정보를 직접 조회
- 요청 파라미터를 조회하는 기능과는 관련 없음!!(@RequestParam , @ModelAttribute x)
- 요청 뿐만 아니라 응답에도 사용할 수 있음
- 메시지 바디 정보 직접 반환
- 헤더 정보 포함 가능
- view 조회 x
HttpEntity 를 상속받은 다음 객체들도 같은 기능을 제공한다.
- RequestEntity
- HttpMethod, url 정보가 추가, 요청에서 사용
- ResponseEntity
- HTTP 상태 코드 설정 가능, 응답에서 사용
- return new ResponseEntity<String>("Hello World", responseHeaders,HttpStatus.CREATED)
RequestBody
/**
* @RequestBody
* - 메시지 바디 정보를 직접 조회(@RequestParam X, @ModelAttribute X)
* - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용 *
* @ResponseBody
* - 메시지 바디 정보 직접 반환(view 조회X)
* - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용
*/
@ResponseBody
@PostMapping("/request-body-string-v4")
public String requestBodyStringV4(@RequestBody String messageBody) {
log.info("messageBody={}", messageBody);
return "ok";
}
- @RequestBody 를 사용하면 HTTP 메시지 바디 정보를 편리하게 조회가능하다.
- 만약 헤더 정보가 필요하면 HttpEntity를 사용하거나, @RequestHeader 사용한다
HTTP 요청 메시지 - JSON
문자 변환 후 객체 변환
/**
* @RequestBody
* HttpMessageConverter 사용 -> StringHttpMessageConverter 적용 *
* @ResponseBody
* - 모든 메서드에 @ResponseBody 적용
* - 메시지 바디 정보 직접 반환(view 조회X)
* - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용
*/
@ResponseBody
@PostMapping("/request-body-json-v2")
public String requestBodyJsonV2(@RequestBody String messageBody) throws
IOException {
HelloData data = objectMapper.readValue(messageBody, HelloData.class);
log.info("username={}, age={}", data.getUsername(), data.getAge());
return "ok";
}
- @RequestBody 를 사용해서 HTTP 메시지에서 데이터를 꺼내고 messageBody에 저장한 다. 그리고 문자로 된 JSON 데이터인 messageBody 를 objectMapper 를 통해서 자바 객체로 변환한다.
바로 객체 변환
/**
* @RequestBody 생략 불가능(@ModelAttribute 가 적용되어 버림)
* HttpMessageConverter 사용 -> MappingJackson2HttpMessageConverter (content-type:
application/json)
*
* @ResponseBody 적용
* - 메시지 바디 정보 직접 반환(view 조회X)
* - HttpMessageConverter 사용 -> MappingJackson2HttpMessageConverter 적용(Accept:
application/json)
*/
@ResponseBody
@PostMapping("/request-body-json-v5")
public HelloData requestBodyJsonV5(@RequestBody HelloData data) {
log.info("username={}, age={}", data.getUsername(), data.getAge());
return data;
}
- @RequestBody 에 직접 만든 객체를 저장할 수 있다
HTTP 응답 - 정적 리소스, 뷰 템플릿
정적 리소스 경로
- src/main/resources/static 다음 디렉토리에 리소스를 넣어두면 정적 리소스로 서비스를 제공한다
뷰 템플릿 경로
- src/main/resources/templates
HTTP 메시지 컨버터
스프링 mvc는 다음의 경우에 HTTP 메시지 컨버터를 적용한다.
- HTTP 요청: @RequestBody , HttpEntity(RequestEntity)
- HTTP 응답: @ResponseBody , HttpEntity(ResponseEntity)
몇가지 주요한 메시지 컨버터를 알아보자.
ByteArrayHttpMessageConverter : byte[] 데이터를 처리한다.
- 클래스 타입: byte[] , 미디어타입: */*
- 요청 예) @RequestBody byte[] data 응답 예) @ResponseBody return byte[] 쓰기 미디어타입 application/octet-stream
StringHttpMessageConverter : String 문자로 데이터를 처리한다.
- 클래스 타입: String , 미디어타입: */*
- 요청 예) @RequestBody String data 응답 예) @ResponseBody return "ok" 쓰기 미디어타입 text/plain
MappingJackson2HttpMessageConverter : application/json
- 클래스 타입: 객체 또는 HashMap , 미디어타입 application/json 관련
- 요청 예) @RequestBody HelloData data 응답 예) @ResponseBody return helloData 쓰기 미디어타입 application/json 관련

ArgumentResolver
- 다양한 파라미터를 처리할 수 있는 이유는 ArgumentResolver 덕분이다
- RequeestMappingHandlerAdapter 는 바로 이 ArgumentResolver 를 호출해서 컨트롤ㄹ러가 필요로 하는 다양한 파라미터 값(객체)를 생성한다. 그리고 파라미터 값이 모두 준비 되면 컨트롤러를 호출해 값을 넘겨준다.
ReturnValueHandler
- 이것은 응답 값을 변환하고 처리함
- 컨트롤러에서 String으로 뷰 이름을 반환해도, 동작하는 이유가 이것 덕분이다.
지금까지 스프링 MVC 패턴과 구조에 대해서 알아보았다. 사실 정리는 해봤지만 이를 지금 다 이해하고 있는지는 미지수이다. 하지만 이런 원리와 구조를 이해하고 있다면 나중에 문제가 발생했을 때 어느 부분에서 문제가 발생했는지 그리고 어떤 구조로 이루어져 있는지 정도는 알 수 있을 것이다. 앞으로 스프링 공부를 이어 나가면서 더 이해되는 부분이 생길 것이라고 생각한다.
'Spring' 카테고리의 다른 글
| [Spring 핵심 원리 이해] 8. 빈 스코프 (4) | 2025.01.13 |
|---|---|
| [Spring 핵심 원리 이해] 7. 빈 생명주기 콜백 (3) | 2025.01.07 |
| [Spring 핵심 원리 이해] 6. 의존관계 자동 주입 (6) | 2025.01.03 |
| [Spring 핵심 원리 이해] 5. 컴포넌트 스캔 (3) | 2024.12.28 |
| [Spring 핵심 원리 이해] 4. 싱글톤 컨테이너 (4) | 2024.12.27 |