스프링MVC-3.활용

소개

지금까지 스프링 MVC의 원리와 설정 방법과 흐름을 보았다
이 일련의 과정을 실제로 어떻게 잘 활용할 것인지 목적으로
각각의 기능을 다시 살펴본다
(https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html#mvc-controller)

애노테이션 기반의 스프링 MVC
요청 맵핑하기
핸들러 메소드
모델과 뷰
데이터 바인더
예외 처리
글로벌 컨트롤러

사용할 기술
스프링 부트
스프링 프레임워크 웹 MVC
타임리프

학습 할 애노테이션
@RequestMapping
@GetMapping, @PostMapping, @PutMapping, …
@ModelAttribute
@RequestParam, @RequestHeader
@PathVariable, @MatrixVariable
@SessionAttribute, @RequestAttribute, @CookieValue
@Valid
@RequestBody, @ResponseBody
@ExceptionHandler
@ControllerAdvice

HTTP 요청 매핑하기

1. 요청 메소드

  • HTTP Method
    • GET, POST, PUT, PATCH, DELETE, …
    • 컨트롤러에 @RequestMapping에서 HTTP Method를 지정하지 않으면 모든 메소드를 허용한다
    • 제한을 주고 싶으면 @RequestMapping(value = "/hello", method = {RequestMethod.GET, RequestMethod.PUT}) 식으로 허용되는 메소드를 정할 수 있다
    • @RequestMapping을 메소드가 아닌 클래스에 붙이면 클래스의 모든 핸들러가
      해당 메소드만 허용하게 된다
    • 허용되지 않는 요청이 들어오면 405응답이 떨어지는데 이를 Mock에서 테스트 할때는
      ‘isOK()’대신 ‘isMethodNotAllowed()’를 쓰면 테스트 할 수 있다
  1. GET 요청
  • 클라이언트가 서버의 리소스를 요청할 때 사용
  • 캐싱 가능(응답 헤더에 캐싱조건 전송 가능.조건적 GET으로 바뀔 수 있다)
  • 브라우저 기록에 남는다
  • 북마크 가능
  • 민감한 데이터를 보낼 때 사용하지 말 것.(URL에서 다 보이므로)
  • idemponent:멱등(冪等). 동일한 요청은 반복해도 결과가 동일하다
  1. POST 요청
  • 클라이언트가 서버의 리소스를 수정하거나 새로 만들 때 사용
  • 서버에 보내는 데이터를 POST 요청의 본문에 담는다
  • 캐시할 수 없다
  • 브라우저 기록에 남지 않는다
  • 묵마크 불가
  • 데이터 길이에 제한이 없다
  1. PUT 요청
  • URI에 해당하는 데이터를 새로 만들거나 수정할 때 사용한다
  • POST와 다른 점 : URI에 대한 의미가 다름
    • POST의 URI: 보내는 데이터를 처리할 리소스를 지칭
    • PUT의 URI: 보내는 데이터에 해당하는 리소스를 지칭
  • idemponent : 이것도 POST와 다른점이라 할 수 있다
  1. PATCH 요청
  • PUT과 비슷하나, 기존 엔티티와 새로운 데이터의 차이점만 전송한다
  • 리소스의 일부분만 보내도 될 경우 PATCH 사용을 설계로 고려
  • idemponent
  1. DELETE 요청
  • URI에 해당하는 리소스를 삭제할 때 사용
  • idemponent

2. URI 패턴 매핑

  • 복수 URI? @RequestMapping({“/hello”, “/hi”})

  • URI> URL, URN, URI가 훨씬 큰 범위

  • 요청 식별자로 매핑 - @RequestMapping이 지원하는 패턴

    • ? : 한글자
      • : 여러글자
    • ** : 여러패스
    • 만약 동시에 여러 매핑이 된다면 어떻까?
      예를들어서 메소드1에는”/hello” , 메소드2에는 “/**“여서 둘다 해당된다면?
      –> 답은 메소드1, 가장 구체적으로 매핑 정보가 있는 곳에 매핑
      –> 스프링MVC에 있는 로직
  • class 매핑과 조합

    • Class에 @RequestMapping(“/abc”), 메소드에 @RequestMapping(“/123”)이라면
      조합되어 /abc/123이라는 URI로 매핑된다
  • 정규 표현식 매핑 가능

    • 형식: /{name:정규식}
    • 예) “/{name:[a-z]+}” :a에서 z까지 오는 문자 여러개가 올 수 있음
    • 여기서 name은 @PathVariable로 받아 올 수 있는 이름이다
      정규 표현식 매핑이 사용된 예제 컨트롤러와 테스터
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      // 컨트롤러
      @Controller
      @RequestMapping("/hello")
      public class SampleController {

      @RequestMapping(value = "/{name:[a-z]+}")
      @ResponseBody
      public String hello(@PathVariable String name) {
      return "hello "+ name;
      }
      }
      // 테스트 클래스
      @RunWith(SpringRunner.class)
      @WebMvcTest
      public class SampleControllerTest {

      @Autowired
      MockMvc mockMvc;
      @Test
      public void helloTest() throws Exception {
      mockMvc.perform(get("/hello/ahn"))
      .andDo(print())
      .andExpect(status().isOk())
      .andExpect(content().string("hello ahn"));
      }
      }
  • 설명

    • 현재 클래스의 /hello와 조합되어 /hello//{name:[a-z]+}의 URI가 되었다
    • mock에서 ahn을 넣으면 이게 정규식을 통과되면 name 이름을 가진 PathVariable이
      되며 name으로 받는다 만약 /hello/123처럼 정규식외의 URI가 들어오면 매핑과 어긋나는 요청이므로 404에러가 난다
  • URI 확장자 매핑 지원

    • 스프링 MVC는 기본적으로 지원한다
      예를들어 “/hello”라고만 매핑하면 스프링MVC는 암묵적으로 /hello.*가 등록된다
    • 이를 이용해서 /hello.json, /hello.xml, /hello.html등의 URI 요청을 받아서
      처리하려고 사용하였다
    • 이 기능은 권장되지않음 실제로 스프링부트는 기본으로 이 기능을 비활성화
      • 보안 이슈 - RFD Attack
      • URI 변수, Path 매개 변수, URI 인코딩을 사용하려 할 떄 불명확
    • 따라서 최근에는 헤더의 accept-header정보를 사용하는 추세
    • 굳이 사용하려면 /hello, /hello.*을 전부 매핑하면 사용은 가능하나 권장X

3. 컨텐츠 타입 매핑

위에서 봤던 Http Method의 종류에 따라 핸들러를 매핑했듯이
요청의 바디에 들어있는 데이터에 따라서 다른 핸들러를 매핑 할 수도 있다
예를 들어서 어떤 핸들러는 요청의 바디에 xml 데이터가 들어있을 때만 처리하는 핸들러라던지 json타입일떄 핸들링되는 핸들러라던지를 만들고 싶을떄는 어떻게 하면 좋을까

1) 특정 타입 데이터를 담은 요청만 처리하는 핸들러

  • @RequestMapping에 consumes로 데이터 타입을 지정 가능

  • Content-Type 헤더로 필터링

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @Controller
    public class SampleController {

    //consumes에 직접 문자열을 주어도 되지만
    //@RequestMapping(value = "/hello", consumes = "application/json")

    //스프링에서 지원하는 MediaType 상수를 쓰는 것이 오타를 막을 수 있다
    @RequestMapping(value = "/hello", consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
    @ResponseBody
    public String hello() {
    return "hello";
    }
    }
  • APPLICATION_JSON_UTF8_VALUE 와 APPLICATION_JSON_UTF8의 차이

    • APPLICATION_JSON_UTF8_VALUE는 문자열 상수
    • APPLICATION_JSON_UTF8는 실제의 MediaType
  • APPLICATION_JSON 과 APPLICATION_JSON_UTF8의 차이

    • APPLICATION_JSON:”application/json”
    • APPLICATION_JSON_UTF8: “application/json;charset=UTF-8”
    • 특정 브라우저에서 헤더 Content-Type의 charset을 이용하는 경우가 있음
    • 허나 이것은 스펙이 아님
    • 따라서 APPLICATION_JSON과 APPLICATION_JSON_UTF8을 서로 헤더와 핸들러
      스펙에 혼용하여도 작동 자체에는 이상이 없다
  • 가급적이면 문자열대신 문자열상수를 이용하도록하자
    문자열 따옴표 안의 오타는 코드로 잡기가 어려워서 오로지 눈으로 잡아야하므로
    어렵다

  • 이렇게 하면 요청 헤더의 Content-Type에 JSON일때만 해당 핸들러가 작동한다

  • 따라서 헤더 정보가 없는 현재의 테스트가 깨지고
    기본적으로 text/html타입인 브라우저의 테스트에서도 실패할 것이다
    415 응답 코드 - Not Supported MediaType
    테스트의 Content-Type 헤더에 JSON을 설정하면 테스트가 성공한다

    perform안의 contentType에 JSON설정
    1
    2
    3
    4
    5
    6
    7
    @Test
    public void helloTest() throws Exception {
    mockMvc.perform(get("/hello")
    .contentType(MediaType.APPLICATION_JSON)) //Content-Type 설정
    .andDo(print())
    .andExpect(status().isOk());
    }
  • 설명

    • perform안의 contentType에 JSON설정
    • contentType이 오버로딩으로 문자열과 MediaType을 받는 2개로 구성되어 있으므로
      APPLICATION_JSON_VALUE 와 APPLICATION_JSON 중 아무거나 써도 된다

2) 특정한 타입의 응답을 만드는 핸들러

이번엔 반대로 특정한 타입의 응답을 만드는 방법
앞에서 보았던 예제로 따지면 accept 헤더에 값을 넣어서 원하는 응답의 타입을 설정

보내는 데이터 값을 요청의 헤더의 Content-Type에 설정 하였다면
필수는 아니지만 리턴되는 응답 바디에 대한 데이터 타입을 accept를 통해 요청할 수
있다 예를들어서 밑의 테스트는 JSON으로 요청하고 JSON응답을 기대한다

perform안의 Accept에 JSON설정
1
2
3
4
5
6
7
8
@Test
public void helloTest() throws Exception {
mockMvc.perform(get("/hello")
.contentType(MediaType.APPLICATION_JSON))
.accept(MediaType.APPLICATION_JSON)) //Accept 헤더 설정
.andDo(print())
.andExpect(status().isOk());
}

만약 핸들러가 요청은 JSON으로 받았지만 응답은 오로지 text/plain으로만 하겠다
라고 한다면 ?

RequestMapping에 produces을 이용해서 응답에 제한을 걸 수 있다
1
2
3
4
5
6
7
8
9
10
11
@Controller
public class SampleController {
@RequestMapping(
value = "/hello",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.TEXT_PLAIN_VALUE) //응답타입을 하나로 제한
@ResponseBody
public String hello() {
return "hello";
}
}

이렇게 한 경우 이 핸들러는 plain/text를 원하는(accpet로) 요청만 처리하게 된다
따라서 위의 JSON을 accept에 넣은 테스트는 실패하게 된다
응답코드는 406 Not Supported 응답이 떨어진다

accept 헤더를 Text로 바꾼다
1
2
3
4
5
6
7
8
9
@Test
public void helloTest() throws Exception {
mockMvc.perform(get("/hello")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_PLAIN_VALUE)) //TEXT 응답 accept
.andDo(print())
.andExpect(status().isOk())
;
}

이러면 테스트가 성공하게 된다
여기서 주의할 점이 있는데 바로 Accept헤더 자체가 없는 경우다
테스트에서 아예 accept를 설정을 하지 않는 경우에는 이때는 accept에 대해서
제한하지 않고 모든 타입의 응답을 다 받아들이겠다는 암묵적인 표현이 된다
따라서 accpet없이 테스트를 해도 텍스트 출력과 함께 200응답코드를 보면서 성공하게
된다

기억할 것 : produces로 응답을 제약해서 Accept 헤더가 없으면 매핑이 된다는 점

클래스의 consumes과 메소드의 consumes이 다를 경우에는 어떻게 될까?

클래스의 consumes과 메소드의 consumes이 다른 예
1
2
3
4
5
6
7
8
9
@Controller
@RequestMapping(consumes = MediaType.APPLICATION_XML_VALUE) //XML
public class SampleController {
@RequestMapping(
value = "/hello",
consumes = MediaType.APPLICATION_JSON_VALUE) //JSON
@ResponseBody
public String hello() { return "hello"; }
}
  • 오버라이딩 된다
  • @RequestMapping의 Value(매핑URL)이 클래스와 메소드의 값이 조합되는 것과 상반되게
    consumes는 클래스의 값을 메소드의 설정값이 덮어 써짐을 주의
  • produces도 consumes과 마찬가지로 오버라이딩 된다

4. 헤더와 파라미터 매핑

  • 위의 3번보다 약간더 제너럴한 방법

  • 특정 요청을 처리하고 싶은 경우

    • 특정 헤더가 있는 요청
      • @RequestMapping(header=”key”)
    • 특정 헤더가 없는 요청
      • @RequestMapping(header=”!key”)
    • 특정 헤더 키/값이 존재하는 요청
      • @RequestMapping(header=”key=value”)
    • 특정 요청 매개변수 키를 가지고 있는 요청
      • @RequestMapping(params=”a”)
    • 특정 요청 매개변수가 없는 요청
      • @RequestMapping(params=”!a”)
    • 특정 요청 매개변수 키/값을 가지고 있는 요청
      • @RequestMapping(params=”a=b”)
  • 테스트에서 헤더

    • 요청을 만들어야하는 MockMVc의 Perform()안에서 header()로 가능하다
    • 문자열로 키/값(name,value)를 줄 수 있지만 3번처럼 오타의 위험이 있으므로
      상수를 사용한다
    • 상수는 MediaType처럼 spring안의 http안에 HttpHeaders로 선언이 되어 있다
  • 테스트에서 파라미터

    • MockMvc의 Perform()안에서 param()으로 가능하다
    • 테스트 실패시 특정 파라미터가 400 Bad Request

이것이 3번에서 봤던 방법의 더 제너럴한 방법이다
예를 들어 3번의 consumes는 4번 방법의 header=HttpHeaders.CONTENT_TYPE으로
처리가 가능하지만 더 편하게 쓰기 위해 3번의 consumes,produces가 있는 것

5. HEAD와 OPTIONS 요청 처리

  • 개발자가 직접 구현하지 않아도 스프링MVC에서 자동으로 처리하는 HTTP Method
    • HEAD
      • 요청은 GET 요청과 동일
      • 응답의 경우 본문을 제외하고 응답 헤더만 보냄
      • 직접 구현도 가능하겠지만 만들 필요가 전혀 없음
        (이미 GetMapping이 있다면 더더욱 필요없음)
      • 설정하지 않아도 이미 스프링 MVC에서 알아서 처리함
    • OPTIONS
      • 사용 가능한 HTTP Method 제공
      • 서버 또는 특정 리소스가 제공하는 기능을 확인할 떄 사용
      • 서버 살아 있는지 , 해당 리소스를 처리 할 수 있는
      • ping 식의 방법으로 200응답이 나오는지, 특정 HTTP Method가 응답 헤더의
        allow에 들어 있는지
        OPTIONS 테스트 해보기
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        //컨트롤러, POST,GET 처리 가능하게 함
        @Controller
        public class SampleController {

        @GetMapping(value = "/hello",params = "name")
        @ResponseBody
        public String hello(@RequestParam String name) {
        return "hello, "+name;
        }

        @PostMapping(value = "/hello",params = "name")
        @ResponseBody
        public String hello2(@RequestParam String name) {
        return "hello, "+name;
        }
        }

        // test
        @Test
        public void helloTest() throws Exception {
        mockMvc.perform(options("/hello"))
        .andDo(print())
        .andExpect(status().isOk())
        .andExpect(header().exists(HttpHeaders.ALLOW))//Allow 유무만 테스트
        ;
        }
        //OPTIONS에 대한 응답 헤더의 allow로 현재 서비스 가능한 HTTP Method 이름이 담긴다
        /*
        Status = 200
        Headers = [Allow:"POST,GET,HEAD,OPTIONS"]
        */

만약 Allow에 담겨 있는 내용을 테스트 하고싶으면 어떻게 하면 될까?

  • .andExpect(header().stringValues(HttpHeaders.ALLOW,"GET","POST","HEAD","OPTIONS"))
    • error
    • allow안의 내용이 같더라도 순서가 안맞으면 에러가 발생한다
    • 안타깝게도 순서를 안준다던지의 flag는 존재하지 않음
    • 다른 방법 필요
      순서에 없이 테스트 하는 방법, 모든 아이템이 존재해야 테스트 성공
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      import static org.hamcrest.CoreMatchers.containsString;
      import static org.hamcrest.CoreMatchers.hasItems;
      @Test
      public void helloTest() throws Exception {
      mockMvc.perform(options("/hello")
      .param("name","test")
      )
      .andDo(print())
      .andExpect(status().isOk())
      .andExpect(header().stringValues(HttpHeaders.ALLOW,
      hasItems(
      containsString("GET"),
      containsString("POST"),
      containsString("HEAD"),
      containsString("OPTIONS")
      )
      ))
      ;
      }

6. 커스텀 애노테이션

좀더 간결한 표현 혹은 구체적인 표현을 위해서 기존 애노테이션을 조합한 새로운 애노테이션이 탄생하기도 한다 내가 느끼기엔 @GetMapping도 그러하다

@GetMapping의 선언 부분, 여러 메타 애노테이션이 보인다
1
2
3
4
5
6
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.GET)
public @interface GetMapping {
}
  • 설명
    • 기존 RequestMapping에 method를 붙여야했고 필요한 너머지를 붙이면 엄칭 길어짐
    • 엄밀히 말하면 GetMapping은 스프링에서 제공하는 애노테이션이지만 이런식으로
      개발자도 커스텀한 애노테이션이 가능하다는 설명하는 측에선 최선의 예시라는 생각
    • 메타 (Meta) 애노테이션
      • 애노테이션에 사용할 수 있는 애노테이션
      • 스프링이 제공하는 대부분의 애노테이션은 메타 애노테이션으로 사용 가능하다
    • Composed Annotation(조합 애노테이션)
      • 한개 혹은 여러개의 메타 애노테이션을 조합해서 만든 애노테이션
      • 코드를 간결하게 줄일 수 있다
      • 보다 구체적인 의미를 부여할 수 있다
    • @Retention
      • 스프링 MVC 이야기가 아닌 자바 애노테이션에 대한 이야기임
      • 해당 애노테이션 정보를 언제까지 유지할 것인가
      • 종류 : RetentionPolicy.*** , 기본값:Class
        • Source: 소스코드까지 유지, 컴파일하면 해당 애노테이션 정보는 사라짐
        • Class: 컴파일한 class파일까지 유지, 클래스 메모리 로딩시 정보가 사라짐
        • Runtime: 메모리에서도 유지, 코드에서 이정보를 바탕으로 특정 로직 실행가능
    • @Target
      • 해당 애노테이션을 어디서 사용가능한가
      • 위의 ElementType.METHOD같은 경우 메소드에서만 사용가능한 애노테이션이 된다
    • @Documented
      • 해당 애노테이션을 사용한 코드의 문서에 그 애노테이션에 대한 정보를 표현여부
    • @Inherited
      • 이 애노테이션을 선언하면 자식클래스가 애노테이션을 상속 받을 수 있다
    • @Repeatable
      • 반복적으로 애노테이션을 선언할 수 있게 한다
    • 메타 애노테이션

