[아이티센 부트캠프] Spring Boot 3

이언덕·2026년 4월 22일

아이티센 부트캠프

목록 보기
65/115
post-thumbnail

Spring MVC에서 자주 쓰는 어노테이션

이 파트는 어노테이션 이름만 외우는 구간이 아니다.
요청값을 어떻게 받고, 모델에 어떻게 담고, 뷰로 어떻게 넘기고, 데이터를 어떻게 응답하는지를 코드 흐름으로 이해하는 구간이다.


그래서 설명 방식도 개념 설명 → 바로 아래 기본 예제 → 마지막에 응용예제 묶음 분석 순서로 간다.
이렇게 해야 초보자가 개념을 먼저 잡고, 짧은 예제로 바로 이해한 뒤, 마지막에 실제 응용 흐름까지 연결해서 볼 수 있다.



어노테이션이란 무엇인가

어노테이션은 영어로 annotation이고, Java 코드에 붙이는 부가 정보이다.
클래스, 메서드, 매개변수처럼 코드에서 특정 역할을 맡는 부분 위에 붙여서 이 코드가 어떤 의미를 가지는지 알려 주는 표지라고 이해하면 된다.
쉽게 말하면 코드 위에 붙이는 설명 표지판이다.


여기서 먼저 구분해야 할 점이 있다.
어노테이션이라는 문법 자체는 Java 문법이다.
하지만 @Controller, @RequestParam, @ModelAttribute 같은 어노테이션은 Spring이 그 의미를 읽고 동작에 활용한다.
즉, 어노테이션은 Java 코드에 붙지만, 실제 처리 방식은 Spring이 그 표시를 해석하면서 정해진다.


그리고 어노테이션 자체가 일을 직접 실행하는 것은 아니다.
@Controller가 붙었다고 해서 @Controller가 직접 요청을 처리하는 것은 아니다.
스프링이 코드를 살펴보다가 @Controller라는 표시를 발견하고, 이 클래스를 웹 요청을 처리하는 컨트롤러로 등록하고 관리하는 것이다.
즉, 어노테이션은 직접 일하는 코드가 아니라, 스프링이 어떻게 처리해야 하는지 알려 주는 표시이다.


예를 들어 @Controller가 클래스 위에 붙어 있으면 스프링은 이 클래스를 컨트롤러로 인식한다.
컨트롤러는 브라우저 요청을 받아서 어떤 메서드를 실행할지 연결하고, 어떤 화면이나 데이터를 응답할지 정하는 역할을 한다.
즉, @Controller는 “이 클래스는 요청 처리 담당자이다”라고 스프링에게 알려 주는 표시이다.


또 @RequestParam이 메서드의 매개변수 앞에 붙어 있으면 스프링은 요청으로 전달된 값을 그 매개변수에 넣어야 한다고 이해한다.
예를 들어 사용자가 ?name=lee처럼 값을 보내면, 스프링은 name이라는 요청값을 찾아서 메서드 매개변수에 연결해 준다.
즉, @RequestParam은 “요청값을 이 변수에 넣어라”라고 알려 주는 표시이다.


이렇게 보면 어노테이션은 단순 장식이 아니다.
어노테이션이 있으면 스프링은 그 표시를 기준으로 객체를 등록하고, 요청 주소를 연결하고, 요청값을 매개변수에 넣고, 반환값을 화면이나 데이터로 처리한다.
즉, 스프링에서 어노테이션은 코드의 역할과 처리 방식을 알려 주는 핵심 표식이다.


다만 모든 어노테이션이 같은 일을 하는 것은 아니다.
어떤 어노테이션은 클래스를 스프링이 관리하게 만들고, 어떤 어노테이션은 요청 주소를 연결하고, 어떤 어노테이션은 요청값을 꺼내는 데 사용된다.
즉, 지금부터 보는 @Controller, @RequestMapping, @RequestParam, @ModelAttribute는 모두 어노테이션이지만, 각각 맡은 역할은 다르다.


따라서 어노테이션은 이름만 외우면 안 된다.
어디에 붙는지, 스프링이 그 표시를 보고 무엇을 하는지, 요청 처리 흐름에서 어떤 역할을 맡는지를 함께 봐야 한다.
이 기준으로 보면 뒤에서 나오는 어노테이션들도 단순 암기가 아니라 요청 처리 흐름 안에서 자연스럽게 이해할 수 있다.



컨트롤러와 컨트롤러 메서드의 역할 (@Controller)

컨트롤러는 브라우저 요청을 실제 코드와 연결하는 역할을 한다.
사용자가 주소를 입력하거나 버튼을 눌러 요청을 보내면, 서버는 그 요청을 처리할 코드를 찾아야 한다.
Spring MVC에서는 이 요청 처리 담당 클래스를 컨트롤러라고 부른다.


여기서 먼저 구분해야 할 점이 있다.
Spring MVC도 Java로 작성한다.
따라서 달라지는 것은 프로그래밍 언어가 아니다.
달라지는 것은 브라우저 요청을 어떤 구조로 받고, 어떤 방식으로 화면까지 연결하느냐이다.


Servlet/JSP 방식에서는 요청을 처리하는 Servlet 클래스에서 HttpServlet, doGet(), doPost(), RequestDispatcher, forward()를 직접 다루는 일이 많았다.
Spring MVC에서는 이 흐름을 @Controller, @GetMapping, Model, 뷰 이름 반환으로 더 간단하게 표현한다.
즉, Spring MVC는 Java를 버리는 것이 아니라, 기존 Servlet/JSP 요청 처리 방식을 더 편하게 감싼 구조라고 이해하면 된다.


그리고 이 섹션에서 가장 먼저 잡아야 할 핵심이 있다.
화면을 반환하는 MVC 컨트롤러라면 클래스 위에 @Controller를 붙이는 것이 기본이다.
그래야 스프링이 이 클래스를 요청 처리 담당자로 인식한다.
다만 문자열이나 JSON 같은 데이터를 바로 응답하는 컨트롤러라면 @RestController를 사용할 수 있다.


즉, @Controller는 주로 화면을 찾아 보여 주는 컨트롤러에서 사용하고, @RestController는 데이터를 바로 응답하는 컨트롤러에서 사용한다.
하지만 모든 Java 파일에 @Controller를 붙이는 것은 아니다.
DTO, Service, Repository, Entity처럼 역할이 다른 클래스에는 @Controller를 붙이지 않는다.


컨트롤러는 무엇을 하는가

컨트롤러는 모든 일을 혼자 처리하는 클래스가 아니다.
요청값을 받고, 필요한 로직을 호출하고, 결과를 화면이나 데이터 응답으로 넘기는 연결자 역할을 한다.


예를 들어 사용자가 /hello라는 주소를 요청했다고 생각하면 된다.
Spring MVC에서는 요청이 먼저 DispatcherServlet이라는 공통 입구로 들어온다.
DispatcherServlet은 요청 주소와 요청 방식을 보고 실행할 컨트롤러 메서드를 찾는다.
그다음 선택된 컨트롤러 메서드가 요청값을 받고, 필요한 데이터를 준비하고, 마지막에 어떤 화면이나 응답 데이터로 이어질지 결정한다.


즉, 컨트롤러는 요청 처리의 전체 흐름을 이어 주는 중간 담당자이다.
컨트롤러가 모든 요청의 첫 입구라는 뜻은 아니다.
전체 입구는 DispatcherServlet이고, 컨트롤러는 그 다음 단계에서 실제 요청 처리를 맡는다.


이 개념은 Servlet/JSP를 배웠다면 더 쉽게 연결할 수 있다.
Servlet에서도 브라우저 요청을 받는 클래스가 있었고, 그 안에서 doGet()이나 doPost()가 실행되었다.
Spring MVC의 컨트롤러도 큰 흐름에서는 요청을 받고 응답으로 연결하는 역할을 한다.
다만 코드 작성 방식이 달라진다.


Servlet에서는 요청 주소가 @WebServlet으로 Servlet 클래스에 연결되고, 요청 방식에 따라 doGet() 또는 doPost()가 실행된다.
Spring MVC에서는 요청 주소가 @GetMapping, @PostMapping, @RequestMapping 같은 매핑 어노테이션을 통해 컨트롤러 메서드와 연결된다.


정리하면 컨트롤러는 아래 흐름을 담당한다.

  • DispatcherServlet이 넘겨준 요청을 실제로 처리한다.
  • 요청값이 있으면 매개변수나 객체로 받는다.
  • 필요한 로직을 호출한다.
  • 화면에 보낼 데이터를 준비한다.
  • 마지막에 뷰 이름이나 응답 데이터를 반환한다.

따라서 컨트롤러는 단순히 파일 하나를 만드는 것이 아니다.
브라우저 요청과 실제 처리 코드를 이어 주는 요청 처리 담당 클래스이다.


컨트롤러 파일이면 무조건 @Controller를 붙이면 되는가

Spring MVC에서 화면을 반환하는 요청 처리 클래스를 만들었다면, 보통 클래스 위에 @Controller를 붙인다.
그래야 스프링이 이 클래스를 웹 요청을 처리하는 컨트롤러로 인식한다.


예를 들어 아래 코드는 /hello 요청을 처리하고, 마지막에 hello.html 화면으로 이동하는 컨트롤러 클래스이다.

// exam01_controller.java
@Controller // 이 클래스가 화면 반환용 컨트롤러임을 스프링에게 알림
public class HelloController {
    @GetMapping("/hello") // GET /hello 요청 처리
    public String hello() {
        return "hello"; // hello.html 화면으로 이동
    }
}

이 코드에서 HelloController는 화면을 반환하는 요청 처리 클래스이다.
그래서 클래스 위에 @Controller를 붙인다.


만약 이 클래스에 @Controller가 없으면 스프링이 이 클래스를 요청 처리 담당자로 인식하지 못할 수 있다.
그러면 메서드 위에 @GetMapping("/hello")이 있어도, 스프링이 이 메서드를 요청과 연결할 대상으로 관리하지 못할 수 있다.


즉, 화면을 반환하는 컨트롤러 역할의 클래스라면 클래스 위에 @Controller를 붙이는 것이 기본이다.
@Controller는 이 파일이 컨트롤러라는 것을 스프링에게 알려 주는 출발점이다.


하지만 모든 Java 파일에 @Controller를 붙이면 되는 것은 아니다.
DTO는 요청값이나 데이터를 담는 객체이다.
Service는 핵심 로직을 처리하는 클래스이다.
Entity는 데이터베이스 테이블과 연결되는 객체이다.
Repository는 데이터베이스 접근을 담당하는 객체이다.
이런 클래스들은 브라우저 요청을 처리하는 컨트롤러 역할이 아니기 때문에 @Controller를 붙이지 않는다.


역할별로 보면 아래처럼 구분할 수 있다.

  • 화면을 반환하는 요청 처리 클래스에는 @Controller를 붙인다.
  • 문자열이나 JSON 데이터를 바로 응답하는 요청 처리 클래스에는 @RestController를 붙일 수 있다.
  • 핵심 로직을 처리하는 서비스 클래스에는 @Service를 붙인다.
  • 데이터를 담기 위한 DTO 클래스에는 보통 @Controller, @Service 같은 스프링 계층 어노테이션을 붙이지 않는다.
  • 데이터베이스 테이블과 연결되는 Entity 클래스에는 @Entity를 붙일 수 있다.
  • 데이터베이스 접근 역할 클래스에는 상황에 따라 @Repository를 붙일 수 있다.

따라서 “컨트롤러 파일이면 무조건 @Controller를 붙이면 되는가?”라는 질문에는 이렇게 답할 수 있다.
화면을 반환하는 요청 처리 클래스라면 @Controller를 붙이는 것이 맞다.
하지만 단순히 파일 이름에 Controller가 들어간다고 붙이는 것이 아니라, 실제 역할이 요청을 처리하고 화면으로 연결하는 클래스인지 확인해야 한다.


파일 이름이 Controller이면 자동으로 컨트롤러가 되는가

파일 이름에 Controller가 들어간다고 해서 자동으로 스프링 컨트롤러가 되는 것은 아니다.
예를 들어 클래스 이름을 HelloController라고 지어도, 스프링이 이 클래스를 컨트롤러로 인식하려면 @Controller 같은 표시가 필요하다.


아래 코드는 이름은 컨트롤러처럼 보이지만, @Controller가 없다.

// exam01_no_controller.java
public class HelloController {
    @GetMapping("/hello") // GET /hello 요청 처리처럼 보임
    public String hello() {
        return "hello"; // hello.html 화면으로 이동하려는 코드
    }
}

이 코드는 파일 이름과 클래스 이름만 보면 컨트롤러처럼 보인다.
하지만 클래스 위에 @Controller가 없다.
그래서 스프링이 이 클래스를 요청 처리 컨트롤러로 등록하지 못할 수 있다.


즉, 메서드 위에 @GetMapping("/hello")이 있다고 해서 무조건 요청이 연결되는 것이 아니다.
스프링이 먼저 이 클래스를 관리 대상으로 인식해야 한다.
그 출발점이 클래스 위의 @Controller이다.


반대로 아래 코드는 스프링이 요청 처리 컨트롤러로 인식할 수 있는 형태이다.

// exam01_with_controller.java
@Controller // 스프링이 이 클래스를 컨트롤러로 인식
public class HelloController {
    @GetMapping("/hello") // GET /hello 요청 처리
    public String hello() {
        return "hello"; // hello.html 화면으로 이동
    }
}

이 코드에서는 @Controller가 클래스 위에 붙어 있다.
그래서 스프링은 이 클래스를 컨트롤러로 관리하고, 그 안에 있는 @GetMapping("/hello")도 요청 매핑 정보로 확인할 수 있다.


정리하면 아래와 같다.

  • 클래스 이름이 Controller라고 해서 자동으로 컨트롤러가 되는 것은 아니다.
  • 스프링이 컨트롤러로 인식하려면 클래스 위에 @Controller가 필요하다.
  • @GetMapping, @PostMapping은 컨트롤러로 등록된 클래스 안에서 요청 메서드를 찾는 기준이 된다.

따라서 파일 이름보다 중요한 것은 클래스의 역할과 그 역할을 스프링에게 알려 주는 어노테이션이다.


@Controller가 붙으면 무엇이 달라지는가

@Controller는 이 클래스가 웹 요청을 처리하는 컨트롤러라는 뜻이다.
스프링은 @Controller가 붙은 클래스를 그냥 지나치지 않는다.
스프링은 @Controller가 붙은 클래스를 관리 대상으로 등록하고, 그 안에 있는 @GetMapping, @PostMapping, @RequestMapping 정보를 함께 읽어서 요청과 메서드를 연결할 준비를 한다.


여기서 스프링 컨테이너는 스프링이 객체를 만들고 보관하고 필요한 곳에 연결해 주는 저장소라고 이해하면 된다.
즉, 개발자가 컨트롤러 객체를 직접 new로 만들지 않아도, 스프링이 미리 객체를 만들어 관리한다.
그래서 @Controller는 단순한 이름표가 아니라 스프링이 이 클래스를 요청 처리 객체로 관리하게 만드는 표시이다.


아래 코드를 보면 HelloController 객체를 개발자가 직접 만들지 않는다.

// exam01_controller_manage.java
@Controller // 스프링이 이 클래스를 컨트롤러 객체로 관리
public class HelloController {
    @GetMapping("/hello") // GET /hello 요청과 hello() 연결
    public String hello() {
        return "hello"; // hello.html 화면으로 이동
    }
}

이 코드에는 new HelloController()가 없다.
그래도 스프링은 @Controller가 붙은 클래스를 찾아 객체로 만들고 관리할 수 있다.


그리고 그 안에 있는 @GetMapping, @PostMapping, @RequestMapping 같은 어노테이션을 보고 어떤 주소 요청과 어떤 메서드를 연결할지 판단한다.
즉, @Controller가 클래스 단위의 출발점이고, 매핑 어노테이션은 메서드 단위의 연결 규칙이다.


이 흐름은 아래처럼 정리할 수 있다.

  • @Controller는 클래스를 스프링이 관리하는 컨트롤러로 등록하게 만든다.
  • 스프링은 컨트롤러 객체를 직접 생성하고 관리할 수 있다.
  • 컨트롤러 안의 @GetMapping, @PostMapping, @RequestMapping을 읽고 요청 주소와 메서드를 연결한다.
  • 개발자는 컨트롤러 객체를 직접 new로 만들지 않아도 된다.

따라서 @Controller는 요청 처리 클래스를 스프링이 찾고 관리하게 만드는 표시이다.
컨트롤러 파일을 만들었는데 @Controller를 붙이지 않으면, 스프링 입장에서는 그 클래스가 요청 처리 담당자인지 알기 어렵다.


@Controller와 @RestController는 무엇이 다른가

@Controller와 @RestController는 둘 다 요청을 처리하는 컨트롤러 클래스를 만들 때 사용한다.
하지만 반환값을 처리하는 방식이 다르다.


@Controller는 보통 화면을 반환하는 컨트롤러에서 사용한다.
예를 들어 return "hello"라고 하면 hello라는 문자열을 화면에 바로 출력하는 것이 아니라, hello.html 같은 뷰를 찾는 흐름으로 이어진다.


반면 @RestController는 데이터를 바로 응답하는 컨트롤러에서 사용한다.
예를 들어 return "hello"라고 하면 뷰 이름으로 해석하지 않고, 응답 본문에 hello라는 문자열을 그대로 보낸다.


아래 코드는 화면을 반환하는 컨트롤러이다.

// exam01_controller_view_basic.java
@Controller // 화면 반환용 컨트롤러
public class ViewController {
    @GetMapping("/view") // GET /view 요청 처리
    public String view() {
        return "hello"; // hello.html 화면으로 이동
    }
}

아래 코드는 데이터를 바로 응답하는 컨트롤러이다.

// exam01_restcontroller_basic.java
@RestController // 데이터 응답용 컨트롤러
public class TextController {
    @GetMapping("/text") // GET /text 요청 처리
    public String text() {
        return "hello"; // 응답 본문에 hello 문자열 출력
    }
}

두 코드 모두 요청을 처리한다.
하지만 첫 번째 코드는 화면을 찾고, 두 번째 코드는 문자열 데이터를 바로 응답한다.


정리하면 아래와 같다.

  • @Controller는 보통 화면을 반환하는 컨트롤러에 사용한다.
  • @RestController는 보통 문자열이나 JSON 같은 데이터를 바로 응답하는 컨트롤러에 사용한다.
  • @Controller에서 문자열 반환은 보통 뷰 이름이다.
  • @RestController에서 문자열 반환은 응답 본문 데이터이다.

따라서 컨트롤러 클래스를 만들 때는 화면을 반환할지, 데이터를 바로 응답할지에 따라 @Controller와 @RestController를 구분해야 한다.
이 섹션에서는 화면을 반환하는 MVC 컨트롤러를 기준으로 @Controller를 설명한다.


@Controller와 @GetMapping은 역할이 다르다

@Controller와 @GetMapping은 둘 다 요청 처리 흐름에서 자주 보인다.
하지만 역할은 다르다.


@Controller는 클래스 위에 붙어서 이 클래스가 요청 처리 담당 컨트롤러라는 것을 알려 준다.
반면 @GetMapping은 메서드 위에 붙어서 어떤 GET 요청이 어떤 메서드로 들어올지 정한다.


아래 코드를 보면 역할 차이가 분명하다.

// exam01_controller_mapping.java
@Controller // 클래스 역할 지정
public class HelloController {
    @GetMapping("/hello") // 메서드와 GET /hello 요청 연결
    public String hello() {
        return "hello"; // hello.html 화면으로 이동
    }
}

@Controller는 HelloController 클래스 전체에 붙어 있다.
그래서 스프링은 이 클래스를 요청 처리 담당 객체로 관리한다.


@GetMapping("/hello")은 hello() 메서드에 붙어 있다.
그래서 GET /hello 요청이 들어왔을 때 이 메서드가 실행된다.


따라서 둘의 역할은 아래처럼 나누어 보면 된다.

  • @Controller는 “이 클래스는 컨트롤러이다”라고 알려 준다.
  • @GetMapping은 “이 메서드는 특정 GET 요청을 처리한다”라고 알려 준다.
  • @Controller는 클래스 단위 표시이다.
  • @GetMapping은 메서드 단위 요청 연결이다.

즉, @Controller와 @GetMapping은 서로 대신할 수 없다.
@Controller는 클래스를 요청 처리 담당자로 등록하고, @GetMapping은 그 안의 메서드를 특정 요청 주소와 연결한다.


DispatcherServlet과 컨트롤러의 차이

가장 헷갈리기 쉬운 부분은 DispatcherServlet과 컨트롤러의 차이이다.
둘 다 요청 처리 흐름에 등장하지만 역할은 다르다.


DispatcherServlet은 Spring MVC에서 웹 요청의 공통 입구 역할을 한다.
브라우저에서 들어온 요청은 먼저 DispatcherServlet을 지난다.
그다음 DispatcherServlet은 이 요청을 실제로 처리할 컨트롤러 메서드를 찾아서 넘긴다.


반면 컨트롤러는 그 요청을 실제로 처리하는 담당자이다.
요청값을 받고, 필요한 작업을 실행하고, 결과를 어떤 뷰나 응답 데이터로 보낼지 정한다.
즉, DispatcherServlet은 요청을 받아 알맞은 담당자를 찾는 입구이고, 컨트롤러는 그 요청을 처리하는 담당자이다.


이 흐름을 /hello 요청으로 보면 아래처럼 이해할 수 있다.

  • 브라우저가 /hello 요청을 보낸다.
  • 요청은 먼저 DispatcherServlet으로 들어간다.
  • DispatcherServlet은 GET /hello를 처리할 컨트롤러 메서드를 찾는다.
  • @Controller가 붙은 클래스 안에서 @GetMapping("/hello")이 붙은 메서드를 찾는다.
  • 찾은 컨트롤러 메서드를 실행한다.
  • 컨트롤러 메서드가 반환한 결과를 기준으로 화면이나 응답 데이터가 만들어진다.

따라서 컨트롤러를 전체 입구라고 이해하면 안 된다.
전체 입구는 DispatcherServlet이고, 컨트롤러는 그 다음 단계에서 요청을 실제로 처리하는 객체이다.


이 차이를 Servlet/JSP와 비교하면 더 쉽게 보인다.
Servlet/JSP에서는 요청 주소가 특정 Servlet에 직접 연결되는 느낌이 강하다.
Spring MVC에서는 요청이 먼저 DispatcherServlet이라는 공통 입구로 들어오고, 그다음 알맞은 컨트롤러 메서드로 전달된다.


정리하면 아래와 같다.

  • DispatcherServlet은 Spring MVC 웹 요청의 공통 입구이다.
  • 컨트롤러는 입구를 지난 요청을 실제로 처리하는 담당자이다.
  • @Controller는 해당 클래스를 스프링이 컨트롤러로 인식하게 만드는 표시이다.
  • @GetMapping, @PostMapping은 컨트롤러 안의 메서드를 요청 주소와 연결하는 표시이다.

이 흐름을 알면 @Controller가 왜 필요한지도 더 분명해진다.
스프링이 컨트롤러를 알아야 DispatcherServlet이 요청을 넘길 대상을 찾을 수 있기 때문이다.


컨트롤러 메서드는 무엇인가

컨트롤러 메서드는 컨트롤러 클래스 안에서 실제 요청을 처리하는 메서드이다.
클래스 전체가 요청 처리 담당자라면, 컨트롤러 메서드는 그 안에서 특정 요청 하나를 맡는 실제 실행 단위이다.


아래 코드를 보면 HelloController는 컨트롤러 클래스이고, hello()는 컨트롤러 메서드이다.

// exam01_controller_method.java
@Controller // 요청 처리 담당 클래스
public class HelloController {
    @GetMapping("/hello") // GET /hello 요청과 연결
    public String hello(Model model) {
        model.addAttribute("msg", "안녕하세요"); // 화면에 넘길 데이터 저장
        return "hello"; // hello.html 화면으로 이동
    }
}

이 코드에서 HelloController는 요청 처리 담당 클래스이다.
hello()는 실제로 GET /hello 요청이 들어왔을 때 실행되는 메서드이다.


즉, 컨트롤러 클래스와 컨트롤러 메서드는 같은 말이 아니다.
컨트롤러 클래스는 요청 처리 메서드들을 담고 있는 큰 단위이다.
컨트롤러 메서드는 그 안에서 특정 요청 하나를 실제로 처리하는 작은 단위이다.


정리하면 아래와 같다.

  • 컨트롤러 클래스는 요청 처리 메서드를 담는 클래스이다.
  • 컨트롤러 메서드는 특정 요청이 들어왔을 때 실제로 실행되는 메서드이다.
  • @Controller는 클래스 위에 붙는다.
  • @GetMapping, @PostMapping, @RequestMapping은 주로 요청 처리 메서드 위에 붙는다.

따라서 컨트롤러 클래스는 요청 처리 담당자이고, 컨트롤러 메서드는 그 담당자 안에서 특정 요청을 처리하는 실행 단위라고 이해하면 된다.


컨트롤러 메서드는 무엇을 받을 수 있는가

컨트롤러 안에서 실제 요청을 처리하는 메서드를 컨트롤러 메서드라고 부른다.
이 메서드는 단순히 문자열 하나만 받는 자리가 아니다.
요청 처리에 필요한 여러 값을 스프링이 매개변수로 넣어 줄 수 있다.


여기서 매개변수는 메서드가 실행될 때 바깥에서 값을 받아 오는 자리이다.
예를 들어 사용자가 보낸 이름, 나이, 요청 객체, 세션 객체 같은 것들이 컨트롤러 메서드의 매개변수로 들어올 수 있다.


아래 코드는 요청 파라미터와 Model을 함께 받는 예제이다.

// exam01_controller_parameter.java
@Controller
public class SearchController {
    @GetMapping("/search") // GET /search 요청 처리
    public String search(@RequestParam("keyword") String keyword, Model model) {
        model.addAttribute("result", keyword); // 요청값을 화면에 넘길 데이터로 저장
        return "searchResult"; // searchResult.html 화면으로 이동
    }
}

이 코드에서 @RequestParam("keyword") String keyword는 요청으로 들어온 keyword 값을 받는 자리이다.
Model model은 화면에 넘길 데이터를 담는 자리이다.


예를 들어 /search?keyword=java로 요청하면 keyword 매개변수에는 java가 들어간다.
그리고 model.addAttribute("result", keyword)를 통해 화면에서는 ${result}로 값을 사용할 수 있다.


컨트롤러 메서드에서 자주 받는 값은 역할별로 나누어 보면 더 쉽다.

  • HttpServletRequest는 요청 객체 자체를 받을 때 사용한다.
  • Locale은 사용자 지역 정보를 받을 때 사용한다.
  • Model은 화면에 넘길 데이터를 담을 때 매개변수로 받을 수 있다.
  • DTO는 여러 요청값을 객체 하나로 묶어 받을 때 사용할 수 있다.
  • HttpSession은 로그인 정보처럼 여러 요청에서 유지해야 하는 값을 다룰 때 사용할 수 있다.
  • @RequestHeader는 요청 헤더 값을 받을 때 사용한다.
  • @CookieValue는 요청에 담긴 쿠키 값을 받을 때 사용한다.
  • BindingResult, Errors는 요청값을 객체에 넣는 과정에서 생긴 오류를 확인할 때 사용한다.

반면 ModelAndView는 보통 컨트롤러 메서드의 매개변수로 받는 객체라기보다, 데이터와 뷰 이름을 한 객체에 담아 반환할 때 자주 사용한다.
예를 들어 Model은 public String hello(Model model)처럼 매개변수로 받는 경우가 많고, ModelAndView는 public ModelAndView hello()처럼 반환 타입으로 쓰는 경우가 많다.


이렇게 보면 컨트롤러 메서드는 요청값만 받는 곳이 아니다.
요청 정보, 화면에 보낼 데이터, 세션 정보, 바인딩 오류 정보까지 함께 받을 수 있는 요청 처리 자리이다.


컨트롤러 메서드는 무엇을 반환하는가

컨트롤러 메서드는 요청을 처리한 뒤 결과를 반환한다.
이 반환값은 상황에 따라 의미가 다르다.


일반적인 @Controller에서 String을 반환하면 보통 그 문자열은 화면에 출력할 글자가 아니다.
대부분 보여 줄 뷰 이름으로 해석된다.
예를 들어 return "hello"라고 작성하면 브라우저에 hello라는 글자를 바로 출력하는 것이 아니라, hello.html 같은 뷰를 찾는 흐름으로 이어진다.


아래 코드를 보면 이 차이가 분명하다.

// exam01_return_view.java
@Controller
public class HelloController {
    @GetMapping("/hello") // GET /hello 요청 처리
    public String hello(Model model) {
        model.addAttribute("msg", "안녕하세요"); // 화면에 보여 줄 데이터 저장
        return "hello"; // hello라는 글자 출력이 아니라 hello.html 뷰 이름
    }
}

이 코드에서 return "hello"는 화면에 hello라는 문자를 출력하라는 뜻이 아니다.
스프링에게 hello.html 같은 뷰를 찾아서 보여 달라는 뜻이다.


ModelAndView를 반환하면 데이터와 뷰 이름을 한 객체에 담아서 반환한다.
이때는 Model에 데이터를 담고 String으로 뷰 이름을 따로 반환하는 대신, ModelAndView 안에 데이터와 뷰 이름을 함께 담는다.


아래처럼 쓸 수 있다.

// exam01_return_modelandview.java
@Controller
public class HelloMavController {
    @GetMapping("/hello-mav") // GET /hello-mav 요청 처리
    public ModelAndView helloMav() {
        ModelAndView mav = new ModelAndView(); // 데이터와 뷰 이름을 담을 객체 생성
        mav.addObject("msg", "안녕하세요"); // 화면에 보여 줄 데이터 저장
        mav.setViewName("hello"); // hello.html 화면으로 이동
        return mav; // 데이터와 뷰 이름을 함께 반환
    }
}

즉, ModelAndView는 컨트롤러 메서드가 받을 수 있는 값이라기보다, 컨트롤러 메서드가 반환할 수 있는 결과 형태로 이해하는 것이 더 자연스럽다.


또 void를 반환하는 경우에는 요청 경로 이름을 기준으로 뷰 이름이 추론될 수 있다.
즉, 반환값은 단순한 결과값이 아니라 스프링이 다음 응답 흐름을 결정하는 기준이 된다.


따라서 컨트롤러 메서드를 볼 때는 세 가지를 함께 봐야 한다.

  • 어떤 주소 요청과 연결되어 있는가.
  • 어떤 값을 매개변수로 받는가.
  • 마지막에 무엇을 반환해서 어디로 응답을 보내는가.

이 세 가지를 같이 보면 컨트롤러 메서드의 흐름이 훨씬 쉽게 보인다.


@Controller에서 문자열 반환과 @ResponseBody의 차이

@Controller에서 String을 반환할 때 가장 많이 헷갈리는 부분이 있다.
일반 @Controller에서 return "hello"는 보통 뷰 이름이다.
하지만 @ResponseBody가 붙으면 그 문자열은 응답 본문으로 바로 나간다.


먼저 일반 @Controller 예제를 보자.

// exam01_controller_view.java
@Controller
public class ViewController {
    @GetMapping("/view") // GET /view 요청 처리
    public String view() {
        return "hello"; // hello.html 화면으로 이동
    }
}

이 코드는 hello.html 화면을 찾는 흐름이다.
브라우저에 hello라는 글자를 바로 출력하는 코드가 아니다.


반면 아래 코드는 다르다.

// exam01_controller_responsebody.java
@Controller
public class TextController {
    @GetMapping("/text") // GET /text 요청 처리
    @ResponseBody
    public String text() {
        return "hello"; // 응답 본문에 hello 문자열 출력
    }
}

이 코드에서는 @ResponseBody가 붙어 있다.
그래서 return "hello"는 뷰 이름이 아니라 응답 데이터이다.
브라우저에는 hello라는 문자열이 그대로 출력된다.


이 차이는 아래처럼 정리할 수 있다.

  • 일반 @Controller에서 String 반환은 보통 뷰 이름이다.
  • @ResponseBody가 붙으면 String 반환은 응답 본문 데이터이다.
  • 화면을 보여 주려면 뷰 이름을 반환한다.
  • 데이터를 바로 보내려면 @ResponseBody나 @RestController를 사용한다.

따라서 컨트롤러 메서드의 반환값은 어노테이션에 따라 화면 이름이 될 수도 있고, 응답 데이터가 될 수도 있다.
이 구분을 하지 않으면 return "hello"가 왜 어떤 때는 화면 이동이고 어떤 때는 글자 출력인지 헷갈리게 된다.


Servlet/JSP 방식과 Spring MVC 방식 비교

아래 두 예제는 같은 /hello 요청을 Servlet/JSP 방식과 Spring MVC 방식으로 비교하기 위한 코드이다.
같은 프로젝트에 두 코드를 동시에 넣어 실행하라는 뜻이 아니다.
요청 처리 흐름이 어떻게 달라지는지 비교해서 보기 위한 예제이다.


먼저 Servlet/JSP 방식에서는 요청을 처리하려면 HttpServlet을 상속한 Servlet 클래스를 만들고, GET 요청이면 doGet() 안에 코드를 작성한다.

// exam01_servlet.java
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        request.setAttribute("msg", "안녕하세요"); // JSP로 넘길 데이터 저장
        RequestDispatcher dispatcher = request.getRequestDispatcher("/WEB-INF/views/hello.jsp"); // 이동할 JSP 지정
        dispatcher.forward(request, response); // request, response를 가지고 JSP로 이동
    }
}
// exam01_hello.jsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>hello</title>
</head>
<body>
<h1>${msg}</h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /hello
// 화면 출력: 안녕하세요

이 방식에서는 Servlet이 요청을 직접 받는다.
그리고 request.setAttribute()로 데이터를 저장한 뒤, RequestDispatcher를 이용해서 JSP로 이동한다.
즉, Servlet/JSP 방식에서는 요청 처리, 데이터 저장, 화면 이동을 개발자가 직접 코드로 연결해야 한다.


이제 같은 흐름을 Spring MVC 방식으로 보면 코드가 달라진다.
HttpServlet을 직접 상속하지 않고, doGet()이라는 정해진 메서드 이름도 쓰지 않는다.
대신 @Controller와 @GetMapping으로 요청 처리 클래스를 표시하고, Model에 데이터를 담은 뒤 뷰 이름을 반환한다.

// exam01_spring.java
@Controller
public class HelloController {
    @GetMapping("/hello") // /hello GET 요청을 이 메서드와 연결
    public String hello(Model model) {
        model.addAttribute("msg", "안녕하세요"); // 뷰에 전달할 데이터 저장
        return "hello"; // hello.html 뷰 이름 반환
    }
}
// exam01_hello.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>hello</title>
</head>
<body>
<h1 th:text="${msg}"></h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /hello
// 화면 출력: 안녕하세요

이 코드에서 @Controller는 이 클래스가 웹 요청을 처리하는 컨트롤러라는 뜻이다.
@GetMapping("/hello")는 /hello로 들어오는 GET 요청을 hello() 메서드와 연결한다.
Model은 화면에 넘길 데이터를 담는 객체이다.
마지막의 return "hello"는 화면에 hello라는 글자를 출력하라는 뜻이 아니라, hello.html 같은 뷰를 찾으라는 뜻이다.


즉, Spring MVC에서는 Servlet에서 직접 작성하던 RequestDispatcher, forward() 같은 코드를 직접 쓰지 않아도 된다.
스프링이 컨트롤러의 반환값을 보고 어떤 뷰로 이동할지 처리해 준다.


두 방식의 차이 정리

Servlet/JSP 방식과 Spring MVC 방식은 요청을 받고 화면으로 넘긴다는 큰 흐름은 같다.
하지만 코드를 작성하는 방식이 달라진다.


차이는 아래처럼 정리할 수 있다.

  • Servlet/JSP 방식에서는 요청을 처리하는 Servlet 클래스가 HttpServlet을 상속한다.
  • Spring MVC에서는 화면을 반환하는 요청 처리 클래스에 @Controller를 붙인다.
  • 데이터 자체를 바로 응답하는 요청 처리 클래스에는 @RestController를 사용할 수 있다.
  • Servlet에서는 doGet(), doPost()에 요청 처리 코드를 작성한다.
  • Spring MVC에서는 원하는 메서드 이름을 정하고 @GetMapping, @PostMapping으로 요청을 연결한다.
  • Servlet에서는 request.setAttribute()로 데이터를 담는다.
  • Spring MVC에서는 Model에 데이터를 담는다.
  • ModelAndView를 사용하면 데이터와 뷰 이름을 한 객체에 함께 담아 반환할 수 있다.
  • Servlet에서는 RequestDispatcher와 forward()로 화면 이동을 직접 처리한다.
  • Spring MVC에서는 뷰 이름을 문자열로 반환하면 스프링이 화면을 찾아 준다.
  • Servlet에서는 개발자가 Servlet 객체 흐름을 직접 다루는 느낌이 강하다.
  • Spring MVC에서는 스프링이 @Controller가 붙은 클래스를 관리하고 요청을 연결해 준다.

따라서 Spring MVC는 기존 웹 처리 흐름을 없앤 것이 아니다.
기존 Servlet/JSP에서 직접 하던 요청 연결, 데이터 전달, 화면 이동을 스프링이 더 간단한 구조로 바꿔 준 것이다.


이 섹션의 핵심은 이것이다.
화면을 반환하는 컨트롤러라면 @Controller를 붙이는 것이 기본이고, 데이터를 바로 응답하는 컨트롤러라면 @RestController를 사용할 수 있다.
컨트롤러 메서드는 @GetMapping, @PostMapping, @RequestMapping으로 특정 요청과 연결되며, 요청값이나 Model 같은 값을 받을 수 있고, 반환값을 통해 뷰 이름이나 응답 데이터를 결정한다.



요청을 주소와 연결하는 방식 (@RequestMapping, @GetMapping, @PostMapping)

브라우저가 서버로 요청을 보내면 스프링은 그 요청을 어떤 메서드가 처리해야 하는지 찾아야 한다.
이때 요청 주소와 컨트롤러 메서드를 연결해 주는 어노테이션이 @RequestMapping, @GetMapping, @PostMapping이다.
쉽게 말하면 이 어노테이션들은 요청 주소를 보고 실행할 메서드를 정해 주는 연결 규칙이다.


이 개념도 Servlet/JSP를 배웠다면 기존 흐름과 비교해서 보면 더 쉽게 이해할 수 있다.
Servlet에서는 보통 @WebServlet("/sample")처럼 요청 주소를 Servlet 클래스에 연결하고, 요청 방식에 따라 doGet()이나 doPost()가 실행되었다.
Spring MVC에서는 @RequestMapping, @GetMapping, @PostMapping을 사용해서 요청 주소와 메서드를 더 직접적으로 연결한다.


여기서 가장 먼저 잡아야 할 핵심이 있다.
@GetMapping을 사용하기 위해 @RequestMapping을 반드시 먼저 써야 하는 것은 아니다.
GET 요청만 처리하고 싶으면 메서드 위에 @GetMapping("/주소")만 써도 된다.
POST 요청만 처리하고 싶으면 메서드 위에 @PostMapping("/주소")만 써도 된다.


클래스 위의 @RequestMapping은 주로 공통 주소를 묶기 위해 사용한다.
즉, @RequestMapping은 필수로 먼저 깔아야 하는 어노테이션이 아니라, 주소를 어떻게 나눠서 적을지 선택하는 방식 중 하나이다.


