728x90
반응형
SMALL

참고자료:

 

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

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

www.inflearn.com

이제 기능적으로 알아두면 좋을 것들 몇가지를 작성해보고자 한다. 

 

@ModelAttribute

Part.2에서 잠시 다뤘던 적이 있는 이 @ModelAttribute는 이런 기능들을 대신해준다.

다음 코드를 보자.

@PostMapping("/add")
public String save(@ModelAttribute Item item) {
    itemRepository.save(item);
    return "basic/item";
}

 

이 코드에 대해 조금 설명을 하자면, 어떤 상품 등록을 하는 폼이 있고 그 폼에서 상품 등록 버튼을 클릭하면 호출되는 @PostMapping이다. 그럼 상품 등록을 폼으로 한다고 하면 폼으로부터 전달되는 데이터가 있을 것인데 그 데이터가 저 @ModelAttribute로 담기게 된다.

그리고 타입은 Item 이라는 클래스인데 다음과 같이 생겼다.

Item

package hello.itemservice.domain.item;

import lombok.Data;

@Data
public class Item {
    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

그래서 폼으로부터 들어오는 데이터가 이 클래스의 필드로 다 만족을 하면 스프링 MVC는 자동으로 폼으로 들어오는 데이터를 저렇게 바인딩 할 수 있다. 그래서 정확히 저 코드는 사실 이렇게 생긴것이다.

@PostMapping("/add")
public String save(@RequestParam String itemName,
                   @RequestParam Integer price,
                   @RequestParam Integer quantity,
                   Model model) {
    // @ModelAttribute가 대신 해주는 작업                   
    Item item = new Item();
    item.setItemName(itemName);
    item.setPrice(price);
    item.setQuantity(quantity);
    model.addAttribute("item", item);
    // @ModelAttribute가 대신 해주는 작업 끝

    itemRepository.save(item);
    return "basic/item";
}

 

"어? Item 객체를 만들어서 데이터를 넣어주는 것까진 알겠는데 model.addAttribute("item", item); 까지 해준다고?!" 그렇다. 저 @ModelAttributeModel 객체에 데이터를 담아주는 것까지 해준다. 그리고 그때 key값은 클래스의 앞글자만 소문자로 바꾼 형태가 된다. (Itemitem)

 

Redirect

이번엔 리다이렉트를 하는 방법이다. 간단하다.

@PostMapping("/{itemId}/edit")
public String edit(@ModelAttribute Item item, @PathVariable Long itemId) {
    itemRepository.update(itemId, item);
    return "redirect:/basic/items/{itemId}";
}

 

저렇게 "redirect:/redirect할 경로" 를 입력하면 된다. 그리고 리다이렉트 할 경로에 PathVariable이 있는 경우 코드처럼 문자열로 {pathVariable}를 입력하면 된다. 그럼 스프링이 알아서 이 메서드의 @PathVariable에 있는 값을 매핑시켜준다.

 

당연히 이렇게도 가능하다. 오히려 @PathVariable을 받지 않는 메서드에서 itemId가 있어야 하는 경로로 리다이렉트 할 땐 아래 코드처럼만 해야한다.

@PostMapping("/add")
public String save(@ModelAttribute Item item) {
    itemRepository.save(item);
    return "redirect:/basic/items/" + item.getId();
}

 

근데 사실 좀 짜치는것도 있고, 더 큰 문제는 URL은 모든게 다 문자열로 이루어져 있다. 그래서 인코딩이 필수다. 숫자 같은 건 괜찮은데 한글이 만약 들어간다? 바로 깨진다. 그래서 인코딩이 필수이고 그 방법이 RedirectAttribute라는 게 있다.

@PostMapping("/add")
public String save(@ModelAttribute Item item, RedirectAttributes redirectAttributes) {
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);

    return "redirect:/basic/items/{itemId}";
}

이렇게 파라미터로 RedirectAttributes를 받으면 이 녀석을 사용할 수가 있는데, addAttribute()로 원하는 key/value를 넣으면 그 key를 가지고 {PathVariable}을 사용할 수가 있다. 

 

그리고 "status"라는 키도 있는데 이렇게 addAttribute()key/value를 저장하고 PathVariable로 사용하지 않는건 쿼리 파라미터로 들어간다. 그리고 이 키는 저장이 잘 됐다면 리다이렉트된 화면에서 뭔가 잘 저장됐다는 표시를 보여주고 싶어서 플래그를 사용했다고 생각하면 된다. 그리고 그 플래그를 Thymeleaf랑 같이 사용할 때 이렇게 param라는 키로 받을수가 있다.

<h2 th:if="${param.status}" th:text="'저장 완료'"></h2>

 

이런 방식이 훨씬 더 깔끔하고 인코딩도 다 해주기 때문에 더 좋은 접근방법이다. 이렇게 사용하자.

 

 

PRG - Post/Redirect/Get

이건 실무에서도 자주 사용되는 방식인데, 폼을 통해 POST 요청을 하고 보여지는 화면에서 사용자가 새로 고침을 누르면 POST 요청이 계속 들어간다. 그런 경우에 POST 요청이 계속 들어오면 만약 그게 상품 저장 기능이었다면 새로 고침한만큼 상품 저장이 되는 문제가 발생한다. 그 것을 방지하기 위해 폼을 통해 POST 요청을 처리하는 컨트롤러에서는 그 요청의 반환으로 Redirect를 해서 GET으로 최종 목적지를 변경해줘야 한다.

@PostMapping("/add")
public String save(@ModelAttribute Item item, RedirectAttributes redirectAttributes) {
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);

    return "redirect:/basic/items/{itemId}";
}

 

그래서 위 코드도 POST로 요청이 들어온 상품 저장 기능에 반환으로 리다이렉트를 통해 저장된 상품의 상세 목록으로 페이지를 이동시킨다. 그래야 사용자는 저장한 후 보여지는 화면에서 새로고침을 눌러도 POST 요청이 계속 발생하지 않는다. 그러니까 새로고침은 가장 마지막에 한 행위를 다시 하는것이다. 그래서 새로고침을 누르더라도 POST 요청이 다시 일어나지 않도록 리다이렉트로 마지막에 요청한 행위는 그저 상품 상세 화면을 보고 있는 GET 요청으로 바꿔줘야 한다. 

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

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

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

www.inflearn.com

이제 Spring MVC는 어떤걸 편리하게 해주고 어떤 효율성이 있는지 하나씩 파악해보자. 우선 그 전에 Welcome 페이지가 하나 있으면 편리할 거 같아서 Welcome 페이지를 만들자. 근데! Welcome 페이지를 만들기 전에 프로젝트가 있어야 한다. 스프링 프로젝트를 만들자.

 

https://start.spring.io

여기로 가서 스프링 프로젝트를 만들면 된다. DependenciesLombok, Spring Web, Thymeleaf 세 가지를 선택하자.

참고로, src/main/resources/static/index.html 경로에 있는 index.html 파일이 스프링 웹의 기본 Welcome 페이지가 된다.

 

src/main/resources/static/index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<ul>
    <li>로그 출력
        <ul>
            <li><a href="/log-test">로그 테스트</a></li>
        </ul>
    </li>
    <!-- -->
    <li>요청 매핑
        <ul>
            <li><a href="/hello-basic">hello-basic</a></li>
            <li><a href="/mapping-get-v1">HTTP 메서드 매핑</a></li>
            <li><a href="/mapping-get-v2">HTTP 메서드 매핑 축약</a></li>
            <li><a href="/mapping/userA">경로 변수</a></li>
            <li><a href="/mapping/users/userA/orders/100">경로 변수 다중</a></li>
            <li><a href="/mapping-param?mode=debug">특정 파라미터 조건 매핑</a></li>
            <li><a href="/mapping-header">특정 헤더 조건 매핑(POST MAN 필요)</a></li>
            <li><a href="/mapping-consume">미디어 타입 조건 매핑 Content-Type(POST MAN 필요)</a></li>
            <li><a href="/mapping-produce">미디어 타입 조건 매핑 Accept(POST MAN 필요)</a></li>
        </ul>
    </li>
    <li>요청 매핑 - API 예시
        <ul>
            <li>POST MAN 필요</li>
        </ul>
    </li>
    <li>HTTP 요청 기본
        <ul>
            <li><a href="/headers">기본, 헤더 조회</a></li>
        </ul>
    </li>
    <li>HTTP 요청 파라미터
        <ul>
            <li><a href="/request-param-v1?username=hello&age=20">요청 파라미터 v1</a></li>
            <li><a href="/request-param-v2?username=hello&age=20">요청 파라미터 v2</a></li>
            <li><a href="/request-param-v3?username=hello&age=20">요청 파라미터 v3</a></li>
            <li><a href="/request-param-v4?username=hello&age=20">요청 파라미터 v4</a></li>
            <li><a href="/request-param-required?username=hello&age=20">요청 파라미터 필수</a></li>
            <li><a href="/request-param-default?username=hello&age=20">요청 파라미터 기본 값</a></li>
            <li><a href="/request-param-map?username=hello&age=20">요청 파라미터 MAP</a></li>
            <li><a href="/model-attribute-v1?username=hello&age=20">요청 파라미터 @ModelAttribute v1</a></li>
            <li><a href="/model-attribute-v2?username=hello&age=20">요청 파라미터 @ModelAttribute v2</a></li>
        </ul>
    </li>
    <li>HTTP 요청 메시지
        <ul>
            <li>POST MAN</li>
        </ul>
    </li>
    <li>HTTP 응답 - 정적 리소스, 뷰 템플릿
        <ul>
            <li><a href="/basic/hello-form.html">정적 리소스</a></li>
            <li><a href="/response-view-v1">뷰 템플릿 v1</a></li>
            <li><a href="/response-view-v2">뷰 템플릿 v2</a></li>
        </ul>
    </li>
    <li>HTTP 응답 - HTTP API, 메시지 바디에 직접 입력
        <ul>
            <li><a href="/response-body-string-v1">HTTP API String v1</a></li>
            <li><a href="/response-body-string-v2">HTTP API String v2</a></li>
            <li><a href="/response-body-string-v3">HTTP API String v3</a></li>
            <li><a href="/response-body-json-v1">HTTP API Json v1</a></li>
            <li><a href="/response-body-json-v2">HTTP API Json v2</a></li>
        </ul>
    </li>
</ul>
</body>
</html>

 

이렇게 파일 하나를 만들어 두고 서버를 재실행한 후 `localhost:8080`으로 접속하면 이 페이지가 보일것이다.

이제 요청 매핑부터 하나하나 살펴보자.

 

요청 매핑

우선 아주 간단한 요청 매핑 하나를 만들어보자.

MappingController

package net.cwchoiit.springmvc.requestmapping;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class MappingController {

    @RequestMapping("/hello-basic")
    public String hello() {
        log.debug("hello!");
        return "Hello Basic";
    }
}

 

여기서 @RestController@Controller랑 무엇이 다르냐? @Controller는 메서드의 반환값이 String이면 해당 값이 뷰의 이름이 된다. 그래서 해당 이름으로 된 뷰를 찾아 뷰가 렌더링된다. 근데 @RestController를 사용하면 반환값으로 뷰를 찾는게 아니라 HTTP 메시지 바디에 바로 입력한다. 따라서 실행 결과로 "Hello Basic"이라는 메시지를 응답으로 받을 수가 있다. 이게 @ResponseBody와 관련이 있는데 뒤에 더 자세히 설명한다.

 

@RequestMapping("/hello-basic")은 URL이 `/hello-basic`으로 요청이 들어오면 이 메서드가 실행되도록 한다. 

참고로, `/hello-basic``/hello-basic/`은 다른 URL이다. 그러므로 당연히 스프링도 서로 다르게 인식한다.

 

그리고 이렇게 @RequestMapping으로만 해놓으면 GET, POST, PUT, DELETE 다 가능하다. 그래서 이를 딱 지정하기 위해 다음과 같은 애노테이션을 사용할 수 있다.

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

 

@GetMapping 애노테이션을 사용하면, GET Method만 허용한다.

이렇게 사용하면 되는데 그리고 99%는 이렇게만 사용할텐데 아래같이 사용해도 가능하긴 하다.

@RequestMapping(value = "/mapping-get", method = RequestMethod.GET)
public String mappingGet() {
    log.debug("mapping GET");
    return "Mapping GET";
}

 

실제로 GET만 받아들이는지 Postman으로 테스트 해보는 것을 추천!

 

PathVariable

그리고 이제 중요한 것, PathVariable이다. 다음 코드를 보자.

@GetMapping("/mapping/{userId}")
public String mappingGet(@PathVariable("userId") String id) {
    log.debug("path variable userId: {}", id);
    return "ok";
}

URL 형식에 `{userId}` 이렇게 중괄호가 있으면 이게 PathVariable이다. 즉, URL로부터 특정 값을 userId라는 키로 받아온다는 의미가 된다. 그래서 만약 `/mapping/userA` 이렇게 요청했다고 하면 userIduserA가 된다. 그리고 파라미터 이름이 PathVariable과 같다면 다음과 같이 더 축약할 수 있다.

@GetMapping("/mapping/{userId}")
public String mappingGet(@PathVariable String userId) {
    log.debug("path variable userId: {}", userId);
    return "ok";
}

 

특정 파라미터, 헤더 조건 매핑

잘 사용되지는 않는데 이런게 가능하다.

@GetMapping(value = "/mapping-param", params = "mode=debug")
public String mappingParam() {
    log.debug("mapping param");
    return "ok";
}

이건 뭐냐면, queryString으로 mode=debug 라는 Key/Value가 반드시 있어야 동작하는 요청이 된다.

그래서 이렇게 `/mapping-param?mode=debug` 요청해야만 한다. 솔직히 잘 사용되지는 않는다.

 

만약 `/mapping-param`으로만 요청하면 400 Bad Request로 응답받는다. 

이걸 헤더로도 조건을 걸 수 있다. 이 또한 자주 사용되는 건 아니다.

@GetMapping(value = "/mapping-header", headers = "mode=debug")
public String mappingHeader() {
    log.debug("mapping header");
    return "ok";
}

이건 헤더에 Key/Value로 mode/debug가 들어있어야 함을 의미한다.

 

미디어 타입 조건 매핑

이건 조금 중요한데, Consume, Produce라는 게 있다. 

  • Consume - 요청 시 Content-Type을 지정한다.
  • Produce - 요청 시 Accept를 지정한다.

다음 코드를 보자.

@GetMapping(value = "/mapping-consume", consumes = MediaType.APPLICATION_JSON_VALUE)
public String mappingConsume() {
    log.debug("mapping consume");
    return "ok";
}

이런 요청 매핑이 있으면, 요청 시 반드시 Content-Type"application/json" 이어야 한다. 그니까 요청할 때 보내는 데이터의 타입을 JSON으로 지정하는 건데 그렇지 않으면 이 요청을 서버는 받아들이지 않겠다는 의미가 된다.

 

이번엔 Produce 관련 코드를 보자.

@GetMapping(value = "/mapping-produce", produces = MediaType.APPLICATION_JSON_VALUE)
public String mappingProduce() {
    log.debug("mapping produce");
    return "ok";
}

이런 요청 매핑이 있으면, 요청 시 반드시 Accept"application/json" 이어야 한다. 그니까 요청할 때 받는 응답 데이터의 타입을 JSON으로 지정하는 것이다. 그렇지 않으면 이 요청을 서버는 받아들이지 않겠다는 의미가 된다.

 

그래서 실제 API를 호출하고 만드는 어떤 관례(?)는 이런식으로 만들어진다.

  • 회원 목록 조회: GET `/users`
  • 회원 등록: POST `/users`
  • 회원 조회: GET `/users/{userId}`
  • 회원 수정: PATCH 또는 PUT `/users/{userId}`
  • 회원 삭제 DELETE `/users/{userId}`

MappingClassController

package net.cwchoiit.springmvc.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 user " + userId;
    }

    @PatchMapping("{userId}")
    public String updateUser(@PathVariable String userId) {
        return "patch user " + userId;
    }

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

 

깔끔한 요청 URL 방식이라고 볼 수 있고 대부분의 API는 이런 요청 URL를 따른다.

 

HTTP 요청 헤더 조회

이제 헤더 정보를 조회하는 방법을 알아보자. 굉장히 간단하고 편리하게 가져올 수 있다. 

 

RequestHeaderController

package net.cwchoiit.springmvc.request;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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 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> headers,
                          @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("headers = {}", headers);
        log.info("host = {}", host);
        log.info("cookie = {}", cookie);
        return "Ok";
    }
}

 

위 코드를 보면 파라미터에서 굉장히 이것 저것 많이 받을 수 있게 되어 있다. HttpServletRequest, HttpServletResponse, HttpMethod, Locale, @RequestHeader, @RequestHeader("host"), @CookieValue까지.

 

@RequestHeaderMultiValueMap으로 가져오는 경우는 헤더 정보 전체를 가져오는 것이다. 근데 왜 MultiValueMap이냐? 그나저나 MultiValueMap은 뭘까? 이건 원래 Map은 키가 유일무이 해야 한다. 근데 헤더는 같은키로 여러 데이터가 들어올 수 있다. 그래서 같은 키라고 할지라도 그 값들 모두 다 가져올 수 있는 방법인 MultiValueMap을 사용한다. 

 

@RequestHeader("host") 이건 딱 host 정보 하나를 가져오는 것이다.

@CookieValue 이건 말 그대로 쿠키값을 가져온다.

 

실제로 실행해서 요청해보면 다음과 같은 결과를 받는다.

2024-07-12T14:13:50.285+09:00  INFO 3865 --- [springmvc] [nio-8080-exec-2] n.c.s.request.RequestHeaderController    : request = org.apache.catalina.connector.RequestFacade@301c5b77
2024-07-12T14:13:50.286+09:00  INFO 3865 --- [springmvc] [nio-8080-exec-2] n.c.s.request.RequestHeaderController    : response = org.springframework.web.context.request.async.StandardServletAsyncWebRequest$LifecycleHttpServletResponse@f7b8612b
2024-07-12T14:13:50.286+09:00  INFO 3865 --- [springmvc] [nio-8080-exec-2] n.c.s.request.RequestHeaderController    : httpMethod = DELETE
2024-07-12T14:13:50.286+09:00  INFO 3865 --- [springmvc] [nio-8080-exec-2] n.c.s.request.RequestHeaderController    : locale = en_US
2024-07-12T14:13:50.286+09:00  INFO 3865 --- [springmvc] [nio-8080-exec-2] n.c.s.request.RequestHeaderController    : headers = {accept=[application/json], content-type=[application/json], user-agent=[PostmanRuntime/7.40.0], postman-token=[a466c2f2-6b13-49b0-8099-52a3aa66ab4b], host=[localhost:8080], accept-encoding=[gzip, deflate, br], connection=[keep-alive], content-length=[21]}
2024-07-12T14:13:50.286+09:00  INFO 3865 --- [springmvc] [nio-8080-exec-2] n.c.s.request.RequestHeaderController    : host = localhost:8080
2024-07-12T14:13:50.286+09:00  INFO 3865 --- [springmvc] [nio-8080-exec-2] n.c.s.request.RequestHeaderController    : cookie = null

 

참고로, @Controller의 사용 가능한 파라미터 목록은 아래 공식 매뉴얼에서 모두 확인 가능하다.  
 

Annotated Controllers :: Spring Framework

Spring MVC provides an annotation-based programming model where @Controller and @RestController components use annotations to express request mappings, request input, exception handling, and more. Annotated controllers have flexible method signatures and d

docs.spring.io

 

HTTP 요청 파라미터 - 쿼리 파라미터, HTML Form

이번엔 HTTP 요청 시 전송하는 쿼리 파라미터, HTML Form으로 전송하는 데이터를 어떻게 받는지 알아볼 차례다.

우선 가장 기본적인 서블릿에서 어떻게 받는지 다시 상기시키자.

 

RequestParamController

package net.cwchoiit.springmvc.request;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;

@Slf4j
@RestController
public class RequestParamController {

    @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("username = {}, age = {}", username, age);

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

서블릿은 이렇게 파라미터로 HttpServletRequest, HttpServletResponse 객체를 받을 수 있고, 그 객체를 통해 데이터를 받아올 수 있다. 테스트 해보면 아주 잘 받아온다.

HTTP 요청 - 쿼리 파라미터
받은 데이터 로그로 찍은 결과

 

근데 이렇게 전송할 때 전달하는 데이터의 생김새가 HTML Form이나 쿼리 파라미터나 동일하다.

`username=cwchoi&age=10` 둘 다 이렇게 들어오기 때문에 동일한 방식으로 데이터를 받을 수가 있다.

 

실제로 그런지 HTML Form을 만들어보자. 간단하게 `src/main/resources/static` 이 경로에 만들자. 이 경로는 스프링 부트는 기본이 다 외부로 내보내게 되어 있는 파일들만 모아놓은 곳이기 때문에 그냥 여기에 파일을 만들면 기본적으로 외부에서 접근이 가능하다.

 

`src/main/resources/static/basic/hello-form.html`

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/request-param-v1" method="post">
    username: <input type="text" name="username" />
    age:      <input type="text" name="age" />
    <button type="submit">전송</button>
</form>
</body>
</html>

 

이렇게 간단히 만들고 해당 경로에 가서 데이터를 전송해보자.

그럼 동일하게 데이터를 받아오는 것을 볼 수 있다.

 

그래서, HTML Form 방식이나 쿼리 파라미터나 같은 방법으로 받아올 수가 있다는 것을 다시 한번 상기했고, 서블릿은 이런식으로 데이터를 받을 수 있다는것도 상기했다. 이 방식을 스프링은 어떻게 더 편리하게 받을 수 있게 해줄까?

 

HTTP 요청 파라미터 - @RequestParam

스프링이 제공하는 이 애노테이션으로 아주 편리하게 데이터를 받아올 수 있다.

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

원하는 쿼리 파라미터 또는 HTML Form 데이터의 키를 @RequestParam("") 안에 적는다. 그럼 그 값을 찾아서 변수에 넣어준다.

 

근데, 만약 변수명과 쿼리 파라미터의 키가 같은 이름으로 된다면 생략 가능하다. 다음 코드처럼.

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

 

둘 다 아주 잘 받아온다. 그나저나, 저 @ResponseBody 애노테이션은 뭐냐면 만약 클래스 레벨에 @RestController가 아니라 @Controller라면 메서드가 String 타입으로 반환하는 메서드일때 기본적으로 뷰의 이름으로 판단하고 뷰를 찾는다.

 

그래서 스프링한테 알려주는 것이다. "이건 뷰의 이름이 아니라 너가 응답 메시지로 반환할 값이야!"라고. 

근데 귀찮게 애노테이션을 하나 더 붙이기가 싫다면 그냥 @RestController를 사용하면 된다.

 

다시 돌아와서, 사람의 욕심은 끝이 없어서 @RequestParam 마저도 생략할 수 있다. 다음 코드처럼.

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

물론 이 경우도 당연히 파라미터 이름과 쿼리 파라미터의 키가 동일한 이름이어야 한다.

근데, 나는 @RequestParam 애노테이션을 붙이는 걸 선호한다. 그래야 한 눈에 바로 파악이 쉽기 때문에.

 

그런데 이 쿼리 파라미터는 기본적으로 무조건 있어야 동작한다. 요청하는 쪽에서 보내지 않으면 에러 화면이 보일것이다. 근데 이 필수라는 옵션을 변경할 수도 있다.

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

이 코드처럼 "required = false"를 주게 되면 이 쿼리 파라미터를 요청 시 던지지 않아도 동작한다. 근데 주의할 점이 있다. `age`와 같이 Primitive Type인 경우 `null`이라는 값을 받을 수 없기 때문에 쿼리 파라미터를 주지 않으면 에러가 발생한다. 그래서 `age`가 필수값이 아니고 싶다면 Integer로 받아야 한다. 아래처럼.

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

 

또 한가지 주의할 점은 null""는 다르다.

"엥? 당연한거 아닌가요?" 당연한건데 URL에 `/request-param-required?username=` 이렇게 입력하면 null로 받는게 아니라 ""로 받아 온다. 그래서 이런 점도 염두하고 있어야 한다. 

 

그리고, 기본값을 설정할 수 있다. 다음 코드를 보자.

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

defaultValue로 기본값을 설정하면 null 처리에 대한 걱정없이 그냥 Primitive Type으로 파라미터를 선언할 수 있고, 사실상 `required = false` 를 입력할 필요도 없다. 없으면 그냥 기본값이 적용될테니까.

 

또한, 파라미터를 Map으로 받을수도 있다.

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

뭐 파라미터 하나하나 다 나열하기 귀찮다하면 이렇게 Map으로 받아오면 된다. 근데 난 개인적으로 좋아하지 않는다.

 

그리고 더 나아가서 MultiValueMap도 사용할 수 있다. 만약, 쿼리 파라미터의 키가 같은데 값이 여러개가 들어갈 수 있는 경우 MultiValueMap을 사용해야 한다. 이런 URL : `/request-param-map?userId=1&userId=2` 근데 이런 경우는 거의 없다.

@ResponseBody
@RequestMapping("/request-param-mulitivaluemap")
public String requestParamMultiValueMap(@RequestParam MultiValueMap<String, Object> paramMap) {
    List<Object> username = paramMap.get("username");
    log.info("username = {}", username.toString());
    return "ok";
}

 

이런식으로 요청 시 쿼리 파라미터와 HTML Form 데이터를 간단하게 받아올 수 있다. (아직 바디에 JSON으로 데이터 보내는 경우는 어떤식으로 처리하는지 작성 안했다) 그럼 이렇게 받아온 데이터를 실제 업무에서는 당연히 객체로 변환하고 사용할 것이다 일반적으로. 그런 경우에 어떻게 하면 될까? 스프링이 이것도 간단하게 도와준다. 

 

HTTP 요청 파라미터 - @ModelAttribute

실제 개발을 하면 요청 파라미터를 받아서 필요한 객체를 만들고 그 객체에 값을 넣어주어야 한다. 우선 DTO를 먼저 만들어보고 말해보자.

HelloData

package net.cwchoiit.springmvc.basic;

import lombok.*;

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

 

가장 단순하고 원초적인 방법은 이렇게 사용할 것이다. 

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

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