현재 컨트롤러의 @GetMapping(“/hello”)에대해서 커스텀 애노테이션을 만들어본다면?

1
2
@RequestMapping(method = RequestMethod.GET, value="/hello")
public @interface @GetHelloMapping{}
  • 설명
    • 테스트하면 실패한다
      • @Retention 설정이 없으므로 기본값인 CLASS로 설정이 된다
      • 따라서 @GetHelloMapping 애노테이션이 사용된 컨트롤러 바이트 코드가 메모리
        로딩시에 해당 애노테이션 정보가 사라지게 된다
      • DispatcherServlet에서 컨트롤러를 빈 등록하려 객체화할때 이미 컨트롤러 클래스
        에는 애노테이션이 존재하지 않는다
      • RUNTIME으로 @Retention하면 테스트 성공
    • @GetMapping 은 메타 애노테이션이 아니므로 @GetHelloMapping을 만드는데
      사용할 수 없다

핸들러 메소드 상세

1. 아규먼트와 리턴타입

굉장히 방대하고 다양하다
핸들러 메소드 아규먼트: 주로 요청 그 자체,또는 요청에 들어있는 정보를 받아오는데 사용

  • 요청 또는 응답 자체에 접근 가능한 API
    • WebRequest: 스프링이 제공, low레벨
    • NativeWebRequest: 스프링이 제공, low레벨 가능
    • ServletRequest(Response) : 서블릿 레벨
    • HttpServletRequest(Response) : 서블릿 레벨
    • 서블릿 레벨에서 어차피 getReader나 getWriter를 꺼낼꺼라면 밑의 방법으로
  • 요청 본문을 읽거나, 응답 본문을 쓸 떄 사용할 수 있는 API

    • InputStream
    • Reader
    • OutputStream
    • Writer
  • 스프링5, HTTP/2 리소스 푸쉬에 사용
    • PushBuilder
  • GET,POST,PUT등의 HTTP Method에 대한 정보
    • HttpMethod
  • LocaleResolver가 분석한 요청의 Locale 정보
    • Locale
    • TimeZone
    • ZoneId

