Developer.

[멋사 백엔드 19기] TIL 37일차 Spring MVC 기초

📂 목차


📚 본문

Spring MVC

Model-View-Controller 패턴을 기반으로 하는 웹 애플리케이션 프레임워크

  • Model: 비즈니스 데이터 + 로직
  • View: 사용자에게 보여지는 화면 (HTML, JSON 등)
  • Controller: 요청을 받고 Model과 View 를 연결

spring-mvc-pattern.png

Spring 은 MVC 2.0 버전 패턴으로 위와 같이 적용하고 있다. DispatcherServlet 은 모든 HTTP 요청의 진입점이며, 톰캣과 디스패쳐가 연결되어 있다. 처리 과정을 자세히 살펴보자.

  1. 브라우저에서 요청, DispatcherServlet 이 받음
  2. HandlerMapping가 어떤 Controller 가 이 요청을 처리할지 찾음
  3. HandlerAdapter가 알맞은 Controller 메서드를 호출함
  4. Controller 는 메서드 호출 받았을 때, Model 데이터를 만들고 View 이름을 반환하게 된다.
    • 모델을 어떤 view 에 적용시켜줄지를 알려주어야 하니 view 이름을 반환하는 것이다.
  5. ViewResolverView 객체를 생성한다.
  6. View 는 생성된 Model 을 읽어들여서 데이터를 적용한다
  7. 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 을 한번에 관리
  1. 사용자의 요청이 들어오면 DispatcherServlet 이 해당 Controller 메서드를 호출한다.
  2. Controller 메서드는 비즈니스 로직을 수행하고, 그 결과를 Model 에 담는다.
  3. DispatcherServletModel 에 담긴 데이터를 View 에 전달한다.
  4. 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.propertiesgreeting=안녕하세요, {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>&copy; 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 제출 URL
  • th:method: form method
  • th: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 정보를 들고 오는 과정

  • Thymeleafth:object 의 값을 키로 하여 BindingResult 에게 해당 키에 대한 정보를 들고오게 된다.
    • th:object을 키로 하고 에러 정보를 들고온다
    • Ex) th:object="userRegisterDto" 라면, BindingResultuserRegisterDto 의 에러 정보를 들고 오게 됨

문제 상황 정리는 끝났다. 여기서 잘못된 점은 바로 모델에게 내가 직접 주입하여서 빈을 넣었기 때문이다. 즉 스프링은 정의된 약속대로 흘러가야 하는데 내가 이를 명명 규칙을 무시하고 모델에 addAttribute 를 토대로 넣어버린다면 어디에선가 오류가 생긴다는 것이다.

따라서 ThymeleafuserRegisterDto 를 들고와야 하는데, BindingResult 가 알고 있는 것은 Class 명명 규칙을 따르기 때문에 userRegisterDTO 에 저장된 에러 정보를 못들고 오고 빈 text 만을 렌더링 하게 되는 것이다. 이는 오류로 뜨지도 않고 알 방법도 없다.

따라서 다음으로 바꿔야 한다.

  • thymeleafth: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 가 결정된 컨트롤러 메서드를 호출하려고 준비한 후에 실행되는 애이고, 다음 역할을 한다:

  • 컨트롤러 메서드의 매개변수를 해석하고
  • 필요한 객체를 생성 및 바인딩

이제 HandlerAdapterHandlerMethodArgumentResolver 가 만들어준 값으로 메서드를 호출하게 된다.

매개변수가 스프링이 관리할 수 있는 POJO 라면, @ModelAttribute 가 자동 적용된다.

이때 스프링이 바인딩을 다음과 같이 하게 된다.

  1. 스프링이 새 객체를 생성하게 된다(빈 생성자를 토대로 함).
  2. ⭐️ HTTP 요청 파라미터가 있다면 setter 나 field 를 통해 객체에 값을 채운다.
    • Ex) UserRegisterDTOusername, email 등이 있다고 치면, 쿼리 스트링에
      ?username=park&email=a@b.com 으로 있으면 자동으로 채워줌
  3. 객체를 모델에 기본 이름(class 이름의 첫 글자를 소문자로 바꾼 명칭) 으로 자동 추가한다.
  4. 뷰에서 th:object="${기본 이름}" 으로 사용할 수 있게 된다

