📂 목차
📚 본문
Spring MVC
Model-View-Controller
패턴을 기반으로 하는 웹 애플리케이션 프레임워크
- Model: 비즈니스 데이터 + 로직
- View: 사용자에게 보여지는 화면 (HTML, JSON 등)
- Controller: 요청을 받고 Model과 View 를 연결
Spring 은 MVC 2.0 버전 패턴으로 위와 같이 적용하고 있다. DispatcherServlet 은 모든 HTTP 요청의 진입점이며, 톰캣과 디스패쳐가 연결되어 있다. 처리 과정을 자세히 살펴보자.
- 브라우저에서 요청,
DispatcherServlet
이 받음 HandlerMapping
가 어떤Controller
가 이 요청을 처리할지 찾음HandlerAdapter
가 알맞은Controller
메서드를 호출함Controller
는 메서드 호출 받았을 때,Model
데이터를 만들고View
이름을 반환하게 된다.- 모델을 어떤 view 에 적용시켜줄지를 알려주어야 하니 view 이름을 반환하는 것이다.
ViewResolver
가View
객체를 생성한다.View
는 생성된 Model 을 읽어들여서 데이터를 적용한다View
가 사용자에게 결과를 전달한다.
Browser Request
↓
DispatcherServlet
↓
HandlerMapping → 어떤 Controller인지 결정
↓
HandlerAdapter → Controller 호출
↓
Controller → Model 생성 / View 이름 반환
↓
ViewResolver → View 객체 생성
↓
View → 사용자에게 HTML / JSON 응답
개발자가 관여할 것은 보라색 뿐이다. 이제 이를 설계해보자.
Controller 설계
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{id}")
public UserDto getUser(@PathVariable Long id) {
return userService.getUserById(id);
}
@PostMapping("/")
public UserDto createUser(@RequestBody UserDto userDto) {
return userService.createUser(userDto);
}
}
하나하나 뜯어본다.
@Controller
는 Spring MVC에서 웹 요청을 처리하는 클래스임을 나타내는 애너테이션이다. 즉, 이 클래스 안의 메서드들이 HTTP 요청과 응답을 담당하게 된다.
역할
- 클래스 레벨에 붙음 → 이 클래스가
Controller
임을 Spring 에 알림 - Spring Bean 으로 등록 → IoC 컨테이너에서 관리
⭐️ 중요: @RestController =
@Controller
+@ResponseBody
의 결합이며 반환값을 JSON, HTTP Body 로 바로 전달하고 싶을 때 쓴다.
@RequestMapping
Controller
에 들어가는 함수들은 전부 제 기능을 하기 위해 @RequestMapping
이 주로 붙는다.
@RequestMapping(value = "/users", method = RequestMethod.GET)
public List<User> getAllUsers() { ... }
이때 위 애너테이션은 다음과도 같다.
@GetMapping("/users")
public List<User> getAllUsers() { ... }
더 쉽게 제공하는 편이다. 이런게 4개 더 있다.
매핑 어노테이션 | 설명 | 예시 |
---|---|---|
@GetMapping |
GET 요청 처리 | @GetMapping("/users") |
@PostMapping |
POST 요청 처리 | @PostMapping("/users") |
@PutMapping |
PUT 요청 처리 | @PutMapping("/users/{id}") |
@DeleteMapping |
DELETE 요청 처리 | @DeleteMapping("/users/{id}") |
@PatchMapping |
PATCH 요청 처리 | @PatchMapping("/users/{id}") |
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{id}")
public String getUser(@PathVariable Long id) {
return "사용자 조회: " + id;
}
@PostMapping("/")
public String createUser(@RequestBody String userName) {
return "사용자 생성: " + userName;
}
@PutMapping("/put/{id}")
public String updateUser(@PathVariable Long id, @RequestBody String newName) {
return "사용자 수정: " + id + " -> " + newName;
}
@DeleteMapping("/delete/{id}")
public String deleteUser(@PathVariable Long id) {
return "사용자 삭제: " + id;
}
@PatchMapping("/patch/{id}")
public String patchUser(@PathVariable Long id, @RequestBody String newEmail) {
return "사용자 이메일 수정: " + id + " -> " + newEmail;
}
}
위와 같이 사용 가능하다. 여기서 잘 보면 @RequestMapping
어노테이션을 클래스 레벨에 두어 내부 메서드 레벨의 @RequestMapping
들에 대해서 prefix 를 적용하는 것처럼 된다.
이제 파라미터에 대한 매핑을 보자.
파라미터 매핑
| 어노테이션 | 설명 |
|——————|————————————|
| @PathVariable
| URL 경로의 변수 값을 매핑 |
| @RequestParam
| 쿼리 파라미터 값을 매핑 |
| @RequestBody
| 요청 Body(JSON 등)를 객체로 매핑 |
| @RequestHeader
| HTTP Header 값 매핑 |
| @CookieValue
| 쿠키 값 매핑 |
| @ModelAttribute
| 폼 데이터를 객체에 바인딩 |
하나하나 다 중요한 것들이니 자세히 본다.
@PathVariable
Url 에 있는 값을 그대로 받을 수도 있다. 이는 사용자 검색을 더 편리하게 할 수 있고, 스크래핑 정보도 제공하기 편리하게 할 수 있다.
@GetMapping("/users/{id}")
public String getUser(@PathVariable Long id) {
return "사용자 조회: " + id;
}
@RequestParam
url 전체의 query 부분으로 key-value 를 보낼때 이를 받게 할 수도 있다.
// GET /users/search?name=홍길동&age=20
@GetMapping("/search")
public String searchUser(
@RequestParam String name,
@RequestParam int age) {
return "검색 사용자: " + name + ", 나이: " + age;
}
@GetMapping("/search")
public String searchUser(
@RequestParam(name = "name", required = false, defaultValue = "익명") String name,
@RequestParam(name = "age", required = false, defaultValue = "0") int age) {
return "검색 사용자: " + name + ", 나이: " + age;
}
// GET /users/filter?roles=ADMIN&roles=USER&roles=GUEST
@GetMapping("/filter")
public String filterUsers(@RequestParam List<String> roles) {
return "필터 역할: " + roles;
}
PostMapping
을 통해서 form 으로 password
, username
등도 받을 수 있다.
여러 개를 받을 때는 &로 연결하고 key 를 중복해서 선언해주면 된다.
@RequestBody
보통 API 만들 용도로 쓰인다 왜냐면 body 자체가 구조화된 데이터 형태로 들어와야 자바 객체로 변환하기 편하며, 클라이언트가 보낸 JSON 등의 데이터를 객체로 직접 받을 때 사용하면 된다.
@PostMapping("/")
public String createUser(@RequestBody UserDto userDto) {
return "사용자 생성: " + userDto.getName() + ", 나이: " + userDto.getAge();
}
@RequestBody
: HTTP 요청의 Body 부분(JSON, XML, 텍스트 등)을 자바 객체로 바로 변환해주는 역할(보통 JSON 등으로 매핑)
⭐️ @RequestHeader
/*
GET /users/header
Authorization: Bearer abc123
User-Agent: Chrome
*/
@GetMapping("/header")
public String getHeader(
@RequestHeader("Authorization") String authHeader,
@RequestHeader(value = "User-Agent", required = false) String userAgent) {
return "인증 헤더: " + authHeader + ", 브라우저: " + userAgent;
}
보통 인증 토큰이 있는지 없는지 판별할 때 쓸 수 있겠다.
- 토큰 인증
- API 키 검증
고급 옵션
RequestMapping
에서 붙일 수 있는 옵션이며, 단순히 요청 뿐 아니라 형식, 파라미터, 헤더에 따라 매핑을 더 세밀히 제어할 수 있도록 한다.
옵션 | 설명 |
---|---|
consumes |
요청 Content-Type 제한 (application/json ) |
produces |
응답 Content-Type 지정 (application/json ) |
params |
특정 요청 파라미터 존재 여부로 매핑 |
headers |
특정 HTTP 헤더 존재 여부로 매핑 |
사용 예시
consumes
는 요청 Content-Type 을 제한하는데, 쉽게 말해서 JSON 요청만 처리하게 제한하도록 할 수 있고 다른 다양한 것들이 있다.
consumes
@PostMapping(value = "/users", consumes = "application/json")
public String createUser(@RequestBody UserDto userDto) {
return "JSON으로 사용자 생성: " + userDto.getName();
}
produces 는 응답의 Content-Type 을 지정한다. 위랑 다르다. 만약 다음과 같이 입력되었다면 JSON 형태로만 응답하도록 지정하는 것과 같다.
produces
@GetMapping(value = "/users/{id}", produces = "application/json")
public UserDto getUser(@PathVariable Long id) {
return new UserDto(id, "홍길동", 25);
}
params 는 특정 쿼리 파라미터 조건이 있을 때만 매핑을 하게 된다. 예를 들어 admin 이라는 parameter key 에 value 로 true
가 아니라면 해당 함수는 매핑이 안되게 된다. 따라서 두 메서드는 다른 메서드로 동작되고 중복 선언으로 오류가 안뜬다.
params
// 예: /users/search?admin=true 일 때만 매핑됨
@GetMapping(value = "/users/search", params = "admin=true")
public String searchAdminUsers() {
return "관리자 계정 검색";
}
// 예: /users/search?role=user 일 때만 매핑됨
@GetMapping(value = "/users/search", params = "role=user")
public String searchNormalUsers() {
return "일반 사용자 검색";
}
headers 는 요청이 오는 HTTP 헤더에 조건이 있을때만 매핑을 하게 된다. 브라우저 별로 사용하는 API 가 달라서 그에 호환되는 처리를 할 수도 있다.
headers
@GetMapping(value = "/users/version", headers = "X-API-VERSION=1")
public String getUserV1() {
return "API Version 1 호출됨";
}
@GetMapping(value = "/users/version", headers = "X-API-VERSION=2")
public String getUserV2() {
return "API Version 2 호출됨";
}
이제 Model 과 View 를 보자.
Model & View
모델은 단순히 데이터를 담는 통(Bean) 이며, 요청과 응답 사이에서 데이터를 전달하는 핵심 매개체이다. Spring MVC 에서는 다음 3가지 형태로 Model
을 다룬다.
org.springframework.ui.Model
: 단순 key-value 저장소ModelMap
: Model 과 유사하나LinkedHashMap
기반ModelAndView
:Model
+ViewName
을 한번에 관리
- 사용자의 요청이 들어오면
DispatcherServlet
이 해당Controller
메서드를 호출한다. Controller
메서드는 비즈니스 로직을 수행하고, 그 결과를Model
에 담는다.DispatcherServlet
은Model
에 담긴 데이터를View
에 전달한다.View(예: Thymeleaf, JSP)
는${key}
또는*{key}
문법으로 데이터를 참조한다.
String 예시
@Controller
@RequestMapping("/example")
public class ExampleController {
@GetMapping("/model")
public String modelExample(Model model) {
// Model에 데이터 담기
model.addAttribute("message", "Hello, Model!");
model.addAttribute("number", 42);
// view 이름 반환 (hello.html)
return "hello";
}
}
ModelMap 예시
@Controller
@RequestMapping("/example")
public class ExampleController {
@GetMapping("/modelmap")
public String modelMapExample(ModelMap modelMap) {
// ModelMap에 데이터 담기
modelMap.put("message", "Hello, ModelMap!");
modelMap.put("number", 123);
// view 이름 반환 (hello.html)
return "hello";
}
}
ModelAndView 예시
@Controller
@RequestMapping("/example")
public class ExampleController {
@GetMapping("/modelandview")
public ModelAndView modelAndViewExample() {
ModelAndView mav = new ModelAndView();
// view 이름 지정
mav.setViewName("hello");
// 데이터 추가
mav.addObject("message", "Hello, ModelAndView!");
mav.addObject("number", 999);
return mav;
}
}
redirect 방식은
ModelAndView("redirect:(path)")
처럼ModelAndView
에서도 사용 가능하다.
Thymeleaf
Java spring 에서 자주 사용하는 HTML 렌터링 서버사이드 템플릿 엔진이다.
기본 구조는 다음과 같고, resorces/templates/
폴더에 html 을 저장하게 된다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Thymeleaf 예제</title>
</head>
<body>
<h1 th:text="${message}">Hello</h1>
</body>
</html>
꼭 html 에 태그로 xmlns:th="http://www.thymeleaf.org"
를 넣어주어야 한다. th 는 자유다. Thymeleaf 는 View
계층에 포함되는 애이며, 여기에 들어가는 데이터들이 전부 Model
에서 가져오게 되겠다.
텍스트 표현
th:text="${var}
: 변수 값 출력th:utext="${var}
: HTML 태그를 포함한 문자열 출력 (escape 안함)th:inline="text"
: 텍스트 인라인 표현 가능${var}
사용
속성 바인딩
th:href="@{/home}"
: 링크 URL 바인딩th:src="@{/images/logo.png}"
: 이미지 src 바인딩th:class="@{condition ? 'active' : ''}
조건부 클래스 적용
조건문
th:if
: 조건이true
면 해당 태그 렌더링th:unless
: 조건이false
면 해당 태그 렌더링
<p th:text="${user.age >= 18 ? '성인' : '미성년'}"></p>
처럼 쓸 수도 있다.
반복문
th:each="(for 내부 변수) : ${var}"
: 컬렉션 반복
URL 링크 처리
th:href=@{경로}
: 컨텍스트 경로 자동 적용이 된다.${}
와 같이 경로 안에 파라미터 포함 가능
실습 코드에서는 다음과 같이 되어 있는데,
<a th:href="@{/users/{id}(id=${user.id})}">View Profile</a>
{} 는 단순 변수이며 $ 나 * 등이 없다. 이때는 () 를 통해 id 에 직접 넣을 수 있도록 할 수 있다.
변수 표현식
${var}
: 일반 변수*{field}
: form 객체의 field (th:object
안에서)
<!-- Form 처리 -->
<form th:action="@{/users}" th:object="${userForm}" method="post">
<input type="text" th:field="*{username}" />
<input type="email" th:field="*{email}" />
<button type="submit">Submit</button>
</form>
#{msg.key}
: 메시지 국제화(i18n)messages.properties
에greeting=안녕하세요, {0}님
의 key-value 가 있다고 쳐보자.
Thymeleaf 를 쓰는 HTML 에서는 다음과 같이 쓸 수 있다.
<p th:text="#{greeting(${user.name})}">기본 인사</p>
~{template}
: fragment/template 참조 아래를 통해 더 자세히 살펴보자.
th:replace
<!-- header.html -->
<!-- fragment 등록 -->
<div th:fragment="header">
<h1>사이트 헤더</h1>
</div>
<!-- main.html -->
<!-- fragment 사용 -->
<div th:replace="header :: header"></div>
템플릿의 위치는 application.yml
이나 프로퍼티에 spring.thymeleaf.prefix=classpath:/templates/
, spring.thymeleaf.suffix=.html
처럼 기본적으로 넣어져있고, 이를 따로 지정하여 넣어줄 수 있다. 이때 그 하위의 html 에 대한 Thymeleaf 문법으로 작성된 문서들이 다 불러와지고, fragment 도 그때 등록된다. 추가로 더 알아볼 것은 ClassLoaderTemplateResolver
을 통해서도 이런 template 위치를 코드 내에서도 지정 가능하다(나중에 알아보자).
위 코드르 보면 :: 를 통해 자바의 정적 참조 문법과 비슷하며, th:replace
를 통해 (파일 경로) :: (fragment 명)
을 써서 지정 가능하다.
실습
<!-- fragments/header.html -->
<header th:fragment="header">
<nav class="navbar">
<a th:href="@{/}">Home</a>
<a th:href="@{/about}">About</a>
<a th:href="@{/contact}">Contact</a>
</nav>
</header>
<!-- fragments/footer.html -->
<footer th:fragment="footer">
<p>© 2024 My Application</p>
</footer>
<!-- main.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>My App</title>
</head>
<body>
<div th:replace="~{fragments/header :: header}"></div>
<main>
<h1>Main Content</h1>
<!-- Page specific content -->
</main>
<div th:replace="~{fragments/footer :: footer}"></div>
</body>
</html>
fragment 의 단점은 경로 지정을 정확히 해주어야만 한다.
폼 처리
th:action
: form 제출 URLth:method
: form methodth:field
: input, select, textarea value 바인딩
기타 유용 속성
th:value
th:checked
th:selected
th:disabled
th:style
th:onclick
가장 중요한 것은
th:(HTML 의 속성 명)
의 형태를 띠며, 값 내부에 java 코드를 일부 집어넣을 수 있다는 것이다. 굳이 외우지 말자.
⭐️ Spring 의 Model, BindingResult 바인딩 처리 및 Thymeleaf 의 BindingResult 호출
교육을 들은 후 아예 프로젝트를 새로 만들어 사용하고 있을때 User 의 회원가입 요청을 받아 Post 로 요청을 주는 것을 하고 있었는데, 입력 검증으로 validate 를 하고 있을때, BindingResult
에서는 잘 뜨던 오류 메시지들이, 유독 thymeleaf 템플릿에서는 렌더링이 안되어서 검증 실패 오류 메시지가 안넘어가는 것을 알아내던 도중 다음 사실을 알았다.
다음은 문제의 코드이다.
view
<form method="post" th:action="@{/user/register}" th:object="${userRegisterDto}">
<label for="username">Username</label><br/>
<input type="text" id="username" th:field="*{username}" required><br/>
<span th:if="${#fields.hasErrors('username')}" th:errors="*{username}" style="color:red;"></span><br/>
<label for="email">Email</label><br/>
<input type="text" id="email" th:field="*{email}" required><br/>
<span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" style="color:red;"></span><br/>
...
controller
@GetMapping("/register")
public String registerForm(Model model) {
model.addAttribute("userRegisterDto", new UserRegisterDTO(null, null, null));
return "user/registerForm";
}
@PostMapping("/register")
public String register(@Valid UserRegisterDTO userRegisterDto, BindingResult result, Model model) {
if (result.hasErrors()) {
model.addAttribute("userRegisterDto", userRegisterDto);
return "user/registerForm";
}
...
위의 상황을 먼저 정리해보자.
Spring 이 Model 과 BindingResult 로의 바인딩 과정
- Spring 은 자동으로
Model
에 Attribute 를 바인딩 할 때, 다음 기본명명규칙을 사용한다.- 클래스 명을 기준으로 한다.
- Ex)
UserRegisterDTO userRegisterDto
->userRegisterDTO
를 키로하여 바인딩
- Spring 은
BindingResult
의 에러 정보를 자동으로 저장할 때, 다음 규칙을 사용한다.- 클래스 명을 키로 하고 에러 정보를 저장한다
- Ex)
@Valid UserRegisterDTO userRegisterDto
가 있다면userRegisterDTO
를 키로 하고 에러 정보를 저장
Thymeleaf 의 Error 정보를 들고 오는 과정
Thymeleaf
는th:object
의 값을 키로 하여BindingResult
에게 해당 키에 대한 정보를 들고오게 된다.th:object
명을 키로 하고 에러 정보를 들고온다- Ex)
th:object="userRegisterDto"
라면,BindingResult
의userRegisterDto
의 에러 정보를 들고 오게 됨
문제 상황 정리는 끝났다. 여기서 잘못된 점은 바로 모델에게 내가 직접 주입하여서 빈을 넣었기 때문이다. 즉 스프링은 정의된 약속대로 흘러가야 하는데 내가 이를 명명 규칙을 무시하고 모델에 addAttribute
를 토대로 넣어버린다면 어디에선가 오류가 생긴다는 것이다.
따라서 Thymeleaf
는 userRegisterDto
를 들고와야 하는데, BindingResult
가 알고 있는 것은 Class 명명 규칙을 따르기 때문에 userRegisterDTO
에 저장된 에러 정보를 못들고 오고 빈 text 만을 렌더링 하게 되는 것이다. 이는 오류로 뜨지도 않고 알 방법도 없다.
따라서 다음으로 바꿔야 한다.
thymeleaf
의th:object
를 수정하고,Model
에 들어가는 key 값도 같게 수정@ModelAttribute
채택
이제 ModelAttribute
를 상세히 보자.
⭐️ @ModelAttribute 와 Thymeleaf 사이의 생략 관계 정리
@ModelAttribute
가 우선 뭔지 봐야 한다. RetentionPolicy
로는 함수, 인자에 사용하고, 메서드 매개변수 또는 메서드 반환 값을 이름이 지정된 모델 속성과 바인딩하여 웹 뷰에 노출하는 애너테이션이다.
즉, 다시 말해서, (name 옵션 값) : (메서드 매개변수 값)
, (name 옵션 값) : (메서드 반환 값)
으로 모델에 바인딩하여 웹 뷰에 노출한다는 것이다.
이때 만약 name 옵션을 생략하면 어떻게 될까? 기본적으로 위에서 봤다시피 클래스 이름에서 첫 글자를 소문자로 바꾼 것을 기본 모델 이름으로 사용하며, 메서드에서도 마찬가지로 반환 타입 클래스 이름에서 첫 글자를 소문자로 바꾼 것을 사용하게 된다.
따라서 우리는 항상 클래스 명을 따라가야 함을 잊지 말아야 한다.
@ModelAttribute 를 사용한 Thymeleaf 의 th:object 생략
object 없이 그냥 key-value 만을 넘겨주었을 때, th:object
없이 *{}
를 그대로 사용할 수 있게 된다.
<form method="post" th:action="@{/user/register}">
<label for="username">Username</label><br/>
<input type="text" id="username" th:field="*{username}" required><br/>
<span th:if="${#fields.hasErrors('username')}" th:errors="*{username}" style="color:red;"></span><br/>
<label for="email">Email</label><br/>
<input type="text" id="email" th:field="*{email}" required><br/>
<span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" style="color:red;"></span><br/>
<label for="password">Password</label><br/>
<input type="password" id="password" th:field="*{password}" required><br/>
<span th:if="${#fields.hasErrors('password')}" th:errors="*{password}" style="color:red;"></span><br/>
<button type="submit">Submit</button>
</form>
@ModelAttribute 의 생략을 이용한 Thymeleaf 의 th:object 자동 바인딩
이걸 말하기 전에 HandlerMethodArgumentResolver
을 먼저 보아야 한다. 스프링이 내부적으로 어떻게 argument 를 해결하는지를 보자.
스프링 MVC 는 컨트롤러 메서드 매개변수를 처리할 때 HandlerMethodArgumentResolver
를 사용하게 된다. 이 놈은 HandlerAdapter
가 결정된 컨트롤러 메서드를 호출하려고 준비한 후에 실행되는 애이고, 다음 역할을 한다:
- 컨트롤러 메서드의 매개변수를 해석하고
- 필요한 객체를 생성 및 바인딩
이제 HandlerAdapter
는 HandlerMethodArgumentResolver
가 만들어준 값으로 메서드를 호출하게 된다.
매개변수가 스프링이 관리할 수 있는 POJO 라면,
@ModelAttribute
가 자동 적용된다.
이때 스프링이 바인딩을 다음과 같이 하게 된다.
- 스프링이 새 객체를 생성하게 된다(빈 생성자를 토대로 함).
- ⭐️ HTTP 요청 파라미터가 있다면 setter 나 field 를 통해 객체에 값을 채운다.
- Ex)
UserRegisterDTO
에username
,email
등이 있다고 치면, 쿼리 스트링에
?username=park&email=a@b.com
으로 있으면 자동으로 채워줌
- Ex)
- 객체를 모델에 기본 이름(class 이름의 첫 글자를 소문자로 바꾼 명칭) 으로 자동 추가한다.
- 뷰에서
th:object="${기본 이름}"
으로 사용할 수 있게 된다
TIP. 따라서
GET
요청일 때는 보통 비어 있는 DTO 를 생성하여서 form backing object 로 사용하게 된다.
TIP.POST
요청 시에는HTTP
파라미터가 채워진 DTO 가 전달되게 될 것이다.
이제 언제 이런 자동 바인딩이 일어나는 지를 살펴보자.
자동 바인딩이 일어나는 규칙
- POJO 객체
@ModelAttribute
가 자동 적용됨GET
/POST
상관 없이 HTTP 파라미터를 객체 필드에 바인딩- 자동 모델 이름으로 클래스명 첫 글자 소문자로 함(이게 싫으면
@ModelAttribute
쓰기)
- 특별한 타입 (
Model
,HttpServletRequest
,BindingResult
,Principal
등)- POJO 객체 바인딩과는 별도로 스프링이 직접 제공하며,
- 모델에 자동으로 추가가 안된다.
BindingResult
- 항상 바로 앞의
@Valid
또는@ModelAttribute
객체와 쌍으로 사용하도록 되어 있고, 다른 POJO 객체와 순서가 섞이면 오류가 발생하게 된다.
- 항상 바로 앞의
BindingResult
는 항상 POJO 객체와 1:1 로 쌍을 이루면서 등장한다. 따라서 POJO 가 2개면BindingResult
인자도 2개 들어가야 한다.
이것들만 지키면 항상 자동 바인딩은 일어난다.
Forwarding 과정
이제 Forwarding
이 어떻게 동작하는지 완벽히 이해가 가능하다.
- HTTP
POST
요청을 통해 WAS 를 거쳐HttpServletRequest
와HttpServletResponse
를 생성하게 된다.HttpServletRequest
와HttpServletResponse
객체는 요청-응답 생명주기를 대표하는 객체
-
DispatcherServlet
가 이를 받고 받은HttpRequest
에서의 path 를HandlerMapping
에 주게 된다. - 받은 path 에 알맞은 mapping 함수를
Controller
에서 골라지게 된다.- 정확히는
HandlerMapping
목록을 순회하며, 이 URL 요청을 처리할 수 있는Controller
의 메서드를 탐색한다. - 찾은 결과를
HandlerExecutionChain
객체로 래핑 후 반환한다. - 여기에 실제
Controller
메서드와Interceptor
체인이 포함되게 된다.
- 정확히는
DispatcherServlet
은HandlerAdapter
를 통해 받았던 데이터(header
,body
등등)를Controller
에 넘겨 주게 된다.Controller
마다 호출 방식이 다르기 때문에DispatcherServlet
은HandlerAdapter
를 사용해 호출 과정을 추상화한다.- Spring 은 기본적으로
RequestMappingHandlerAdapter
를 사용하고,ArgumentResolver
와ReturnValueHandler
체계를 통하여 매개변수를 분석(@RequestParam
,@ModelAttribute
,@RequestBody
등)하고, 반환값을 해석 하게 된다(String
,ModelAndView
,ResponseBody
등)
-
이제
Controller
는 내부적으로 비즈니스 로직을 실행시켜 알맞은 결과값을 반환 후 Model
을 통해 넘겨줄 데이터들을 정해준 후에 뷰 이름을HandlerAdapter
에게 반환하게 된다.- 여기서
Controller
가 리턴한 값을 받아ModelAndView
객체로 통합하게 된다. - 만약 반환 타입이
String
이면viewName
으로 해석하고, - 별도로
Model
에 저장된 데이터를 함께 담는다. - 이때 4번 단계에서
ModelAndView
,ResponseBody
등으로 리턴하면 수행 단계가 조금 달라지긴 한다. 그 자체를 그대로 사용하게 된다.
- 여기서
ModelAndView
로 이미Controller
에서HandlerAdapter
에게 반환되었다면 6번 과정은 생략하고 다음 단계로 이동한다.
- 그러고
DispatcherServlet
이HandlerAdapter
로 부터 받은viewName
을 가지고,ViewResolver
에게 어떤 view 를 쓸 지를 정하게 되고,DispatcherServlet
은viewName
을ViewResolver
에게 넘긴다(ModelAndView
를 받았다면 이 과정은 없다).ViewResolver
는 논리 뷰 이름(logical view name) 을 물리적 리소스 경로로 변환하여View
객체(ThymeleafView
,JstlView
등)으로 변환한다 (ThymeleafViewResolver
,InternalResourceViewResolver
등이ViewResolver
의 대표적 구현체이다)- 최종적으로
View
가 생성된다.
- 그 다음
DispatcherServlet
은 받았던HttpServletResponse
와 함께 View 로forwarding
을 해주고,DispatcherServlet
은 선택된View
객체의render()
메서드를 호출하고, 이때HttpServletRequest
,HttpServletResponse
이 전달된다.
View
에서는ViewResolver
를 통해 알맞은View
가 생성되고,View
는Model
을 참조하여서 결과들을 가져오고 완전한 문서를 만들게 된다View
는Model
의 데이터를 참조하고, 렌더링 결과는HttpServletResponse
의 body 로 직접 작성되게 된다.- 렌더링이 끝나면
DispatcherServlet
이response.flushBuffer()
후 요청-응답 객체는 소멸된다.
Redirecting 과정
이제 Redirecting
이 어떻게 동작하는지 완벽히 이해가 가능하다.
Controller 의 return 값이 "redirect:"
접두어를 포함한 String 일 때
-
사용자가
POST
요청을 보낸다. WAS(Tomcat 등)가 이를 받아HttpServletRequest
,HttpServletResponse
객체를 생성하고DispatcherServlet
에게 요청을 전달한다. DispatcherServlet
은 요청의 URI 정보를 확인하여HandlerMapping
에게 어떤Controller
가 처리해야 하는지 조회를 맡긴다.HandlerMapping
은 요청 정보를 기반으로 적절한@RequestMapping
메서드를 찾아HandlerExecutionChain
으로 감싸 반환한다.- 이 객체에는 Controller 메서드와 Interceptor 체인이 포함된다.
DispatcherServlet
은HandlerAdapter
를 통해 해당Controller
메서드를 실행한다.- 이때
HandlerAdapter
는ArgumentResolver
,ReturnValueHandler
를 통해 요청 데이터를 파라미터에 바인딩하고, 반환 타입(String
,ModelAndView
,ResponseBody
) 을 분석한다.
- 이때
Controller
가 비즈니스 로직을 처리한 후,return "redirect:/users/welcome";
처럼"redirect:"
로 시작하는 문자열을 반환한다.- 이 접두어는 Spring MVC 내부에서 RedirectView 로 자동 변환되도록 트리거한다.
- ⭐️
DispatcherServlet
은 반환된 뷰 이름을 확인하고,"redirect:"
접두어가 포함되어 있음을 감지하면RedirectView
를 생성한다.- ⭐️ 이때는 ViewResolver 를 거치지 않는다.
- ⭐️ 대신
RedirectView
의render()
가 호출되어HttpServletResponse
에302 Found
상태 코드와Location
헤더를 설정한다.
- 응답이 클라이언트(브라우저)로 전송된다.
- 응답 헤더 예시:
HTTP/1.1 302 Found Location: /users/welcome
- 브라우저는 이를 보고
/users/welcome
으로 새로운 GET 요청을 자동으로 보낸다.
- 응답 헤더 예시:
-
새로운 GET 요청이 발생하면 WAS 는 다시
HttpServletRequest
,HttpServletResponse
를 새로 생성하고, 다시 1~4번의 Forwarding 과정과 동일한DispatcherServlet
처리 흐름을 거친다. - 이때, 이전 요청의
Model
데이터는 유지되지 않는다.- 대신
RedirectAttributes.addFlashAttribute()
로 추가된 데이터는 일시적 세션(FlashMap) 에 저장되어 새 요청 시점에 한 번만 사용된다. - 이후 요청이 완료되면
FlashMap
데이터는 자동 소멸된다.
- 대신
- 새로운
Controller
메서드(/users/welcome
) 가 호출되고, 뷰 렌더링이 완료되면 최종 HTML 응답이 브라우저에 전달된다.
즉, Redirect 는 “요청이 두 번 일어난다”.
첫 번째 요청은 서버의 302 응답으로 끝나고,
두 번째 요청이 실제 결과 페이지를 렌더링한다.
따라서 Forward 와 달리 URL 이 바뀌며, Model 데이터는 유지되지 않는다.
Forward vs Redirect 비교 요약
구분 | Forward | Redirect |
---|---|---|
요청 횟수 | 1회 | 2회 (POST → GET) |
URL 변경 | ❌ 그대로 유지 | ✅ 변경됨 |
데이터 전달 | Model 로 전달 |
FlashAttribute 로 1회 전달 |
처리 방식 | 서버 내부 이동 | 클라이언트 재요청 |
주 용도 | 단순 뷰 렌더링 | PRG(Post-Redirect-Get) 패턴, URL 노출 변경 등 |
Forward
는 “서버 내부 이동”
Redirect
는 “클라이언트 재요청”
— 이 한 줄만 정확히 기억하면, Spring MVC 흐름은 완전히 잡은 것이다.