많이 사용하게 되는 것들

  • @PathVariable
    • URI 템플릿 변수 읽을 때 사용
  • @MatrixVariable
    • URI 경로에서 키/값 쌍을 읽어 올 때
    • 잘 사용하지는 않는다
  • @RequestParam
    • 서블릿 요청 매개변수 값을 선언한 메소드 아규먼트 타입으로 변환한다
    • 단순 타입인 경우 이 애노테이션 생략이 가능하다
  • @RequestHeader
    • 요청 헤더 값을 선언한 메소드 아규먼트 타입으로 변환
  • @RequestParam
    • Http 요청 본문의 데이타를 HttpMessageConverter를 사용해서 특정 타입 으로 교체
    • REST API 만들떄 많이 사용
  • HttpEntity
    • 위 @RequestParam과 비슷하게 사용 가능
    • @RequestParam이 본문데이터만 첨부하는 것과 달리 HttpEntity는 헤더까지 포함
      해서 좀더 다양한 정보를 접근
  • @RequestPart
    • 파일 업로드시 사용

-참고: https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html#mvc-ann-arguments

핸들러 메소드 리턴: 주로 응답, 또는 모델을 렌더링할 뷰에 대한 정보를 제공하는데 사용

  • @ResponseBody

    • 리턴값을 HttpMessageConverter를 사용해 응답 본문으로 사용
    • 역시 REST API 개발시 많이 사용
  • HttpEntity, ResponseEntity

    • 응답 몬문 뿐 아니라 헤어 정보까지 포함해서 전체 응답을 만들 때
      1
      2
      3
      4
      5
      6
      7
      @GetMapping("/events")
      @ResponseBody
      public ResponseBody<String> evetnts(){ //본문 데이터 타입을 알고 있을때 명시
      ResponseEntity<String> build = ResponseEntity.ok().build();
      return build;
      }

  • 응답코드, 응답헤더, 응답 본문을 전부 세팅 가능 하므로 REST API를 좀더 심도 있게
    만들기 위해서는 결국 ResponseEntity를 쓰게 된다

  • REST API 공부시 더 파고 지금은 이런게 있다는 정도만

  • String

    • ViewResolver를 사용해서 뷰를 찾을때 사용할 뷰 이름
  • View

    • 암묵적인 모델 정보를 렌더링할 뷰 인스턴스
    • ViewResolver를 사용하지 않고 직접 뷰를 알때 쓰는 방법
  • Map, Model

    • (RequestToViewNameTranslator를 통해)암묵적으로 판단한
      뷰 렌더링시 사용할 모델 정보
    • RequestToViewNameTranslator가 찾는다고 가정하기에 뷰정보가 없는
      모델 정보만 넘긴다
  • @ModelAttribute

    • (RequestToViewNameTranslator를 통해)암묵적으로 판단한
      뷰 렌더링시 사용할 모델 정보에 추가한다
    • 이 애노테이션은 생략 가능하다
  • 참고:https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html#mvc-ann-return-types

2. URI 패턴

  • @PathVariable

    • 요청 URI 패턴의 일부를 핸들러 메소드 아규먼트로 받는 방법
    • 타입 변환 지원
    • (기본)값이 반드시 있어야 한다
    • Optional 지원
  • @MatrixVariable

    • 요청 URI 패턴에서 키/값 쌍의 데이터를 메소드 아규먼트로 받는 방법
    • RFC3986에서 논의
    • ex)GET /pets/42;q=11;r=22
    • 위의 예에서 /pets/42까지가 uri이고 이후에는 키와값이다
    • 타입 변환 지원
    • (기본)값이 반드시 있어야 한다
    • Optional 지원
    • 기본적으로 비활성화 상태이며 활성화하려면 다음과 같이 설정한다
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      @Configuration
      public class WebConfig implements WebMvcConfigurer {

      @Override
      public void configurePathMatch(PathMatchConfigurer configurer) {
      UrlPathHelper urlPathHelper = new UrlPathHelper();
      urlPathHelper.setRemoveSemicolonContent(false);
      configurer.setUrlPathHelper(urlPathHelper);
      }
      }
  • 설명

    • PathMatch 메소드 재정의
    • urlPathHelper를 새로 만들어 세미콜론을 제거하지 않도록 만들고
      cinfigurer의 pathhelper로 등록한다
  • 참고

3. 요청 매개변수(단순타입)

  • @RequestParam

    • 요청 매개변수에 있는 단순 타입 데이터를 메소드 아규먼트로 받아올 수 있다
    • 값이 반드시 있어야 한다
      • required=false 또는 Optional을 사용해서 부가적인 값으로 설정할 수 있다
        • ex) @RequestParam(required=false, defaultValue=”ahn”)
        • 이렇게 하면 요청이 안들어오면 기본값을 줄 수 있다
    • String이 아닌 값들은 타입 컨버전을 지원
      기초적인 RequestParam 예제
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      //컨트롤러
      @Controller
      public class SampleController {
      @PostMapping("/events")
      @ResponseBody
      public Event getEvent(@RequestParam String name) {
      Event event = new Event();
      event.setName(name);
      return event;
      }
      }

      //test
      @Test
      public void helloTest() throws Exception {
      mockMvc.perform(post("/events?name=ahn"))
      .andDo(print())
      .andExpect(status().isOk())
      .andExpect(jsonPath("name").value("ahn"));
      }
    • Map<String, String> 또는 MultiValueMap<String, String>에 사용해서
      모든 매개변수를 받아 올 수도 있다
      (이때는 애노테이션 생략하면 에러발생을 확인- Map을 쓸때는 생략하 지말자)
      Map으로 모든 매개변수를 받아온후 필요한 값을 Map에서 찾아 쓰는 예
      1
      2
      3
      4
      5
      6
      7
      @PostMapping("/events")
      @ResponseBody
      public Event getEvent(@RequestParam Map<String, String> params) {
      Event event = new Event();
      event.setName(params.get("name"));
      return event;
      }
    • 애노테이션을 생략 가능하다. 핸들러에 파라미터가 있는 경우 이 애노테이션이
      생략되었는지 확인하자
  • 요청 매개 변수?

    • 쿼리 매개변수
    • 폼 데이터
      쿼리 매개변수는 위에서 보았고 폼데이터를 보자

4. 매개변수의 폼 데이터 Submit

  • 폼 예제
    • GET /event/form
    • 뷰: event/form.html
    • 모델 “event”, new Event() -> Form Backing Object
      • Form Backing Object 혹은 Command Object라고 부른다

      • Form의 데이타를 모으기위한 POJO객체이며 데이타만 포함한다

      • 스프링의 튜토리얼에서는 커맨드 객체라고 많이 표현한다

      • 타임리프

        • @{}:Link(URL) 표현식
        • 사용 예:
          • URL을 상황에 맞게 만든다고 생각하면 된다
          • 뒤에 세션아이디를 붙여줄 수도 있고 context ROOT에 설정된 값이 있다면
            이를 붙여준다
          • 현재 예제는 contextRoot가 “/“이기 때문에 사실 th:action이 아닌 일반
            폼 액션에서 action=”/event”해도 동작은 할 것이다
        • ${} : variable 표현식
        • *{} : selection 표현식
get요청이 들어오면 폼을 보여주고 post요청이 들어오면 해당객체를 출력
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Controller
public class SampleController {

@GetMapping("/events/form")
public String eventsForm(Model model) {
Event newEvent = new Event();
newEvent.setLimit(50);//기본값을 가졌기에 form에 이 값이 나타난다
model.addAttribute("event", newEvent);
return "/events/form";
}

@PostMapping("/events")
@ResponseBody
public Event getEvent(String name, Integer limit) {
Event event = new Event();
event.setName(name);
event.setLimit(limit);
return event;
}
}
  • 설명
    • 두 핸들러는 각각 GET요청과 POST 요청 처리
    • 처음에 브라우저에서 해당 URL을 GET으로 요청하면 기본값을 가진 객체가 모델에
      저장된채 뷰를 찾게 된다
    • 뷰의 form에서 submit하면 해당 값을 set한 새로운 객체생성후 이를 응답으로 리턴