TIP. 따라서 GET 요청일 때는 보통 비어 있는 DTO 를 생성하여서 form backing object 로 사용하게 된다.
TIP. POST 요청 시에는 HTTP 파라미터가 채워진 DTO 가 전달되게 될 것이다.

이제 언제 이런 자동 바인딩이 일어나는 지를 살펴보자.

자동 바인딩이 일어나는 규칙

  1. POJO 객체
    • @ModelAttribute 가 자동 적용됨
    • GET/POST 상관 없이 HTTP 파라미터를 객체 필드에 바인딩
    • 자동 모델 이름으로 클래스명 첫 글자 소문자로 함(이게 싫으면 @ModelAttribute 쓰기)
  2. 특별한 타입 (Model, HttpServletRequest, BindingResult, Principal 등)
    • POJO 객체 바인딩과는 별도로 스프링이 직접 제공하며,
    • 모델에 자동으로 추가가 안된다.
  3. BindingResult
    • 항상 바로 앞의 @Valid 또는 @ModelAttribute 객체와 쌍으로 사용하도록 되어 있고, 다른 POJO 객체와 순서가 섞이면 오류가 발생하게 된다.

BindingResult 는 항상 POJO 객체와 1:1 로 쌍을 이루면서 등장한다. 따라서 POJO 가 2개면 BindingResult 인자도 2개 들어가야 한다.

이것들만 지키면 항상 자동 바인딩은 일어난다.

Forwarding 과정

이제 Forwarding 이 어떻게 동작하는지 완벽히 이해가 가능하다.

  1. HTTP POST 요청을 통해 WAS 를 거쳐 HttpServletRequestHttpServletResponse 를 생성하게 된다.
    • HttpServletRequestHttpServletResponse 객체는 요청-응답 생명주기를 대표하는 객체
  2. DispatcherServlet 가 이를 받고 받은 HttpRequest 에서의 path 를 HandlerMapping 에 주게 된다.

  3. 받은 path 에 알맞은 mapping 함수를 Controller 에서 골라지게 된다.
    • 정확히는 HandlerMapping 목록을 순회하며, 이 URL 요청을 처리할 수 있는 Controller 의 메서드를 탐색한다.
    • 찾은 결과를 HandlerExecutionChain 객체로 래핑 후 반환한다.
    • 여기에 실제 Controller 메서드와 Interceptor 체인이 포함되게 된다.
  4. DispatcherServletHandlerAdapter 를 통해 받았던 데이터(header, body 등등)를 Controller 에 넘겨 주게 된다.
    • Controller 마다 호출 방식이 다르기 때문에 DispatcherServletHandlerAdapter 를 사용해 호출 과정을 추상화한다.
    • Spring 은 기본적으로 RequestMappingHandlerAdapter 를 사용하고, ArgumentResolverReturnValueHandler 체계를 통하여 매개변수를 분석(@RequestParam, @ModelAttribute, @RequestBody 등)하고, 반환값을 해석 하게 된다(String, ModelAndView, ResponseBody 등)
  5. 이제 Controller 는 내부적으로 비즈니스 로직을 실행시켜 알맞은 결과값을 반환 후

  6. Model 을 통해 넘겨줄 데이터들을 정해준 후에 뷰 이름을 HandlerAdapter 에게 반환하게 된다.
    • 여기서 Controller 가 리턴한 값을 받아 ModelAndView 객체로 통합하게 된다.
    • 만약 반환 타입이 String 이면 viewName 으로 해석하고,
    • 별도로 Model 에 저장된 데이터를 함께 담는다.
    • 이때 4번 단계에서 ModelAndView, ResponseBody 등으로 리턴하면 수행 단계가 조금 달라지긴 한다. 그 자체를 그대로 사용하게 된다.