    return "ok";
}

@RequestParam을 통해 요청 파라미터를 받아서 DTO 객체를 만들어서 넣는다.

 

근데 스프링은 이 과정 자체를 자동화 해준다.

@ResponseBody
@RequestMapping("/model-attribute-v1-2")
public String modelAttributeV1(@ModelAttribute HelloData helloData) {
    log.info("helloData = {}", helloData);

    return "ok";
}

이렇게 @ModelAttribute 라는 애노테이션으로 파라미터로 받는 데이터를 객체에 알아서 넣어준다.

그리고 더 나아가 @RequestParam처럼 이것도 생략이 가능하다.

@ResponseBody
@RequestMapping("/model-attribute-v2")
public String modelAttributeV2(HelloData helloData) {
    log.info("helloData = {}", helloData);

    return "ok";
}

 

그럼 헷갈릴 수 있다. 어떤게 @RequestParam을 생략한거고 어떤게 @ModelAttribute를 생략한 것인지. 스프링은 Primitive Type, Primitive TypeWrapper(String, Integer, ..) 클래스를 파라미터로 받을땐 @RequestParam을 생략한다고 간주한다. 그리고 직접 만든 객체처럼 HelloData이런 것들을 @ModelAttribute로 간주한다. 

주의할 점은, 요청에서 파라미터를 받았을 때 파라미터가 username, age라면 이 프로퍼티의 Setter를 먼저 찾는다. 만약 Setter가 없다면 final 필드로 된 전체 파라미터를 받는 생성자를 찾는다. 이 두가지가 다 없으면 바인딩 되지 않는다. 

 

또한, 위에서 말한것처럼 HelloData와 같은 직접 만든 객체를 @ModelAttribute로 간주한다 했는데 여기서 Argument Resolver는 제외이다. Argument Resolver라는 건 예를 들어 HttpServletRequest, HttpServletResponse 이런것들을 말한다. 

 

지금까지는 요청 파라미터를 받는 방법에 대해 알아보았다. 이제 파라미터가 아니라 메시지(요청 바디에 넣는 데이터)는 어떻게 처리하는지 알아보자.

 

HTTP 요청 메시지

메시지는 크게 세 가지로 받을 수 있다.

  • 단순 텍스트
  • JSON
  • XML

XML은 요즘은 거의 사용하는 추세가 아니기 때문에 따로 다루지 않는다. 그럼 요청 시 바디에 단순 텍스트 또는 JSON을 던져서 보낼 때 어떻게 받는지 하나씩 알아보자.

 

단순 텍스트

서블릿에서 했던것을 기억해보면 뭐 별게 없다. 다음 코드처럼 받을 수 있다.

@PostMapping("/request-body-string-v1")
public void requestBodyStringV1(HttpServletRequest req, HttpServletResponse res) throws IOException {

    ServletInputStream inputStream = req.getInputStream();
    String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

    log.info("messageBody: {}", messageBody);
    res.getWriter().write("ok");
}

HttpServletRequest, HttpServletResponse 객체를 파라미터로 받아서, InputStream을 얻어온다. 그리고 그 안에 있는 데이터를 String으로 받아오면 끝난다. 근데 스프링이 파라미터로 받을 수 있는 것들 중에 InputStreamWriter가 있다. 굳이 HttpServletRequest, HttpServletResponse 전체를 다 받을 필요 없이 딱 필요한것만 받는 방법이 된다.

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

    String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

    log.info("messageBody: {}", messageBody);
    writer.write("ok");
}

 

스프링이 파라미터로 받을 수 있는 것들은 이 공식 문서를 참고해보자.

 

Method Arguments :: Spring Framework

JDK 8’s java.util.Optional is supported as a method argument in combination with annotations that have a required attribute (for example, @RequestParam, @RequestHeader, and others) and is equivalent to required=false.

docs.spring.io

 

근데 당연히 여기서 끝날 스프링이 아니다. 그냥 이 과정 자체를 아예 자동화 해준다. 그래서 무엇을 받을 수 있냐? 다음과 같이 HttpEntity를 받을 수 있다.

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

    String body = httpEntity.getBody();

    log.info("body: {}", body);
    return new HttpEntity<>("ok");
}

내가 HttpEntity<String> 이라고 선언을 하면 자동으로 바디에 있는 값을 String으로 변환해서 넣어준다.

그리고 반환도 마찬가지로 HttpEntity<String>이라고 반환을 하면 응답 메시지에 내가 넣을 데이터를 문자열로 받아 반환해준다.

 

그리고 이 HttpEntity를 상속받는 좀 더 구체적인 객체가 있다. 바로 RequestEntity, ResponseEntity

@PostMapping("/request-body-string-v3-2")
public ResponseEntity<String> requestBodyStringV3_2(RequestEntity<String> requestEntity) {
    String body = requestEntity.getBody();
    log.info("body: {}", body);

    return new ResponseEntity<>("ok", HttpStatus.CREATED);
}

ResponseEntity는 이렇게 상태 코드도 넣어줄 수 있다.

그러나, 애노테이션 기반이 대세로 자리잡은 지금 당연히 이것도 애노테이션이 있다.

@PostMapping("/request-body-string-v4")
public ResponseEntity<String> requestBodyStringV4(@RequestBody String messageBody) {
    log.info("body: {}", messageBody);

    return new ResponseEntity<>("ok", HttpStatus.CREATED);
}

이렇게 @RequestBody라고 해주면 끝난다. 알아서 요청 바디의 데이터를 내가 선언한 타입(String)으로 변환해서 넣어준다.

이 방식이 가장 많이 사용되고 실제로 편리한 방식이다.

참고로 @RequestBody@RequestParam, @ModelAttribute와는 아무런 관련이 없다. @RequestParam, @ModelAttribute는 요청 파라미터를 받아오는 방법들 중 하나이다. 반면, @RequestBody 요청 바디를 받아오는 방법이다. 절대 구분!

 

이렇게 요청 바디의 단순 메시지를 받아오는 방법을 알아봤다. 거의 95%는 JSON으로 데이터를 주고 받는다. 그래서 이제 알아볼 JSON을 받아오는 방법에 집중해보자!

 

JSON

우선, 가장 먼저 태초의 상태에서부터 점진적으로 세련된 코드(?)로 나아가보자.

당연히 가장 원초적인것은 서블릿에서 처리했던 방식이다.

 

RequestBodyJsonController

package net.cwchoiit.springmvc.request;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import net.cwchoiit.springmvc.basic.HelloData;
import org.springframework.http.HttpEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseBody;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Slf4j
@Controller
public class RequestBodyJsonController {

    private final 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("helloData: {}", helloData);

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

 

우선 단순 텍스트를 어떤 특정 객체로 변환하기 위해 사용되는 라이브러리인 ObjectMapper를 새로 만들자. 당연히 이 ObjectMapper는 실제 개발에서는 여러 설정이 곁들어진 상태로 빈으로 등록해서 여기저기서 주입되는 방식으로 사용될테지만 지금은 그런 경우는 아니니까.

 

그리고 서블릿에서 했던것처럼 HttpServletRequest, HttpServletResponse 객체를 받아서 InputStream을 가져와서 스트링으로 변환한다. 그리고 변환된 문자열을 ObjectMapper를 통해 객체로 변환한다. 

 

그 다음은 @RequestBody 애노테이션을 사용해서 좀 더 편리하게 문자열로 가져오는 방법이다.

@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);
    log.info("helloData: {}", helloData);

    return "ok";
}

 

근데 굳이 @RequestBody를 문자열로 받지 않아도 될 것 같다. 바로 그 객체에 담아버릴 수 없을까? @ModelAttribute처럼? 당연히 된다. 다음 코드를 보자.

@ResponseBody
@PostMapping("/request-body-json-v3")
public String requestBodyJsonV3(@RequestBody HelloData helloData) {
    log.info("helloData: {}", helloData);

    return "ok";
}

 

이게 이제 가장 최신 방식이고 편리한 방식이다. 그리고 주의할 은 이 @RequestBody를 통해서 요청 바디의 데이터를 특정 객체로 변환할 때는 @RequestBody를 생략할 수 없다! 왜냐하면 이미 @ModelAttribute에서 생략 가능하기 때문에 @RequestBody를 생략해버리면 @ModelAttribute처럼 동작하게 된다. 즉, 파라미터에서 데이터를 찾게 된다는 말이다. 그래서 안된다!

참고로, 이렇게 @RequestBody 애노테이션으로 JSON을 특정 객체로 변환해 주려면 반드시 요청 헤더에 Content-Type`application/json`이어야 한다. 이래야만 이후에 다룰 HTTP 메시지 컨버터가 "아 이 값이 지금 JSON이고 이런 객체로 변환하길 원하는구나!?"로 이해하고 바꿔주기 때문이다.

 

그리고 HttpEntity로도 받을 수 있다. 단순 텍스트가 가능했듯 이 특정 객체도 HttpEntity를 사용해 받아올 수 있다.

@ResponseBody
@PostMapping("/request-body-json-v2-1")
public String requestBodyJsonV2_1(HttpEntity<HelloData> httpEntity) {
    HelloData body = httpEntity.getBody();
    log.info("helloData: {}", body);

    return "ok";
}

근데 그러려면 이렇게 getBody()를 호출해야 하는 번거로움 때문에 거의 사용하지 않는다.

 

그래서 요청 바디에 JSON을 태울 때 어떻게 받아오는지도 알아보았다. 결국 단순 텍스트JSON이든 @RequestBody를 통해 깔끔하고 쉽게 받아올 수가 있다. 이제 요청 관련 처리를 쭉 알아봤으니 응답 관련 처리를 쭉 알아보자! 

 

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

스프링에서 응답 데이터를 만드는 방법은 크게 세가지이다. 

  • 정적 리소스 예) 웹 브라우저에 정적인 HTML, CSS, JS를 제공할 때는 정적 리소스를 사용한다.
  • 뷰 템플릿 예) 웹 브라우저에 동적인 HTML을 제공할 때는 뷰 템플릿을 사용한다.
  • HTTP 메시지 예) HTTP API를 제공하는 경우에는 HTML이 아니라 데이터를 전달해야 하므로, HTTP 메시지 바디에 JSON과 같은 형식으로 데이터를 실어 보낸다

정적 리소스

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

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

`src/main/resources`는 리소스를 보관하는 곳이고, 또 `classpath`의 시작 경로이다.

 

그러므로 이 디렉토리에 리소스를 넣어두면 스프링 부트가 정적 리소스로 서비스를 제공한다.

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

이 경로에 파일이 다음과 같이 들어있으면 `src/main/resources/static/basic/hello-form.html`

웹 브라우저에서 다음과 같이 실행하면 된다. `http://localhost:8080/basic/hello-form.html`

 

정적 리소스는 해당 파일을 변경 없이 그대로 서비스하는 것이다.

뷰 템플릿

뷰 템플릿을 거쳐서 HTML이 생성되고, 뷰가 응답을 만들어서 전달한다.

일반적으로 HTML을 동적으로 생성하는 용도로 사용하지만, 다른 것들도 가능하다. 뷰 템플릿이 만들 수 있는 것이라면 뭐든지 가능하다.

 

스프링 부트는 기본 뷰 템플릿 경로를 제공하고 그 경로는 다음과 같다.

`src/main/resources/templates`

 

뷰 템플릿을 생성해보자. 여기서 뷰 템플릿은 Thymeleaf를 사용한다. 