resources/templates/events/form.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>new Event</title>
</head>
<body>

<form action="#" th:action="@{/events}" method="post" th:object="${event}">
<input type="text" title="name" th:field="*{name}"/>
<input type="text" title="limit" th:field="*{limit}"/>
<input type="submit" value="Create"/>
</form>

</body>
</html>
  • 설명
    • 타임리프
    • 폼안의 표현식을 잘 살펴볼 것

5. @ModelAttribute

  • @ModelAttribute
    • 여러 곳에 있는 단순 타입의 데이터를 복합 타입 객체로 받아오거나
      해당 객체를 새로 만들때 사용 가능
    • 여러 곳에서 오는 데이터를 하나의 복합타입 객체로 받을 수 있다
    • 값들이 꼭 요청 파라미터일 필요는 없다
      -URI패스, 요청 매개변수, 세션등
    • 생략 가능
    • 값 바인딩 실패시 BindException 발생으로 400 BadRequest가 떨어진다
      -바인딩 에러를 직접 다루고 싶은 경우: BindingResult 타입 아규먼트 추가
      BindingResult을 아규먼트로 추가
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      @PostMapping("/events")
      @ResponseBody
      public Event getEvent(
      @ModelAttribute Event event,
      BindingResult bindingResult) {
      //이렇게 BindingResult가 있을경우에는 BindException을 바로 던지지 않고
      // 이 아규먼트에 에러를 담고 그냥 요청 처리가 된다
      // 에러를 출력하고 싶으면 밑에처럼 하면 된다
      if (bindingResult.hasErrors()) {
      System.out.println("================");
      bindingResult.getAllErrors().forEach(c->{
      System.out.println(c.toString());
      });
      System.out.println("================");
      }
      return event;
      }

6. @Validated

@Valid만 붙이면 값 할당된후 자동으로 검증
1
2
3
4
5
6
public Event getEvent(@Valid @ModelAttribute Event event,
BindingResult bindingResult){
return event;
}
//이렇게 BindingResult가 있을경우에는 BindException을 바로 던지지 않고
// 이 아규먼트에 에러를 담고 그냥 요청 처리가 된다
  • 검증 작업 추가?
    • @Valid or @Validated 사용
    • @Valid : JSR303이 지원이 지원하는 Validation, Group Validation 미지원
    • @Validated : Group Validation지원, 스프링이에서 만든 애노테이션
      아직 JSR303 스펙에는 들어가 있지 않음
    • 검증할 객체에 @Notnull, @Min(0) 이런식으로 검증 애노테이션 해놓으면 된다
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      // group Validated 예제

      public class Event {

      //마커 인터페이스용
      interface ValidateLimit {}
      interface ValidateName {}

      private Integer id;

      @NotBlank(groups = ValidateName.class)
      private String name;

      @Min(value = 0, groups = ValidateLimit.class)
      private Integer limit;

      public Integer getLimit() { return limit; }
      public void setLimit(Integer limit) { this.limit = limit; }

      public Integer getId() {
      return id;
      }
      public void setId(Integer id) {
      this.id = id;
      }

      public String getName() {
      return name;
      }
      public void setName(String name) {
      this.name = name;
      }
      }

      //컨트롤러
      @Controller
      public class SampleController {

      @PostMapping("/events")
      @ResponseBody
      public Event getEvent(@Validated(Event.ValidateName.class) Event event, BindingResult bindingResult) {
      if (bindingResult.hasErrors()) {
      System.out.println("================");
      bindingResult.getAllErrors().forEach(c->{
      System.out.println(c.toString());
      });
      System.out.println("================");
      }
      return event;
      }
      }


      //TEST
      @RunWith(SpringRunner.class)
      @WebMvcTest
      public class SampleControllerTest {

      @Autowired
      MockMvc mockMvc;

      @Test
      public void postEventTest() throws Exception {

      mockMvc.perform(post("/events")
      .param("name","ahn")
      .param("limit","-10"))
      .andDo(print())
      .andExpect(status().isOk());
      }
      }
  • 설명
    • 테스트에서 limit에 음수를 넣으려고 한다
    • 컨트롤러에서 검증을 하는데 ValidateName 인터페이스 그룹의 검증을 하려한다
    • limit의 Min(0) 설정은 ValidateLimit 인터페이스 그룹이므로 무시된다
    • 결과적으로 에러출력도 되지 않고 성공으로 여겨진다

7 폼 suibmit에서 에러 처리

@ModelAttribute등에서 일어 날 수 있는 바인딩 에러,
@Valid, @Validated에서 발생할 수 있는 검증 에러..
이것들이 BindingResult에 담겨있을때 폼 처리를 어떻게 할 수 있을까?

templates/events/list.html 에러가 없을시 보여지는 리스트뷰
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"><title>new Event list</title>
</head>
<body>

<a th:href="@{/events/form}">Create New Event</a>
<div th:unless="${#lists.isEmpty(eventList)}">
<ul th:each="event: ${eventList}">
<p th:text="${event.Name}">Event Name</p>
<p th:text="${event.limit}">Event Name</p>
</ul>
</div>

</body>
</html>
form.html 에러 처리시 에러메세지가 나오는 부분 살펴보자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<!--/* Workaround for bug https://youtrack.jetbrains.com/issue/IDEA-132738 -->
<!--@thymesVar id="event" type="me.rkaehdaos.springmvcdemo3.Event"-->
<!--@thymesVar id="limit" type="Integer"-->
<!--@thymesVar id="name" type="String"-->
<!--*/-->
<head> <meta charset="UTF-8"><title>new Event</title> </head>
<body>
<form action="#" th:action="@{/events}" method="post" th:object="${event}">
<p th:if="${#fields.hasErrors('limit')}" th:errors="*{limit}">Incorrect Date</p>
<p th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Incorrect name</p>
<input type="text" title="name" th:field="*{name}"/>
<input type="text" title="limit" th:field="*{limit}"/>
<input type="submit" value="Create"/>
</form>
</body>
</html>
  • 인텔리J에서 타임리프 변수 cannot resolve 에러를 막기위해 workaround 작성부분 주목
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//컨트롤러
@Controller
public class SampleController {

@GetMapping("/events/form")
public String eventsForm(Model model) {
Event newEvent = new Event();
newEvent.setLimit(50);
model.addAttribute("event", newEvent);
return "/events/form";
}

@PostMapping("/events")
public String createEvent(@Validated @ModelAttribute Event event,
BindingResult bindingResult,
Model model) { //모델 추가

if (bindingResult.hasErrors()) {
//에러 출력부분 하던 말던 맘대로
return "/events/form";
}

//원래 Repository가 있다면 여기서 저장후 반환 값 리스트를 만듬
// Repo가 없는 테스트이므로 리스트를 만든다
List<Event> eventList = new ArrayList<>();
eventList.add(event);
model.addAttribute("eventList",eventList);

//키값과 등록할 오브젝트 이름이 같다면 밑처럼 키값을 생략해도 된다
//model.addAttribute(eventList);
return "/events/list";
}
}

  • PRG 패턴
    • 위 화면에서 refresh 하면 요새 브라우저는 데이터 재전송에 대한 팝업이 뜬다
    • Post/Redirect/Get 패턴 : https://en.wikipedia.org/wiki/Post/Redirect/Get
    • Post 이후 브라우저를 리프래시 하더라도 폼 Submit이 발생하지 않도록 하는 패턴
  • 타임리프 목록 보여주기
    • https://www.thymeleaf.org/doc/tutorials/2.1/thymeleafspring.html#listing-seed-starter-data
    • 방법
      1. 핸들러의 뷰를(핸들러 리턴을) redirectView를 사용하는 방법
      2. redirect prefix를 사용하는 (일반적인)방법
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        @Controller
        public class SampleController {

        @GetMapping("/events/form")
        public String eventsForm(Model model) {
        Event newEvent = new Event();
        newEvent.setLimit(50);
        model.addAttribute("event", newEvent);
        return "/events/form";
        }

        @PostMapping("/events")
        public String createEvent(@Validated @ModelAttribute Event event,
        BindingResult bindingResult,
        RedirectAttributes redirectAttributes) {

        if (bindingResult.hasErrors()) {
        return "/events/form";
        }

        //Repo에 Data Save

        //리다이렉트 된 get핸들러에서 Repo에서 데이터를
        //다시 read하는 것을 막기 위해?
        redirectAttributes.addFlashAttribute(event);
        return "redirect:/events/list"; //get으로 redirect
        }

        @GetMapping("/events/list")
        public String getEvents(@ModelAttribute Event event, Model model){

        //redirectAttributes로 넘어온 데이터를 받음
        //redirectAttributes를 안썼으면 repo로부터 데이터 읽어야함
        List<Event> eventList = new ArrayList<>();
        eventList.add(event);
        model.addAttribute(eventList);

        //뷰 리턴 -> GET으로 되서 refresh 걱정없음
        return "/events/list";
        }
        }

  • 설명
    • 에러가 없으면 Repository에 데이터를 저장후 get으로 redirect
    • get요청에 해당하는 핸들러가 새로 만들어져있다
    • 여기서 아까 Post에서 저장된 데이터를 불러온다
      아니면 예제처럼 redirectAttributes를 이용해서 받아온 데이터를 쓴다