ModelAndView 로 이미 Controller 에서 HandlerAdapter 에게 반환되었다면 6번 과정은 생략하고 다음 단계로 이동한다.

  1. 그러고 DispatcherServletHandlerAdapter 로 부터 받은 viewName 을 가지고, ViewResolver 에게 어떤 view 를 쓸 지를 정하게 되고,
    • DispatcherServletviewNameViewResolver 에게 넘긴다(ModelAndView 를 받았다면 이 과정은 없다).
    • ViewResolver 는 논리 뷰 이름(logical view name) 을 물리적 리소스 경로로 변환하여 View 객체(ThymeleafView, JstlView 등)으로 변환한다 (ThymeleafViewResolver, InternalResourceViewResolver 등이 ViewResolver 의 대표적 구현체이다)
    • 최종적으로 View 가 생성된다.
  2. 그 다음 DispatcherServlet 은 받았던 HttpServletResponse 와 함께 View 로 forwarding 을 해주고,
    • DispatcherServlet 은 선택된 View 객체의 render() 메서드를 호출하고, 이때 HttpServletRequest, HttpServletResponse 이 전달된다.
  3. View 에서는 ViewResolver 를 통해 알맞은 View 가 생성되고, ViewModel 을 참조하여서 결과들을 가져오고 완전한 문서를 만들게 된다
    • ViewModel 의 데이터를 참조하고, 렌더링 결과는 HttpServletResponse 의 body 로 직접 작성되게 된다.
    • 렌더링이 끝나면 DispatcherServletresponse.flushBuffer() 후 요청-응답 객체는 소멸된다.
Redirecting 과정

이제 Redirecting 이 어떻게 동작하는지 완벽히 이해가 가능하다.

Controller 의 return 값이 "redirect:" 접두어를 포함한 String 일 때

  1. 사용자가 POST 요청을 보낸다. WAS(Tomcat 등)가 이를 받아 HttpServletRequest, HttpServletResponse 객체를 생성하고 DispatcherServlet 에게 요청을 전달한다.

  2. DispatcherServlet 은 요청의 URI 정보를 확인하여 HandlerMapping 에게 어떤 Controller 가 처리해야 하는지 조회를 맡긴다.
    • HandlerMapping 은 요청 정보를 기반으로 적절한 @RequestMapping 메서드를 찾아 HandlerExecutionChain 으로 감싸 반환한다.
    • 이 객체에는 Controller 메서드Interceptor 체인이 포함된다.
  3. DispatcherServletHandlerAdapter 를 통해 해당 Controller 메서드를 실행한다.
    • 이때 HandlerAdapterArgumentResolver, ReturnValueHandler 를 통해 요청 데이터를 파라미터에 바인딩하고, 반환 타입(String, ModelAndView, ResponseBody) 을 분석한다.
  4. Controller 가 비즈니스 로직을 처리한 후, return "redirect:/users/welcome"; 처럼 "redirect:" 로 시작하는 문자열을 반환한다.
    • 이 접두어는 Spring MVC 내부에서 RedirectView 로 자동 변환되도록 트리거한다.
  5. ⭐️ DispatcherServlet 은 반환된 뷰 이름을 확인하고, "redirect:" 접두어가 포함되어 있음을 감지하면 RedirectView 를 생성한다.
    • ⭐️ 이때는 ViewResolver 를 거치지 않는다.
    • ⭐️ 대신 RedirectViewrender() 가 호출되어 HttpServletResponse302 Found 상태 코드와 Location 헤더를 설정한다.
  6. 응답이 클라이언트(브라우저)로 전송된다.
    • 응답 헤더 예시:
      HTTP/1.1 302 Found
      Location: /users/welcome
      
    • 브라우저는 이를 보고 /users/welcome 으로 새로운 GET 요청을 자동으로 보낸다.
  7. 새로운 GET 요청이 발생하면 WAS 는 다시 HttpServletRequest, HttpServletResponse 를 새로 생성하고, 다시 1~4번의 Forwarding 과정과 동일한 DispatcherServlet 처리 흐름을 거친다.

  8. 이때, 이전 요청의 Model 데이터는 유지되지 않는다.
    • 대신 RedirectAttributes.addFlashAttribute() 로 추가된 데이터는 일시적 세션(FlashMap) 에 저장되어 새 요청 시점에 한 번만 사용된다.
    • 이후 요청이 완료되면 FlashMap 데이터는 자동 소멸된다.
  9. 새로운 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 흐름은 완전히 잡은 것이다.