RE-Heat 개발자 일지

스프링 MVC 1편 - [6] 기본 기능(하편) 본문

백엔드/스프링

스프링 MVC 1편 - [6] 기본 기능(하편)

RE-Heat 2023. 7. 2. 20:19

출처 : https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1

 

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 - 인프런 | 강의

웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., -

www.inflearn.com

인프런 김영한님의 스프링 MVC 1편 강의를 듣고 정리한 내용입니다.

 

 

[8] HTTP 요청파라미터 - @ModelAttribute

실제 개발을 하면 요청 파라미터를 받은 후 필요한 객체를 만들고 그 값을 객체에 넣어야 하는 번거로움이 있다. 그런데 스프링에선 이 과정을 자동화해 주는 @ModelAttribute라는 편리한 기능이 있다.

 

HelloData

@Data
public class HelloData {
    private String username;
    private int age;
}

 

파라미터를 담을 객체 생성

참고 : 롬복의 @Data 애노테이션

=> Getter , @Setter , @ToString , @EqualsAndHashCode , @RequiredArgsConstructor를 자동으로 적용해 줌. 

 

RequestParamController: model-attribute-v1 @ModelAttribute 적용 전 후

@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@RequestParam String username, @RequestParam int age){
    HelloData helloData = new HelloData();
    helloData.setUsername(username);
    helloData.setAge(age);

    log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
    log.info("helloData={}", helloData);

    return "ok";
}

@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@ModelAttribute HelloData helloData){

    log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
    log.info("helloData={}", helloData);

    return "ok";
}

@ModelAttribute를 써서 HelloData 객체를 만들고 파라미터를 담는 과정을 건너뛴 것을 확인할 수 있다.

 

스프링 MVC @ModelAttribute

1. HelloData객체를 생성

2. 요청 파라미터 이름으로 HelloData객체의 프로퍼티를 찾음.

3. 찾으면 해당 프로퍼티의 setter를 호출해 파라미터 값을 입력(바인딩).

 

RequestParamController: model-attribute-v2

@ResponseBody
@RequestMapping("/model-attribute-v2")
public String modelAttributeV2(HelloData helloData){

    log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
    log.info("helloData={}", helloData);

    return "ok";
}

@ModelAttribute 생략 가능

이유 : 스프링은 String, int, Integer 등 단순 타입은 @RequestParam 그 외는 @ModelAttribute로 자동 처리

 

[9] HTTP 요청 메시지 - 단순 텍스트

Http message body에 데이터를 직접 담아서 요청하는 방식

    - HTTP API에서 주로 사용 (JSON, XML, TEXT)

    - 데이터 형식은 주로 JSON 사용

    - POST, PUT, PATCH

 

HTTP message body에 데이터를 담아서 요청할 땐 @RequestParam과 @ModelAttribute를 사용할 수 없다. 그럴 땐 어떻게 읽어야 할까?

 

RequestBodyStringController

@Slf4j
@Controller
public class RequestBodyStringController {
    @PostMapping("/request-body-string-v1")
    public void requestBodyString(HttpServletRequest request, HttpServletResponse response) throws IOException {
        ServletInputStream inputStream = request.getInputStream();
        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

        log.info("messageBody={}", messageBody);

        response.getWriter().write("ok");
    }

    @PostMapping("/request-body-string-v2")
    public void requestBodyStringV2(InputStream inputStream, Writer responseWriter) throws IOException {
        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

        log.info("messageBody={}", messageBody);

        responseWriter.write("ok");
    }

    @PostMapping("/request-body-string-v3")
    public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) throws IOException {
        String body = httpEntity.getBody(); // http 메시지의 body를 꺼냄

        log.info("messageBody={}", body);

        return new HttpEntity<String>("ok"); // 맨 처음에 return 메시지 값
    }

    //참고용 RequestEntity, ResponseEntity
    @PostMapping("/request-body-string-v3-v2")
    public HttpEntity<String> requestBodyStringV32(RequestEntity<String> httpEntity) throws IOException {
        String body = httpEntity.getBody(); // http 메시지의 body를 꺼냄

        log.info("messageBody={}", body);

        return new ResponseEntity<>("ok", HttpStatus.CREATED); // 맨 처음에 return 메시지 값
    }

    @ResponseBody
    @PostMapping("/request-body-string-v4")
    public String requestBodyStringV4(@RequestBody String messageBody) {
        log.info("messageBody={}", messageBody);

        return "ok";
    }

}