8. @SessionAttributes

  • 모델 정보를 HTTP 세션에 저장해주는 애노테이션

  • 핸들러안에서 HttpSession을 직접 아규먼트로 받아서 사용해서
    httpSession.setAttribute(“event”, newEvent); 로 사용할 수도 있을 것이다
    low레벨이 필요한 경우에는 이렇게도 가능

  • 이 애노테이션을 쓰면 애노테이션에 설정된 이름에 해당하는 Model 정보를 자동으로
    세션에 넣어준다

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Controller
    @SessionAttributes("event")
    public class SampleController {

    @GetMapping("/events/form")
    public String eventsForm(Model model) {
    Event newEvent = new Event();
    newEvent.setLimit(50);
    model.addAttribute("event", newEvent);
    return "/events/form";
    }
    }
  • @ModelAttribute는 세션에 있는 데이터도 바인딩한다

  • 쓰는 이유?

    • 장바구니 : 여러페이지에서 데이터 유지
    • Form이 커서 여러 단계로 넣을 경우 화면을 나눴을때 전에 데이터 유지
    • 나중에 한꺼번에 처리할때 @ModelAttribute에서 바인딩 가능
  • SessionStatus를 사용해서 세션 처리 환료 가능

    • SessionStatus를 메소드 아규먼트로 지정해서 받는다
    • 완전히 작업(폼처리나 장바구니결재)가 끝나면 sessionStatus.setComplete()로
      세션을 비운다

이제 이를 사용해서 멀티 폼 을 서브밋 해보자

9. multi-form submit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Controller
@SessionAttributes("event")
public class SampleController {

@GetMapping("/events/form/name")
public String eventsFormName(Model model) {
model.addAttribute("event", new Event());
return "/events/form-name";
}

@PostMapping("/events/form/name")
public String eventsFormNameSubmit(@Validated @ModelAttribute Event event, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "/events/form-name";
}

return "redirect:/events/form/limit"; //get으로 redirect
}

@GetMapping("/events/form/limit")
public String eventsFormLimit(@ModelAttribute Event event, Model model){
model.addAttribute(event);
return "/events/form-limit";
}

@PostMapping("/events/form/limit")
public String eventsFormLimitSubmit(@Validated @ModelAttribute Event event,
BindingResult bindingResult,
RedirectAttributes redirectAttributes,
SessionStatus sessionStatus) {
if (bindingResult.hasErrors()) {
return "/events/form-limit";
}
//data save

sessionStatus.setComplete();

redirectAttributes.addFlashAttribute(event);
return ("redirect:/events/list");
}

@GetMapping("/events/list")
public String getEvents(@ModelAttribute Event event, Model model){
List<Event> eventList = new ArrayList<>();
eventList.add(event);
model.addAttribute(eventList);
return "/events/list";
}
}
  • 설명
    • 사실 순서대로 쫙 읽으면 답 나옴
    • name과 limit을 각각의 폼에 나눠서 입력받는다
    • form/limit으로 redirect할때 model에 데이터를 저장하지 않았어도
    • 세션에 데이터가 저장된채 넘아가는 부분을 살펴보자

더 수정한 최종본

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class Event {

//marker
interface CheckName {}
interface CheckLimit {}

private Integer id;

@NotBlank(groups = CheckName.class)
private String name;

@Min(value = 0, groups = CheckLimit.class)
@NotNull(groups = CheckLimit.class)
private Integer limit;

public Integer getLimit() {
return limit;
}

public void setLimit(Integer limit) {
this.limit = limit;
}

public Integer getId() {
return id;
}

public void setId(Integer id) {
this.id = id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

각 valid는 해당 폼이 있을때만 Valid 할 수 있도록

최종본 컨트롤러, @ModelAttribute 생략, 세션이용하는 만큼 model도 생략
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Controller
@SessionAttributes("event")
public class SampleController {

@GetMapping("/events/form/name")
public String eventsFormName(Model model) {
model.addAttribute("event", new Event());
return "/events/form-name";
}

@PostMapping("/events/form/name")
public String eventsFormNameSubmit(@Validated(Event.CheckName.class) Event event, BindingResult bindingResult) {
if (bindingResult.hasErrors()) { return "/events/form-name"; }
return "redirect:/events/form/limit";
}

@GetMapping("/events/form/limit")
public String eventsFormLimit(Event event){
return "/events/form-limit";
}

@PostMapping("/events/form/limit")
public String eventsFormLimitSubmit(@Validated(Event.CheckLimit.class) Event event, BindingResult bindingResult,
RedirectAttributes redirectAttributes, SessionStatus sessionStatus) {
if (bindingResult.hasErrors()) { return "/events/form-limit"; }

//data save

sessionStatus.setComplete();
redirectAttributes.addFlashAttribute(event);
return "redirect:/events/list";
}

@GetMapping("/events/list")
public String getEvents(Event event, Model model){
List<Event> eventList = new ArrayList<>();
eventList.add(event);
model.addAttribute(eventList);
return "/events/list";
}
}

10 @SessionAttribute

위의 SessionAttributes와 다름을 확실히 구분하자
이름은 비슷하나 하는 행동은 많이 다름

  • HTTP 세션에 들어있는 데이터를 참조할 때 사용
  • @SessionAttributes와 다르다
    • @SessionAttributes는 해당 클래스 안에서 어노테이션에 정해진 키값에 대한
      Model안의 정보를 세션에 넣어주고 SessionStatus를 통해 정의할 수 있다
      즉, 여러 컨트롤러에 걸쳐서 적용되지 않는다. 또한 @SessionAttributes에 의해 세션에 저장된 모델의 객체정보도 @SessionAttribute로 가져 올 수도 있다
      (어차피 세션에 저장된 정보이기 때문)
    • @SessionAttribute는 컨트롤러 밖(인터셉터나 필터)에서 만들어준 세션 데이터에
      접근할 때 사용

다음은 위예제에 처음 접속 시간을 세션에 저장하고 list출력시 콘솔로 그 시간을 찍는
예제

세션에 시간을 저장하는 인터셉터 구현
1
2
3
4
5
6
7
8
9
10
11
12
public class VisitTimeInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession();
if (session.getAttribute("visitTime")==null){
session.setAttribute("visitTime", LocalDateTime.now());
System.out.println("visitTime saved.");
}
return true;
}
}
인터셉터 등록
1
2
3
4
5
6
7
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new VisitTimeInterceptor());
}
}
위예제 컨트롤러 마지막에서 visitTime을 받고 출력하게 바꾼다
1
2
3
4
5
6
7
8
9
10
@GetMapping("/events/list")
public String getEvents(@ModelAttribute Event event,
@SessionAttribute LocalDateTime visitTime,
Model model){
List<Event> eventList = new ArrayList<>();
eventList.add(event);
model.addAttribute(eventList);
System.out.println("======visit Time:"+ visitTime+"==========");
return "/events/list";
}
  • HttpSession과 비교?
    • 위의 예제에서 @SessionAttribute대신 HttpSession을 썼었다면?
      • 핸들러에서 HttpSession을 아규먼트로 받은후 getAttribute(“visitTime”)
      • 리턴값이 object이므로 컨버전이 필요
      • 단순 리턴이 아니라 게임이라면 1시간마다 과도한 게임 자제 문구가 떠서 시간계산이
        필요하다면 더더욱 캐스팅 부분이 귀찮을 수 있음
    • HttpSession을 사용할 때 비해 타입 컨버전이 자동지원되므로 더 편리하다
    • Http 세션에 데이터를 넣고 빼고 싶은 경우에는 HttpSession을 직접 사용하자

11. RedirectAttributes

앞의 예제에서도 써었던 RedirectAttributes에 대한 정리

  • redirect시 Model안의 primitive Type 데이터는 기본적으로 URI 쿼리 매개변수로 추가
    • 스프링웹 MVC에서는 기본적으로 사용됨
    • 스프링 부트 사용시 기본적으로 비활성화되어 있음
    • Ignore-default-model-on-redirect 프로퍼티를 사용해서 활성화 가능
    • 활성화후 위의 예제를 실행하면 list시 리다이렉트된 uri에 값들이 노출된다
      WebMvcAutoConfiguration의 일부, 기본값이 true
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      @Bean
      @Override
      public RequestMappingHandlerAdapter requestMappingHandlerAdapter() {
      RequestMappingHandlerAdapter adapter = super.requestMappingHandlerAdapter();
      adapter.setIgnoreDefaultModelOnRedirect(this.mvcProperties == null
      || this.mvcProperties.isIgnoreDefaultModelOnRedirect());
      return adapter;
      }
      ```
      - 만약 쿼리매개변수로 들어가는 데이터가 전부 필요가 없다면 RedirectAttributes 를 사용해서 같은 처리를 할 수 있다

      ```java
      @PostMapping("/events/form/limit")
      public String eventsFormLimitSubmit(@Validated(Event.CheckLimit.class) Event event, BindingResult bindingResult,
      RedirectAttributes redirectAttributes, SessionStatus sessionStatus) {
      if (bindingResult.hasErrors()) { return "/events/form-limit"; }

      //data save

      sessionStatus.setComplete();

      redirectAttributes.addAttribute("name",event.getName());
      redirectAttributes.addAttribute("limit",event.getLimit());
      return "redirect:/events/list";
      }

      @GetMapping("/events/list")
      public String getEvents(@ModelAttribute("newEvent") Event event,
      @SessionAttribute LocalDateTime visitTime,
      Model model){
      List<Event> eventList = new ArrayList<>();
      eventList.add(event);
      model.addAttribute(eventList);
      System.out.println("======visit Time:"+ visitTime+"==========");
      return "/events/list";
      }
  • 설명
    • redirect.addAttribute로 primitive 타입지정
    • redirect시 위에서 설정한 2개의 값이 쿼리 스트링으로 붙게 된다
    • @ModelAttribute에서 받게 된다 이 때 newEvent로 이름을 지정한 까닭은
      event로 하면 @SessionAttributes(“event”)의 영향으로 세션에서 값을 찾게 되는데
      redirect전에 sessionStatus에서 세션을 마무리 했으므로 값이 존재하지 않아서
      에러가 발생하게 된다
    • @SessionAttributes에 지정한 이름과 다른 이름을 @ModelAttribute에 지정하면 에러가 발생하지 않는다
    • 쿼리매개변수는 @RequestParam 이나 @ModelAttribute로 받을 수 있는 것을 기억