`src/main/resources/templates/response/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>

 

뷰 템플릿을 호출하는 컨트롤러

ResponseViewController

package net.cwchoiit.springmvc.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 mv = new ModelAndView("response/hello");

        mv.addObject("data", "Hello World");
        return mv;
    }

    @RequestMapping("/response-view-v2")
    public String responseViewV2(Model model) {
        model.addAttribute("data", "Hello World");
        return "response/hello";
    }

    /**
     * 권장하지 않음
     * @param model
     */
    @RequestMapping("/response/hello")
    public void responseViewV3(Model model) {
        model.addAttribute("data", "Hello V3");
    }
}

 

  • ModelAndView를 반환하는 경우 해당 객체에 뷰 논리 이름이 들어가 있고 데이터도 같이 들어가 있다. 
  • 단순 문자열을 반환하는 경우 반환값이 곧 뷰 논리 이름이 된다. 그리고 파라미터로 Model 객체를 받아 모델에 데이터를 추가하면 뷰 템플릿에서 모델에 넣은 키와 일치하는 부분에 알아서 치환이 된다.
  • 세번째 방법은 권장하지 않는다. 반환값이 없는 경우 만약 @RequestMapping("")에 넣은 경로가 templates 경로와 일치하는 경우 그 뷰를 보여주게 되는데 명시성이 너무 떨어지기 때문에 사용하지 않는걸 권장.
참고로, Thymeleaf 스프링 부트 설정은 Thymeleaf 라이브러리를 추가하면 자동으로 스프링 부트에서 해준다. 스프링 부트가 ThymeleafViewResolver와 필요한 스프링 빈들을 등록해준다. 그리고 application.yml 파일에 자동으로 prefix, suffix를 등록해준다.

 

application.properties

spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html

 

물론 실제로 이 파일을 열었을 때 저 두줄이 보이는게 아니라 자동으로 등록해준다는 의미이다. 그리고 저 값을 변경하고 싶을때만 이 파일을 수정하면 된다. 여기서 `classpath``src/main/resources`를 말한다.

 

아마 Thymeleaf와 스프링 부트를 사용하는 것은 이후에도 해 볼 것이기 때문에 이 정도로만 하고 가장 중요한(?) HTTP API에 대한 응답 처리를 알아보자!

 

HTTP 응답 - HTTP API

HTTP API를 제공하는 경우에는 HTML이 아니라 데이터를 전달해야 하므로, HTTP 메시지 바디에 JSON과 같은 형식으로 데이터를 실어 보낸다. 

참고로, HTML이나 뷰 템플릿을 사용해도 HTTP 응답 메시지 바디에 HTML 데이터가 담겨서 전달된다. 여기서 설명하는 내용은 정적 리소스나 뷰 템플릿을 거치지 않고, 직접 HTTP 응답 메시지를 전달하는 경우를 말한다.

 

말로 장황하게 설명할 것 없이 바로 코드로 들어가자.

ResponseBodyController

package net.cwchoiit.springmvc.response;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import net.cwchoiit.springmvc.basic.HelloData;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import java.io.IOException;

@Slf4j
@Controller
public class ResponseBodyController {

    @GetMapping("/response-body-string-v1")
    public void responseBodyStringV1(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.getWriter().write("ok");
    }

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

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

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

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

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

 

단순 스트링의 경우

  • 첫번째 메서드의 경우, 간단하게 서블릿 방식을 사용해서 파라미터로 들어온 HttpServletResponse 객체의 Writer를 사용해서 응답 메시지를 보낸다.
  • 두번째 메서드의 경우, ResponseEntity를 반환타입으로 주면 알아서 HTTP 응답 바디에 메시지를 넣어 보내고 상태 코드도 설정할 수 있다.
  • 세번째 메서드의 경우, 그냥 단순 String을 반환하는 경우 뷰 논리 이름이 기본이지만 @ResponseBody 애노테이션이 붙었기 때문에 반환값 자체가 응답 바디에 넣어지는 값이 된다.

JSON의 경우

  • 네번째 메서드의 경우, 반환값으로 ResponseEntity<HelloData>로 지정했다. 이 말은 응답 메시지에 HelloData 타입의 데이터를 실어 내보내겠다는 의미가 되고 상태 코드도 지정할 수 있게 된다.
  • 다섯번째 메서드의 경우, 반환값이 HelloData이다. 이 경우도 이 HelloData 객체가 응답 메시지에 들어간다. 그러나 이 경우 상태 코드를 지정하지 못하기 때문에 @ResponseStatus(HttpStatus.OK) 애노테이션을 사용해서 상태코드를 추가해준다.

 

근데 이렇게 계속 @ResponseBody 애노테이션을 붙이기가 상당히 귀찮다. 그리고 실제로 개발을 해보면 이렇게 사용하지도 않는다.

@ResponseBody + @Controller가 합쳐진 애노테이션이 바로 @RestController이다.

 

그래서 @RestController가 클래스 레벨에 붙고, 단순 String을 반환하는 메서드가 있다면 이 메서드는 무조건 응답 바디에 반환값을 넣는다는 의미다. 뷰를 찾는게 아니라.

ResponseBodyController - @RestController 사용

package net.cwchoiit.springmvc.response;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import net.cwchoiit.springmvc.basic.HelloData;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;

@Slf4j
@RestController
public class ResponseBodyController {

    @GetMapping("/response-body-string-v1")
    public void responseBodyStringV1(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.getWriter().write("ok");
    }

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

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

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

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

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

 

 

HTTP 메시지 컨버터

이제 HTML을 생성해서 응답하는게 아니라 HTTP API처럼 JSON 데이터를 HTTP 메시지 바디에서 직접 읽거나 쓰는 경우 그래서 그것을 반환할 때 HTTP 메시지 컨버터를 사용하면 편리하다. 원래는 어떻게 했나? 원래는 서블릿 방식으로 HttpServletResponse 객체를 받아와서 response.getWriter().write("OK"); 이런식으로 Writer를 가져왔었다. 이건 쓰기 방식이고 읽을땐? HttpServletRequest 객체를 받아와서 InputStream을 받아와서 데이터를 읽어왔다. 매우매우 불편하다.

 

스프링 MVC는 다음의 경우에 HTTP 메시지 컨버터를 적용한다.

  • HTTP 요청: @RequestBody, HttpEntity(RequestEntity)
  • HTTP 응답: @ResponseBody, HttpEntity(ResponseEntity)

HTTP 메시지 컨버터는 다음과 같이 생겼다.

HttpMessageConverter

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.http.converter;

import java.io.IOException;
import java.util.Collections;
import java.util.List;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;

public interface HttpMessageConverter<T> {
    boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);

    boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);

    List<MediaType> getSupportedMediaTypes();

    default List<MediaType> getSupportedMediaTypes(Class<?> clazz) {
        return !this.canRead(clazz, (MediaType)null) && !this.canWrite(clazz, (MediaType)null) ? Collections.emptyList() : this.getSupportedMediaTypes();
    }

    T read(Class<? extends T> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException;

    void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException;
}

 

  • canRead(), canWrite(): 메시지 컨버터가 해당 클래스, 미디어타입을 지원하는지 체크
  • read(), write(): 메시지 컨버터를 통해서 메시지를 읽고 쓰는 기능

스프링 부트 기본 메시지 컨버터는 다음과 같다.

(일부 생략)

0 = ByteArrayHttpMessageConverter
1 = StringHttpMessageConverter
2 = MappingJackson2HttpMessageConverter

 

스프링 부트는 다양한 메시지 컨버터를 제공하는데, 대상 클래스 타입과 미디어 타입 둘을 체크해서 사용여부를 결정한다. 만약 만족하지 않으면 다음 메시지 컨버터로 우선순위가 넘어간다.

 

몇가지 주요한 메시지 컨버터를 알아보자.

  • ByteArrayHttpMessageConverter: byte[] 데이터를 처리한다.
    • 클래스 타입: byte[], 미디어타입: */*
    • 요청 예) @RequestBody byte[] data
    • 응답 예) @ResponseBody, return byte[]; 쓰기 미디어 타입 application/octet-stream
  • StringHttpMessageConverter: String 문자로 데이터를 처리한다.
    • 클래스 타입: String, 미디어타입: */*
    • 요청 예) @RequestBody String data
    • 응답 예) @ResponseBody, return "ok"; 쓰기 미디어 타입 text/plain
  • MappingJackson2HttpMessageConverter: JSON 관련 데이터 처리
    • 클래스 타입: 객체 또는 HashMap, 미디어 타입: application/json
    • 요청 예) @RequestBody HelloData data
    • 응답 예) @ResponseBody, return data; 쓰기 미디어타입: application/json

 

그러니까, 쉽게 생각해서 이전에 요청 바디의 메시지를 가져올땐 HttpServletRequest 객체를 파라미터로 받아서 객체의 InputStream을 가져왔다. InputStream으로부터 데이터를 꺼내오는 작업을 했었는데 이 작업의 자동화를 해준 애노테이션이 @RequestBody 애노테이션이었다. 그럼 이 @RequestBody 애노테이션이 어떻게 이 작업을 해주는 것인가?에 대한 비밀이 이 HttpMessageConverter가 되는것이다. 

 

HttpMessageConverter의 동작 흐름

그래서 만약 다음 코드가 있다고 했을때,

@ResponseBody
@PostMapping("/request-body-json-v3")
public String requestBodyJsonV3(@RequestBody HelloData helloData) {
    log.info("helloData: {}", helloData);

    return "ok";
}

 

스프링 부트의 HttpMessageConverter는 우선 컨버터 우선순위에 입각해서 하나씩 물어본다. 기본적으로 아래가 우선순위라고 생각하면 된다. 물론 더 많은 컨버터가 있지만 중요한것만 위주로 보자.

0 = ByteArrayHttpMessageConverter
1 = StringHttpMessageConverter
2 = MappingJackson2HttpMessageConverter

첫번째로, ByteArrayHttpMessageConverter에게 읽을 수 있는 데이터인지 물어본다. (canRead())

ByteArrayHttpMessageConverter 이 녀석은 클래스 타입이 byte[]여야 하고, 미디어 타입은 */*여야 한다. 클래스 타입이 HelloData이기 때문에 넘어가게 된다.

두번째로, StringHttpMessageConverter에게 읽을 수 있는 데이터인지 물어본다. (canRead())

StringHttpMessageConverter 이 녀석은 클래스 타입이 String 이어야 하고 미디어 타입은 */*여야 한다. 클래스 타입이 HelloData이기 때문에 넘어가게 된다.

세번째로, MappingJackson2HttpMessageConverter에게 읽을 수 있는 데이터인지 물어본다. (canRead())

MappingJackson2HttpMessageConverter 이 녀석은 클래스 타입이 객체 또는 HashMap 이어야 하고, 미디어 타입이 application/json이어야 한다. 우선, HelloData는 객체라서 조건을 만족하고, 요청 시 Content-Typeapplication/json으로 보냈기 때문에 미디어타입도 만족한다. 그럼 이 MappingJackson2HttpMessageConverter 녀석이 이 데이터를 가지고 HelloData 객체의 각 프로퍼티에 데이터를 바인딩 해주게 된다. 이게 바로 HTTP 메시지 컨버터가 하는 일이다. 

 

요청과 일치하게 응답도 동일하다. 만약 다음 코드가 있다고 했을 때,

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

반환값이 HelloData로 되어 있다. 그리고 이 메서드를 가진 클래스는 @RestController 애노테이션을 달았다. 그럼 메시지 컨버터가 동작하고, 클래스 타입이 HelloData라는 객체이고 미디어 타입은 요청 시 Acceptapplication/json으로 던졌다고 가정하면 클래스 타입과 미디어 타입을 모두 만족하는 MappingJackson2HttpMessageConverter 녀석이 동작하여 응답 시 알아서 잘 해주게 되는 것이다. 참고로 여기서 컨버터들에게 너 이거 쓸 수 있어? 라고 물어보는 메서드는 canWrite() 메서드가 되겠지!

 

MappingJackson2HttpMessageConverter 이 녀석 내부적으로 Writer를 가져와서 write()를 하겠지만 우리가 직접 하지 않아도 되니 얼마나 편한가? 

 

그럼 HTTPMessageConverter가 뭐하는건지 이해를 했다. 그럼 스프링 MVC의 어디쯤에 존재하고 사용되는 것일까?

 

요청 매핑 핸들러 어댑터(RequestMappingHandlerAdapter) 구조

그래서 다시 위의 원초적인 질문으로 돌아와서 그렇다면 HTTP 메시지 컨버터는 스프링 MVC 어디쯤에서 사용되는 것일까?

이전에 봤던 이 그림을 다시 보자.

이 그림에서 HTTP 메시지 컨버터는 보이지 않는다. 그럼 어디에?

애노테이션 기반의 컨트롤러, 그러니까 @RequestMapping을 처리하는 핸들러 어댑터인 RequestMappingHandlerAdapter(요청 매핑 핸들러 어댑터)에 있다.

 

한번 이 핸들러 어댑터의 동작 방식을 다시 한번 상기해보자.

  • 1. 외부로부터 요청이 들어온다.
  • 2. 이 요청과 매핑된 핸들러(컨트롤러)를 찾는다.
  • 3. 찾은 핸들러를 처리할 수 있는 핸들러 어댑터를 찾는다
  • 4. 찾은 핸들러 어댑터는 RequestMappingHandlerAdapter가 된다. 이 핸들러 어댑터가 본인이 가지고 있는 handle()을 호출해서 실제로 핸들러가 실행된다.
  • 5. 실행된 핸들러의 결과를 반환 받은 핸들러 어댑터는 뷰를 반환해야 하면 뷰 리졸버를 호출하고, 그대로 응답 메시지에 반환해야 하면 그 작업을 또 하게 된다.

저기서 4번이 중요하다. 생각해보면 컨트롤러에 엄청 많은 Arguments를 받을 수 있었다. 대표적인 예로,

  • HttpServletRequest, HttpServletResponse
  • @ModelAttribute
  • Model
  • @RequestParam
  • @RequestBody
  • 등등

그럼 결국 이 arguments들에 실제 어떤 값이 담겨서 컨트롤러를 호출하는 handle()을 호출해야 한다. 그 값들이 채워지는 시점은 바로 4번이다. 그래서 다음 그림을 보자.

핸들러 어댑터가 필요한 Arguments들을 보고 이 Arguments들을 처리할 수 있는 ArgumentResolver를 호출한다.

호출해서 "너가 이 Argument 처리할 수 있어?"라고 물어보고 처리할 수 있는 녀석이 해당 Argument를 처리해서 실제 값을 담아주게 된다. 값을 그렇게 하나씩 차곡차곡 담아서 핸들러 어댑터가 모든 Arguments들이 완성이 되면 그제서야 핸들러를 호출하게 되는것이다.

 

참고로, ArgumentResolver의 정확한 명칭은 HandlerMethodArgumentResolver이고, 생김새는 이렇게 생겼다.

HandlerMethodArgumentResolver

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.web.method.support;

import org.springframework.core.MethodParameter;
import org.springframework.lang.Nullable;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;

public interface HandlerMethodArgumentResolver {
    boolean supportsParameter(MethodParameter parameter);

    @Nullable
    Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;
}

그래서 딱 봐도 supportsParameter()를 호출해서 이 Argument를 처리할 수 있는 구현체를 찾을 것이다. 참고로 구현체 겁나 많다..

그래서 찾았다면 resolveArgument를 호출해서 실제 값을 객체에 차곡차곡 담아준다.

 

그리고 이렇게 다 생성된 객체를 핸들러 어댑터가 받아서 실제 핸들러를 호출하게 되는것이다.

그리고 스프링은 이렇게 모든게 인터페이스 - 구현체 구조로 되어 있기 때문에 확장에 너무나 용이하다. OCP 원칙을 고수하고 있다는 의미이고 새로운 기술을 도입해도 클라이언트 코드는 변경에 영향을 받지 않는다. 그리고 그 말은! 내가 원하는 나만의 Argument를 받을 수 있게 나만의 ArgumentResolver를 만들수도 있다는 얘기다.

 

"아니 그래서 HTTP 메시지 컨버터는 어디 있는데요!?"

맞다. 아직도 그래서 이 HTTP 메시지 컨버터의 비밀을 밝혀지지 않았다. 이 메시지 컨버터가 사용되는 지점은 바로 ArgumentResolver가 사용한다. 당연히 그렇겠지? 왜냐하면 @RequestBody는 파라미터로 들어오니까 그 파라미터를 처리할 수 있는 ArgumentResolver가 있고 거기서 사용될것이다. 다음 그림을 보자.

저렇게 ArgumentResolver가 HTTP 메시지 컨버터를 사용한다. 그리고 핸들러가 결국 응답을 하게 되는데 그 응답도 또한 인터페이스 - 구현체 구조로 되어 있다. 그 때 인터페이스는 ReturnValueHandler라는 녀석인데 뷰를 다루는 핸들러가 아니라 응답 데이터를 HTTP 메시지에 입력해야 하는 핸들러는 이 인터페이스를 구현한 구현체를 핸들러 어댑터에게 응답한다. 그리고 이 ReturnValueHandler가 또 HTTP 메시지 컨버터를 사용한다. HTTP 메시지 컨버터는 요청과 응답 둘 다 사용된다고 말했던 바 있다.

 

좀 더 깊이 들어가보자.

ArgumentResolver는 요청의 경우, @RequestBody를 처리하는 ArgumentResolver가 있고 HttpEnttiy를 처리하는 ArgumentResolver가 있다. (파라미터로 @RequestBody, HttpEntity를 둘 다 받을 수 있었다. 기억해보자!) 그래서 둘 중에 어떤게 사용됐는지 확인하여 적절한 ArgumentResolver가 선택되면 거기서 HTTP 메시지 컨버터가 실행된다.

 

응답의 경우도 동일하게 @ResponseBodyHttpEntity를 처리하는 ReturnValueHandler가 있다. 그리고 이 ReturnValueHandler 녀석이 HTTP 메시지 컨버터를 사용하는 것이다.

 

그래서 정말 신기하고 새로웠던 @RequestBody로 데이터를 자동으로 바인딩 해주고 @ResponseBody로 반환값을 자동으로 반환해주는 비밀의 열쇠인 HttpMessageConverter에 대해 알아봤다.

 

결론

여기까지 하면 스프링 MVC의 핵심 구조들은 다 이해해 본 것이다. 이젠 이걸 활용하고 사용하는 것만이 남았다.

잘 사용해보자!

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

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

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

www.inflearn.com

스프링이 제공하는 컨트롤러는 애노테이션 기반으로 동작한다. 그래서 매우 유연하고 실용적이다.

 

@RequestMapping

이 애노테이션이 바로 스프링이 사용하는 애노테이션 기반 컨트롤러이다. 이 애노테이션을 기반으로 핸들러 매핑과 핸들러 어댑터가 존재한다. 핸들러 매핑과 핸들러 어댑터가 뭔지 모른다면 이전 포스팅을 꼭 읽고 오길 바란다. 핸들러 매핑을 통해 URL과 매핑된 컨트롤러를 찾고 그 컨트롤러를 처리할 수 있는 핸들러 어댑터를 찾아내는게 핸들러 어댑터이다. 그게 스프링은 굉장히 여러 형태의 구현체로 존재하는데 이 애노테이션 기반은 다음 두 개이다.

  • RequestMappingHandlerMapping
  • RequestMappingHandlerAdapter

가장 우선순위가 높은 핸들러 매핑핸들러 어댑터이다. 애노테이션의 이름을 따서 만든 이름이다.

지금까지 쭉 만들어왔던 스프링 MVC 구조를 이해하기 위해서 작업했던 것들을 스프링 MVC로 바꿔보자.

 

참고로, 이 글은 이전 포스팅을 의존하기 때문에 이전 포스팅을 읽지 않았다면 먼저 읽고 오는 것을 권장한다.

2024.07.07 - [Spring MVC] - Spring MVC 구조 이해하기 2 - 스프링 MVC 전체구조

 

Spring MVC 구조 이해하기 2 - 스프링 MVC 전체구조

참고자료: 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 강의 | 김영한 - 인프런김영한 | 웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의

cwchoiit.tistory.com

2024.05.07 - [Spring MVC] - Spring MVC 구조 이해하기 1 - Front Controller

 

Spring MVC 구조 이해하기 1 - Front Controller

참고자료: 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 | 김영한 - 인프런김영한 | 웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심

cwchoiit.tistory.com

 

 

SpringMemberFormControllerV1

package org.example.servlet.web.springmvc.v1;

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

@Controller
public class SpringMemberFormControllerV1 {

    @RequestMapping("/springmvc/v1/members/new-form")
    public ModelAndView process() {
        return new ModelAndView("new-form");
    }


}

클래스 레벨에 @Controller 애노테이션이 붙었다. 이건 뭘까? 스프링이 자동으로 스프링 빈으로 등록하게 해주는 애노테이션이다. 저 애노테이션 내부에 @Component 애노테이션이 있어서 컴포넌트 스캔의 대상이 된다. 또한, 스프링 MVC에서 애노테이션 기반 컨트롤러로 인식한다. 그래서 @Controller 하나만 있어도 스프링 빈으로 자동 등록해주고 스프링 MVC에서 애노테이션 기반 컨트롤러로 인식하게 된다. @RequestMapping은 요청 정보를 매핑한다. 해당 URL이 호출되면 이 메서드가 호출된다. 

 

RequestMappingHandlerMapping은 스프링 빈 중에서 @RequestMapping 또는 @Controller가 클래스 레벨에 붙어 있는 경우에 매핑 정보로 인식한다. 그래서 다음과 같은 코드도 동일하게 동작한다.

package org.example.servlet.web.springmvc.v1;

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

@Component
@RequestMapping
public class SpringMemberFormControllerV1 {

    @RequestMapping("/springmvc/v1/members/new-form")
    public ModelAndView process() {
        return new ModelAndView("new-form");
    }


}

 

또는 컴포넌트 자동 스캔을 사용하지 않고 직접 빈으로 등록한다면 이런 코드도 가능하다.

package org.example.servlet.web.springmvc.v1;

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

@RequestMapping
public class SpringMemberFormControllerV1 {

    @RequestMapping("/springmvc/v1/members/new-form")
    public ModelAndView process() {
        return new ModelAndView("new-form");
    }


}

근데 굳이 그럴 필요없이 @Controller를 사용한다면 모든게 충족된다.

주의! 스프링 부트 3.0이상부터는 클래스 레벨@RequestMapping이 있어도 스프링 컨트롤러로 인식하지 않는다. 오직 @Controller가 있어야 스프링 컨트롤러로 인식한다. 참고로 @RestController는 해당 애노테이션 내부에 @Controller가 있으므로 인식이 된다. 따라서 위에 설명한 두개의 코드는 이제 스프링 컨트롤러로 인식되지 않는다. RequestMappingHandlerMapping이 이제 @Controller만 인식을 한다.

 

 

SpringMemberListControllerV1

package org.example.servlet.web.springmvc.v1;

import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

import java.util.List;

@Controller
public class SpringMemberListControllerV1 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @RequestMapping("/springmvc/v1/members")
    public ModelAndView process() {
        List<Member> members = memberRepository.findAll();

        ModelAndView mv = new ModelAndView("members");
        mv.addObject("members", members);

        return mv;
    }
}

 

모두 반환타입이 ModelAndView 타입이다. 이는 이 컨트롤러는 어떤 특정 뷰를 보여줄것을 의미한다. 그리고 반드시 그 뷰의 이름을 넣어주게 되어 있고 필요하다면 해당 뷰에서 사용할 데이터를 ModelAndViewModel에 담는다. 담을땐 mv.addObject("key", "value")로 넣으면 된다. 그리고 이 ModelAndView를 반환하면 끝이다.

 

근데, 지금 코드는 불편한 부분이 있다. 이 세개의 컨트롤러가 모두 나뉘어져 있는것이 상당히 불편하고 번잡하다. 하나로 합칠 수 있다. 그것을 해보자. 그리고 사실 이전 포스팅에서 배웠지만 단순 문자열만 반환해도 뷰를 보여줄 수 있었다. 그것 또한 차근차근 알아보자.

 

SpringMemberControllerV2

package org.example.servlet.web.springmvc.v2;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

import java.util.List;

@Controller
public class SpringMemberControllerV2 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @RequestMapping("/springmvc/v2/members/new-form")
    public ModelAndView newForm() {
        return new ModelAndView("new-form");
    }

    @RequestMapping("/springmvc/v2/members")
    public ModelAndView members() {
        List<Member> members = memberRepository.findAll();

        ModelAndView mv = new ModelAndView("members");
        mv.addObject("members", members);

        return mv;
    }

    @RequestMapping("/springmvc/v2/members/save")
    public ModelAndView save(HttpServletRequest request, HttpServletResponse response) throws Exception {
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        ModelAndView mv = new ModelAndView("save-result");
        mv.addObject("member", member);
        return mv;
    }
}

@RequestMapping 애노테이션이 메서드 레벨에 붙기 때문에 연관성 있는 메서드들끼리 묶어서 한 컨트롤러에서 모두 처리가 가능하다.

파일3개가 파일1개가 돼버리니 훨씬 기분이 좋아진다. 그리고 지금 @RequestMapping의 URL 정보 중 `/springmvc/v2/members/` 까지는 모두 동일하다. 이것 또한 하나로 줄일 수 있다.

 

SpringMemberControllerV2

package org.example.servlet.web.springmvc.v2;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

import java.util.List;

@Controller
@RequestMapping("/springmvc/v2/members")
public class SpringMemberControllerV2 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @RequestMapping("/new-form")
    public ModelAndView newForm() {
        return new ModelAndView("new-form");
    }

    @RequestMapping("")
    public ModelAndView members() {
        List<Member> members = memberRepository.findAll();

        ModelAndView mv = new ModelAndView("members");
        mv.addObject("members", members);

        return mv;
    }

    @RequestMapping("/save")
    public ModelAndView save(HttpServletRequest request, HttpServletResponse response) throws Exception {
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        ModelAndView mv = new ModelAndView("save-result");
        mv.addObject("member", member);
        return mv;
    }
}

위 코드처럼 아예 클래스 레벨에 동일하게 들어가는 Path를 고정시키고 변경되는 부분만 메서드의 @RequestMapping으로 지정해주면 된다. 

 

근데 여전히 불편한 부분이 있다. 위에서 잠깐 얘기했지만 모든것들이 다 ModelAndView를 반환해야 하고, 또 요청 URL에서 파라미터를 받아오는 부분이 굉장히 거슬린다. 이 부분을 실무에서 많이 사용하는 방식으로 변경해보자.

 

 

SpringMemberControllerV3

package org.example.servlet.web.springmvc.v3;

import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@Controller
@RequestMapping("/springmvc/v3/members")
public class SpringMemberControllerV3 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @RequestMapping("/new-form")
    public String newForm() {
        return "new-form";
    }

    @RequestMapping("")
    public String members(Model model) {
        List<Member> members = memberRepository.findAll();
        model.addAttribute("members", members);
        return "members";
    }

    @RequestMapping("/save")
    public String save(@RequestParam("username") String username,
                       @RequestParam("age") int age,
                       Model model) {
        Member member = new Member(username, age);
        memberRepository.save(member);
        model.addAttribute("member", member);
        return "save-result";
    }
}

 

우선 반환타입은 이제 단순 문자열이고 그 문자열이 곧 뷰 이름이 된다. 그리고 필요한 경우 뷰에 전달할 데이터를 모델에 담기 위해 ModelAndView를 사용했는데 파라미터에 그냥 Model을 받을 수가 있다. 그래서 이 녀석의 메서드 중 addAttribute()를 사용하면 알아서 반환할 뷰에게 데이터가 전달된다. 그리고 또한, URL로부터 가져와야 할 파라미터를 @RequestParam을 사용해서 아주 간편하게 가져올 수 있을뿐 아니라 보면 알겠지만 int 타입으로 알아서 형변환까지 해준다. 원래 URL은 무조건 모든게 다 문자열이다. 그래서 문자열이 아닌 경우를 원할땐 다 형변환을 해줘야했다(V2를 생각해보면 된다). 근데 그럴 필요 없이 알아서 타입을 맞춰준다. 

 

참고로, @RequestParamGET 요청의 URL 파라미터도 가져오지만 POST 요청의 바디 FormData도 가져올 수 있다.

 

그리고 마지막으로, 지금의 경우 HTTP Method가 구분이 전혀 안 된 상태이다. 그니까 모든 멤버들을 조회하는 members()를 호출할때 GET, POST, PUT, PATCH, DELETE 다 사용이 가능한 상태이다. 아주 좋지 않다. 이 점도 깔끔하게 수정이 가능하다.

아래처럼 @RequestMapping()에는 Method라는 값을 전달할 수가 있다. 그래서 딱 원하는 Method로 결정할 수 있다.

@RequestMapping(value = "", method = RequestMethod.GET)
public String members(Model model) {
    List<Member> members = memberRepository.findAll();
    model.addAttribute("members", members);
    return "members";
}

 

근데! 이것마저 귀찮다고 이런걸 만들었다.

SpringMemberControllerV3

package org.example.servlet.web.springmvc.v3;

import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Controller
@RequestMapping("/springmvc/v3/members")
public class SpringMemberControllerV3 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @GetMapping("/new-form")
    public String newForm() {
        return "new-form";
    }

    @GetMapping
    public String members(Model model) {
        List<Member> members = memberRepository.findAll();
        model.addAttribute("members", members);
        return "members";
    }

    @PostMapping("/save")
    public String save(@RequestParam("username") String username,
                       @RequestParam("age") int age,
                       Model model) {
        Member member = new Member(username, age);
        memberRepository.save(member);
        model.addAttribute("member", member);
        return "save-result";
    }
}

 

이게 가장 최신의 깔끔한 방식이다. V1과 비교해보면 정말 군더더기 없이 깔끔하다. 그러나 V1에서 이 V3로의 역사를 알면 더 깊은 이해를 할 수가 있다. 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

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

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

www.inflearn.com

구조 이해하기 1편에서는 직접 MVC 구조와 거의 유사한 구조를 만들어서 이 구조가 어떻게 동작하는지 알아봤다.

이번엔 실제 스프링 MVC 구조를 직접 보고 1편에서 만든 구조와 어떻게 같고 어떤 부분은 다른지를 좀 더 자세히 알아보자. 

 

우선 1편에서 만든 구조와 스프링 MVC 구조의 전체적인 그림을 보자.

 

스프링 MVC 구조

 

어떤가? 스프링 MVC 구조와 직접 만든 구조가 거의 똑같다. 이름만 다른거 아닌가? 싶을 정도로 똑같다. 

즉, 이 구조를 이해하기 위해 이 구조를 직접 만들어보고 어떻게 동작하는지 직접 해 본 것이다.

 

비교를 해보자면, 

직접 만든 구조 <-> 스프링 MVC 구조

  • FrontController <-> DispatcherServlet
  • handlerMappingMap <-> HandlerMapping
  • MyHandlerAdapter <-> HandlerAdapter
  • ModelView <-> ModelAndView
  • viewResolver <-> ViewResolver
  • MyView <-> View

1편에서 말했던 내용인데 handlerMappingMap을 외부에서 주입받게 구현하면 FrontController는 아예 변경 사항이 없을 것이다라고 말했었다. 그게 스프링 MVC는 하고 있는 일이다. HandlerMapping으로 딱 봐도 타입(클래스, 인터페이스)형이다. 즉, 외부에서 주입받아 사용한다는 의미가 된다. ViewResolver도 마찬가지.

 

그건 그렇고, 우리가 만든 구조의 핵심은 FrontController였다. 결국 스프링 MVC 구조도 핵심은 DispatchServlet이다. 왜냐? 여기서 적절한 컨트롤러도 다 매핑해주고 외부로의 요청을 받는것도 다 이곳이 최초이기 때문에. 이 녀석을 살짝만 알아보자.

 

DispatcherServlet

우리가 만든 구조에서 FrontControllerServlet은 서블릿이었다. 스프링 MVC의 DispatcherServlet 역시 서블릿이고 결국 HttpServlet을 상속 받아서 사용한다. 스프링 부트는 DispatcherServlet을 서블릿으로 자동으로 등록하면서 모든 경로(urlPatterns = "/")에 대해서 매핑한다. 그래야 어떤 경로로 사용자가 요청하던, 이 DispatcherServlet을 먼저 통할테니까.

 

요청 흐름은 다음과 같다.

  • 디스패쳐 서블릿이 호출되면 HttpServlet이 제공하는 service()가 호출된다.
  • 스프링 MVC는 DispatcherServlet의 부모인 FrameworkServlet에서 service()를 오버라이드 해두었다.
  • FrameworkServlet.service()를 시작으로 여러 메서드가 호출되면서 DispatcherServlet.doDispatch()가 호출된다.

이 서블릿에서 가장 핵심 메서드는 doDispatch()이다. 이 코드를 보자.

 

DispatcherServlet.doDispatch()

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
        HttpServletRequest processedRequest = request;
        HandlerExecutionChain mappedHandler = null;
        boolean multipartRequestParsed = false;
        WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);

        try {
            try {
                ModelAndView mv = null;
                Exception dispatchException = null;

                try {
                    processedRequest = this.checkMultipart(request);
                    multipartRequestParsed = processedRequest != request;
                    
                    // 1. 핸들러 조회
                    mappedHandler = this.getHandler(processedRequest);
                    if (mappedHandler == null) {
                        this.noHandlerFound(processedRequest, response);
                        return;
                    }
                    // 2. 핸들러 어댑터 조회
                    HandlerAdapter ha = this.getHandlerAdapter(mappedHandler.getHandler());
                    String method = request.getMethod();
                    boolean isGet = HttpMethod.GET.matches(method);
                    if (isGet || HttpMethod.HEAD.matches(method)) {
                        long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
                        if ((new ServletWebRequest(request, response)).checkNotModified(lastModified) && isGet) {
                            return;
                        }
                    }

                    if (!mappedHandler.applyPreHandle(processedRequest, response)) {
                        return;
                    }

                    // 3. 핸들러 어댑터 실행
                    mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
                    if (asyncManager.isConcurrentHandlingStarted()) {
                        return;
                    }

                    this.applyDefaultViewName(processedRequest, mv);
                    mappedHandler.applyPostHandle(processedRequest, response, mv);
                } catch (Exception var20) {
                    Exception ex = var20;
                    dispatchException = ex;
                } catch (Throwable var21) {
                    Throwable err = var21;
                    dispatchException = new ServletException("Handler dispatch failed: " + err, err);
                }

                this.processDispatchResult(processedRequest, response, mappedHandler, mv, (Exception)dispatchException);
            } catch (Exception var22) {
                Exception ex = var22;
                triggerAfterCompletion(processedRequest, response, mappedHandler, ex);
            } catch (Throwable var23) {
                Throwable err = var23;
                triggerAfterCompletion(processedRequest, response, mappedHandler, new ServletException("Handler processing failed: " + err, err));
            }

        } finally {
            if (asyncManager.isConcurrentHandlingStarted()) {
                if (mappedHandler != null) {
                    mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response);
                }
            } else if (multipartRequestParsed) {
                this.cleanupMultipart(processedRequest);
            }

        }
    }

 

코드가 꽤 길지만 내가 직접 주석 처리한 1번, 2번, 3번을 보자. 결국 직접 만든 FrontControllerServlet에서 가장 핵심인 부분과 유사하다. 1. 핸들러(컨트롤러)를 조회하고 2. 그 핸들러를 다룰 수 있는 어댑터를 찾아3. 어댑터의 handle() 메서드를 호출한다.

 

그리고 그 하단에는 processDispatchResult()를 호출한다. 여기에 핸들러 어댑터의 handle() 메서드를 호출해서 받은 ModelAndView 객체와 핸들러를 넘기는 것을 알 수 있다. 이 메서드는 뭘할까?

 

DispatchServlet.processDispatchResult()

private void processDispatchResult(HttpServletRequest request, HttpServletResponse response, @Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv, @Nullable Exception exception) throws Exception {
        boolean errorView = false;
        if (exception != null) {
            if (exception instanceof ModelAndViewDefiningException) {
                ModelAndViewDefiningException mavDefiningException = (ModelAndViewDefiningException)exception;
                this.logger.debug("ModelAndViewDefiningException encountered", exception);
                mv = mavDefiningException.getModelAndView();
            } else {
                Object handler = mappedHandler != null ? mappedHandler.getHandler() : null;
                mv = this.processHandlerException(request, response, handler, exception);
                errorView = mv != null;
            }
        }

        if (mv != null && !mv.wasCleared()) {
            // 1. 뷰 렌더링 호출
            this.render(mv, request, response);
            if (errorView) {
                WebUtils.clearErrorRequestAttributes(request);
            }
        } else if (this.logger.isTraceEnabled()) {
            this.logger.trace("No view rendering, null ModelAndView returned.");
        }

        if (!WebAsyncUtils.getAsyncManager(request).isConcurrentHandlingStarted()) {
            if (mappedHandler != null) {
                mappedHandler.triggerAfterCompletion(request, response, (Exception)null);
            }

        }
    }

몇 줄의 코드가 있지만 가장 중요한 뷰 렌더링을 하는 render()를 호출한다. 

이렇게 직접 만든 구조와 거의 동일하다. 물론 스프링 MVC가 훨씬 더 안정적이고 버그에 덜 취약하며 이것 저것 유효성 검사도 많고 잘 만들었지만 흐름이 유사하다는 것이다.

 

그럼 저 render()는 뭘할까?

DispatcherServlet.render()

protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception {
        Locale locale = this.localeResolver != null ? this.localeResolver.resolveLocale(request) : request.getLocale();
        response.setLocale(locale);
        String viewName = mv.getViewName();
        View view;
        if (viewName != null) {
            // 뷰 리졸버를 통해서 뷰 찾고 뷰를 반환받는다.
            view = this.resolveViewName(viewName, mv.getModelInternal(), locale, request);
            if (view == null) {
                String var10002 = mv.getViewName();
                throw new ServletException("Could not resolve view with name '" + var10002 + "' in servlet with name '" + this.getServletName() + "'");
            }
        } else {
            view = mv.getView();
            if (view == null) {
                throw new ServletException("ModelAndView [" + mv + "] neither contains a view name nor a View object in servlet with name '" + this.getServletName() + "'");
            }
        }

        if (this.logger.isTraceEnabled()) {
            this.logger.trace("Rendering view [" + view + "] ");
        }

        try {
            if (mv.getStatus() != null) {
                request.setAttribute(View.RESPONSE_STATUS_ATTRIBUTE, mv.getStatus());
                response.setStatus(mv.getStatus().value());
            }
            // 뷰 렌더링
            view.render(mv.getModelInternal(), request, response);
        } catch (Exception var8) {
            Exception ex = var8;
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Error rendering view [" + view + "]", ex);
            }

            throw ex;
        }
    }

마찬가지로 내가 직접 넣은 주석을 보면, 뷰 리졸버를 통해 뷰를 찾아 결국 마지막 즈음에 뷰를 렌더링한다. 결국은 같은 흐름으로 이어진다.

그러니까 직접 만든 구조와 흐름이 100% 동일하다.

 

그리고 스프링 MVC는 확장에 유연하고 변경에 닫혀있는 OCP원칙을 훨씬 더 잘 고수하며 만든 프레임워크라서 이 DispatcherServlet의 코드 변경 없이 원하는 기능을 변경하거나 확장할 수 있다. 지금까지 말했던 대부분의 것들을 확장 가능할 수 있게 인터페이스로 제공한다.

 

핵심 인터페이스 목록

  • 핸들러 매핑: org.springframework.web.servlet.HandlerMapping
  • 핸들러 어댑터: org.springframework.web.servlet.HandlerAdapter
  • 뷰 리졸버: org.springframework.web.servlet.ViewResolver
  • 뷰: org.springframework.web.servlet.View

이 인터페이스들만 구현해서, DispatchServlet에 등록하면 나만의 컨트롤러를 만들수도 있다. (만들라는 얘기는 절대 아니다.

 

이렇게 큰 맥락에서 스프링 MVC 구조와 직접 만든 구조를 비교해 보았다. 이미 직접 만들어봤기 때문에 이해하는데 어렵지 않았다. 이런 과정을 통해 스프링 MVC가 동작하는구나를 이해하면 된다. 그럼 DispatcherServlet을 알아봤는데 핸들러와 핸들러 어댑터는 어떻게 만들었을까? 요새 스프링으로 개발하는 거의 99%는 애노테이션 기반의 컨트롤러를 사용한다. 그래서 RequestMappingHandlerAdapter라는걸 스프링이 만들어서 사용하는데 그 전 세대 사람들은 어떻게 개발했을까?

 

핸들러 매핑과 핸들러 어댑터

지금은 전혀 사용되지 않지만, 과거에 주로 사용했던 스프링이 제공하는 간단한 컨트롤러로 핸들러 매핑과 어댑터를 이해해보자.

 

과거에는 Controller라는 인터페이스를 사용했다.

스프링이 만들어둔 Controller 인터페이스

public interface Controller {
	ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception;
}
참고로, 이 Controller 인터페이스와 @Controller는 아예 다른것이다.

 

그래서 저 예전 버전의 컨트롤러 인터페이스로 한번 구현해보자.

OldController

package org.example.servlet.web.springmvc.old;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.Controller;

@Component("/springmvc/old-controller")
public class OldController implements Controller {
    @Override
    public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
        System.out.println("OldController.handleRequest");
        return null;
    }
}
  • @Component 애노테이션을 활용해서 빈으로 자동 주입을 했다. 그리고 빈의 이름을 지정했는데 꼭 보면 빈의 이름이 URL같이 생겼다. 맞다. 빈의 이름을 URL로 매핑한다.

그래서 한번 URL에 다음 경로로 들어가보자. `http://localhost:8080/springmvc/old-controller`