v1 : InputStream을 사용해 직접 읽을 수 있다.

v2 : 스프링이 지원하는 파라미터인 InputStream, OutputStream을 활용해 코드를 더 간결하게 만듦.

v3 : HttpEntity로 HTTP header, body 정보를 편리하게 조회.

    v3-v2 : HttpEntity를 상속받은 RequestEntity, ResponseEntity도 사용 가능

    => ResponseEntity는 HTTP 상태 코드 설정 가능하다는 게 차이점( ex) HttpStatus.CREATED)

v4 : @RequestBody로 HTTP 메시지 바디 정보를 편리하게 조회. 헤더 정보가 필요하면 HttpEntity를 활용하거나 @RequestHeader를 쓰면 된다.

 

!!!중요!!!

요청 파라미터는 GET의 쿼리스트링 또는 HTML Form방식인 경우에만 한함. 그 외엔 HttpEntity를 사용하거나 데이터를 직접 꺼내야 한다.

1. 요청파라미터 : @RequestParam, @ModelAttribute

2. HTTP 메시지 바디 조회 : @RequestBody

 

[10] HTTP 요청 메시지 - JSON

 

RequestBodyJsonController

/*
 * {"username":"hello", "age":20}
 * content-type:application/json
 * */

@Slf4j
@Controller
public class RequestBodyJsonController {
    private ObjectMapper objectMapper = new ObjectMapper();

    @PostMapping("/request-body-json-v1")
    public void requestBodyJsonV1(HttpServletRequest request, HttpServletResponse response) throws IOException {
        ServletInputStream inputStream = request.getInputStream();
        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

        log.info("messageBody={}", messageBody);
        HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
        log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());

        response.getWriter().write("ok");
    }

    @ResponseBody
    @PostMapping("/request-body-json-v2")
    public String requestBodyJsonV2(@RequestBody String messageBody) throws IOException {
        log.info("messageBody={}", messageBody);
        HelloData helloData = objectMapper.readValue(messageBody, HelloData.class); //굳이 ObjectMapper로 바꿔야 하나?
        log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());

        return "ok";
    }

    @ResponseBody
    @PostMapping("/request-body-json-v3")
    public String requestBodyJsonV3(@RequestBody HelloData data) {
        log.info("username={}, age={}", data.getUsername(), data.getAge());
        return "ok";
    }

    @ResponseBody
    @PostMapping("/request-body-json-v4")
    public String requestBodyJsonV4(HttpEntity<HelloData> httpEntity) {
        HelloData data = httpEntity.getBody();
        log.info("username={}, age={}", data.getUsername(), data.getAge());
        return "ok";
    }

    @ResponseBody
    @PostMapping("/request-body-json-v5")
    public HelloData requestBodyJsonV5(@RequestBody HelloData data) {
        log.info("username={}, age={}", data.getUsername(), data.getAge());
        return data; // 이렇게 하면 JSON값으로 반환 가능
    }
}

v1 : 메시지 바디 JSON 데이터를 inputStream으로 불러온 후 String화 => ObjectMapper를 활용해 원하는 객체에 매핑

v2 : @RequestBody로 HTTP 메시지 데이터를 꺼내고 messageBody에 저장 -> 문자화된 JSON 데이터를 ObjectMapper로 자바 객체로 변환

v3 : @RequestBody로 직접 만든 객체(HelloData)에 매핑 가능. 단, 단순 타입을 제외하면 모두 @ModelAttribute가 붙으므로 @RequestBody는 생략 불가능!

=> 생략 시 HelloData data -> @ModelAttribute HelloData data가 되어 버림

v4 : HttpEntity를 활용한 방식

v5 : @ResponseBody를 사용하면 해당 객체를 HTTP 메시지 바디에 직접 넣을 수 있음. JSON값으로 반환 가능

    @RequestBody : JSON 요청 -> HTTP 메시지 컨버터 -> 객체

    @RespnseBody : 객체 -> HTTP 메시지 컨버터 -> JSON 응답


[11] 응답 - 정적 리소스, 뷰 템플릿