12. Flash Atttributes

위에서 봤던 RedirectAttributes에서 사용가능하다
addAttribute말고 addFlashAttribute를 통해 사용 가능

  • addAttribute와 addFlashAttribute의 차이점

  • 객체 저장 가능

    • 어떻게? 세션을 이용
    • 라다이렉트전에 데이터를 세션에 저장하고 리다이렉트 된 곳에서 요청을 처리하면
      즉시 세션에서 제거된다 -> 즉 일회성 -> 그래서 Flash키워드
    • 앞에서 봤던 addAttribute은 URI의 쿼리문자열로 가기에 문자열로 변환가능해야한다
    • addFlashAttribute는 객체를 저장한다
    • 세션에 저장하기 때문에 URI에 데이터에 노출되지 않는다
    • @ModelAttribute로 받을 수 있지 Model에도 들어오기때문에 Model 선언후
      model.asMap().get(“event”)식으로로 꺼내올 수 있다(Object이므로 변환필요)
  • RedirectAttributes의 테스트 방법

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Test
    public void sessionAndRedirectAttributesTest() throws Exception {

    Event newEvent = new Event();
    newEvent.setName("sessiontest");
    newEvent.setLimit(100);

    mockMvc.perform(get("/events/list")
    .sessionAttr("visitTime", LocalDateTime.now())
    .flashAttr("newEvent", newEvent)
    )
    .andDo(print())
    .andExpect(status().isOk())
    .andExpect(request().sessionAttribute("visitTime", is(notNullValue())))
    .andExpect(xpath("//p").nodeCount(2))
    ;
    }
  • 설명

    • 바로 list로 요청
    • 세션에 visitTime을 저장하고 addFlashAttribute로 객체를 보냄
    • /events/list 요청에서 @ModelAttribute로 newEvent를 받고 출력함
    • 세션값은 andExpect에서 request().sessionAttrubute로 확인 가능
      -2가지로 오버라이딩 되어있음
      • 키, Matcher: 키로 가져온 객체와 Matcher로 비교
      • zl, 객체: 키와 객체를 직접 비교
    • data는 xpath를 이용해서 p노드를 카운트 하였음
  • XPath

    • Xml Path Language
    • W3C의 표준으로 XML 구조를 통해서 경로위에 지정한 구문을 사용하여
      항목 배치및 처리방법을 기술하는 언어
    • XML 표현보다 쉽고 약어로 되어 있음
    • XSL변환(XSLT), XML지시자언어(XPointer)에 쓰임

13. MultipartFile

  • MultipartFile

    • 파일 업로드시 사용하는 메소드 아구먼트
    • MultipartResolver 빈이 DispatcherServlet에 설정 되어 있어야 사용할 수 있다
      • 기억이 가물가물?
      • DispatcherServlet 열어보자
      • initStrategies에서 initMultipartResolver를 호출하는 부분 참고
      • initMultipartResolver는 해당 빈을 찾고 등록된 빈이 없다면 설정하지 않는다
      • 따라서 기본 전략은 MultipartResolver가 없는 것
      • 스프링 부트를 사용한다면 자동설정에 의해(MultipartAutoConfiguration)
        MultipartResolver가 빈으로 등록이 된다
      • 따라서 DS가 해당 빈을 발견하게 되고 설정하게 된다
      • 자동설정에 사용된 프로퍼티즈는 MultipartProperties 환경설정이며
        spring.servlet.multipart prefix를 가진다
      • 따라서 application.properties에 해당 접두사를 이용해서
        MultipartResolver 설정이 가능하다
    • POST Multipart/form-data 요청에 들어 있는 파일을 참조할 수 있다
    • List 아규먼트로 여러 파일을 참조할 수 있다
    파일 업로드 폼
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <!--저장후 redirect를 위한 페이지 -->
    <div th:if="${message}">
    <h2 th:text="${message}"/>
    </div>

    <form method="POST" enctype="multipart/form-data" action="#" th:action="@{/files}">
    File: <input type="file" name="file"/>
    <input type="submit" value="Upload"/>
    </form>
파일 업로드 처리 핸들러
1
2
3
4
5
6
7
8
9
10
11
12
@PostMapping("/files")
public String fileUpload(@RequestParam MultipartFile file,
RedirectAttributes redirectAttributes) {

//스토리지 서비스를 이용한 저장
//로컬에 저장을 하든, aws나 dropbox에 저장을 하든

String message = file.getOriginalFilename()+"is uploaded.";
System.out.println(message);
redirectAttributes.addFlashAttribute("message", message);
return "redirect:/files";
}
  • 설명
    • files에 처음에 접근하면 message는 없기에 div는 뜨지 않고 form만 나온다
    • form에 파일명 등록 후 submit
    • POST 요청에 multipart/form-data 타입이므로 MultipartResolver 가 처리
    • MultipartFile로 받아진다
    • addFlashAttribute(message)하면 키값이 “string”이 들어가므로 키값 명시
    • redirect하면 이제 message가 존재하므로 div 태그가 나타난다

테스트도 가능한데 다음과 같다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class FileControllerTest {
@Autowired
private MockMvc mockMvc;

@Test
public void fileUploadTest() throws Exception {

MockMultipartFile file = new MockMultipartFile(
"file",
"test.txt",
"text/plain",
"hello file".getBytes());

mockMvc.perform(
multipart("/files") //자체는 post요청에 enctype="multipart/form-data"
.file(file)
)
.andDo(print())
.andExpect(status().is3xxRedirection())
;
}

}
  • 설명
    • @SpringBootTest
      • 스프링 부트 전반에 걸쳐서 테스트 하는 경우
      • @SpringBootApplication을 기준으로 한 모든 빈을 등록
      • 어플리케이션 전반에 걸친 통합 테스트
      • 이경우 @WebMvcTest와 다르게 모든 빈이 다 등록
      • MockMvc를 자동으로 만들어주지 않는다
      • MockMvc를 자동으로 만들기 위해서는 @AutoConfigureMockMvc 사용이 필요
    • MockMultipartFile
      • SpringMvc에서 지원하는 테스트 클래스
      • 형태: 아규먼트 오버로딩으로 4타입
        1. MockMultipartFile(String name, @Nullable byte[] content)
        • name: 파일의 이름
        • content: 파일 내용
        1. MockMultipartFile(String name, InputStream contentStream)
        • name: 파일의 이름
        • contentStream: 스트림으로써의 파일 내용
        • 스트림 읽기에 실패할 경우 IOException발생
        1. MockMultipartFile(String name, @Nullable String originalFilename,
          @Nullable String contentType, @Nullable byte[] content)
        • name: 파일의 이름
        • originalFilename: 실제 파일로 여겨지는 original filename
        • contentType: 컨텐츠 타입(알고 있는 경우)
        • content: 파일의 내용
        1. MockMultipartFile(String name, @Nullable String originalFilename,
          @Nullable String contentType, InputStream contentStream)
        • name: 파일의 이름
        • originalFilename: 실제 파일로 여겨지는 original filename
        • contentType: 컨텐츠 타입(알고 있는 경우)
        • contentStream: 스트림으로써의 파일 내용
        • 스트림 읽기에 실패할 경우 IOException발생
      • 위에선 3번 타입으로 처리
    • mockmvc.perform안에서 multipart 처리 부분
    • 요청에서 redirect을 확인했는지 보고 성공처리 확인

14. ResponseEntity

파일 업로드를 해봤으니 이제 파일 다운로드를 해보자(핸들러 아규먼트랑은 상관없지만)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@GetMapping("/files/{filename}")
//@ResponseBody를 안해도 응답자체를 작성한 것이기에 사실상 안붙여도 무방
public ResponseEntity<Resource> fileDownload(@PathVariable String filename) throws IOException {
Resource resource = resourceLoader.getResource("classpath:" + filename);

//File file = resource.getFile();

//Tika 객체 자체는 재사용이 가능하므로 빈등록을 해서 사용하는 것이 좋을 것 같다
Tika tika = new Tika();
//String mediaType= tika.detect(file);
String mediaType = tika.detect(resource.getFile());

return ResponseEntity.ok() //이것만으로도 200응답코드
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachement; filename=\"" + resource.getFilename() + "\"")

//.header(HttpHeaders.CONTENT_TYPE,"image/png") //타입을 알 수 있으면 이렇게 하겠지만..
.header(HttpHeaders.CONTENT_TYPE,mediaType)

//.header(HttpHeaders.CONTENT_LENGTH, file.length()+"")
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(resource.contentLength()))
.body(resource)
;
}
  • 설명
    • URL PATH에 파일이름을 따오기 위해 @PathVariable로 받고 있다
    • 반환값인 ResponseEntity가 응답이기에 @ResponseBody없어도 무방
      하지만, 만약 Resource를 직접 반환한다면 @ResponseBody가 필요하다
    • ResponseEntity 하위에는 여러 메소드가 있는데 ok()는 200응답과 함께
      나머지 ResponseEntity를 구성할 수 있는 BodyEntity인터페이스를 반환한다
    • HttpHeaders.CONTENT_DISPOSITION 부분은 외우기 어려우니 잘 봐두고 나중에 참조
    • HttpHeaders.CONTENT_TYPE은 알고 있으면 주석된 부분처럼 기록
    • 다양하다면 여러가지 방법이 있는데 여기서는 Apache의 Tika를 사용하였다
    • 의존성에 Apache의 Tika-core를 추가하면 Tika 사용이 가능하다
    • Tika의 detect를 이용하면 mediaType을 알 수 있다
      주석에 있듯이 Tika객체는 재사용이가능하므로 예제가 아닌 실제에서는 빈등록도 고려
    • HttpHeaders.CONTENT_LENGTH에 resource.contentLength로 구하는 부분 주의
    • body 자체는 파일내용이 된다