저 시스템 로그가 찍히면 정상적으로 동작하는 것이다.

 

그럼 이 URL이 호출될 때 이 컨트롤러를 실행하는 과정이 어떻게 될까? 우선 이 컨트롤러가 실행되려면 두가지가 필요하다.

  • HandlerMapping
  • HandlerAdapter

가장 먼저, 핸들러 매핑을 통해서 이 컨트롤러를 찾을 수 있어야 한다. 스프링은 핸들러 매핑에 어떻게 등록할까?

 

스프링 부트가 자동 등록하는 핸들러 매핑과 핸들러 어댑터가 있다.

스프링 부트가 자동 등록해주는 HandlerMapping

  • 0 = RequestMappingHandlerMapping
  • 1 = BeanNameUrlHandlerMapping

우선 0번부터 우선순위가 더 높은거라고 생각하면 된다. RequestMappingHandlerMapping은 애노테이션 기반으로 컨트롤러를 찾는 핸들러 매핑이다. 이건 아니고 두번째 거를 보자. BeanNameUrlHandlerMapping이다. 이름만 봐도 너무 이거일것같다. 맞다.

= 빈의 이름이 곧 URL이 되는 컨트롤러를 찾는 핸들러 매핑이다.

 

이 핸들러 매핑을 통해 적절한 컨트롤러 (위의 예시에선 내가 만든 OldController)를 찾아서 이 컨트롤러를 처리할 수 있는 어댑터를 찾는다. 스프링 부트가 역시 마찬가지로 자동으로 등록해주는 HandlerAdapter가 있다.

스프링 부트가 자동 등록해주는 HandlerAdapter

  • 0 = RequestMappingHandlerAdapter
  • 1 = HttpRequestHandlerAdapter
  • 2 = SimpleControllerHandlerAdapter

우선, 마찬가지로 0번이 제일 우선순위가 높은것이다. 그리고 RequestMappingHandlerAdapter는 애노테이션 기반의 핸들러를 처리할 수 있는 어댑터이고 HttpRequestHandlerAdapter는 이후에 살펴볼 핸들러 타입의 어댑터이다. 즉, 마지막 SimpleControllerHandlerAdapter가 바로 Controller라는 인터페이스를 구현한 컨트롤러를 처리할 수 있는 어댑터이다. 실제로 이 코드를 한번 봐보자.

SimpleControllerHandlerAdapter

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.web.servlet.mvc;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.web.servlet.HandlerAdapter;
import org.springframework.web.servlet.ModelAndView;

public class SimpleControllerHandlerAdapter implements HandlerAdapter {
    public SimpleControllerHandlerAdapter() {
    }

    public boolean supports(Object handler) {
        return handler instanceof Controller;
    }

    @Nullable
    public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        return ((Controller)handler).handleRequest(request, response);
    }

    public long getLastModified(HttpServletRequest request, Object handler) {
        if (handler instanceof LastModified lastModified) {
            return lastModified.getLastModified(request);
        } else {
            return -1L;
        }
    }
}

굉장히 익숙하게 생긴 supports()가 있고 보면 Controller 타입인지를 체크한다. 그 Controller는 위에서 다뤄본 Controller 인터페이스이다. 이게 바로 과거의 방식이었다. 하나 더 알아보자.

 

이번엔 Controller 인터페이스 말고 HttpRequestHandler를 알아보자.

스프링 부트가 자동으로 등록해주는 HttpRequestHandler

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.web;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

@FunctionalInterface
public interface HttpRequestHandler {
    void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}

이것도 역시 위 Controller 인터페이스와 유사한데 얘는 리턴 타입도 void이다. 즉 저 메서드 안에서 전부 다 처리해주는 방식이다.

이것을 구현한 컨트롤러가 있으면 된다.

MyHttpRequestHandler

package org.example.servlet.web.springmvc.old;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.HttpRequestHandler;

import java.io.IOException;

@Component("/springmvc/request-handler")
public class MyHttpRequestHandler implements HttpRequestHandler {
    @Override
    public void handleRequest(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        System.out.println("MyHttpRequestHandler.handleRequest");
    }
}

이 또한 역시 빈의 이름으로 URL을 매핑하는 핸들러를 만든다. 그래서 핸들러를 찾을때 빈의 이름과 URL이 똑같은 컨트롤러를 찾는다. (바로 이 MyHttpRequestHandler) 그럼 핸들러 매핑을 통해 핸들러를 찾았으면 이 핸들러가 어떤 어댑터에 적용될 수 있는지 핸들러 어댑터를 찾는다. 위에 말했던 1번 어댑터인 HttpRequestHandlerAdapter에 걸리는 것이다. 

 

보면 결국은 1편에서 직접 만들어본 구조랑 완전 똑같다. 스프링이 어떻게 MVC 구조를 만들었는지 아니까 이게 어렵지가 않다. 그리고 이 어댑터도 실제 코드를 보면 이렇게 생겼다.

HttpRequestHandlerAdapter

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.web.servlet.mvc;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.web.HttpRequestHandler;
import org.springframework.web.servlet.HandlerAdapter;
import org.springframework.web.servlet.ModelAndView;

public class HttpRequestHandlerAdapter implements HandlerAdapter {
    public HttpRequestHandlerAdapter() {
    }

    public boolean supports(Object handler) {
        return handler instanceof HttpRequestHandler;
    }

    @Nullable
    public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        ((HttpRequestHandler)handler).handleRequest(request, response);
        return null;
    }

    public long getLastModified(HttpServletRequest request, Object handler) {
        if (handler instanceof LastModified lastModified) {
            return lastModified.getLastModified(request);
        } else {
            return -1L;
        }
    }
}

supports()HttpRequestHandler 타입인지를 판단한다. 그리고 handle()은 결국 그 핸들러가 가지고 있는 (구현해야만 하는) handleRequest()를 호출한다. 너무너무 이해가 잘된다. 그리고 이젠 이런 방식을 사용하지 않는다는 것도 알고 있다.

 

지금은 거의 100%에 가깝게 애노테이션 기반의 컨트롤러(핸들러)를 사용하기 때문에 나도 이 방식으로 개발을 할거지만 이런 역사가 있었다는 사실을 알면 개발하는데 무조건 도움이 된다. 어떤게 불편해서 지금의 스프링이 있는지 이해를 할 수 있기 때문에.

 

그럼 핸들러와 핸들러 어댑터를 알아봤으니 뷰 리졸버도 한번 알아보자.

 

뷰 리졸버

우리가 직접 만든 구조에서 뷰 리졸버를 통해 논리 이름을 가지고 전체 이름을 가질 수 있게 만들었다.

스프링 부트를 사용하면 어떻게 해야 할까? 우선 위에서 만들어본 완전 과거 버전의 컨트롤러인 OldController를 이렇게 변경해보자.

 

OldController

package org.example.servlet.web.springmvc.old;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.Controller;

@Component("/springmvc/old-controller")
public class OldController implements Controller {
    @Override
    public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
        System.out.println("OldController.handleRequest");
        return new ModelAndView("new-form");
    }
}

반환값을 변경했다. 기존에는 null을 리턴했는데 이제 new ModelAndView("new-form")을 리턴한다. 그리고 이 컨트롤러를 호출하면 어떻게 될까? 결과는 다음과 같다. 아무것도 나타나지 않는다. 분명 우리 프로젝트에는 JSP 파일이 있다.

그러나 아무것도 보이진 않는다. 그치만 로그는 찍히고 있다. 컨트롤러 호출은 됐다는 이야기이다.

 

그럼 스프링은 뷰 리졸버를 어떻게 적용해야 할까?

스프링 부트의 설정 파일인 application.yml 파일로 가자.

application.yml

spring.application.name=servlet

spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp

파일의 아래 두 개의 라인을 추가해주면 된다. 그럼 정상적으로 동작할 것이다.

어떻게 동작하는 걸까? 스프링 부트는 InternalResourceViewResolver 라는 뷰 리졸버를 자동으로 등록하는데, 이때 application.yml 파일에 등록한 spring.mvc.view.prefix, spring.mvc.view.suffix 설정 정보를 사용해서 등록한다. 

 

그러니까 스프링은 뷰 리졸버도 인터페이스로 등록하고 그 인터페이스를 구현한 많은 구현체 중 하나로부터 이 뷰를 보여주는 방법을 사용하는 것이다. 그리고 스프링 부트가 자동으로 등록하는 뷰 리졸버는 여러개가 있고 그 중 일부는 다음과 같다.

  • 1 = BeanNameViewResolver: 빈 이름으로 뷰를 찾아서 반환 (예: 엑셀 파일 생성 기능에 사용)
  • 2 = InternalResourceViewResolver: JSP를 처리할 수 있는 뷰를 반환

저 중 BeanNameViewResolver는 현재 위 코드의 해당 사항이 아니다. 이는 빈 이름으로 뷰를 찾아서 반환하는데 우리가 호출하는 뷰 이름이 `new-form`인데 이런 빈이 없기 때문에 자기는 처리해줄 수 없다고 다음으로 넘긴다. 그리고 이 뷰 리졸버는 보통 엑셀 파일 생성 기능에 사용한다.

 

참고로, Thymeleaf 뷰 템플릿을 사용하면 ThymeleafViewResolver를 등록해야 하는데 최근 스프링 부트는 라이브러리만 추가하면 스프링 부트가 이런 작업도 모두 자동으로 해준다.

 

그래서 전체적인 구조를 다시 보자.

결국 핸들러 어댑터 목록을 통해 찾은 핸들러를 처리할 수 있는 핸들러 어댑터를 찾고 핸들러 어댑터가 가진 handle()을 호출하면 결과적으로 뷰의 논리 이름을 얻게 된다. 뷰의 논리 이름을 얻은 DispatchServlet은 뷰 리졸버를 통해 뷰를 찾아내는데 수많은 뷰 리졸버 중 우리는 InternalResourceViewResolver를 통해 뷰를 찾아서 렌더링 하게 된다.

 

결론

이게 바로 스프링 MVC의 전체 구조가 된다. 굉장히 복잡한 구조인데 직접 이 구조를 만들어보고 나니 그렇게 어렵게 느껴지지 않는다. 제대로 배운 느낌이 든다. 이제 구조도 다 이해했으니 진짜 Spring MVC를 사용해보자.

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

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

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

www.inflearn.com

 

이제 스프링 MVC의 핵심 구조를 하나하나 직접 구현해보면서 이해해보자.

핵심은 바로 FrontController.

 

결국 공통으로 처리되어야 할 부분들을 앞에서 모두 처리하고 필요한 컨트롤러만 찾아서 호출해주는 것이다.

기존 코드를 이 FrontController를 도입해서 하나씩 바꿔가보자.

 

우선, ControllerV1 이라는 인터페이스를 만든다. FrontController에서 가장 중요한 것 중 하나는 '다형성'이다.

 

ControllerV1

package org.example.servlet.web.frontcontroller.v1;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

public interface ControllerV1 {
    void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}

 

이 인터페이스를 구현하는 각각의 컨트롤러(Save, Form, List)를 만들자.

 

MemberFormControllerV1

package org.example.servlet.web.frontcontroller.v1.controller;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.v1.ControllerV1;

import java.io.IOException;

public class MemberFormControllerV1 implements ControllerV1 {
    @Override
    public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String viewPath = "/WEB-INF/views/new-form.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

 

MemberListControllerV1

package org.example.servlet.web.frontcontroller.v1.controller;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.v1.ControllerV1;

import java.io.IOException;
import java.util.List;

public class MemberListControllerV1 implements ControllerV1 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        List<Member> members = memberRepository.findAll();

        // Model에 보관
        request.setAttribute("members", members);

        String viewPath = "/WEB-INF/views/members.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

 

MemberSaveControllerV1

package org.example.servlet.web.frontcontroller.v1.controller;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.v1.ControllerV1;

import java.io.IOException;

public class MemberSaveControllerV1 implements ControllerV1 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        // Model에 데이터를 보관
        request.setAttribute("member", member);

        String viewPath = "/WEB-INF/views/save-result.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

 

이제 이 각각의 컨트롤러의 앞에서 공통 부분을 처리하고 필요한 컨트롤러를 호출해주는 FrontController를 만들면 된다.

FrontControllerServletV1

package org.example.servlet.web.frontcontroller.v1;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.v1.controller.MemberFormControllerV1;
import org.example.servlet.web.frontcontroller.v1.controller.MemberListControllerV1;
import org.example.servlet.web.frontcontroller.v1.controller.MemberSaveControllerV1;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@WebServlet(name = "frontControllerServletV1", urlPatterns = "/front-controller/v1/*")
public class FrontControllerServletV1 extends HttpServlet {

    private final Map<String, ControllerV1> controllerMap = new HashMap<>();

    public FrontControllerServletV1() {
        controllerMap.put("/front-controller/v1/members/new-form", new MemberFormControllerV1());
        controllerMap.put("/front-controller/v1/members/save", new MemberSaveControllerV1());
        controllerMap.put("/front-controller/v1/members", new MemberListControllerV1());
    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String requestURI = req.getRequestURI();

        ControllerV1 controller = controllerMap.get(requestURI);
        if (controller == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        controller.process(req, resp);
    }
}

 

이 FrontController는 Servlet이다. 이 서블릿의 URL Pattern은 "/front-controller/v1/*"이다. 이게 의미하는 건 /front-controller/v1/으로 시작하는 모든 URL에 대해 이 서블릿이 처리하겠다는 의미가 된다. 그리고 이 서블릿은 Map을 가진다. 이 Map엔 각 URL에 상응하는 컨트롤러가 담겨있고 이 컨트롤러들은 모두 타입이 ControllerV1이다. 왜냐? 다형성 덕분에 가능하다.

 

그래서 이 Front Controller는 받은 요청의 URI를 통해 어떤 컨트롤러를 호출할지 찾아내서 그 컨트롤러가 구현한 메서드인 process()를 호출한다. 

 

"어? 더 불편해 보이는데요..?"

맞다. 여전히 지금은 각 컨트롤러마다 어떤 뷰를 보여줘야 하는지에 대한 코드가 중복으로 남아있다. 이런 코드들.

String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);

 

이제 이 부분을 공통으로 처리하게 해보자. 그럼 구조는 이렇게 생기게 된다.

 

바뀌는 부분은 FrontController가 요청으로 들어온 URI를 보고 Controller를 찾는데, 찾고 그 Controller가 가진 process() 메서드를 호출하고 끝나는게 아니라 호출하면 반환하는 MyView라는 객체의 render() 메서드를 호출하는 것까지 FrontController가 하게 된다.

 

위에서도 말했지만, 공통으로 처리될 부분을 앞에서 다 해주는 것이 원래 기대값이다. 그 방향으로 하나씩 나아가는 중인 것.

 

우선 MyView 라는 뷰를 렌더링 하는것을 담당하는 클래스를 만든다.

 

MyView

package org.example.servlet.web.frontcontroller;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

public class MyView {
    private final String viewPath;

    public MyView(String viewPath) {
        this.viewPath = viewPath;
    }