정적 리소스 : 웹브라우저에 정적인 HTML, CSS, JS를 제공할 땐 정적 리소스 사용

뷰 템플릿 사용 : 웹브라우저에 동적인 HTML을 제공할 땐 뷰 템플릿 사용(jsp, thymeleaf 등)

 

1] 정적 리소스

정적 리소스 경로 : src/main/resources/static

 

2] 뷰 템플릿

뷰 템플릿 경로 : src/main/resources/templates

 

hello.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<p th:text="${data}">empty</p>
</body>
</html>

① th:text는 empty부분을 치환해 줌.

② ${data}는 Model에 있는 데이터 중 data를 꺼내쓸 때 사용.

 

ResponseViewController

@Slf4j
@Controller
public class ResponseViewController {

    @RequestMapping("/response-view-v1")
    public ModelAndView responseViewV1(){
        ModelAndView mav = new ModelAndView("response/hello");
        mav.addObject("data", "hello!");

        return mav;
    }

    @RequestMapping("/response-view-v2")
    public String responseViewV2(Model model){
        model.addAttribute("data", "hello!");
        return "response/hello";
    }
    //권장하지 않음.
    @RequestMapping("/response/hello")
    public void responseViewV3(Model model){
        model.addAttribute("data", "hello!");
    }
}

v1 : ModelAndView에 논리이름 담고, data라는 변수에 hello! 값을 담아 리턴

v2 : 스프링이 제공하는 매개변수인 Model 객체를 받고 데이터를 넣음. 그 후 논리이름을 반환

v3 : 이러면 requestMapping 이름을 논리이름으로 사용함. 그러나 명시성이 떨어져 추천하지 않는 방법

 

◎ 타임리프 스프링부트 설정방법

build.gradle에 이미 추가 돼 있음(https://start.spring.io/의 디펜던시에 타임리프 추가했기 때문)

스프링부트가 타임리프에 필요한 ThymeleafViewResolver와 필요한 스프링 빈들을 자동 등록.

기본값


[12] HTTP 응답 - HTTP API, 메시지 바디에 직접 입력

HTTP API를 제공할 땐 HTML이 아닌 데이터를 전달해야 하므로 HTTP 메시지 바디에 JSON, TEXT 형식의 데이터를 실어 보내야 한다.

 

ResponseBodyController

@Slf4j
@RestController
public class ResponseBodyController {
    @GetMapping("/response-body-string-v1")
    public void responseBodyV1(HttpServletResponse response) throws IOException {
        response.getWriter().write("ok");
    }

    @GetMapping("/response-body-string-v2")
    public ResponseEntity<String> responseBodyV2() throws IOException {
        return new ResponseEntity<>("ok", HttpStatus.OK);
    }

    //@ResponseBody
    @GetMapping("/response-body-string-v3")
    public String responseBodyV3() {
        return "ok";
    }

    @GetMapping("/response-body-json-v1")
    public ResponseEntity<HelloData> responseBodyJSONV1(){
        HelloData helloData = new HelloData();
        helloData.setUsername("userA");
        helloData.setAge(20);

        return new ResponseEntity<>(helloData, HttpStatus.OK);
    }

    //@ResponseBody
    @GetMapping("/response-body-json-v2")
    public HelloData responseBodyJSONV2(){
        HelloData helloData = new HelloData();
        helloData.setUsername("userA");
        helloData.setAge(20);

        return helloData;
    }

    @ResponseStatus(HttpStatus.OK)
    //@ResponseBody
    @GetMapping("/response-body-json-v3")
    public HelloData responseBodyJSONV3(){
        HelloData helloData = new HelloData();
        helloData.setUsername("userA");
        helloData.setAge(20);

        return helloData;
    }
}

String

    v1 : Writer객체를 불러 메시지 바디에 담을 내용 직접 입력.

    v2 : ResponseEntity<>로 메시지 바디에 담을 내용 + 상태코드 추가(HttpStatus.OK)

    v3 : @ResponseBody를 활용해 메시지 바디에 직접 입력.

JSON

    v1 : ResponseEntity를 반환. HTTP 메시지 컨버터를 거쳐 JSON 형식으로 반환됨.

    v2 : @ResponseBody로 객체 반환

    v3 : ResponseEntity가 아닌 @ResponseBody로 반환하면 HTTP 응답코드를 설정하기 어려운데, @ResponseStatus(HttpStatus.OK) 애노테이션을 활용하면 응답코드도 설정 가능

 

@RestController = @ResponseBody + @Controller

해당 컨트롤러 모두에 @ResponseBody가 적용되는 효과. 따라서 뷰 템플릿을 사용하지 않고 HTTP 메시지 바디에 직접 데이터를 입력하는 형식. 즉, Rest API를 만들 때 사용하는 컨트롤러다.   

 

[13] HTTP 메시지 컨버터

뷰 템플릿으로 HTML을 생성해 응답하지 않고, HTTP API처럼 JSON 데이터를 HTTP 메시지 바디에서 직접 읽거나 쓸 때 HTTP 메시지 컨버터를 사용하면 편리하다.

스프링 MVC는 다음 같은 케이스에 HTTP 메시지 컨버터를 사용한다.

- HTTP request : @RequestBody, HttpEntity(RequestEntity)

- HTTP response : @ResponseBody, HttpEntity(ResponseEntity)

 

스프링 HttpMessageConverter의 우선순위

0순위 = ByteArrayHttpMessageConverter

    [클래스 타입 : byte[], 미디어타입 : */*]

    예시) @RequestBody byte[] data, @ResponseBody return byte[]

1순위 = StringHttpMessageConverter

    [클래스 타입 : String, 미디어타입 : */*]

    예시) @RequestBody String data, @ResponseBody return "ok"