15. @RequestBody & HttpEntity

  • @RequestBody

    • 요청 본문(request body)에 들어 있는 데이터를 HttpMessageConverter를 통해
      변환한 객체로 받아올 수 있다
    • 핸들러 아답터의 아규먼트에 애노테이션 사용
    • 해당 아규먼트를 리졸빙할때 현재 등록되어 있는 여러 HttpMessageConverter들 중에
      현재 이 본문을 컨버팅을 할 수 있는 컨버터를 선택해서 컨버전을 함
    • 가령 요청이 JSON으로 들어왔다면 요청헤더에 Content-Type이 들어 있다
      이를 보고 판단해서 JSON을 컨버팅할 수 있는 컨버터가 선택된다
      Header의 Content-Type은 컨버터 선택에 있어서 주요한 판단이 된다
    • @Valid or @Validated를 사용해서 값을 검증 가능
    • BindingResult 아규먼트를 이용해서 코드로 바인딩 또는 검증에러 확인 가능
      (@ModelAttribute때와 같다고 생각하면 된다. 기본적으로는 BindException이
      발생하며 이 아규먼트가 있으면 에러를 담아주고 에러를 발생시키진 않는다
      개발자가 응답을 담던지 응답코드를 바꾸던지등 처리)
  • HttpMessageConverter

    • 스프링 MVC 설정(WebMvcConfigurer)에서 설정 가능
    • configureMessageConverters: 기본 메세지 컨버터 대체
    • extendMessageConverters: 메세지 컨버터에 추가
    • 기본 컨버터 : WebMvcConfigurationSupport.addDefaultHttpMessageConverters

예제를 보자

RequestBody예제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
//EventApi.java
@RestController
@RequestMapping("/api/events")
public class EventApi {

@PostMapping
public Event createEvent(@RequestBody @Valid Event event){

//DB등에 연결된 Respository가 있다면 여기서 저장

return event;
}
}


//test code
@RunWith(SpringRunner.class)
@WebMvcTest
public class EventApiTest {

@Autowired
//fasterxml(jackson)에서 제공하는 api, 객체와 json을 서로 컨버팅가능
// 스프링 부트에서 설정
ObjectMapper objectMapper;

@Autowired
MockMvc mockMvc;

@Test
public void createEvent() throws Exception {
//body에 들어갈 Event 객체
Event event = new Event();
event.setName("geunchang");
event.setLimit(20);

String json = objectMapper.writeValueAsString(event);

mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(json)
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("name").value("geunchang"))
.andExpect(jsonPath("limit").value("20"))
;

}

}
  • 설명
    • EventApi는 Request Body를 그대로 응답으로 반환하는 핸들러를 가진다
    • 테스트 Runner는 SpringRunner
    • 웹테스트이므로 @WebMvcTest 사용
      (만약 컨트롤러안의 주석처럼 Repository 서비스 사용으로 저장등을 한다면 해당 빈
      등록도 필요하므로 @WebMvcTest대신 @SpringBootTest+@AutoConfigureMockMvc
      조합을 사용하자. 잘 기억안나면 2번으로 돌아가자 ㅋ)
    • ObjectMapper 컨버터는 스프링 부트의 jacksonAutoConfigure에 의해 등록되어있음
    • ObjectMapper를 @Autowired로 받아와서 이를 사용해 객체와 json을 서로 컨버팅가능
      writeValueAsString를 사용해 컨버팅 하는 부분 주목
    • 테스트에서는 임의의 객체를 생성해서 요청후 값을 체크

위처럼 @RequestBody의 경우 요청 헤더에는 접근을 할 수 없고 요청의 본문에 대해서만
접근이 가능하다 이 때 사용할 수 있는게 HttpEntity다

  • HttpEntity

    • @RequestBody와 비슷하지만 추가적으로 요청 헤더 정보를 사용 가능
    • 둘다 요청 본문의 HttpMessageConverter를 통해 받는다는 점에서는 동일
    • 굳이 어노테이션을 붙이지 않아도 되지만 에 Body 타입을 지정을 해주어야한다
      이를 이용해서 위의 컨트롤러를 바꿔보자
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      //수정 이전의 핸들러 아답터
      @PostMapping
      public Event createEvent(@RequestBody Event event){
      return event;
      }

      //수정 이후의 핸들러 아답터
      @PostMapping
      public Event createEvent(HttpEntity<Event> reqHttpEntity){
      //HttpEntity를 사용하면 헤더에 접근이 가능
      //예를 들어 request에 들어있는 contentType이 알고 싶다면
      MediaType mediaType = reqHttpEntity.getHeaders().getContentType();
      System.out.println("mediatype: "+mediaType);
      return reqHttpEntity.getBody(); //수정 전의 return event에 해당하는 부분
      }
  • 설명

    • 수정전과 수정후의 응답은 같다
    • 다만 헤더에 접근 가능하다는 것을 표현하고자 요청 헤더의 컨텐츠 타입을 출력
  • 참고

16. @ResponseBody & ResponseEntity

  • @ResponseBody

    • 데이터를 HttpMessageConverter를 사용해 응답 본문 메시지로 보낼 때 사용한다
    • 컨버터 선택에 있어서 주요한 단서는 요청 헤더의 Accept헤더
    • 실제 웹브라우저의 경우 기본 요청을 보면 Accept헤더에 text나 xml이 들어있다
      따라서 특별한 명시없으면 xml로 응답이 된다
    • curl이나 피들러 사용시엔 이것은 사람이 아닌 기계요청으로 생각하고 기계에 유리한
      JSON으로 응답을 보낸다
    • @RestController 사용시 자동으로 모든 핸들러 메소드에 적용 된다
  • ResponseEntity

    • 응답 헤더 상태 코드 본문을 직접 다루고 싶은 경우에 사용한다
      @Controller
      ResponseEntity사용으로 바꿔본 예제
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      @RequestMapping("/api/events")
      public class EventApi {

      @PostMapping
      public ResponseEntity<Event> createEvent(@RequestBody @Valid Event event, BindingResult bindingResult){
      if(bindingResult.hasErrors()){
      bindingResult.getAllErrors().forEach(objectError -> {
      System.out.println(objectError);
      });
      return ResponseEntity.badRequest().body(event);
      }
      ResponseEntity resEntity= new ResponseEntity(event,HttpStatus.OK);
      return resEntity;
      }
      }
  • 설명

    • ResponseEntity는 @ResponseBody가 필요 없으니 @RestController일 필요도 없음
      따라서 일반 @Controller로 교체함
    • ResponseEntity는 body, headers, status 3개 설정하며 이중 status는 NotNull
      4가지로 오버로딩 되어있으며 예제에선 ResponseEntity(event,HttpStatus.OK)로
      body와 status 설정후 리턴
    • 자주쓰는 경우 static 메소드가 있음 위의 경우 new를 할 필요 없이
      return ResponseEntity.ok(event);로 처리가 간편
    • 에러시 ResponseEntity.BadRequest()등 사용가능
      자세한 것은 REST API 응답만들 떄 공부
    • 에러테스트는 임의 이벤트에 음수값을 넣고 그 값을 assert하면 된다
      @Valid에 의해 에러가 발생하며 BindingResult에 따라 BadRequest가 발생하니
      그부분 체크하면 됨
    • badRequest().build()만 하명 응답만 가고, 예제처럼 하면 body도 같이 가니
      테스트에서 -값도 검증가능
    • 내 경우 @Validated group 테스트를 Event에 만들어 놓는 바람에 내 예제에서는
      @Validated({Event.CheckName.class,Event.CheckLimit.class}) 로 해서 성공
  • 참고

17. 정리

기타

1. 모델: @ModelAttribute의 또 다른 사용법

  • @ModelAttribute의 용법
    1. @RequestMapping을 사용한 핸들러 메소드의 아규먼트에 사용
    2. @Controller or @ControllerAdvice를 사용한 클래스에서 모델정보 초기화시
    3. @RequestMapping과 같이 사용하면 해당 메소드의 리턴객체를 모델에 넣어준다

1번 방법

위 예제들에서 설명 끝

2번 방법

설명
지금까지 응답에 객체를 전달하기 위해선 핸들러 아답터에서 다음처럼 사용하였다

  • public String event1(Model model) {…}
  • public String event2(ModelMap model) {…}
  • Model과 ModelMap은 거의 인터페이스가 동일하며 거의 같은 용도로 사용
  • Model과 ModelMap을 아규먼트로 해서 모델 정보를 담아 줄 수 있다
  • 뷰에서 참조해서 렌더링

만약 event100까지 있었는데 여러 핸들러에서 참조해야하는 공통적인 정보가 있다면
매번 model.addAttribute를 하는 것이 아니라 한곳에서 할수 있지 않을까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Controller
@SessionAttributes("event")
public class EventController {

//1번째 방법
@ModelAttribute
public void categories1(Model model) {
model.addAttribute("categories1", List.of("study", "seminar", "hobby", "social"));
}

//2번째 방법
@ModelAttribute("categories2")
public List<String> categories2() {
return List.of("study", "seminar", "hobby", "social", "기타");
}
//@RequestMapping이 된 다른 핸들러들
}
  • 설명
    • 1번과 2번중 택해서 사용 가능
    • 1번은 아규먼트에 model을 받아 add하는 형태이며 여러 값을 추가 가능
    • 2번은 리턴이 하나일때 아규먼트를 아예 안받고 애노테이션 이름으로 매핑
    • 1번이 간단해 보일 수 있지만 2번이 각각 공통 부분을 다시 나눌 수 있으므로
      유지보수 측면에서 좋아보인다
    • 이후 다른 핸들러 응답을 보면 Model에 categories1,categories2가 보인다
    • 이처럼 다른 핸들러가 실행되지 않아도 이 메소드들은 실행되서 모델정보를 준비한다
    • 이를 이용해서 다른 핸들러들의 중복 코드들을 제거 할 수 있다