요청 주소 연결은 왜 필요한가

브라우저가 /sample이라는 주소로 요청을 보냈다고 해서 서버가 자동으로 어떤 메서드를 실행해야 하는지 아는 것은 아니다.
서버는 이 주소를 처리할 코드가 어디에 있는지 알아야 한다.
그래서 요청 주소와 실행할 코드를 연결하는 규칙이 필요하다.


Servlet/JSP 방식에서는 이 연결을 주로 @WebServlet으로 했다.
예를 들어 @WebServlet("/sample")이라고 쓰면 /sample 요청이 해당 Servlet 클래스로 들어간다.
그다음 요청 방식이 GET이면 doGet(), POST이면 doPost()가 실행된다.


Spring MVC에서는 이 연결을 컨트롤러 메서드 단위로 더 세밀하게 나눈다.
@GetMapping("/sample")은 GET /sample 요청을 특정 메서드에 연결한다.
@PostMapping("/sample")은 POST /sample 요청을 다른 메서드에 연결한다.


이 차이는 아래처럼 볼 수 있다.

  • Servlet에서는 주소가 먼저 Servlet 클래스에 연결된다.
  • 그 안에서 요청 방식에 따라 doGet()이나 doPost()가 실행된다.
  • Spring MVC에서는 주소와 요청 방식을 메서드에 직접 연결할 수 있다.

따라서 Spring MVC의 매핑 어노테이션은 단순히 주소를 적는 문법이 아니다.
요청이 어떤 컨트롤러 메서드로 들어갈지 정하는 기준이다.


이제 여기서 중요한 점은 주소와 요청 방식을 따로 봐야 한다는 것이다.
/sample은 요청 주소이고, GET이나 POST는 요청 방식이다.
스프링은 이 둘을 함께 보고 실행할 메서드를 결정한다.


@RequestMapping만 메서드 위에 쓰면 요청 방식 제한 없이 여러 방식이 들어올 수 있다

@RequestMapping은 특정 요청 주소가 어떤 클래스나 메서드로 들어올지 정하는 기본 매핑 어노테이션이다.
예를 들어 /sample이라는 주소로 요청이 들어왔을 때 어떤 메서드가 처리할지 알려 주는 역할을 한다.
즉, @RequestMapping은 요청을 처리할 길을 정해 주는 길 안내판이라고 보면 된다.


먼저 메서드 위에 @RequestMapping("/sample")만 쓰는 경우를 봐야 한다.
이 경우에는 주소만 연결되고, 요청 방식은 따로 제한되지 않는다.

// exam02_requestmapping_only.java
@Controller
public class SampleController {
    @RequestMapping("/sample") // /sample 주소 처리, 요청 방식 제한 없음
    public String sample() {
        return "result"; // result.html 화면으로 이동
    }
}
// 출력결과
// GET /sample  → sample() 실행
// POST /sample → sample() 실행

이 코드에서 @RequestMapping("/sample")은 /sample 주소를 sample() 메서드와 연결한다.
하지만 GET만 처리하겠다는 조건도 없고, POST만 처리하겠다는 조건도 없다.


그래서 대표적으로 GET /sample 요청도 sample() 메서드로 들어올 수 있다.
POST /sample 요청도 sample() 메서드로 들어올 수 있다.
즉, 이 코드는 GET과 POST만 특별히 허용한다는 뜻이 아니라, 요청 방식을 따로 제한하지 않는다는 뜻이다.


즉, 메서드 위에 @RequestMapping("/sample")만 쓰면 요청 방식 제한이 없어서 같은 주소의 여러 요청 방식이 같은 메서드로 들어올 수 있다.
이 방식은 주소 하나로 여러 요청 방식을 한꺼번에 받고 싶을 때는 가능하다.
하지만 GET 처리와 POST 처리를 명확하게 나누고 싶을 때는 헷갈릴 수 있다.


따라서 이 코드를 볼 때는 이렇게 이해해야 한다.

  • /sample 주소는 연결되어 있다.
  • 하지만 GET 전용은 아니다.
  • POST 전용도 아니다.
  • 요청 방식 제한이 없기 때문에 여러 방식이 같은 메서드로 들어올 수 있다.

이 부분이 @GetMapping, @PostMapping과 가장 크게 다른 지점이다.


GET만 처리하고 싶으면 @GetMapping만 써도 된다

GET 요청만 처리하고 싶을 때는 메서드 위에 @GetMapping("/sample")만 써도 된다.
이때 @RequestMapping을 반드시 먼저 써야 하는 것은 아니다.


아래 코드는 /sample 주소로 들어오는 GET 요청만 처리한다.

// exam02_getmapping_only.java
@Controller
public class SampleController {
    @GetMapping("/sample") // GET /sample 요청만 처리
    public String sampleGet() {
        return "getResult"; // getResult.html 화면으로 이동
    }
}
// 출력결과
// GET /sample  → sampleGet() 실행
// POST /sample → sampleGet() 실행 안 됨

이 코드에서 @GetMapping("/sample")은 GET /sample 요청을 sampleGet() 메서드와 연결한다.
따라서 GET /sample 요청이 들어오면 sampleGet()이 실행된다.


하지만 POST /sample 요청은 이 메서드로 들어오지 않는다.
이유는 간단하다.
@GetMapping은 이름 그대로 GET 요청 전용 매핑이기 때문이다.


즉, GET만 처리하려고 일부러 클래스 위에 @RequestMapping을 먼저 쓸 필요는 없다.
메서드 위에 @GetMapping("/sample")만 써도 GET /sample 요청을 처리할 수 있다.


이 코드를 볼 때는 아래처럼 정리하면 된다.

  • @GetMapping("/sample")은 주소와 요청 방식을 한 번에 정한다.
  • 주소는 /sample이다.
  • 요청 방식은 GET이다.
  • 그래서 GET /sample만 이 메서드로 들어온다.

따라서 @GetMapping은 @RequestMapping 뒤에 붙는 보조 어노테이션이 아니다.
GET 요청을 처리할 수 있는 독립적인 매핑 어노테이션이다.


POST만 처리하고 싶으면 @PostMapping만 써도 된다

POST 요청만 처리하고 싶을 때도 마찬가지이다.
메서드 위에 @PostMapping("/sample")만 써도 된다.
이 경우에도 @RequestMapping을 반드시 먼저 쓸 필요는 없다.


아래 코드는 /sample 주소로 들어오는 POST 요청만 처리한다.

// exam02_postmapping_only.java
@Controller
public class SampleController {
    @PostMapping("/sample") // POST /sample 요청만 처리
    public String samplePost() {
        return "postResult"; // postResult.html 화면으로 이동
    }
}
// 출력결과
// GET /sample  → samplePost() 실행 안 됨
// POST /sample → samplePost() 실행

이 코드에서 @PostMapping("/sample")은 POST /sample 요청을 samplePost() 메서드와 연결한다.
따라서 POST /sample 요청이 들어오면 samplePost()가 실행된다.


반대로 GET /sample 요청은 이 메서드로 들어오지 않는다.
@PostMapping은 POST 요청 전용 매핑이기 때문이다.


즉, POST만 처리하려고 일부러 클래스 위에 @RequestMapping을 먼저 쓸 필요는 없다.
메서드 위에 @PostMapping("/sample")만 써도 POST /sample 요청을 처리할 수 있다.


이 코드를 볼 때는 아래처럼 정리하면 된다.

  • @PostMapping("/sample")은 주소와 요청 방식을 한 번에 정한다.
  • 주소는 /sample이다.
  • 요청 방식은 POST이다.
  • 그래서 POST /sample만 이 메서드로 들어온다.

따라서 @PostMapping도 @RequestMapping 뒤에 붙는 보조 어노테이션이 아니다.
POST 요청을 처리할 수 있는 독립적인 매핑 어노테이션이다.


클래스 위의 @RequestMapping은 공통 주소를 묶을 때 사용한다

@RequestMapping은 메서드 위에도 붙을 수 있고, 클래스 위에도 붙을 수 있다.
그런데 클래스 위에 붙을 때는 보통 요청 방식을 제한하려는 목적보다, 여러 메서드가 함께 사용할 공통 주소를 정하려는 목적이 크다.


아래 코드는 /sample이라는 공통 주소를 클래스 위에 적고, 메서드 위에서 GET과 POST를 나누는 예제이다.

// exam02_common_requestmapping.java
@Controller
@RequestMapping("/sample") // 이 컨트롤러의 공통 주소
public class SampleController {
    @GetMapping // GET /sample 요청 처리
    public String sampleGet() {
        return "getResult"; // getResult.html 화면으로 이동
    }

    @PostMapping // POST /sample 요청 처리
    public String samplePost() {
        return "postResult"; // postResult.html 화면으로 이동
    }
}

이 코드에서 클래스 위의 @RequestMapping("/sample")은 공통 주소를 정한다.
그래서 이 컨트롤러 안의 메서드들은 기본적으로 /sample 주소를 기준으로 동작한다.


그다음 메서드 위의 @GetMapping은 GET /sample 요청을 처리한다.
메서드 위의 @PostMapping은 POST /sample 요청을 처리한다.


여기서 중요한 점은 클래스 위의 @RequestMapping("/sample")이 GET과 POST를 처리하기 위해 반드시 필요한 것이 아니라는 점이다.
이 예제에서는 두 메서드가 같은 /sample 주소를 사용하기 때문에, 중복되는 주소를 클래스 위로 빼 둔 것이다.


같은 코드는 아래처럼 메서드마다 주소를 직접 적어도 된다.

// exam02_same_result_without_class_requestmapping.java
@Controller
public class SampleController {
    @GetMapping("/sample") // GET /sample 요청 처리
    public String sampleGet() {
        return "getResult"; // getResult.html 화면으로 이동
    }

    @PostMapping("/sample") // POST /sample 요청 처리
    public String samplePost() {
        return "postResult"; // postResult.html 화면으로 이동
    }
}

이 코드도 위 코드와 같은 흐름으로 동작한다.
GET /sample 요청은 sampleGet()으로 들어가고, POST /sample 요청은 samplePost()로 들어간다.


따라서 두 코드는 주소를 적는 위치만 다르다.
첫 번째 코드는 공통 주소 /sample을 클래스 위에 적었다.
두 번째 코드는 /sample 주소를 메서드마다 직접 적었다.


이 차이는 아래처럼 정리할 수 있다.

  • @GetMapping("/sample")은 메서드 하나가 GET /sample을 직접 처리한다.
  • @PostMapping("/sample")은 메서드 하나가 POST /sample을 직접 처리한다.
  • @RequestMapping("/sample")을 클래스 위에 쓰면 /sample이라는 공통 주소를 묶을 수 있다.
  • 클래스 위의 공통 주소와 메서드 위의 매핑이 합쳐져 최종 요청 주소가 된다.

따라서 클래스 위의 @RequestMapping은 필수 선행 조건이 아니라, 공통 주소를 정리하기 위한 선택 방식이다.


@RequestMapping으로도 GET과 POST를 직접 제한할 수 있다

@RequestMapping은 주소만 연결할 수도 있지만, 요청 방식까지 직접 제한할 수도 있다.
이때는 method 옵션을 사용한다.


예를 들어 GET /sample 요청만 처리하고 싶으면 아래처럼 쓸 수 있다.

// exam02_requestmapping_get.java
@Controller
public class SampleController {
    @RequestMapping(value = "/sample", method = RequestMethod.GET) // GET /sample 요청만 처리
    public String sampleGet() {
        return "getResult"; // getResult.html 화면으로 이동
    }
}

이 코드는 GET /sample 요청만 처리한다.
하지만 표현이 조금 길다.
그래서 같은 의미를 더 짧게 쓰면 아래처럼 된다.

// exam02_getmapping_compare.java
@Controller
public class SampleController {
    @GetMapping("/sample") // GET /sample 요청만 처리
    public String sampleGet() {
        return "getResult"; // getResult.html 화면으로 이동
    }
}

즉, @GetMapping("/sample")은 @RequestMapping(value = "/sample", method = RequestMethod.GET)을 짧게 쓴 형태이다.


POST 요청도 마찬가지이다.

// exam02_requestmapping_post.java
@Controller
public class SampleController {
    @RequestMapping(value = "/sample", method = RequestMethod.POST) // POST /sample 요청만 처리
    public String samplePost() {
        return "postResult"; // postResult.html 화면으로 이동
    }
}

이 코드는 POST /sample 요청만 처리한다.
하지만 같은 의미를 더 짧게 쓰면 아래처럼 된다.

// exam02_postmapping_compare.java
@Controller
public class SampleController {
    @PostMapping("/sample") // POST /sample 요청만 처리
    public String samplePost() {
        return "postResult"; // postResult.html 화면으로 이동
    }
}

즉, @PostMapping("/sample")은 @RequestMapping(value = "/sample", method = RequestMethod.POST)를 짧게 쓴 형태이다.


이제 이 관계를 정확히 정리하면 아래와 같다.

  • @RequestMapping("/sample")은 /sample 주소를 처리하지만 요청 방식 제한은 없다.
  • @RequestMapping(value = "/sample", method = RequestMethod.GET)은 GET /sample 요청만 처리한다.
  • @RequestMapping(value = "/sample", method = RequestMethod.POST)는 POST /sample 요청만 처리한다.
  • @GetMapping("/sample")은 GET /sample 요청만 처리한다.
  • @PostMapping("/sample")은 POST /sample 요청만 처리한다.

따라서 @GetMapping과 @PostMapping은 완전히 다른 기능이 아니라, @RequestMapping에서 요청 방식을 지정한 코드를 더 짧고 읽기 쉽게 만든 전용 어노테이션이다.


다만 초보자 입장에서는 이렇게 이해하면 된다.
GET만 처리하려면 @GetMapping을 쓰고, POST만 처리하려면 @PostMapping을 쓰면 된다.
@RequestMapping을 먼저 써야 하는지부터 고민할 필요는 없다.


Servlet/JSP 방식과 Spring MVC 방식 비교

아래 두 예제는 같은 /sample 요청을 Servlet/JSP 방식과 Spring MVC 방식으로 비교하기 위한 코드이다.
같은 프로젝트에 두 코드를 동시에 넣어 실행하라는 뜻이 아니다.
요청 주소와 요청 방식이 어떻게 메서드로 연결되는지 비교해서 보기 위한 예제이다.


먼저 Servlet/JSP 방식에서는 /sample 주소가 하나의 Servlet 클래스에 연결된다.
그리고 요청 방식에 따라 doGet() 또는 doPost()가 실행된다.

// exam02_servlet.java
@WebServlet("/sample")
public class SampleServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        request.setAttribute("msg", "GET 방식 요청에 대한 응답"); // GET 결과 데이터 저장
        RequestDispatcher dispatcher = request.getRequestDispatcher("/WEB-INF/views/result.jsp"); // 결과 JSP 지정
        dispatcher.forward(request, response); // JSP로 이동
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        request.setAttribute("msg", "POST 방식 요청에 대한 응답"); // POST 결과 데이터 저장
        RequestDispatcher dispatcher = request.getRequestDispatcher("/WEB-INF/views/result.jsp"); // 결과 JSP 지정
        dispatcher.forward(request, response); // JSP로 이동
    }
}
// exam02_result.jsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>result</title>
</head>
<body>
<h1>${msg}</h1>
</body>
</html>
// 출력결과
// 브라우저 요청: GET /sample
// 실행 메서드: doGet()
// 화면 출력: GET 방식 요청에 대한 응답
// 브라우저 요청: POST /sample
// 실행 메서드: doPost()
// 화면 출력: POST 방식 요청에 대한 응답

이 방식에서는 /sample 요청이 SampleServlet으로 들어간다.
그리고 요청 방식이 GET이면 doGet()이 실행되고, POST이면 doPost()가 실행된다.
즉, Servlet 방식에서는 요청 주소는 클래스에 연결되고, 요청 방식은 정해진 메서드 이름으로 나뉜다.


이제 같은 흐름을 Spring MVC 방식으로 보면 요청 주소와 요청 방식을 컨트롤러 메서드에 직접 연결할 수 있다.

// exam02_spring.java
@Controller
@RequestMapping("/sample") // /sample 공통 주소 지정
public class SampleController {
    @GetMapping // GET /sample 요청 처리
    public String getForm() {
        return "getResult"; // GET 결과 화면으로 이동
    }

    @PostMapping // POST /sample 요청 처리
    public String submit() {
        return "postResult"; // POST 결과 화면으로 이동
    }
}
// exam02_getResult.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>getResult</title>
</head>
<body>
<h1>GET 방식 요청에 대한 응답</h1>
</body>
</html>
// exam02_postResult.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>postResult</title>
</head>
<body>
<h1>POST 방식 요청에 대한 응답</h1>
</body>
</html>
// 출력결과
// 브라우저 요청: GET /sample
// 실행 메서드: getForm()
// 화면 출력: GET 방식 요청에 대한 응답
// 브라우저 요청: POST /sample
// 실행 메서드: submit()
// 화면 출력: POST 방식 요청에 대한 응답

이 코드에서 클래스 위의 @RequestMapping("/sample")은 공통 주소를 정한다.
그래서 이 컨트롤러 안의 메서드들은 기본적으로 /sample 요청과 연결된다.


첫 번째 메서드에는 @GetMapping이 붙어 있으므로 GET /sample 요청이 들어왔을 때 실행된다.
두 번째 메서드에는 @PostMapping이 붙어 있으므로 POST /sample 요청이 들어왔을 때 실행된다.


여기서 클래스 위의 @RequestMapping("/sample")은 GET과 POST를 처리하기 위해 반드시 필요한 어노테이션이 아니다.
이 예제에서는 두 메서드가 같은 /sample 주소를 사용하기 때문에 공통 주소를 클래스 위에 빼 둔 것이다.


같은 코드는 아래처럼 메서드마다 주소를 직접 적어도 된다.

// exam02_spring_same_result.java
@Controller
public class SampleController {
    @GetMapping("/sample") // GET /sample 요청 처리
    public String getForm() {
        return "getResult"; // GET 결과 화면으로 이동
    }

    @PostMapping("/sample") // POST /sample 요청 처리
    public String submit() {
        return "postResult"; // POST 결과 화면으로 이동
    }
}

이 코드도 위 코드와 같은 흐름으로 동작한다.
GET /sample 요청은 getForm()으로 들어가고, POST /sample 요청은 submit()으로 들어간다.


즉, Spring MVC 방식에서는 doGet(), doPost()라는 고정된 메서드 이름을 쓰지 않아도 된다.
개발자가 메서드 이름을 정하고, 그 위에 @GetMapping, @PostMapping을 붙여 요청 방식을 연결한다.


두 방식의 차이 정리

Servlet/JSP 방식과 Spring MVC 방식은 같은 주소라도 요청 방식에 따라 다른 처리를 할 수 있다는 큰 흐름은 같다.
하지만 요청을 코드와 연결하는 방식이 달라진다.


차이는 아래처럼 정리할 수 있다.

  • Servlet에서는 @WebServlet("/sample")로 요청 주소를 Servlet 클래스에 연결한다.
  • Servlet에서는 GET 요청이면 doGet(), POST 요청이면 doPost()가 실행된다.
  • Spring MVC에서는 @RequestMapping, @GetMapping, @PostMapping으로 요청 주소와 메서드를 연결한다.
  • @RequestMapping("/sample")만 메서드 위에 쓰면 요청 방식 제한 없이 /sample 요청을 처리할 수 있다.
  • @GetMapping("/sample")만 메서드 위에 쓰면 GET /sample 요청만 처리한다.
  • @PostMapping("/sample")만 메서드 위에 쓰면 POST /sample 요청만 처리한다.
  • 클래스 위의 @RequestMapping("/sample")은 여러 메서드가 함께 사용할 공통 주소를 정할 때 자주 사용한다.
  • Spring MVC에서는 GET 요청이면 @GetMapping이 붙은 메서드, POST 요청이면 @PostMapping이 붙은 메서드가 실행된다.
  • Servlet에서는 RequestDispatcher와 forward()로 결과 화면 이동을 직접 처리한다.
  • Spring MVC에서는 뷰 이름을 반환하면 스프링이 결과 화면을 찾아 준다.

따라서 Spring MVC의 매핑 어노테이션은 기존 요청 처리 흐름을 없앤 것이 아니다.
기존 Servlet에서 주소와 요청 방식으로 처리 메서드를 나누던 흐름을 어노테이션으로 더 읽기 쉽게 표현한 것이다.


이 섹션의 핵심은 이것이다.
@RequestMapping만 메서드 위에 쓰면 요청 방식 제한이 없어서 같은 주소의 여러 요청 방식이 들어올 수 있고, @GetMapping과 @PostMapping은 각각 GET과 POST 요청만 처리한다.
클래스 위의 @RequestMapping은 GET이나 POST를 처리하기 위해 반드시 쓰는 것이 아니라, 공통 주소를 묶기 위해 쓰는 경우가 많다.



요청값을 하나씩 받는 방식 (@RequestParam)

@RequestParam은 요청으로 전달된 값 하나를 컨트롤러 메서드의 매개변수 하나에 연결해서 받는 어노테이션이다.
예를 들어 사용자가 /search?query=java처럼 요청하면, query라는 요청값을 메서드 안의 변수로 받을 수 있다.
즉, @RequestParam은 요청값을 하나씩 매개변수로 받아서 쓰는 방식이라고 이해하면 된다.


이 개념도 Servlet/JSP에서 배운 흐름과 비교하면 더 쉽게 이해할 수 있다.
Servlet에서는 요청값을 받을 때 request.getParameter("query")처럼 요청 객체에서 직접 꺼냈다.
Spring MVC에서는 @RequestParam("query") String query처럼 매개변수 앞에 어노테이션을 붙여 요청값을 바로 받을 수 있다.


즉, Spring MVC에서는 Servlet에서 직접 꺼내던 요청 파라미터를 컨트롤러 메서드의 매개변수로 바로 연결할 수 있다.
값이 적을 때는 간단하고 직관적이지만, 값이 많아지면 매개변수가 길어질 수 있으므로 뒤에서 배우는 객체 바인딩 방식과 비교해서 봐야 한다.


요청 파라미터와 매개변수의 관계

요청 파라미터는 사용자가 서버에 보내는 이름과 값의 묶음이다.
예를 들어 /search?query=java에서 요청 파라미터 이름은 query이고, 값은 java이다.


컨트롤러 메서드에서는 이 값을 매개변수로 받을 수 있다.
매개변수는 메서드가 실행될 때 바깥에서 들어오는 값을 담는 자리이다.
즉, 요청 파라미터가 브라우저에서 서버로 온 값이라면, 매개변수는 그 값을 자바 코드 안에서 쓰기 위해 받는 자리이다.


Servlet에서는 이 값을 직접 꺼내야 했다.
request.getParameter("query")라고 작성해야 요청 안에 들어 있는 query 값을 꺼낼 수 있었다.
반면 Spring MVC에서는 @RequestParam("query") String query라고 작성하면 스프링이 요청값을 찾아 매개변수에 넣어 준다.


이 흐름은 아래처럼 이해하면 된다.

  • 브라우저가 /search?query=java 요청을 보낸다.
  • 요청 안에는 query라는 이름과 java라는 값이 들어 있다.
  • Servlet에서는 request.getParameter("query")로 직접 꺼낸다.
  • Spring MVC에서는 @RequestParam("query")가 붙은 매개변수로 바로 받는다.

따라서 @RequestParam은 Servlet의 getParameter() 흐름을 더 짧고 명확하게 바꾼 방식으로 볼 수 있다.


이름이 같을 때와 다를 때

요청 파라미터 이름과 메서드 매개변수 이름이 같으면 스프링이 값을 자동으로 연결할 수 있다.
예를 들어 요청이 /search?query=java이고 매개변수 이름도 query라면, 스프링은 같은 이름을 기준으로 값을 넣어 준다.


하지만 이름이 다르면 직접 연결 규칙을 적어야 한다.
예를 들어 요청 파라미터 이름은 num인데 자바 변수 이름은 number라면, 스프링이 둘을 같은 값이라고 바로 판단하기 어렵다.
이럴 때 @RequestParam("num") int number처럼 작성해서 요청 이름 num을 자바 변수 number에 넣으라고 알려 준다.


이 차이는 아래처럼 정리할 수 있다.

  • 요청 이름과 변수 이름이 같으면 자동 연결될 수 있다.
  • 요청 이름과 변수 이름이 다르면 @RequestParam("요청이름")으로 직접 연결한다.
  • 이름이 다른데 연결 규칙을 적지 않으면 원하는 값이 들어오지 않을 수 있다.

따라서 @RequestParam을 볼 때는 요청에서 넘어오는 이름과 메서드에서 받는 변수 이름이 같은지를 먼저 확인해야 한다.


필수 여부와 기본값 처리

요청값은 항상 들어온다고 보장할 수 없다.
사용자가 값을 입력하지 않을 수도 있고, 선택 항목이라서 아예 보내지지 않을 수도 있다.
그래서 @RequestParam에는 값이 없을 때 어떻게 처리할지 정하는 옵션이 있다.


required=false는 해당 요청값이 없어도 요청 처리를 계속하겠다는 뜻이다.
즉, 값이 꼭 필요하지 않은 선택 파라미터일 때 사용할 수 있다.


defaultValue는 요청값이 없거나 비어 있을 때 사용할 기본값을 정하는 옵션이다.
예를 들어 @RequestParam(defaultValue="10") int number라고 하면, number 값이 전달되지 않아도 기본값 10을 넣어 처리할 수 있다.


둘의 차이는 아래처럼 보면 쉽다.

  • required=false는 값이 없어도 허용한다.
  • defaultValue는 값이 없을 때 대신 넣을 값을 정한다.
  • defaultValue를 사용하면 값이 없을 때도 계산이나 출력에 사용할 값이 생긴다.

즉, @RequestParam은 값을 받는 것뿐 아니라, 값이 없을 때의 처리 방식까지 정할 수 있다.
다만 숫자형 값은 값이 없을 때 더 조심해야 하므로 다음 내용을 같이 봐야 한다.


숫자형 파라미터는 왜 더 조심해야 하는가

Servlet에서 request.getParameter()로 꺼낸 값은 항상 문자열로 들어온다.
그래서 숫자로 계산하려면 Integer.parseInt() 같은 코드로 직접 변환해야 했다.


Spring MVC에서 @RequestParam int number처럼 작성하면 스프링이 요청값을 숫자로 바꿔서 넣어 줄 수 있다.
하지만 값이 없을 때는 문제가 생길 수 있다.
기본형 숫자인 int는 값이 없다는 뜻의 null을 담을 수 없기 때문이다.


예를 들어 /querystring2처럼 number 값을 보내지 않았는데 컨트롤러가 int number를 받으려고 하면 오류가 날 수 있다.
왜냐하면 스프링은 요청에서 값을 찾지 못했는데, int에는 null을 넣을 수 없기 때문이다.
즉, 기본형 숫자는 “값이 없음”을 그대로 담을 수 없다.


이 문제를 피하려면 상황에 따라 아래 방법을 사용할 수 있다.

  • 반드시 값이 있어야 한다면 요청에서 값을 빠뜨리지 않게 한다.
  • 값이 없을 수도 있다면 Integer처럼 null을 담을 수 있는 타입을 사용한다.
  • 값이 없을 때 기본값이 필요하다면 defaultValue를 사용한다.

여기서 Integer는 기본형 int를 객체처럼 다루는 타입이다.
int는 값이 반드시 있어야 하지만, Integer는 값이 없음을 의미하는 null을 담을 수 있다.
따라서 숫자형 요청값은 값이 없을 가능성까지 생각해서 타입과 옵션을 정해야 한다.


@RequestParam을 언제 쓰면 좋은가

@RequestParam은 요청값을 하나씩 분명하게 받고 싶을 때 사용하기 좋다.
검색어, 페이지 번호, 정렬 기준처럼 값이 몇 개 되지 않을 때는 @RequestParam으로 받으면 코드가 읽기 쉽다.


하지만 회원가입처럼 이름, 전화번호, 아이디, 비밀번호 등 값이 많아지면 매개변수가 너무 길어진다.
이럴 때는 값을 하나씩 받는 것보다 DTO 같은 객체 하나로 묶어 받는 방식이 더 자연스럽다.
즉, @RequestParam은 낱개 값 중심, @ModelAttribute는 객체 중심으로 비교해서 이해하면 된다.


Servlet/JSP 방식과 Spring MVC 방식 비교

아래 두 예제는 같은 /search?query=java 요청을 Servlet/JSP 방식과 Spring MVC 방식으로 비교하기 위한 코드이다.
같은 프로젝트에 두 코드를 동시에 넣어 실행하라는 뜻이 아니다.
요청 파라미터를 어떻게 꺼내고 화면으로 넘기는지 비교해서 보기 위한 예제이다.


먼저 Servlet/JSP 방식에서는 request.getParameter()로 요청값을 직접 꺼낸다.
그리고 request.setAttribute()로 JSP에 넘길 데이터를 저장한 뒤 forward()로 화면을 연결한다.

// exam03_servlet.java
@WebServlet("/search")
public class SearchServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        String query = request.getParameter("query"); // query 요청값 직접 꺼내기
        request.setAttribute("result", query); // JSP로 넘길 데이터 저장
        RequestDispatcher dispatcher = request.getRequestDispatcher("/WEB-INF/views/result.jsp"); // 결과 JSP 지정
        dispatcher.forward(request, response); // JSP로 이동
    }
}
// exam03_result.jsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>result</title>
</head>
<body>
<h1>${result}</h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /search?query=java
// 화면 출력: java

이 방식에서는 요청값을 직접 꺼내는 코드가 필요하다.
request.getParameter("query")는 요청 안에서 query라는 이름의 값을 찾는다.
그리고 그 값을 result라는 이름으로 저장한 뒤 JSP에서 ${result}로 출력한다.


이제 같은 흐름을 Spring MVC 방식으로 보면 요청값을 매개변수로 바로 받을 수 있다.

// exam03_spring.java
@Controller
public class ParamController {
    @GetMapping("/search") // /search 요청 처리
    public String search(@RequestParam("query") String query, Model model) {
        model.addAttribute("result", query); // 요청값을 모델에 담음
        return "result"; // 결과 화면으로 이동
    }
}
// exam03_result.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>result</title>
</head>
<body>
<h1 th:text="${result}"></h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /search?query=java
// 화면 출력: java

이 코드에서 /search?query=java로 요청하면 query 매개변수에는 java가 들어간다.
@RequestParam("query")가 요청 파라미터 이름 query와 메서드 매개변수 query를 연결해 주기 때문이다.


그리고 컨트롤러는 그 값을 Model에 result라는 이름으로 담아 결과 화면으로 넘긴다.
result.html에서는 ${result} 값을 출력하므로 화면에는 java가 보인다.
즉, 이 흐름은 요청값 → 매개변수 → 모델 → 화면으로 이어진다.


여기서 Model은 왜 필요한가

@RequestParam으로 받은 단순 값은 컨트롤러 메서드 안에서 바로 사용할 수 있는 값이다.
예를 들어 @RequestParam("query") String query라고 작성하면, 요청으로 들어온 query 값은 query 매개변수에 들어간다.
하지만 이 값이 자동으로 뷰에서 사용할 수 있는 데이터가 되는 것은 아니다.


컨트롤러에서 받은 값을 결과 화면에 보여 주려면, 그 값을 뷰에서 사용할 수 있는 저장 공간에 다시 담아야 한다.
Spring MVC에서는 이때 Model을 사용한다.
Model은 컨트롤러가 뷰에 전달할 데이터를 담는 객체이다.
쉽게 말하면 컨트롤러와 화면 사이에서 데이터를 옮겨 주는 전달 상자이다.


Servlet/JSP에서는 화면에 넘길 데이터를 request.setAttribute("result", query)처럼 request 객체에 담았다.
그리고 forward()로 JSP에 이동하면, JSP에서 ${result}로 그 값을 꺼내 쓸 수 있었다.


Spring MVC에서는 같은 역할을 model.addAttribute("result", query)가 한다.
model.addAttribute()는 뷰에서 사용할 이름과 값을 함께 저장한다.
예를 들어 model.addAttribute("result", query)라고 작성하면, 결과 화면에서는 ${result}라는 이름으로 query 값을 사용할 수 있다.


또 하나 중요한 점은 Model에 담은 값이 무조건 오래 유지되는 값은 아니라는 점이다.
일반적인 Model 값은 현재 요청에서 뷰를 보여 줄 때 사용하는 값이다.
즉, Servlet/JSP의 request.setAttribute()처럼 이번 요청의 결과 화면에 데이터를 넘기는 용도로 이해하면 된다.
로그인 정보처럼 여러 요청 동안 유지해야 하는 값은 Model이 아니라 Session 같은 별도 저장 공간을 사용해야 한다.


즉, @RequestParam과 Model은 역할이 다르다.
@RequestParam은 요청값을 컨트롤러 메서드로 가져오는 역할이다.
Model은 컨트롤러에서 만든 값이나 받은 값을 뷰로 넘기는 역할이다.


이 흐름은 아래처럼 정리할 수 있다.

  • @RequestParam은 요청값을 메서드 매개변수로 받는다.
  • 받은 값은 컨트롤러 메서드 안에서 사용할 수 있다.
  • 그 값을 화면에 보여 주려면 Model에 다시 담아야 한다.
  • Servlet/JSP의 request.setAttribute()와 Spring MVC의 model.addAttribute()는 둘 다 뷰에 데이터를 넘기기 위한 역할을 한다.
  • 일반적인 Model 값은 현재 요청의 결과 화면에서 사용하는 값이다.

따라서 @RequestParam은 값을 받는 단계이고, Model은 받은 값을 화면으로 넘기는 단계라고 이해하면 된다.


두 방식의 차이 정리

Servlet/JSP 방식과 Spring MVC 방식은 요청 파라미터를 받아서 화면에 출력한다는 큰 흐름은 같다.
하지만 요청값을 꺼내는 방식이 달라진다.


차이는 아래처럼 정리할 수 있다.

  • Servlet에서는 request.getParameter("query")로 요청값을 직접 꺼낸다.
  • Spring MVC에서는 @RequestParam("query")로 요청값을 매개변수에 바로 연결한다.
  • Servlet에서는 숫자값을 받으면 Integer.parseInt() 같은 변환을 직접 해야 한다.
  • Spring MVC에서는 매개변수 타입을 보고 기본적인 타입 변환을 처리해 줄 수 있다.
  • Servlet에서는 request.setAttribute()로 화면에 넘길 데이터를 담는다.
  • Spring MVC에서는 Model에 화면에 넘길 데이터를 담는다.

따라서 @RequestParam은 기존 Servlet의 getParameter() 흐름을 없앤 것이 아니다.
요청값을 직접 꺼내던 코드를 어노테이션과 매개변수 연결 방식으로 더 간단하게 표현한 것이다.


이 섹션의 핵심은 이것이다.
@RequestParam은 요청값 하나를 매개변수 하나에 직접 연결하고, 이름 연결과 필수 여부, 기본값 처리까지 함께 정할 수 있는 어노테이션이다.
다만 받은 값을 화면에 보여 주려면 Model에 담아 뷰로 넘겨야 한다.



요청값을 객체로 묶어 받는 방식 (@ModelAttribute)

@ModelAttribute는 여러 요청값을 객체 하나에 묶어서 받는 어노테이션이다.
앞에서 본 @RequestParam은 요청값을 하나씩 매개변수로 받는 방식이었다.
반면 @ModelAttribute는 이름, 나이, 주소처럼 서로 관련 있는 여러 값을 DTO 같은 객체 하나에 담아 받는다.


여기서 DTO는 데이터를 담아 옮기기 위한 객체이다.
쉽게 말하면 여러 입력값을 따로따로 들고 다니지 않고, 하나의 그릇에 담아서 이동시키는 구조이다.
예를 들어 이름, 나이, 주소를 각각 따로 받는 대신 UserDTO 객체 하나에 묶어 받을 수 있다.


이 개념은 Servlet/JSP에서 배운 흐름과 비교하면 더 쉽게 이해할 수 있다.
Servlet/JSP에서도 여러 요청값을 하나의 의미 있는 데이터로 다루고 싶을 때 DTO를 만들 수 있었다.
다만 그때는 개발자가 요청 객체에서 값을 직접 꺼내고, 직접 DTO 객체를 만들고, 직접 setter로 값을 넣어야 했다.


예를 들어 request.getParameter("name")은 요청 객체 안에서 name이라는 이름의 값을 개발자가 직접 찾는 코드이다.
즉, 요청값이 자동으로 객체에 들어오는 것이 아니라, 개발자가 name, age, address를 하나씩 꺼내는 방식이다.
그다음 여러 값을 하나의 회원 정보로 묶고 싶다면 new UserDTO()로 객체를 만들고 setName(), setAge(), setAddress()를 직접 호출해야 했다.


반대로 화면에 값만 각각 보여 주면 된다면 request.setAttribute("name", name), request.setAttribute("age", age)처럼 각각 담아 보낼 수도 있었다.
하지만 이 방식은 이름, 나이, 주소를 하나의 회원 정보 객체로 다루는 구조는 아니다.


Spring MVC에서는 @ModelAttribute를 사용하면 이 객체 생성과 요청값 연결 과정을 Spring이 처리해 준다.
즉, @ModelAttribute는 브라우저가 보낸 여러 요청값을 DTO 객체 하나에 바인딩해서 컨트롤러 메서드의 매개변수로 전달하는 방식이다.


DTO는 왜 필요한가

DTO는 여러 값을 하나로 묶어 담기 위해 사용한다.
입력값이 적을 때는 @RequestParam으로 하나씩 받아도 크게 복잡하지 않다.
하지만 입력값이 많아지면 컨트롤러 메서드의 매개변수가 길어지고, 관련 있는 값들이 흩어져 보인다.


예를 들어 이름, 나이, 주소를 각각 받는다고 생각해 보자.

// RequestParamManyExample.java
@PostMapping("/user/add") // 회원 등록 요청 처리
public String addUser(@RequestParam("name") String name,
                      @RequestParam("age") int age,
                      @RequestParam("address") String address) {
    System.out.println(name); // 이름 요청값 확인
    System.out.println(age); // 나이 요청값 확인
    System.out.println(address); // 주소 요청값 확인
    return "userComplete"; // 처리 완료 화면으로 이동
}
// 출력결과
// lee
// 20
// seoul

이 방식은 값이 세 개 정도일 때는 이해하기 쉽다.
하지만 회원가입처럼 아이디, 비밀번호, 이름, 전화번호, 주소, 이메일처럼 값이 많아지면 매개변수가 계속 늘어난다.


매개변수가 많아지면 아래 문제가 생긴다.

  • 컨트롤러 메서드가 너무 길어진다.
  • 어떤 값들이 서로 관련 있는지 한눈에 보기 어렵다.
  • 같은 입력값 묶음을 다른 메서드에서도 써야 할 때 반복이 많아진다.
  • 값을 서비스로 넘기거나 화면으로 다시 넘길 때 하나씩 따로 넘겨야 해서 코드가 지저분해진다.

이럴 때 관련 있는 값을 하나의 객체로 묶으면 코드 흐름이 더 정리된다.
아래 코드는 바인딩용 완성 코드라기보다, 이름, 나이, 주소를 하나의 객체 구조로 묶는다는 점을 보기 위한 예시이다.

// UserDTO.java
public class UserDTO {
    private String name; // 이름 값 저장
    private int age; // 나이 값 저장
    private String address; // 주소 값 저장
}

UserDTO는 이름, 나이, 주소를 하나로 묶어 담는 객체이다.
컨트롤러는 이 객체 하나만 받으면 여러 요청값을 함께 다룰 수 있다.