    public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

 

이 클래스는 viewPath 필드를 가지고 있다. 컨트롤러에서 이 객체를 만들 때 viewPath를 넣어주면 이 객체의 render() 메서드는 viewPath를 통해 JSP를 호출한다.

 

이번엔 컨트롤러들의 인터페이스를 만든다.

ControllerV2

package org.example.servlet.web.frontcontroller.v2;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.MyView;

import java.io.IOException;

public interface ControllerV2 {
    MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}

 

이전 V1과 달라지는 것은 반환하는 MyView 객체가 있다는 것.

 

이제 ControllerV2를 구현할 세가지의 컨트롤러(Form, Save, List)를 만든다.

 

MemberFormControllerV2

package org.example.servlet.web.frontcontroller.v2.controller;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v2.ControllerV2;

import java.io.IOException;

public class MemberFormControllerV2 implements ControllerV2 {
    @Override
    public MyView process(HttpServletRequest request, HttpServletResponse response) throws
            ServletException, IOException {
        return new MyView("/WEB-INF/views/new-form.jsp");
    }
}

 

MemberListControllerV2

package org.example.servlet.web.frontcontroller.v2.controller;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v2.ControllerV2;

import java.io.IOException;
import java.util.List;

public class MemberListControllerV2 implements ControllerV2 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public MyView process(HttpServletRequest request, HttpServletResponse response) throws
            ServletException, IOException {
        List<Member> members = memberRepository.findAll();

        // Model에 보관
        request.setAttribute("members", members);
        return new MyView("/WEB-INF/views/members.jsp");
    }
}

 

MemberSaveControllerV2

package org.example.servlet.web.frontcontroller.v2.controller;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v2.ControllerV2;

import java.io.IOException;

public class MemberSaveControllerV2 implements ControllerV2 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public MyView process(HttpServletRequest request, HttpServletResponse response) throws
            ServletException, IOException {
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        // Model에 데이터를 보관
        request.setAttribute("member", member);

        return new MyView("/WEB-INF/views/save-result.jsp");
    }
}

 

보다시피 이제 뷰에 대한 중복 코드가 컨트롤러에서 사라지고 깔끔하게 정리됐다. 이 작업만으로도 컨트롤러의 코드가 더 깔끔하고 보기 좋아졌다. 이제 이 컨트롤러의 process() 메서드를 호출하는 FrontController를 만들어야한다. 왜냐하면 이제 이것만 호출하고 끝이 아니라 얘가 반환하는 MyView 객체를 가지고 render() 메서드를 호출해줘야 한다.

 

FrontControllerServletV2

package org.example.servlet.web.frontcontroller.v2;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v2.controller.MemberFormControllerV2;
import org.example.servlet.web.frontcontroller.v2.controller.MemberListControllerV2;
import org.example.servlet.web.frontcontroller.v2.controller.MemberSaveControllerV2;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@WebServlet(name = "frontControllerServletV2", urlPatterns = "/front-controller/v2/*")
public class FrontControllerServletV2 extends HttpServlet {

    private final Map<String, ControllerV2> controllerMap = new HashMap<>();

    public FrontControllerServletV2() {
        controllerMap.put("/front-controller/v2/members/new-form", new MemberFormControllerV2());
        controllerMap.put("/front-controller/v2/members/save", new MemberSaveControllerV2());
        controllerMap.put("/front-controller/v2/members", new MemberListControllerV2());
    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String requestURI = req.getRequestURI();

        ControllerV2 controller = controllerMap.get(requestURI);
        if (controller == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        MyView view = controller.process(req, resp);
        view.render(req, resp);
    }
}

 

이제 이 FrontController는 꽤나 어깨가 무거워졌다. URI에 따라 어떤 컨트롤러를 호출할지 정해줘야하고, 컨트롤러가 호출해서 돌려준 MyView 객체로 뷰를 요청에 대한 응답으로 돌려줘야한다. 그래도 한 곳에서 관리하기 때문에 중복이 많이 제거됐다.

 

그렇지만, 여전히 뭔가 아쉽다. 예를 들면 다음 코드.

new MyView("/WEB-INF/views/save-result.jsp");

저 경로의 위치가 컨트롤러마다 전부 중복으로 쓰여지고 있다. 그리고 HttpServletRequest, HttpServletResponse가 필요가 없는 컨트롤러도 있다. MemberFormControllerV2를 보면 아예 사용자체가 안되지만 파라미터로 넘겨받고 있다.

 

그리고 다른 컨트롤러도 파라미터 가져오거나, setAttribute() 메서드로 모델에 데이터를 담는 작업 외엔 하는것도 없다. 그러니까 HttpServletResponse는 진짜로 하는게 없다. 그래서, 아예 HttpServletRequest, HttpServletResponse를 사용하지 말아보자.

필요한 파라미터나 모델을 담는것은 서블릿에 종속적이지 않아도 될 것 같다.

 

V3

그래서 ModelView라는 클래스를 만들자. 이 클래스는 모델과 뷰를 동시에 다루는 클래스이다.

어떤 뷰를 보여줄지에 대한 정보와 그 뷰에서 사용할 모델을 동시에 가지고 있는 것이다.

 

ModelView

package org.example.servlet.web.frontcontroller;

import lombok.Getter;
import lombok.Setter;

import java.util.HashMap;
import java.util.Map;

@Getter
@Setter
public class ModelView {
    private String viewName;
    private Map<String, Object> model = new HashMap<>();

    public ModelView(String viewName) {
        this.viewName = viewName;
    }
}

어떤 뷰를 보여줘야 하는지 알고 있는 viewName, 그 뷰에서 사용될 데이터를 담은 model이 있다.

이제 이 클래스를 반환타입으로 컨트롤러가 사용하면 된다.

 

ControllerV3

package org.example.servlet.web.frontcontroller.v3;

import org.example.servlet.web.frontcontroller.ModelView;

import java.util.Map;

public interface ControllerV3 {

    ModelView process(Map<String, String> paramMap);
}

이번엔 HttpServletRequest, HttpServletResponse가 필요가 없다. 받는 파라미터는 그저 해당 뷰에서 사용될 파라미터 정보뿐이다. 

 

이 ControllerV3를 구현할 세 가지 컨트롤러(Form, Save, List)를 만들자

 

MemberFormControllerV3

package org.example.servlet.web.frontcontroller.v3.controller;

import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v3.ControllerV3;

import java.util.Map;

public class MemberFormControllerV3 implements ControllerV3 {
    @Override
    public ModelView process(Map<String, String> paramMap) {
        return new ModelView("new-form");
    }
}

 

MemberSaveControllerV3

package org.example.servlet.web.frontcontroller.v3.controller;

import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v3.ControllerV3;

import java.util.Map;

public class MemberSaveControllerV3 implements ControllerV3 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public ModelView process(Map<String, String> paramMap) {
        String username = paramMap.get("username");
        int age = Integer.parseInt(paramMap.get("age"));

        Member member = new Member(username, age);

        memberRepository.save(member);

        ModelView mv = new ModelView("save-result");
        mv.getModel().put("member", member);

        return mv;
    }
}

 

MemberListControllerV3

package org.example.servlet.web.frontcontroller.v3.controller;

import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v3.ControllerV3;

import java.util.List;
import java.util.Map;

public class MemberListControllerV3 implements ControllerV3 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public ModelView process(Map<String, String> paramMap) {
        List<Member> members = memberRepository.findAll();

        ModelView mv = new ModelView("members");
        mv.getModel().put("members", members);

        return mv;
    }
}

 

이제는 각각의 컨트롤러가 서블릿에 대한 종속이 전혀 없다. 보다시피 파라미터로 HttpServletRequest, HttpServletResponse를 받지도 않고 있다. 그리고 ModelView라는 직접 만든 클래스를 사용해서 보여줄 뷰의 이름과 그 뷰에서 사용할 모델을 처리한 후 이 ModelView 객체를 반환한다.

 

그럼 FrontController는 이제 이 반환값을 가지고 공통적으로 또 처리해 줄 것들을 처리하면 된다.

 

FrontControllerServletV3

package org.example.servlet.web.frontcontroller.v3;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v3.controller.MemberFormControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberListControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberSaveControllerV3;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@WebServlet(name = "frontControllerServletV3", urlPatterns = "/front-controller/v3/*")
public class FrontControllerServletV3 extends HttpServlet {

    private final Map<String, ControllerV3> controllerMap = new HashMap<>();

    public FrontControllerServletV3() {
        controllerMap.put("/front-controller/v3/members/new-form", new MemberFormControllerV3());
        controllerMap.put("/front-controller/v3/members/save", new MemberSaveControllerV3());
        controllerMap.put("/front-controller/v3/members", new MemberListControllerV3());
    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String requestURI = req.getRequestURI();

        ControllerV3 controller = controllerMap.get(requestURI);
        if (controller == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        Map<String, String> paramMap = createParamMap(req);

        ModelView mv = controller.process(paramMap);

        MyView view = viewResolver(mv);

        view.render(mv.getModel(), req, resp);
    }

    private static MyView viewResolver(ModelView mv) {
        return new MyView("/WEB-INF/views/" + mv.getViewName() + ".jsp");
    }

    private static Map<String, String> createParamMap(HttpServletRequest req) {
        Map<String, String> paramMap = new HashMap<>();
        req.getParameterNames()
                .asIterator()
                .forEachRemaining(paramName ->
                        paramMap.put(paramName, req.getParameter(paramName))
                );
        return paramMap;
    }
}

 

FrontController는 두 가지 공통작업을 처리한다.

  • 각 컨트롤러가 필요한 파라미터에 대한 작업 (createParamMap())
  • 각 컨트롤러가 보여줄 뷰에 대한 경로 작업 (viewResolver())

그럼 끝인가? 아쉽지만 아니다. 어떤게 남았나면 이제 우리가 직접 만든 모델을 사용하기 때문에 그 모델에 담긴 데이터를 다시 서블릿의 request에 넣어줘야 한다. JSP는 HttpServletRequest 객체인 requestgetAttribute()를 통해서 데이터를 꺼내오기 때문에 꼭 해줘야 한다. 그게 새롭게 만든 MyViewrender()메서드이다.

public void render(Map<String, Object> model, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    model.forEach(req::setAttribute);

    RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
    dispatcher.forward(req, resp);
}

 

이렇게 하면, FrontController가 하는 일이 많아진 대신 각각의 세부 컨트롤러는 하는일이 더더욱 적어졌다. 그리고 FrontController가 중복적인 부분을 혼자 담당하기 때문에 변경이 필요하면 이 부분만 변경하면 된다. 예를 들면 뷰의 경로가

 

"/WEB-INF/views/" + mv.getViewName() + ".jsp" 여기서 "/WEB-INF/jsp/" + mv.getViewName() + ".jsp" 이렇게 변경되더라도 말이다.

 

그러나, 만든 V3 컨트롤러는 잘 설계된 컨트롤러는 맞지만 (서블릿 종속성을 제거하고, 뷰 경로의 중복을 제거하는 등) 실제 컨트롤러 인터페이스를 구현하는 개발자 입장에서 보면, 항상 ModelView 객체를 생성하고 반환해야 하는 부분이 조금은 번거롭다. 좋은 프레임워크는 아키텍쳐도 중요하지만, 그와 더불어 실제 개발하는 개발자가 단순하고 편리하게 사용할 수 있어야 한다. 소위 실용성이 있어야 한다.

 

이번에는 V3를 조금 변경해서 실제 구현하는 개발자들이 매우 편리하게 개발할 수 있는 V4 버전을 만들어보자!

 

V4

스프링은 ModelView를 반환하는 컨트롤러를 만들수도 있지만, 그냥 단순 스트링을 반환하고 그 스트링이 뷰의 이름이되는 반환을 하기도 한다. 이 V4는 그것을 똑같이 만들어보고 싶은것이다. 

 

지금 상태에서 크게 바꿀것도 없다. 우선 ControllerV4를 만들자.

 

ControllerV4

package org.example.servlet.web.frontcontroller.v4;

import java.util.Map;

public interface ControllerV4 {
    /**
     *
     * @param paramMap
     * @param model
     * @return
     */
    String process(Map<String, String> paramMap, Map<String, Object> model);
}

 

참고로, 저 메서드레벨의 주석 단축키가 굉장히 편한데 그냥 `/**` 입력하고 엔터만 치면 자동으로 저렇게 써준다.

 

이제는 V4는 한가지 파라미터를 더 받는다. model이다. 그래서 ModelView를 컨트롤러가 모두 반환해야 하는 불편함을 없애는 것.

이제 Form, List, Save 컨트롤러를 만들어보자.

 

MemberFormControllerV4

package org.example.servlet.web.frontcontroller.v4.controller;

import org.example.servlet.web.frontcontroller.v4.ControllerV4;

import java.util.Map;

public class MemberFormControllerV4 implements ControllerV4 {
    @Override
    public String process(Map<String, String> paramMap, Map<String, Object> model) {
        return "new-form";
    }
}

이제 process() 메서드는 단순 스트링만을 반환할 수 있다. 귀찮게 ModelViewnew로 생성해서 반환할 필요가 사라졌다.

그리고 저 반환하는 스트링은 바로 뷰의 이름이 된다. 

MemberListControllerV4

package org.example.servlet.web.frontcontroller.v4.controller;

import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v3.ControllerV3;
import org.example.servlet.web.frontcontroller.v4.ControllerV4;

import java.util.List;
import java.util.Map;

public class MemberListControllerV4 implements ControllerV4 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    /**
     *
     * @param paramMap
     * @param model
     * @return
     */
    @Override
    public String process(Map<String, String> paramMap, Map<String, Object> model) {
        List<Member> members = memberRepository.findAll();

        model.put("members", members);

        return "members";
    }
}

모든 멤버들을 보여주는 MemberListControllerV4. 이것 역시 그냥 단순히 스트링을 반환한다. 마찬가지로 반환하는 문자열은 뷰의 이름이 된다.

MemberSaveControllerV4

package org.example.servlet.web.frontcontroller.v4.controller;

import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;
import org.example.servlet.web.frontcontroller.v4.ControllerV4;

import java.util.Map;

public class MemberSaveControllerV4 implements ControllerV4 {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public String process(Map<String, String> paramMap, Map<String, Object> model) {
        String username = paramMap.get("username");
        int age = Integer.parseInt(paramMap.get("age"));

        Member member = new Member(username, age);

        memberRepository.save(member);

        model.put("member", member);

        return "save-result";
    }
}

이번엔 멤버를 저장하는 컨트롤러이다. 마찬가지다. 변경되는 지점은 받는 파라미터에 model이 추가되고, 반환 타입이 String.

이렇게 컨트롤러들을 전부 변경했으니 FrontController도 변경해보자.

 

FrontControllerServletV4

package org.example.servlet.web.frontcontroller.v4;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v4.controller.MemberFormControllerV4;
import org.example.servlet.web.frontcontroller.v4.controller.MemberListControllerV4;
import org.example.servlet.web.frontcontroller.v4.controller.MemberSaveControllerV4;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@WebServlet(name = "frontControllerServletV4", urlPatterns = "/front-controller/v4/*")
public class FrontControllerServletV4 extends HttpServlet {

    private final Map<String, ControllerV4> controllerMap = new HashMap<>();

    public FrontControllerServletV4() {
        controllerMap.put("/front-controller/v4/members/new-form", new MemberFormControllerV4());
        controllerMap.put("/front-controller/v4/members/save", new MemberSaveControllerV4());
        controllerMap.put("/front-controller/v4/members", new MemberListControllerV4());
    }

    /**
     *
     * @param req
     * @param resp
     * @throws ServletException
     * @throws IOException
     */
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String requestURI = req.getRequestURI();

        ControllerV4 controller = controllerMap.get(requestURI);
        if (controller == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        Map<String, String> paramMap = createParamMap(req);
        Map<String, Object> model = new HashMap<>();

        String viewName = controller.process(paramMap, model);

        MyView view = viewResolver(viewName);

        view.render(model, req, resp);
    }

    private static MyView viewResolver(String viewName) {
        return new MyView("/WEB-INF/views/" + viewName + ".jsp");
    }

    private static Map<String, String> createParamMap(HttpServletRequest req) {
        Map<String, String> paramMap = new HashMap<>();
        req.getParameterNames()
                .asIterator()
                .forEachRemaining(paramName ->
                        paramMap.put(paramName, req.getParameter(paramName))
                );
        return paramMap;
    }
}

여기서 변경되는 부분은 service()에서 적절한 컨트롤러의 process()를 실행할 때 model을 추가적으로 넘겨주는 것과 받는 반환타입이 바로 viewResolver()의 파라미터로 들어간다는 점이다. 그리고 view.render()의 첫번째 파라미터가 model이 되면 된다.

 

이러면 모든게 기존과 동일하게 동작한다. 어떤 부분에서 더 유연해졌냐?

  • 실제 컨트롤러들(FrontController 말고)이 굳이 ModelView를 매번 새로운 인스턴스로 만들어내지 않아도 된다. 특히 모델이 아예 필요없고 뷰의 논리 이름만을 위해서 만드는 MemberFormController의 경우 정말 비효율적인 방식이었는데 이를 깔끔하게 해결해준다.

 

이 방식이 바로 스프링이 컨트롤러를 만들때 ModelView를 반환해도 가능하고 단순 String을 반환해도 상관없는 이유이다. 

"어?! 근데 지금 코드는 단순 String만 반환 가능할 거 같은데요?" 맞다. 왜냐하면, 이 FrontControllerV4는 컨트롤러를 확정짓기 위해 사용되는 controllerMapValueControllerV4로 한정되어 있다. 그러나 스프링은 두 가지 경우 모두 지원한다. 즉, 더 유연하다는 소리고 그 방법을 V5에서 알아보자!

 

V5

지금까지의 구조는 다음과 같다.

위 구조를 흐름대로 설명하면 다음과 같은 흐름이 발생한다.

  • 사용자로부터 요청이 들어온다.
  • 요청을 최초에 FrontControllerV4가 받는다.
  • 요청 URL에 따라 처리 가능한 컨트롤러를 FrontControllerV4는 찾고 그 컨트롤러를 호출한다.
  • 해당 컨트롤러에서 필요한 수행 작업을 모두 마친 후 보여줄 화면에 대한 뷰 이름을 가진 반환값을 FrontControllerV4에게 돌려준다.
  • 받은 뷰 이름을 전체 이름으로 변경해주는 viewResolver()FrontControllerV4가 호출한다.
  • 호출해서 받은 전체 뷰 경로를 가지고 MyView 객체의 render()를 호출해서 사용자에게 최종 화면을 보여준다.

여기서 개선될 부분은 개발자가 "나는 V4 형태 말고 V3 형태로 컨트롤러를 만들고 싶어!"라고 할 때 그러지 못한다는 점이다. 이 점을 가능하게 변경해보자. 

 

우선, MyHandlerAdapter라는 인터페이스 하나가 필요하다.

MyHandlerAdapter

package org.example.servlet.web.frontcontroller.v5;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.ModelView;

import java.io.IOException;

public interface MyHandlerAdapter {
    boolean supports(Object handler);

    ModelView handle(HttpServletRequest req, HttpServletResponse resp, Object handler)
            throws ServletException, IOException;
}

 

이 인터페이스는 구현체를 봐야 좀 더 이해가 명확하게 되니까 구현체도 바로 만들자.

ControllerV3HandlerAdapter

package org.example.servlet.web.frontcontroller.v5.adapter;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v3.ControllerV3;
import org.example.servlet.web.frontcontroller.v5.MyHandlerAdapter;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class ControllerV3HandlerAdapter implements MyHandlerAdapter {
    @Override
    public boolean supports(Object handler) {
        return (handler instanceof ControllerV3);
    }

    @Override
    public ModelView handle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws
            ServletException, IOException {
        ControllerV3 controller = (ControllerV3) handler;

        Map<String, String> paramMap = createParamMap(req);

        return controller.process(paramMap);
    }

    private static Map<String, String> createParamMap(HttpServletRequest req) {
        Map<String, String> paramMap = new HashMap<>();
        req.getParameterNames()
                .asIterator()
                .forEachRemaining(paramName ->
                        paramMap.put(paramName, req.getParameter(paramName))
                );
        return paramMap;
    }
}

보면, ControllerV3HandlerAdapter라는 구현체가 있다. 이 구현체가 하는 역할은 다음과 같다.

  • supports()에서 들어온 파라미터가 ControllerV3 타입인지를 판단한다. 만약 그렇다면, 참을 반환한다.
  • handle()supports()가 참을 반환했을 때 유효한 메서드이다. 들어온 handlerControllerV3 타입이라면 이 handle()ControllerV3가 했던 동작을 그대로 할 뿐이다.

이 둘만 가지고는 이해가 제대로 되지 않는다. FrontControllerV5를 만들어보자.

FrontControllerServletV5