3번 방법

3번 방법의 예시
1
2
3
@GetMapping("/event/test")
@ModelAttribute //생략 가능
public Event eventTest() { return new Event();}
  • 설명

    • 직접 핸들러 메소드에 사용
    • 핸들러 메소드의 리턴 객체를 자동으로 model에 담아준다
    • 훨씬 간경할 코드를 만들 수 있다
    • 애노테이션 생략 가능
    • view는 어떻게? RequestToViewNameTranslator 인터페이스가 위 GetMapping의
      요청과 정확히 일치하는 뷰 이름을 리턴해 준다
  • 개인적

    • 개인적으로 String 리턴이 아닌 객체리턴을 가진 핸들러를 애노테이션에 상관없이
      @ResponseBody로 생각하는 경향이 있음
    • @RestController남발 사용의 폐해;;
    • @RestController에서 @ResponseBody가 생략된 핸들러 메소드와
      @Controller에서 @ModelAttribute가 생략된 핸들러 메소드의 생김새가 너무 흡사
    • 왜 착각하는지 모르겠는데 자꾸 착각.. 주의하자

2. DataBinder: @InitBinder

  • binder 자체는 계속 예제에서 사용해왔음
  • 요청URI패스에 들어있거나, 요청 매개변수나, 요청 form에 있거나 binder를 사용했음

특정 컨트롤러에서 바인딩 또는 검증 설정을 커스터마이징하고 싶을 떄

  • 바인딩 설정

    • ex) webDataBinder.setDisallowedFields(“id”)
    • Event 데이터는 밖에서 받아오는데 id는 받고 싶지 않을때
    • update시 수정된 값을 밖에서 넘겨받는데 id까지 덮여 쓰여지는 것 방지
    • DTO를 따로 둔다면, 아니면 일일히 매핑하는 부분을 만든다면 가능하겠지만
      이 방법을 이용하면 도메일 모델을 계속 사용할 수 있음
      in
      1
      2
      3
      4
      5
      6
      7
      8
      9
      @InitBinder
      public void initEventBinder(WebDataBinder webDataBinder){
      //바인딩 설정
      webDataBinder.setDisallowedFields("id"); //blacklist
      //webDataBinder.setAllowedFields("id"); //whitelist

      //포매터 설정
      webDataBinder.addCustomFormatter(//포매터//);
      }
  • 포매터 설정

    • Event 도메인 필드에 LocalDate testDate라고 지정했다고 하자

      1
      2
      @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
      private LocalDate testDate;
    • LocalDate, LocalDateTime은 joda time 따라 만든 새로운 날짜 API

    • iso값은 문자열로 줄 수 있지만 표준 형태로 정의한 enum사용이 이롭다

    • 이대로만 해도 자동으로 바인딩이 된다

    • 이유는 @DateTimeFormat 애노테이션을 이해하는 포매터가 이미 들어 있기 떄문

    • 이런 일들을 하는 것이 포매터,프로퍼티 에디터, 컨버터등 여러 종류가 있지만
      주로 웹쪽은 포매터를 쓴다고 생각해도 무방

    • 필요하면 커스텀 포매터 등록이 필요하다

    • ex)webDataBinder.addCustomFormatter()

  • Validator 설정

    • 커스텀 Validator
    • 지금까지 사용했던 Validation은 JSR303 애노테이션이 지원하는 Validation
    • 예를들어 name에서 특정한 값이 들어오는 것을 막는다던지 할때
      일반적인 표준 Validation에서는 처리하기가 쉽지 않다
    • Validator 구현은 스프링 기본 정리 했던 곳에 있으니 확인
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      import org.springframework.validation.Validator;

      public class EventValidator implements Validator {
      @Override
      public boolean supports(Class<?> clazz) {
      return Event.class.isAssignableFrom(clazz);
      }

      @Override
      public void validate(Object target, Errors errors) {
      Event event = (Event) target;
      if (event.getName().equalsIgnoreCase("aaa")) {
      errors.rejectValue("name", "wrongValue", "the Value is now allowd");
      }
      }
      }

      //등록은 다시 InitBinder쪽
      @Controller
      public class EventController {
      @InitBinder
      public void initEventBinder(WebDataBinder webDataBinder){
      webDataBinder.setDisallowedFields("id");
      //webDataBinder.addCustomFormatter();
      webDataBinder.addValidators(new EventValidator());
      }
      }
  • 설명

    • 커스텀Validator 생성은 Validator인터페이스를 구현하면된다
    • supports에선 어떤 타입의 검증을 할지
    • validate에서 실제 검증 부분으로 여기서는 aaa의 이름을 받지 않도록
    • 이 Validator를 들록하기 위해서는 다시 InitBinder쪽에서 addValidators로 등록

이 방법을 사용하지 않고 스프링 기초에서 봤던 것처럼 커스텀Validator를 빈으로
받은후 컨트롤러나 서비스에서 @Autowired로 받은 후 특정시점에서 명시적으로
eventValidator.validate(event, bindingResult); 식으로 검증이 가능하다
(이경우에는 Validator 인터페이스 구현도 필요가 없겠지)

  • InitBinder(“name”)
    • InitBinder 안에 value를 주면 해당 이름의 객체를 받을때에만 InitBinder가 적용

예외처리 핸들러 : @ExceptionHandler

특정 예외가 발생하는 요청을 처리하는 핸들러 정의

  • 지원하는 메소드 아규먼트(해당 예외객체, 핸들러 객체, …) :밑의 레퍼런스 참고
  • 지원하는 리턴 값: 밑의 레퍼런스 참고
  • REST API의 경우 응답 몬문에 에러에 대한 정보를 담아주고, 상태코드를 설정하려면
    ResponseEntity를 주로 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
//커스텀 익셉션 :EventException.java
public class EventException extends RuntimeException{
}

//컨트롤러 안에 설정
@Controller
public class EventController {
@ExceptionHandler
public String eventErrorHandler(EventException exception, Model model){
model.addAttribute("message","event error");
return "error";
}
}
  • 설명
    • 컨트롤러안에 ExceptionHandler어노테이션을 가지는 예외 핸들러 메소드 생성
    • 예외를 받을때 커스텀하게 정의한 EventException 정의
    • 예외 발생시 해당 핸들러가 동작하며 동작내용은 일반 핸들러와 비슷하다
    • 따라서 모델에 메세지를 저장한후 error라는 이름의 view를 찾게 된다
    • 이후 view에서 에러메세지 출력하면 끝
    • 여러 예외 핸들러 메소드(EventException, RuntimeException 등)이 있는 가운데
      thorw new EventException()이 발생하면 가장 근사한 EventException 예외 핸들러
      하나만 작동함을 주의
    • @ExceptionHandler에 처리할수 있는 예외처리를 여러개 줄 수 있다
      다만 이경우에는 해당 예외처리를 전부 처리할 수 있는 상위 타입의 Exception필요
      여러 예외를 같이 처리하는 핸들러의 경우 상위 타입의 Exception
      1
      2
      3
      4
      5
      @ExceptionHandler({EventException.class, RuntimeException.class})
      public String eventErrorHandler(RuntimeException exception, Model model){
      model.addAttribute("message","event error");
      return "error";
      }

REST API의 경우 처음 말한 것처럼 ResponseEntity를 주로 사용하게 된다
REST API에서 에러가 발생할 경우에는 메세지 본문에 이 에러가 왜 일어 났는지
에러 정보를 주어야 클라이언트가 인지 할 수 있기 떄문

더 자세한 것은 밑의 참고 레퍼런스를 열어서 보자
수많은 아규먼트와 많은 리턴값은 레퍼런스를 참고해서 보자

전역 컨트롤러

지금까지 EventController예제에서 보았던

  • @ModelAttribute
  • @InitBinder
  • @ExceptionHandler

이 3가지의 역활(모델 객체, 바인딩 설정, 예외처리)는 해당 컨트롤러에만 적용 된다
만약 이것들을 모든 컨트롤러 전반에 걸쳐 적용하고 싶다면 어떻게 해야할까?

  • 방법
    • 컨트롤러클래스를 만들고 @ControllerAdvice 애노테이션을 붙인다
      이로써 이 컨트롤러는 전역 컨트롤러가 된다
    • 여기에 지금까지 만들었던 부분을 잘라 붙인다
    • 이제 밑의 내용들은 모든 컨트롤러 전반에 적용된다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@ControllerAdvice //전역 컨트롤러
public class BaseController {

//지금까지 만들었던
@ExceptionHandler
public String eventErrorHandler(EventException exception, Model model) {
model.addAttribute("message", "event error");
return "error";
}

//지금까지 만들었던
@InitBinder
public void initEventBinder(WebDataBinder webDataBinder) {
webDataBinder.setDisallowedFields("id");
webDataBinder.addValidators(new EventValidator());
}

//지금까지 만들었던
//1st
@ModelAttribute
public void categories1(Model model) {
model.addAttribute("categories1", List.of("study", "seminar", "hobby", "social"));
}

//2nd
@ModelAttribute("categories2")
public List<String> categories2() {
return List.of("study", "seminar", "hobby", "social", "기타");
}
}
  • 이러면 모든 컨트롤러에 다 적용
  • 필요에 따라 적용할 범위 조절 필요
  • @ControllerAdvice를 열면 알수 있음
    • 스프링 4.0부터 지원

    • basePackage 지정 가능 : 특정 패키지 이하의 컨트롤러에만 적용 가능

    • assignableTypes() : 특정 클래스 타입의 컨트롤러에만 적용

      • 예) @ControllerAdvice(assignableTypes={Controller1.class,
        Controller2.class})
    • annotations() : 특정 애노테이션의 컨트롤러에만 적용

  • @RestControllerAdvice도 존재
  • 자세한 사용은 레퍼런스 참고
  • 참고 : https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html#mvc-ann-controller-advice

마무리

아직도 공부 못한 부분

Related POST

공유하기