다만 실제 @ModelAttribute 바인딩에서 DTO를 사용할 때는 값이 들어가고, 다시 꺼내지는 구조까지 필요하다.
그래서 보통 기본 생성자, getter, setter를 함께 준비한다.
직접 작성하면 아래처럼 길어진다.

// UserDTO.java
public class UserDTO {
    private String name; // 이름 값 저장
    private int age; // 나이 값 저장
    private String address; // 주소 값 저장

    public UserDTO() {
        // 기본 생성자
    }

    public String getName() {
        return name; // 이름 값 반환
    }

    public void setName(String name) {
        this.name = name; // 이름 값 저장
    }

    public int getAge() {
        return age; // 나이 값 반환
    }

    public void setAge(int age) {
        this.age = age; // 나이 값 저장
    }

    public String getAddress() {
        return address; // 주소 값 반환
    }

    public void setAddress(String address) {
        this.address = address; // 주소 값 저장
    }
}

이 코드는 동작을 이해하기에는 좋지만, 매번 직접 작성하기에는 길다.
그래서 Lombok을 사용하면 같은 구조를 더 짧게 작성할 수 있다.

// UserDTO.java
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@NoArgsConstructor // 기본 생성자 자동 생성
@AllArgsConstructor // 모든 필드를 받는 생성자 자동 생성
public class UserDTO {
    private String name; // 이름 값 저장
    private int age; // 나이 값 저장
    private String address; // 주소 값 저장
}

여기서 중요한 점은 Spring이 getter, setter, 생성자를 만들어 주는 것이 아니라는 점이다.
Lombok이 컴파일 과정에서 해당 코드를 만들어 준다.
그리고 Spring은 그렇게 만들어진 기본 생성자, 전체 필드 생성자, setter, getter를 사용할 수 있다.


정리하면 아래와 같다.

  • DTO는 여러 요청값을 하나의 객체로 묶기 위해 사용한다.
  • @RequestParam이 많아질수록 DTO를 쓰는 편이 코드가 정리된다.
  • @Getter는 값을 읽는 getter를 만들어 준다.
  • @Setter는 값을 넣거나 수정하는 setter를 만들어 준다.
  • @NoArgsConstructor는 비어 있는 객체를 만들 수 있는 기본 생성자를 만들어 준다.
  • @AllArgsConstructor는 모든 값을 한 번에 받아 객체를 만들 수 있는 생성자를 만들어 준다.

따라서 DTO는 단순히 클래스를 하나 더 만드는 것이 아니다.
관련 있는 요청값을 하나의 의미 있는 데이터 묶음으로 다루기 위한 그릇이다.


HTML form에서 보낸 값이 DTO까지 들어가는 전체 흐름

@ModelAttribute를 정확히 이해하려면 실제로 작성하는 파일 기준으로 흐름을 봐야 한다.
여기서 직접 작성하는 핵심 파일은 보통 아래 세 가지이다.

  • userForm.html: 사용자가 값을 입력하고 서버로 전송하는 화면
  • UserDTO.java: 요청값을 하나로 묶어 담는 객체
  • UserController.java: 요청을 받아 처리하는 컨트롤러

이 구간에서는 가장 기본적인 HTML form 요청 흐름을 기준으로 본다.
실제 Spring MVC에서는 이미 Model에 있는 객체를 가져오거나, Session에 있는 객체를 사용하는 경우도 있다.
하지만 처음에는 이 예제처럼 사용자가 입력한 form 요청값이 DTO 객체에 들어가는 흐름부터 이해하면 된다.


먼저 입력 화면은 아래처럼 작성한다.

// userForm.html
<form action="/user/add" method="post">
    <input type="text" name="name"> <!-- 서버로 name 값 전송 -->
    <input type="number" name="age"> <!-- 서버로 age 값 전송 -->
    <input type="text" name="address"> <!-- 서버로 address 값 전송 -->
    <button type="submit">등록</button> <!-- form 전송 -->
</form>

사용자가 화면에서 아래처럼 입력했다고 생각하면 된다.

// 입력값 예시
// name 입력칸: lee
// age 입력칸: 20
// address 입력칸: seoul

그러면 브라우저는 서버로 아래 값을 보낸다.

// 브라우저가 보내는 요청
// 요청 방식: POST
// 요청 주소: /user/add
// 요청값: name=lee&age=20&address=seoul

여기서 input의 name 속성이 요청값 이름이 된다.
name="name"은 서버로 name=lee 값을 보낸다는 뜻이다.
name="age"는 서버로 age=20 값을 보낸다는 뜻이다.
name="address"는 서버로 address=seoul 값을 보낸다는 뜻이다.


그다음 요청값을 담을 DTO를 만든다.

// UserDTO.java
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@NoArgsConstructor // 기본 생성자 자동 생성
@AllArgsConstructor // 모든 필드를 받는 생성자 자동 생성
public class UserDTO {
    private String name; // name 요청값을 저장할 자리
    private int age; // age 요청값을 저장할 자리
    private String address; // address 요청값을 저장할 자리
}

이 DTO에서 name, age, address는 요청값이 저장될 자리이다.
즉, name=lee는 name 속성에 들어가고, age=20은 age 속성에 들어가고, address=seoul은 address 속성에 들어갈 수 있다.


그다음 컨트롤러는 아래처럼 작성한다.

// UserController.java
@Controller
public class UserController {
    @PostMapping("/user/add") // POST /user/add 요청 처리
    public String addUser(@ModelAttribute UserDTO user, Model model) {
        model.addAttribute("user", user); // 화면에서 사용할 객체 저장
        return "userView"; // 결과 화면으로 이동
    }
}

이제 실제 요청 흐름을 차례대로 보면 아래와 같다.

  • 사용자가 userForm.html에서 값을 입력한다.
  • 등록 버튼을 누르면 브라우저가 POST /user/add 요청을 보낸다.
  • 요청값으로 name=lee, age=20, address=seoul이 함께 전송된다.
  • 요청은 먼저 DispatcherServlet으로 들어간다.
  • DispatcherServlet은 POST /user/add를 처리할 컨트롤러 메서드를 찾는다.
  • @PostMapping("/user/add")가 붙은 addUser() 메서드를 찾는다.
  • addUser() 메서드의 매개변수에 @ModelAttribute UserDTO user가 있는 것을 확인한다.
  • Spring은 UserDTO 객체를 준비한다.
  • 이때 대표적으로 setter 바인딩 또는 생성자 바인딩 방식으로 객체에 값을 넣을 수 있다.
  • 값이 들어간 UserDTO 객체가 컨트롤러 메서드의 user 매개변수로 들어온다.

여기서 가장 중요한 부분은 “요청값 이름과 DTO 속성 이름을 비교한 뒤 어떻게 저장되는가”이다.
이 저장 방식은 크게 setter 바인딩과 생성자 바인딩으로 나누어 볼 수 있다.
바로 다음 구간에서 이 차이를 정확히 보면 된다.


객체 바인딩은 setter 바인딩과 생성자 바인딩으로 나누어 볼 수 있다

객체 바인딩은 브라우저가 보낸 요청값을 Spring이 DTO 객체 안에 넣어 주는 과정이다.
여기서 값이 들어가는 방식은 크게 두 가지로 나누어 볼 수 있다.

setter 바인딩은 기본 생성자와 setter를 사용한다

setter 바인딩은 기본 생성자로 빈 객체를 먼저 만들고, 그다음 setter를 호출해서 값을 넣는 방식이다.


아래처럼 DTO에 @Getter, @Setter, @NoArgsConstructor가 있다고 생각하면 된다.

// UserDTO.java
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@NoArgsConstructor // 기본 생성자 자동 생성
public class UserDTO {
    private String name; // name 요청값 저장
    private int age; // age 요청값 저장
    private String address; // address 요청값 저장
}

이 코드는 Lombok 때문에 실제로 아래 코드가 있는 것처럼 동작한다.

// UserDTO.java
public class UserDTO {
    private String name; // name 요청값 저장
    private int age; // age 요청값 저장
    private String address; // address 요청값 저장

    public UserDTO() {
        // 기본 생성자
    }

    public String getName() {
        return name; // name 값 읽기
    }

    public int getAge() {
        return age; // age 값 읽기
    }

    public String getAddress() {
        return address; // address 값 읽기
    }

    public void setName(String name) {
        this.name = name; // name 값 저장
    }

    public void setAge(int age) {
        this.age = age; // age 값 저장
    }

    public void setAddress(String address) {
        this.address = address; // address 값 저장
    }
}

여기서 중요한 점은 Spring이 getter, setter, 생성자를 만들어 주는 것이 아니라는 점이다.
Lombok이 컴파일 과정에서 해당 코드를 만들어 준다.
그리고 Spring은 그렇게 만들어진 기본 생성자와 setter를 이용해 값을 넣을 수 있다.


이제 사용자가 HTML 입력 화면에서 아래 값을 보냈다고 생각하면 된다.

// 요청값
// name=lee
// age=20
// address=seoul

setter 바인딩에서는 흐름이 이렇게 이어진다.

  • Spring이 UserDTO() 기본 생성자로 비어 있는 객체를 만든다.
  • 요청값 name=lee를 확인한다.
  • UserDTO에 setName()이 있으므로 setName("lee")로 값을 넣는다.
  • 요청값 age=20을 확인한다.
  • age 속성 타입이 int이므로 숫자로 변환할 수 있으면 20으로 바꾼다.
  • UserDTO에 setAge()가 있으므로 setAge(20)으로 값을 넣는다.
  • 요청값 address=seoul을 확인한다.
  • UserDTO에 setAddress()가 있으므로 setAddress("seoul")로 값을 넣는다.

이 흐름을 실제 값 기준으로 보면 아래와 같다.

// setter 바인딩 후 UserDTO 객체 상태
// user.name    → "lee"
// user.age     → 20
// user.address → "seoul"

즉, setter 바인딩에서는 기본 생성자가 빈 객체를 만들고, setter가 요청값을 객체 안에 저장한다.
이 방식에서는 @NoArgsConstructor와 @Setter가 중요하다.
@Getter는 저장된 값을 컨트롤러, 서비스, 화면에서 읽을 때 필요하다.


생성자 바인딩은 AllArgsConstructor 생성자를 사용할 수 있다

생성자 바인딩은 빈 객체를 먼저 만들고 setter로 값을 넣는 것이 아니라, 생성자를 호출할 때 요청값을 생성자 매개변수에 넣어서 객체를 만드는 방식이다.


아래처럼 DTO에 @Getter와 @AllArgsConstructor만 있다고 생각하면 된다.

// UserDTO.java
@Getter // getter 자동 생성
@AllArgsConstructor // 모든 필드를 받는 생성자 자동 생성
public class UserDTO {
    private String name; // name 요청값 저장
    private int age; // age 요청값 저장
    private String address; // address 요청값 저장
}

이 코드는 Lombok 때문에 실제로 아래 코드가 있는 것처럼 동작한다.

// UserDTO.java
public class UserDTO {
    private String name; // name 요청값 저장
    private int age; // age 요청값 저장
    private String address; // address 요청값 저장

    public UserDTO(String name, int age, String address) {
        this.name = name; // 생성자에서 name 값 저장
        this.age = age; // 생성자에서 age 값 저장
        this.address = address; // 생성자에서 address 값 저장
    }

    public String getName() {
        return name; // name 값 읽기
    }

    public int getAge() {
        return age; // age 값 읽기
    }

    public String getAddress() {
        return address; // address 값 읽기
    }
}

이 구조에는 setName(), setAge(), setAddress()가 없다.
따라서 setter로 값을 넣는 방식이 아니다.
대신 @AllArgsConstructor가 만들어 준 생성자를 통해 값을 넣을 수 있는 구조가 된다.


사용자가 아래 값을 보냈다고 생각하면 된다.

// 요청값
// name=lee
// age=20
// address=seoul

생성자 바인딩에서는 흐름이 이렇게 이어진다.

  • Spring이 생성자 매개변수 name, age, address를 확인한다.
  • 요청값 name=lee를 생성자 매개변수 name에 연결한다.
  • 요청값 age=20을 생성자 매개변수 age에 연결한다.
  • age 타입이 int이므로 숫자로 변환할 수 있으면 20으로 바꾼다.
  • 요청값 address=seoul을 생성자 매개변수 address에 연결한다.
  • 이 예제 구조에서는 new UserDTO("lee", 20, "seoul")처럼 생성자를 호출해 객체를 만들 수 있다.

이 흐름을 실제 값 기준으로 보면 아래와 같다.

// 생성자 바인딩 후 UserDTO 객체 상태
// user.name    → "lee"
// user.age     → 20
// user.address → "seoul"

즉, setter가 없는데 값이 들어갔다면, 이 예제 구조에서는 @AllArgsConstructor가 만들어 준 생성자를 통해 생성자 바인딩이 가능했기 때문이라고 이해하면 된다.
다만 모든 상황에서 @AllArgsConstructor만 붙이면 무조건 생성자 바인딩이 된다고 단정하면 안 된다.
Spring이 생성자 매개변수 이름과 요청값 이름을 맞출 수 있어야 한다.


setter 바인딩과 생성자 바인딩의 차이

두 방식은 모두 요청값을 DTO 객체에 넣는 방식이다.
다만 값을 넣는 시점과 필요한 코드가 다르다.


차이는 아래처럼 정리할 수 있다.

  • setter 바인딩은 기본 생성자로 빈 객체를 만든 뒤 setter로 값을 넣는다.
  • 생성자 바인딩은 생성자를 호출하는 순간 매개변수로 값을 넣어 객체를 만든다.
  • @Setter가 있으면 setter 바인딩이 가능하다.
  • @AllArgsConstructor가 있으면 생성자 바인딩이 가능한 구조가 될 수 있다.
  • @Getter는 저장된 값을 읽기 위해 함께 두는 경우가 많다.
  • 저장 방식 자체는 setter 바인딩 또는 생성자 바인딩으로 이해해야 한다.

따라서 초보자 단계에서는 @ModelAttribute용 DTO를 @Getter, @Setter, @NoArgsConstructor 중심으로 이해하는 것이 가장 안전하다.
그 위에서 setter 없이도 값이 들어가는 경우는 @AllArgsConstructor가 만들어 준 생성자를 통한 생성자 바인딩으로 따로 이해하면 된다.


input name과 DTO 속성 이름이 맞아야 바인딩된다

객체 바인딩에서 가장 중요한 기준은 input의 name 속성과 DTO의 속성 이름이다.
Spring이 아무 값이나 아무 필드에 넣는 것이 아니다.
요청값 이름과 객체 속성 이름을 기준으로 어느 값이 어느 자리에 들어갈지 판단한다.


입력 화면에서 요청값 이름은 아래처럼 정해진다.

// userForm.html
<form action="/user/add" method="post">
    <input type="text" name="name"> <!-- 요청값 이름: name -->
    <input type="number" name="age"> <!-- 요청값 이름: age -->
    <input type="text" name="address"> <!-- 요청값 이름: address -->
    <button type="submit">등록</button>
</form>

DTO에는 같은 이름의 속성이 있어야 자연스럽게 연결된다.

// UserDTO.java
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
    private String name; // 요청값 name과 연결
    private int age; // 요청값 age와 연결
    private String address; // 요청값 address와 연결
}

이 경우 값은 아래처럼 연결된다.

// 요청값
// name=lee
// age=20
// address=seoul
// 바인딩 후 UserDTO 객체 상태
// user.name    → "lee"
// user.age     → 20
// user.address → "seoul"

반대로 input name과 DTO 속성 이름이 다르면 값이 제대로 들어가지 않을 수 있다.

// wrongUserForm.html
<form action="/user/add" method="post">
    <input type="text" name="userName"> <!-- DTO에는 userName 속성이 없음 -->
    <input type="number" name="userAge"> <!-- DTO에는 userAge 속성이 없음 -->
    <input type="text" name="userAddress"> <!-- DTO에는 userAddress 속성이 없음 -->
    <button type="submit">등록</button>
</form>

이 경우 요청값 이름은 userName, userAge, userAddress이다.
하지만 UserDTO의 속성 이름은 name, age, address이다.
이름이 다르므로 Spring이 어떤 요청값을 어떤 속성에 넣어야 하는지 바로 연결하기 어렵다.


정리하면 아래와 같다.

  • input name="name"은 UserDTO의 name 속성과 연결된다.
  • input name="age"는 UserDTO의 age 속성과 연결된다.
  • input name="address"는 UserDTO의 address 속성과 연결된다.
  • input name과 DTO 속성 이름이 다르면 객체 바인딩이 제대로 되지 않을 수 있다.
  • 숫자 필드는 요청값이 숫자로 변환 가능한 형태여야 한다.

따라서 객체 바인딩은 input name과 DTO 속성 이름을 기준으로 요청값을 DTO 객체에 넣는 과정이다.


DTO 값을 사용하는 방법은 여러 가지가 있다

@ModelAttribute로 받은 DTO 객체는 컨트롤러 안에서도 사용할 수 있고, 화면으로 넘길 수도 있고, 서비스로 전달할 수도 있다.
즉, DTO는 단순히 화면 출력용 객체가 아니다.
요청값이 묶여 있는 객체이기 때문에 필요한 위치에서 꺼내 쓰거나 넘길 수 있다.

컨트롤러에서 getter로 값을 꺼내 사용할 수 있다

첫 번째 방법은 컨트롤러에서 getter로 값을 꺼내는 것이다.

// UserControllerGetterExample.java
@Controller
public class UserController {
    @PostMapping("/user/add") // 회원 등록 요청 처리
    public String addUser(@ModelAttribute UserDTO user, Model model) {
        String name = user.getName(); // DTO 안의 name 값 꺼내기
        int age = user.getAge(); // DTO 안의 age 값 꺼내기
        String address = user.getAddress(); // DTO 안의 address 값 꺼내기

        System.out.println(name); // 이름 값 출력
        System.out.println(age); // 나이 값 출력
        System.out.println(address); // 주소 값 출력

        model.addAttribute("user", user); // 화면으로 객체 전달
        return "userView"; // 결과 화면으로 이동
    }
}
// 출력결과
// lee
// 20
// seoul

이 방식은 컨트롤러 안에서 요청값을 확인하거나 간단히 사용할 때 쓴다.
user.getName()은 name 값을 꺼내고, user.getAge()는 age 값을 꺼낸다.


Model에 담아 화면으로 넘길 수 있다

두 번째 방법은 DTO 객체를 그대로 Model에 담아 화면으로 넘기는 것이다.

// UserControllerModelExample.java
@Controller
public class UserController {
    @PostMapping("/user/add") // 회원 등록 요청 처리
    public String addUser(@ModelAttribute UserDTO user, Model model) {
        model.addAttribute("user", user); // 화면에서 사용할 객체 저장
        return "userView"; // 결과 화면으로 이동
    }
}

화면에서는 Model에 담긴 user 객체를 꺼내 사용할 수 있다.

// userView.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>userView</title>
</head>
<body>
<p>이름: <span th:text="${user.name}"></span></p>
<p>나이: <span th:text="${user.age}"></span></p>
<p>주소: <span th:text="${user.address}"></span></p>
</body>
</html>

Thymeleaf에서 ${user.name}은 user 객체의 name 값을 읽는 표현이다.
초보자 기준에서는 내부적으로 getName()을 통해 값을 읽는다고 이해하면 된다.


서비스로 전달할 수 있다

세 번째 방법은 DTO 객체를 서비스로 넘기는 것이다.
컨트롤러는 요청을 받고 흐름을 연결하는 역할이기 때문에, 핵심 처리는 서비스에게 맡길 수 있다.

// UserControllerServiceExample.java
@Controller
public class UserController {
    private final UserService userService; // 서비스 의존

    public UserController(UserService userService) {
        this.userService = userService; // 생성자 주입
    }

    @PostMapping("/user/add") // 회원 등록 요청 처리
    public String addUser(@ModelAttribute UserDTO user, Model model) {
        userService.register(user); // DTO 객체를 서비스로 전달
        model.addAttribute("user", user); // 화면에서 사용할 객체 저장
        return "userView"; // 결과 화면으로 이동
    }
}
// UserService.java
@Service
public class UserService {
    public void register(UserDTO user) {
        System.out.println(user.getName()); // 서비스에서 이름 값 사용
        System.out.println(user.getAge()); // 서비스에서 나이 값 사용
        System.out.println(user.getAddress()); // 서비스에서 주소 값 사용
    }
}

이 방식은 회원 등록, 게시글 작성, 주문 처리처럼 실제 처리 로직이 필요한 경우에 자연스럽다.
컨트롤러는 DTO를 받아 서비스로 넘기고, 서비스는 그 객체 안의 값을 사용해 필요한 처리를 한다.


정리하면 아래와 같다.

  • 컨트롤러에서는 getter로 DTO 값을 꺼낼 수 있다.
  • 화면에 보여 줄 값은 Model에 담아 넘길 수 있다.
  • 실제 처리 로직이 필요하면 DTO 객체를 서비스로 넘길 수 있다.

따라서 @ModelAttribute로 받은 DTO는 컨트롤러 안에서 읽고, 화면으로 넘기고, 서비스로 전달할 수 있는 요청 데이터 묶음이다.


setter는 언제 직접 사용하는가

@ModelAttribute 바인딩에서는 개발자가 요청값을 하나씩 직접 setter로 넣지 않아도 된다.
예를 들어 name, age, address 요청값은 Spring이 바인딩 과정에서 DTO 객체 안에 넣어 준다.


하지만 이것이 개발자가 setter를 절대 쓰지 않는다는 뜻은 아니다.
이미 들어온 값을 서버 기준에 맞게 정리하거나 바꿔야 할 때는 컨트롤러나 서비스에서 setter를 직접 사용할 수 있다.


가장 쉬운 예시는 이름 앞뒤 공백 제거이다.
사용자가 이름을 입력할 때 실수로 앞뒤 공백을 넣을 수 있다.
예를 들어 " lee "처럼 입력하면 그대로 저장하거나 출력하기 전에 "lee"로 정리하고 싶을 수 있다.

// UserControllerSetterExample.java
@Controller
public class UserController {
    @PostMapping("/user/add") // 회원 등록 요청 처리
    public String addUser(@ModelAttribute UserDTO user, Model model) {
        String trimmedName = user.getName().trim(); // 이름 앞뒤 공백 제거
        user.setName(trimmedName); // 정리된 이름으로 다시 저장

        model.addAttribute("user", user); // 화면에서 사용할 객체 저장
        return "userView"; // 결과 화면으로 이동
    }
}

처음 요청값이 아래처럼 들어왔다고 생각하면 된다.

// 변경 전
// user.name → " lee "
// 변경 후
// user.name → "lee"

즉, setter는 새로운 값을 억지로 추가할 때만 쓰는 것이 아니다.
이미 들어온 요청값을 정리하거나 수정해서 객체에 다시 저장할 때도 사용할 수 있다.


다만 복잡한 값 변경이나 핵심 처리 규칙은 컨트롤러보다 서비스에서 처리하는 것이 더 자연스럽다.
컨트롤러는 요청과 응답 흐름을 연결하는 역할이고, 서비스는 핵심 로직을 처리하는 역할이기 때문이다.


정리하면 아래와 같다.

  • Spring은 객체 바인딩 과정에서 setter를 사용할 수 있다.
  • 개발자는 요청값을 직접 넣기 위해 setter를 반복해서 호출하지 않아도 된다.
  • 개발자가 직접 setter를 쓰는 경우는 이미 들어온 값을 정리하거나 수정할 때이다.
  • 예를 들어 " lee "를 "lee"로 바꾼 뒤 setName()으로 다시 저장할 수 있다.
  • 복잡한 값 변경은 컨트롤러보다 서비스에서 처리하는 편이 좋다.

따라서 Spring이 바인딩할 때 사용하는 setter와 개발자가 값을 수정하기 위해 직접 사용하는 setter는 구분해서 이해해야 한다.


ModelAttribute 이름 지정과 생략

@ModelAttribute는 이름을 지정해서 사용할 수도 있고, 생략할 수도 있다.
이 부분은 실제 코드에서 자주 보이기 때문에 정확히 알아야 한다.


먼저 아래처럼 @ModelAttribute만 붙이면 요청값이 UserDTO 객체에 바인딩된다.

// ModelAttributeDefaultNameExample.java
@PostMapping("/user/add")
public String addUser(@ModelAttribute UserDTO user, Model model) {
    model.addAttribute("user", user); // 화면에서 사용할 이름을 직접 지정
    return "userView"; // 결과 화면으로 이동
}

이 방식은 가장 명확하다.
요청값을 UserDTO 객체로 받고, 화면에서 사용할 이름은 model.addAttribute("user", user)로 직접 정한다.


다음처럼 @ModelAttribute("user")로 이름을 지정할 수도 있다.

// ModelAttributeNamedExample.java
@PostMapping("/user/add")
public String addUser(@ModelAttribute("user") UserDTO user) {
    return "userView"; // user라는 이름으로 Model에 담길 수 있음
}

이 코드에서 "user"는 모델에 담길 이름이다.
따라서 결과 화면에서는 ${user.name}처럼 사용할 수 있다.
즉, @ModelAttribute("user")는 요청값을 객체에 바인딩하면서, 그 객체를 모델에서 사용할 이름도 함께 지정하는 방식이다.


또 아래처럼 @ModelAttribute를 생략할 수 있는 경우도 있다.

// ModelAttributeOmittedExample.java
@PostMapping("/user/add")
public String addUser(UserDTO user, Model model) {
    model.addAttribute("user", user); // 화면에서 사용할 객체 저장
    return "userView"; // 결과 화면으로 이동
}

UserDTO처럼 단순 값 타입이 아닌 객체가 컨트롤러 메서드 매개변수로 오면, Spring이 요청값을 객체에 바인딩할 수 있다.
그래서 @ModelAttribute를 생략해도 동작할 수 있다.


하지만 처음 배우는 단계에서는 생략보다 명시가 더 좋다.
@ModelAttribute UserDTO user라고 적혀 있으면 이 객체가 요청값을 받아 만들어지는 객체라는 점이 코드에서 바로 보이기 때문이다.


세 방식을 비교하면 아래와 같다.

  • @ModelAttribute UserDTO user는 요청값을 UserDTO 객체로 받는다는 뜻이 분명하다.
  • @ModelAttribute("user") UserDTO user는 요청값을 객체로 받으면서 모델 이름도 user로 지정한다.
  • UserDTO user처럼 생략해도 객체 바인딩이 일어날 수 있지만, 처음에는 의미가 덜 드러난다.
  • model.addAttribute("user", user)를 직접 쓰면 화면에서 사용할 이름이 명확하다.

따라서 처음에는 아래 방식이 가장 안전하다.

// ModelAttributeRecommendedExample.java
@PostMapping("/user/add")
public String addUser(@ModelAttribute UserDTO user, Model model) {
    model.addAttribute("user", user); // 화면에서 사용할 이름을 명확하게 지정
    return "userView"; // 결과 화면으로 이동
}

이렇게 쓰면 흐름이 단순하다.
요청값은 UserDTO 객체에 들어간다.
컨트롤러는 그 객체를 user라는 이름으로 Model에 담는다.
화면은 user라는 이름으로 객체 안의 값을 꺼낸다.


정리하면 @ModelAttribute는 생략할 수 있는 경우도 있지만, 처음에는 명시해서 요청값이 객체로 바인딩되는 흐름을 코드에 드러내는 것이 좋다.
그리고 화면에서 사용할 이름은 Model에 직접 담거나 @ModelAttribute("이름")으로 지정할 수 있다.


Servlet/JSP 방식과 Spring MVC 방식 비교

아래 예제는 같은 회원 입력값을 Servlet/JSP 방식과 Spring MVC 방식으로 비교하기 위한 코드이다.
같은 프로젝트에 두 코드를 동시에 넣어 실행하라는 뜻이 아니다.
여러 요청값을 화면으로 넘기는 방식과, 여러 요청값을 객체로 묶어 넘기는 방식이 어떻게 달라지는지 비교해서 보기 위한 예제이다.

Servlet/JSP에서 값을 각각 화면에 넘기는 흐름

먼저 Servlet/JSP 방식에서는 요청값을 하나씩 직접 꺼낼 수 있다.
이때 화면에 값만 각각 보여 주면 된다면 request.setAttribute()로 각각 담아 보낼 수 있다.

// UserServletSimpleAttribute.java
@WebServlet("/user/add")
public class UserServletSimpleAttribute extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        String name = request.getParameter("name"); // name 요청값 직접 꺼내기
        int age = Integer.parseInt(request.getParameter("age")); // age 요청값 숫자로 변환
        String address = request.getParameter("address"); // address 요청값 직접 꺼내기

        request.setAttribute("name", name); // 이름 값을 JSP로 전달
        request.setAttribute("age", age); // 나이 값을 JSP로 전달
        request.setAttribute("address", address); // 주소 값을 JSP로 전달

        RequestDispatcher dispatcher = request.getRequestDispatcher("/WEB-INF/views/userView.jsp"); // 결과 JSP 지정
        dispatcher.forward(request, response); // JSP로 이동
    }
}

이 방식은 값을 각각 화면에 보여 주기에는 가능하다.
하지만 이름, 나이, 주소를 하나의 회원 정보 객체로 다루는 구조는 아니다.
즉, name, age, address가 각각 따로 전달될 뿐이다.


Servlet/JSP에서 DTO로 묶어 화면에 넘기는 흐름

여러 값을 하나의 회원 정보처럼 묶어 다루고 싶다면 Servlet/JSP 방식에서도 DTO를 만들 수 있다.
다만 이 경우에는 개발자가 직접 요청값을 꺼내고, 직접 DTO 객체를 만들고, 직접 setter로 값을 넣어야 한다.

// UserServletDTO.java
@WebServlet("/user/add")
public class UserServletDTO extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        String name = request.getParameter("name"); // name 요청값 직접 꺼내기
        int age = Integer.parseInt(request.getParameter("age")); // age 요청값 숫자로 변환
        String address = request.getParameter("address"); // address 요청값 직접 꺼내기

        UserDTO user = new UserDTO(); // DTO 객체 직접 생성
        user.setName(name); // 이름 값 직접 저장
        user.setAge(age); // 나이 값 직접 저장
        user.setAddress(address); // 주소 값 직접 저장

        request.setAttribute("user", user); // JSP에서 사용할 객체 저장

        RequestDispatcher dispatcher = request.getRequestDispatcher("/WEB-INF/views/userView.jsp"); // 결과 JSP 지정
        dispatcher.forward(request, response); // JSP로 이동
    }
}
// userView.jsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>userView</title>
</head>
<body>
<p>이름: ${user.name}</p>
<p>나이: ${user.age}</p>
<p>주소: ${user.address}</p>
</body>
</html>
// 출력결과
// 요청 방식: POST /user/add
// 전달값: name=lee, age=20, address=seoul
// 화면 출력: 이름: lee
// 화면 출력: 나이: 20
// 화면 출력: 주소: seoul

이 흐름에서는 name, age, address가 따로 흩어진 값으로 끝나지 않는다.
개발자가 직접 UserDTO 객체를 만들고, 그 안에 값을 넣은 뒤, user라는 이름으로 화면에 넘긴다.
그래서 JSP에서는 ${user.name}, ${user.age}, ${user.address}처럼 객체 안의 값을 꺼내 출력할 수 있다.


Spring MVC에서 ModelAttribute로 DTO를 받는 흐름

이제 같은 흐름을 Spring MVC 방식으로 보면 요청값을 객체에 묶어 받을 수 있다.

// userForm.html
<form action="/user/add" method="post">
    <input type="text" name="name"> <!-- UserDTO의 name 속성과 연결 -->
    <input type="number" name="age"> <!-- UserDTO의 age 속성과 연결 -->
    <input type="text" name="address"> <!-- UserDTO의 address 속성과 연결 -->
    <button type="submit">등록</button> <!-- 입력값 전송 -->
</form>
// UserController.java
@Controller
public class UserController {
    @PostMapping("/user/add") // /user/add POST 요청 처리
    public String addUser(@ModelAttribute UserDTO user, Model model) {
        model.addAttribute("user", user); // 결과 화면에서 사용할 객체 저장
        return "userView"; // 결과 화면으로 이동
    }
}
// userView.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>userView</title>
</head>
<body>
<p>이름: <span th:text="${user.name}"></span></p>
<p>나이: <span th:text="${user.age}"></span></p>
<p>주소: <span th:text="${user.address}"></span></p>
</body>
</html>
// 출력결과
// 요청 방식: POST /user/add
// 전달값: name=lee, age=20, address=seoul
// 화면 출력: 이름: lee
// 화면 출력: 나이: 20
// 화면 출력: 주소: seoul

Spring MVC 방식에서는 컨트롤러가 request.getParameter()를 여러 번 호출하지 않아도 된다.
또 컨트롤러가 new UserDTO()로 객체를 직접 만들고, 요청값을 하나씩 setter로 넣지 않아도 된다.
Spring이 컨트롤러 메서드 실행 전에 UserDTO 객체를 준비하고, 요청값을 바인딩한 뒤, 그 객체를 매개변수로 전달하기 때문이다.


세 방식의 차이 정리

세 방식의 차이는 아래처럼 정리할 수 있다.

  • Servlet/JSP에서 값만 각각 화면에 보낼 때는 request.setAttribute()로 각각 담을 수 있다.
  • Servlet/JSP에서도 여러 값을 객체로 묶고 싶다면 DTO를 만들 수 있다.
  • 다만 Servlet/JSP에서 DTO를 사용할 때는 개발자가 직접 new UserDTO()를 만들고 setter로 값을 넣어야 한다.
  • Spring MVC에서는 @ModelAttribute로 여러 요청값을 DTO 객체에 묶어 받을 수 있다.
  • Spring MVC에서는 컨트롤러 메서드 실행 전에 Spring이 DTO 객체를 준비하고 요청값을 바인딩한다.
  • 일반적인 setter 바인딩에서는 기본 생성자로 빈 객체를 만들고, setter로 값을 넣는 흐름을 사용한다.
  • setter가 없어도 생성자 바인딩이 가능한 구조라면 생성자를 통해 값이 들어갈 수 있다.
  • Spring MVC에서는 필드 타입에 맞춰 기본적인 타입 변환을 처리해 줄 수 있다.
  • Servlet/JSP에서는 request.setAttribute()로 화면에 넘길 값을 담는다.
  • Spring MVC에서는 Model에 화면에 넘길 값을 담는다.

따라서 @ModelAttribute는 기존 Servlet/JSP에서 DTO를 쓰던 흐름과 완전히 다른 개념이 아니다.
요청값을 꺼내고, 객체를 만들고, 객체에 직접 값을 넣던 과정을 Spring이 객체 바인딩으로 처리해 주는 방식이다.


이 섹션의 핵심은 이것이다.
@ModelAttribute는 HTML form에서 전송된 여러 요청값을 DTO 객체 하나에 묶어 컨트롤러 메서드의 매개변수로 전달하는 방식이다.
값이 들어가는 방식은 대표적으로 setter 바인딩과 생성자 바인딩이 있으며, 초보자 단계에서는 기본 생성자로 빈 객체를 만들고 setter로 값을 넣는 흐름을 먼저 이해하는 것이 가장 안전하다.



컨트롤러에서는 getter로 값을 꺼내거나 서비스로 넘길 수 있고, 화면에 출력할 값은 Model에 담아 뷰로 넘긴다고 이해하면 된다.



주소 경로에서 값을 받는 방식 (@PathVariable)

@PathVariable은 요청 주소의 경로 안에 들어 있는 값을 꺼내서 컨트롤러 메서드의 매개변수로 받는 어노테이션이다.
예를 들어 /board/1처럼 요청이 들어오면, 마지막의 1을 게시글 번호처럼 사용할 수 있다.
즉, @PathVariable은 주소의 일부를 값처럼 꺼내 쓰는 방식이다.


이 개념을 이해하려면 먼저 @RequestParam과 비교해서 봐야 한다.
@RequestParam은 /board/detail?id=1처럼 ? 뒤에 붙은 값을 받는다.
반면 @PathVariable은 /board/1처럼 주소 경로 자체에 포함된 값을 받는다.


둘 다 컨트롤러 메서드의 매개변수로 값을 받는다는 점은 비슷하다.
하지만 값이 들어 있는 위치와 주소에서 표현하는 의미가 다르다.
@RequestParam은 URL 뒤의 쿼리 문자열에서 값을 꺼내고, @PathVariable은 주소 경로 안에서 값을 꺼낸다.


초보자 단계에서는 먼저 이렇게 구분하면 된다.
@RequestParam은 조건값을 받을 때 자연스럽고, @PathVariable은 특정 대상을 식별하는 값을 받을 때 자연스럽다.
검색어, 페이지 번호, 정렬 기준처럼 조건에 가까운 값은 @RequestParam이 어울린다.
게시글 번호, 회원 번호, 상품 번호처럼 특정 대상을 가리키는 값은 @PathVariable이 어울린다.


이 개념도 Servlet/JSP에서 배운 흐름과 비교하면 더 쉽게 이해할 수 있다.
Servlet에서도 /board/1처럼 주소 뒤에 값을 붙여 요청할 수 있다.
하지만 이 값을 쓰려면 request.getPathInfo()로 경로 정보를 꺼내고, 필요한 부분을 직접 잘라서 사용해야 했다.


Spring MVC에서는 /board/{id}처럼 주소 안에 값이 들어갈 자리를 표시하고, @PathVariable("id")로 그 값을 바로 받을 수 있다.
즉, Spring MVC에서는 주소 경로에 들어 있는 값을 직접 자르지 않고, 어노테이션으로 매개변수에 연결할 수 있다.


아래 코드들은 비교를 위한 예제이다.
같은 프로젝트에 전부 동시에 넣어 실행하라는 뜻이 아니다.
요청 주소 모양과 값이 들어오는 위치를 비교하기 위한 코드로 보면 된다.


@RequestParam과 @PathVariable의 차이

@RequestParam과 @PathVariable은 모두 요청에서 값을 꺼내 컨트롤러 메서드로 전달한다.
그래서 처음 보면 둘이 비슷해 보인다.
하지만 실제 주소 모양을 보면 차이가 바로 보인다.


먼저 @RequestParam은 ? 뒤에 붙는 값을 받을 때 사용한다.
예를 들어 /board/detail?id=1에서 id=1은 쿼리 문자열로 전달된 요청 파라미터이다.
이런 값은 @RequestParam("id")로 받을 수 있다.

// BoardRequestParamExample.java
@Controller
public class BoardRequestParamController {
    @GetMapping("/board/detail") // /board/detail?id=1 요청 처리
    public String detail(@RequestParam("id") int id, Model model) {
        model.addAttribute("id", id); // 화면에 넘길 게시글 번호 저장
        return "boardView"; // boardView.html 화면으로 이동
    }
}
// 출력결과
// 브라우저 주소: /board/detail?id=1
// id 매개변수 값: 1
// 화면 출력: 게시글 번호: 1

이 방식에서는 주소가 /board/detail이고, 실제 값은 ?id=1 부분에 들어 있다.
즉, id는 주소 경로가 아니라 요청 조건처럼 붙은 값이다.


반면 @PathVariable은 주소 경로 자체에 포함된 값을 받을 때 사용한다.
예를 들어 /board/1에서 1은 ? 뒤에 붙은 값이 아니라 주소 경로의 일부이다.
이런 값은 @PathVariable("id")로 받을 수 있다.

