티스토리 뷰

개발환경

프로젝트 생성 : https://start.spring.io/

GitHub Repository : https://github.com/jinyoungchoi95/learn_servlet

  • Gradle 6.8.3
  • Java 11
  • Spring Boot 2.4.5
  • Packaging : Jar
  • Dependency : Spring web, Lombok, Thymeleaf

 Jar를 통해 내장 톰캣을 사용하며 이전에 썼던 webapp경로도 사용하지 않게 된다. JSP를 이제 사용하지 않을 것이기 때문에 Jar 방식으로 진행을 이어간다.

 

스프링 부트 Jar 에서의 Welcome Page

 스프링 부트에서 Jar를 사용한다면 /resource/static/index.html 파일은 항상 Welcome 페이지로 처리한다. "/" 요청 즉, https:://localhost:8080/에 아무런 매핑정보가 없더라도 해당 파일을 디스플레이해주는 것이다.


 "/" 요청에 대해서 아무런 처리가 없음에도 /resource/static/index.html위치의 파일이 그대로 클라이언트에 표시되었음을 확인할 수 있다.

 

요청 매핑

@RequestController, @RequestMapping
package hello.springmvc.basic.requestmapping;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;

@RestController
public class MappingController {
    private Logger log = LoggerFactory.getLogger(getClass());

    @RequestMapping({"/hello-basic", "/hello-go"})
    public String helloBasic() {
        log.info("helloBasic");
        return "ok";
    }
  • @RestController : 전까지 사용했던 @Controller는 String을 반환하면 논리 뷰네임으로 보고 물리 뷰 경로를 만들어서 뷰를 랜더링해줬었다. 하지만 @RestController는 뷰를 찾는 것이 아니라 HTTP 바디에 바로 입력한다. 따라서 "ok"를 리턴했다면 다음과 같이 "ok"가 HTTP 바디에 들어간다.

  • @RequestMapping : 매핑할 url 정보를 담고있다. {"/hello-basic", "/hello-go"}으로 여러개의 url 정보를 담을 수 있다.

 

HTTP 메소드 지정
@RequestMapping(value = "/mapping-get-v1", method = RequestMethod.GET)
public String mappingGetV1() {
    log.info("mappingGetV1");
    return "ok";
}

@GetMapping("/mapping-get-v2")
public String mappingGetV2() {
    log.info("mappingGetV2");
    return "ok";
}

@RequestMapping은 HTTP 요청 메소드에 따라 상관없이 다 받을 수 있으나 method 옵션으로 원하는 메소드만 받을 수 있다. 옵션 지정 시 다른 HTTP 요청 메소드가 들어오면 405 메소드 에러가 발생한다.


 메소드 옵션을 지정하지 않고 @GetMapping으로 각각의 메소드 명 + Mapping으로 어노테이션을 지원하기도 한다. 크게 차이있는 것은 아니고 @GetMapping 코드를 보면 알 수 있듯이 @RequestMapping에 Get 메소드 옵션을 달아놓고 편하게 쓰는 것 뿐이다.


 

@PathVariable 경로 변수
@GetMapping("/mapping/{userId}")
public String mappingPath(@PathVariable("userId") String data) {
    log.info("mappingPath userId={}", data);
    return "ok";
}

@GetMapping("/mapping/users/{userId}/orders/{orderId}")
public String mappingPathDouble(@PathVariable String userId, @PathVariable Long orderId){
    log.info("mappingPath userId={}, orderId={}", userId, orderId);
    return "ok";
}

 url 경로 내에서 변수를 찾아서 사용하는 방식이다. REST API 방식의 URL을 설계할 때 많이 사용하는 방식이다.

 @PathVariable이 URL 경로에서 매칭되는 부분을 편하게 조회할 수 있으며 @PathVariable의 이름과 파라메터의 이름이 같으면 생략할 수 있다.

 

특정 파라메터 조건 매핑
@GetMapping(value = "/mapping-param", params = "mode=debug")
public String mappingParam() {
    log.info("mappingParam");
    return "ok";
}
//	https://localhost8080:/mapping-param?mode=debug