package org.example.servlet.web.frontcontroller.v5;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v3.controller.MemberFormControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberListControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberSaveControllerV3;
import org.example.servlet.web.frontcontroller.v5.adapter.ControllerV3HandlerAdapter;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {

    private final Map<String, Object> handlerMappingMap = new HashMap<>();

    private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();

    public FrontControllerServletV5() {
        initHandlerMappingMap();
        initHandlerAdapters();
    }

    private void initHandlerMappingMap() {
        handlerMappingMap.put("/front-controller/v5/v3/members/new-form", new MemberFormControllerV3());
        handlerMappingMap.put("/front-controller/v5/v3/members/save", new MemberSaveControllerV3());
        handlerMappingMap.put("/front-controller/v5/v3/members", new MemberListControllerV3());
    }

    private void initHandlerAdapters() {
        handlerAdapters.add(new ControllerV3HandlerAdapter());
    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        Object handler = getHandler(req);
        if (handler == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        MyHandlerAdapter handlerAdapter = getHandlerAdapter(handler);
        ModelView mv = handlerAdapter.handle(req, resp, handler);

        MyView view = viewResolver(mv.getViewName());

        view.render(mv.getModel(), req, resp);
    }

    private MyHandlerAdapter getHandlerAdapter(Object handler) {
        for (MyHandlerAdapter handlerAdapter : handlerAdapters) {
            if (handlerAdapter.supports(handler)) {
                return handlerAdapter;
            }
        }
        throw new IllegalArgumentException("handler not found for class " + handler.getClass().getName());
    }

    private Object getHandler(HttpServletRequest req) {
        String requestURI = req.getRequestURI();
        return handlerMappingMap.get(requestURI);
    }

    private static MyView viewResolver(String viewName) {
        return new MyView("/WEB-INF/views/" + viewName + ".jsp");
    }
}
  • handlerMappingMap URL에 따라 처리하는 컨트롤러를 모두 저장해 놓는 Map이다.
  • handlerAdapters는 모든 MyHandlerAdapter 타입의 객체를 담는 List이다.
  • 생성자에서 두 가지 메서드를 호출한다. initHandlerMappingMap(), initHandlerAdapters().
  • initHandlerMappingMap()은 요청 URL에 따라 처리를 할 수 있는 컨트롤러를 모두 Map에 추가해주는 메서드이다. 위 코드를 보면 `/front-controller/v5/v3/members`로 들어온 URL은 MemberListControllerV3로 Key/Value를 가진다.
  • initHandlerAdapters()MyHandlerAdapter 타입의 모든 인스턴스를 추가한다. 위 코드를 보면 ControllerV3HandlerAdapter를 추가했음을 알 수 있다.

이제 여기서부터 실제 흐름이다. 어떤 흐름을 통해 동작하는지 이해해보자.

  • 첫번째로 FrontControllerV5로 모든 사용자의 요청이 들어오게 된다. 
  • 사용자의 요청에 따라 첫번째로 할 일은 요청 URL과 매핑해둔 컨트롤러(핸들러)를 찾는다. -> `getHandler()`
  • 만약 핸들러를 찾지 못했다면, NOT_FOUND 에러를 내보낸다.
  • 핸들러를 찾았다면, 그 핸들러를 통해 핸들러 어답터를 찾는다. 즉, 찾은 핸들러가 만약 MemberListControllerV3였다면, 이 핸들러를 통해 handlerAdapters에서 쭉 루프를 돌면서 핸들러 어답터를 찾는다. 어떻게 찾을까? handlerAdapters에는 모든 MyHandlerAdapter를 구현한 구현체가 들어있게 된다. 각 구현체는 supports()를 구현해야 하는데 이 메서드는 들어온 파라미터가 ControllerV3, ControllerV4 타입인지를 체크한다. 찾았다면 해당 핸들러어답터를 가져오고 찾지 못했다면 에러를 던진다.
  • 가져온 핸들러 어답터의 handle()을 호출한다. 이 handle()은 V3 버전인지 V4 버전인지에 따라 처리하는 로직을 구분지어 각 버전에 맞게 컨트롤러가 처리하는 로직이 담겨있다.

 

이렇게 어떤 버전의 컨트롤러라도 처리할 수 있는 FrontControllerV5가 만들어지게 된다. 스프링도 이렇게 구현해 두었다. 그래서 스프링도 컨트롤러의 메서드 중 반환타입이 단순 문자열인 메서드가 존재하고 ModelAndView 타입의 메서드가 존재해도 상관이 없는 것이다.

 

그럼 V3 관련 컨트롤러를 처리해 놨으니 V4도 어댑터를 만들어보자.

ControllerV4HandlerAdapter

package org.example.servlet.web.frontcontroller.v5.adapter;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.v4.ControllerV4;
import org.example.servlet.web.frontcontroller.v5.MyHandlerAdapter;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class ControllerV4HandlerAdapter implements MyHandlerAdapter {
    @Override
    public boolean supports(Object handler) {
        return (handler instanceof ControllerV4);
    }

    @Override
    public ModelView handle(HttpServletRequest req, HttpServletResponse resp, Object handler)
            throws ServletException, IOException {
        ControllerV4 controller = (ControllerV4) handler;

        Map<String, String> paramMap = createParamMap(req);
        Map<String, Object> model = new HashMap<>();

        String viewName = controller.process(paramMap, model);

        ModelView mv = new ModelView(viewName);
        mv.setModel(model);

        return mv;
    }

    private static Map<String, String> createParamMap(HttpServletRequest req) {
        Map<String, String> paramMap = new HashMap<>();
        req.getParameterNames()
                .asIterator()
                .forEachRemaining(paramName ->
                        paramMap.put(paramName, req.getParameter(paramName))
                );
        return paramMap;
    }
}

마찬가지로 이 V4 핸들러 어댑터는 MyHandlerAdapter를 구현한다. 그리고 구현 내용이 V3가 아닌 V4일 뿐이다.

 

그리고 FrontControllerV5를 보자.

FrontControllerServletV5

package org.example.servlet.web.frontcontroller.v5;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.web.frontcontroller.ModelView;
import org.example.servlet.web.frontcontroller.MyView;
import org.example.servlet.web.frontcontroller.v3.controller.MemberFormControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberListControllerV3;
import org.example.servlet.web.frontcontroller.v3.controller.MemberSaveControllerV3;
import org.example.servlet.web.frontcontroller.v4.controller.MemberFormControllerV4;
import org.example.servlet.web.frontcontroller.v4.controller.MemberListControllerV4;
import org.example.servlet.web.frontcontroller.v4.controller.MemberSaveControllerV4;
import org.example.servlet.web.frontcontroller.v5.adapter.ControllerV3HandlerAdapter;
import org.example.servlet.web.frontcontroller.v5.adapter.ControllerV4HandlerAdapter;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {

    private final Map<String, Object> handlerMappingMap = new HashMap<>();

    private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();

    public FrontControllerServletV5() {
        initHandlerMappingMap();
        initHandlerAdapters();
    }

    private void initHandlerMappingMap() {
        handlerMappingMap.put("/front-controller/v5/v3/members/new-form", new MemberFormControllerV3());
        handlerMappingMap.put("/front-controller/v5/v3/members/save", new MemberSaveControllerV3());
        handlerMappingMap.put("/front-controller/v5/v3/members", new MemberListControllerV3());
        handlerMappingMap.put("/front-controller/v5/v4/members/new-form", new MemberFormControllerV4());
        handlerMappingMap.put("/front-controller/v5/v4/members/save", new MemberSaveControllerV4());
        handlerMappingMap.put("/front-controller/v5/v4/members", new MemberListControllerV4());
    }

    private void initHandlerAdapters() {
        handlerAdapters.add(new ControllerV3HandlerAdapter());
        handlerAdapters.add(new ControllerV4HandlerAdapter());
    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        Object handler = getHandler(req);
        if (handler == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        MyHandlerAdapter handlerAdapter = getHandlerAdapter(handler);
        ModelView mv = handlerAdapter.handle(req, resp, handler);

        MyView view = viewResolver(mv.getViewName());

        view.render(mv.getModel(), req, resp);
    }

    private MyHandlerAdapter getHandlerAdapter(Object handler) {
        for (MyHandlerAdapter handlerAdapter : handlerAdapters) {
            if (handlerAdapter.supports(handler)) {
                return handlerAdapter;
            }
        }
        throw new IllegalArgumentException("handler not found for class " + handler.getClass().getName());
    }

    private Object getHandler(HttpServletRequest req) {
        String requestURI = req.getRequestURI();
        return handlerMappingMap.get(requestURI);
    }

    private static MyView viewResolver(String viewName) {
        return new MyView("/WEB-INF/views/" + viewName + ".jsp");
    }
}

변경되는 부분은 handlerAdaptersV4HandlerAdapter가 추가되고, handlerMappingMap에 V4관련 컨트롤러를 추가한 것뿐이다. 그 외에는 아무런 변경사항이 없다. 심지어 이 handlerAdaptershandlerMappingMap도 밖에서 주입받게 해 놓으면 아예 이 FrontCotrollerV5는 변경 사항이 아예 없어진다. 이게 변경에는 닫혀있고 확장에는 열려있는 OCP 원칙이다.

 

스프링도 이와 유사한 (거의 똑같다) 구조를 가지고 있고 이렇게 만들어 놓다가 애노테이션 기반의 컨트롤러가 대세가 되면서 애노테이션 기반의 컨트롤러를 처리할 수 있는 어댑터를 하나 만들어서 이 핸들러 어댑터에 추가만 해줄뿐이다. 그러니까 확장이 너무 유연해지고 간결해지는 것이다. 

 

결론

그레서 최종 V5의 모습은 이와 같다.

  • 중간에 핸들러 어댑터라는게 추가됐다. 이 핸들러 어댑터 덕분에 여러 버전의 컨트롤러를 만들어도 아무런 문제없이 해당 버전에 맞는 컨트롤러 처리를 할 수 있게 됐다.
  • 핸들러는 그저 컨트롤러의 다른말 일뿐이다.

이 구조가 바로 Spring MVC 구조이다. 그리고 잠깐 위에서 말했지만 애노테이션 기반의 컨트롤러가 대세가 되면서 이 애노테이션 기반의 컨트롤러를 처리할 수 있는 핸들러 어댑터 하나만 만들어주면 되는 식으로 확장이 용이하다고 했는데 스프링에서 이 핸들러 어댑터 이름이 바로 "RequestMappingHandlerAdapter"이다. 느낌이 바로 오지 않는가? V5 구조에서 만든 핸들러 어댑터 이름은 ControllerV4HandlerAdapter, ControllerV5HandlerAdapter였다. 이제 @RequestMapping("/hello") 이런 애노테이션 기반의 컨트롤러를 처리할 수 있는 핸들러 어댑터가 필요하니까 핸들러 어댑터를 만들었는데 그 이름이 저것인거다. 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

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

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

www.inflearn.com

 

MVC 패턴이란건 왜 나왔는가? 이전 포스팅까지 그 이유를 알아봤다.

서블릿, JSP를 사용해보니 여러 불편한 점이 많았고 그 중 JSP는 서블릿보단 HTML을 만들어내기가 쉽지만 담당하고 있는게 너무 많아져버린다. 화면과 비즈니스 로직을 전부 담당하고 나니 지저분해지고 보기가 힘들어진다. 이는 곧 유지보수가 어려워진다. 

 

그래서 화면은 딱 화면을 담당하는 쪽에서만, 비즈니스 로직은 비즈니스 로직을 담당하는 쪽에서만 관리하고 처리하게 하고 싶은것이다.

 

그리고 또 하나는 둘 간의 변경 사이클이 다를 확률이 높다. 무슨 말이냐면 화면에 보이는 버튼의 위치를 바꾸고 싶다는 요구사항이 생길 때 비즈니스 로직을 건들 필요가 없다. 반대로 비즈니스 로직을 해결한 기술을 바꾸고 싶을 때 화면을 구성하는 어떤 부분도 변경할 필요가 없다. 근데 두 코드가 같은 파일에 있다는 것은 유지보수하기 좋지 않다. 

 

그래서 MVC 패턴이 등장한다. Model, View, Controller로 영역을 나눈것이다.

  • 컨트롤러: HTTP 요청을 받아서 파라미터를 검증하고, 비즈니스 로직을 실행한다(호출한다). 그리고 뷰에 전달할 결과 데이터를 조회해서 모델에 담는다.
  • 모델: 뷰에 출력할 데이터를 담아둔다. 뷰가 필요한 데이터를 모두 모델에 담아서 전달해주는 덕분에 뷰는 비즈니스 로직이나 데이터 접근을 몰라도 되고, 화면을 렌더링 하는 일에 집중할 수 있다.
  • : 모델에 담겨있는 데이터를 사용해서 화면을 그리는 일에 집중한다. 여기서는 HTML을 생성하는 부분을 말한다.
참고: 컨트롤러가 비즈니스 로직을 실행할 수도 있다. 근데 사이즈가 조금만 크다면 실행하지 말고 호출해라. 비즈니스 로직을 수행하는 부분을 컨트롤러로부터 떼어내는 것이다. 일반적으로 잘 알려진 '서비스'라는 레이어로 말이다.

 

 

 

그래서, 지금부터 할 내용은 작성한 JSP 파일에서 비즈니스 로직과 뷰 부분을 떼어낼 것이다. 서블릿을 컨트롤러로 사용하고 JSP를 뷰로 사용해서 MVC 패턴을 적용해보자.

서블릿을 컨트롤러로, JSP를 뷰로

우선, 서블릿 하나를 만들자. 멤버를 생성하는 폼에 대한 컨트롤러이다.

경로는 /path/your/package/web/servletmvc로 만들었다.

 

MvcMemberFormServlet

package org.example.servlet.web.servletmvc;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@WebServlet(name = "mvcMemberFormServlet", urlPatterns = "/servlet-mvc/members/new-form")
public class MvcMemberFormServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

        String viewPath = "/WEB-INF/views/new-form.jsp";
        RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
        dispatcher.forward(req, resp);
    }
}

여기서 보면, 화면으로 뿌려줄 JSP파일의 경로를 작성해서 RequestDispatcher 객체로 만들었다. 이 객체는 서버로 이 요청이 들어오면 forward()를 통해 JSP파일로 요청을 전달해버리는 기능을 가진다. 리다이렉트와 유사한것 같지만 리다이렉트는 클라이언트와 통신을 두번한다.

 

최초의 요청 -> 서버는 리다이렉트 경로를 알려주는 정보를 가지고 응답 -> 응답 데이터에 있는 리다이렉트 확인 ->  다시 해당 정보로 서버에 요청 -> 서버가 응답.

 

이게 리다이렉트라면 이 forward()는 요청이 들어와서 서버 내부에서 호출을 해서 최종 결과를 클라이언트에게 전달해준다. 그래서 클라이언트와 서버 간 통신은 한번뿐이다. 

 

그리고 WEB-INF는 뭐냐면 기존에는 webapp안에 jsp/members/new-form.jsp 이렇게 경로를 지정해서 JSP 파일을 만들었다. 그리고 이 경로 그대로 URL에 입력하면 JSP 파일이 딱 브라우저에 뜬다. 근데 그걸 못하게 하는 것이다. WEB-INF 내부에 있는 자원들은 외부에서 직접적으로 접근하지 못하고 항상 컨트롤러를 통해 호출된다. 

 

src/main/resources/WEB-INF/views/new-form.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
<form action="save" method="post">
    username: <input type="text" name="username" />
    age:      <input type="text" name="age" />
    <button type="submit">전송</button>
</form>
</body>
</html>

여기서는 action의 경로를 유심히 봐야한다. 단순히 "save"가 끝이다. 즉, 상대경로로 POST 요청이 날라가게 설정했다. 이렇게 해두면 현재 URL에 뒤에 "/save"가 붙은 경로로 보낸다.

 

예를 들어 이 new-form.jsp를 보여주기 위한 URL은 "http://localhost:8080/servlet-mvc/members/new-form"이다. 그럼 이제 전송 버튼을 클릭하면 경로가 "http://localhost:8080/servlet-mvc/members/save"인 곳으로 POST 요청이 날라간다.

 

그럼 이제 멤버를 저장할 서블릿과 JSP를 만들어야한다.

MvcMemberSaveServlet

package org.example.servlet.web.servletmvc;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;

import java.io.IOException;

@WebServlet(name = "mvcMemberSaveServlet", urlPatterns = "/servlet-mvc/members/save")
public class MvcMemberSaveServlet extends HttpServlet {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String username = req.getParameter("username");
        int age = Integer.parseInt(req.getParameter("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        // Model에 데이터를 보관
        req.setAttribute("member", member);

        String viewPath = "/WEB-INF/views/save-result.jsp";
        RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
        dispatcher.forward(req, resp);
    }
}

 

여기서 중요한 건 모델을 만들어서 뷰에게 전달해줘야한다. 그 부분이 바로 req.setAttribute("member", member);이다.

그 다음은 똑같이 forward()를 통해 JSP 파일을 호출한다.

 

src/main/resources/WEB-INF/views/save-result.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
성공
<ul>
    <li>id=${member.id}</li>
    <li>username=${member.username}</li>
    <li>age=${member.age}</li>
</ul>
<a href="/index.html">메인</a>
</body>
</html>

모델에 담긴 데이터를 꺼내는 방법은 "${}"를 사용하면 된다. 해당 객체가 가지고 있는 프로퍼티를 그대로 꺼내서 사용한다.

한번 잘 나오는지 직접 테스트해보자.

 

이제 회원목록을 보여주는 서블릿과 JSP 파일을 작성하자.

MvcMemberListServlet

package org.example.servlet.web.servletmvc;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;

import java.io.IOException;
import java.util.List;

@WebServlet(name = "mvcMemberListServlet", urlPatterns = "/servlet-mvc/members")
public class MvcMemberListServlet extends HttpServlet {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        List<Member> members = memberRepository.findAll();

        // Model에 보관
        req.setAttribute("members", members);

        String viewPath = "/WEB-INF/views/members.jsp";
        RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
        dispatcher.forward(req, resp);
    }
}

 

src/main/resources/WEB-INF/views/members.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
<table>
    <thead>
    <th>id</th>
    <th>username</th>
    <th>age</th>
    </thead>
    <tbody>
    <c:forEach var="item" items="${members}">
        <tr>
            <td>${item.id}</td>
            <td>${item.username}</td>
            <td>${item.age}</td>
        </tr>
    </c:forEach>
    </tbody>
</table>
</body>
</html>

 

JSP에서는 여러 데이터를 하나씩 뽑아서 뿌려주는 방법이 있는데 그 중 하나가 이 녀석을 사용하는 것이다.

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>

근데 이거 진짜 그냥 이런게 있구나하고 넘어가도 된다. 필요하면 찾아보면 되는거고 JSP 쓸 일 거의 없다. 이렇게 하고 리스트를 보면 잘 나온다.

 

 

결론

이렇게 서블릿과 JSP를 사용해서 한 곳에서 작성되던 뷰와 비즈니스 로직을 쪼개서 각자가 자기것만 잘 담당할 수 있도록 해봤다. 그러다보니 코드가 이전보다 깔끔해졌다. 그러나 여전히 100% 만족스럽지 않다. 왜냐하면 서블릿에서 지금 중복 코드가 계속 반복되고 있기 때문이다. 다음 코드를 보자.

String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
dispatcher.forward(req, resp);
String viewPath = "/WEB-INF/views/members.jsp";
RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
dispatcher.forward(req, resp);
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
dispatcher.forward(req, resp);

 

viewPath의 실제 경로 말고 모든게 똑같다. 이런 공통적으로 처리될 부분들이 계속해서 중복되고 있다. 이걸 처리하는 유틸성 메서드를 만들면 조금 더 나아지겠지만 그것을 매번 호출하는 것 역시 중복이다. 그래서 이것을 더 개선하고 싶어진다.

 

공통된 부분들은 앞에서 미리 다 처리하고 들어오는 방식으로 말이다. 수문장 하나가 맨 앞에서 모든 요청을 받고 그 요청마다 공통적으로 처리되는 부분들을 거기서 전부 해결하고난 후 각각의 컨트롤러로 요청을 전달해주는 것이다. 이런 패턴을 Front Controller 패턴이라고 하고 스프링 MVC도 이 패턴을 잘 구현한 방식이다.

 

한번 직접 이 Front Controller를 만들고 MVC 패턴을 구현해보자. 

728x90
반응형
LIST
728x90
반응형
SMALL

 참고자료:

 

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

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

www.inflearn.com

 

회원 관리 애플리케이션을 서블릿을 사용해서 만들어보자. 간단하게만 일단 만들어보자.

 

Member

package org.example.servlet.domain.member;

import lombok.Data;

@Data
public class Member {
    private Long id;
    private String username;
    private int age;

    public Member(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

 

MemberRepository

package org.example.servlet.domain.member;

import lombok.Getter;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class MemberRepository {
    private static Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L;

    private static final MemberRepository instance = new MemberRepository();

    public static MemberRepository getInstance() {
        return instance;
    }

    private MemberRepository() {}

    public Member save(Member member) {
        member.setId(++sequence);
        store.put(member.getId(), member);
        return member;
    }

    public Member findById(Long id) {
        return store.get(id);
    }

    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }

    public void clearStore() {
        store.clear();
    }
}

 

데이터베이스는 없지만 메모리 상에서 회원 정보를 저장하게 해보자. 우선 그럴려면 멤버를 저장할 자료구조가 필요한데 그 HashMap을 이용해보자. 동시성 문제에 대해선 고려하지 않은채로 진행하자.

 

그리고, 이 MemberRepository는 딱 한 개의 인스턴스만 존재하는 싱글톤이다. 그래서 getInstance()로만 이 클래스의 인스턴스에 접근이 가능하도록 만들었다. 

 

save(Member member), findById(Long id), findAll(), clearStore() 이렇게 4개의 public 메서드가 있다.

 

 

테스트 코드도 작성하자.

MemberRepositoryTest

package org.example.servlet.domain.member;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

class MemberRepositoryTest {

    MemberRepository memberRepository = MemberRepository.getInstance();

    @AfterEach
    void afterEach() {
        memberRepository.clearStore();
    }

    @Test
    public void save() {
        Member member = new Member("hello", 20);

        Member savedMember = memberRepository.save(member);

        Member findMember = memberRepository.findById(savedMember.getId());

        assertEquals(savedMember, findMember);
    }

    @Test
    void findAll() {
        Member member1 = new Member("member1", 20);
        Member member2 = new Member("member2", 20);

        memberRepository.save(member1);
        memberRepository.save(member2);

        List<Member> members = memberRepository.findAll();

        assertEquals(members.size(), 2);
        assertThat(members).contains(member1, member2);
    }
}

 

Member, MemberRepository를 만들고 이를 이용한 CRUD에 대한 간단한 테스트 코드를 작성했다. 이제 서블릿을 사용해서 클라이언트와 서버간 통신을 해서 간단한 애플리케이션을 만들어보자.

 

package 경로 path/your/package/web/servlet안에 MemberFormServlet을 만들자.

이 클래스는 유저를 생성할 때 필요한 폼을 HTML로 보여주는 클래스가 될 것이다.

 

MemberFormServlet

package org.example.servlet.web.servlet;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.MemberRepository;

import java.io.IOException;
import java.io.PrintWriter;

@WebServlet(name = "memberFormServlet", urlPatterns = "/servlet/members/new-form")
public class MemberFormServlet extends HttpServlet {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

        resp.setContentType("text/html;charset=utf-8");

        PrintWriter writer = resp.getWriter();
        writer.write("<!DOCTYPE html>\n" +
                "<html>\n" +
                "<head>\n" +
                "    <meta charset=\"UTF-8\">\n" +
                "    <title>Title</title>\n" +
                "</head>\n" +
                "<body>\n" +
                "<form action=\"/servlet/members/save\" method=\"post\">\n" +
                "    username: <input type=\"text\" name=\"username\" />\n" +
                "    age:      <input type=\"text\" name=\"age\" />\n" +
                "    <button type=\"submit\">전송</button>\n" +
                "</form>\n" +
                "</body>\n" +
                "</html>\n");
    }
}

 

서블릿을 사용하면 가장 불편한건 HTML 작성이 너무너무너무 불편하다는 사실이다. 이런 불편함덕에 JSP가 나타나고 Spring MVC가 나타나고 하는거지만 결국 어떤 발전 과정이 있는지 아는게 중요하기 때문에 서블릿으로 MVC패턴을 만드는 것을 해보는 것이다.

 

여튼 저렇게 폼 하나를 만들면 우리의 서버에서 잘 뿌려주는지 확인할 수 있다.

username, age를 입력받는 폼이 잘 나온다. 지금은 전송 버튼을 누르면 에러가 발생한다. 폼을 보면 알겠지만, action="/servlet/members/save" 인데 이 경로에 대한 서블릿을 만들지 않았기 때문이다. 만들어보자.

 

MemberSaveServlet

package org.example.servlet.web.servlet;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;

import java.io.IOException;
import java.io.PrintWriter;

@WebServlet(name = "memberSaveServlet", urlPatterns = "/servlet/members/save")
public class MemberSaveServlet extends HttpServlet {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

        String username = req.getParameter("username");
        int age = Integer.parseInt(req.getParameter("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        resp.setContentType("text/html;charset=utf-8");
        PrintWriter writer = resp.getWriter();

        writer.write("<html>\n" +
                "<head>\n" +
                "    <meta charset=\"UTF-8\">\n" +
                "</head>\n" +
                "<body>\n" +
                "성공\n" +
                "<ul>\n" +
                "    <li>id="+member.getId()+"</li>\n" +
                "    <li>username="+member.getUsername()+"</li>\n" +
                "    <li>age="+member.getAge()+"</li>\n" +
                "</ul>\n" +
                "<a href=\"/index.html\">메인</a>\n" +
                "</body>\n" +
                "</html>");
    }
}

 

POST Method로 들어온 요청에 담긴 username, age를 받아와서 MemberRepository를 통해 멤버를 저장하는 로직이 있다.

그리고 저장된 멤버를 보여주는 HTML을 응답으로 돌려준다.

 

여기서 문제는, 비즈니스 로직과 응답에 대한 뷰를 처리하는게 동시에 있다는 사실이다. 즉, 이 서블릿이 하고 있는 업무가 너무 많다. 여하튼 서블릿을 이용해서 멤버를 만들어내는 폼을 보여주는 화면과 폼 데이터를 전송해서 멤버를 저장하고 저장된 멤버를 보여주는 작업을 완료했다. 아래 화면은 폼 화면에서 username에 "choi", age에 "30"을 넣고 전송버튼을 눌렀을 때 결과 화면이다.

 

 

이제 멤버리스트 화면을 보여주는 서블릿이 필요하다.

MemberListServlet

package org.example.servlet.web.servlet;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.servlet.domain.member.Member;
import org.example.servlet.domain.member.MemberRepository;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;

@WebServlet(name = "memberListServlet", urlPatterns = "/servlet/members")
public class MemberListServlet extends HttpServlet {

    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        List<Member> members = memberRepository.findAll();