// BoardPathVariableExample.java
@Controller
public class BoardPathVariableController {
    @GetMapping("/board/{id}") // /board/1 같은 요청 처리
    public String detail(@PathVariable("id") int id, Model model) {
        model.addAttribute("id", id); // 화면에 넘길 게시글 번호 저장
        return "boardView"; // boardView.html 화면으로 이동
    }
}
// 출력결과
// 브라우저 주소: /board/1
// id 매개변수 값: 1
// 화면 출력: 게시글 번호: 1

두 예제에서 사용하는 결과 화면은 아래처럼 생각하면 된다.

// boardView.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>boardView</title>
</head>
<body>
<h1>게시글 번호: <span th:text="${id}"></span></h1>
</body>
</html>

boardView.html은 Model에 담긴 id 값을 화면에 출력한다.
따라서 @RequestParam으로 받은 값이든, @PathVariable로 받은 값이든 컨트롤러에서 model.addAttribute("id", id)로 담으면 화면에서는 ${id}로 출력할 수 있다.


두 코드를 비교하면 차이가 더 분명하다.

  • @RequestParam 방식 주소는 /board/detail?id=1이다.
  • @PathVariable 방식 주소는 /board/1이다.
  • @RequestParam은 ?id=1처럼 이름과 값을 함께 보낸다.
  • @PathVariable은 /1처럼 주소 경로 안에 값을 넣는다.
  • @RequestParam은 검색 조건, 필터, 페이지 번호처럼 선택 조건에 자주 사용한다.
  • @PathVariable은 게시글 번호, 회원 번호, 상품 번호처럼 특정 대상을 식별할 때 자주 사용한다.

따라서 두 어노테이션은 단순히 문법만 다른 것이 아니다.
요청값을 주소에 어떤 의미로 표현할 것인지가 다르다.


같은 게시글 번호라도 주소 의미가 달라진다

같은 id=1이라는 값이라도 주소에 어떻게 표현하느냐에 따라 의미가 달라진다.


/board/detail?id=1은 /board/detail이라는 기능에 id=1이라는 조건을 붙인 느낌이다.
즉, “게시글 상세 보기 기능을 실행하는데, 그 조건으로 id가 1이다”라고 읽을 수 있다.


반면 /board/1은 주소 자체가 1번 게시글을 가리키는 느낌이다.
즉, “게시글 중에서 1번 게시글이라는 자원에 접근한다”라고 읽을 수 있다.


예를 들어 게시글 검색은 아래처럼 @RequestParam이 자연스럽다.

// BoardSearchRequestParamExample.java
@Controller
public class BoardSearchController {
    @GetMapping("/boards") // 게시글 목록 검색 요청 처리
    public String search(@RequestParam("keyword") String keyword,
                         @RequestParam("page") int page,
                         Model model) {
        model.addAttribute("keyword", keyword); // 검색어 저장
        model.addAttribute("page", page); // 페이지 번호 저장
        return "boardList"; // 목록 화면으로 이동
    }
}
// 출력결과
// 브라우저 주소: /boards?keyword=java&page=1
// keyword 값: java
// page 값: 1

이 주소는 java라는 검색어와 1페이지라는 조건으로 게시글 목록을 조회한다는 의미에 가깝다.
검색어와 페이지 번호는 특정 게시글 하나를 가리키는 값이 아니라, 목록을 조회할 때 붙는 조건이다.
그래서 @RequestParam이 자연스럽다.


반면 게시글 한 개를 조회할 때는 아래처럼 @PathVariable이 자연스럽다.

// BoardDetailPathVariableExample.java
@Controller
public class BoardDetailController {
    @GetMapping("/boards/{boardId}") // 특정 게시글 조회 요청 처리
    public String detail(@PathVariable("boardId") int boardId, Model model) {
        model.addAttribute("boardId", boardId); // 게시글 번호 저장
        return "boardDetail"; // 상세 화면으로 이동
    }
}
// 출력결과
// 브라우저 주소: /boards/10
// boardId 값: 10

이 주소는 게시글 목록 중 10번 게시글 자체를 가리킨다.
따라서 특정 대상을 식별하는 값은 @PathVariable로 받는 것이 더 자연스럽다.


정리하면 아래와 같다.

  • 조건을 전달하는 값은 @RequestParam이 자연스럽다.
  • 특정 대상을 식별하는 값은 @PathVariable이 자연스럽다.
  • /boards?keyword=java&page=1은 게시글 목록에 검색 조건을 붙이는 요청이다.
  • /boards/10은 10번 게시글이라는 특정 대상을 가리키는 요청이다.

즉, @RequestParam은 조건값, @PathVariable은 식별값이라고 먼저 구분하면 이해하기 쉽다.


경로 변수는 어떻게 표시하는가

@PathVariable을 사용하려면 먼저 요청 주소에 값이 들어갈 자리를 표시해야 한다.
이때 중괄호 {}를 사용한다.
예를 들어 /board/{id}라고 쓰면, /board/1, /board/2, /board/100처럼 들어오는 주소에서 {id} 자리에 해당하는 값을 꺼낼 수 있다.


여기서 {id}는 실제 주소에 그대로 보이는 글자가 아니다.
값이 들어갈 자리라는 뜻이다.
즉, /board/{id}는 “/board/ 뒤에 오는 값을 id라는 이름으로 받겠다”는 의미이다.


컨트롤러 메서드에서는 @PathVariable("id") int id처럼 작성해서 그 값을 받을 수 있다.
그러면 /board/1로 요청했을 때 id 매개변수에는 1이 들어간다.
즉, 주소 경로에 있던 값이 Java 코드 안의 변수로 들어오는 것이다.

// PathVariableBasicFlowExample.java
@Controller
public class PathVariableBasicController {
    @GetMapping("/board/{id}") // {id} 자리에 들어온 값을 받음
    public String board(@PathVariable("id") int id, Model model) {
        model.addAttribute("id", id); // 화면에서 사용할 id 저장
        return "boardView"; // 결과 화면으로 이동
    }
}
// 출력결과
// 요청 주소: /board/1
// {id} 자리에 들어온 값: 1
// id 매개변수 값: 1

이 흐름은 아래처럼 정리할 수 있다.

  • 주소 패턴에 {id}처럼 값이 들어갈 자리를 만든다.
  • 실제 요청에서는 /board/1처럼 값이 들어온다.
  • 스프링은 {id} 자리에 들어온 1을 꺼낸다.
  • 꺼낸 값을 @PathVariable("id")이 붙은 매개변수에 넣어 준다.
  • 컨트롤러는 그 값을 Model에 담아 화면으로 넘길 수 있다.

따라서 @PathVariable을 볼 때는 주소 패턴의 {이름}과 메서드 매개변수의 연결 관계를 함께 봐야 한다.


이름이 같을 때와 다를 때

주소 패턴의 변수 이름과 메서드 매개변수 이름이 같으면 연결이 비교적 단순하다.
예를 들어 주소 패턴이 /board/{id}이고 매개변수도 int id라면, 스프링은 id라는 이름을 기준으로 값을 연결할 수 있다.


하지만 이름이 다르면 연결 이름을 명확하게 적어 주는 것이 좋다.
예를 들어 주소 패턴은 /board/{id}인데 매개변수 이름을 number로 쓰고 싶다면 @PathVariable("id") int number처럼 작성한다.
이렇게 하면 주소의 {id} 값을 number 변수에 넣겠다는 의미가 분명해진다.


아래 코드는 주소 패턴의 이름과 매개변수 이름이 같은 경우이다.

// PathVariableSameNameExample.java
@Controller
public class PathVariableSameNameController {
    @GetMapping("/board/{id}") // 경로 변수 이름: id
    public String board(@PathVariable("id") int id, Model model) {
        model.addAttribute("id", id); // id 이름으로 화면에 전달
        return "boardView"; // 결과 화면으로 이동
    }
}
// 출력결과
// 요청 주소: /board/3
// id 매개변수 값: 3

아래 코드는 주소 패턴의 이름과 매개변수 이름이 다른 경우이다.

// PathVariableDifferentNameExample.java
@Controller
public class PathVariableDifferentNameController {
    @GetMapping("/board/{id}") // 경로 변수 이름: id
    public String board(@PathVariable("id") int number, Model model) {
        model.addAttribute("id", number); // number 값을 id 이름으로 화면에 전달
        return "boardView"; // 결과 화면으로 이동
    }
}
// 출력결과
// 요청 주소: /board/3
// {id} 자리에 들어온 값: 3
// number 매개변수 값: 3

이 코드에서 주소의 이름은 {id}이고, Java 매개변수 이름은 number이다.
하지만 @PathVariable("id")라고 적었기 때문에 스프링은 {id} 자리에 들어온 값을 number 매개변수에 넣는다.


정리하면 아래와 같다.

  • {id}와 매개변수 id처럼 이름이 같으면 같은 이름으로 연결된다.
  • {id}와 매개변수 number처럼 이름이 다르면 @PathVariable("id")로 직접 연결한다.
  • 이름을 직접 적으면 어떤 경로값을 받는지 코드에서 더 분명하게 보인다.

따라서 학습 단계에서는 @PathVariable("id")처럼 이름을 명시해서 쓰는 편이 흐름을 이해하기 쉽다.


여러 개의 경로값도 받을 수 있다

@PathVariable은 경로값 하나만 받을 때만 쓰는 것이 아니다.
주소 안에 값이 여러 개 들어가면 여러 개의 @PathVariable로 받을 수 있다.


예를 들어 /members/5/boards/10이라는 주소가 있다고 생각해 보자.
이 주소는 5번 회원이 작성한 10번 게시글을 조회하는 느낌으로 읽을 수 있다.
이때 5는 회원 번호이고, 10은 게시글 번호이다.


코드는 아래처럼 작성할 수 있다.

// MultiplePathVariableExample.java
@Controller
public class MultiplePathVariableController {
    @GetMapping("/members/{memberId}/boards/{boardId}") // 회원 번호와 게시글 번호를 경로에서 받음
    public String detail(@PathVariable("memberId") int memberId,
                         @PathVariable("boardId") int boardId,
                         Model model) {
        model.addAttribute("memberId", memberId); // 회원 번호 저장
        model.addAttribute("boardId", boardId); // 게시글 번호 저장
        return "memberBoardView"; // 결과 화면으로 이동
    }
}
// 출력결과
// 요청 주소: /members/5/boards/10
// memberId 값: 5
// boardId 값: 10

이 코드에서 {memberId} 자리에 들어온 5는 memberId 매개변수로 들어간다.
{boardId} 자리에 들어온 10은 boardId 매개변수로 들어간다.


즉, @PathVariable은 주소 안에서 의미 있는 위치에 있는 값을 각각 꺼내서 사용할 수 있다.
다만 주소가 너무 길어지거나 값의 의미가 애매해지면 읽기 어려워질 수 있다.
그래서 경로값은 보통 특정 대상을 식별하는 데 필요한 값 위주로 사용하는 것이 좋다.


경로값도 타입 변환이 일어날 수 있다

주소 경로에 들어오는 값은 처음에는 문자열이다.
예를 들어 /board/10에서 10은 주소 안에 들어 있는 문자 형태의 값이다.
하지만 컨트롤러 매개변수를 int id처럼 숫자 타입으로 작성하면, 스프링은 가능한 경우 문자열 "10"을 숫자 10으로 바꿔 넣어 줄 수 있다.


아래 예제를 보면 된다.

// PathVariableTypeConvertExample.java
@Controller
public class PathVariableTypeConvertController {
    @GetMapping("/board/{id}") // /board/10 요청 처리
    public String board(@PathVariable("id") int id, Model model) {
        model.addAttribute("id", id); // 숫자로 변환된 id 저장
        return "boardView"; // 결과 화면으로 이동
    }
}
// 출력결과
// 요청 주소: /board/10
// 경로값 원래 형태: "10"
// int id 값: 10

하지만 /board/abc처럼 숫자로 바꿀 수 없는 값이 들어오면 문제가 생길 수 있다.
왜냐하면 abc는 int로 변환할 수 없기 때문이다.


정리하면 아래와 같다.

  • 경로값은 처음에는 문자열로 들어온다.
  • 매개변수 타입이 int, long 같은 숫자형이면 스프링이 변환을 시도할 수 있다.
  • 숫자로 바꿀 수 없는 값이 들어오면 타입 변환 오류가 발생할 수 있다.

따라서 @PathVariable로 숫자를 받을 때는 실제 주소에 숫자로 변환 가능한 값이 들어와야 한다.


경로값이 없으면 어떻게 되는가

@RequestParam은 값이 없어도 required=false나 defaultValue로 처리할 수 있는 경우가 많다.
예를 들어 /boards?page=1에서 page가 없으면 기본값을 줄 수 있다.


하지만 @PathVariable은 주소 경로 자체에 값이 들어가야 매핑이 된다.
예를 들어 컨트롤러가 /board/{id}를 처리한다고 했을 때, /board/1은 매핑될 수 있다.
하지만 /board는 {id} 자리에 들어갈 값이 없기 때문에 같은 요청으로 보기 어렵다.

// 경로값이 있는 요청
// /board/1 → /board/{id}와 매칭 가능
// 경로값이 없는 요청
// /board → /board/{id}와 매칭되지 않음

따라서 @PathVariable은 특정 값이 주소의 일부로 들어오는 경우에 사용해야 한다.
값이 선택 사항이고 없어도 되는 조건이라면 @RequestParam이 더 자연스러운 경우가 많다.


정리하면 아래와 같다.

  • /board/{id}는 /board/1처럼 값이 있는 주소와 맞는다.
  • /board처럼 값이 빠진 주소는 /board/{id}와 다른 주소이다.
  • 선택 조건처럼 없어도 되는 값은 @RequestParam이 더 자연스러울 수 있다.
  • 특정 대상을 반드시 식별해야 하는 값은 @PathVariable이 자연스럽다.

따라서 주소 설계에서 값이 필수인지 선택인지도 함께 생각해야 한다.


REST 스타일 주소에서 자주 쓰인다

@PathVariable은 REST 스타일 주소에서 자주 사용된다.
REST는 웹에서 자원을 주소로 표현하는 방식이라고 이해하면 된다.
여기서 자원은 게시글, 회원, 상품처럼 서버에서 다루는 대상이다.


예를 들어 게시글 목록은 /boards로 표현할 수 있고, 특정 게시글 하나는 /boards/1처럼 표현할 수 있다.
이때 /boards/1의 1은 단순한 옵션이 아니라 “1번 게시글”이라는 대상을 가리키는 값이다.
그래서 이런 값은 @PathVariable로 받는 것이 자연스럽다.


반대로 검색어처럼 조건에 가까운 값은 @RequestParam이 더 자연스럽다.
예를 들어 /boards?keyword=java&page=1은 특정 게시글 하나가 아니라 게시글 목록 중에서 검색 조건을 붙인 요청이다.
즉, @PathVariable과 @RequestParam은 주소 설계 의도에 따라 나누어 쓰면 된다.


정리하면 아래와 같다.

  • /boards/1은 1번 게시글이라는 대상을 가리킨다.
  • /boards?keyword=java&page=1은 게시글 목록에 검색 조건을 붙인다.
  • 대상을 식별하는 값은 @PathVariable이 자연스럽다.
  • 조건을 전달하는 값은 @RequestParam이 자연스럽다.

이 차이를 알면 주소를 볼 때 요청의 의미를 더 쉽게 읽을 수 있다.


Servlet/JSP 방식과 Spring MVC 방식 비교

아래 두 예제는 같은 /board/1 요청을 Servlet/JSP 방식과 Spring MVC 방식으로 비교하기 위한 코드이다.
같은 프로젝트에 두 코드를 동시에 넣어 실행하라는 뜻이 아니다.
주소 경로에 들어 있는 값을 어떻게 꺼내고 화면으로 넘기는지 비교해서 보기 위한 예제이다.