 파라메터에 특정 값이 포함되어야 동작할 수 있다. 여기서는 mode=debug가 포함되어야 한다.

특정 헤더 조건 매핑
@GetMapping(value = "/mapping-header", headers = "mode=debug")
public String mappingHeader() {
    log.info("mappingHeader");
    return "ok";
}

 이번엔 헤더에 포함되어야 동작할 수 있다.


Content-type 조건 매핑
@PostMapping(value = "/mapping-consume", consumes = "application/json")
public String mappingConsumes() {
    log.info("mappingConsumes");
    return "ok";
}

 Content-type 또한 헤더에 있는 값이기 때문에 위와 동일하게 쓴다는 생각을 할 수도 있지만 스프링 내부에서 consumes을 가지고 처리하는 부분이 있기 때문에 headers가 아닌 consumes를 사용해야 한다.


produce 조건 매핑
@PostMapping(value = "/mapping-produce", produces = "text/html")
public String mappingProduces() {
    log.info("mappingProduces");
    return "ok";
}

 방금 전의 content-type 조건 매핑은 서버가 http 요청을 받을 때 해당 content-type이어야만 받을 수 있었다.

 지금은 클라이언트가 서버로부터 결과를 받을 때 해당 content-type이어야만 받을 수 있는 조건이다. 이는 accept를 보면 알 수 있으며 기본적으로는 */*로 대부분의 content-type을 지원한다.


 

요청 매핑 - API 예시

 회원 관리 기능을 API로 만든다고 생각하면 어떻게 만들까?

  • 회원 목록 : GET /users
  • 회원 등록 : POST /users
  • 회원 조회 : GET /users/{userId}
  • 회원 수정 : POST /users/{userId}
  • 회원 삭제 : DELETE /users/{userId}
package hello.springmvc.basic.requestmapping;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/mapping/users")
public class MappingClassController {

    @GetMapping
    public String user() {
        return "get users";
    }

    @PostMapping
    public String addUser() {
        return "post user";
    }

    @GetMapping("/{userId}")
    public String findUser(@PathVariable String userId) {
        return "get userId=" + userId;
    }

    @PatchMapping("/{userId}")
    public String updateUser(@PathVariable String userId) {
        return "update userId=" + userId;
    }

    @DeleteMapping("/{userId}")
    public String deleteUser(@PathVariable String userId) {
        return "delete userId=" + userId;
    }
}

 실제 기능 구현은 하지않고 매핑이 되는지 안되는지를 위해 단순 구현만 해두었다.

 

HTTP 요청 - 기본, 헤더 조회

package hello.springmvc.basic.request;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Locale;

@Slf4j
@RestController
public class RequestHeaderController {

    @RequestMapping("/headers")
    public String headers(HttpServletRequest request,
                          HttpServletResponse response,
                          HttpMethod httpMethod,
                          Locale locale,
                          @RequestHeader MultiValueMap<String, String> headerMap,
                          @RequestHeader("host") String host,
                          @CookieValue(value = "myCookie", required = false) String cookie) {
        log.info("request={}", request);
        log.info("response={}", response);
        log.info("httpMethod={}", httpMethod);
        log.info("locale={}", locale);
        log.info("headerMap={}", headerMap);
        log.info("header host={}", host);
        log.info("myCookie={}", cookie);

        return "ok";
    }
}
  • HttpMethod : Http 메소드 정보를 조회한다. Get 메소드로 전송했다면 GET이 출력된다.
  • Locale : Locale 정보를 조회한다. 추가 조건 없이 요청한다면 가장 첫 Locale은 ko_KR가 출력될 것이다.
  • @RequestHeader MultiValueMap<String, String> headerMap : 모든 HTTP 헤더 정보를 출력한다.
  • @RequestHeader("host") String host : HTTP 헤더 정보에서 특정 값을 출력할 수 있다.
  • @CookieValue(value = "myCookie", required = false) String cookie) : 특정 쿠키를 조회한다.

 

HTTP 요청 파라미터 - @RequestParam

 클라이언트에서 요청 데이터를 보낼 때 이를 조회하는 방법은 총 3가지였다.

  • GET 쿼리스트링
  • POST HTML Form
  • HTTP message body에 담아서 요청

 

 이렇게 들어오면 어떻게 처리하는지 파라미터 처리부터 알아보자.

@RequestMapping("/request-param-v1")
public void requestParamV1(HttpServletRequest request, HttpServletResponse response) throws IOException {
    String username = request.getParameter("username");
    int age = Integer.parseInt(request.getParameter("age"));
    log.info("usernmae={}, age={}", username, age);

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

 초기에 우리는 HttpServletRequest에서 getParameter()로 직접 꺼냈다.

 

@ResponseBody
@RequestMapping("/request-param-v2")
public String requestParamV2(
        @RequestParam("username") String memberName,
        @RequestParam("age") int memberAge) {
    log.info("usernmae={}, age={}", memberName, memberAge);
    return "ok";
}

 굳이 HttpServletRequest를 받아와서 getParameter()만 쓰기에는 뭔가 어색해보인다.

 Spring에서는 파라메터 이름으로 바로 바인딩해서 받아올 수 있는 @RequestParam 어노테이션을 지원한다.

  • @RequestParam("username") String memberName
  • String memberName = request.getParameter("username")

 둘 다 똑같은 동작을 한다. 심지어 int 형으로 맞추어두면 자동으로 캐스팅해준다.

 

@ResponseBody

 앞전에 @Controller 어노테이션을 사용해서 컨트롤러를 만들면 반환값은 View를 반환한다고 했었다. @Controller만을 사용하면 반환한 값을 가지고 View Resolver로 전달해서 그에 맞는 View를 찾아서 랜더링하는 것이다.

 근데 @ResponseBody가 붙은 요청은 반환값을 그대로 Http message body 에 넣어서 반환한다.

 

@ResponseBody
@RequestMapping("/request-param-v3")
public String requestParamV3(
        @RequestParam String username,
        @RequestParam int age) {
    log.info("usernmae={}, age={}", username, age);
    return "ok";
}

 만약 파라메터의 변수명과 동일하게 받으면 굳이 ("username")과 같이 입력하지 않아도 알아서 받아준다.

 

@ResponseBody
@RequestMapping("/request-param-v4")
public String requestParamV4(String username, int age) {
    log.info("usernmae={}, age={}", username, age);
    return "ok";
}

 더 심하게는 @RequestParam까지 생략해도 동작하지만 너무 생략되어도 좋은 방법은 아니며, 개발 설계시 정해놓고 사용하는 것이 좋다.

 단, 파라메터가 무조건 들어와야하는 (required = true) 조건이 default이지만 @RequestParam을 생략했을 경우 required=false로 설정된다. 이는 아래에서 설명한다.

 

 username과 age를 파라메터로 받는다고 가정했었는데 만약 인자 하나가 안들어오면 어떻게 될까?

http://localhost:8080/request-param?username=hello

 일단 가장 처음했던 HttpServletRequest를 사용하는 컨트롤러는 원하는 대로 동작하진 않는다. HttpServletRequest에서 해당 파라메터를 getParameter()를 해야하는데 비어있어서 에러가 나기 때문이다. 그럼 파라메터 값이 요구되는 값이 들어와야하는데 안전하게 옵션으로 변경할 방법이 없을까?

@ResponseBody
@RequestMapping("/request-param-required")
public String requestParamRequired(
        @RequestParam(required = true) String username,
        @RequestParam(required = false) Integer age) {
    log.info("usernmae={}, age={}", username, age);
    return "ok";
}

@RequestParam에서 required = true 옵션을 걸어 놓으면 해당 파라메터가 반드시 들어와야하며 들어오지 않았을 경우 400에러가 발생한다. (required = true는 기본 default로 @RequestParam을 사용했을 때 required 옵션을 생략하면 true로 동작한다.)


 하지만 required = false 옵션을 걸어 놓으면 들어오지 않아도 알아서 null로 처리해준다. 단, 이때 default는 "항상" null이 들어온다는 사실을 주의해야한다.

 우리는 @RequestParam에 기본 타입을 설정해도 되었다. int와 같이 말이다. 근데 required = false를 걸어놓고 int에 null을 받는다면? 당연히 타입이 맞지않아서 에러가 발생한다.

 따라서 required = false를 사용하였을 때는 null이 들어온다는 사실을 인지하고 Interger로 변경하거나 아래에서 설명할 값이 비었을때 default 설정을 조정해주어야 한다.

 

@ResponseBody
@RequestMapping("/request-param-default")
public String requestParamDefault(
        @RequestParam(defaultValue = "guest") String username,
        @RequestParam(defaultValue = "-1") int age) {
    log.info("usernmae={}, age={}", username, age);
    return "ok";
}

 만약 @RequestParam이 들어왔는데 값이 null이 들어온다면 default로 지정할 값을 지정해주는 옵션이다.

 기존에 아래와 같이 호출한다면 어떤 값이 들어올까?

http://localhost:8080/request-param-required?username=&age=
usernmae=, age=null

 둘 다 null이 들어온다. 즉, 일단 defaultValue의 default는 null이다. 이걸 원하는 값인 "guest"나 -1처럼 바꾸어주면

usernmae=guest, age=-1

 원하는 값이 들어온다.

 

 이때 요즘은 Integer를 int로 사용해주었다는 점이다.

 앞전에 required = false 옵션을 int age에 사용해준다면 age 파라메터를 쓰지않아도 값이 들어오지만, 기본 값이 null로 들어오기 때문에 int와 타입이 맞지않아 에러가 났고 Integer로 사용해야 가능했었다.

 하지만 지금은 defaultValue = "-1"로 지정해주었기 때문에 null이 들어오지 않아 int를 사용해도 에러가 나지 않는다.

 

 또한 defaultValue를 사용하면 이미 기본값이 적용된 상태이기 때문에 required옵션이 의미가 없어지고 항상 defaultValue로 적용된 값이 default이다.

 

@ResponseBody
@RequestMapping("/request-param-map")
public String requestParamMap(@RequestParam Map<String, Object> paramMap) {
    log.info("usernmae={}, age={}", paramMap.get("username"), paramMap.get("age"));
    return "ok";
}

 요청 파라메터이야기를 할때 같은 이름의 파라메터가 여러개 요청왔을 때도 이야기한 적이 있다.

http://localhost:8080/request?username=hello&username=hello2&age=20

 얘는 그냥 getParameter()를 사용하면 가장 처음 선언된 파라메터만 받아오고 여러 개의 파라메터를 배열로 받아오는 방법으로 String[] usernames = request.getParameterValues("username");의 형태로 다 받아올 수 있었다.

 @RequestParam에서는 모든 파라메터를 Map 하나에 한번에 받을 수 있는 방법 또한 제공한다.

 그냥 매개변수를 Map<String, Object> paramMap으로 받아오면 Map 내부에 파라메터 네임, 파라메터 값을 받아온다. 단 위와 같이 username이 중복되었을 경우에는 String 값이 겹치기 때문에 중복된다. 따라서 MultiValueMap을 사용한다.

 

HTTP 요청 파라미터 - @ModelAttribute

 실제로 받은 파라메터를 사용할 때 그대로 사용하진 않고 객체를 만들어서 그 객체 안에 넣고 사용을 할 것이다. 그러면 지금과 같이 짰을 때 어떻게 될까?

HelloData data = new HelloData();
data.setUsername(username);
data.setAge(age);

 길다... 이걸 스프링에서는 @ModelAttribute어노테이션을 통해 자동으로 받아준다.

 일단 데이터를 담을 객체를 만든다.

package hello.springmvc.basic;

import lombok.Data;

@Data
public class HelloData {

    private String username;
    private int age;
}

@Data@Getter, @Setter, @ToString, @EqualsAndHashCode, @RequiredArgsContructor를 합쳐서 제공해준다.

@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@ModelAttribute HelloData helloData) {
    log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
    log.info("hellodata={}", helloData.toString());
    return "ok";
}

 HelloData 객체 안에 username과 age 모두 잘 들어가있음을 확인할 수 있다.

@ModelAttribute

 ModelAttribute는 호출되면 다음과 같이 동작한다.

  1. HelloData 객체를 생성한다.
  2. 요청 파라메터의 이름으로 HelloData 객체의 프로퍼티를 찾는다. 그리고 객체프로퍼티의 setter를 통해 값을 바인딩한다.

 파라미터 이름이 username이면 setUsername()을 찾아서 호출하면서 값을 입력한다.

 

@ResponseBody
@RequestMapping("/model-attribute-v2")
public String modelAttributeV2(HelloData helloData) {
    log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
    log.info("hellodata={}", helloData.toString());
    return "ok";
}

 물론 @ModelAttribute또한 생략이 가능하다. 근데 @RequestParam도 생략이 가능한데 스프링에서는 생략시 다음과 같은 규칙을 적용한다.

  • String, int, Integer 단순 타입 > @RqeustParam
  • 나머지 argument resolver 지정한 것 외 > @ModelAttribute

 

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

 HTTP message body에 데이터를 담아서 요청하는 방법도 서블릿에서 다루었었다.

 HTTP message body를 통해서 데이터가 직접 넘어오는 경우 @RequestParam, @ModelAttribute를 사용할 수 없으나 HTML Form 형식으로 전달되는 경우 요청 파라메터로 인정된다.

 

@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");
}

 먼저 HttpServletRequest를 이용한 방법이다. Servlet에서 했던 방식과 똑같은 방식이다.

 

@PostMapping("/request-body-string-v2")
public void requestBodyStringV2(InputStream inputStream, Writer responseWriter) throws IOException {

    String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
    log.info("messageBody={}", messageBody);
    responseWriter.write("ok");
}

 스프링에서는 다양한 상황에 대한 매개변수들을 모두 미리 저장해두었기 때문에 InputStream으로 들어와도 사용이 가능하며 심지어는 Writer 역시 지원한다.

  • InpustStream(Reader) : HTTP 요청 메시지 바디 내용을 조회
  • OutputStream(Writer) : HTTP 응답 메시지 바디에 출력

 

@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) throws IOException {

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

    return new HttpEntity<>("ok");
}

 이 둘을 하나로 사용할 수 있는 매개변수인 HttpEntity<String> httpEntity를 스프링에서는 지원한다. HTTP body 뿐만 아니라 header의 정보도 조회하며 응답 또한 반환 가능하다.

 단, HTTP 요청 메시지를 읽을 경우 요청파라미터에서 사용했던 @RequestParam@ModelAttribute는 사용할 수 없다. response를 내보낼 때도 HttpEntity<>를 사용해서 message body에 정보를 직접 반환할 수 있다.

 

@PostMapping("/request-body-string-v4")
public String requestBodyStringV4(@RequestBody String messageBody) {

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

    return "ok";
}

 앞전에 사용했던 @RequestBody를 사용하면 HTTP 메시지 정보를 쉽게 조회할 수 있다. 단 헤더 정보는 @RequestBody에 담기는 것이 아닌 @RequestHeader를 사용하면 된다.

 

 HTTP 요청 메시지에 대해서 간략하게 정리하면 요청 파라메터를 사용하는 것과는 별개의 작업이라는 것이다.

  • 요청 파라메터 : @RequestParam, @ModelAttribute
  • HTTP 요청 메시지 : @RequestBody

 또한 HTTP 요청 메시지 바디에 직접 담아서 결과를 내보낼 수가 있는데, 이때는 view를 사용하지 않는다. 이는 아래 response에서 다시 다루기로 한다.

 

HTTP 요청 메시지 - JSON

 요즘은 HTTP 요청 메시지에 String으로 그대로 넣는것이 아닌 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("messgaeBody={}", messageBody);
        HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
    }

 이전 포스팅에서 했던 방식과 동일한 방식이다.

 messagebody를 직접 받아서 ObjectMapper를 사용해서 객체로 변환할 수 있다.

 

@ResponseBody
@PostMapping("/request-body-json-v2")
public String requestBodyJsonV2(@RequestBody String messageBody) throws IOException {

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

    return "ok";
}

@RequestBody를 사용해서 message body를 받은 뒤 동일한 방식으로 ObjectMapper를 사용해서 객체로 변환한다. 입력은 @RequestBody를 사용했지만 객체화하는 것도 간편화할 수 없을까?

 

@ResponseBody
@PostMapping("/request-body-json-v3")
public String requestBodyJsonV3(@RequestBody HelloData helloData) {

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

 물론 @RequestBody를 그대로 사용하면서 객체로 바로받으면 ObjectMapper를 생략하고 객체로 데이터를 받을 수 있다.

 어떻게 이렇게 객체를 받아도 알아서 매핑이 될까? @Requestbody를 들어가보면 다음과 같은 설명이 있다.


 즉 @RequestBody를 사용하면 HTTP message 컨버터가 HTTP message body의 내용을 입력한 객체의 형태로 반환해 주는 것이다. 물론 문자 뿐만 아니라 JSON으로 들어와도 객체로 변환해준다. 단, application/json 형태의 content-type으로 들어와야 JSON으로 처리할 수 있다.

 

생략 불가능

 앞서 @RequestParam, @ModelAttribute는 어노테이션을 생략해도 알아서 해당 어노테이션으로 처리를 해줬다.

  • @RequestParam : String, Integer, int 등의 단순 타입
  • @ModelAttribute : argument resolver로 지정해준 타입 외 나머지

 따라서 어노테이션을 생략하고 객체를 바로 받을 경우 @RequestBody가 적용되는 것이 아닌 @ModelAttribute가 걱용되어버리기 때문에 잘 판단하여서 어노테이션을 사용해야 한다.

@ResponseBody
@PostMapping("/request-body-json-v4")
public String requestBodyJsonV4(HttpEntity<HelloData> httpEntity) {

    HelloData helloData = httpEntity.getBody();
    log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
    return "ok";
}

 물론 HttpEntity를 사용해도 가능하다.

 

@ResponseBody
@PostMapping("/request-body-json-v5")
public HelloData requestBodyJsonV5(@RequestBody HelloData helloData) {

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

 응답할 때에도 메소드 단에 @ResponseBody를 걸어줌으로써 해당 객체를 HTTP message body에 직접 넣어줄 수 있다. 이 과정에서도 message 컨버터가 동작을 한다.

  • @RequestBody : JSON 요청 > HTTP message 컨버터가 객체로 변환 > 객체로 받음
  • @ResponseBody : 객체 전달 > HTTP message 컨버터가 JSON으로 변환 > JSON 전달

 

HTTP 응답 - 정적 리소스, 뷰 템플릿

 스프링에서 응답 데이터를 만들어 보내는 방법은 총 3가지다

  • 정적리소스
  • 뷰 템플릿
  • HTTP message(JSON)

 정적리소스는 말그대로 정적인 페이지를 말하며 뷰 템플릿은 JSP같은 동적인 페이지이다.

 

정적리소스

 스프링 부트에서는 클래스 패스의 다음 디렉토리에 있는 정적 리소스를 자동으로 제공한다.

/static, /public, /resources, /META-INF/resources

 /resources의 경우 리소스를 보관하는 곳이며 또한 클래스패스의 시작 경로이다. 따라서 해당 디렉토리에 넣어두면 부트가 정적 리소스로 서비스를 제공한다.

 예를들어 src/main/resources/static/basic/hello.html에 파일이 들어있으면 http://localhost:8080/basic/hello.html로 실행할 수 있다. 즉, 파일의 변경없이 정적인 파일을 그대로 서비스하는 방법이다.

 

뷰 템플릿

 뷰 템플릿을 거쳐서 HTML이 생성되고 뷰가 응답을 만들어서 전달하는 방식이다. 주로 방금전 정적이던 HTML을 동적으로 생성하는 용도로 사용한다.

 부트는 뷰 템플릿 또한 경로로 제공한다.

  • src/main/resources/templates
<!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>
package hello.springmvc.basic.response;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

@Controller
public class ResponseViewController {

    @RequestMapping("/response-view-v1")
    public ModelAndView responseViewV1() {
        ModelAndView mav = new ModelAndView("response/hello")
                .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");
    }
}
ModelAndView 객체 반환

 ModelAndView 객체 내에 View 경로와 Model을 담아서 그대로 전송하는 방법이다.

String 반환

 @ResponseBody가 없으면 해당 경로로("response/hello") 들어가서 뷰 리졸버를 실행하여 뷰를 찾고 렌더링한다.

 단, @ResponseBody가 있으면 HTTP message body에 "response/hello"를 String으로써 전달한다.

void 반환

 @Controller를 사용하고, Http ServletResponse, OutputStream(Writer) 같은 HTTP message body를 처리하는 파라메터가 없으면 요청된 url을 참고해서 논리 뷰 이름으로 사용한다.

 즉 지금은 ("/response/hello")를 요청 url로 받았기 때문에 이를 뷰 리졸버가 받아 물리 뷰로 전달한다.

 

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

 HTTP API의 형태로 제공하는 경우 HTML이 아니라 데이터의 형태로 제공하며 요즘의 대부분의 API는 JSON 데이터 형태로 많이 보낸다.

 

@GetMapping("/response-body-string-v1")
public void responsBodyV1(HttpServletResponse response) throws IOException {
    response.getWriter().write("ok");
}

 가장 기본적인 서블릿을 사용할 때는 HttpServletResponse 객체를 통해서 HTTP message body에 직접 메시지를 전달한다.

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

ResponseEntity<>는 앞전에 사용했던 HttpEntity를 상속받았는데 HttpEntity는 HTTP message의 헤더와 바디 정보를 담고, 이를 반환할 수 있었는데 ResponseEntity는 HTTP 응답코드까지 전송할 수 있다.

 

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

@ResponseBody를 사용하면 뷰로 사용되지 않고 직접 HTTP message에 담아 전송할 수 있다.

 

@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);
}

ResponseEntity에 객체의 형태로 반환을 한다면 JSON 형태로 변환되어 반환한다.

 

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

@ResponseBody를 사용할 때 객체를 반환하면 해당 객체로 HTTP message 컨버터가 변환을 해서 반환한다.

 단, HTTP 응답 코드를 같이 담아서 보낸 ResponseEntity와는 다르게 @ResponseStatus 어노테이션을 사용해서 응답 코드를 전달 할 수 있다 . 단, 동적으로는 변환할 수 없기 때문에 프로그램 조건에 따라서 응답 코드를 동적으로 전달하고 싶다면 ResponseEntity를 사용해야 한다.

 

@RestController vs @Controller

 이 둘의 차이는 단순히 뷰를 반환하느냐 아니냐의 차이이다.

 @ResponseBody를 사용하면 @Controller가 뷰를 반환하는 것이 아니라 HTTP message body에 데이터를 그대로 넣는다고했다. 즉 @RestController는 이 둘을 합친 것으로 코드를 보면 단순하게 이해할 수 있다.


 HTTP message body에 데이터를 입력하여 반환하는 말 그대로 Rest API를 만들 때 사용하는 컨트롤러이다.

 

 

HTTP 메시지 컨버터

 지금까지 HTTP message body 내부에 JSON 형태로 내보내거나 JSON 형태로 받을 때 HTTP message 컨버터를 이용해서 변환해주었다. 그럼 이 Http 메시지 컨버터가 어떻게 동작하는지 알아보자.

 

 @ResponseBody를 사용한다면 HTTP message body에 문자 내용을 그대로 반환하므로 viewResolver대신 HttpMessageConverter가 동작했었다. 스프링은 이 외에도 다음과 같은 경우 HTTP 메시지 컨버터를 사용한다.

  • HTTP request : @RequestBody, HttpEntity(RequestEntity)
  • HTTP response : @ResponseBody, HttpEntity(ResponseEntity)

 

 HttpMessageConverter는 요청과 응답이 들어왔을 때 이를 어떻게 사용해야할지 체크를 하는데

  • canRead() , canWrite() 를 통해서 메시지 컨버터가 해당 클래스, 미디어 타입을 지원할 수 있는지 체크하고
  • read(), write()를 통해서 메시지를 읽고쓴다.

 

 부트에서는 기본적으로 다양한 메시지 컨버터를 제공하는데 앞전에 스프링MVC 구조에서 url 요청이 들어왔을 때 HandlerMapping과 HandlerAdapter를 찾을 때 우선순위가 있던 것처럼 메시지 컨버터도 우선순위를 통해서 먼저 검색한다.

 

0 = ByteArrayHttpMessageConverter
1 = StringHttpMessageConverter
2 = MappingJackson2HttpMEssageConverter
...

 물론 모든 컨버터가 HttpMessageConverter인터페이스를 상속한 클래스이다.

 각각의 주요 내용을 알아보면

  • ByteArrayHttpMessageConverter : byte[] 데이터 처리
    • 클래스 타입 : byte[], 미디어 타입 : */*
    • 요청) @RequestBody byte[] data
    • 응답) @ResponseBody, return byte[], 미디어 타입 : application/octet-stream
  • StringHttpMessageConverter : String 데이터 처리
    • 클래스 타입 : String, 미디어 타입 : */*
    • 요청) @RequestBody String data
    • 응답) @ResponseBody, return String, 미디어 타입 : text/plain
  • MappingJackson2HttpMessageConverter : Json 데이터 처리
    • 클래스 타입 : 객체 or HashMap, 미디어 타입 : application/json
    • 요청) @RequestBody HelloData data
    • 응답) @ResponseBody, return data, 미디어 타입 : application/json

 

 각각의 되는 경우와 안되는 경우를 알아보자

 

StringHttpMessageConverter
content-type : application/json

@RequestMapping
void hello(@ReqeustBody String data) {}

 이 경우 byte는 아니기 때문에 String 컨버터로 넘어온다. 클래스 타입이 String이고 content-type이 json으로 들어왔지만 String 컨버터는 모든 미디어 타입을 허용하기 때문에 String 컨버터가 동작한다.

 

MappingJackson2HttpMessageConverter
content-type : application/json

@RequestMapping
void hello(@RequestBody HelloData data) {}

 byte도 아니고 클래스타입이 객체이기 때문에 String 컨버터도 아니다. 따라서 content-type이 json인 덕분에 json 컨버터가 동작한다.

 

?
content-type : text/html

@ReqeustMapping
void hello(@ReqeustBody HelloData data) {}

 byte도 아니고 클래스타입이 객체이기 때문에 String 컨버터도 아니다. 또한 content-type이 json이 아니기 때문에 json 컨버터도 아니다. 따라서 올바른 값을 받아내지 못한다.

 

 HTTP 컨버터가 동작하는 과정을 정리하면 다음과 같다.

HTTP Request 읽기
  • HTTP 요청이 오면 컨트롤러에서 @ReuqestBody, HttpEntity 파라미터를 사용한다.
  • 메시지 컨버터가 해당 메시지를 읽을 수 있는지 어떤 컨버터를 사용해야하는지 canRead()로 확인한다.
  • canRead()조건을 만족하는 컨버터를 가져와서 read()를 호출해 객체를 생성하고 반환한다.
HTTP Response 응답
  • 컨트롤러에서 @ResponseBody, @HttpEntity로 값이 반환된다.
  • 메시지 컨버터가 해당 메시지를 쓸 수 있는지 어떤 컨버터를 사용해야하는지 canWrite()로 확인한다.
  • canWrite()조건을 만족하는 컨버터를 가져와서 @write()를 호출해 HTTP message body에 데이터를 생성해 반환한다.

 

* 김영한님의 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술을 듣고 기록한 학습 자료입니다.

댓글
최근에 올라온 글
Total
Today
Yesterday