2순위 = MappingJackson2HttpMessageConverter

    [클래스 타입 : 객체·HashMap, 미디어타입 : application/json]

    예시)  @RequestBody HelloData data, @ResponseBody return helloData

 

위 코드의 경우 클래스타입이 객체이므로 MappingJackson2HttpMessageConverter가 호출. 그런데 미디어 타입이 application/json이 아닌 text/html이므로 오류 발생.

 

HttpMessageConverter의 내부 메서드

① canRead() : 메시지 컨버터가 요청 데이터를 대상으로 해당 클래스·미디어타입을 지원하는지 체크
② canWrite()  : 메시지 컨버터가 응답 데이터를 대상으로 해당 클래스·미디어타입을 지원하는지 체크
③ read() , write() : 메시지 컨버터를 통해서 메시지를 읽고 쓴다.


[14] 요청 매핑 핸들러 어댑터 구조

그렇다면 HTTP 메시지 컨버터는 스프링 구조 어디 부분에서 이용될까? 

전체적인 구조 중 핸들러 어댑터에서 핸들러로 넘어가는 과정에서 HTTP 메시지 컨버터가 사용되는데, 

우선 애노테이션 기반 컨트롤러에서 우리는 다양한 파라미터(HttpServletRequest, Model, @RequestParam, @ModelAttribute 등)를 사용할 수 있는데, 이는 HandlerMethodArgumentResolver(a.k.a ArgumentResolver) 덕분이다.

 

ArgumentResolver

 동작방식

1] supportsParameter()를 호출해 해당 파라미터 지원하는지 확인

2] 지원하면 resolverArgument()를 호출해 실체 객체를 생성하고, 이 생성된 객체가 컨트롤러 호출 시 넘어감

 

ReturnValueHandler(=HandlerMethodReturnValueHandler)

ArgumentResolver와 비슷한데, 응답값을 반환하고 처리한다는 게 차이점. 

 

 

ArgumentResolver가 HTTP 메시지의 요청(@RequestBody, HttpEntity), ReturnValueHanlder가 HTTP 메시지의 응답(@ResponseBody, HttpEntity) 등을 처리할 때 쓰는 게 HTTP 메시지 컨버터다.

 

기능 확장

필요하면 ArgumentResolver·ReturnValueHandler도 확장 가능하다. 기능 확장은 "WebMvcConfigurer"를 상속받아서 "스프링 빈"으로 등록하면 된다. 단, 스프링이 대부분 기능을 제공하기 때문에 잘 쓰일 일은 없다.

 

WebMVCConfigurer

@Bean
public WebMvcConfigurer webMvcConfigurer() {
    return new WebMvcConfigurer() {
        @Override
        public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
            //...
        }

        @Override
        public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
            //...
        }
    };
}