        resp.setContentType("text/html;charset=utf-8");
        PrintWriter w = resp.getWriter();

        w.write("<html>");
        w.write("<head>");
        w.write("    <meta charset=\"UTF-8\">");
        w.write("    <title>Title</title>");
        w.write("</head>");
        w.write("<body>");
        w.write("<a href=\"/index.html\">메인</a>");
        w.write("<table>");
        w.write("    <thead>");
        w.write("    <th>id</th>");
        w.write("    <th>username</th>");
        w.write("    <th>age</th>");
        w.write("    </thead>");
        w.write("    <tbody>");

        for (Member member : members) {
            w.write("    <tr>");
            w.write("        <td>"+member.getId()+"</td>");
            w.write("        <td>"+member.getUsername()+"</td>");
            w.write("        <td>"+member.getAge()+"</td>");
            w.write("    </tr>");
        }

        w.write("    </tbody>");
        w.write("</table>");
        w.write("</body>");
        w.write("</html>");
    }
}

 

딱히 설명할 내용은 없는것같다. 멤버 전체를 보여주는 화면이다. 바로 서블릿이 보여주는 화면을 봐보자.

 

이렇게 멤버를 생성하고 저장된 멤버들을 보여주는 화면을 만들어봤다. 사용을 해보니 서블릿으로 동적인 HTML 파일도 만들수 있고 화면을 사용자에게 뿌려주는것도 잘되고 좋은것 같지만 서블릿의 가장 큰 단점은 HTML을 작성해내기가 너무 힘들다는 것이다. 이것을 해결하기 위해 템플릿 엔진이 나왔다. 대표적인 것이 JSP, Thymeleaf이다. JSP를 먼저 해보고 JSP가 서블릿의 문제를 어떻게 해결했고 어떤 불편함이 있길래 Thymeleaf가 나왔는지 또 알아보자.

 

서블릿 대신 JSP를 사용하기

implementation 'org.apache.tomcat.embed:tomcat-embed-jasper'
implementation 'javax.servlet:jstl:1.2'

JSP를 사용하려면 이렇게 의존성을 추가해줘야 한다.

 

이제 JSP 파일을 생성할건데, src/main/webapp/jsp 경로안에 만들어야 한다.

그래서 멤버를 생성하기 위해 입력하는 폼을 보여줄 jsp 파일을 src/main/webapp/jsp/members/new-form.jsp 만든다.

 

new-form.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
<form action="/jsp/members/save.jsp" method="post">
    username: <input type="text" name="username" />
    age:      <input type="text" name="age" />
    <button type="submit">전송</button>
</form>
</body>
</html>

 

이렇게 만들면 브라우저에 webapp 아래부터 경로 그대로를 입력해주면 된다.

 

이제 new-form.jsp에서 form이 보내는 경로인 save.jsp 파일을 만들어야 한다.

save.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="org.example.servlet.domain.member.Member" %>
<%@ page import="org.example.servlet.domain.member.MemberRepository" %>
<%
    // request, response는 그냥 사용 가능
    MemberRepository memberRepository = MemberRepository.getInstance();

    String username = request.getParameter("username");
    int age = Integer.parseInt(request.getParameter("age"));

    Member member = new Member(username, age);
    memberRepository.save(member);
%>
<html>
<head>
    <title>Title</title>
</head>
<body>
성공
<ul>
    <li>id=<%=member.getId()%></li>
    <li>username=<%=member.getUsername()%></li>
    <li>age=<%=member.getAge()%></li>
</ul>
<a href="/index.html">메인</a>
</body>
</html>

JSP는 로직과 HTML을 딱 구분지어서 이렇게 작성할 수 있다. 

이제 멤버 리스트를 보여주는 화면인 members.jsp 파일도 만들어보자.

 

members.jsp

<%@ page import="org.example.servlet.domain.member.MemberRepository" %>
<%@ page import="org.example.servlet.domain.member.Member" %>
<%@ page import="java.util.List" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
    MemberRepository memberRepository = MemberRepository.getInstance();
    List<Member> members = memberRepository.findAll();
%>
<html>
<head>
    <title>Title</title>
</head>
<body>
<table>
    <thead>
    <th>id</th>
    <th>username</th>
    <th>age</th>
    </thead>
    <tbody>
    <%
        for (Member member : members) {
            out.write("    <tr>");
            out.write("        <td>" + member.getId() + "</td>");
            out.write("        <td>" + member.getUsername() + "</td>");
            out.write("        <td>" + member.getAge() + "</td>");
            out.write("    </tr>");
        }
    %>
    </tbody>
</table>
</body>
</html>

이렇게 JSP를 이용해서, 같은 내용을 서블릿에서 JSP로 변경해봤다. 확실히 HTML을 쉽게 작성할 수 있다. 근데 여전히 맘에 들지 않는다. 두가지 일을 한 곳에서 다 해버리고 있다는 게 불편하다. 비즈니스 로직과 뷰가 동일한 곳에서 작성되다보니 지저분하다. 

 

결론

서블릿과 JSP를 사용해서 아주아주 작은 웹 애플리케이션을 구현해봤다. 

 

서블릿의 단점

  • HTML 코드를 작성하는게 너무 힘들다.

서블릿의 단점을 극복하고자 JSP가 등장했지만? 

JSP의 단점

  • 비즈니스 로직을 담당하는 부분과 화면을 담당하는 부분을 같이 다루고 있기 때문에 복잡하고 지저분하다.
  • 소스가 커지면 커질수록 감당하기 어려워진다.

저 위 코드는 비즈니스 로직이 너무너무 간단하니까 눈에 잘 들어오기라도 하지만 소스가 커지면 커질수록 점점 아찔해질거다. 이런 문제를 해결하고자 MVC 패턴이 등장한것이다. 비즈니스 로직은 비즈니스 로직만 다루고, 화면은 화면에만 집중할 수 있도록 말이다.

 

이제 스프링 MVC를 배울건데 배우기전에 MVC 패턴을 이해하는 과정이 필요하다. 그 과정을 다음에 다뤄보겠다.

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

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

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

www.inflearn.com

 

Spring MVC를 배우기 전에 어디서부터 시작되어 Spring MVC까지 도착했는지를 공부하고 싶어졌다. Spring MVC가 어떤 것을 나 대신해주고 어떤 것 때문에 사용하는지 좀 더 자세히 이해하기 위해 Servlet부터 시작해 보고자 한다. 

 

프로젝트 만들기

우선 프로젝트를 만들자. 만들 때 따로 톰캣을 설치하고 띄운 상태가 아니라면 스프링 부트의 도움을 받아서 바로 WAS 서버를 실행할 수 있게 스프링 프레임워크를 사용할거다. 서블릿은 스프링이고 스프링부트고 아무것도 없어도 되는데 딱 이 이유때문에 스프링 프레임워크를 사용할 거다.

 

IntelliJ > New Project > Spring Boot

 

중요: Packaging을 Jar말고 War로 선택한다. JSP를 사용해야 하기 때문이다. Jar와 War의 차이는 서버가 따로 있고 그 서버에 따로 톰캣을 설치하고 그 톰캣 위에 배포하는 경우 War를 사용하는데 꼭 그렇게 안하더라도 스프링 부트를 사용하면 War에 WAS서버가 내장되어 있다. 근데 JSP를 사용하기 위해선 War를 사용해야 한다.

 

Dependencies는 저 두개만 있으면 된다. Spring Web이 필요한 이유는 Apache Tomcat을 내장시키려면 저게 필요하기 때문.

 

우선 먼저 실행해봐야 한다. 잘 실행된다면 다음과 같이 로그가 찍힌다.

톰캣이 실행됐다는 로그가 찍힌다. 스프링 부트가 내장하고 있는 WAS가 띄워지는 것이다.

다시 말하지만 이거 때문에 서블릿으로 만들건데도 스프링 부트를 사용했다. 딱 이 이유뿐이다. 서블릿은 스프링과 아무런 관련이 없다. 서블릿은 톰캣 같은 웹 애플리케이션 서버를 직접 본인의 서버위에 설치하고 그 위에 서블릿 코드를 클래스 파일로 빌드해서 올린 다음 톰캣 서버를 실행하면 된다. 근데 이 과정이 번거롭기 때문에 톰캣 서버가 내장되어 있는 스프링 부트를 사용한다.

 

스프링에서 서블릿을 사용하려면 @ServletComponentScan 애노테이션을 붙여야 한다. 애노테이션을 붙이면 저 패키지 org.example.servlet 하위에 있는 모든 패키지에서 서블릿을 다 뒤지고 찾아서 자동으로 등록해준다.

 

이제 간단한 Servlet을 하나 만들어보자.

HelloServlet

package org.example.servlet.basic;


import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@WebServlet(name = "helloServlet", urlPatterns = "/hello")
public class HelloServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        super.service(req, resp);
    }
}

패키지는 org.example.servlet.basic이다.

Servlet을 만드려면 HttpServlet을 상속받아야 한다.

 

상속 받으면 service(HttpServletRequest req, HttpServletResponse resp) 메서드를 오버라이딩 할 수 있다. 이 메서드가 /hello로 요청했을 때 실행되는 메서드이다.

 

@WebServlet(name = "helloServlet", urlPatterns = "/hello") 라는 애노테이션을 붙여서 이 서블릿의 이름과 어떤 path로 이동하면 이 서블릿을 가져다가 사용할 건지에 대한 urlPatterns를 명명해준다.

 

다음처럼 클래스와 메서드명을 찍어보자.

@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

    System.out.println("HelloServlet.service");
}

 

이 상태로 다시 서버를 띄우고 "localhost:8080/hello"로 접속해보면 다음과 같이 찍힌다.

 

이제 서버로 Path가 "/hello" 요청이 들어오면 이 서블릿이 요청을 받아 개발자가 작성한 코드를 실행해준다.

 

이제 요청 정보에서 원하는 정보들을 알아오기 위해 다음 코드를 보자.

@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

    System.out.println("HelloServlet.service");

    String username = req.getParameter("username");
    System.out.println("username = " + username);

}

요청 시 파라미터가 있을 때 그 파라미터를 getParameter()로 가져올 수 있다. 

이 상태로 실행하고 다음과 같이 입력해보자.

http://localhost:8080/hello?username=hi

그럼 이렇게 찍히게 된다.

HelloServlet.service
username = hi

 

그래서 요청 데이터에서 원하는 값을 저 req 객체로부터 가져올 수 있다.

그리고 응답도 서블릿이 대신 다 해준다고 했다. 내가 원하는 응답 데이터만 서블릿한테 말해주면 된다.

아래 코드를 보자.

@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

    System.out.println("HelloServlet.service");

    String username = req.getParameter("username");
    System.out.println("username = " + username);

    resp.setContentType("text/plain");
    resp.setCharacterEncoding("UTF-8");
    resp.getWriter().println("Hello " + username);
}

 

응답 데이터에 Content-Type, Encoding, Body값을 넣어준다. getWriter().println()을 사용하면 Response Body에 데이터를 넣어줄 수 있다. 이 상태로 다시 요청을 해보면 다음과 같이 보인다.

 

이렇게 서블릿을 이용해서 HTTP 프로토콜을 통해 요청과 응답을 할 수 있게 됐다.

정리를 하자면

스프링 부트를 이용해서 프로젝트를 만들었다. 스프링 부트를 사용한 이유는 스프링 부트에 톰캣과 같은 WAS가 내장되어 있기 때문이다. 그래서 서블릿을 만들고 서블릿을 스캔하도록 설정하면 스프링 부트가 띄워질 때 서블릿 컨테이너에 만든 서블릿을 다 넣어둔다. 그리고 외부에서 이 서버로부터 요청이 들어올 때 요청을 처리하는 서블릿을 찾아 적절한 응답을 보내줬다.

 

HttpServletRequest

서블릿에서 요청 정보를 가져오려면 파라미터로 들어오는 HttpServletRequest 객체로부터 가능하다. 이 녀석은 말 그대로 HTTP 요청에 대한 모든 정보를 다 가지고 있어서 원하는 정보를 내가 쏙쏙 뽑아올 수 있다.

 

예를 들면 다음 코드를 보자.

@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

    System.out.println("req.getMethod() = " + req.getMethod());
}

 

보면 요청정보에 Method를 가져온다. GET, POST 등 요청이 어떤 Method인지 가져올 수 있단 소리고 이 말은 GET, POST, PUT, DELETE 어떤 Method라도 이 서블릿이 다 처리할 수 있다는 뜻이다. 포스트맨으로 POST로 날려보자.

실행결과:

req.getMethod() = POST

 

이번엔 GET으로 날려보자.

실행결과:

req.getMethod() = GET

 

어떤 Method라도 이 서블릿이 만능으로 다 받아줄 수 있다. 요청정보에 대한 Headers 정보도 가져올 수 있다.

@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

    req
        .getHeaderNames()
        .asIterator()
        .forEachRemaining(header -> System.out.println("header = " + header));
}

실행결과:

header = accept
header = user-agent
header = cache-control
header = postman-token
header = host
header = accept-encoding
header = connection

요청정보에 담긴 Headers 정보들이다. 이런 Key에 대한 Headers가 요청에 들어왔다는 소리이고 이 키로 값도 가져올 수 있다.

이렇게 서블릿은 요청에 대한 원하는 모든 정보를 다 가져올 수가 있다. 

 

HTTP 요청 데이터

HTTP 요청 메시지를 통해 클라이언트에서 서버로 데이터를 전달하는 방법은 크게 3가지다.

  • GET - 쿼리 파라미터
    • 메시지 바디 없이, URL의 쿼리 파라미터에 데이터를 포함해서 전달
  • POST - HTML Form
    • Content-Type: application/x-www-form-urlencoded
    • 메시지 바디에 쿼리 파라미터 형식으로 전달 (예: username=hello&age=20)
  • HTTP message body 
    • HTTP API에서 주로 사용하고 JSON, XML 형식으로 전달 가능

 

GET - 쿼리 파라미터를 통해 데이터를 전달할 때 받아보는 방법을 알아보자.

RequestParamServlet

package org.example.servlet.basic;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@WebServlet(name = "requestParamServlet", urlPatterns = "/request-param")
public class RequestParamServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

        System.out.println("[전체 파라미터 조회] - start");

        req.getParameterNames()
                .asIterator()
                .forEachRemaining(paramName -> System.out.println(paramName + "=" + req.getParameter(paramName)));

        System.out.println("[전체 파라미터 조회] - end");

        System.out.println("[단일 파라미터 조회] - start");

        String username = req.getParameter("username");
        String age = req.getParameter("age");
        System.out.println("username = " + username);
        System.out.println("age = " + age);

        System.out.println("[단일 파라미터 조회] - end");

        System.out.println("[이름이 같은 복수 파라미터 조회] - start");

        String[] usernames = req.getParameterValues("username");
        for (String name : usernames) {
            System.out.println("username = " + name);
        }

        System.out.println("[이름이 같은 복수 파라미터 조회] - end");
    }
}

전체 파라미터를 가져오는 방법은 getParameterNames()를 통해 가져올 수 있다.

 

근데 보통은 저렇게 사용안하고 내가 어떤걸 받을지 이미 알기 때문에 getParameter()로 딱 원하는 쿼리파라미터를 가져온다.

 

그리고 거의 없지만 가끔 이런 경우가 있다. http://localhost:8080/request-param?username=hello&age=20&username=hi 

이렇게 같은 키의 파라미터(username)가 있는 경우에는 getParameterValues()를 호출해서 전체 값을 다 가져올 수 있다.

 

(http://localhost:8080/request-param?username=hello&age=20) 실행결과:

[전체 파라미터 조회] - start
username=hello
age=10
[전체 파라미터 조회] - end
[단일 파라미터 조회] - start
username = hello
age = 10
[단일 파라미터 조회] - end
[이름이 같은 복수 파라미터 조회] - start
username = hello
[이름이 같은 복수 파라미터 조회] - end

 

이게 서블릿을 사용할 때 제공해주는 HttpServletRequest 객체를 통해 요청 정보 중 쿼리 파라미터 데이터를 가져오는 방법이다.

그리고 스프링을 사용해도 결국 이 서블릿의 이 기능을 통해 가져오는건데 우리를 위해 더 편리하게 해줄뿐이다.

 

이제, POST - HTML Form을 통해 데이터를 전송할 때 어떻게 데이터를 받는지 보자.

우선 그러려면 Form이 있는 HTML 파일 하나가 있어야한다.

 

src/main/webapp/basic/hello-form.html

<!DOCTYPE html>
<html>
<head>
 <meta charset="UTF-8">
 <title>Title</title>
</head>
<body>
    <form action="/request-param" method="post">
        username: <input type="text" name="username" />
        age: <input type="text" name="age" />
        <button type="submit">전송</button>
    </form>
</body>
</html>

 

간단한 Form 하나를 만들어서 테스트 해보자. 이 폼을 웹 브라우저에서 열려면 저 webapp 경로 아래의 경로 그대로를 브라우저에 입력하면 된다. 그러니까 http://localhost:8080/basic/hello-form.html 이라고 입력하면 된다.

여기에 입력을 하고 전송버튼을 누르면 서버에 어떻게 들어올까? 

실행결과:

[전체 파라미터 조회] - start
username=choi
age=20
[전체 파라미터 조회] - end
[단일 파라미터 조회] - start
username = choi
age = 20
[단일 파라미터 조회] - end
[이름이 같은 복수 파라미터 조회] - start
username = choi
[이름이 같은 복수 파라미터 조회] - end

 

GET 방식으로 받았던 코드 그대로인 상태로 수행했는데 잘 가져와진다. 그 이유는 Form으로 보내는 데이터 형식은 GET 방식의 쿼리 파라미터랑 형식이 같기 때문이다. 다음은 개발자 도구에서 네트워크 탭에서 요청 시 보여지는 Form Data이다. 쿼리 파라미터랑 형식이 똑같기 때문에 getParameter()로 여전히 가져올 수가 있다.

그리고 한가지 더 HTML Form으로 데이터를 보낼 때 Content-Type을 보면 application/x-www-form-urlencoded로 되어 있다. 이게 Form을 통해 데이터를 전달할 때 Content-Type이다. 

 

그럼 GETContent-Type이 어떻게 될까? Null이다. 왜냐하면 GET은 바디에 데이터를 태우지 않기 때문이다. 그래서 GET - 쿼리 파라미터POST - HTML Form 으로 데이터를 전송할 때 받는 방법은 둘 다 형식이 같아서 Parameter 받아오는 방법을 사용하면 된다는 것을 알았다. 물론, POST의 HTML Form으로 전송하는 데이터는 바디로 들어오는 데이터를 읽어서 가져와도 된다. 상관없다. 근데 좀 귀찮다 그 작업이 InputStream으로 가져와서 변환하고 어쩌구 해야해서 그럴 필요가 없이 getParameter()를 호출하면 된다.

 

 

HTTP Message Body로 데이터를 전송할 때 방식이 나뉜다.

  • 단순 스트링
  • JSON, XML

먼저 단순 스트링으로 보낼 때 데이터를 어떻게 받는지 보자.

RequestBodyStringServlet

package org.example.servlet.basic;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@WebServlet(name = "requestBodyStringServlet", urlPatterns = "/request-body-string")
public class RequestBodyStringServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        ServletInputStream inputStream = req.getInputStream();
        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

        System.out.println(messageBody);
    }
}

 

HttpServletRequest 객체에서 getInputStream()을 호출하면 Body의 데이터를 가져올 수 있다. 그 데이터가 바이트 형식으로 되어 있는데 이것을 스트링으로 변환할 때 여러 방법이 있는데 지금은 스프링 부트를 사용중이니까 (앞으로도 그럴거니까) 스프링이 제공하는 StreamUtils라는 클래스가 있다. 이 녀석을 사용하면 매우 편리하다.

 

한번 실행해보자. Body에 데이터를 태워야 하니까 Postman으로 테스트 해보자.

우선 POST로 설정하고 Body탭에 raw - Text를 선택하면 단순 스트링으로 바디에 데이터를 보낸다.

이렇게 전송을 하면 다음과 같이 보낸 데이터를 받아올 수 있다.

그리고 이렇게 단순 텍스트로 데이터를 보내면 Request Headers에는 Content-Type이 어떻게 되어 있을까? text/plain이다.

그러니까 Request HeadersContent-Typetext/plain으로 되어있는건 "아 요청할 때 단순 텍스트로 바디에 값을 넣었구나!"라고 생각하면 된다.

 

 

하지만, 이렇게 보내는 경우는 거의 없다. 그래서 JSON으로 데이터를 주고 받는 방법에 대해 자세히 알아야 한다. 이제 JSON으로 데이터를 주고 받을 때 어떻게 하는지 보자.

 

들어오는 JSON 데이터를 객체로 변환하려고 한다. 자바는 모든게 객체이기 때문에.

그래서 변환할 객체에 대한 클래스를 하나 만들자.

HelloData

package org.example.servlet.basic.model;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class HelloData {
    private String username;
    private int age;
}

 

RequestBodyJsonServlet

package org.example.servlet.basic.request;

import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.util.StreamUtils;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@WebServlet(name = "requestBodyJsonServlet", urlPatterns = "/request-body-json")
public class RequestBodyJsonServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        ServletInputStream inputStream = req.getInputStream();
        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

        System.out.println("messageBody = " + messageBody);
    }
}

 

JSON이라고 뭐 엄청 다른게 아니다. 단순 스트링으로 변환할 때처럼 똑같이 할 수 있다. JSON도 텍스트다. 저 상태로 한번 실행해보자.

 

Postman으로 다음과 같이 요청한다. JSON으로 바디에 데이터를 넣으려면 raw - JSON을 선택하면 된다.

이렇게 JSON으로 바디에 데이터를 넣으면 Request Headers에서 Content-Typeapplication/json이 된다.

 

요청해보면 서버에서는 다음과 같이 스트링으로 잘 받아진다.

 

근데 원하는건 이렇게 문자열로 들어오는걸로 끝이 아니라 들어오는 데이터를 객체로 변환해서 객체로 다루고 싶은 것이다. 객체로 변환하려면 라이브러리를 사용해야 한다. 스프링에서는 Jackson 라이브러리가 공식적으로 지원해주고 있다. 이 라이브러리에서 ObjectMapper라는 녀석이 있는데 이 녀석을 사용하면 된다. 가장 좋은 방식은 빈으로 딱 하나만 등록해놓고 가져다가 사용하는 것이다. 그래서 우선 빈으로 등록하자. 

 

ServletApplication

package org.example.servlet;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.context.annotation.Bean;

@ServletComponentScan
@SpringBootApplication
public class ServletApplication {

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper();
    }

    public static void main(String[] args) {
        SpringApplication.run(ServletApplication.class, args);
    }

}

빈으로 등록하는 방법은 여러가지가 있는데, 가장 간단한 건 @SpringBootApplication 애노테이션이 붙은 클래스에서 저렇게 @Bean 애노테이션으로 등록하는 것이다. 등록하면 이제 주입(DI)을 할 수 있다.

 

RequestBodyJsonServlet

package org.example.servlet.basic.request;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.example.servlet.basic.model.HelloData;
import org.springframework.util.StreamUtils;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@WebServlet(name = "requestBodyJsonServlet", urlPatterns = "/request-body-json")
@RequiredArgsConstructor
public class RequestBodyJsonServlet extends HttpServlet {

    private final ObjectMapper objectMapper;

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        ServletInputStream inputStream = req.getInputStream();
        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

        HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
        System.out.println("helloData.getUsername() = " + helloData.getUsername());
        System.out.println("helloData.getAge() = " + helloData.getAge());
    }
}

주입하는 방법은 여러가지가 있는데 생성자 주입을 통해 하는것을 스프링도 권장하고 있다. 나도 생성자 주입으로 가져왔다. "어? 생성자가 없는데요?" Lombok에서 제공하는 @RequiredArgsConstructor를 공부하고 오면 된다. 

 

그래서 스트링으로 변환된 JSON 데이터를 가지고 ObjectMapperreadValue()를 통해 객체로 변환해낸다. 실행해보자.

실행결과:

helloData.getUsername() = Choi
helloData.getAge() = 20

 

자, 흐름을 한번 짚고 넘어가보면 HttpServletRequest 객체에서 바디의 데이터를 꺼내와서 스트림에 바이트를 스트링으로 변환하고 그 스트링 형태의 데이터를 ObjectMapper를 통해 객체로 변환해내는 과정을 직접 해봤다. 이거 Spring MVC 사용하면 파라미터에 service(HelloData helloData)로 끝낼 수 있다. 그럼 스프링은 저 과정을 다 대신해준다. 근데 이런 과정을 스프링이 해준다는 것을 알고 쓰는거랑 모르고 쓰는거랑은 천지차이라고 누가 그러더라. 맞는 말인거 같고. 그래서 직접 이 과정을 한번은 해보자는 취지에서 공부중이다.

 

이제 HttpServletRequest 객체에 대해서 알아봤으니 HttpServletResponse에 대해서도 알아보자.

HttpServletResponse

요청에 대한 응답을 줄 때도 역시나 여러 정보들이 포함된다. 응답 코드, Response Body, Response Headers 요청과 똑같은 스펙에 필요한 데이터를 넣어서 응답해준다. 그래서 하나씩 살펴보자.

 

ResponseHeaderServlet

package org.example.servlet.basic.response;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@WebServlet(name = "responseHeaderServlet", urlPatterns = "/response-header")
public class ResponseHeaderServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // [response status-line]
        resp.setStatus(HttpServletResponse.SC_OK);

        // [response-headers]
        resp.setHeader("Content-Type", "text/plain");
        resp.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
        resp.setHeader("Pragma", "no-cache");
        resp.setHeader("custom-header", "hello");

        // [response body]
        resp.getWriter().write("ok");
    }
}

 

서블릿 하나를 만들었다. "/response-header"로 들어오는 요청에 대한 응답 정보를 이렇게 보낸다. 한번 테스트 해보자.

요청에 대한 응답 내용을 보면 상태 코드 200, Headers 정보엔 넣은 Key/Value가 고대로 고스란히 들어가 있다. 그리고 Body에도 역시 다음과 같이 내가 작성한 코드 그대로 들어가 있다.

이렇게 응답 정보를 만들어서 요청에 대한 응답으로 돌려줄 수 있다.

 

근데 Content-Type에 Charset 정보를 넣어주지 않았더니 자동으로 ISO-8859-1로 들어가 있는게 보이는가? 이러면 이제 한글같은 경우가 깨질수도 있다. 응답 바디에 한글로 넣으면 어떻게 나오는지 보자.

resp.getWriter().write("안녕");

단순 텍스트로 한글로 된 문자 "안녕"을 응답 바디에 넣어 돌려준다. 결과는 "??"로 나온다. 이건 서버에서 Content-Type에 대한 Charset 설정을 UTF-8로 해주지 않았기에 일어난 일이다.

 

 

그래서 응답에 Content-Type에 대한 Charset 정보를 넣어줄 수 있다. 이렇게 한 줄 수정하고 다시 돌려보자.

resp.setHeader("Content-Type", "text/plain;charset=utf-8");

이제 한글도 잘 나온다.

 

근데 이 Headers에 데이터를 추가할 때 저렇게 두 개의 파라미터를 받아서 Key, Value를 직접 작성할 수도 있고 아예 이런 메서드를 사용할 수도 있다. 이런 편의 메서드도 제공을 한다는 걸 알아두면 좋을 것 같다.

// resp.setHeader("Content-Type", "text/plain;charset=utf-8");
resp.setContentType("text/plain");
resp.setCharacterEncoding("UTF-8");

 

그리고 Content-Length 라는건 말 그대로 바디에 있는 데이터의 길이를 의미한다. Request, Response 둘 다 헤더에 넣을 수 있는데 안 넣으면 자동으로 계산이 된다. 그래서 지금 나의 코드는 헤더에 저 Content-Length를 넣지 않았지만 응답 정보를 보면 이렇게 자동으로 계산해서 보여준다.

 

Cookie도 넣을 수 있다. 한번 넣어보자.

// resp.setHeader("Set-Cookie", "myCookie=good; Max-Age=600");
Cookie cookie = new Cookie("myCookie", "good");
cookie.setMaxAge(600); // 600초
resp.addCookie(cookie);

쿠키도 역시 헤더 정보에 들어가는 데이터이기 때문에 resp.setHeader()로 넣을 수 있지만 귀찮기 때문에 객체를 생성해서 addCookie()를 사용하면 좀 더 가시성도 좋아지고 편리해진다.

 

Redirect도 해보자.

resp.setStatus(HttpServletResponse.SC_FOUND); // 302
resp.setHeader("Location", "/basic/hello-form.html");
//resp.sendRedirect("/basic/hello-form.html");

위에 두 줄이 상태 코드 302로 Redirect임을 알리고 Location"/basic/hello-form.html"로 보내라라는 의미이다.

근데 이것도 역시나 편의메서드를 제공한다. sendRedirect().

 

Network를 보면 response-header로 먼저 요청이 가고 302 Redirect로 hello-form.html로 온 것을 볼 수 있다.

 

이 정도 해두면 응답 시 사용하는 거의 대부분을 확인해본 것이다. 이제 남은건 응답 시 바디 데이터에 데이터를 보내는 것에 초점을 두고 하나씩 알아보자. 여기도 역시나 단순 스트링과 HTML, JSON이 있다.

 

HTTP 응답 메시지는 주로 다음 내용을 담는다.

  • 단순 텍스트
  • HTML
  • JSON

우선 HTML로 응답하는 경우를 보자. 이거 서블릿으로 하려면 상당히 힘들다.

ResponseHtmlServlet

package org.example.servlet.basic.response;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.io.PrintWriter;

@WebServlet(name = "responseHtmlServlet", urlPatterns = "/response-html")
public class ResponseHtmlServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        resp.setContentType("text/html");
        resp.setCharacterEncoding("UTF-8");
        
        PrintWriter writer = resp.getWriter();
        writer.println("<html>");
        writer.println("<body>");
        writer.println("<h1>안녕</h1>");
        writer.println("</body>");
        writer.println("</html>");
    }
}

우선, Content-Typetext/html로 해준다. 안 해줘도 요즘은 알아서 잘 해주는데 해주는게 정석이긴 하다.

CharacterEncoding은 UTF-8로 해주면 된다.

 

그리고 이제 PrintWriter 객체를 받아서 HTML을 작성하면 되는데, 이거 상당히 힘들다. 이게 서블릿의 엄청난 한계이다. JSP가 탄생한 이유이기도 하고. 그래서 이렇게 힘들게 HTML을 만들어서 뿌리면 다음과 같이 보여진다.

 

 

이게 이제 HTML을 응답 메시지에 넣어주는 방식이다. 가장 중요한 건 이게 아니라 JSON으로 돌려주는 HTTP API이다. 

HTTP API - JSON

ResponseJsonServlet

package org.example.servlet.basic.response;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.example.servlet.basic.model.HelloData;

import java.io.IOException;

@WebServlet(name = "responseJsonServlet", urlPatterns = "/response-json")
@RequiredArgsConstructor
public class ResponseJsonServlet extends HttpServlet {

    private final ObjectMapper objectMapper;

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        resp.setContentType("application/json");
        resp.setCharacterEncoding("UTF-8");

        HelloData helloData = new HelloData("choi", 30);

        String json = objectMapper.writeValueAsString(helloData);
        resp.getWriter().write(json);
    }
}

 

우선 응답 메시지를 JSON으로 돌려줄거니까 Content-Typeapplication/json으로 설정한다. 

그리고 객체를 하나 만들어서 그 객체를 JSON으로 만들어서 돌려줄거다. 간단하다. JSON을 객체로 변환했다면 객체를 JSON으로 변환하는것도 가능하다. ObjectMapper를 사용해서 변환한다.

 

실행 결과는 이렇다. 응답 메시지에 JSON 데이터로 잘 들어왔다.

 

이제 이 코드가 스프링에서는 그냥 반환 타입이 HelloData고 객체 만들어서 리턴만 해주면 끝난다. 이거 이렇게 된다는 걸 알고 스프링을 써도 써야지 모르면 안된다고 생각한다. 이렇게까지 하면 이제 응답 메시지를 HTML, 단순 텍스트, JSON으로 보내는거까지 다 알아본것이다. 이제 진짜 MVC패턴을 스프링없이 만들어보자.

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

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

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

www.inflearn.com

 

저번 포스팅에선 서블릿에 대해 알아봤다. 서블릿은 HTTP 프로토콜을 통해 클라이언트와 서버간 통신이 가능하게 해주는 것이었다. 개발자 대신 HTTP 요청정보와 응답정보를 만들어주고, 외부 요청이 들어오면 그 요청을 처리하는 서블릿을 서블릿 컨테이너에서 호출하면서 진행된다.

 

이제 서블릿으로 직접 웹 애플리케이션을 만들어 볼건데 그 전에 웹 기술의 역사와 HTML, HTTP API, CSR, SSR과 같은 용어 정리를 한 번 하고 넘어가자. 우선 반드시 알아두어야 할 키워드는 다음과 같다.

 

  • 정적 리소스 - 고정된 HTML 파일, CSS, JS, 이미지, 영상 등을 제공

 

  • HTML 페이지 - 동적으로 필요한 HTML 파일을 생성해서 전달

이때 브라우저는 HTML 페이지를 해석하는 역할을 한다.

 

 

  • HTTP API - HTML이 아니라 데이터를 전달한다. 주로 JSON형식을 사용하고 다양한 시스템(앱, 웹, 서버)에서 호출할 수 있다.

 

그래서 앱, 웹, 서버 어디서나 요청을 할 수 있고 요청에 따라 적절한 응답값을 화면이 아닌 데이터로 돌려주는 것이 HTTP API이다.

 

서버사이드 렌더링(SSR)

서버 사이드 렌더링은 서버에서 HTML 최종 결과물을 만들어서 웹 브라우저에 전달하는 방식이다. 주로 정적인 화면에 사용한다. 대표적인 기술로는 Thymeleaf가 있다.

클라이언트 사이드 렌더링(CSR)

HTML 결과를 자바스크립트를 사용해 웹 브라우저에서 동적으로 생성해서 적용한다. 주로 동적인 화면에 사용한다. 그러니까 서버에서는 이 화면을 만들기 위해 필요한 스크립트와 디자인을 클라이언트에 주고 클라이언트가 그 코드를 서버에 요청하는 방식이다. 대표적인 기술로는 React, Vue가 있다.

 

자바 웹 기술 역사

  • 서블릿 - 1997
    • HTML 생성이 어려움
  • JSP - 1999
    • HTML 생성은 편리하지만 비즈니스 로직까지 너무 많은 역할 담당
  • 서블릿, JSP 조합 MVC 패턴 사용
    • 모델, 뷰, 컨트롤러로 역할을 나누어 개발
  • 다수의 MVC 프레임워크가 출몰(2000 - 2010)
  • 애노테이션 기반의 스프링 MVC 등장
    • @Controller
  • 스프링 부트의 등장
    • 스프링 부트는 서버를 내장하고 있다. 무슨 말이냐면 과거에는 서버에 WAS를 직접 설치하고, 소스는 War 파일을 만들어서 설치한 WAS에 배포했는데 스프링 부트는 빌드 결과(Jar)에 WAS 서버를 포함하고 있다. 그래서 빌드, 배포의 단순화가 됐다.
    • 스프링 부트는 스프링 프레임워크로 개발할 때 필요한 여러 설정을 개발자 대신 해준다. 그리고 최적의 버전 호환성도 맞춰준다.

그래서 스프링 MVC, 스프링 부트가 최종 병기가 된 상태이다.

여기에 스프링이 공식적으로 지원하는 Thymeleaf라는 뷰 템플릿과 같이 사용하면 된다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

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

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

www.inflearn.com

 

Spring MVC를 제대로 공부하려면, Spring MVC가 없던 시절로 돌아가서 어떤 웹 기술로부터 출발했고 어떻게 발전했는지 이해하는게 중요하다는 것을 깨닫고 맨 밑바닥부터 잡고 올라와 볼 생각이다. 그 시작엔 서블릿이 있다.

 

서블릿

간단한 HTML Form에서 데이터를 서버로 전송한다고 가정해보자.

유저는 Form에 username, age를 입력하고 전송 버튼을 누르면, HTTP 프로토콜을 이용하여 POST 방식으로 서버로 요청이 전달된다.

그럼 서버입장에선 날라온 요청(Request)을 분석하고 적절한 처리를 해야할 것이다.

 

근데 만약 아무런 도움을 받지 않고 0부터 100까지 전부 다 직접 구현해야 한다면 어떨까?

 

우선 TCP/IP 연결 대기부터 시작해서 소켓을 연결하는 코드를 작성하고, HTTP 요청 메시지를 파싱하고, 어떤 Method이고 어떤 URL로의 호출인지 파악하고, 바디를 읽어서 데이터를 가져오고, .... 응답 메시지를 생성하고, 응답을 전달하는 이 모든 과정을 직접 구현해야 한다면 정말 복잡한 일이 될 것이다. 그리고 이 안에서 의미있는 비즈니스 로직은 바디에 있는 데이터를 가져와서 비즈니스 로직에 맞게 데이터를 어떻게 정제하고 데이터베이스에 저장하는 딱 이 부분밖에 없다.

 

이 모든 일련의 과정을 전세계 모든 개발자가 다 일일이 하고 있다면 너무나 비효율적이지 않을까? 여기서 서블릿이 등장한다.

서블릿의 역할

서블릿은 저 부분에서 딱 비즈니스 로직을 제외한 모든 작업을 다 대신해준다.

그래서 지금 사진처럼 딱 초록색 박스에 있는 부분만 개발자는 신경쓰면 된다.

서블릿 코드를 한번 살펴보자.

@WebServlet(name = "helloServlet", urlPatterns = "/hello")
public class HelloServlet extends HttpServlet {
    
    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response){
    	//애플리케이션 로직
    }
}

 

최신 스타일의 서블릿 코드이지만 충분하다. 저렇게 urlPatterns"/hello"라고 설정하고 웹 브라우저에서 "서버URL/hello" 라고 입력하면 이 서블릿 코드가 실행이 된다. 그래서 실제로 요청과 응답에 필요한 모든 부분은 이 서블릿이 대신 다 해주고 저 코드에서처럼 service()에서 작성한 애플리케이션 로직(비즈니스 로직)에 관련된 코드만 개발자가 작성하면 되는것이다.

 

저 service()가 호출되면 두 개의 파라미터가 들어온다. 

  • HttpServletRequest request: HTTP 요청 정보를 편리하게 사용할 수 있는 객체
  • HttpServletResponse response: HTTP 응답 정보를 편리하게 제공할 수 있는 객체

위 그림처럼 클라이언트로부터 요청이 들어오면 그 요청 정보를 직접 파싱하지 않고 서블릿이 다 해준다고 했는데 예를 들면, Method 정보라던가, Parameter 정보라던가, Content-Type 정보라던가 등 요청 시 필요한 모든 정보를 서블릿이 우리대신 다 만들어서 가져다주고 그게 HttpServletRequest이다.

 

그리고, 위 그림처럼 응답을 해줘야 하는데 이 때 응답 메시지를 직접 만드는게 아니라 서블릿이 제공해주는 HttpServletResponse 객체를 통해 내가 원하는 것을 넣어서 응답만 해주면 된다.

 

서블릿과 HTTP 요청, 응답 흐름

그래서 이 내용을 그림으로 살펴보면 다음과 같다.

 

HTTP 요청이 딱 들어오면 웹 애플리케이션 서버(WAS)와 개발자가 다음과 같은 작업을 한다.

  • WAS는 Request, Response 객체를 새로 만들어서 서블릿 객체 호출
  • 개발자는 Request 객체에서 HTTP 요청 정보를 편리하게 꺼내서 사용
  • 개발자는 Response 객체에 HTTP 응답 정보를 편리하게 입력
  • WAS는 Response 객체에 담겨있는 내용으로 HTTP 응답 정보를 생성

 

저 그림에서 서블릿 컨테이너라는 게 보이는데 쟤는 뭘까?

서블릿 컨테이너

  • 서블릿을 지원하는 WAS를 서블릿 컨테이너라고 함
  • 서블릿 컨테이너는 서블릿 객체를 생성, 초기화, 호출, 종료하는 생명주기를 관리
  • 서블릿 객체는 싱글톤으로 관리
    • 고객의 요청이 올 때 마다 계속 객체를 생성하는 것은 비효율
    • 최초 로딩 시점에 서블릿 객체를 미리 만들어두고 재활용
    • 모든 고객 요청은 동일한 서블릿 객체 인스턴스에 접근
    • 공유 변수 사용 주의
    • 서블릿 컨테이너 종료시 함께 종료
  • 동시 요청을 위한 멀티 스레드 처리 지원

 

동시 요청을 위한 멀티 스레드 처리를 지원한다는 건 어떤 의미인지 살펴보자.

 

동시 요청과 멀티 스레드

지금까지 배운 내용은 웹 애플리케이션 서버가 있고 클라이언트가 이 WAS에 HTTP 요청을 보내면 그에 상응하는 미리 만들어 둔 서블릿 객체를 서블릿 컨테이너로부터 꺼내서 요청과 응답 데이터를 편리하게 꺼내쓰고 작성하여 다시 응답을 돌려준다는 것이다.

 

이런 그림이라고 보면 되는데 여기서 궁금한 점이 있다. 저 호출단계에서 서블릿 객체는 누가 호출할까?

서블릿 객체를 호출하는 것은 스레드이다.

 

스레드

  • 애플리케이션 코드를 하나하나 순차적으로 실행하는 것은 스레드
  • 자바 메인 메서드를 처음 실행하면 main이라는 이름의 스레드가 실행
  • 스레드가 없다면 자바 애플리케이션 실행이 불가능
  • 스레드는 한번에 하나의 코드 라인만 수행
  • 동시 처리가 필요하면 스레드를 추가로 생성

스레드가 서블릿 객체를 호출하고 사용한다는 것을 알았고 이 일련의 과정을 또 그림으로 이해해보자.

 

최초의 상태

 

요청이 들어옴

요청이 들어왔으니 해당 요청에 대해 스레드를 할당한 다음 스레드는 서블릿 객체를 호출한다.

 

요청을 처리하고 응답

모든 작업이 끝남

 

 

단일 요청일 땐 이렇게 간단하게 처리가 된다. 이제 다중 요청을 봐보자.

 

요청이 들어옴

 

요청1이 들어온 상태에서 또 다른 요청2가 들어옴

요청1이 이미 스레드를 사용중이기 때문에 요청2는 대기해야한다.

그런데, 요청1이 처리가 지연되면 결국 요청1, 요청2 모두 작업을 처리하지 못하는 상태가 일어날 수 있다.

요청1과 요청2 모두 처리 불능 상태로 빠짐

 

이런 문제를 해결하기 위해서 가장 간단한 방법은 스레드를 하나 더 생성하면 된다.

요청1이 스레드를 사용하고 있는 중에 요청2가 들어와서 새로운 스레드를 만듦

 

이렇게 요청이 들어올때마다 스레드를 생성하면 어떤 장단점이 있을까?

장점

  • 동시 요청을 처리할 수 있다.
  • 리소스(CPU, 메모리)가 허용할 때까지 처리 가능
  • 하나의 스레드가 지연되어도, 나머지 스레드는 정상 동작한다.

단점

  • 스레드 생성 비용은 매우 비싸다. 고객의 요청이 올 때 마다 스레드를 생성하면 응답 속도가 늦어진다.
  • 스레드는 컨텍스트 스위칭 비용이 발생한다.
  • 스레드 생성에 제한이 없다. 즉, 고객 요청이 너무 많이 오면 CPU, 메모리 임계점을 넘어서서 서버가 죽을 수 있다.

 

그래서 이런 문제를 해결하기 위해 보통의 WAS는 스레드 풀이라는 개념을 사용한다.

풀 안에 적절한 수의 스레드를 미리 만들어서 넣어두고 요청이 들어올 때마다 스레드 풀에서 하나씩 꺼내서 서블릿 객체를 호출해서 요청을 처리한다. 그래서 풀에 있는 모든 스레드가 다 사용중일 때 요청이 들어오면 다음과 같이 그 요청부턴 대기상태로 기다리거나 또는 거절하게 된다.

 

이렇게 풀을 만들어서 사용하면 요청이 들어올 때마다 스레드를 생성하는 단점을 보완할 수 있다.

스레드 풀 특징

  • 필요한 스레드를 스레드 풀에 보관하고 관리한다.
  • 스레드 풀에 생성 가능한 스레드의 최대치를 관리한다. 톰캣은 최대 200개 기본 설정 (변경 가능)

스레드 풀 사용

  • 스레드가 필요하면, 이미 생성되어 있는 스레드를 스레드 풀에서 꺼내서 사용한다.
  • 사용을 종료하면 스레드 풀에 해당 스레드를 반납한다.
  • 최대 스레드가 모두 사용중이어서 스레드 풀에 스레드가 없으면 기다리는 요청을 거절하거나 특정 숫자만큼만 대기하도록 설정할 수 있다.

스레드 풀 장점

  • 스레드가 미리 생성되어 있으므로 스레드를 생성하고 종료하는 비용이 절약되고 응답 시간이 빠르다.
  • 생성 가능한 스레드의 최대치가 있으므로 너무 많은 요청이 들어와도 기존 요청은 안전하게 처리할 수 있다.

 

그럼, 이 최대 스레드 수는 어떻게 설정하면 좋을까?

WAS의 주요 튜닝 포인트는 최대 스레드(max thread)수이다.

  • 이 값을 너무 낮게 설정하면? - 동시 요청이 많으면 서버 리소스는 여유로워도 클라이언트는 금방 응답 지연
  • 이 값을 너무 높게 설정하면? - 동시 요청이 많으면 CPU, 메모리 리소스 임계점 초과로 서버 다운

이 값을 너무 낮게 설정한다면 사용가능한 리소스(CPU, 메모리)는 넘쳐나는데도 고객은 응답을 지연받으니 매우 안좋은 결과가 생기고 이 값을 너무 높게 설정하면 동시 요청이 많아질수록 사용하는 리소스도 많아지다가 임계점 초과로 서버가 다운된다. 그래서 적절한 수의 최대 스레드 수가 필요하다. 

 

그럼 적절한 수는 어떻게 찾아요?

정답은 없고 최대한 실제 서비스와 유사하게 성능 테스트를 해가면서 적절한 숫자를 찾아야 한다. (JMeter, nGrinder와 같은)

 

가장 중요한 핵심은,

멀티 스레드에 대한 부분은 WAS가 다 처리해준다는 것.

개발자는 멀티 스레드 관련 코드를 신경쓰지 않고 아까 저 위에 코드처럼 service() 코드만 작성하면 된다.

그리고 최대 스레드 개수만 적절하게 설정해주면 된다. 

멀티 스레드 환경이므로 싱글톤 객체(서블릿, 스프링 빈)의 공유변수만 주의해서 사용하면 된다.

728x90
반응형
LIST

+ Recent posts