먼저 Servlet/JSP 방식에서는 /board/1처럼 주소 뒤에 붙은 값을 직접 꺼내서 가공해야 한다.
이때 /board/*처럼 주소 패턴을 잡고, request.getPathInfo()로 /1 같은 경로 정보를 가져올 수 있다.

// PathVariableServletExample.java
@WebServlet("/board/*")
public class BoardServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        String pathInfo = request.getPathInfo(); // /1 같은 경로 정보 꺼내기
        String idText = pathInfo.substring(1); // 앞의 / 제거
        int id = Integer.parseInt(idText); // 문자열을 숫자로 변환

        request.setAttribute("id", id); // JSP로 넘길 게시글 번호 저장

        RequestDispatcher dispatcher = request.getRequestDispatcher("/WEB-INF/views/boardView.jsp"); // 결과 JSP 지정
        dispatcher.forward(request, response); // JSP로 이동
    }
}
// boardView.jsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>boardView</title>
</head>
<body>
<h1>게시글 번호: ${id}</h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /board/1
// 화면 출력: 게시글 번호: 1
// 브라우저 주소: /board/10
// 화면 출력: 게시글 번호: 10

이 방식에서는 주소 뒤에 붙은 값을 직접 다뤄야 한다.
request.getPathInfo()로 /1 같은 경로 정보를 가져오고, substring(1)로 /를 제거한 뒤, Integer.parseInt()로 숫자로 바꾼다.
그리고 그 값을 request.setAttribute()로 JSP에 넘긴다.
즉, Servlet/JSP 방식에서는 경로값 꺼내기 → 문자열 가공 → 숫자 변환 → 화면 전달을 개발자가 직접 코드로 작성한다.


이제 같은 흐름을 Spring MVC 방식으로 보면 주소 패턴에 {id}를 표시하고, @PathVariable로 바로 받을 수 있다.

// PathVariableSpringExample.java
@Controller
public class BoardController {
    @GetMapping("/board/{id}") // /board/값 형태의 요청 처리
    public String board(@PathVariable("id") int id, Model model) {
        model.addAttribute("id", id); // 경로에서 꺼낸 값을 모델에 저장
        return "boardView"; // 결과 화면으로 이동
    }
}
// boardView.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>boardView</title>
</head>
<body>
<h1>게시글 번호: <span th:text="${id}"></span></h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /board/1
// 화면 출력: 게시글 번호: 1
// 브라우저 주소: /board/10
// 화면 출력: 게시글 번호: 10

이 코드에서 /board/{id}는 /board/ 뒤에 오는 값을 id라는 이름으로 받겠다는 뜻이다.
따라서 /board/1로 요청하면 id는 1이 되고, /board/10으로 요청하면 id는 10이 된다.


컨트롤러는 이 값을 Model에 담아서 화면으로 넘긴다.
boardView.html에서는 ${id}를 출력하므로 화면에는 요청 주소에서 꺼낸 게시글 번호가 보인다.
즉, 주소 경로에 들어 있던 값이 컨트롤러 매개변수로 들어오고, 다시 모델을 통해 뷰까지 전달되는 흐름이다.


두 방식의 차이 정리

Servlet/JSP 방식과 Spring MVC 방식은 주소 경로 안의 값을 꺼내서 화면에 출력한다는 큰 흐름은 같다.
하지만 경로값을 꺼내는 방식이 달라진다.


차이는 아래처럼 정리할 수 있다.

  • Servlet에서는 request.getPathInfo()로 경로 정보를 직접 꺼낸다.
  • Servlet에서는 substring()으로 필요한 문자열을 직접 잘라야 할 수 있다.
  • Servlet에서는 숫자 변환을 직접 해야 한다.
  • Spring MVC에서는 /board/{id}처럼 경로 변수 자리를 표시한다.
  • Spring MVC에서는 {id} 자리에 들어온 값이 @PathVariable("id") 매개변수로 바로 연결된다.
  • Spring MVC에서는 매개변수 타입을 보고 기본적인 타입 변환을 처리해 줄 수 있다.
  • Servlet에서는 request.setAttribute()로 화면에 넘길 값을 담는다.
  • Spring MVC에서는 Model에 화면에 넘길 값을 담는다.

따라서 @PathVariable은 기존 Servlet의 경로값 처리 흐름을 없앤 것이 아니다.
주소 경로에서 값을 직접 꺼내고 자르던 과정을 스프링이 경로 변수와 매개변수 연결 방식으로 더 간단하게 처리해 주는 것이다.


이 섹션의 핵심은 이것이다.
@PathVariable은 주소 경로 안에 포함된 값을 꺼내 컨트롤러 메서드의 매개변수로 연결하는 어노테이션이다.
@RequestParam은 ? 뒤의 조건값을 받을 때 자연스럽고, @PathVariable은 주소가 직접 가리키는 대상의 식별값을 받을 때 자연스럽다.



요청 객체에서 직접 꺼내는 방식 (HttpServletRequest)

HttpServletRequest는 브라우저가 서버로 보낸 요청 정보를 담고 있는 객체이다.
요청 주소, 요청 방식, 요청 파라미터, 헤더, 쿠키 같은 정보가 이 객체 안에 들어 있다.
즉, HttpServletRequest는 요청 자체를 직접 다룰 때 사용하는 객체이다.


앞에서 본 @RequestParam, @ModelAttribute, @PathVariable은 Spring이 요청값을 매개변수나 객체에 자동으로 연결해 주는 방식이었다.
반면 HttpServletRequest를 사용하면 개발자가 요청 객체에서 필요한 값을 직접 꺼낸다.
즉, HttpServletRequest는 자동 바인딩을 쓰지 않고 요청 객체를 직접 다루는 방식이다.


이 방식은 Servlet/JSP에서 배운 흐름과 가장 가깝다.
Servlet에서는 doGet()이나 doPost()의 매개변수로 HttpServletRequest request를 받아서 request.getParameter()로 값을 직접 꺼냈다.
Spring MVC에서도 컨트롤러 메서드의 매개변수에 HttpServletRequest를 적으면 같은 요청 객체를 받을 수 있다.
즉, 기존 방식이 사라진 것이 아니라 필요할 때 Spring MVC 컨트롤러 안에서도 요청 객체를 직접 사용할 수 있다.


다만 초보자 단계에서 먼저 기준을 잡아야 한다.
요청값만 단순히 받고 싶다면 @RequestParam이나 @ModelAttribute가 더 읽기 쉽다.
요청 방식, 요청 주소, 헤더처럼 요청 객체 자체의 정보가 필요하면 HttpServletRequest가 더 자연스럽다.


따라서 요청값만 받을 때는 자동 바인딩을 먼저 생각하고, 요청 객체 자체가 필요할 때 HttpServletRequest를 사용한다.
이 기준을 잡으면 언제 직접 꺼내야 하는지 헷갈리지 않는다.


HttpServletRequest는 무엇을 직접 꺼낼 수 있는가

HttpServletRequest에는 요청과 관련된 정보가 들어 있다.
가장 자주 보는 것은 요청 파라미터이다.
예를 들어 /direct?name=lee&age=20처럼 요청이 들어오면 request.getParameter("name"), request.getParameter("age")로 값을 꺼낼 수 있다.


또 요청 방식이나 요청 주소도 확인할 수 있다.
request.getMethod()를 사용하면 GET, POST 같은 요청 방식을 알 수 있다.
request.getRequestURI()를 사용하면 현재 요청된 주소를 알 수 있다.


헤더 정보도 꺼낼 수 있다.
예를 들어 request.getHeader("Referer")를 사용하면 이전 페이지 주소를 확인할 수 있다.
다만 Referer는 브라우저나 요청 상황에 따라 없을 수도 있다.
사용자가 주소창에 직접 입력해서 들어오거나, 브라우저 정책 때문에 전달되지 않을 수도 있기 때문이다.
그래서 Referer는 항상 값이 온다고 생각하면 안 된다.


정리하면 HttpServletRequest로는 아래 정보를 직접 다룰 수 있다.

  • getParameter()로 요청 파라미터를 꺼낼 수 있다.
  • getMethod()로 요청 방식을 확인할 수 있다.
  • getRequestURI()로 요청 주소를 확인할 수 있다.
  • getHeader()로 요청 헤더 값을 확인할 수 있다.

따라서 HttpServletRequest는 요청값뿐 아니라 요청 자체의 여러 정보를 직접 확인할 때 사용하는 객체이다.


@RequestParam과 HttpServletRequest의 차이

@RequestParam과 HttpServletRequest는 둘 다 요청 파라미터를 받을 수 있다.
하지만 값을 받는 방식이 다르다.


@RequestParam은 Spring이 요청값을 찾아서 매개변수에 넣어 주는 방식이다.
개발자는 매개변수만 선언하면 된다.

// RequestParamCompareExample.java
@Controller
public class RequestParamCompareController {
    @GetMapping("/direct-param") // /direct-param?name=lee&age=20 요청 처리
    public String directParam(@RequestParam("name") String name,
                              @RequestParam("age") int age,
                              Model model) {
        model.addAttribute("result", name + ":" + age); // 화면에 넘길 결과 저장
        return "result"; // result.html 화면으로 이동
    }
}
// 출력결과
// 브라우저 주소: /direct-param?name=lee&age=20
// name 값: lee
// age 값: 20
// 화면 출력: lee:20

이 방식에서는 Spring이 name 요청값을 String name에 넣어 준다.
그리고 age 요청값은 int age에 넣어 준다.
age는 원래 문자열로 전달되지만, Spring이 가능한 경우 숫자로 변환해 준다.


반면 HttpServletRequest를 사용하면 개발자가 직접 요청 객체에서 값을 꺼내야 한다.

// HttpServletRequestCompareExample.java
@Controller
public class HttpServletRequestCompareController {
    @GetMapping("/direct-request") // /direct-request?name=lee&age=20 요청 처리
    public String directRequest(HttpServletRequest request, Model model) {
        String name = request.getParameter("name"); // name 요청값 직접 꺼내기
        String ageText = request.getParameter("age"); // age 요청값을 문자열로 꺼내기
        int age = Integer.parseInt(ageText); // 문자열 age를 숫자로 변환

        model.addAttribute("result", name + ":" + age); // 화면에 넘길 결과 저장
        return "result"; // result.html 화면으로 이동
    }
}
// 출력결과
// 브라우저 주소: /direct-request?name=lee&age=20
// name 값: lee
// ageText 값: "20"
// age 값: 20
// 화면 출력: lee:20

이 방식에서는 request.getParameter("name")으로 name 값을 직접 꺼낸다.
request.getParameter("age")로 꺼낸 값은 바로 숫자가 아니라 문자열이다.
그래서 숫자로 계산하거나 숫자값으로 다루려면 Integer.parseInt()로 직접 변환해야 한다.


두 방식의 차이는 아래처럼 정리할 수 있다.

  • @RequestParam은 Spring이 요청값을 매개변수에 넣어 준다.
  • HttpServletRequest는 개발자가 request.getParameter()로 직접 꺼낸다.
  • @RequestParam int age는 Spring이 가능한 경우 숫자로 변환해 준다.
  • request.getParameter("age")로 꺼낸 값은 문자열이므로 숫자 변환을 직접 해야 한다.

즉, @RequestParam은 요청값을 편하게 받는 방식이고, HttpServletRequest는 요청 객체에서 직접 찾아 꺼내는 방식이다.


getParameter로 꺼낸 값은 항상 문자열이다

HttpServletRequest에서 getParameter()로 꺼낸 값은 기본적으로 문자열이다.
요청 주소가 /age-check?age=20이어도 20은 처음부터 숫자 20으로 들어오는 것이 아니다.
요청 파라미터는 문자열 "20"으로 들어온다.


그래서 숫자로 사용하려면 직접 변환해야 한다.

// HttpServletRequestStringValueExample.java
@Controller
public class HttpServletRequestStringValueController {
    @GetMapping("/age-check") // /age-check?age=20 요청 처리
    public String ageCheck(HttpServletRequest request, Model model) {
        String ageText = request.getParameter("age"); // "20" 문자열로 꺼냄
        int age = Integer.parseInt(ageText); // 문자열을 숫자로 변환

        model.addAttribute("age", age); // 화면에 넘길 나이 저장
        return "ageResult"; // ageResult.html 화면으로 이동
    }
}
// 출력결과
// 브라우저 주소: /age-check?age=20
// ageText 값: "20"
// age 값: 20

여기서 ageText는 문자열이다.
따라서 숫자 계산이 필요하다면 반드시 Integer.parseInt(ageText)처럼 숫자로 바꿔야 한다.


하지만 요청값이 숫자로 바꿀 수 없는 값이면 오류가 생길 수 있다.
예를 들어 /age-check?age=abc처럼 요청하면 abc는 숫자로 변환할 수 없다.


정리하면 아래와 같다.

  • request.getParameter()로 꺼낸 값은 문자열이다.
  • 숫자로 사용하려면 Integer.parseInt() 같은 변환이 필요하다.
  • 숫자로 바꿀 수 없는 값이 들어오면 변환 오류가 발생할 수 있다.
  • 숫자 요청값은 값이 있는지, 숫자로 바꿀 수 있는지 확인해야 안전하다.

따라서 HttpServletRequest로 직접 꺼낼 때는 자동 변환이 되지 않는다는 점을 꼭 기억해야 한다.


요청값이 없으면 null이 나올 수 있다

request.getParameter("name")은 요청 안에서 name이라는 값을 찾는다.
그런데 요청에 name이 없으면 값이 들어오지 않는다.
이때 결과는 null이 될 수 있다.


예를 들어 /direct-null처럼 요청했는데 name 파라미터가 없다고 생각하면 된다.

// HttpServletRequestNullExample.java
@Controller
public class HttpServletRequestNullController {
    @GetMapping("/direct-null") // /direct-null 또는 /direct-null?name=lee 요청 처리
    public String directNull(HttpServletRequest request, Model model) {
        String name = request.getParameter("name"); // 없으면 null 가능

        if (name == null) {
            name = "이름 없음"; // 값이 없을 때 기본 문구 지정
        }

        model.addAttribute("name", name); // 화면에 넘길 이름 저장
        return "nameResult"; // nameResult.html 화면으로 이동
    }
}
// 출력결과
// 브라우저 주소: /direct-null
// request.getParameter("name") 결과: null
// 최종 name 값: 이름 없음
// 화면 출력: 이름 없음
// 브라우저 주소: /direct-null?name=lee
// request.getParameter("name") 결과: lee
// 최종 name 값: lee
// 화면 출력: lee

이 코드에서 요청에 name이 없으면 request.getParameter("name")은 null이 될 수 있다.
그래서 값을 사용하기 전에 null인지 확인하는 코드가 필요할 수 있다.


이 예제에서는 처음에 꺼낸 값이 null이면 if문 안에서 name 값을 "이름 없음"으로 바꾼다.
그래서 request.getParameter("name")의 결과는 null이지만, 최종적으로 화면에 출력되는 값은 "이름 없음"이다.


이 부분은 @RequestParam의 required=false나 defaultValue와 비교해서 생각하면 좋다.
@RequestParam(defaultValue = "이름 없음")처럼 쓰면 기본값 처리를 Spring에게 맡길 수 있다.
하지만 HttpServletRequest로 직접 꺼내면 개발자가 직접 기본값 처리를 작성해야 한다.


정리하면 아래와 같다.

  • 요청값이 없으면 request.getParameter() 결과가 null일 수 있다.
  • 직접 꺼낸 값은 사용하기 전에 null 처리를 고민해야 한다.
  • 기본값이 필요하면 개발자가 직접 조건문으로 처리할 수 있다.
  • 단순 기본값 처리는 @RequestParam(defaultValue = "...")가 더 간단할 수 있다.

따라서 HttpServletRequest는 자유도가 높지만, 그만큼 직접 처리해야 할 코드도 많아진다.


요청 객체 자체의 정보를 확인할 수 있다

HttpServletRequest는 요청 파라미터만 꺼내는 객체가 아니다.
요청 자체의 정보도 확인할 수 있다.


예를 들어 현재 요청 방식이 GET인지 POST인지, 어떤 주소로 요청이 들어왔는지, 이전 페이지 정보가 있는지 확인할 수 있다.

// HttpServletRequestInfoExample.java
@Controller
public class HttpServletRequestInfoController {
    @GetMapping("/request-info") // /request-info 요청 처리
    public String requestInfo(HttpServletRequest request, Model model) {
        String method = request.getMethod(); // 요청 방식 확인
        String uri = request.getRequestURI(); // 요청 주소 확인
        String referer = request.getHeader("Referer"); // 이전 페이지 주소 확인 가능

        model.addAttribute("method", method); // 요청 방식 저장
        model.addAttribute("uri", uri); // 요청 주소 저장
        model.addAttribute("referer", referer); // 이전 페이지 주소 저장
        return "requestInfo"; // requestInfo.html 화면으로 이동
    }
}
// 출력결과
// 브라우저 주소: /request-info
// method 값: GET
// uri 값: /request-info
// referer 값: 요청 상황에 따라 없을 수 있음

request.getMethod()는 요청 방식을 알려 준다.
request.getRequestURI()는 현재 요청된 주소를 알려 준다.
request.getHeader("Referer")는 이전 페이지 주소를 알려 줄 수 있다.


다만 Referer는 항상 존재하는 값이 아니다.
사용자가 주소창에 직접 입력해서 들어오거나, 브라우저 정책이나 보안 설정에 따라 전달되지 않을 수 있다.
그래서 Referer는 값이 없을 수도 있다고 생각해야 한다.


이런 요청 자체의 정보는 @RequestParam만으로는 확인하기 어렵다.
그래서 요청 파라미터가 아니라 요청 객체 전체의 정보가 필요할 때 HttpServletRequest를 사용한다.


자동 바인딩과 직접 꺼내기의 차이

스프링의 자동 바인딩을 사용하면 요청값을 매개변수나 객체로 바로 받을 수 있다.
예를 들어 @RequestParam("name") String name이라고 쓰면 Spring이 name 요청값을 찾아서 매개변수에 넣어 준다.


@ModelAttribute UserDTO user처럼 쓰면 여러 요청값을 UserDTO 객체에 묶어서 받을 수 있다.
@PathVariable("id") int id처럼 쓰면 주소 경로에 있는 값을 매개변수로 받을 수 있다.


반대로 HttpServletRequest를 사용하면 개발자가 직접 값을 꺼내야 한다.
예를 들어 request.getParameter("name")처럼 작성해야 요청값을 얻을 수 있다.
즉, Spring이 대신 연결해 주는 것이 아니라 개발자가 요청 객체 안에서 직접 찾는 방식이다.


차이는 아래처럼 볼 수 있다.

  • 자동 바인딩은 Spring이 요청값을 매개변수나 객체에 넣어 준다.
  • 직접 꺼내기는 개발자가 request.getParameter()로 값을 꺼낸다.
  • 직접 꺼낸 값은 문자열이므로 숫자가 필요하면 직접 변환해야 한다.
  • 요청 객체 전체가 필요할 때는 HttpServletRequest를 쓰는 것이 자연스럽다.

즉, 자동 바인딩은 값을 받는 코드를 줄여 주고, HttpServletRequest는 요청 객체 자체를 직접 다룰 수 있게 해 준다.


언제 HttpServletRequest를 쓰면 좋은가

요청값만 단순히 받을 때는 @RequestParam이나 @ModelAttribute가 더 읽기 쉽다.
이름, 나이, 주소 같은 입력값을 받을 때는 Spring이 자동으로 연결해 주는 방식이 코드도 짧고 실수도 줄어든다.


하지만 요청 객체 자체가 필요할 때는 HttpServletRequest를 사용할 수 있다.
예를 들어 요청 방식, 요청 주소, 헤더 정보, 이전 페이지 정보처럼 단순 파라미터가 아닌 요청 정보를 확인해야 할 때는 HttpServletRequest가 자연스럽다.


아래처럼 구분하면 된다.

  • 이름, 나이, 검색어처럼 요청값만 받으면 @RequestParam이나 @ModelAttribute를 먼저 생각한다.
  • 요청 방식, 요청 주소, 헤더 정보처럼 요청 객체 정보가 필요하면 HttpServletRequest를 생각한다.
  • Servlet/JSP에서 하던 방식처럼 직접 요청 객체를 다뤄야 할 때 HttpServletRequest를 사용할 수 있다.

따라서 HttpServletRequest는 항상 써야 하는 기본 방식이라기보다, 요청 객체 전체에서 직접 정보를 꺼내야 할 때 사용하는 방식으로 이해하면 된다.


Servlet/JSP 방식과 Spring MVC 방식 비교

아래 두 예제는 같은 /direct?name=lee&age=20 요청을 Servlet/JSP 방식과 Spring MVC 방식으로 비교하기 위한 코드이다.
같은 프로젝트에 두 코드를 동시에 넣어 실행하라는 뜻이 아니다.
요청 객체에서 값을 직접 꺼내는 흐름이 두 방식에서 어떻게 이어지는지 비교해서 보기 위한 예제이다.


먼저 Servlet/JSP 방식에서는 doGet()의 매개변수로 받은 HttpServletRequest에서 값을 직접 꺼낸다.

// HttpServletRequestServletExample.java
@WebServlet("/direct")
public class DirectServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        String name = request.getParameter("name"); // name 요청값 직접 꺼내기
        int age = Integer.parseInt(request.getParameter("age")); // age 요청값을 숫자로 변환

        request.setAttribute("result", name + ":" + age); // JSP로 넘길 결과 저장

        RequestDispatcher dispatcher = request.getRequestDispatcher("/WEB-INF/views/result.jsp"); // 결과 JSP 지정
        dispatcher.forward(request, response); // JSP로 이동
    }
}
// result.jsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>result</title>
</head>
<body>
<h1>${result}</h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /direct?name=lee&age=20
// 화면 출력: lee:20

이 방식에서는 request.getParameter("name")으로 이름을 꺼내고, request.getParameter("age")로 나이를 꺼낸다.
그리고 나이는 문자열로 들어오기 때문에 Integer.parseInt()로 숫자로 바꾼다.
마지막으로 request.setAttribute()에 결과를 담고 JSP로 이동한다.


이제 같은 흐름을 Spring MVC 방식으로 보면 컨트롤러 메서드에서도 HttpServletRequest를 받을 수 있다.

// HttpServletRequestSpringExample.java
@Controller
public class RequestController {
    @GetMapping("/direct") // /direct 요청 처리
    public String direct(HttpServletRequest request, Model model) {
        String name = request.getParameter("name"); // name 요청값 직접 꺼내기
        int age = Integer.parseInt(request.getParameter("age")); // age 요청값을 숫자로 변환
        model.addAttribute("result", name + ":" + age); // 모델에 결과 저장
        return "result"; // 결과 화면으로 이동
    }
}
// result.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>result</title>
</head>
<body>
<h1 th:text="${result}"></h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /direct?name=lee&age=20
// 화면 출력: lee:20

이 코드에서 HttpServletRequest request는 현재 요청 객체를 받는 매개변수이다.
컨트롤러 메서드 안에서는 Servlet에서 하던 것처럼 request.getParameter()로 값을 직접 꺼낼 수 있다.


다만 화면에 데이터를 넘기는 방식은 달라진다.
Servlet/JSP 방식에서는 request.setAttribute()를 사용했지만, Spring MVC 방식에서는 Model에 데이터를 담는다.
그리고 return "result"로 뷰 이름을 반환하면 Spring이 result.html을 찾아 화면을 보여 준다.


두 방식의 차이 정리

Servlet/JSP 방식과 Spring MVC 방식은 HttpServletRequest로 요청 객체를 직접 다룰 수 있다는 점은 같다.
하지만 요청 처리 클래스와 화면 연결 방식이 달라진다.


차이는 아래처럼 정리할 수 있다.

  • Servlet에서는 doGet()이나 doPost()의 매개변수로 HttpServletRequest를 받는다.
  • Spring MVC에서는 컨트롤러 메서드의 매개변수로 HttpServletRequest를 받을 수 있다.
  • 두 방식 모두 request.getParameter()로 요청값을 직접 꺼낼 수 있다.
  • 두 방식 모두 숫자값은 필요하면 직접 변환해야 한다.
  • Servlet에서는 request.setAttribute()로 화면에 넘길 값을 담는다.
  • Spring MVC에서는 Model에 화면에 넘길 값을 담는다.
  • Servlet에서는 RequestDispatcher와 forward()로 화면 이동을 직접 처리한다.
  • Spring MVC에서는 뷰 이름을 반환하면 Spring이 화면을 찾아 준다.

따라서 HttpServletRequest는 Spring MVC에서 사라진 개념이 아니다.
기존 Servlet에서 쓰던 요청 객체를 Spring MVC 컨트롤러에서도 필요할 때 직접 받을 수 있다.


이 섹션의 핵심은 이것이다.
HttpServletRequest는 요청 객체 자체를 직접 다루는 방식이고, 자동 바인딩으로 처리하기 어려운 요청 정보가 필요할 때 사용할 수 있다.



모델에 데이터를 담아 뷰로 넘기는 방식 (Model, ModelAndView)

Model은 컨트롤러가 뷰에 전달할 데이터를 담는 객체이다.
컨트롤러는 요청을 처리한 뒤 화면에 보여 줄 값을 준비해야 한다.
이때 그 값을 화면에 바로 출력하는 것이 아니라, Model이라는 저장 공간에 이름과 값으로 담아서 뷰로 넘긴다.


이 개념은 Servlet/JSP에서 배운 request.setAttribute()와 비교하면 쉽게 이해할 수 있다.
Servlet/JSP 방식에서는 request.setAttribute("msg", "안녕하세요")처럼 request 객체에 데이터를 담고, forward()로 JSP에 넘겼다.
Spring MVC에서는 model.addAttribute("msg", "안녕하세요")처럼 Model에 데이터를 담고, 뷰 이름을 반환해서 화면으로 넘긴다.


즉, Model은 기존 Servlet/JSP에서 request.setAttribute()로 뷰에 데이터를 넘기던 흐름을 Spring MVC 방식으로 표현한 객체라고 이해하면 된다.
다만 Model은 데이터를 담는 역할이고, 실제 화면 이동은 컨트롤러 메서드가 반환한 뷰 이름을 기준으로 Spring이 처리한다.


Model은 무엇을 하는가

Model은 뷰에서 사용할 데이터를 담는 공간이다.
예를 들어 컨트롤러에서 "안녕하세요"라는 값을 화면에 보여 주고 싶다면, 그 값을 Model에 담아야 한다.


Model에 값을 담을 때는 addAttribute()를 사용한다.
예를 들어 model.addAttribute("msg", "안녕하세요")라고 작성하면, 뷰에서는 msg라는 이름으로 "안녕하세요" 값을 꺼낼 수 있다.


이때 중요한 점은 Model에 값을 담는다고 해서 그 값이 바로 화면에 출력되는 것은 아니라는 점이다.
컨트롤러는 Model에 값을 담고, 마지막에 어떤 화면으로 갈지 뷰 이름을 반환한다.
그다음 뷰에서 ${msg} 같은 표현으로 Model에 담긴 값을 꺼내 출력한다.


아래 코드는 Model에 값을 담아 화면으로 넘기는 가장 기본적인 예제이다.

// ModelBasicExample.java
@Controller
public class ModelBasicController {
    @GetMapping("/model-basic") // /model-basic 요청 처리
    public String modelBasic(Model model) {
        model.addAttribute("msg", "안녕하세요"); // msg라는 이름으로 값 저장
        return "modelResult"; // modelResult.html 화면으로 이동
    }
}
// modelResult.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>modelResult</title>
</head>
<body>
<h1 th:text="${msg}"></h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /model-basic
// Model에 저장된 이름: msg
// Model에 저장된 값: 안녕하세요
// 화면 출력: 안녕하세요

이 흐름을 실제 값 이동으로 보면 아래와 같다.

  • 컨트롤러가 "안녕하세요"라는 값을 만든다.
  • model.addAttribute("msg", "안녕하세요")로 Model에 저장한다.
  • return "modelResult"로 보여 줄 화면 이름을 반환한다.
  • Spring은 modelResult.html을 찾는다.
  • modelResult.html은 ${msg}로 Model에 담긴 값을 꺼낸다.
  • 화면에는 안녕하세요가 출력된다.

따라서 Model은 단순한 변수 하나가 아니다.
컨트롤러와 뷰 사이에서 데이터를 전달하는 저장 공간이다.


Model에 담는 이름과 화면에서 꺼내는 이름은 맞아야 한다

Model에 데이터를 담을 때는 이름과 값을 함께 저장한다.
예를 들어 model.addAttribute("msg", "안녕하세요")에서 "msg"는 화면에서 사용할 이름이고, "안녕하세요"는 실제 값이다.


뷰에서는 이 이름을 기준으로 값을 꺼낸다.
Thymeleaf에서는 ${msg}처럼 작성해서 Model에 저장된 msg 값을 읽을 수 있다.


아래 코드를 보면 이름이 어떻게 연결되는지 더 분명하다.

// ModelNameMatchExample.java
@Controller
public class ModelNameMatchController {
    @GetMapping("/model-name") // /model-name 요청 처리
    public String modelName(Model model) {
        model.addAttribute("userName", "둘리"); // userName이라는 이름으로 값 저장
        model.addAttribute("userAge", 10); // userAge라는 이름으로 값 저장
        return "userResult"; // userResult.html 화면으로 이동
    }
}
// userResult.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>userResult</title>
</head>
<body>
<p>이름: <span th:text="${userName}"></span></p>
<p>나이: <span th:text="${userAge}"></span></p>
</body>
</html>
// 출력결과
// 브라우저 주소: /model-name
// userName 값: 둘리
// userAge 값: 10
// 화면 출력: 이름: 둘리
// 화면 출력: 나이: 10

이 코드에서 userName이라는 이름으로 저장한 값은 화면에서도 ${userName}으로 꺼낸다.
userAge라는 이름으로 저장한 값은 화면에서도 ${userAge}로 꺼낸다.


만약 컨트롤러에서 model.addAttribute("userName", "둘리")라고 저장했는데, 화면에서 ${name}으로 꺼내려고 하면 값이 맞지 않는다.
왜냐하면 Model에 저장된 이름은 userName이지 name이 아니기 때문이다.


정리하면 아래와 같다.

  • model.addAttribute("userName", "둘리")에서 userName은 화면에서 사용할 이름이다.
  • "둘리"는 실제로 화면에 출력할 값이다.
  • 화면에서는 ${userName}으로 값을 꺼낸다.
  • Model에 담은 이름과 뷰에서 꺼내는 이름이 맞아야 한다.

따라서 Model은 이름과 값을 함께 저장하고, 뷰는 그 이름으로 값을 꺼낸다.
이 기준을 놓치면 컨트롤러에서는 값을 담았는데 화면에는 값이 안 나오는 상황이 생길 수 있다.


Servlet/JSP의 request.setAttribute와 Model의 관계

Servlet/JSP에서는 화면에 데이터를 넘기기 위해 request.setAttribute()를 자주 사용했다.
예를 들어 request.setAttribute("msg", "안녕하세요")라고 작성하면, JSP에서는 ${msg}로 값을 출력할 수 있었다.


Spring MVC에서는 이 흐름을 Model로 처리한다.
예를 들어 model.addAttribute("msg", "안녕하세요")라고 작성하면, Thymeleaf에서는 ${msg}로 값을 출력할 수 있다.


둘의 역할은 비슷하다.
둘 다 컨트롤러 쪽에서 만든 값을 화면 쪽으로 넘기기 위해 사용한다.
하지만 화면으로 이동하는 방식은 다르다.
Servlet/JSP에서는 RequestDispatcher와 forward()를 직접 사용한다.
Spring MVC에서는 컨트롤러 메서드가 뷰 이름을 반환하면, Spring이 그 이름에 맞는 화면을 찾아 준다.


아래는 같은 결과를 Servlet/JSP 방식으로 작성한 예제이다.

// ModelServletCompareExample.java
@WebServlet("/model")
public class ModelServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        request.setAttribute("msg", "Model 사용"); // JSP에 넘길 데이터 저장
        RequestDispatcher dispatcher = request.getRequestDispatcher("/WEB-INF/views/result.jsp"); // 결과 JSP 지정
        dispatcher.forward(request, response); // JSP로 이동
    }
}
// result.jsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>result</title>
</head>
<body>
<h1>${msg}</h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /model
// request에 저장된 이름: msg
// request에 저장된 값: Model 사용
// 화면 출력: Model 사용

같은 흐름을 Spring MVC 방식으로 작성하면 아래처럼 바뀐다.

// ModelSpringCompareExample.java
@Controller
public class ModelController {
    @GetMapping("/model") // /model 요청 처리
    public String model(Model model) {
        model.addAttribute("msg", "Model 사용"); // 화면에 보낼 데이터를 Model에 저장
        return "result"; // result.html 화면으로 이동
    }
}
// result.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>result</title>
</head>
<body>
<h1 th:text="${msg}"></h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /model
// Model에 저장된 이름: msg
// Model에 저장된 값: Model 사용
// 화면 출력: Model 사용

두 방식 모두 화면에 Model 사용을 출력한다.
하지만 처리 흐름은 다르다.


Servlet/JSP 방식에서는 request.setAttribute()로 데이터를 담고, RequestDispatcher와 forward()로 직접 JSP에 이동한다.
Spring MVC 방식에서는 model.addAttribute()로 데이터를 담고, return "result"로 뷰 이름을 반환한다.
그러면 Spring이 result.html을 찾아서 화면을 보여 준다.


차이는 아래처럼 볼 수 있다.

  • Servlet/JSP에서는 request.setAttribute()로 데이터를 담는다.
  • Spring MVC에서는 model.addAttribute()로 데이터를 담는다.
  • Servlet/JSP에서는 forward()로 직접 화면 이동을 처리한다.
  • Spring MVC에서는 뷰 이름을 반환하면 Spring이 화면을 찾아 준다.

즉, Model은 기존 request.setAttribute()와 비슷하게 뷰에 데이터를 넘기는 역할을 한다.
다만 화면 이동은 Spring MVC의 뷰 처리 흐름과 함께 동작한다.


ModelAndView는 무엇이 다른가

ModelAndView는 이름 그대로 모델 데이터와 뷰 이름을 한 객체에 함께 담는 방식이다.
Model을 사용할 때는 데이터는 Model에 담고, 뷰 이름은 String으로 따로 반환한다.
반면 ModelAndView는 데이터와 뷰 이름을 하나의 객체에 같이 넣어서 반환한다.


예를 들어 Model 방식에서는 아래처럼 흐름이 나뉜다.
컨트롤러 메서드는 Model에 데이터를 담고, 마지막에 "result"라는 뷰 이름을 String으로 반환한다.


반대로 ModelAndView 방식에서는 ModelAndView 객체를 만들고, 그 안에 데이터와 뷰 이름을 모두 넣는다.
즉, Model 방식은 데이터와 뷰 이름을 따로 다루고, ModelAndView 방식은 데이터와 뷰 이름을 한 객체에 함께 담는다.


먼저 Model 방식 예제를 다시 보면 아래와 같다.

// ModelReturnStringExample.java
@Controller
public class ModelReturnStringController {
    @GetMapping("/model-result") // /model-result 요청 처리
    public String modelResult(Model model) {
        model.addAttribute("msg", "Model 방식"); // 데이터는 Model에 저장
        return "result"; // 뷰 이름은 String으로 반환
    }
}
// 출력결과
// 브라우저 주소: /model-result
// Model에 저장된 값: Model 방식
// 반환한 뷰 이름: result
// 화면 출력: Model 방식

같은 결과를 ModelAndView 방식으로 작성하면 아래처럼 된다.

// ModelAndViewBasicExample.java
@Controller
public class ModelAndViewBasicController {
    @GetMapping("/mav-result") // /mav-result 요청 처리
    public ModelAndView mavResult() {
        ModelAndView mav = new ModelAndView(); // 데이터와 뷰 이름을 담을 객체 생성
        mav.addObject("msg", "ModelAndView 방식"); // 화면에 보낼 데이터 저장
        mav.setViewName("result"); // 보여 줄 화면 이름 저장
        return mav; // 데이터와 뷰 이름을 함께 반환
    }
}
// 출력결과
// 브라우저 주소: /mav-result
// ModelAndView에 저장된 값: ModelAndView 방식
// ModelAndView에 저장된 뷰 이름: result
// 화면 출력: ModelAndView 방식

이 코드에서는 ModelAndView 객체 안에 데이터와 뷰 이름을 모두 담는다.
mav.addObject("msg", "ModelAndView 방식")은 화면에 넘길 데이터를 저장한다.
mav.setViewName("result")는 보여 줄 화면 이름을 저장한다.


마지막에 return mav를 하면 Spring은 ModelAndView 안에 들어 있는 뷰 이름을 보고 result.html을 찾는다.
그리고 result.html에서는 ${msg} 값을 출력한다.
즉, ModelAndView는 데이터와 뷰 이름을 한 번에 묶어 반환하는 방식이다.


Model 방식과 ModelAndView 방식의 값 이동 비교

Model 방식과 ModelAndView 방식은 결과적으로 화면에 데이터를 전달한다는 점은 같다.
하지만 컨트롤러에서 반환하는 형태가 다르다.


Model 방식의 값 이동은 아래처럼 볼 수 있다.

// Model 방식 값 이동
// 컨트롤러에서 만든 값: "Model 방식"
// model.addAttribute("msg", "Model 방식")
// return "result"
// Spring이 result.html 찾기
// result.html에서 ${msg} 출력

Model 방식은 데이터 저장과 뷰 이름 반환이 나뉘어 있다.
데이터는 model.addAttribute()에 담고, 화면 이름은 return "result"로 따로 반환한다.


ModelAndView 방식의 값 이동은 아래처럼 볼 수 있다.

// ModelAndView 방식 값 이동
// 컨트롤러에서 만든 값: "ModelAndView 방식"
// mav.addObject("msg", "ModelAndView 방식")
// mav.setViewName("result")
// return mav
// Spring이 mav 안의 result를 보고 result.html 찾기
// result.html에서 ${msg} 출력

ModelAndView 방식은 데이터와 뷰 이름을 mav 객체 안에 함께 담는다.
그래서 컨트롤러 메서드의 반환값도 String이 아니라 ModelAndView가 된다.


차이는 아래처럼 정리할 수 있다.

  • Model은 데이터만 담고, 뷰 이름은 String으로 따로 반환한다.
  • ModelAndView는 데이터와 뷰 이름을 한 객체에 함께 담는다.
  • 둘 다 결국 뷰에 데이터를 전달하기 위한 도구이다.
  • Model 방식은 코드가 짧고 자주 사용된다.
  • ModelAndView 방식은 데이터와 뷰 이름을 한 번에 묶어 반환하고 싶을 때 사용한다.

따라서 둘 중 하나만 무조건 맞는 것은 아니다.
컨트롤러가 결과를 어떤 형태로 반환하느냐에 따라 선택해서 사용할 수 있다.


Spring MVC의 ModelAndView 방식

이번에는 ModelAndView 방식만 전체 코드로 다시 정리한다.
ModelAndView는 데이터와 뷰 이름을 한 객체에 함께 담아서 반환한다.

// ModelAndViewFullExample.java
@Controller
public class ModelAndViewController {
    @GetMapping("/mav") // /mav 요청 처리
    public ModelAndView mav() {
        ModelAndView mav = new ModelAndView(); // 데이터와 뷰 이름을 담을 객체 생성
        mav.addObject("msg", "ModelAndView 사용"); // 화면에 보낼 데이터 저장
        mav.setViewName("result"); // 보여 줄 화면 이름 저장
        return mav; // 데이터와 뷰 이름 함께 반환
    }
}
// result.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>result</title>
</head>
<body>
<h1 th:text="${msg}"></h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /mav
// 화면 출력: ModelAndView 사용

이 코드에서는 ModelAndView 객체 안에 데이터와 뷰 이름을 모두 담는다.
mav.addObject("msg", "ModelAndView 사용")은 화면에 넘길 데이터를 저장한다.
mav.setViewName("result")는 보여 줄 화면 이름을 저장한다.


마지막에 return mav를 하면 Spring은 ModelAndView 안에 들어 있는 뷰 이름을 보고 result.html을 찾는다.
그리고 result.html에서 ${msg} 값을 출력한다.
즉, ModelAndView는 데이터와 뷰 이름을 한 번에 묶어 반환하는 방식이다.


세 방식의 차이 정리

Servlet/JSP, Spring MVC의 Model, Spring MVC의 ModelAndView는 모두 화면에 데이터를 전달한다는 큰 흐름은 같다.
하지만 데이터를 담고 화면으로 이동하는 방식이 다르다.


차이는 아래처럼 정리할 수 있다.

  • Servlet/JSP에서는 request.setAttribute()로 데이터를 담는다.
  • Spring MVC의 Model 방식에서는 model.addAttribute()로 데이터를 담는다.
  • Spring MVC의 ModelAndView 방식에서는 mav.addObject()로 데이터를 담는다.
  • Servlet/JSP에서는 RequestDispatcher와 forward()로 화면 이동을 직접 처리한다.
  • Model 방식에서는 뷰 이름을 String으로 반환한다.
  • ModelAndView 방식에서는 뷰 이름을 ModelAndView 객체 안에 담아 반환한다.

이 차이를 실제 흐름으로 보면 아래와 같다.

// Servlet/JSP 방식
// request.setAttribute("msg", 값)
// RequestDispatcher로 JSP 지정
// forward(request, response)
// JSP에서 ${msg} 출력
// Spring MVC Model 방식
// model.addAttribute("msg", 값)
// return "result"
// Spring이 result.html 찾기
// Thymeleaf에서 ${msg} 출력
// Spring MVC ModelAndView 방식
// mav.addObject("msg", 값)
// mav.setViewName("result")
// return mav
// Spring이 result.html 찾기
// Thymeleaf에서 ${msg} 출력

따라서 Model과 ModelAndView는 기존 Servlet/JSP의 데이터 전달 흐름을 없앤 것이 아니다.
화면에 데이터를 넘기고 이동하던 흐름을 Spring MVC 방식으로 더 간단하게 표현한 것이다.


이 섹션의 핵심은 이것이다.
Model은 뷰에 전달할 데이터를 담고 뷰 이름은 String으로 따로 반환하는 방식이고, ModelAndView는 데이터와 뷰 이름을 한 객체에 함께 담아 반환하는 방식이다.



메서드 레벨 ModelAttribute (@ModelAttribute)

@ModelAttribute는 매개변수에만 붙는 어노테이션이 아니다.
메서드 위에 붙이면 요청 처리 메서드가 실행되기 전에 Model 데이터를 미리 만들어 둘 수 있다.
즉, 컨트롤러가 본격적으로 요청을 처리하기 전에 뷰에서 사용할 값을 먼저 준비하는 방식이다.


앞에서 본 매개변수 @ModelAttribute는 요청값을 DTO 같은 객체에 묶어 받는 역할이었다.
예를 들어 사용자가 보낸 name, age, address 같은 요청값을 UserDTO 객체 하나에 넣는 흐름이었다.


하지만 메서드 레벨 @ModelAttribute는 역할이 다르다.
메서드 위에 @ModelAttribute("data")처럼 붙이면, 이 메서드는 요청값을 받는 용도가 아니라 Model에 미리 넣을 데이터를 만드는 용도가 된다.


따라서 같은 @ModelAttribute라도 붙는 위치에 따라 역할이 완전히 달라진다.
매개변수에 붙으면 요청값을 객체로 받는 흐름이고, 메서드 위에 붙으면 요청 처리 전에 Model 데이터를 준비하는 흐름이다.


이 개념도 Servlet/JSP에서 배운 흐름과 비교하면 더 쉽게 이해할 수 있다.
Servlet/JSP 방식에서는 JSP에서 사용할 데이터를 요청 처리 코드 안에서 request.setAttribute()로 직접 넣어야 했다.
반면 Spring MVC에서는 메서드 위에 @ModelAttribute를 붙여 두면, 요청 처리 메서드가 실행되기 전에 Spring이 그 메서드를 먼저 실행해서 Model 데이터를 준비해 준다.


즉, 메서드 레벨 @ModelAttribute는 요청 처리 전에 뷰에서 사용할 Model 데이터를 미리 준비하는 기능이다.
이 기능을 알면 같은 컨트롤러 안에서 반복해서 필요한 Model 데이터를 매번 직접 넣는 코드를 줄일 수 있다.


매개변수 @ModelAttribute와 메서드 레벨 @ModelAttribute의 차이

@ModelAttribute는 어디에 붙는지에 따라 의미가 달라진다.
매개변수에 붙으면 요청값을 객체에 묶어 받는 역할을 한다.
메서드 위에 붙으면 요청 처리 전에 Model 데이터를 준비하는 역할을 한다.


먼저 매개변수에 붙는 @ModelAttribute를 보면 아래와 같다.

// ParameterModelAttributeExample.java
@Controller
public class UserController {
    @PostMapping("/user/add") // 회원 등록 요청 처리
    public String addUser(@ModelAttribute UserDTO user, Model model) {
        model.addAttribute("user", user); // 요청값이 담긴 객체를 화면으로 전달
        return "userView"; // 결과 화면으로 이동
    }
}

이 코드에서 @ModelAttribute UserDTO user는 사용자가 보낸 요청값을 UserDTO 객체에 묶어 받는다.
즉, 핵심은 요청값을 객체로 받는 것이다.


반대로 메서드 위에 붙는 @ModelAttribute는 의미가 다르다.

// MethodModelAttributeExample.java
@Controller
public class PrepController {
    @ModelAttribute("data")
    public String createData() {
        return "미리 준비된 값"; // Model에 data라는 이름으로 들어갈 값
    }

    @GetMapping("/prep")
    public String prep() {
        return "result"; // 결과 화면으로 이동
    }
}

이 코드에서 createData()는 /prep 요청을 직접 처리하는 메서드가 아니다.
/prep 요청과 직접 연결된 메서드는 prep()이다.


하지만 prep()가 실행되기 전에 createData()가 먼저 실행된다.
그리고 createData()가 반환한 "미리 준비된 값"은 data라는 이름으로 Model에 들어간다.


차이는 아래처럼 정리할 수 있다.

  • 매개변수 @ModelAttribute는 요청값을 객체에 묶어 받는다.
  • 메서드 레벨 @ModelAttribute는 요청 처리 전에 Model 데이터를 준비한다.
  • 매개변수에 붙으면 객체 바인딩 흐름이다.
  • 메서드에 붙으면 Model 준비 흐름이다.

따라서 같은 @ModelAttribute라도 붙는 위치를 먼저 봐야 한다.
붙는 위치를 보지 않고 이름만 보면 두 기능을 헷갈리기 쉽다.


요청 처리 전에 실행된다는 뜻

메서드 레벨 @ModelAttribute의 핵심은 실행 순서이다.
이 메서드는 요청 처리 메서드보다 먼저 실행된다.
그래서 요청 처리 메서드 안에서 직접 model.addAttribute()를 하지 않아도, 뷰에서는 미리 준비된 Model 값을 사용할 수 있다.


예를 들어 /prep 요청이 들어왔다고 생각하면 된다.
Spring은 바로 prep() 메서드만 실행하는 것이 아니다.
같은 컨트롤러 안에 메서드 레벨 @ModelAttribute가 있으면, 그 메서드를 먼저 실행해서 Model에 값을 넣는다.
그다음 실제 요청을 처리하는 prep() 메서드를 실행한다.


아래 코드로 실행 순서를 보면 더 분명하다.

// ModelAttributeOrderExample.java
@Controller
public class ModelAttributeOrderController {
    @ModelAttribute("data")
    public String createData() {
        return "미리 준비된 값"; // 1. 먼저 실행되어 Model 데이터 준비
    }

    @GetMapping("/prep")
    public String prep() {
        return "result"; // 2. 그 다음 요청 처리 메서드 실행
    }
}
// result.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>result</title>
</head>
<body>
<h1 th:text="${data}"></h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /prep
// 먼저 실행되는 메서드: createData()
// Model에 저장되는 이름: data
// Model에 저장되는 값: 미리 준비된 값
// 실제 요청 처리 메서드: prep()
// 화면 출력: 미리 준비된 값

이 코드에서 prep() 안에는 Model 매개변수가 없다.
또 model.addAttribute("data", "...") 코드도 없다.
그런데도 result.html에서는 ${data}를 사용할 수 있다.


이유는 @ModelAttribute("data")가 붙은 createData()가 먼저 실행되어 Model에 data 값을 넣어 두었기 때문이다.


흐름은 아래처럼 볼 수 있다.

  • 브라우저가 /prep을 요청한다.
  • Spring이 메서드 레벨 @ModelAttribute 메서드를 먼저 실행한다.
  • 반환값을 Model에 저장한다.
  • 그다음 @GetMapping("/prep")이 붙은 요청 처리 메서드가 실행된다.
  • 요청 처리 메서드는 뷰 이름을 반환한다.
  • 뷰는 미리 준비된 Model 값을 출력한다.

즉, 메서드 레벨 @ModelAttribute는 요청 처리 전에 화면에서 사용할 재료를 먼저 만들어 두는 준비 단계라고 이해하면 된다.


Servlet/JSP에서는 어떻게 처리했는가

Servlet/JSP 방식에서는 JSP로 넘길 데이터를 요청 처리 코드 안에서 request.setAttribute()로 직접 넣어야 했다.
예를 들어 화면에서 사용할 값을 보여 주려면, 요청을 처리하는 Servlet 안에서 데이터를 직접 준비하고 request에 저장했다.


아래 예제는 /prep 요청이 들어오면 data라는 값을 JSP로 넘기는 흐름이다.

// ModelAttributeServletExample.java
@WebServlet("/prep")
public class PrepServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        request.setAttribute("data", "미리 준비된 값"); // JSP에서 사용할 데이터 직접 저장
        RequestDispatcher dispatcher = request.getRequestDispatcher("/WEB-INF/views/result.jsp"); // 결과 JSP 지정
        dispatcher.forward(request, response); // JSP로 이동
    }
}
// result.jsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>result</title>
</head>
<body>
<h1>${data}</h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /prep
// 화면 출력: 미리 준비된 값

이 방식에서는 요청 처리 메서드 안에서 데이터를 직접 준비한다.
request.setAttribute("data", "미리 준비된 값")으로 값을 저장하고, forward()로 JSP에 넘긴다.


즉, Servlet/JSP 방식에서는 요청 처리 코드 안에서 화면에 보낼 데이터를 직접 넣는 흐름이다.
반복해서 필요한 데이터가 많아지면 request.setAttribute() 코드가 여러 요청 처리 코드에 반복될 수 있다.


Spring MVC에서는 어떻게 바뀌는가

Spring MVC에서는 메서드 레벨 @ModelAttribute를 사용해서 Model 데이터를 미리 준비할 수 있다.
요청 처리 메서드 안에서 직접 데이터를 넣지 않아도, 별도의 Model 준비 메서드가 먼저 실행된다.


아래 예제는 /prep 요청을 처리하기 전에 data라는 Model 값을 미리 준비하는 코드이다.

// ModelAttributeSpringExample.java
@Controller
public class ModelPrepController {
    @ModelAttribute("data")
    public String createData() {
        return "미리 준비된 값"; // 요청 처리 전에 Model에 들어갈 값 반환
    }

    @GetMapping("/prep")
    public String prep() {
        return "result"; // 결과 화면 이름 반환
    }
}
// result.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>result</title>
</head>
<body>
<h1 th:text="${data}"></h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /prep
// 화면 출력: 미리 준비된 값

이 코드에서 createData()는 요청 처리 메서드가 아니다.
/prep 요청과 직접 연결된 메서드는 prep()이다.
하지만 prep()가 실행되기 전에 createData()가 먼저 실행되고, 그 반환값이 data라는 이름으로 Model에 들어간다.


그래서 prep() 안에는 Model 매개변수가 없고, model.addAttribute() 코드도 없다.
그런데도 result.html에서는 ${data}를 사용할 수 있다.
이유는 @ModelAttribute("data")가 붙은 createData()가 미리 Model 데이터를 준비했기 때문이다.


즉, Spring MVC 방식에서는 Model 준비와 요청 처리 메서드를 분리할 수 있다.
요청 처리 메서드는 어떤 화면으로 갈지만 정하고, Model 데이터 준비는 별도의 @ModelAttribute 메서드가 맡을 수 있다.


여러 요청 처리 메서드에서 공통으로 사용할 수 있다

메서드 레벨 @ModelAttribute는 같은 컨트롤러 안의 요청 처리 메서드가 실행되기 전에 먼저 실행될 수 있다.
그래서 같은 컨트롤러 안에서 여러 화면이 공통으로 필요로 하는 데이터를 준비할 때 사용할 수 있다.


예를 들어 여러 화면에서 공통으로 categoryList가 필요하다고 생각하면 된다.
각 요청 처리 메서드마다 model.addAttribute("categoryList", ...)를 반복해서 쓰면 코드가 길어진다.
이럴 때 메서드 레벨 @ModelAttribute로 공통 데이터를 준비할 수 있다.

// CommonModelAttributeExample.java
@Controller
public class BoardController {
    @ModelAttribute("categoryList")
    public List<String> categoryList() {
        List<String> categories = new ArrayList<>(); // 카테고리 목록 생성
        categories.add("공지사항"); // 첫 번째 카테고리 추가
        categories.add("자유게시판"); // 두 번째 카테고리 추가
        categories.add("질문게시판"); // 세 번째 카테고리 추가
        return categories; // categoryList라는 이름으로 Model에 저장
    }

    @GetMapping("/board/write")
    public String writeForm() {
        return "boardWrite"; // 글쓰기 화면으로 이동
    }

    @GetMapping("/board/edit")
    public String editForm() {
        return "boardEdit"; // 수정 화면으로 이동
    }
}
// 출력결과
// 브라우저 주소: /board/write
// 먼저 실행되는 메서드: categoryList()
// Model에 저장되는 이름: categoryList
// 이동 화면: boardWrite.html
// 브라우저 주소: /board/edit
// 먼저 실행되는 메서드: categoryList()
// Model에 저장되는 이름: categoryList
// 이동 화면: boardEdit.html

이 예제에서 writeForm()과 editForm()은 직접 Model에 categoryList를 넣지 않는다.
하지만 같은 컨트롤러 안에 @ModelAttribute("categoryList")가 붙은 메서드가 있기 때문에 요청 처리 전에 카테고리 목록이 준비될 수 있다.


이런 구조는 여러 요청에서 공통으로 필요한 화면 데이터를 준비할 때 유용하다.
다만 모든 데이터를 무조건 메서드 레벨 @ModelAttribute로 빼는 것은 아니다.
특정 요청에서만 필요한 데이터라면 해당 요청 처리 메서드 안에서 model.addAttribute()로 넣는 편이 더 명확할 수 있다.


여러 타입의 데이터도 미리 준비할 수 있다

메서드 레벨 @ModelAttribute는 문자열만 Model에 넣는 기능이 아니다.
배열, 객체, 날짜, 리스트 같은 값도 미리 준비할 수 있다.
즉, 뷰에서 사용할 다양한 타입의 데이터를 요청 처리 전에 Model에 넣어 둘 수 있다.


아래 코드는 문자열, 배열, 객체, 날짜, 리스트를 Model에 미리 넣는 예제이다.

// MyVO.java
public class MyVO {
    private int number; // 숫자 값 저장
    private String color; // 색상 값 저장

    public MyVO(int number, String color) {
        this.number = number; // 숫자 값 저장
        this.color = color; // 색상 값 저장
    }

    public int getNumber() {
        return number; // 숫자 값 반환
    }

    public String getColor() {
        return color; // 색상 값 반환
    }
}
// MultipleModelAttributeExample.java
@Controller
public class ModelPrepMultiController {
    @ModelAttribute("v1")
    public String createString() {
        return "테스트!!"; // 문자열 Model 준비
    }

    @ModelAttribute("v2")
    public int[] createArray() {
        return new int[] {10, 20, 30, 40, 50}; // 배열 Model 준비
    }

    @ModelAttribute("v3")
    public MyVO createVO() {
        return new MyVO(23, "yellow"); // 객체 Model 준비
    }

    @ModelAttribute("v4")
    public Date createDate() {
        return new Date(); // 날짜 Model 준비
    }

    @ModelAttribute("v5")
    public List<MyVO> createList() {
        List<MyVO> list = new ArrayList<MyVO>(); // 리스트 생성
        list.add(new MyVO(7, "red")); // 첫 번째 객체 추가
        list.add(new MyVO(11, "pink")); // 두 번째 객체 추가
        return list; // 리스트 Model 준비
    }

    @RequestMapping("/modeltest1")
    public String handle() {
        return "modelResult1"; // 미리 준비한 Model을 보여 줄 화면으로 이동
    }
}
// modelResult1.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>modelResult1</title>
</head>
<body>
<p th:text="${v1}"></p>
<p th:text="${v2[0]}"></p>
<p th:text="${v3.color}"></p>
<p th:text="${v4}"></p>
<p th:text="${v5[0].color}"></p>
</body>
</html>
// 출력결과
// 브라우저 주소: /modeltest1
// 화면 출력: 테스트!!
// 화면 출력: 10
// 화면 출력: yellow
// 화면 출력: 실행 시점의 날짜와 시간
// 화면 출력: red

이 코드에서 handle()은 Model 데이터를 직접 만들지 않는다.
그런데도 뷰에서는 v1, v2, v3, v4, v5를 사용할 수 있다.
요청 처리 전에 각각의 @ModelAttribute 메서드가 먼저 실행되어 Model에 값을 넣기 때문이다.


v1은 문자열이므로 그대로 출력된다.
v2는 배열이므로 v2[0]처럼 첫 번째 값을 꺼낼 수 있다.
v3는 MyVO 객체이므로 v3.color처럼 객체의 값을 꺼낼 수 있다.
v4는 실행 시점에 만들어진 날짜 객체이므로 실행할 때마다 화면에 보이는 값이 달라질 수 있다.
v5는 MyVO 객체가 들어 있는 리스트이므로 v5[0].color처럼 첫 번째 객체의 색상 값을 꺼낼 수 있다.


정리하면 아래와 같다.

  • createString()은 문자열을 v1로 준비한다.
  • createArray()는 배열을 v2로 준비한다.
  • createVO()는 객체를 v3로 준비한다.
  • createDate()는 날짜를 v4로 준비한다.
  • createList()는 리스트를 v5로 준비한다.
  • handle()은 Model을 직접 만들지 않고 뷰 이름만 반환한다.

따라서 메서드 레벨 @ModelAttribute는 뷰에서 사용할 여러 종류의 Model 데이터를 요청 처리 전에 미리 준비하는 기능이다.


언제 메서드 레벨 ModelAttribute를 쓰면 좋은가

메서드 레벨 @ModelAttribute는 모든 상황에서 무조건 쓰는 기능이 아니다.
요청 처리 메서드 안에 model.addAttribute()를 쓰는 것이 더 명확한 경우도 있다.


이 기능은 보통 같은 컨트롤러 안에서 여러 요청이 공통으로 사용하는 데이터가 있을 때 유용하다.
예를 들어 여러 화면에서 카테고리 목록, 선택 옵션 목록, 공통 안내 문구처럼 반복해서 필요한 데이터가 있다면 메서드 레벨 @ModelAttribute로 미리 준비할 수 있다.


반대로 특정 요청에서만 필요한 데이터라면 해당 요청 처리 메서드 안에서 model.addAttribute()로 직접 넣는 것이 더 읽기 쉽다.
모든 데이터를 메서드 레벨 @ModelAttribute로 분리하면 오히려 어느 요청에서 어떤 데이터가 필요한지 찾기 어려워질 수 있다.


정리하면 아래와 같다.

  • 여러 요청에서 공통으로 필요한 데이터라면 메서드 레벨 @ModelAttribute가 유용하다.
  • 한 요청에서만 필요한 데이터라면 요청 처리 메서드 안의 model.addAttribute()가 더 명확할 수 있다.
  • 메서드 레벨 @ModelAttribute는 요청 처리 전에 실행되므로 실행 순서를 이해해야 한다.
  • 같은 컨트롤러 안에서 반복되는 Model 준비 코드를 줄일 수 있다.

따라서 메서드 레벨 @ModelAttribute는 공통 Model 데이터를 미리 준비할 때 사용하는 기능으로 이해하면 된다.


두 방식의 차이 정리

Servlet/JSP 방식과 Spring MVC 방식은 뷰에서 사용할 데이터를 준비한다는 큰 흐름은 같다.
하지만 데이터를 준비하는 위치와 방식이 달라진다.


차이는 아래처럼 정리할 수 있다.

  • Servlet/JSP에서는 요청 처리 메서드 안에서 request.setAttribute()로 데이터를 직접 넣는다.
  • Spring MVC에서는 메서드 레벨 @ModelAttribute로 요청 처리 전에 데이터를 미리 넣을 수 있다.
  • Servlet/JSP에서는 반복 데이터가 많아지면 setAttribute() 코드가 여러 요청 처리 코드에 반복될 수 있다.
  • Spring MVC에서는 같은 컨트롤러 안에서 Model 준비 메서드를 분리해서 반복을 줄일 수 있다.
  • Servlet/JSP에서는 데이터 준비와 요청 처리가 한 메서드 안에 섞이기 쉽다.
  • Spring MVC에서는 Model 준비와 요청 처리를 나누어 볼 수 있다.

따라서 메서드 레벨 @ModelAttribute는 기존 데이터 전달 흐름을 없앤 것이 아니다.
뷰에서 사용할 데이터를 요청 처리 전에 미리 준비하도록 분리해 주는 Spring MVC 방식이다.


이 섹션의 핵심은 이것이다.
메서드 레벨 @ModelAttribute는 요청 처리 메서드가 실행되기 전에 Model 데이터를 미리 준비하고, 요청 처리 메서드는 그 데이터를 사용하는 뷰로 흐름을 연결한다.



세션과 연결되는 모델 속성 (@SessionAttributes, SessionStatus)

@SessionAttributes는 Model 속성 중 일부를 세션에 유지하는 어노테이션이다.
보통 Model에 담긴 값은 요청 하나가 처리되는 동안만 사용된다.
하지만 @SessionAttributes("data1")처럼 지정하면 data1이라는 이름의 Model 속성은 세션에도 저장되어 다음 요청에서도 이어서 사용할 수 있다.


여기서 세션은 사용자별로 서버에 유지되는 저장 공간이다.
한 번의 요청이 끝나도 바로 사라지지 않고, 같은 사용자의 다음 요청에서도 값을 꺼내 쓸 수 있다.
따라서 여러 단계의 입력 과정, 장바구니, 값 누적처럼 이전 요청의 데이터를 다음 요청에서도 계속 써야 할 때 세션이 필요하다.


이 개념은 Servlet/JSP에서 배운 HttpSession과 연결해서 보면 더 쉽다.
Servlet/JSP 방식에서는 request.getSession()으로 세션 객체를 직접 꺼낸 뒤, session.setAttribute()로 값을 저장했다.
반면 Spring MVC의 @SessionAttributes는 개발자가 세션에 직접 넣는 방식이 아니라, Model에 담긴 값 중 지정한 이름만 세션에 유지하도록 Spring에게 알려 주는 방식이다.


즉, @SessionAttributes는 세션 저장소 전체를 직접 다루는 기능이 아니다.
Model 속성 중 일부를 세션과 연결해서 여러 요청 동안 유지하는 기능이다.


일반 Model 값과 세션 값의 차이

Model은 원래 컨트롤러가 뷰에 데이터를 전달할 때 사용하는 객체이다.
예를 들어 model.addAttribute("msg", "안녕하세요")라고 하면 현재 요청의 결과 화면에서 ${msg}로 값을 출력할 수 있다.


하지만 일반 Model 값은 기본적으로 요청 하나에서 사용되는 값이다.
첫 번째 요청에서 Model에 값을 담았다고 해서, 두 번째 요청에서도 그 값이 자동으로 남아 있는 것은 아니다.


아래 예제는 일반 Model에 값을 담는 흐름이다.

// NormalModelExample.java
@Controller
public class NormalModelController {
    @GetMapping("/normal-model") // /normal-model 요청 처리
    public String normalModel(Model model) {
        model.addAttribute("data1", "요청 하나에서만 사용하는 값"); // 현재 요청의 화면에 넘길 값 저장
        return "normalResult"; // normalResult.html 화면으로 이동
    }
}
// 출력결과
// 브라우저 주소: /normal-model
// Model에 저장된 이름: data1
// Model에 저장된 값: 요청 하나에서만 사용하는 값
// 화면 출력: 요청 하나에서만 사용하는 값

이 값은 /normal-model 요청을 처리하고 결과 화면을 보여 줄 때 사용된다.
하지만 다음 요청에서도 자동으로 이어지는 값은 아니다.


반대로 세션에 저장된 값은 같은 사용자의 여러 요청 동안 유지될 수 있다.
예를 들어 첫 번째 요청에서 java를 저장하고, 두 번째 요청에서 spring을 이어 붙이면 javaspring처럼 누적된 값을 만들 수 있다.


정리하면 아래와 같다.

  • 일반 Model 값은 현재 요청의 결과 화면에 데이터를 넘기는 용도이다.
  • 일반 Model 값은 다음 요청에 자동으로 이어지지 않는다.
  • 세션 값은 같은 사용자의 여러 요청 동안 유지될 수 있다.
  • @SessionAttributes는 지정한 Model 속성을 세션에도 유지하게 만든다.

따라서 한 번 보여 주고 끝나는 값은 일반 Model로 충분하고, 다음 요청에서도 이어져야 하는 값은 세션 유지가 필요하다.


모델 속성이 세션에 유지된다는 것은 무엇인가

@SessionAttributes("data1")라고 쓰면 data1이라는 이름의 Model 속성이 세션 유지 대상이 된다.
즉, Model에 data1이라는 이름으로 들어간 값이 세션에도 보관되어 다음 요청에서도 다시 사용할 수 있다.


여기서 중요한 점은 모든 Model 속성이 세션에 들어가는 것이 아니라는 점이다.
@SessionAttributes("data1")라고 지정했다면 data1만 세션 유지 대상이다.
data2, msg, result 같은 다른 Model 속성은 따로 지정하지 않으면 일반 Model 속성처럼 요청이 끝나면 사라지는 값으로 보면 된다.


아래처럼 이해하면 쉽다.

// SessionAttributeNameExample.java
@Controller
@SessionAttributes("data1") // data1이라는 Model 속성만 세션 유지 대상
public class SessionAttributeNameController {
    @GetMapping("/session-name")
    public String sessionName(Model model) {
        model.addAttribute("data1", "세션에 유지될 수 있는 값"); // 세션 유지 대상
        model.addAttribute("data2", "현재 요청에서만 사용할 값"); // 일반 Model 값
        return "sessionNameResult"; // 결과 화면으로 이동
    }
}
// 출력결과
// data1: @SessionAttributes("data1") 대상이므로 세션에 유지될 수 있음
// data2: 지정 대상이 아니므로 일반 Model 값으로 사용됨

이 코드에서 data1과 data2는 모두 Model에 들어간다.
하지만 세션 유지 대상으로 지정된 이름은 data1뿐이다.
그래서 data1은 세션에도 유지될 수 있고, data2는 일반 Model 값처럼 현재 요청에서 사용하는 값으로 이해하면 된다.


정리하면 아래와 같다.

  • 일반 Model 속성은 요청 하나가 끝나면 사라진다.
  • @SessionAttributes로 지정한 Model 속성은 세션에도 저장된다.
  • 세션에 저장된 값은 같은 사용자의 다음 요청에서도 이어질 수 있다.
  • 모든 Model 속성이 아니라, 지정한 이름의 Model 속성만 세션에 유지된다.

따라서 @SessionAttributes를 볼 때는 어떤 Model 이름이 세션 유지 대상으로 지정되었는지를 먼저 확인해야 한다.


StringBuffer와 append는 왜 사용하는가

StringBuffer는 문자열을 계속 이어 붙일 수 있는 객체이다.
일반 String은 값을 붙일 때 기존 문자열을 직접 바꾸는 구조라기보다, 새 문자열을 다시 만들어 변수에 넣는 흐름에 가깝다.
반면 StringBuffer는 같은 객체 안에 문자열을 계속 누적할 수 있다.


이 예제에서는 요청이 여러 번 들어올 때마다 기존 값 뒤에 새 문자열을 붙여야 한다.
그래서 StringBuffer를 사용한다.
그리고 기존 문자열 뒤에 값을 붙일 때는 append() 메서드를 사용한다.


아래처럼 생각하면 된다.

// StringBufferAppendExample.java
StringBuffer data1 = new StringBuffer(); // 문자열을 누적할 객체 생성
data1.append("java"); // 기존 값 뒤에 java 추가
data1.append("spring"); // 기존 값 뒤에 spring 추가
// 출력결과
// data1 값: javaspring

첫 번째로 append("java")를 실행하면 data1 안에는 java가 들어간다.
그 다음 append("spring")을 실행하면 기존 값 뒤에 spring이 붙는다.
그래서 최종 값은 javaspring이 된다.


이 흐름이 세션 예제와 연결된다.
첫 번째 요청이 /session?str=java이면 data1에 java가 붙는다.
두 번째 요청이 /session?str=spring이면 세션에 유지되던 data1 뒤에 spring이 붙는다.
그래서 화면에는 javaspring이 출력된다.


정리하면 아래와 같다.

  • String은 일반 문자열이다.
  • StringBuffer는 문자열을 계속 수정하거나 이어 붙이기 위한 객체이다.
  • append()는 기존 문자열 뒤에 새 값을 붙인다.
  • 이 예제에서는 세션에 남아 있는 값에 java, spring, boot 같은 요청값을 계속 누적하려고 StringBuffer를 사용한다.

따라서 이 예제에서 StringBuffer를 쓰는 이유는 요청이 여러 번 들어올 때마다 문자열을 새로 덮어쓰는 것이 아니라, 기존 값 뒤에 계속 누적하기 위해서이다.


HttpSession 직접 사용과 @SessionAttributes의 차이

HttpSession을 직접 사용하면 개발자가 세션 저장소 자체를 직접 다룬다.
예를 들어 session.setAttribute("data1", value)처럼 세션에 값을 직접 저장하고, session.getAttribute("data1")로 직접 꺼낸다.


반면 @SessionAttributes는 Model 속성을 세션에 연결하는 방식이다.
개발자가 처음부터 session.setAttribute()를 호출하는 것이 아니라, Model에 들어간 특정 이름의 값이 세션에도 유지되도록 지정한다.
즉, 출발점이 세션이 아니라 Model이다.


아래는 HttpSession을 직접 사용하는 방식이다.

// HttpSessionDirectExample.java
@Controller
public class HttpSessionDirectController {
    @GetMapping("/session-direct") // /session-direct?str=java 요청 처리
    public String sessionDirect(@RequestParam(value = "str", required = false) String str,
                                HttpSession session,
                                Model model) {
        StringBuffer data1 = (StringBuffer) session.getAttribute("data1"); // 세션에서 직접 값 꺼내기

        if (data1 == null) {
            data1 = new StringBuffer(); // 처음 요청이면 새 객체 생성
            session.setAttribute("data1", data1); // 세션에 직접 저장
        }

        if (str != null) {
            data1.append(str); // 기존 값 뒤에 새 문자열 누적
        }

        model.addAttribute("data1", data1); // 화면에 출력할 값 저장
        return "sessionResult"; // 결과 화면으로 이동
    }
}

이 방식은 세션 객체를 직접 다룬다.
session.getAttribute("data1")로 값을 직접 꺼내고, 값이 없으면 session.setAttribute("data1", data1)로 직접 저장한다.


반면 @SessionAttributes 방식은 아래처럼 Model 속성 이름을 세션 유지 대상으로 지정한다.

// SessionAttributesBasicExample.java
@Controller
@SessionAttributes("data1")
public class SessionAttributesBasicController {
    @ModelAttribute("data1")
    public StringBuffer createData1() {
        return new StringBuffer(); // data1 Model 속성의 초기값 준비
    }

    @GetMapping("/session-attr") // /session-attr?str=java 요청 처리
    public String sessionAttr(@ModelAttribute("data1") StringBuffer data1,
                              @RequestParam(value = "str", required = false) String str,
                              Model model) {
        if (str != null) {
            data1.append(str); // 세션에 유지되는 Model 속성에 새 문자열 누적
        }

        model.addAttribute("data1", data1); // 화면에 출력할 값 저장
        return "sessionResult"; // 결과 화면으로 이동
    }
}

이 방식에서는 개발자가 session.setAttribute()를 직접 호출하지 않는다.
대신 @SessionAttributes("data1")로 data1이라는 Model 속성을 세션에 유지하겠다고 지정한다.


차이는 아래처럼 보면 쉽다.

  • HttpSession 직접 사용은 세션 저장소에 직접 값을 넣고 뺀다.
  • @SessionAttributes는 Model 속성 중 지정한 이름을 세션에 유지한다.
  • HttpSession은 세션 자체를 직접 다루는 방식이다.
  • @SessionAttributes는 Model과 세션을 연결하는 방식이다.

따라서 둘은 모두 세션을 사용할 수 있지만 접근 방식이 다르다.
@SessionAttributes는 Model에서 시작한 값을 세션에 유지하는 구조라고 이해해야 한다.


SessionStatus는 언제 사용하는가

@SessionAttributes로 유지한 값은 작업이 끝나면 정리해야 한다.
그대로 두면 같은 세션 안에서 계속 남아 있어서 다음 요청에도 영향을 줄 수 있다.
예를 들어 입력 단계가 끝났는데도 이전 입력값이 계속 남아 있으면 화면이나 처리 결과가 헷갈릴 수 있다.


이때 사용하는 것이 SessionStatus이다.
SessionStatus의 setComplete()를 호출하면 @SessionAttributes로 유지하던 Model 속성의 세션 유지 흐름을 끝낼 수 있다.


주의할 점은 setComplete()가 세션 전체를 비우는 기능은 아니라는 점이다.
세션에 저장된 모든 값을 지우는 것이 아니라, 해당 컨트롤러에서 @SessionAttributes로 관리하던 속성의 유지 상태를 끝내는 쪽에 가깝다.


아래 코드는 세션 유지 흐름을 끝내는 예제이다.

// SessionStatusClearExample.java
@Controller
@SessionAttributes("data1")
public class SessionStatusClearController {
    @ModelAttribute("data1")
    public StringBuffer createData1() {
        return new StringBuffer(); // 세션에 유지될 Model 속성 초기값
    }

    @GetMapping("/session/clear")
    public String clear(SessionStatus status) {
        status.setComplete(); // @SessionAttributes로 유지하던 값 정리
        return "sessionClear"; // 정리 결과 화면으로 이동
    }
}
// 출력결과
// 요청 주소: /session/clear
// 실행 코드: status.setComplete()
// 결과: @SessionAttributes로 유지하던 data1 유지 흐름 종료

status.setComplete()는 @SessionAttributes로 관리하던 data1의 유지 흐름을 끝낸다.
그래서 이후 다시 data1이 필요하면 새로 준비되는 흐름으로 이어질 수 있다.


정리하면 아래와 같다.

  • @SessionAttributes는 지정한 Model 속성을 세션에 유지한다.
  • 작업이 끝나면 SessionStatus#setComplete()로 유지 흐름을 끝낼 수 있다.
  • setComplete()는 세션 전체 삭제가 아니다.
  • @SessionAttributes로 관리하던 Model 속성 정리에 가깝다.

따라서 @SessionAttributes를 사용하면 값 유지뿐 아니라 언제 정리할지도 함께 생각해야 한다.


Servlet/JSP에서는 어떻게 처리했는가

Servlet/JSP 방식에서는 세션 값을 직접 저장하고 직접 꺼냈다.
아래 예제는 /session?str=java처럼 요청할 때마다 세션에 저장된 문자열 뒤에 새 값을 이어 붙이는 흐름이다.


같은 프로젝트에 아래 코드와 뒤의 Spring MVC 코드를 동시에 넣어 비교하라는 뜻이 아니다.
세션 값을 직접 다루는 방식과 @SessionAttributes를 사용하는 방식을 비교하기 위한 예제이다.

// SessionServletExample.java
@WebServlet("/session")
public class SessionServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        HttpSession session = request.getSession(); // 세션 객체 직접 꺼내기
        StringBuffer data1 = (StringBuffer) session.getAttribute("data1"); // 세션 값 직접 꺼내기

        if (data1 == null) {
            data1 = new StringBuffer(); // 처음 요청이면 새 객체 생성
            session.setAttribute("data1", data1); // 세션에 직접 저장
        }

        String str = request.getParameter("str"); // 요청값 직접 꺼내기
        if (str != null) {
            data1.append(str); // 기존 값 뒤에 새 값 누적
        }

        request.setAttribute("data1", data1); // JSP에서 출력할 값 저장
        RequestDispatcher dispatcher = request.getRequestDispatcher("/WEB-INF/views/sessionResult.jsp"); // 결과 JSP 지정
        dispatcher.forward(request, response); // JSP로 이동
    }
}
// sessionResult.jsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>sessionResult</title>
</head>
<body>
<h1>${data1}</h1>
</body>
</html>
// 출력결과
// 첫 번째 요청: /session?str=java
// 세션에 저장된 data1 기존 값: 없음
// 새 StringBuffer 생성 후 java 누적
// 화면 출력: java
// 두 번째 요청: /session?str=spring
// 세션에 저장된 data1 기존 값: java
// 기존 값 뒤에 spring 누적
// 화면 출력: javaspring

이 방식에서는 개발자가 세션 객체를 직접 꺼낸다.
그리고 session.getAttribute("data1")로 기존 값을 확인하고, 값이 없으면 new StringBuffer()로 새 객체를 만든 뒤 session.setAttribute()로 직접 저장한다.


그다음 요청 파라미터 str을 꺼내서 기존 문자열 뒤에 붙인다.
첫 번째 요청에서는 기존 값이 없으므로 java가 된다.
두 번째 요청에서는 기존 값 java가 세션에 남아 있으므로 spring이 뒤에 붙어 javaspring이 된다.


즉, Servlet/JSP 방식에서는 세션 객체 가져오기 → 세션 값 확인 → 값 생성 → 세션 저장 → 값 누적 → 화면 전달을 개발자가 직접 코드로 작성한다.


Spring MVC에서는 어떻게 바뀌는가

Spring MVC에서는 @SessionAttributes를 사용해서 Model 속성 일부를 세션에 유지할 수 있다.
아래 예제는 data1이라는 Model 속성을 세션에 유지하고, 요청이 반복될 때마다 문자열을 누적하는 흐름이다.

// SessionAttributesSpringExample.java
@Controller
@SessionAttributes("data1")
public class SessionController {
    @ModelAttribute("data1")
    public StringBuffer createData1() {
        return new StringBuffer(); // 세션에 유지될 Model 데이터 준비
    }

    @GetMapping("/session")
    public String session(@ModelAttribute("data1") StringBuffer data1,
                          @RequestParam(value = "str", required = false) String str,
                          Model model) {
        if (str != null) {
            data1.append(str); // 세션에 유지되는 값에 새 문자열 누적
        }

        model.addAttribute("data1", data1); // 화면에 출력할 값 저장
        return "sessionResult"; // 결과 화면으로 이동
    }

    @GetMapping("/session/clear")
    public String clear(SessionStatus status) {
        status.setComplete(); // @SessionAttributes로 유지하던 값 정리
        return "sessionClear"; // 정리 결과 화면으로 이동
    }
}
// sessionResult.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>sessionResult</title>
</head>
<body>
<h1 th:text="${data1}"></h1>
</body>
</html>
// sessionClear.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>sessionClear</title>
</head>
<body>
<h1>세션 유지 흐름 정리 완료</h1>
</body>
</html>
// 출력결과
// 첫 번째 요청: /session?str=java
// data1 기존 값: 빈 StringBuffer
// java 누적
// 화면 출력: java
// 두 번째 요청: /session?str=spring
// data1 기존 값: java
// spring 누적
// 화면 출력: javaspring
// 정리 요청: /session/clear
// status.setComplete() 실행
// 화면 출력: 세션 유지 흐름 정리 완료
// 다시 요청: /session?str=boot
// data1 새로 준비
// boot 누적
// 화면 출력: boot

이 코드에서 @SessionAttributes("data1")는 data1이라는 Model 속성을 세션에 유지하겠다는 뜻이다.
@ModelAttribute("data1")가 붙은 createData1()은 data1 Model 속성을 미리 준비한다.
그리고 session() 메서드에서 @ModelAttribute("data1") StringBuffer data1로 그 값을 다시 받아 사용한다.


첫 번째 요청에서 str=java가 들어오면 data1에는 java가 누적된다.
두 번째 요청에서 str=spring이 들어오면 기존 값이 유지되어 있으므로 javaspring이 된다.
StringBuffer의 append()는 기존 문자열 뒤에 새 문자열을 붙이기 때문이다.


/session/clear를 요청하면 SessionStatus#setComplete()가 실행된다.
이때 @SessionAttributes로 유지하던 data1의 세션 유지 흐름이 끝난다.
그래서 다시 /session?str=boot를 요청하면 이전의 javaspring이 이어지지 않고 새로 시작되어 boot만 출력된다.


요청 범위와 세션 범위의 차이

이 예제에서 중요한 차이는 요청 범위와 세션 범위이다.
요청 범위는 요청 하나가 처리되는 동안만 값이 유지되는 범위이다.
세션 범위는 같은 사용자의 여러 요청 동안 값이 유지되는 범위이다.


일반 Model 속성은 요청 범위에 가깝다.
한 번 화면에 넘기고 나면 다음 요청에 자동으로 이어지지 않는다.
반면 @SessionAttributes로 지정한 Model 속성은 세션 범위에 저장되어 다음 요청에서도 이어질 수 있다.


이 차이는 아래처럼 정리할 수 있다.

  • 요청 범위 값은 요청 하나가 끝나면 사라진다.
  • 세션 범위 값은 같은 사용자의 다음 요청에서도 유지될 수 있다.
  • 일반 Model 속성은 기본적으로 요청 범위로 이해하면 된다.
  • @SessionAttributes로 지정한 Model 속성은 세션 범위에도 유지된다.

따라서 값이 누적되거나 다음 단계로 이어져야 한다면 세션 유지가 필요하다.
반대로 한 화면에서만 쓰고 끝나는 값이라면 일반 Model 속성으로 충분하다.


언제 @SessionAttributes를 쓰면 좋은가

@SessionAttributes는 모든 세션 처리에 무조건 사용하는 기능이 아니다.
Model에 담긴 값 중 일부를 여러 요청 동안 유지해야 할 때 사용한다.


예를 들어 여러 단계로 나누어 입력을 받는 화면이 있다고 생각하면 된다.
첫 번째 화면에서 입력한 값을 두 번째 화면에서도 사용해야 하고, 마지막 완료 화면까지 이어가야 한다면 세션 유지가 필요할 수 있다.
이때 @SessionAttributes는 Model 속성을 세션에 유지하는 방식으로 사용할 수 있다.


반대로 로그인 사용자 정보처럼 세션 전체에서 직접 관리하는 값은 HttpSession을 직접 사용하는 방식이 더 명확할 수 있다.
즉, @SessionAttributes는 HttpSession을 완전히 대신하는 기능이라기보다, 컨트롤러의 Model 속성을 세션과 연결하는 기능으로 이해하는 것이 좋다.


정리하면 아래와 같다.

  • Model에 담은 값을 여러 요청 동안 이어가고 싶을 때 @SessionAttributes를 사용할 수 있다.
  • 여러 단계 입력 화면처럼 중간 데이터를 유지해야 할 때 유용할 수 있다.
  • 세션 저장소 자체를 직접 다뤄야 한다면 HttpSession 직접 사용이 더 명확할 수 있다.
  • @SessionAttributes를 사용한 값은 작업이 끝났을 때 SessionStatus#setComplete()로 정리해야 한다.

따라서 @SessionAttributes는 Model 속성을 임시로 세션에 유지해야 하는 흐름에서 사용하고, 끝나면 반드시 정리까지 생각해야 한다.


두 방식의 차이 정리

Servlet/JSP 방식과 Spring MVC 방식은 세션에 값을 유지한다는 큰 흐름은 같다.
하지만 값을 세션에 연결하는 방식이 다르다.


차이는 아래처럼 정리할 수 있다.

  • Servlet/JSP에서는 request.getSession()으로 세션 객체를 직접 꺼낸다.
  • Servlet/JSP에서는 session.setAttribute()로 세션에 직접 값을 저장한다.
  • Spring MVC에서는 @SessionAttributes로 특정 Model 속성을 세션에 유지하도록 지정한다.
  • Spring MVC에서는 메서드 레벨 @ModelAttribute로 세션에 유지될 Model 값을 먼저 준비할 수 있다.
  • Spring MVC에서는 매개변수 @ModelAttribute로 세션에 유지되던 Model 값을 다시 받아 사용할 수 있다.
  • SessionStatus#setComplete()는 @SessionAttributes로 유지하던 흐름을 끝낼 때 사용한다.

따라서 @SessionAttributes는 기존 세션 기능을 없앤 것이 아니다.
Model 속성 중 일부를 세션과 연결해서 여러 요청 동안 유지하고, 필요할 때 SessionStatus로 정리하는 Spring MVC 방식이다.


이 섹션의 핵심은 이것이다.
@SessionAttributes는 지정한 Model 속성을 세션에 유지하고, SessionStatus#setComplete()는 그 유지 흐름을 끝내는 역할을 한다.



요청 본문과 응답 본문을 다루는 방식 (@RequestBody, @ResponseBody, @RestController)

@RequestBody와 @ResponseBody는 요청과 응답의 본문을 다룰 때 사용하는 어노테이션이다.
여기서 본문은 HTTP Body를 의미한다.
HTTP Body는 요청이나 응답에서 실제 데이터가 담기는 부분이다.


앞에서 배운 @RequestParam, @ModelAttribute, @PathVariable은 주로 URL, 폼 데이터, 주소 경로에 있는 값을 받는 방식이었다.
반면 @RequestBody는 JSON처럼 요청 본문에 담긴 데이터를 읽을 때 사용한다.
그리고 @ResponseBody는 컨트롤러 메서드의 반환값을 뷰 이름으로 보지 않고, 응답 본문에 그대로 담아 보낼 때 사용한다.


이 구간에서 가장 먼저 구분해야 할 점이 있다.
일반 @Controller는 보통 화면으로 이동하는 흐름에서 사용한다.
예를 들어 return "result"라고 하면 result.html 같은 화면을 찾는 흐름이다.


하지만 @ResponseBody나 @RestController를 사용하면 반환값은 화면 이름이 아니라 응답 데이터가 된다.
즉, 같은 return "result"라도 어떤 어노테이션이 붙었는지에 따라 화면 이름이 될 수도 있고, 응답 본문 데이터가 될 수도 있다.


이 개념은 Servlet/JSP와 비교하면 더 쉽게 이해할 수 있다.
Servlet에서는 요청 본문을 읽으려면 request.getReader() 같은 방식으로 직접 읽어야 했다.
응답 본문을 보내려면 response.getWriter()로 직접 출력해야 했다.
Spring MVC에서는 @RequestBody, @ResponseBody, @RestController를 사용해서 요청 본문 읽기와 응답 본문 생성을 더 간단하게 처리할 수 있다.


즉, 요청 본문과 응답 본문을 다루는 방식은 화면 이동 중심이 아니라 데이터 교환 중심의 처리 방식이다.
일반 화면을 보여 주는 컨트롤러와 데이터를 바로 주고받는 컨트롤러를 구분해서 이해해야 한다.


요청 본문은 무엇을 읽는가

@RequestBody는 요청 본문에 담긴 데이터를 읽을 때 사용한다.
예를 들어 클라이언트가 JSON 데이터를 서버로 보냈다면, 그 JSON은 보통 HTTP Body 안에 들어 있다.
이때 @RequestBody를 사용하면 Spring이 요청 본문을 읽어서 Java 객체로 바꿔 줄 수 있다.


여기서 중요한 점은 값이 들어 있는 위치이다.
@RequestParam은 /search?keyword=java처럼 URL 뒤의 쿼리 문자열에서 값을 받는다.
@ModelAttribute는 주로 폼에서 전송된 값을 객체에 묶어 받는다.
@PathVariable은 /boards/10처럼 주소 경로 안에 들어 있는 값을 받는다.
반면 @RequestBody는 HTTP Body 안의 데이터를 읽는다.


차이는 아래처럼 정리할 수 있다.

  • @RequestParam은 URL 뒤의 쿼리 문자열 값을 받는다.
  • @ModelAttribute는 폼에서 넘어온 값을 객체에 묶어 받는 흐름에서 자주 사용한다.
  • @PathVariable은 주소 경로 안의 값을 받는다.
  • @RequestBody는 요청 본문에 담긴 데이터를 읽는다.

따라서 @RequestBody는 단순히 요청값을 받는 또 다른 문법이 아니다.
값이 URL이나 폼 파라미터가 아니라 요청 본문에 들어 있을 때 사용하는 방식이다.


@RequestParam과 @RequestBody의 차이

@RequestParam과 @RequestBody는 둘 다 클라이언트가 보낸 값을 컨트롤러에서 받는 방식이다.
하지만 값을 읽는 위치가 다르다.


먼저 @RequestParam은 URL 뒤의 쿼리 문자열에서 값을 읽는다.
예를 들어 /api/member?name=lee&age=20처럼 요청하면 name, age 값을 매개변수로 받을 수 있다.

// RequestParamBodyCompareExample.java
@Controller
public class RequestParamBodyCompareController {
    @GetMapping("/api/member-param") // /api/member-param?name=lee&age=20 요청 처리
    @ResponseBody
    public String memberParam(@RequestParam("name") String name,
                              @RequestParam("age") int age) {
        return name + ":" + age; // 응답 본문으로 문자열 반환
    }
}
// 출력결과
// 요청 방식: GET
// 브라우저 주소: /api/member-param?name=lee&age=20
// name 값: lee
// age 값: 20
// 응답 본문: lee:20

이 방식에서는 값이 URL 뒤에 보인다.
name=lee, age=20은 요청 본문이 아니라 쿼리 문자열이다.


반면 @RequestBody는 요청 본문에 들어 있는 데이터를 읽는다.
예를 들어 클라이언트가 아래처럼 JSON을 본문에 담아 보낸다고 생각하면 된다.

// 요청 예시
// 요청 방식: POST
// 요청 주소: /api/member-body
// 요청 헤더: Content-Type: application/json
// 요청 본문: {"name":"lee","age":20}

이때 컨트롤러는 아래처럼 @RequestBody로 요청 본문을 받을 수 있다.

// MemberDTO.java
public class MemberDTO {
    private String name; // 이름 저장
    private int age; // 나이 저장

    public MemberDTO() {
        // JSON을 객체로 바꿀 때 필요한 기본 생성자
    }

    public String getName() {
        return name; // 이름 반환
    }

    public void setName(String name) {
        this.name = name; // 이름 저장
    }

    public int getAge() {
        return age; // 나이 반환
    }

    public void setAge(int age) {
        this.age = age; // 나이 저장
    }
}
// RequestBodyCompareExample.java
@Controller
public class RequestBodyCompareController {
    @PostMapping("/api/member-body") // JSON 본문을 가진 POST 요청 처리
    @ResponseBody
    public String memberBody(@RequestBody MemberDTO member) {
        return member.getName() + ":" + member.getAge(); // 응답 본문으로 문자열 반환
    }
}
// 출력결과
// 요청 방식: POST
// 요청 주소: /api/member-body
// 요청 본문: {"name":"lee","age":20}
// member.getName() 값: lee
// member.getAge() 값: 20
// 응답 본문: lee:20

이 코드에서 @RequestBody MemberDTO member는 요청 본문에 들어온 JSON 데이터를 MemberDTO 객체로 받는다는 뜻이다.
JSON의 name 값은 member.name에 들어가고, age 값은 member.age에 들어간다.


정리하면 아래와 같다.

  • @RequestParam은 URL 뒤에 붙은 값을 읽는다.
  • @RequestBody는 요청 본문에 들어 있는 값을 읽는다.
  • @RequestParam은 검색 조건이나 간단한 요청값을 받을 때 자주 사용한다.
  • @RequestBody는 JSON 데이터를 객체로 받을 때 자주 사용한다.

따라서 값이 어디에 담겨 오는지 먼저 확인해야 @RequestParam을 쓸지, @RequestBody를 쓸지 판단할 수 있다.


@RequestBody로 JSON을 객체로 받는 흐름

@RequestBody를 사용하면 요청 본문에 들어온 JSON 데이터를 객체로 받을 수 있다.
이때 JSON의 필드 이름과 DTO의 필드 이름이 맞아야 값이 자연스럽게 들어간다.


예를 들어 요청 본문이 아래처럼 들어온다고 생각하면 된다.

// 요청 본문
// {"name":"lee","age":20}

이 JSON에는 name과 age라는 이름이 있다.
따라서 MemberDTO에도 name, age 필드와 값을 넣을 수 있는 setter가 있어야 한다.

// RequestBodyMemberDTO.java
public class RequestBodyMemberDTO {
    private String name; // JSON의 name 값이 들어갈 필드
    private int age; // JSON의 age 값이 들어갈 필드

    public RequestBodyMemberDTO() {
        // 요청 본문 JSON을 객체로 만들 때 사용할 기본 생성자
    }

    public String getName() {
        return name; // 이름 반환
    }

    public void setName(String name) {
        this.name = name; // JSON name 값을 필드에 저장
    }

    public int getAge() {
        return age; // 나이 반환
    }

    public void setAge(int age) {
        this.age = age; // JSON age 값을 필드에 저장
    }
}
// RequestBodyMemberController.java
@Controller
public class RequestBodyMemberController {
    @PostMapping("/api/request-body-member") // JSON 본문 요청 처리
    @ResponseBody
    public String requestBodyMember(@RequestBody RequestBodyMemberDTO member) {
        String name = member.getName(); // 객체에 들어온 name 값 꺼내기
        int age = member.getAge(); // 객체에 들어온 age 값 꺼내기
        return name + ":" + age; // 응답 본문으로 반환
    }
}
// 출력결과
// 요청 방식: POST
// 요청 주소: /api/request-body-member
// 요청 헤더: Content-Type: application/json
// 요청 본문: {"name":"lee","age":20}
// 객체에 들어간 name 값: lee
// 객체에 들어간 age 값: 20
// 응답 본문: lee:20

이 흐름을 값 이동으로 보면 아래와 같다.

  • 클라이언트가 요청 본문에 {"name":"lee","age":20}을 담아 보낸다.
  • 요청 헤더에는 Content-Type: application/json이 들어간다.
  • Spring은 요청 본문이 JSON 데이터라고 이해한다.
  • @RequestBody가 붙은 RequestBodyMemberDTO member에 값을 넣는다.
  • JSON의 name 값은 member.name에 들어간다.
  • JSON의 age 값은 member.age에 들어간다.
  • 컨트롤러는 member.getName(), member.getAge()로 값을 꺼낸다.
  • 반환 문자열은 @ResponseBody 때문에 응답 본문으로 나간다.

즉, @RequestBody는 요청 본문을 읽고, 그 내용을 객체로 바꿔 컨트롤러 메서드에 전달하는 역할을 한다.


@ResponseBody는 반환값을 응답 본문으로 보낸다

@ResponseBody는 컨트롤러 메서드의 반환값을 응답 본문에 바로 담아 보내는 어노테이션이다.
일반 @Controller에서 String을 반환하면 보통 뷰 이름으로 해석된다.
하지만 메서드에 @ResponseBody가 붙으면 그 반환값은 뷰 이름이 아니라 응답 데이터가 된다.


먼저 일반 @Controller에서 문자열을 반환하는 예제이다.

// ControllerViewNameExample.java
@Controller
public class ControllerViewNameController {
    @GetMapping("/view-result") // 화면 요청 처리
    public String viewResult() {
        return "result"; // result.html 화면 이름
    }
}
// 출력결과
// 요청 주소: /view-result
// 반환값: result
// 의미: result.html 화면을 찾음

이 코드에서 return "result"는 브라우저에 result라는 글자를 출력하라는 뜻이 아니다.
result.html 같은 뷰를 찾아 보여 주라는 뜻이다.


반면 @ResponseBody가 붙으면 같은 문자열도 응답 본문으로 나간다.

// ResponseBodyTextExample.java
@Controller
public class ResponseBodyTextController {
    @GetMapping("/text-result") // 데이터 응답 요청 처리
    @ResponseBody
    public String textResult() {
        return "result"; // 응답 본문에 result 문자열 반환
    }
}
// 출력결과
// 요청 주소: /text-result
// 반환값: result
// 의미: 응답 본문에 result 문자열 출력
// 브라우저 화면: result

두 코드의 반환값은 모두 "result"이다.
하지만 첫 번째 코드는 뷰 이름으로 해석되고, 두 번째 코드는 응답 본문 데이터로 해석된다.


정리하면 아래와 같다.

  • 일반 @Controller에서 String 반환은 보통 뷰 이름이다.
  • @ResponseBody가 붙은 메서드의 반환값은 응답 본문으로 나간다.
  • @ResponseBody는 화면을 찾는 흐름을 거치지 않는다.
  • 문자열, 객체, JSON 데이터를 바로 응답할 때 사용한다.

따라서 @ResponseBody가 붙었는지에 따라 반환 문자열의 의미가 완전히 달라진다.


객체를 응답하면 JSON으로 나갈 수 있다

@ResponseBody는 문자열만 응답할 때 사용하는 것이 아니다.
객체를 반환하면 Spring이 그 객체를 JSON 형태로 바꿔 응답할 수 있다.


예를 들어 컨트롤러가 MemberDTO 객체를 반환한다고 생각하면 된다.

// ResponseBodyMemberDTO.java
public class ResponseBodyMemberDTO {
    private String name; // 이름 저장
    private int age; // 나이 저장

    public ResponseBodyMemberDTO(String name, int age) {
        this.name = name; // 이름 저장
        this.age = age; // 나이 저장
    }

    public String getName() {
        return name; // 이름 반환
    }

    public int getAge() {
        return age; // 나이 반환
    }
}
// ResponseBodyJsonController.java
@Controller
public class ResponseBodyJsonController {
    @GetMapping("/api/member-json") // JSON 응답 요청 처리
    @ResponseBody
    public ResponseBodyMemberDTO memberJson() {
        return new ResponseBodyMemberDTO("lee", 20); // 객체를 응답 본문으로 반환
    }
}
// 출력결과
// 요청 주소: /api/member-json
// 응답 본문: {"name":"lee","age":20}

이 코드에서 반환값은 ResponseBodyMemberDTO 객체이다.
@ResponseBody가 붙어 있으므로 Spring은 이 객체를 뷰 이름으로 보지 않는다.
객체 안의 값을 응답 본문에 담아 보낸다.


실제 응답은 보통 JSON 형태로 만들어진다.
name 필드는 "lee"로, age 필드는 20으로 응답된다.


즉, @ResponseBody는 문자열뿐 아니라 객체 응답에도 사용되는 어노테이션이다.
데이터 응답을 중심으로 하는 API에서는 이런 방식이 자주 사용된다.


@RestController는 @ResponseBody를 클래스 전체에 적용한 것처럼 이해할 수 있다

@RestController는 데이터 응답 중심 컨트롤러를 만들 때 사용하는 어노테이션이다.
일반 @Controller에서는 메서드마다 @ResponseBody를 붙여야 반환값이 응답 본문으로 나간다.
반면 @RestController를 클래스 위에 붙이면, 그 클래스 안의 메서드 반환값은 기본적으로 응답 본문으로 나간다.


아래는 일반 @Controller와 @ResponseBody를 함께 사용하는 방식이다.

// ControllerResponseBodyExample.java
@Controller
public class ControllerResponseBodyController {
    @GetMapping("/api/text1") // 데이터 응답 요청 처리
    @ResponseBody
    public String text1() {
        return "hello"; // 응답 본문으로 반환
    }
}
// 출력결과
// 요청 주소: /api/text1
// 응답 본문: hello

같은 흐름을 @RestController로 작성하면 아래처럼 된다.

// RestControllerTextExample.java
@RestController
public class RestControllerTextController {
    @GetMapping("/api/text2") // 데이터 응답 요청 처리
    public String text2() {
        return "hello"; // 응답 본문으로 반환
    }
}
// 출력결과
// 요청 주소: /api/text2
// 응답 본문: hello

두 예제 모두 응답 본문에 hello를 보낸다.
하지만 첫 번째는 일반 @Controller이기 때문에 메서드에 @ResponseBody를 붙였다.
두 번째는 클래스 자체가 @RestController이기 때문에 메서드마다 @ResponseBody를 붙이지 않아도 데이터 응답 흐름으로 동작한다.


정리하면 아래와 같다.

  • @Controller는 보통 화면 반환 컨트롤러에 사용한다.
  • @Controller에서 데이터를 바로 응답하려면 메서드에 @ResponseBody를 붙인다.
  • @RestController는 데이터 응답 중심 컨트롤러에 사용한다.
  • @RestController에서는 반환값이 기본적으로 응답 본문으로 나간다.

따라서 화면을 보여 주는 컨트롤러인지, 데이터를 바로 응답하는 컨트롤러인지에 따라 @Controller와 @RestController를 구분해야 한다.


Servlet에서는 어떻게 처리했는가

Servlet 방식에서는 요청 본문을 읽고 응답 본문을 보내는 작업을 직접 작성해야 했다.
요청 본문은 request.getReader()로 읽고, 응답 본문은 response.getWriter()로 출력하는 방식이다.


아래 예제는 요청 본문을 문자열로 읽고, 그대로 응답하는 흐름이다.

// BodyServletExample.java
@WebServlet("/api/body")
public class BodyServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        request.setCharacterEncoding("UTF-8"); // 요청 한글 인코딩 설정
        response.setContentType("text/plain;charset=UTF-8"); // 응답 타입과 인코딩 설정

        BufferedReader reader = request.getReader(); // 요청 본문을 읽기 위한 객체
        String body = reader.readLine(); // 요청 본문 한 줄 읽기

        PrintWriter writer = response.getWriter(); // 응답 본문 출력 객체
        writer.write(body); // 읽은 본문을 응답으로 출력
    }
}
// 요청 예시
// 요청 방식: POST /api/body
// 요청 헤더: Content-Type: text/plain
// 요청 본문: hello
// 출력결과
// 응답 본문: hello

이 방식에서는 개발자가 요청 본문을 직접 읽는다.
request.getReader()로 본문을 읽고, response.getWriter()로 응답 본문을 직접 출력한다.


만약 요청 본문이 JSON이라면 문자열로 읽은 뒤, 그 문자열을 다시 객체로 바꾸는 처리가 필요할 수 있다.
즉, Servlet 방식에서는 요청 본문 읽기와 응답 본문 출력 흐름을 개발자가 직접 코드로 작성한다.


Spring MVC에서는 어떻게 바뀌는가

Spring MVC에서는 @RequestBody로 요청 본문을 객체로 받을 수 있고, @ResponseBody로 반환값을 응답 본문에 바로 보낼 수 있다.
아래 예제는 요청 본문에 들어온 회원 정보를 객체로 받고, 그 값을 문자열 응답으로 돌려주는 흐름이다.

// SpringBodyMemberDTO.java
public class SpringBodyMemberDTO {
    private String name; // name 값 저장
    private int age; // age 값 저장

    public SpringBodyMemberDTO() {
        // JSON 데이터를 객체로 만들 때 사용할 수 있는 기본 생성자
    }

    public String getName() {
        return name; // 이름 값 반환
    }

    public void setName(String name) {
        this.name = name; // 이름 값 저장
    }

    public int getAge() {
        return age; // 나이 값 반환
    }

    public void setAge(int age) {
        this.age = age; // 나이 값 저장
    }
}
// SpringBodyController.java
@Controller
public class SpringBodyController {
    @PostMapping("/api/member") // /api/member POST 요청 처리
    @ResponseBody
    public String member(@RequestBody SpringBodyMemberDTO member) {
        return member.getName() + ":" + member.getAge(); // 응답 본문으로 보낼 문자열 반환
    }
}
// 요청 예시
// 요청 방식: POST /api/member
// 요청 헤더: Content-Type: application/json
// 요청 본문: {"name":"lee","age":20}
// 출력결과
// 응답 본문: lee:20

이 코드에서 @RequestBody SpringBodyMemberDTO member는 요청 본문에 들어온 JSON 데이터를 SpringBodyMemberDTO 객체로 받는다는 뜻이다.
JSON의 name 값은 member.name에 들어가고, age 값은 member.age에 들어간다.
이때 요청 헤더에 Content-Type: application/json이 있어야 요청 본문을 JSON으로 해석하는 흐름이 자연스럽다.


그리고 @ResponseBody가 붙어 있기 때문에 return member.getName() + ":" + member.getAge()는 뷰 이름이 아니다.
이 반환값은 응답 본문에 그대로 들어간다.
따라서 클라이언트는 lee:20이라는 응답 데이터를 받는다.


즉, Spring MVC 방식에서는 요청 본문 읽기, 객체 변환, 응답 본문 출력 흐름을 어노테이션 중심으로 처리할 수 있다.


@RestController로 JSON 요청과 JSON 응답 처리하기

@RestController를 사용하면 데이터 응답 중심의 컨트롤러를 더 간단하게 작성할 수 있다.
아래 예제는 JSON 요청을 객체로 받고, 다시 객체를 반환해서 JSON 응답을 만드는 흐름이다.

// RestMemberDTO.java
public class RestMemberDTO {
    private String name; // 이름 저장
    private int age; // 나이 저장

    public RestMemberDTO() {
        // 요청 JSON을 객체로 만들 때 사용할 기본 생성자
    }

    public RestMemberDTO(String name, int age) {
        this.name = name; // 이름 저장
        this.age = age; // 나이 저장
    }

    public String getName() {
        return name; // 이름 반환
    }

    public void setName(String name) {
        this.name = name; // 이름 저장
    }

    public int getAge() {
        return age; // 나이 반환
    }

    public void setAge(int age) {
        this.age = age; // 나이 저장
    }
}
// RestMemberController.java
@RestController
public class RestMemberController {
    @PostMapping("/api/rest-member") // JSON 요청을 받고 JSON 응답 반환
    public RestMemberDTO restMember(@RequestBody RestMemberDTO member) {
        return new RestMemberDTO(member.getName(), member.getAge()); // 객체를 응답 본문으로 반환
    }
}
// 요청 예시
// 요청 방식: POST /api/rest-member
// 요청 헤더: Content-Type: application/json
// 요청 본문: {"name":"lee","age":20}
// 출력결과
// 응답 본문: {"name":"lee","age":20}

이 코드에서는 클래스 위에 @RestController가 붙어 있다.
그래서 메서드 위에 @ResponseBody를 따로 붙이지 않아도 반환값이 응답 본문으로 나간다.


@RequestBody RestMemberDTO member는 요청 본문 JSON을 객체로 받는다.
그리고 return new RestMemberDTO(member.getName(), member.getAge())는 객체를 응답 본문으로 반환한다.
이 객체는 응답에서 JSON 형태로 전달될 수 있다.


즉, @RestController는 JSON 요청과 JSON 응답처럼 데이터를 주고받는 API 컨트롤러에서 자연스럽게 사용된다.


일반 @Controller와 데이터 응답 컨트롤러의 차이

일반 @Controller와 @RestController는 모두 컨트롤러이다.
하지만 반환값을 해석하는 방식이 다르다.


일반 @Controller에서 문자열을 반환하면 보통 뷰 이름으로 해석된다.
반면 @RestController에서 문자열을 반환하면 그 문자열이 응답 본문으로 바로 나간다.
또 일반 @Controller에서도 메서드에 @ResponseBody를 붙이면 해당 메서드는 데이터 응답 방식으로 동작한다.


차이는 아래처럼 정리할 수 있다.

  • 일반 @Controller는 보통 뷰 이름을 반환해서 화면을 보여 준다.
  • @ResponseBody가 붙은 메서드는 반환값을 응답 본문으로 보낸다.
  • @RestController는 클래스 전체가 데이터 응답 중심으로 동작한다.
  • 화면을 보여 줄 때는 일반 @Controller가 자연스럽다.
  • JSON이나 문자열 데이터를 바로 응답할 때는 @ResponseBody나 @RestController가 자연스럽다.

따라서 컨트롤러를 볼 때는 반환값이 화면 이름인지, 응답 데이터인지를 반드시 구분해야 한다.


두 방식의 차이 정리

Servlet 방식과 Spring MVC 방식은 요청 본문을 읽고 응답 본문을 보낼 수 있다는 큰 흐름은 같다.
하지만 본문 데이터를 처리하는 방식이 달라진다.


차이는 아래처럼 정리할 수 있다.

  • Servlet에서는 request.getReader()로 요청 본문을 직접 읽는다.
  • Spring MVC에서는 @RequestBody로 요청 본문을 객체로 받을 수 있다.
  • Servlet에서는 response.getWriter()로 응답 본문을 직접 출력한다.
  • Spring MVC에서는 @ResponseBody로 반환값을 응답 본문에 바로 보낼 수 있다.
  • 일반 @Controller는 보통 뷰 이름을 반환한다.
  • @RestController는 반환값을 응답 본문으로 바로 보내는 데이터 중심 컨트롤러이다.

따라서 @RequestBody, @ResponseBody, @RestController는 기존 본문 처리 흐름을 없앤 것이 아니다.
요청 본문을 읽고 응답 본문을 보내던 흐름을 Spring이 어노테이션 중심으로 더 간단하게 처리해 주는 방식이다.


이 섹션의 핵심은 이것이다.
@RequestBody는 요청 본문을 읽어 객체로 받고, @ResponseBody는 반환값을 응답 본문으로 보내며, @RestController는 데이터 응답 중심 컨트롤러를 만들 때 사용한다.



서비스 계층의 역할 (@Service)

@Service는 이 클래스가 서비스 계층이라는 뜻을 나타내는 어노테이션이다.
서비스 계층은 실제 핵심 비즈니스 로직을 처리하는 영역이다.
여기서 비즈니스 로직은 프로그램이 실제로 해야 하는 중요한 처리 규칙을 뜻한다.
예를 들어 할인 금액 계산, 주문 가능 여부 판단, 회원 가입 처리, 게시글 등록 처리 같은 흐름이 비즈니스 로직에 해당한다.


컨트롤러는 요청을 받고 응답으로 연결하는 입구 역할을 한다.
반면 서비스는 요청을 받은 뒤 실제로 어떤 처리를 해야 하는지 판단하고 실행하는 중심 역할을 한다.
즉, 컨트롤러는 흐름 연결을 담당하고, 서비스는 핵심 처리를 담당한다.


따라서 @Service는 컨트롤러와 핵심 로직의 역할을 분리하기 위해 사용하는 어노테이션이라고 이해하면 된다.
컨트롤러가 모든 일을 직접 처리하면 코드가 빠르게 복잡해진다.
요청 처리, 값 검증, 계산, 데이터 저장, 화면 이동이 한 곳에 섞이기 때문이다.


Spring MVC에서는 컨트롤러가 요청을 받은 뒤, 필요한 실제 처리는 서비스에게 맡기는 구조를 많이 사용한다.
이렇게 나누면 컨트롤러는 요청과 응답 흐름에 집중할 수 있고, 서비스는 실제 처리 규칙에 집중할 수 있다.


@Service는 무엇을 뜻하는가

@Service는 해당 클래스가 서비스 계층의 클래스라는 뜻이다.
동시에 Spring은 @Service가 붙은 클래스를 스프링 빈으로 등록해서 컨테이너에서 관리한다.
여기서 빈은 Spring이 직접 만들고 관리하는 객체를 뜻한다.


즉, @Service는 단순히 사람이 보기 좋으라고 붙이는 이름표만은 아니다.
이 클래스가 서비스 역할을 한다는 의미도 있고, Spring이 이 객체를 관리하게 만드는 의미도 있다.


아래 코드는 가장 단순한 서비스 클래스이다.

// HelloService.java
@Service
public class HelloService {
    public String getMessage() {
        return "안녕하세요"; // 실제 처리 결과 반환
    }
}

이 코드에서 HelloService는 문자열을 만들어 반환하는 실제 처리 역할을 한다.
컨트롤러가 직접 "안녕하세요"를 만들 수도 있지만, 이 예제에서는 그 일을 서비스가 맡는다.


정리하면 아래와 같다.

  • @Service는 서비스 계층 클래스를 표시한다.
  • @Service가 붙은 클래스는 Spring이 관리하는 빈이 된다.
  • 서비스는 실제 핵심 처리 로직을 담당한다.
  • 컨트롤러는 서비스를 호출해서 처리 결과를 받아 사용한다.

따라서 @Service는 역할 표시와 스프링 빈 등록을 함께 담당하는 어노테이션이다.


컨트롤러와 서비스는 무엇이 다른가

컨트롤러와 서비스는 둘 다 자바 클래스일 수 있다.
하지만 맡는 역할이 다르다.


컨트롤러는 브라우저나 클라이언트의 요청을 받는다.
그리고 요청값을 받고, 필요한 서비스를 호출하고, 결과를 Model에 담거나 응답 본문으로 보낸다.
즉, 컨트롤러는 웹 요청과 응답 흐름을 연결하는 역할을 한다.


서비스는 실제 처리 규칙을 담당한다.
예를 들어 주문 금액이 얼마인지 계산하거나, 회원 가입이 가능한지 판단하거나, 입력값을 저장하기 전에 필요한 처리를 수행한다.
즉, 서비스는 애플리케이션이 실제로 해야 하는 일을 처리한다.


차이는 아래처럼 볼 수 있다.

  • 컨트롤러는 요청을 받는다.
  • 컨트롤러는 어떤 서비스를 호출할지 결정한다.
  • 컨트롤러는 처리 결과를 화면이나 응답으로 연결한다.
  • 서비스는 실제 핵심 처리를 수행한다.
  • 서비스는 계산, 검증, 저장 전 처리 같은 로직을 담당한다.

즉, 컨트롤러는 흐름 연결, 서비스는 핵심 처리라고 구분하면 이해하기 쉽다.


컨트롤러가 모든 일을 직접 처리하면 왜 문제가 되는가

처음에는 컨트롤러 안에 모든 코드를 넣는 것이 쉬워 보일 수 있다.
요청을 받은 메서드 안에서 계산하고, 결과를 만들고, 화면까지 넘기면 한눈에 보이는 것처럼 느껴진다.


하지만 기능이 조금만 커져도 문제가 생긴다.
컨트롤러 안에 요청 처리 코드와 실제 업무 처리 코드가 섞이면 코드가 길어진다.
또 같은 로직을 다른 컨트롤러에서도 써야 할 때 복사해서 붙여 넣게 될 수 있다.


아래 코드는 컨트롤러가 할인 계산까지 직접 처리하는 예시이다.

// BadOrderController.java
@Controller
public class BadOrderController {
    @GetMapping("/bad-order") // /bad-order?price=10000 요청 처리
    public String badOrder(@RequestParam("price") int price, Model model) {
        int discountPrice = price / 10; // 할인 금액 계산
        int finalPrice = price - discountPrice; // 최종 결제 금액 계산

        model.addAttribute("finalPrice", finalPrice); // 화면에 넘길 값 저장
        return "orderResult"; // 결과 화면으로 이동
    }
}

이 코드는 짧을 때는 크게 문제 없어 보인다.
하지만 할인 규칙이 바뀌거나, 다른 화면에서도 같은 할인 계산을 써야 한다면 컨트롤러 코드가 점점 복잡해진다.


이런 계산 로직은 컨트롤러보다 서비스에 두는 것이 더 자연스럽다.
컨트롤러는 요청값을 받고 서비스에게 계산을 맡긴다.
서비스는 할인 계산이라는 핵심 로직을 처리한다.


서비스로 분리하면 흐름이 어떻게 바뀌는가

서비스로 분리하면 컨트롤러와 실제 처리 로직이 나뉜다.
컨트롤러는 요청값을 받고 서비스를 호출한다.
서비스는 계산 결과를 만들어 반환한다.
컨트롤러는 반환받은 결과를 화면에 넘긴다.


먼저 할인 계산을 담당하는 서비스 클래스를 만든다.

// OrderService.java
@Service
public class OrderService {
    public int calculateFinalPrice(int price) {
        int discountPrice = price / 10; // 할인 금액 계산
        return price - discountPrice; // 최종 결제 금액 반환
    }
}
// OrderController.java
@Controller
public class OrderController {
    private final OrderService orderService; // 서비스 의존

    public OrderController(OrderService orderService) {
        this.orderService = orderService; // 생성자 주입
    }

    @GetMapping("/order") // /order?price=10000 요청 처리
    public String order(@RequestParam("price") int price, Model model) {
        int finalPrice = orderService.calculateFinalPrice(price); // 서비스에 계산 맡김
        model.addAttribute("finalPrice", finalPrice); // 계산 결과를 화면에 전달
        return "orderResult"; // 결과 화면으로 이동
    }
}
// orderResult.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>orderResult</title>
</head>
<body>
<h1>최종 결제 금액: <span th:text="${finalPrice}"></span></h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /order?price=10000
// 요청값 price: 10000
// 서비스에서 계산한 최종 금액: 9000
// 화면 출력: 최종 결제 금액: 9000

이 구조에서 컨트롤러는 할인 계산 방법을 자세히 알 필요가 없다.
컨트롤러는 orderService.calculateFinalPrice(price)를 호출하고 결과만 받는다.
할인 계산 규칙은 OrderService 안에 들어 있다.


따라서 할인 규칙이 바뀌면 컨트롤러를 고치는 것이 아니라 OrderService를 고치면 된다.
이렇게 역할을 나누면 코드 수정 위치가 분명해진다.


생성자 주입으로 서비스를 사용하는 흐름

컨트롤러가 서비스를 사용하려면 서비스 객체가 필요하다.
Spring에서는 @Service가 붙은 클래스를 빈으로 관리하고, 컨트롤러가 필요로 할 때 넣어 줄 수 있다.
이렇게 필요한 객체를 외부에서 넣어 주는 방식을 의존성 주입이라고 한다.


의존성은 한 클래스가 다른 클래스를 필요로 하는 관계를 뜻한다.
예를 들어 OrderController는 계산을 하기 위해 OrderService가 필요하다.
따라서 OrderController는 OrderService에 의존한다고 말할 수 있다.


생성자 주입은 생성자를 통해 필요한 객체를 전달받는 방식이다.
아래 코드에서 OrderController(OrderService orderService) 생성자가 바로 그 역할을 한다.

// ConstructorInjectionServiceExample.java
@Controller
public class ConstructorInjectionServiceController {
    private final OrderService orderService; // 필요한 서비스 저장

    public ConstructorInjectionServiceController(OrderService orderService) {
        this.orderService = orderService; // Spring이 넣어 준 서비스를 필드에 저장
    }

    @GetMapping("/service-injection")
    public String serviceInjection(Model model) {
        int finalPrice = orderService.calculateFinalPrice(10000); // 서비스 메서드 호출
        model.addAttribute("finalPrice", finalPrice); // 결과 저장
        return "orderResult"; // 결과 화면으로 이동
    }
}

이 코드에서 컨트롤러는 new OrderService()를 직접 하지 않는다.
Spring이 @Service가 붙은 OrderService 객체를 관리하고 있다가, 컨트롤러를 만들 때 생성자로 넣어 준다.


정리하면 흐름은 아래와 같다.

  • @Service가 붙은 OrderService를 Spring이 빈으로 등록한다.
  • OrderController는 생성자에서 OrderService를 필요로 한다.
  • Spring이 컨트롤러를 만들 때 OrderService 빈을 생성자로 넣어 준다.
  • 컨트롤러는 주입받은 서비스를 호출해서 실제 처리를 맡긴다.

따라서 @Service는 서비스 객체를 Spring이 관리하게 만들고, 컨트롤러는 그 서비스를 주입받아 사용한다.


기본 예제로 보는 Controller와 Service 연결

아래 코드는 컨트롤러가 직접 모든 일을 처리하지 않고, 서비스 계층으로 넘기는 가장 단순한 구조이다.
서비스는 메시지를 만들고, 컨트롤러는 그 메시지를 화면으로 넘긴다.

// HelloService.java
@Service
public class HelloService {
    public String getMessage() {
        return "안녕하세요"; // 실제 처리 결과 반환
    }
}
// HelloController.java
@Controller
public class HelloController {
    private final HelloService helloService; // 서비스 의존

    public HelloController(HelloService helloService) {
        this.helloService = helloService; // 생성자 주입
    }

    @GetMapping("/service-test") // /service-test 요청 처리
    public String hello(Model model) {
        String msg = helloService.getMessage(); // 서비스 호출
        model.addAttribute("msg", msg); // 서비스 결과를 화면에 전달
        return "hello"; // hello.html 화면으로 이동
    }
}
// hello.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>hello</title>
</head>
<body>
<h1 th:text="${msg}"></h1>
</body>
</html>
// 출력결과
// 브라우저 주소: /service-test
// 실행 흐름: Controller → Service → Controller → View
// 서비스 반환값: 안녕하세요
// 화면 출력: 안녕하세요

이 예제에서 HelloController는 /service-test 요청을 받는다.
하지만 "안녕하세요"라는 값을 직접 만들지 않는다.
대신 helloService.getMessage()를 호출해서 서비스에게 실제 값을 만들어 달라고 맡긴다.


HelloService는 getMessage() 메서드에서 "안녕하세요"를 반환한다.
컨트롤러는 그 값을 Model에 담고 hello.html로 넘긴다.
즉, 요청 흐름은 컨트롤러에서 시작하지만 실제 처리 결과는 서비스에서 만들어진다.


Service가 Repository나 DAO를 호출하는 흐름

실제 프로젝트에서는 서비스가 단순히 문자열만 반환하지 않는다.
보통 서비스는 필요한 데이터를 가져오기 위해 Repository나 DAO를 호출한다.
여기서 Repository나 DAO는 데이터베이스에 접근하는 계층이라고 이해하면 된다.


전체 흐름은 보통 아래처럼 이어진다.

// 계층 흐름
// Controller → Service → Repository 또는 DAO → DB

컨트롤러는 요청을 받고 서비스에게 맡긴다.
서비스는 실제 처리 규칙을 수행하면서 필요한 데이터가 있으면 Repository나 DAO에게 요청한다.
Repository나 DAO는 데이터베이스와 연결되어 데이터를 조회하거나 저장한다.


아래 코드는 실제 DB 대신 문자열을 반환해서 계층 흐름만 보여 주는 예제이다.

// MessageRepository.java
@Repository
public class MessageRepository {
    public String findMessage() {
        return "서비스 계층 테스트"; // 실제 DB 조회 대신 문자열 반환
    }
}
// MessageService.java
@Service
public class MessageService {
    private final MessageRepository messageRepository; // Repository 의존

    public MessageService(MessageRepository messageRepository) {
        this.messageRepository = messageRepository; // 생성자 주입
    }

    public String getMessage() {
        return messageRepository.findMessage(); // Repository에서 데이터 조회
    }
}
// MessageController.java
@Controller
public class MessageController {
    private final MessageService messageService; // Service 의존

    public MessageController(MessageService messageService) {
        this.messageService = messageService; // 생성자 주입
    }

    @GetMapping("/message") // /message 요청 처리
    public String message(Model model) {
        String msg = messageService.getMessage(); // Service 호출
        model.addAttribute("msg", msg); // 조회 결과를 화면에 전달
        return "messageView"; // 결과 화면으로 이동
    }
}
// 출력결과
// 브라우저 주소: /message
// 실행 흐름: Controller → Service → Repository
// Repository 반환값: 서비스 계층 테스트
// 화면 출력: 서비스 계층 테스트

이 예제에서는 컨트롤러가 Repository를 직접 호출하지 않는다.
컨트롤러는 MessageService만 호출한다.
그리고 MessageService가 필요한 데이터를 얻기 위해 MessageRepository를 호출한다.


이렇게 나누면 각 계층의 역할이 분명해진다.
컨트롤러는 요청과 화면 연결을 맡는다.
서비스는 핵심 처리 흐름을 맡는다.
Repository는 데이터 접근을 맡는다.


Servlet/JSP 방식과 비교하면 어떻게 다른가

Servlet/JSP 방식에서도 역할을 나눌 수 있었다.
컨트롤러 역할을 하는 Servlet이 요청을 받고, 서비스 역할의 자바 클래스를 호출하는 구조를 만들 수 있었다.
즉, 서비스 계층이라는 개념 자체는 Spring MVC에서 갑자기 생긴 것이 아니다.


다만 Spring MVC에서는 @Service를 사용해서 서비스 역할의 클래스를 더 명확하게 표시하고, Spring이 그 객체를 빈으로 관리하게 할 수 있다.
그래서 컨트롤러가 서비스 객체를 직접 만들지 않고 주입받아 사용할 수 있다.


차이는 아래처럼 정리할 수 있다.

  • Servlet/JSP에서도 서비스 역할의 자바 클래스를 만들 수 있다.
  • 하지만 직접 객체를 만들거나 연결하는 코드가 필요할 수 있다.
  • Spring MVC에서는 @Service로 서비스 계층을 표시한다.
  • Spring MVC에서는 Spring이 서비스 객체를 빈으로 관리한다.
  • 컨트롤러는 필요한 서비스를 생성자 주입으로 받아 사용할 수 있다.

따라서 @Service는 단순히 클래스 이름을 보기 좋게 만드는 표시가 아니다.
서비스 역할의 객체를 Spring이 관리하게 하고, 컨트롤러와 핵심 로직을 분리하는 구조와 연결된다.


언제 Service로 분리하면 좋은가

모든 코드를 무조건 서비스로 빼야 하는 것은 아니다.
아주 단순한 화면 이동만 처리하는 경우에는 컨트롤러만으로 충분할 수도 있다.
하지만 실제 처리 규칙이 들어가기 시작하면 서비스로 분리하는 것이 좋다.


예를 들어 아래와 같은 코드가 컨트롤러에 들어가기 시작하면 서비스 분리를 생각할 수 있다.

  • 할인 금액을 계산한다.
  • 회원 가입 가능 여부를 판단한다.
  • 게시글 등록 전에 입력값을 검사한다.
  • 주문 상태를 변경한다.
  • 데이터 저장 전에 여러 조건을 확인한다.
  • 여러 Repository나 DAO를 호출해서 결과를 만든다.

이런 로직은 단순 화면 이동이 아니라 애플리케이션의 실제 처리 규칙이다.
따라서 컨트롤러 안에 그대로 두면 컨트롤러가 너무 많은 일을 하게 된다.


서비스로 분리하면 컨트롤러는 요청 흐름에 집중하고, 서비스는 실제 처리 규칙에 집중할 수 있다.
또 같은 로직을 여러 컨트롤러에서 다시 사용할 수도 있다.


정리하면 아래와 같다.

  • 단순 요청 연결은 컨트롤러에서 처리할 수 있다.
  • 실제 업무 처리 규칙이 들어가면 서비스로 분리하는 것이 좋다.
  • 여러 곳에서 재사용할 로직은 서비스에 두는 것이 좋다.
  • 데이터 접근 계층과 연결되는 처리 흐름도 서비스가 담당하는 경우가 많다.

따라서 Service는 컨트롤러가 직접 처리하기에는 무겁거나 중요한 로직을 맡기는 계층이라고 이해하면 된다.


두 방식의 차이 정리

컨트롤러만 사용하는 방식과 서비스로 분리하는 방식은 모두 요청을 처리할 수 있다.
하지만 역할을 나누는 정도가 다르다.


차이는 아래처럼 정리할 수 있다.

  • 컨트롤러만 사용하는 방식은 요청 처리와 핵심 로직이 한 클래스에 섞이기 쉽다.
  • 서비스로 분리하면 컨트롤러는 요청과 응답 흐름에 집중한다.
  • 서비스로 분리하면 핵심 비즈니스 로직은 서비스 클래스에 모인다.
  • @Service가 붙은 클래스는 Spring이 서비스 빈으로 관리한다.
  • 컨트롤러는 서비스를 직접 만들지 않고 주입받아 사용할 수 있다.
  • 서비스는 필요하면 Repository나 DAO를 호출해서 데이터 접근 계층과 연결된다.

따라서 @Service는 기존 처리 흐름을 없앤 것이 아니다.
요청 처리 흐름에서 핵심 로직을 컨트롤러 밖으로 분리하고, 그 서비스 객체를 Spring이 관리하도록 만드는 방식이다.


이 섹션의 핵심은 이것이다.
@Service는 핵심 비즈니스 로직을 처리하는 서비스 계층을 나타내며, 컨트롤러는 서비스를 호출해 처리 결과를 받아 화면이나 응답으로 연결한다.



스프링 빈 등록과 의존성 주입 (@Component, @Autowired, @Qualifier, @Primary, @PostConstruct)

@Component, @Autowired, @Qualifier, @Primary, @PostConstruct는 스프링이 객체를 만들고, 필요한 객체를 넣어 주고, 초기 작업까지 실행하는 흐름과 관련된 어노테이션이다.
이 구간을 이해하려면 먼저 스프링이 객체를 직접 관리한다는 개념부터 잡아야 한다.


일반 Java 코드에서는 필요한 객체를 사용할 때 new로 직접 만든다.
예를 들어 CoffeeMaker가 CoffeeMachine을 필요로 하면 new EspressoMachine()처럼 직접 객체를 생성할 수 있다.
하지만 Spring에서는 개발자가 모든 객체를 직접 만들기보다, Spring 컨테이너가 객체를 만들고 관리하게 할 수 있다.


여기서 컨테이너는 객체를 담아 두고 관리하는 공간이라고 이해하면 된다.
Spring 컨테이너가 관리하는 객체를 빈이라고 한다.
즉, 스프링 빈은 Spring이 직접 만들고 관리하는 객체이다.


@Component는 클래스를 스프링 빈으로 등록하는 역할을 한다.
@Autowired는 필요한 빈을 스프링이 찾아서 넣어 주는 역할을 한다.
@Qualifier와 @Primary는 같은 타입의 빈이 여러 개 있을 때 어떤 빈을 넣을지 정하는 역할을 한다.
@PostConstruct는 빈 생성과 의존성 주입이 끝난 뒤 실행할 초기화 메서드를 지정한다.


따라서 이 구간의 핵심 흐름은 아래와 같다.

  • @Component로 객체를 스프링 빈으로 등록한다.
  • @Autowired로 필요한 빈을 주입받는다.
  • 같은 타입 빈이 여러 개면 @Qualifier나 @Primary로 선택 기준을 정한다.
  • 의존성 주입이 끝난 뒤 @PostConstruct로 초기 작업을 실행한다.

즉, 이 어노테이션들은 각각 따로 외우는 것이 아니라, 스프링이 객체를 관리하고 연결하는 흐름 안에서 함께 이해해야 한다.


아래 코드들은 각 어노테이션의 역할을 비교하기 위한 예제이다.
같은 프로젝트에 모든 예제 클래스를 한 번에 넣어 실행하라는 뜻이 아니다.
특히 CoffeeMachine을 구현한 클래스가 여러 번 나오기 때문에, 실제 실습에서는 필요한 예제 묶음만 선택해서 실행해야 한다.


스프링 빈은 무엇인가

스프링 빈은 Spring이 만들고 관리하는 객체이다.
일반 객체는 개발자가 new로 직접 만들고 직접 관리한다.
반면 스프링 빈은 Spring 컨테이너가 생성하고 보관하고 필요한 곳에 넣어 줄 수 있다.


예를 들어 아래 코드는 일반 Java 방식으로 객체를 직접 만드는 흐름이다.

// DirectObjectCreateExample.java
public class DirectObjectCreateExample {
    public static void main(String[] args) {
        CoffeeService service = new CoffeeService(); // 개발자가 직접 객체 생성
        String result = service.orderCoffee(); // 직접 만든 객체 사용
        System.out.println(result); // 결과 출력
    }
}
// CoffeeService.java
public class CoffeeService {
    public String orderCoffee() {
        return "커피 주문 완료"; // 주문 결과 반환
    }
}
// 출력결과
// 커피 주문 완료

이 방식에서는 CoffeeService 객체를 직접 만든다.
그래서 객체가 필요한 곳마다 new CoffeeService()를 작성해야 할 수 있다.


반면 Spring 방식에서는 CoffeeService를 스프링 빈으로 등록하고, 필요한 클래스가 그 빈을 주입받아 사용할 수 있다.
이때 사용하는 대표적인 어노테이션이 @Component이다.


@Component는 클래스를 스프링 빈으로 등록한다

@Component는 이 클래스를 Spring이 관리하는 빈으로 등록하겠다는 뜻이다.
즉, 개발자가 직접 new로 객체를 만드는 대신, Spring이 이 클래스의 객체를 만들어 컨테이너 안에 보관할 수 있게 한다.


아래 코드는 CoffeeService를 스프링 빈으로 등록하는 예제이다.

// CoffeeServiceComponentExample.java
@Component
public class CoffeeServiceComponentExample {
    public String orderCoffee() {
        return "커피 주문 완료"; // 서비스 결과 반환
    }
}

이 코드에서 @Component가 붙어 있기 때문에 Spring은 이 클래스를 스캔해서 빈으로 등록할 수 있다.
스캔은 Spring이 정해진 범위 안에서 @Component 같은 어노테이션이 붙은 클래스를 찾아 빈으로 등록하는 과정이다.


즉, @Component가 붙은 클래스는 “이 객체는 내가 직접 만들 테니 건드리지 마”가 아니라, Spring이 만들고 관리할 수 있는 대상으로 등록한다는 의미이다.


정리하면 아래와 같다.

  • @Component는 클래스를 스프링 빈으로 등록한다.
  • 빈으로 등록된 객체는 Spring 컨테이너가 관리한다.
  • 다른 클래스는 이 빈을 주입받아 사용할 수 있다.
  • 개발자가 직접 new로 객체를 만드는 흐름을 줄일 수 있다.

따라서 @Component는 객체를 스프링 컨테이너 안에 맡기는 시작점이라고 이해하면 된다.


@Autowired는 필요한 빈을 자동으로 넣어 준다

@Autowired는 필요한 의존성 객체를 Spring이 자동으로 찾아 넣어 주게 하는 어노테이션이다.
여기서 의존성은 한 클래스가 다른 클래스를 필요로 하는 관계를 뜻한다.
예를 들어 CoffeeMaker가 커피를 만들기 위해 CoffeeService를 필요로 한다면, CoffeeMaker는 CoffeeService에 의존한다.


일반 Java 방식에서는 필요한 객체를 직접 만든다.

// DirectDependencyExample.java
public class DirectDependencyExample {
    private CoffeeService coffeeService = new CoffeeService(); // 필요한 객체를 직접 생성

    public String makeCoffee() {
        return coffeeService.orderCoffee(); // 직접 만든 객체 사용
    }
}

이 방식은 단순해 보이지만, 클래스가 직접 객체 생성까지 담당한다.
객체 생성 방식이 바뀌면 이 클래스도 같이 수정될 수 있다.


Spring에서는 필요한 객체를 직접 만들지 않고 주입받을 수 있다.

// CoffeeMakerAutowiredExample.java
@Component
public class CoffeeMakerAutowiredExample {
    @Autowired
    private CoffeeServiceComponentExample coffeeService; // Spring이 필요한 빈을 찾아 주입

    public String makeCoffee() {
        return coffeeService.orderCoffee(); // 주입받은 빈 사용
    }
}

이 코드에서 CoffeeMakerAutowiredExample은 CoffeeServiceComponentExample 객체를 직접 만들지 않는다.
대신 @Autowired가 붙은 필드에 Spring이 알맞은 빈을 찾아 넣어 준다.


즉, @Autowired의 핵심은 이것이다.
필요한 객체를 내가 직접 new로 만들지 않고, Spring이 컨테이너 안에서 찾아 넣어 준다.


필드 주입과 생성자 주입

@Autowired는 필드, 생성자, 메서드에 붙일 수 있다.
학습 단계에서는 필드에 바로 붙이는 방식이 눈에 잘 들어온다.
하지만 실제 프로젝트에서는 생성자 주입을 자주 사용한다.


필드 주입은 아래처럼 필드 위에 @Autowired를 붙이는 방식이다.

// FieldInjectionExample.java
@Component
public class FieldInjectionExample {
    @Autowired
    private CoffeeServiceComponentExample coffeeService; // 필드에 바로 주입

    public String makeCoffee() {
        return coffeeService.orderCoffee(); // 주입받은 객체 사용
    }
}

필드 주입은 코드가 짧고 보기 쉽다.
하지만 이 클래스가 어떤 객체를 반드시 필요로 하는지 생성자만 보고는 알기 어렵다.


생성자 주입은 필요한 객체를 생성자로 받는 방식이다.

// ConstructorInjectionExample.java
@Component
public class ConstructorInjectionExample {
    private final CoffeeServiceComponentExample coffeeService; // 필요한 객체를 필드로 보관

    public ConstructorInjectionExample(CoffeeServiceComponentExample coffeeService) {
        this.coffeeService = coffeeService; // 생성자로 주입받은 객체 저장
    }

    public String makeCoffee() {
        return coffeeService.orderCoffee(); // 주입받은 객체 사용
    }
}

생성자 주입은 이 클래스가 만들어질 때 어떤 객체가 꼭 필요한지 분명하게 보여 준다.
ConstructorInjectionExample 객체가 만들어지려면 CoffeeServiceComponentExample이 필요하다는 사실이 생성자에 드러난다.


정리하면 아래와 같다.

  • 필드 주입은 필드에 바로 @Autowired를 붙이는 방식이다.
  • 생성자 주입은 생성자를 통해 필요한 빈을 받는 방식이다.
  • 필드 주입은 코드가 짧다.
  • 생성자 주입은 의존 관계가 더 분명하게 보인다.

따라서 초보자 단계에서는 필드 주입으로 흐름을 이해하고, 실제 코드 구조를 잡을 때는 생성자 주입을 함께 익히는 것이 좋다.


같은 타입 빈이 여러 개 있으면 문제가 생길 수 있다

@Autowired는 기본적으로 필요한 타입의 빈을 찾아 넣어 준다.
그런데 같은 타입의 빈이 여러 개 있으면 Spring은 어떤 빈을 넣어야 할지 헷갈릴 수 있다.


예를 들어 CoffeeMachine이라는 인터페이스가 있고, 이를 구현한 클래스가 두 개 있다고 생각하면 된다.
하나는 EspressoMachine이고, 다른 하나는 DripCoffeeMachine이다.
둘 다 CoffeeMachine 타입으로 볼 수 있다.


아래 코드를 보면 구조가 더 분명하다.

// CoffeeMachine.java
public interface CoffeeMachine {
    String brew(); // 커피 추출 기능
}
// EspressoMachine.java
@Component
public class EspressoMachine implements CoffeeMachine {
    @Override
    public String brew() {
        return "에스프레소 추출"; // 에스프레소 추출 결과
    }
}
// DripCoffeeMachine.java
@Component
public class DripCoffeeMachine implements CoffeeMachine {
    @Override
    public String brew() {
        return "드립 커피 추출"; // 드립 커피 추출 결과
    }
}

이 상태에서 아래처럼 CoffeeMachine 타입을 주입받으려고 하면 문제가 생길 수 있다.

// CoffeeMakerConflictExample.java
@Component
public class CoffeeMakerConflictExample {
    @Autowired
    private CoffeeMachine coffeeMachine; // CoffeeMachine 타입 빈이 여러 개라 선택이 애매함
}

Spring 입장에서는 CoffeeMachine 타입으로 볼 수 있는 빈이 두 개이다.
EspressoMachine도 가능하고, DripCoffeeMachine도 가능하다.
이때 어떤 빈을 넣어야 할지 기준이 없으면 주입 대상이 애매해진다.


이런 상황을 해결하는 방법이 @Primary와 @Qualifier이다.
@Primary는 기본 선택 대상을 정하는 방식이고, @Qualifier는 이름으로 정확히 지정하는 방식이다.


@Primary는 기본 선택 대상을 정한다

@Primary는 같은 타입의 빈이 여러 개 있을 때 기본으로 선택할 빈을 지정한다.
즉, 특별히 다른 이름을 지정하지 않으면 이 빈을 우선으로 사용하라는 뜻이다.


아래 예제에서는 DripCoffeeMachine에 @Primary를 붙였다.

// DripCoffeeMachinePrimary.java
@Component
@Primary
public class DripCoffeeMachinePrimary implements CoffeeMachine {
    @Override
    public String brew() {
        return "드립 커피 추출"; // 기본 선택 대상
    }
}
// EspressoMachineComponent.java
@Component
public class EspressoMachineComponent implements CoffeeMachine {
    @Override
    public String brew() {
        return "에스프레소 추출"; // 다른 CoffeeMachine 빈
    }
}
// PrimaryCoffeeMaker.java
@Component
public class PrimaryCoffeeMaker {
    private final CoffeeMachine coffeeMachine; // CoffeeMachine 타입 의존

    public PrimaryCoffeeMaker(CoffeeMachine coffeeMachine) {
        this.coffeeMachine = coffeeMachine; // @Primary가 붙은 빈이 우선 주입될 수 있음
    }

    @PostConstruct
    public void init() {
        System.out.println(coffeeMachine.brew()); // 주입 후 결과 확인
    }
}
// 출력결과
// 드립 커피 추출

이 예제에서는 CoffeeMachine 타입 빈이 여러 개 있어도 DripCoffeeMachinePrimary에 @Primary가 붙어 있다.
그래서 특별히 다른 빈을 지정하지 않으면 DripCoffeeMachinePrimary가 기본 선택 대상이 된다.


정리하면 아래와 같다.

  • @Primary는 같은 타입 빈이 여러 개일 때 기본 선택 대상을 정한다.
  • 여러 후보 중 특별한 지정이 없으면 @Primary가 붙은 빈이 우선 선택될 수 있다.
  • 기본값처럼 자주 사용할 구현체에 붙이면 이해하기 쉽다.

즉, @Primary는 여러 후보 중 기본으로 사용할 빈을 정하는 방식이다.


@Qualifier는 이름으로 정확히 지정한다

@Qualifier는 어떤 빈을 주입받을지 이름으로 정확히 지정하는 방식이다.
같은 타입 빈이 여러 개 있을 때 “기본값 말고 이 빈을 넣어 줘”라고 직접 선택하는 느낌이다.


먼저 빈 이름을 명확하게 지정할 수 있다.

// EspressoMachineNamed.java
@Component("espressoMachine")
public class EspressoMachineNamed implements CoffeeMachine {
    @Override
    public String brew() {
        return "에스프레소 추출"; // 에스프레소 추출 결과
    }
}
// DripCoffeeMachineNamed.java
@Component("dripCoffeeMachine")
@Primary
public class DripCoffeeMachineNamed implements CoffeeMachine {
    @Override
    public String brew() {
        return "드립 커피 추출"; // 기본 선택 대상
    }
}

이 상태에서 @Qualifier("espressoMachine")를 사용하면 @Primary가 붙은 dripCoffeeMachine이 아니라 espressoMachine을 정확히 주입받을 수 있다.

// QualifierCoffeeMaker.java
@Component
public class QualifierCoffeeMaker {
    private final CoffeeMachine coffeeMachine; // 선택된 커피 머신 저장

    public QualifierCoffeeMaker(@Qualifier("espressoMachine") CoffeeMachine coffeeMachine) {
        this.coffeeMachine = coffeeMachine; // 이름으로 지정한 빈 주입
    }

    @PostConstruct
    public void init() {
        System.out.println(coffeeMachine.brew()); // 주입 후 결과 확인
    }
}
// 출력결과
// 에스프레소 추출

이 예제에서 DripCoffeeMachineNamed에는 @Primary가 붙어 있다.
하지만 생성자 매개변수에 @Qualifier("espressoMachine")가 붙어 있으므로 espressoMachine 빈이 정확히 선택된다.


정리하면 아래와 같다.

  • @Qualifier는 빈 이름으로 주입 대상을 직접 지정한다.
  • 같은 타입 빈이 여러 개 있을 때 특정 빈을 정확히 고를 수 있다.
  • @Primary가 기본 선택이라면, @Qualifier는 직접 지정이다.

따라서 @Qualifier는 여러 후보 중 특정 빈을 이름으로 정확히 찍어 선택하는 방식이다.


@Primary와 @Qualifier의 차이

@Primary와 @Qualifier는 모두 같은 타입 빈이 여러 개 있을 때 사용한다.
하지만 선택 방식이 다르다.


@Primary는 기본 선택 대상을 정한다.
그래서 특별한 지정이 없으면 @Primary가 붙은 빈이 우선 선택될 수 있다.


@Qualifier는 주입받는 위치에서 어떤 빈을 사용할지 이름으로 직접 지정한다.
그래서 특정 위치에서 반드시 특정 구현체를 써야 할 때 더 분명하다.


차이는 아래처럼 정리할 수 있다.

  • @Primary는 빈 쪽에 붙여 기본 선택 대상을 정한다.
  • @Qualifier는 주입받는 쪽에서 이름으로 정확히 지정한다.
  • @Primary는 기본값을 정하는 방식이다.
  • @Qualifier는 특정 빈을 직접 선택하는 방식이다.
  • 둘이 함께 있을 때는 @Qualifier처럼 직접 지정한 정보가 더 명확하다.

이 차이를 알면 같은 타입 빈이 여러 개 있을 때 어떤 방식으로 해결해야 하는지 판단할 수 있다.
기본적으로 자주 쓰는 구현체가 있다면 @Primary가 자연스럽고, 특정 위치에서 반드시 특정 빈이 필요하면 @Qualifier가 자연스럽다.


@PostConstruct는 주입이 끝난 뒤 실행된다

@PostConstruct는 Spring이 객체를 만들고 필요한 의존성 주입까지 끝낸 뒤, 자동으로 실행할 메서드에 붙인다.
즉, 객체가 만들어진 직후 아무 때나 실행되는 것이 아니라, 필요한 빈이 모두 들어온 뒤 실행되는 초기화 단계이다.


생성자와 @PostConstruct는 역할이 다르다.
생성자는 객체가 만들어지는 과정이다.
@PostConstruct는 객체가 만들어지고 의존성 주입까지 끝난 뒤 실행되는 초기 작업이다.


아래 예제를 보면 차이가 더 분명하다.

// PostConstructCoffeeMaker.java
@Component
public class PostConstructCoffeeMaker {
    private final CoffeeMachine coffeeMachine; // 주입받을 의존성

    public PostConstructCoffeeMaker(@Qualifier("espressoMachine") CoffeeMachine coffeeMachine) {
        this.coffeeMachine = coffeeMachine; // 생성자에서 의존성 주입
    }

    @PostConstruct
    public void init() {
        System.out.println(coffeeMachine.brew()); // 주입이 끝난 뒤 실행
    }
}
// 출력결과
// 에스프레소 추출

이 코드에서 init()은 개발자가 직접 호출하지 않는다.
Spring이 PostConstructCoffeeMaker 객체를 만들고, 생성자를 통해 coffeeMachine을 넣어 준 뒤, @PostConstruct가 붙은 init()을 자동으로 실행한다.


정리하면 아래와 같다.

  • 생성자는 객체를 만드는 단계이다.
  • 의존성 주입은 필요한 빈을 객체에 넣는 단계이다.
  • @PostConstruct는 의존성 주입이 끝난 뒤 실행되는 초기화 단계이다.
  • 주입받은 객체를 사용해야 하는 초기 작업은 @PostConstruct에 두면 자연스럽다.

따라서 의존성이 모두 준비된 뒤 실행해야 하는 코드는 @PostConstruct에 두는 것이 좋다.


기본 예제로 전체 흐름 보기

아래 코드는 @Component, @Autowired, @Qualifier, @Primary, @PostConstruct가 어떤 흐름으로 연결되는지 가장 단순하게 보여 주는 예제이다.
먼저 커피 머신 역할을 정의하는 인터페이스를 만든다.

// CoffeeMachine.java
public interface CoffeeMachine {
    String brew(); // 커피 추출 기능
}
// EspressoMachine.java
@Component("espressoMachine")
public class EspressoMachine implements CoffeeMachine {
    @Override
    public String brew() {
        return "에스프레소 추출"; // 에스프레소 추출 결과
    }
}
// DripCoffeeMachine.java
@Component("dripCoffeeMachine")
@Primary
public class DripCoffeeMachine implements CoffeeMachine {
    @Override
    public String brew() {
        return "드립 커피 추출"; // 기본 우선 선택 대상
    }
}
// CoffeeMaker.java
@Component
public class CoffeeMaker {
    private final CoffeeMachine coffeeMachine; // 주입받은 커피 머신 저장

    public CoffeeMaker(@Qualifier("espressoMachine") CoffeeMachine coffeeMachine) {
        this.coffeeMachine = coffeeMachine; // 이름으로 정확히 지정한 빈 주입
    }

    @PostConstruct
    public void init() {
        System.out.println(coffeeMachine.brew()); // 주입 후 자동 실행
    }
}
// 출력결과
// 에스프레소 추출

이 예제에서 EspressoMachine, DripCoffeeMachine, CoffeeMaker는 모두 스프링 빈이다.
@Component가 붙어 있기 때문에 Spring이 객체를 만들고 관리할 수 있다.


EspressoMachine과 DripCoffeeMachine은 둘 다 CoffeeMachine 타입이다.
그래서 그냥 CoffeeMachine만 주입받으려고 하면 후보가 여러 개가 된다.
이때 DripCoffeeMachine에는 @Primary가 붙어 있으므로 기본 선택 대상이 될 수 있다.


하지만 CoffeeMaker 생성자에는 @Qualifier("espressoMachine")가 붙어 있다.
그래서 기본 선택 대상인 dripCoffeeMachine이 아니라, 이름이 espressoMachine인 빈이 정확히 주입된다.


마지막으로 @PostConstruct가 붙은 init() 메서드는 주입이 끝난 뒤 자동으로 실행된다.
그래서 coffeeMachine.brew()를 호출했을 때 에스프레소 추출이 출력된다.


전체 흐름은 아래처럼 정리할 수 있다.

// 실행 흐름
// 1. Spring이 @Component가 붙은 클래스를 빈으로 등록한다.
// 2. CoffeeMachine 타입 빈 후보가 espressoMachine, dripCoffeeMachine 두 개 생긴다.
// 3. dripCoffeeMachine은 @Primary 때문에 기본 선택 대상이 된다.
// 4. CoffeeMaker는 @Qualifier("espressoMachine")로 espressoMachine을 직접 지정한다.
// 5. Spring이 CoffeeMaker 생성자에 espressoMachine 빈을 주입한다.
// 6. 의존성 주입이 끝난 뒤 @PostConstruct가 붙은 init()을 실행한다.
// 7. 에스프레소 추출이 출력된다.

즉, 이 흐름은 빈 등록 → 의존성 주입 → 필요한 빈 선택 → 주입 후 초기화 순서로 이해하면 된다.


두 방식의 차이 정리

일반 Java 방식과 Spring 방식은 모두 객체를 만들어 사용할 수 있다.
하지만 객체를 만들고 연결하는 책임이 다르다.


일반 Java 방식에서는 개발자가 new로 객체를 직접 만든다.
객체가 다른 객체를 필요로 하면 그 객체도 직접 만들거나 직접 전달해야 한다.


Spring 방식에서는 @Component가 붙은 클래스를 Spring이 빈으로 등록하고 관리한다.
그리고 @Autowired나 생성자 주입을 통해 필요한 빈을 넣어 준다.
같은 타입 빈이 여러 개이면 @Primary나 @Qualifier로 선택 기준을 정한다.


차이는 아래처럼 정리할 수 있다.

  • 일반 Java 방식은 개발자가 직접 객체를 만든다.
  • Spring 방식은 Spring 컨테이너가 빈을 만들고 관리한다.
  • @Component는 클래스를 빈으로 등록한다.
  • @Autowired는 필요한 빈을 자동으로 주입받게 한다.
  • @Primary는 기본 선택 대상을 정한다.
  • @Qualifier는 특정 빈을 이름으로 정확히 지정한다.
  • @PostConstruct는 의존성 주입 후 초기 작업을 실행한다.

따라서 이 어노테이션들은 각각 따로 떨어진 개념이 아니다.
스프링 컨테이너가 객체를 만들고, 필요한 곳에 넣고, 같은 타입 빈 중 어떤 것을 쓸지 고르고, 마지막 초기 작업까지 실행하는 흐름으로 연결된다.


이 섹션의 핵심은 이것이다.
@Component는 빈 등록, @Autowired는 의존성 주입, @Primary는 기본 선택, @Qualifier는 이름 기반 선택, @PostConstruct는 주입 후 초기화를 담당한다.



요청 부가 정보와 바인딩 결과를 받는 방식 (@RequestHeader, @CookieValue, HttpSession, BindingResult, Errors)

@RequestHeader, @CookieValue, HttpSession, BindingResult, Errors는 일반 요청값과는 조금 다른 정보를 받을 때 사용한다.
앞에서 본 @RequestParam, @ModelAttribute, @PathVariable, @RequestBody는 주로 사용자가 보낸 값을 받는 방식이었다.
반면 이 구간의 객체와 어노테이션은 요청에 함께 담겨 온 부가 정보, 세션 저장소, 바인딩 결과를 다룬다.


@RequestHeader는 요청 헤더 값을 받을 때 사용한다.
헤더는 브라우저나 클라이언트가 서버에 요청을 보낼 때 함께 보내는 부가 정보이다.
예를 들어 브라우저 종류를 담는 User-Agent, 이전 페이지 주소를 나타낼 수 있는 Referer, 인증 정보를 담을 수 있는 Authorization 같은 값이 헤더에 들어갈 수 있다.


@CookieValue는 요청에 담겨 온 쿠키 값을 받을 때 사용한다.
쿠키는 브라우저에 저장되어 있다가 요청할 때 서버로 함께 전달되는 작은 값이다.
예를 들어 테마 설정, 자동 로그인 관련 값, 사용자 선호 값처럼 브라우저에 남겨 둘 수 있는 값이 쿠키로 다뤄질 수 있다.


HttpSession은 사용자별로 서버에 유지되는 세션 저장소를 직접 다룰 때 사용한다.
@SessionAttributes가 Model 속성 일부를 세션에 연결하는 방식이었다면, HttpSession은 세션 객체 자체에 접근하는 방식이다.
즉, @SessionAttributes는 모델 중심 세션 유지이고, HttpSession은 세션 저장소 직접 접근이다.


BindingResult와 Errors는 요청값을 객체에 바인딩한 뒤 생긴 오류를 확인할 때 사용한다.
예를 들어 숫자 필드에 문자가 들어오거나, 검증 조건에 맞지 않는 값이 들어오면 오류 정보를 확인할 수 있다.
특히 BindingResult는 보통 바인딩 대상 객체 바로 뒤에 둬야 한다.


따라서 이 구간은 단순히 값을 받는 문법을 외우는 구간이 아니다.
요청 안의 부가 정보, 사용자별 저장 공간, 요청값 바인딩 결과를 컨트롤러 메서드에서 어떻게 받는지 이해하는 구간이다.


요청 헤더는 무엇인가

요청 헤더는 클라이언트가 서버에 요청을 보낼 때 함께 전달하는 부가 정보이다.
사용자가 화면에 직접 입력한 값이라기보다, 요청을 설명하는 정보에 가깝다.


예를 들어 브라우저가 서버에 요청을 보낼 때 자신이 어떤 브라우저인지, 어떤 형식의 응답을 받을 수 있는지, 이전에 어떤 페이지에서 왔는지 같은 정보를 헤더에 담아 보낼 수 있다.
그래서 헤더는 요청 본문이나 요청 파라미터와 다르다.
요청 파라미터는 사용자가 보낸 값에 가깝고, 헤더는 요청 자체에 대한 정보에 가깝다.


Spring MVC에서는 특정 헤더 하나를 컨트롤러 매개변수로 바로 받고 싶을 때 @RequestHeader를 사용할 수 있다.
예를 들어 User-Agent 헤더를 받고 싶으면 아래처럼 작성한다.

// RequestHeaderBasicExample.java
@Controller
public class RequestHeaderBasicController {
    @GetMapping("/header-info") // /header-info 요청 처리
    public String headerInfo(@RequestHeader(value = "User-Agent", required = false) String userAgent,
                             Model model) {
        model.addAttribute("userAgent", userAgent); // User-Agent 헤더 값을 화면에 전달
        return "headerResult"; // 결과 화면으로 이동
    }
}
// headerResult.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>headerResult</title>
</head>
<body>
<p>브라우저 정보: <span th:text="${userAgent}"></span></p>
</body>
</html>
// 출력결과
// 요청 주소: /header-info
// 읽는 헤더 이름: User-Agent
// userAgent 값: 브라우저가 보낸 User-Agent 값
// 화면 출력: 브라우저 정보: 브라우저가 보낸 User-Agent 값

이 코드에서 @RequestHeader(value = "User-Agent", required = false)는 요청 헤더 중 User-Agent 값을 받겠다는 뜻이다.
required = false는 해당 헤더가 없어도 오류로 처리하지 않겠다는 뜻이다.


헤더는 클라이언트나 요청 상황에 따라 없을 수도 있다.
그래서 꼭 들어온다고 확신할 수 없는 헤더는 required = false를 함께 쓰면 안전하다.


정리하면 아래와 같다.

  • @RequestHeader는 요청 헤더 값을 매개변수로 받을 때 사용한다.
  • 헤더는 요청 자체를 설명하는 부가 정보이다.
  • User-Agent는 브라우저나 클라이언트 정보를 담을 수 있다.
  • 헤더가 없을 수 있으면 required = false를 사용할 수 있다.

따라서 @RequestHeader는 사용자가 입력한 값보다 요청에 함께 붙어 온 부가 정보를 읽는 방식으로 이해하면 된다.


쿠키는 무엇이고 @CookieValue는 언제 쓰는가

쿠키는 브라우저에 저장되어 있다가 요청할 때 서버로 함께 전달되는 작은 값이다.
서버가 브라우저에 쿠키를 저장하게 만들 수 있고, 이후 브라우저는 같은 사이트로 요청을 보낼 때 그 쿠키를 함께 보낼 수 있다.


쿠키는 사용자 설정이나 간단한 상태 값을 저장할 때 사용될 수 있다.
예를 들어 테마 설정, 자동 로그인 관련 값, 최근 선택값 같은 작은 데이터를 쿠키로 다룰 수 있다.


@CookieValue는 요청에 포함된 쿠키 값을 컨트롤러 매개변수로 바로 받을 때 사용한다.
예를 들어 theme이라는 쿠키 값을 받고 싶다면 아래처럼 작성한다.

// CookieValueBasicExample.java
@Controller
public class CookieValueBasicController {
    @GetMapping("/cookie-info") // /cookie-info 요청 처리
    public String cookieInfo(@CookieValue(value = "theme", required = false) String theme,
                             Model model) {
        model.addAttribute("theme", theme); // theme 쿠키 값을 화면에 전달
        return "cookieResult"; // 결과 화면으로 이동
    }
}
// cookieResult.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>cookieResult</title>
</head>
<body>
<p>선택한 테마: <span th:text="${theme}"></span></p>
</body>
</html>
// 출력결과
// 요청 주소: /cookie-info
// 읽는 쿠키 이름: theme
// theme 값: 브라우저가 보낸 theme 쿠키 값
// 화면 출력: 선택한 테마: theme 쿠키 값

이 코드에서 @CookieValue(value = "theme", required = false)는 요청에 담겨 온 쿠키 중 theme 값을 받겠다는 뜻이다.
theme 쿠키가 없을 수도 있으므로 required = false를 사용했다.


쿠키도 항상 존재하는 값이 아니다.
처음 방문한 사용자라면 쿠키가 없을 수 있고, 사용자가 브라우저에서 쿠키를 지웠을 수도 있다.
그래서 쿠키 값을 받을 때도 없는 경우를 생각해야 한다.


정리하면 아래와 같다.

  • @CookieValue는 요청에 담겨 온 쿠키 값을 받을 때 사용한다.
  • 쿠키는 브라우저에 저장되었다가 요청 시 함께 전달되는 작은 값이다.
  • 쿠키가 없을 수 있으면 required = false를 사용할 수 있다.
  • 쿠키라는 목적이 분명한 값을 받을 때는 @CookieValue가 코드 의도를 잘 보여 준다.

따라서 @CookieValue는 요청 헤더 전체를 직접 뒤지는 방식이 아니라, 쿠키 값을 바로 받겠다는 의도를 드러내는 방식이다.


@RequestHeader와 @CookieValue의 차이

@RequestHeader와 @CookieValue는 둘 다 요청에 함께 들어온 부가 정보를 받는다.
그래서 처음 보면 비슷해 보일 수 있다.
하지만 읽으려는 정보의 성격이 다르다.


@RequestHeader는 요청 헤더 값을 읽는다.
헤더는 요청 자체를 설명하는 정보이다.
브라우저 정보, 인증 정보, 요청 형식 같은 부가 정보가 여기에 들어갈 수 있다.


@CookieValue는 쿠키 값을 읽는다.
쿠키는 브라우저에 저장되어 있다가 요청할 때 함께 전달되는 값이다.
사용자 설정이나 작은 상태 값을 유지하는 데 사용될 수 있다.


아래 코드는 헤더와 쿠키를 함께 받는 예제이다.

// HeaderCookieCompareExample.java
@Controller
public class HeaderCookieCompareController {
    @GetMapping("/request-info")
    public String requestInfo(@RequestHeader(value = "User-Agent", required = false) String userAgent,
                              @CookieValue(value = "theme", required = false) String theme,
                              Model model) {
        model.addAttribute("userAgent", userAgent); // 요청 헤더 값 저장
        model.addAttribute("theme", theme); // 쿠키 값 저장
        return "requestInfoResult"; // 결과 화면으로 이동
    }
}
// requestInfoResult.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>requestInfoResult</title>
</head>
<body>
<p>헤더 정보: <span th:text="${userAgent}"></span></p>
<p>쿠키 정보: <span th:text="${theme}"></span></p>
</body>
</html>
// 출력결과
// 요청 주소: /request-info
// User-Agent 헤더 값: 브라우저가 보낸 User-Agent 값
// theme 쿠키 값: 브라우저가 보낸 theme 쿠키 값
// 화면 출력: 헤더 정보와 쿠키 정보

이 예제에서 userAgent는 요청 헤더에서 가져온 값이다.
반면 theme은 쿠키에서 가져온 값이다.
둘 다 요청 안에 함께 들어온 부가 정보이지만 목적이 다르다.


차이는 아래처럼 정리할 수 있다.

  • @RequestHeader는 요청 헤더 값을 읽는다.
  • @CookieValue는 요청에 포함된 쿠키 값을 읽는다.
  • 헤더는 요청 자체를 설명하는 부가 정보에 가깝다.
  • 쿠키는 브라우저에 저장되었다가 요청 때 함께 전달되는 값이다.

따라서 요청 자체의 부가 정보는 @RequestHeader, 브라우저에 저장된 쿠키 값은 @CookieValue로 구분하면 된다.


HttpSession은 세션 저장소를 직접 다룬다

HttpSession은 사용자별로 서버에 유지되는 세션 저장소에 접근할 때 사용한다.
세션은 한 번의 요청이 끝나도 바로 사라지지 않고, 같은 사용자의 다음 요청에서도 값을 꺼내 쓸 수 있는 저장 공간이다.


앞에서 본 @SessionAttributes는 Model 속성 일부를 세션에 유지하는 방식이었다.
반면 HttpSession은 세션 객체 자체를 직접 다루는 방식이다.
즉, session.setAttribute()로 값을 직접 저장하고, session.getAttribute()로 값을 직접 꺼낸다.


아래 코드는 HttpSession에 로그인 사용자 이름을 직접 저장하고 꺼내는 예제이다.

// HttpSessionBasicExample.java
@Controller
public class HttpSessionBasicController {
    @GetMapping("/session-login") // /session-login?name=lee 요청 처리
    public String sessionLogin(@RequestParam("name") String name,
                               HttpSession session) {
        session.setAttribute("loginUser", name); // 세션에 로그인 사용자 이름 저장
        return "redirect:/session-my"; // 내 정보 확인 요청으로 이동
    }

    @GetMapping("/session-my") // 세션에 저장된 값 확인
    public String sessionMy(HttpSession session,
                            Model model) {
        Object loginUser = session.getAttribute("loginUser"); // 세션에서 로그인 사용자 값 꺼내기
        model.addAttribute("loginUser", loginUser); // 화면에 전달
        return "sessionMy"; // 결과 화면으로 이동
    }
}
// sessionMy.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>sessionMy</title>
</head>
<body>
<p>로그인 사용자: <span th:text="${loginUser}"></span></p>
</body>
</html>
// 출력결과
// 첫 번째 요청: /session-login?name=lee
// session.setAttribute("loginUser", "lee") 실행
// 다음 요청: /session-my
// session.getAttribute("loginUser") 결과: lee
// 화면 출력: 로그인 사용자: lee

이 흐름에서는 첫 번째 요청에서 세션에 loginUser 값을 저장한다.
그리고 두 번째 요청에서 같은 세션에 저장된 loginUser 값을 다시 꺼낸다.


즉, HttpSession은 여러 요청 동안 유지해야 하는 값을 직접 저장하고 꺼낼 때 사용할 수 있다.
로그인 사용자 정보처럼 서버 쪽에서 사용자별로 유지해야 하는 값은 HttpSession으로 직접 다루는 흐름이 자연스러울 수 있다.


정리하면 아래와 같다.

  • HttpSession은 사용자별 세션 저장소에 직접 접근한다.
  • session.setAttribute()로 세션에 값을 저장한다.
  • session.getAttribute()로 세션에서 값을 꺼낸다.
  • 로그인 사용자 정보처럼 여러 요청 동안 유지해야 하는 값을 다룰 때 사용할 수 있다.

따라서 HttpSession은 세션 저장소 자체를 직접 다루는 방식이다.


@SessionAttributes와 HttpSession의 차이

@SessionAttributes와 HttpSession은 둘 다 세션과 관련이 있다.
하지만 중심이 다르다.


@SessionAttributes는 Model 속성 중 일부를 세션에 유지하는 방식이다.
즉, 출발점이 Model이다.
컨트롤러에서 Model에 담긴 값 중 지정된 이름의 값이 세션에도 유지된다.


반면 HttpSession은 세션 저장소 자체를 직접 다룬다.
session.setAttribute()와 session.getAttribute()를 사용해서 값을 직접 넣고 꺼낸다.
즉, 출발점이 세션 객체이다.


아래처럼 구분하면 된다.

  • @SessionAttributes는 Model 속성 일부를 세션에 유지한다.
  • HttpSession은 세션 저장소 자체를 직접 다룬다.
  • @SessionAttributes는 폼 단계처럼 컨트롤러 안의 모델 값을 임시로 유지할 때 어울린다.
  • HttpSession은 로그인 사용자 정보처럼 세션 전체에서 직접 관리할 값에 어울릴 수 있다.

따라서 @SessionAttributes는 모델 중심이고, HttpSession은 세션 객체 직접 접근 중심이라고 구분하면 된다.


BindingResult는 바인딩 결과와 오류를 담는다

BindingResult는 요청값을 객체에 바인딩한 뒤 생긴 오류를 담는 객체이다.
여기서 바인딩은 요청값을 자바 객체에 연결해서 넣는 과정을 뜻한다.
예를 들어 요청 파라미터 name=lee, age=20을 MemberDTO 객체의 name, age 필드에 넣는 것이 바인딩이다.


그런데 바인딩 과정에서 문제가 생길 수 있다.
예를 들어 age는 숫자여야 하는데 사용자가 abc를 보냈다면 숫자로 바꿀 수 없다.
이런 오류 정보를 확인할 수 있는 객체가 BindingResult이다.


중요한 점은 위치이다.
BindingResult는 보통 바인딩 대상 객체 바로 뒤에 둬야 한다.
예를 들어 @ModelAttribute MemberDTO member의 바인딩 결과를 확인하려면 바로 뒤에 BindingResult bindingResult를 둔다.


아래 코드는 바인딩 오류를 확인하는 기본 예제이다.

// MemberDTO.java
public class MemberDTO {
    private String name; // 이름 저장
    private int age; // 나이 저장

    public String getName() {
        return name; // 이름 반환
    }

    public void setName(String name) {
        this.name = name; // 이름 저장
    }

    public int getAge() {
        return age; // 나이 반환
    }

    public void setAge(int age) {
        this.age = age; // 나이 저장
    }
}
// BindingResultBasicExample.java
@Controller
public class BindingResultBasicController {
    @PostMapping("/member/check") // 회원 입력값 확인 요청 처리
    public String check(@ModelAttribute MemberDTO member,
                        BindingResult bindingResult,
                        Model model) {
        if (bindingResult.hasErrors()) {
            return "memberForm"; // 바인딩 오류가 있으면 입력 화면으로 이동
        }

        model.addAttribute("member", member); // 정상 객체를 화면에 전달
        return "memberResult"; // 결과 화면으로 이동
    }
}
// 출력결과
// 요청 예시 1: name=lee&age=20
// 바인딩 결과: 오류 없음
// 이동 화면: memberResult.html
// 요청 예시 2: name=lee&age=abc
// 바인딩 결과: age를 int로 바꿀 수 없어 오류 발생
// 이동 화면: memberForm.html

이 코드에서 MemberDTO의 age는 int이다.
따라서 age=20처럼 숫자가 들어오면 정상적으로 바인딩될 수 있다.
하지만 age=abc처럼 숫자로 바꿀 수 없는 값이 들어오면 바인딩 오류가 생길 수 있다.


이때 bindingResult.hasErrors()로 오류가 있는지 확인할 수 있다.
오류가 있으면 다시 입력 화면으로 보내고, 오류가 없으면 결과 화면으로 이동한다.


정리하면 아래와 같다.

  • BindingResult는 바인딩 결과와 오류 정보를 담는다.
  • 바인딩은 요청값을 객체에 넣는 과정이다.
  • 숫자 변환 실패 같은 오류를 확인할 수 있다.
  • BindingResult는 바인딩 대상 객체 바로 뒤에 둬야 한다.

따라서 BindingResult는 요청값을 객체로 받은 뒤 그 과정에서 문제가 있었는지 확인하는 객체이다.


BindingResult의 위치가 중요한 이유

BindingResult는 아무 위치에나 두면 안 된다.
보통 오류를 확인할 바인딩 대상 객체 바로 뒤에 둬야 한다.


예를 들어 아래처럼 작성하면 member 객체의 바인딩 결과를 자연스럽게 이어서 받을 수 있다.

// BindingResultPositionGoodExample.java
@Controller
public class BindingResultPositionGoodController {
    @PostMapping("/member/good")
    public String good(@ModelAttribute MemberDTO member,
                       BindingResult bindingResult,
                       Model model) {
        if (bindingResult.hasErrors()) {
            return "memberForm"; // member 바인딩 오류가 있으면 입력 화면
        }

        model.addAttribute("member", member); // 정상 객체 저장
        return "memberResult"; // 결과 화면
    }
}

이 구조는 @ModelAttribute MemberDTO member 바로 뒤에 BindingResult bindingResult가 온다.
그래서 bindingResult는 바로 앞의 member 객체에 대한 바인딩 결과를 담는다고 이해하면 된다.


반대로 아래처럼 중간에 다른 매개변수가 끼면 흐름이 어색해지고, 바인딩 결과를 자연스럽게 연결하기 어렵다.

// BindingResultPositionBadExample.java
@Controller
public class BindingResultPositionBadController {
    @PostMapping("/member/bad")
    public String bad(@ModelAttribute MemberDTO member,
                      Model model,
                      BindingResult bindingResult) {
        return "memberResult"; // BindingResult 위치가 적절하지 않은 예
    }
}

이 코드는 BindingResult가 바인딩 대상 객체 바로 뒤에 있지 않다.
학습 단계에서는 이런 형태를 피하고, 항상 바인딩 대상 객체 바로 뒤에 둔다고 기억하는 것이 좋다.


정리하면 아래와 같다.

  • BindingResult는 확인할 객체 바로 뒤에 둔다.
  • @ModelAttribute MemberDTO member, BindingResult bindingResult 순서가 자연스럽다.
  • 중간에 Model 같은 다른 매개변수를 끼우지 않는다.
  • 위치가 맞아야 어떤 객체의 바인딩 결과인지 분명하다.

따라서 BindingResult는 위치까지 문법의 일부처럼 생각해야 한다.


Errors는 BindingResult와 무엇이 다른가

Errors는 바인딩이나 검증 과정에서 생긴 오류를 다루는 상위 타입으로 이해하면 된다.
BindingResult는 Errors를 더 구체적으로 확장한 타입이다.
학습 단계에서는 둘이 비슷한 오류 확인 역할을 한다고 이해해도 흐름을 따라가는 데 큰 문제는 없다.


다만 실제 코드에서는 BindingResult를 더 자주 보는 경우가 많다.
왜냐하면 BindingResult는 바인딩 결과를 더 구체적으로 다루는 타입이고, 컨트롤러 메서드에서 바인딩 대상 객체 바로 뒤에 두는 패턴이 많이 사용되기 때문이다.


아래 코드는 Errors로 오류를 확인하는 예제이다.

// ErrorsBasicExample.java
@Controller
public class ErrorsBasicController {
    @PostMapping("/member/errors")
    public String errorsCheck(@ModelAttribute MemberDTO member,
                              Errors errors,
                              Model model) {
        if (errors.hasErrors()) {
            return "memberForm"; // 오류가 있으면 입력 화면
        }

        model.addAttribute("member", member); // 정상 객체 저장
        return "memberResult"; // 결과 화면
    }
}

이 코드에서 errors.hasErrors()는 오류가 있는지 확인한다.
BindingResult의 hasErrors()와 사용 흐름이 비슷하다.


정리하면 아래와 같다.

  • Errors는 오류 정보를 다루는 상위 타입에 가깝다.
  • BindingResult는 바인딩 결과를 더 구체적으로 다루는 타입이다.
  • 둘 다 hasErrors()로 오류 여부를 확인할 수 있다.
  • 학습 단계에서는 BindingResult를 중심으로 익히는 것이 더 자연스럽다.

따라서 Errors는 비슷한 오류 확인 역할을 하는 타입이고, 실제 바인딩 결과 확인은 BindingResult 중심으로 먼저 이해하면 된다.


Servlet/JSP에서는 어떻게 처리했는가

Servlet/JSP 방식에서는 헤더, 쿠키, 세션, 오류 처리를 직접 코드로 다뤄야 했다.
헤더는 request.getHeader()로 꺼내고, 쿠키는 request.getCookies()로 배열을 받아 직접 찾아야 했다.
세션은 request.getSession()으로 꺼낸 뒤 setAttribute()나 getAttribute()를 사용했다.


아래 예제는 헤더, 쿠키, 세션을 직접 다루는 흐름이다.

// InfoServletExample.java
@WebServlet("/info")
public class InfoServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        String userAgent = request.getHeader("User-Agent"); // 헤더 직접 읽기
        String theme = null; // theme 쿠키 값을 담을 변수

        Cookie[] cookies = request.getCookies(); // 쿠키 배열 직접 꺼내기
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("theme".equals(cookie.getName())) {
                    theme = cookie.getValue(); // theme 쿠키 값 저장
                }
            }
        }

        HttpSession session = request.getSession(); // 세션 객체 직접 꺼내기
        Object loginUser = session.getAttribute("loginUser"); // 세션 값 직접 읽기

        request.setAttribute("userAgent", userAgent); // JSP에 넘길 헤더 값 저장
        request.setAttribute("theme", theme); // JSP에 넘길 쿠키 값 저장
        request.setAttribute("loginUser", loginUser); // JSP에 넘길 세션 값 저장
        RequestDispatcher dispatcher = request.getRequestDispatcher("/WEB-INF/views/infoResult.jsp"); // JSP 지정
        dispatcher.forward(request, response); // JSP로 이동
    }
}

이 방식에서는 필요한 정보를 전부 요청 객체에서 직접 꺼낸다.
특히 쿠키는 원하는 쿠키 이름을 찾기 위해 배열을 반복해서 확인해야 한다.


즉, Servlet/JSP 방식에서는 헤더 읽기, 쿠키 찾기, 세션 접근, 화면 전달을 개발자가 직접 코드로 작성한다.


Spring MVC에서는 어떻게 바뀌는가

Spring MVC에서는 헤더와 쿠키를 매개변수로 바로 받을 수 있고, 세션 객체도 컨트롤러 메서드 매개변수로 받을 수 있다.
또 바인딩 결과도 BindingResult로 이어서 확인할 수 있다.


아래 코드는 @RequestHeader, @CookieValue, HttpSession을 함께 사용하는 예제이다.

// InfoControllerExample.java
@Controller
public class InfoControllerExample {
    @GetMapping("/info")
    public String info(@RequestHeader(value = "User-Agent", required = false) String userAgent,
                       @CookieValue(value = "theme", required = false) String theme,
                       HttpSession session,
                       Model model) {
        Object loginUser = session.getAttribute("loginUser"); // 세션값 확인

        model.addAttribute("userAgent", userAgent); // 헤더 값 저장
        model.addAttribute("theme", theme); // 쿠키 값 저장
        model.addAttribute("loginUser", loginUser); // 세션 값 저장
        return "infoResult"; // 결과 화면으로 이동
    }
}
// infoResult.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>infoResult</title>
</head>
<body>
<p>헤더: <span th:text="${userAgent}"></span></p>
<p>쿠키: <span th:text="${theme}"></span></p>
<p>세션: <span th:text="${loginUser}"></span></p>
</body>
</html>
// 출력결과
// 요청 주소: /info
// userAgent 값: User-Agent 헤더 값
// theme 값: theme 쿠키 값 또는 null
// loginUser 값: 세션에 저장된 loginUser 값 또는 null
// 화면 출력: 헤더, 쿠키, 세션 값

이 코드에서 @RequestHeader는 헤더 값을 바로 받는다.
@CookieValue는 쿠키 값을 바로 받는다.
HttpSession은 세션 객체 자체를 받는다.


즉, Spring MVC에서는 요청 객체를 직접 뒤지는 코드를 줄이고, 필요한 값을 메서드 매개변수로 바로 받을 수 있다.


바인딩 결과 확인까지 함께 처리할 수 있다

요청값을 객체로 받을 때는 바인딩 결과를 함께 확인할 수 있다.
이때 BindingResult를 바인딩 대상 객체 바로 뒤에 둔다.


아래 코드는 회원 입력값을 객체로 받고, 바인딩 오류가 있으면 다시 입력 화면으로 보내는 예제이다.

// MemberFormControllerExample.java
@Controller
public class MemberFormControllerExample {
    @PostMapping("/member/check")
    public String check(@ModelAttribute MemberDTO member,
                        BindingResult bindingResult,
                        Model model) {
        if (bindingResult.hasErrors()) {
            return "memberForm"; // 바인딩 오류가 있으면 다시 입력 화면
        }

        model.addAttribute("member", member); // 정상 데이터 저장
        return "memberResult"; // 결과 화면으로 이동
    }
}
// 출력결과
// 정상 요청: name=lee&age=20
// bindingResult.hasErrors(): false
// 이동 화면: memberResult.html
// 오류 요청: name=lee&age=abc
// bindingResult.hasErrors(): true
// 이동 화면: memberForm.html

이 예제에서 BindingResult는 MemberDTO member 바로 뒤에 있다.
그래서 member 객체로 요청값을 묶는 과정에서 생긴 오류를 bindingResult에서 확인할 수 있다.


이 위치가 바뀌면 어떤 객체의 바인딩 결과인지 흐름이 깨질 수 있다.
따라서 BindingResult는 항상 확인할 객체 바로 뒤에 둔다고 기억하는 것이 좋다.


두 방식의 차이 정리

Servlet/JSP 방식과 Spring MVC 방식은 모두 요청의 부가 정보와 세션, 바인딩 결과를 다룰 수 있다.
하지만 값을 꺼내는 방식과 오류를 확인하는 방식이 달라진다.


차이는 아래처럼 정리할 수 있다.

  • Servlet/JSP에서는 request.getHeader()로 헤더를 직접 읽는다.
  • Spring MVC에서는 @RequestHeader로 특정 헤더 값을 바로 받을 수 있다.
  • Servlet/JSP에서는 request.getCookies()로 쿠키 배열을 직접 꺼내 원하는 쿠키를 찾아야 한다.
  • Spring MVC에서는 @CookieValue로 특정 쿠키 값을 바로 받을 수 있다.
  • Servlet/JSP에서는 request.getSession()으로 세션 객체를 직접 꺼낸다.
  • Spring MVC에서는 컨트롤러 메서드 매개변수로 HttpSession을 바로 받을 수 있다.
  • Spring MVC에서는 객체 바인딩 결과를 BindingResult나 Errors로 확인할 수 있다.

따라서 이 구간의 어노테이션과 객체들은 요청 처리 흐름을 없앤 것이 아니다.
요청에 포함된 부가 정보, 쿠키, 세션, 바인딩 결과를 컨트롤러 메서드에서 더 명확하게 받을 수 있도록 도와주는 방식이다.


이 섹션의 핵심은 이것이다.
@RequestHeader는 헤더 값, @CookieValue는 쿠키 값, HttpSession은 세션 저장소 직접 접근, BindingResult와 Errors는 객체 바인딩 뒤의 오류 결과를 확인할 때 사용한다.

0개의 댓글