Jinhyuk Kim

Software Development Engineer @ Amazon

reniowood at gmail.com
resume

Spring Web MVC - 3. Annotated Controllers

2018-10-03

다음 내용은 Spring Web MVC 5.1.0.RELEASE 버전의 공식 문서인 Web On Servlet Stack의 Spring Web MVC 부분 중 일부분을 발췌해 번역해 놓은 것입니다. 링크된 공식 문서의 내용과 다른 부분이나 생략된 부분이 있을 수 있습니다.

Spring Web MVC

Annotated Controllers

Spring MVC는 요청 할당과 예외 처리에 대한 어노테이션을 사용하는 @Controller@RestController component를 기반으로 한 프로그래밍 모델을 제공한다. 어노테이션을 가진 컨트롤러는 기반 클래스를 상속하거나 특정 인터페이스를 구현할 필요 없이 유연한 메소드를 가질 수 있다.

@Controller
public class HelloController {

    @GetMapping("/hello")
    public String handle(Model model) {
        model.addAttribute("message", "Hello World!");
        return "index";
    }
}

위의 예제에서는 메소드가 Model을 입력받아 문자열로 된 뷰 이름을 반환한다.

Declaration

컨트롤러 bean은 Servlet의 WebApplicationContext의 표준 Spring bean 정의를 사용해 정의할 수 있다. Spring은 @Component 어노테이션의 기능(classpath의 클래스 자동 감지 및 bean 정의 자동 등록)을 @Controller 어노테이션에 제공한다. 또한 @Controller를 가진 클래스가 웹 component 역할임을 표시한다.

@Controller bean을 자동 감지하기 위해서는 자바 configuration에 @ComponentScan을 추가한다.

@RestController@Controller@ResponseBody 어노테이션을 가진 복합 어노테이션으로, 컨트롤러의 모든 메소드가 @ResponseBody 어노테이션을 상속해 반환값을 HTML 템플릿에 쓰는 대신 응답 본문에 바로 쓴다.

AOP Proxies

컨트롤러를 런타임에 AOP 프록시를 이용해 호출할 때가 있다. 예를 들어 컨트롤러에 @Transactional 어노테이션을 추가하면 클래스 기반의 프록시를 사용한다. Spring Context 콜백이 아닌 인터페이스를 구현할 때에는 명시적으로 해당 클래스가 프록시의 대상임을 밝혀주어야 한다.

Request Mapping

@RequestMapping 어노테이션을 이용해 요청을 컨트롤러 메소드에 할당할 수 있다. URL이나 HTTP 메소드, 요청 파라미터, 헤더값, media type에 따라 할당이 가능하다. 클래스에 해당 어노테이션을 추가해 컨트롤러 메소드 전체에 공통된 할당을 할 수도 있다.

@RequestMapping의 HTTP 메소드를 특정한 단축 버전도 있다.

URI patterns

다음 패턴을 사용해 요청을 할당할 수 있다.

@PathVariable을 이용해 URI 변수값에 접근할 수 있다.

@GetMapping("/owners/{ownerId}/pets/{petId}")
public Pet findPet(@PathVariable Long ownerId, @PathVariable Long petId) {
    // ...
}

클래스와 메소드 모두 URI 변수값에 접근할 수 있다.

@Controller
@RequestMapping("/owners/{ownerId}")
public class OwnerController {

    @GetMapping("/pets/{petId}")
    public Pet findPet(@PathVariable Long ownerId, @PathVariable Long petId) {
        // ...
    }
}

URI 변수는 자동으로 적절한 타입으로 변환되거나 TypeMismatchException을 던진다. 간단한 타입을 기본적으로 지원하며, 다른 타입을 추가로 지원할 수 있다.

@PathVariable("customId")와 같이 URI 변수의 이름을 명시할 수도 있다.

{varName:regex} 문법은 URI 변수를 정규식과 함께 선언한다. 예를 들어, URL ```“/spring-web-3.0.5.jar”에 대해 다음 메소드는 이름, 버전, 확장자를 추출한다.

@GetMapping("/{name:[a-z-]+}-{version:\\d\\.\\d\\.\\d}{ext:\\.[a-z]+}")
public void handle(@PathVariable String version, @PathVariable String ext) {
    // ...
}

Pattern Comparison

두 개 이상의 패턴이 URL에 대응되는 경우, AntPathMatcher.getPatternComparator(String path)를 이용해 더 구체적인 패턴을 찾아낸다.

*는 1점으로, ** 2점으로, URI 변수를 각각 1점으로 매겼을 때, 더 높은 점수를 가진 쪽이 더 구체적인 패턴이다. 같은 점수일 때는 더 긴쪽이 선택된다. 같은 점수와 길이를 가졌을 때는 ***보다 URI 변수가 더 많은쪽이 선택된다.

/**는 점수를 셀 때 제외되며 항상 맨 마지막에 처리된다. 접두사 패턴(/public/**같은 것)은 **을 가지지 않은 패턴보다 덜 구체적으로 간주된다.

Suffix Match

Spring MVC는 컨트롤러의 /person 패턴에 대해 /person.*도 대응이 되도록 .* 접미사 패턴 매칭을 사용한다. 이 때의 확장자는 요청 content type을 해석하기 위해 사용된다.

브라우저가 해석하기 어려운 Accept 헤더를 보내던 시절에는 파일 확장자가 필요했지만 지금은 더 이상 필요하지 않다. 게다가 지금은 URI 변수, 경로 파라미터, URI 인코딩 등을 함께 사용할 때 모호하게 만든다. URL 기반의 인증과 보안도 더 힘들게 만든다.

파일 확장자 사용을 금지하려면 다음 두 가지를 모두 설정해야 한다.

URL 기반의 content negotiation이 유용하기 때문에 파일 확장자를 사용하는 대신 쿼리 파라미터 기반의 전략을 추천한다. 만약 꼭 파일 확장자를 사용해야겠다면 ContentNegotiationConfigurermediaTypes에 명시적으로 등록된 확장자만 사용하는 것을 고려한다.

Suffix Match and RFD

RFD(Reflected File Download) 공격은 XSS와 비슷하게 요청 입력값이 응답에 반영되도록 하지만, XSS와는 다르게 브라우저가 응답을 실행할 수 있는 파일로 다운받게 만든다.

Spring MVC의 @ResponseBodyResponseEntity 메소드가 문제가 될 수 있다. 접두사 패턴 매칭을 사용하지 않고 경로 확장자를 사용하면 위험도를 낮출 수는 있지만 막을 수는 없다.

RFD 공격을 막기 위해서 Spring MVC는 응답 본문을 만들기 전에 Content-Disposition:inline;filename=f.txt와 같은 헤더를 추가해 고정된 안전한 다운로드 파일을 제공한다. 이 기능은 URL 경로가 content negotiation을 위해 명시적으로 등록되거나 허용 목록에 있지 않으면 작동한다. 그러나 이 방법은 URL을 브라우저에 직접 입력할 때 부작용을 발생시킬 여지가 있다.

많은 공통 경로 확장자가 기본값으로 허용되어있다. 임의의 HttpMessageConverter 구현을 사용하게 되면 공통 경로 확장자들에 대해 Content-Disposition 헤더가 추가되지 않도록 파일 확장자를 등록할 수 있다.

Consumable Media Types

다음과 같이 요청의 Content-Type을 기반으로 요청을 제한할 수 있다.

@PostMapping(path = "/pets", consumes = "application/json") 
public void addPet(@RequestBody Pet pet) {
    // ...
}

consumes!text/plain을 사용하면 text/plain을 제외한 모든 content type이 사용 가능하다.

클래스에 consumes를 추가하면 각 메소드의 consumes는 클래스의 설정에 추가되지 않고 덮어 쓴다.

Producible Media Types

다음과 같이 요청의 Accept 헤더와 메소드가 만들어내는 content type을 기반으로 요청을 제한할 수 있다.

@GetMapping(path = "/pets/{petId}", produces = "application/json;charset=UTF-8") 
@ResponseBody
public Pet getPet(@PathVariable String petId) {
    // ...
}

media type은 character set도 지정할 수 있다. !로 특정 content type을 제외하고 전부 받아들일 수도 있다.

클래스에 produces를 추가하면 각 메소드의 produces는 클래스의 설정에 추가되지 않고 덮어 쓴다.

Parameters, Headers

요청 파라미터 조건이나 헤더 값으로도 요청을 제한할 수 있다.

@GetMapping(path = "/pets/{petId}", params = "myParam=myValue") 
public void findPet(@PathVariable String petId) {
    // ...
}
@GetMapping(path = "/pets", headers = "myHeader=myValue") 
public void findPet(@PathVariable String petId) {
    // ...
}

HTTP HEAD, OPTIONS

@GetMapping(과 @RequestMapping(method=HttpMethod.GET))은 HTTP HEAD 요청 할당을 지원한다. 컨트롤러 메소드는 수정할 필요가 없다. javax.servlet.http.HttpServlet에 적용된 응답 wrapper는 Content-Length 헤더가 응답 본문의 바이트수까지 포함하도록 보장한다.

@GetMapping(과 @RequestMapping(method=HttpMethod.GET))은 암묵적으로 HTTP HEAD 요청에 할당된다. HTTP HEAD 요청은 응답 본문을 쓰지는 않되, 응답 본문의 크기까지 포함한 전체 응답 바이트 수를 Content-Length 헤더에 담는것을 제외하면 HTTP GET 요청처럼 처리된다.

기본값으로 HTTP OPTIONS는 URL 패턴에 대응되며 허용하는 HTTP 메소드 목록에 Allow 응답 헤더가 설정되어있는 @RequestMapping 메소드에 의해 처리된다.

@RequestMapping에 HTTP 메소드 목록을 선언 하지 않으면 Allow 헤더는 GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS로 정해진다. 컨트롤러 메소드는 반드시 지원하는 HTTP 메소드 목록을 선언해야 한다.

@RequestMapping 메소드에 HTTP HEAD와 HTTP OPTIONS를 명시적으로 선언할 수 있지만, 대부분의 경우에 그럴 필요가 없다.

Custom Annotations

Spring MVC는 요청 할당을 위해 @RequestMapping 어노테이션을 가진 복합 어노테이션을 지원한다. @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping이 그 예이다. 대부분의 컨트롤러 메소드를 특정 HTTP 메소드를 지원하도록 할당하기 때문에 이러한 복합 어노테이션을 제공한다.

임의의 요청 대응을 위한 요청 할당 속성을 추가하려면 RequestMappingHandlerMapping를 상속하고 getCustomMethodCondition 메소드를 override 해야한다. 이 메소드를 통해 임의의 속성을 확인하고 직접 만든 RequestCondition을 반환한다.

Explicit Registrations

다음과 같이 동적으로 등록하고 싶거나 특별한 용도를 위해 핸들러 메소드를 코드로 등록할 수 있다. 예를 들면 다른 URL을 같은 핸들러의 다른 인스턴스로 할당하는 경우에도 사용할 수 있다.

@Configuration
public class MyConfig {

    @Autowired
    public void setHandlerMapping(RequestMappingHandlerMapping mapping, UserHandler handler) 
            throws NoSuchMethodException {

        RequestMappingInfo info = RequestMappingInfo
                .paths("/user/{id}").methods(RequestMethod.GET).build(); 

        Method method = UserHandler.class.getMethod("getUser", Long.class); 

        mapping.registerMapping(info, handler, method); 
    }

}

Handler Methods

Method Arguments

다음은 컨트롤러 메소드 인자로 지원하는 타입 목록이다. Reactive 타입을 지원하지 않는다. JDK8의 java.util.Optionalrequired 속성을 가진 어노테이션과 함께 쓸 수 있다. 이 때 required값은 false가 된다.

Return Values

다음 값들은 컨트롤러 메소드가 지원하는 반환값이다. Reactive 타입을 지원한다.

Type Conversion

컨트롤러 메소드의 인자가 String 기반의 요청 입력값 어노테이션을 (예를 들어 @RequestParam, @RequestHeader, @PathVariable, @MatrixVariable, @CookieValue)가지고 타입이 String 타입이 아니라면 타입 변환이 필요할 수도 있다.

이런 경우에는 설정한 converter에 따라 자동으로 타입을 변환한다. 기본값으로 int, long, Date 등의 간단한 타입을 지원한다. WebDataBinder을 통해서 혹은 FormattingConversionServiceFormatters를 등록하면 타입 변환을 설정할 수 있다. Spring Field Formatting 참조.

Matrix Variables

RFC 3986은 세부 경로에서의 이름-값 쌍에 대해 이야기한다. Spring MVC는 이 이름-값 쌍을 Tim Bernes-Lee의 오래된 글 을 기반으로 “matrix variable”이라고 부르지만, URI 경로 파라미터라고 부르기도 한다.

Matrix variable은 어느 세부 경로에도 나타날 수 있으며, 각 변수는 세미콜론으로 구분하고 두 개 이상의 값은 콤마로 구분한다. (예를 들어 /cars;color=red,green;year=2012). 두 개 이상의 값은 변수 이름을 반복해서도 나타낼 수 있다. (예를 들어 color=red;color=green;color=blue)

URL이 matrix variable을 가지고 있다면, 컨트롤러 메소드에 요청을 할당할 때 변수의 값에 대응하는 URI 변수를 사용해야 하며 요청을 matrix variable의 존재와 순서와 무관하게 대응해야 한다. 다음 예제에선 matrix variable을 사용한다.

// GET /pets/42;q=11;r=22

@GetMapping("/pets/{petId}")
public void findPet(@PathVariable String petId, @MatrixVariable int q) {

    // petId == 42
    // q == 11
}

모든 세부 경로가 matrix variable을 포함할 수 있으며 어떤 경로 변수에 어떤 matrix variable이 대응되는지 모호하지 않게 만들어야 한다. 다음 예제에선 모호하지 않게 경로 변수를 적어 놓았다.

// GET /owners/42;q=11/pets/21;q=22

@GetMapping("/owners/{ownerId}/pets/{petId}")
public void findPet(
        @MatrixVariable(name="q", pathVar="ownerId") int q1,
        @MatrixVariable(name="q", pathVar="petId") int q2) {

    // q1 == 11
    // q2 == 22
}

다음과 같이 matrix variable은 기본값이 있는 선택 변수로 정의할 수 있다.

// GET /pets/42

@GetMapping("/pets/{petId}")
public void findPet(@MatrixVariable(required=false, defaultValue="1") int q) {

    // q == 1
}

다음 예제와 같이, 경로의 모든 matrix variable을 얻기 위해서 MultiValueMap을 사용할 수 있다.

// GET /owners/42;q=11;r=12/pets/21;q=22;s=23

@GetMapping("/owners/{ownerId}/pets/{petId}")
public void findPet(
        @MatrixVariable MultiValueMap<String, String> matrixVars,
        @MatrixVariable(pathVar="petId") MultiValueMap<String, String> petMatrixVars) {

    // matrixVars: ["q" : [11,22], "r" : 12, "s" : 23]
    // petMatrixVars: ["q" : 22, "s" : 23]
}

Spring MVC에서 matrix variable을 사용하기 위해서는 removeSemicolonContent=falseUrlPathHelper을 설정해야 한다. (XML 설정으로는 <mvc:annotation-driven enable-matrix-variables="true"/>을 사용한다)

Using @RequestParam

@RequestParam 어노테이션으로 Servlet 요청 파라미터를 메소드 인자에 할당할 수 있다.

@Controller
@RequestMapping("/pets")
public class EditPetForm {

    // ...

    @GetMapping
    public String setupForm(@RequestParam("petId") int petId, Model model) { 
        Pet pet = this.clinic.loadPet(petId);
        model.addAttribute("pet", pet);
        return "petForm";
    }

    // ...

}

기본적으로 @RequestParam 어노테이션을 가진 파라미터는 필수값이 되지만, required 설정값을 false로 하거나 인자의 타입을 java.util.Optional로 설정할 수도 있다.

파라미터 타입이 String이 아니면 자동으로 타입을 변환한다. Type Conversion 참고

@RequestParam 어노테이션을 가진 인자가 Map<String, String> 타입이거나 MultiValueMap<String, String>타입이면 모든 요청 파라미터를 해당 인자에 집어넣는다.

간단한 타입의 인자에 대해서는 @RequestParam을 사용하지 않아도 사용한 것으로 간주하고 요청 파라미터를 할당한다.

Using @RequestHeader

요청 헤더를 메소드 인자에 할당하기 위해 @RequestHeader 어노테이션을 사용한다.

다음 헤더를 가진 요청이 있다고하자.

Host                    localhost:8080
Accept                  text/html,application/xhtml+xml,application/xml;q=0.9
Accept-Language         fr,en-gb;q=0.7,en;q=0.3
Accept-Encoding         gzip,deflate
Accept-Charset          ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive              300

다음 예제는 Accept-EncodingKeep-Alive 헤더값을 가져온다.

@GetMapping("/demo")
public void handle(
        @RequestHeader("Accept-Encoding") String encoding, 
        @RequestHeader("Keep-Alive") long keepAlive) { 
    //...
}

String 타입이 아닌 파라미터에 대해선 자동으로 타입을 변환한다.

@RequestHeader 어노테이션을 가진 Map<String, String>, MultiValueMap<String, String>, HttpHeader 인자에는 자동으로 전체 헤더 값이 할당된다.

Using @CookieValue

HTTP 쿠키값을 가져오기 위해 @CookieValue 어노테이션을 사용한다.

다음 쿠키값을 가져오기 위해서는

JSESSIONID=415A4AC178C59DACE0B2C9CA727CDD84

다음 예제와 같이 사용한다.

@GetMapping("/demo")
public void handle(@CookieValue("JSESSIONID") String cookie) { 
    //...
}

String 타입이 아닌 파라미터에 대해선 자동으로 타입을 변환한다.

Using @ModelAttribute

@ModelAttribute 어노테이션을 사용해 모델의 속성에 접근하거나 값을 초기화 할 수 있다. 모델의 속성 필드의 이름과 HTTP Servlet 요청 파라미터의 이름이 같으면 따로 일일이 설정하지 않고도 요청 파라미터로부터 모델 속성에 값을 바로 할당할 수 있다.

@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
public String processSubmit(@ModelAttribute Pet pet) { } 

위 예제의 Pet 인스턴스는 다음과 같이 값을 알아낸다.

보통 Model을 이용해 모델과 속성을 초기화하지만, URI 경로 변수를 Converter<String, T>를 이용해 변환해 초기화하기도 한다. 다음 예제에서 모델 속성의 이름 acount가 URI 경로 변수의 이름 account와 일치하기 때문에, 경로 변수의 String 값을 등록된 Converter<String, Account>을 이용해 변환해 Account 인스턴스를 만든다.

@PutMapping("/accounts/{account}")
public String save(@ModelAttribute("account") Account account) {
    // ...
}

모델 속성 인스턴스를 얻어온 이후에 data binding을 적용한다. WebDataBinder 클래스가 Servlet 요청 파라미터 이름(쿼리 파라미터와 폼 필드)과 대상 Object의 필드 이름을 맞춰본다. 맞는 값을 찾은 대상 필드에 타입 변환한 값을 적용한다. data binding(과 validation)에 대해 더 알고 싶다면 Validation을 참조한다. data binding을 설정하기 위해선 Using DataBinder을 참조한다.

Data binding은 에러를 만들기도 한다. 이 때, 기본값으로 BindException 발생한다. 컨트롤러 메소드에서 이러한 에러가 발생했는지 확인하려면 다음 예제처럼 @ModelAttribute 인자 바로 옆에 BindingResult 인자를 추가한다.

@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
public String processSubmit(@ModelAttribute("pet") Pet pet, BindingResult result) { 
    if (result.hasErrors()) {
        return "petForm";
    }
    // ...
}

data binding없이 모델 속성을 사용하고 싶을 때는 Model을 컨트롤러에 직접 주입하거나 다음 예제와 같이 @ModelAttribute(binding=false) 어노테이션을 사용한다.

@ModelAttribute
public AccountForm setUpForm() {
    return new AccountForm();
}

@ModelAttribute
public Account findAccount(@PathVariable String accountId) {
    return accountRepository.findOne(accountId);
}

@PostMapping("update")
public String update(@Valid AccountUpdateForm form, BindingResult result,
        @ModelAttribute(binding=false) Account account) { 
    // ...
}

javax.validation.Valid 어노테이션 혹은 Spring의 @Validated 어노테이션을 추가하면 data binding 후에 자동으로 validation을 할 수 있다.

@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
public String processSubmit(@Valid @ModelAttribute("pet") Pet pet, BindingResult result) { 
    if (result.hasErrors()) {
        return "petForm";
    }
    // ...
}

다른 어떤 인자로도 해석되지 않는 간단한 타입이 아닌 인자는 @ModelAttribute 가 없어도 있는 것 처럼 처리한다.

Using @SessionAttributes

@SessionAttributes를 사용하면 요청간의 HTTP Servlet 세션에 모델을 저장할 수 있다. 이 어노테이션은 세션 속성 클래스에 사용한다. 세션에 저장할 모델 속성이나 모델 속성의 타입 이름을 나열한다.

다음 예제에서 @SessionAttributes 어노테이션을 사용한다.

@Controller
@SessionAttributes("pet")
public class EditPetForm {
    // ...
}

첫 요청을 처리할 때, pet이란 이름의 모델 속성이 모델에 추가되고 HTTP Servlet 세션에도 자동으로 추가된다. 이 모델 속성은 다른 컨트롤러 메소드가 SessionStatus 메소드 인자를 이용해 저장공간을 지울 때까지 남아있게 된다.

@Controller
@SessionAttributes("pet") 
public class EditPetForm {

    // ...

    @PostMapping("/pets/{id}")
    public String handle(Pet pet, BindingResult errors, SessionStatus status) {
        if (errors.hasErrors) {
            // ...
        }
            status.setComplete();
            // ...
        }
    }
}

Using @SessionAttribute

이미 존재하는 전역 세션 속성에 접근하기 위해 메소드 파라미터에 @SessionAttribute를 사용할 수 있다.

@RequestMapping("/")
public String handle(@SessionAttribute User user) { 
    // ...
}

세션 속성을 추가하거나 삭제하기 위해서는 컨트롤러 메소드에 org.springframework.web.context.request.WebRequestjavax.servlet.http.HttpSession를 주입한다.

컨트롤러 실행 과정의 일부로 세션에 모델 속성을 임시로 저장하기 위해서는 @SessionAttributes를 사용한다.

Using @RequestAttribute

@SessionAttribute와 유사하게, @RequestAttribute를 사용해 이미 존재하는 요청 속성 (Servlet FilterHandlerInterceptor가 미리 만들어놓은)에 접근한다.

@GetMapping("/")
public String handle(@RequestAttribute Client client) {
    // ...
}

Redirect Attributes

기본적으로 모든 모델 속성은 리다이렉트 URL의 URI 템플릿 변수로 노출된다고 봐도 좋다. 원시 타입이나 원시 타입의 컬렉션 혹은 배열은 쿼리 파라미터로 추가된다.

리다이렉트를 위해 특별히 준비한 모델 인스턴스가 있다면 원시 타입 속성값을 쿼리 파라미터로 추가할 수 있다. 그러나 모델에는 렌더링을 위해 추가로 존재하는 속성값도 있다. 이러한 속성값이 URL에 노출되는 것을 막으려면 @RequestMapping 메소드는 RedirectAttributes 타입의 인자를 선언하고 어떤 속성값을 RedirectView에 나타낼지 정할 수 있다. 메소드가 리다이렉트를 하게 되면 RedirectAttributes의 내용을 사용하고 그렇지 않으면 모델의 내용을 사용한다.

RequestMappingHandlerAdapter는 컨트롤러 메소드가 리다이렉트를 할 때 기본 Model의 내용을 사용하지 않도록 설정할 수 있는 ignoreDefaultModelOnRedirect 설정값을 제공한다. 대신 메소드가 RedirectView에 넘겨줄 속성값을 RedirectAttributes 타입의 속성값에 반드시 명시해야 한다. Spring MVC는 이전 버전에 대한 호환성을 위해 ignoreDefaultModelOnRedirect의 값을 false로 둔다. 하지만 새로운 어플리케이션에는 true로 설정하기를 권장한다.

현재 요청의 URI 템플릿 변수는 리다이렉트 URL을 만들 때 자동으로 사용 가능해지고 해당 변수들을 Model이나 RedirectAttributes에 명시적으로 추가해야 한다. 다음 예제는 리다이렉트를 정의하는 법을 보여준다.

@PostMapping("/files/{path}")
public String upload(...) {
    // ...
    return "redirect:files/{path}";
}

데이터를 리다이렉트 목적지에 전달하기 위해선 플래시 속성값을 사용할 수도 있다. 다른 리다이렉트 속성값과는 다르게 플래시 속성값은 HTTP 세션에 저장한다.

Flash Attributes

플래시 속성값은 이용해 요청의 속성값을 다른 곳에 쓰기 위해 저장할 수 있다. 예를 들면 POST-리다이렉트-GET 패턴과 같은 리다이렉트에 쓰기 위한 값을 저장할 수 있다. 리다이렉트 하기 전에 플래시 속성값을 임시로 저장해 리다이렉트 이후의 요청에 사용할 수 있게 해주며 그 뒤에 즉시 삭제한다.

Spring MVC는 플래시 속성값을 지원하기 위해 FlashMapFlashMapManager를 제공한다. FlashMap을 이용해 플래시 속성값을 가지고 있으며, FlashMapManager를 이용해 FlashMap 인스턴스를 저장하고 읽고 관리한다.

플래시 속성값은 항상 사용 가능하며 명시적으로 설정할 필요가 없다. 그러나 사용하지 않으면 HTTP 세션을 절대 만들지 않는다. 요청 마다 이전 요청에서 넘어온 FlashMap 이 있으며, 이후 요청을 위한 출력 FlashMap이 있다. 두 FlashMap 인스턴스 모두 RequestContextUtils의 정적 메소드를 통해 Spring MVC의 어디서든 접근이 가능하다.

컨트롤러가 직접 FlashMap을 가지고 사용할 일은 많지 않다. 대신, @RequestMapping 메소드가 RedirectAttributes 타입의 인자를 사용해 리다이렉트를 위해 플래시 속성값을 추가할 수 있다. 이 인자에 추가한 속성값은 자동으로 출력 FlashMap에 추가된다. 마찬가지로, 입력 FlashMap의 속성값도 자동으로 리다이렉트 대상 URL을 처리하는 컨트롤러의 Model에 추가된다.

Multipart

MultipartResolver가 활성화되면, multipart/form-data 형식을 가진 POST 요청의 컨텐츠를 파싱해 일반 요청 파라미터처럼 사용할 수 있도록 해준다. 다음 예제에서 하나의 일반 폼 필드와 하나의 업로드 파일을 접근할 수 있다.

@Controller
public class FileUploadController {

    @PostMapping("/form")
    public String handleFormUpload(@RequestParam("name") String name,
            @RequestParam("file") MultipartFile file) {

        if (!file.isEmpty()) {
            byte[] bytes = file.getBytes();
            // store the bytes somewhere
            return "redirect:uploadSuccess";
        }

        return "redirect:uploadFailure";
    }

}

multipart 데이터를 모델 객체의 data binding의 일부로도 사용할 수 있다. 예를 들어 이전 예제의 폼 필드와 파일을 가진 폼 객체를 사용할 수 있다.

class MyForm {

    private String name;

    private MultipartFile file;

    // ...

}

@Controller
public class FileUploadController {

    @PostMapping("/form")
    public String handleFormUpload(MyForm form, BindingResult errors) {

        if (!form.getFile().isEmpty()) {
            byte[] bytes = form.getFile().getBytes();
            // store the bytes somewhere
            return "redirect:uploadSuccess";
        }

        return "redirect:uploadFailure";
    }

}

RESTful 서비스를 사용할 때에는 브라우저가 아닌 클라이언트가 multipart 요청을 보낼 수 있다. 다음 예제는 JSON과 함께 파일이 있는 요청 내용을 보여준다.

POST /someUrl
Content-Type: multipart/mixed

--edt7Tfrdusa7r3lNQc79vXuhIIMlatb7PQg7Vp
Content-Disposition: form-data; name="meta-data"
Content-Type: application/json; charset=UTF-8
Content-Transfer-Encoding: 8bit

{
    "name": "value"
}
--edt7Tfrdusa7r3lNQc79vXuhIIMlatb7PQg7Vp
Content-Disposition: form-data; name="file-data"; filename="file.properties"
Content-Type: text/xml
Content-Transfer-Encoding: 8bit
... File Data ...

@RequestParam을 이용해 String으로 메타데이터 부분을 가져올 수도 있지만, JSON을 역직렬화하고 싶다면 @RequestPart 어노테이션을 이용해 HttpMessageConverter로 변환한 multipart 데이터에 접근할 수 있다.

@PostMapping("/")
public String handle(@RequestPart("meta-data") MetaData metadata,
        @RequestPart("file-data") MultipartFile file) {
    // ...
}

@RequestPartjavax.validation.Valid를 함께 사용하거나 Spring의 @Validated 어노테이션을 함께 사용할 수 있다. 기본값으로 validation 에러는 400(BAD_REQUEST)응답을 만드는 MethodArgumentNotValidException을 던진다. 아니면 다음 예제와 같이 ErrorsBindingResult 인자를 이용해 validation 에러를 컨트롤러 내부에서 처리할 수도 있다.

@PostMapping("/")
public String handle(@Valid @RequestPart("meta-data") MetaData metadata,
        BindingResult result) {
    // ...
}

Using @RequestBody

@RequestBody 어노테이션을 사용해 요청 본문을 읽거나 HttpMessageConverter를 통해 Object로 역직렬화할 수 있다.

@PostMapping("/accounts")
public void handle(@RequestBody Account account) {
    // ...
}

@RequestPartjavax.validation.Valid를 함께 사용하거나 Spring의 @Validated 어노테이션을 함께 사용할 수 있다. 기본값으로 validation 에러는 400(BAD_REQUEST)응답을 만드는 MethodArgumentNotValidException을 던진다. 아니면 다음 예제와 같이 ErrorsBindingResult 인자를 이용해 validation 에러를 컨트롤러 내부에서 처리할 수도 있다.

@PostMapping("/accounts")
public void handle(@Valid @RequestBody Account account, BindingResult result) {
    // ...
}

HttpEntity

HttpEntity@RequestBody를 사용하는 것과 비슷하지만, 요청 헤더와 본문 모두를 가지고 있다.

@PostMapping("/accounts")
public void handle(HttpEntity<Account> entity) {
    // ...
}

Using @ResponseBody

@ResponseBody를 이용해 HttpMessageConverter로 직렬화한 응답 본문을 반환할 수 있다.

@GetMapping("/accounts/{id}")
@ResponseBody
public Account handle() {
    // ...
}

클래스에 @ResponseBody를 사용하면 모든 컨트롤러 메소드가 해당 어노테이션을 상속받게 된다. @RestController가 같은 역할을 하며, 이 어노테이션은 @Controller@ResponseBody를 합쳐놓은 것과 같다.

@ResponseBody를 reactive 타입과 함께 사용할 수 있다. Asynchronous RequestsReactive Types를 참조하라.

@ResponseBody 메소드를 JSON 직렬화 뷰와 함께 사용할 수 있다.

ResponseEntity

ResponseEntity@ResponseBody를 사용하는 것과 비슷하지만, 응답 헤더와 본문 모두를 가지고 있다.

@PostMapping("/something")
public ResponseEntity<String> handle() {
    // ...
    URI location = ... ;
    return ResponseEntity.created(location).build();
}

Jackson JSON

Spring은 Jackson JSON 라이브러리를 지원한다.

Jackson Serialization Views

Spring MVC는 객체의 일부 필드만을 렌더링하도록 도와주는 Jackson의 직렬화 뷰에 대한 지원을 내장했다. @ResponseBodyResponseEntity와 함께 이 기능을 사용하려면 다음과 같이 Jackson의 @JsonView 어노테이션을 직렬화 뷰 클래스에 추가한다.

@RestController
public class UserController {

    @GetMapping("/user")
    @JsonView(User.WithoutPasswordView.class)
    public User getUser() {
        return new User("eric", "7!jd#h23");
    }
}

public class User {

    public interface WithoutPasswordView {};
    public interface WithPasswordView extends WithoutPasswordView {};

    private String username;
    private String password;

    public User() {
    }

    public User(String username, String password) {
        this.username = username;
        this.password = password;
    }

    @JsonView(WithoutPasswordView.class)
    public String getUsername() {
        return this.username;
    }

    @JsonView(WithPasswordView.class)
    public String getPassword() {
        return this.password;
    }
}

뷰를 채우는 컨트롤러를 위해서는 다음 예제와 같이 직렬화 뷰 클래스를 모델에 추가할 수 있다.

@Controller
public class UserController extends AbstractController {

    @GetMapping("/user")
    public String getUser(Model model) {
        model.addAttribute("user", new User("eric", "7!jd#h23"));
        model.addAttribute(JsonView.class.getName(), User.WithoutPasswordView.class);
        return "userView";
    }
}

Model

@ModelAttribute를 이용해

이번 절에서는 두 번째 사용법인 @ModelAttribute 메소드에 대해 이야기한다. 컨트롤러는 @ModelAttribute 메소드를 몇개든 가질 수 있다. 모든 @ModelAttribute 메소드는 @ControllerAdvice를 통해 컨트롤러 간에도 공유할 수 있다.

@ModelAttribute 메소드는 유연한 메소드 서명을 가진다. @RequestMapping 메소드가 지원하는 인자 중 @ModelAttribute 자체나 요청 본문과 관련되지 않은 대부분을 지원한다.

다음 예제는 @ModelAttribute 메소드를 보여준다.

@ModelAttribute
public void populateModel(@RequestParam String number, Model model) {
    model.addAttribute(accountRepository.findAccount(number));
    // add more ...
}

다음 예제에선 @ModelAttribute 메소드가 하나의 속성값만을 더한다.

@ModelAttribute
public Account addAccount(@RequestParam String number) {
    return accountRepository.findAccount(number);
}

@RequestMapping 메소드의 반환값을 모델 속성값으로 사용할 수 있을 때 @ModelAttribute@RequestMapping메소드에 덧붙일 수 있다. 하지만 해당 메소드의 반환값을 뷰 이름으로 해석하지 못하게 하는 경우가 아니면, HTML 컨트롤러의 기본 동작이므로 @ModelAttribute를 필수적으로 추가해주지 않아도 된다.

@ModelAttribute는 다음 예제와 같이 모델 속성값 이름을 임의로 변경할 수 있도록 도와준다.

@GetMapping("/accounts/{id}")
@ModelAttribute("myAccount")
public Account handle() {
    // ...
    return account;
}

Using DataBinder

@Controller@ControllerAdvice 클래스는 WebDataBinder의 인스턴스를 초기화하는 @InitBinder 메소드를 가질 수 있다. 해당 메소드는

@InitBinder 메소드는 컨트롤러의 특정한 java.bean.PropertyEditor나 Spring ConverterFormatter 컴포넌트를 등록할 수 있다. 추가로 MVC 설정을 이용해 전역으로 공유하는 FormattingConvensionServiceConverterFormatter를 등록할 수 있다.

@InitBinder 메소드는 @RequestMapping 메소드가 지원하는 인자 중 @ModelAttribute 인자를 제외하곤 대부분 지원한다. 인자는 보통 WebDataBinder 타입으로 선언되며 반환값이 없다 (void). 다음 예제는 @InitBinder의 사용 예제를 보여준다.

@Controller
public class FormController {

    @InitBinder 
    public void initBinder(WebDataBinder binder) {
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
        dateFormat.setLenient(false);
        binder.registerCustomEditor(Date.class, new CustomDateEditor(dateFormat, false));
    }

    // ...
}

FormattingConversionService를 통해 Formatter 기반의 설정을 사용한다면, 위와 비슷한 방식으로 다음 예제처럼 컨트롤러별로 Formatter 구현을 등록할 수 있다.

@Controller
public class FormController {

    @InitBinder 
    protected void initBinder(WebDataBinder binder) {
        binder.addCustomFormatter(new DateFormatter("yyyy-MM-dd"));
    }

    // ...
}

Exceptions

@Controller@ControllerAdvice 클래스는 예외 처리를 위해 @ExceptionHandler 메소드를 사용한다.

@Controller
public class SimpleController {

    // ...

    @ExceptionHandler
    public ResponseEntity<String> handle(IOException ex) {
        // ...
    }
}

@ExceptionHandler의 예외는 전파되는 최상위 예외(직접 던져진 IOException)이거나 최상위 예외에 포장된 직접적 원인(IOExceptionIllegalStateException으로 감싼 경우)일 수 있다.

위의 예제처럼 예외 처리를 하려는 대상 예외를 메소드 인자로 선언한다. 여러 예제가 동시에 메소드가 처리하려는 예외와 일치한다면 최상위 예외가 원인 예외보다 더 우선순위가 높다. ExceptionDepthComparator가 던져진 예외 타입으로부터 깊이를 알아내 예외 우선순위를 정렬한다.

다음 예제처럼 어노테이션에 처리할 대상 예외 타입을 더 구체적으로 나타낼 수 있다.

@ExceptionHandler({FileSystemException.class, RemoteException.class})
public ResponseEntity<String> handle(IOException ex) {
    // ...
}

다음처럼 구체적인 예외 타입을 표시하고 일반적인 인자를 사용할 수도 있다.

@ExceptionHandler({FileSystemException.class, RemoteException.class})
public ResponseEntity<String> handle(Exception ex) {
    // ...
}

보통은 대상 예외를 잘못 할당할 가능성을 줄이기 위해 인자 타입을 최대한 구체적으로 지정해주는 것이 좋다. 여러 예외가 할당될 수 있는 @ExceptionHandler 메소드를 하나의 예외만 할당되는 메소드로 분리하는 것을 검토하라.

여러 @ControllerAdvice가 있으면 우선순위가 높은 @ControllerAdvice에 우선적인 최상위 예외를 할당하도록 권고한다. 최상위 예외가 원인 예외보다 우선시되긴 하지만, 한 컨트롤러나 @ControllerAdvice 클래스안에서만 적용된다. 결국 우선순위가 높은 @ControllerAdvice bean의 원인 예외가 낮은 우선순위의 @ControllerAdvice bean의 모든 예외보다 우선순위가 높다는 말이다.

@ExceptionHandler 메소드 구현이 주어진 예외 인스턴스를 원래 형태로 다시 던져서 그 예외 인스턴스를 처리하지 못하도록 할 수도 있다. 최상위 레벨의 예외만 대응하거나 정적으로 예외 처리 대상이 정해지지 않는 특정 상황에서 유용한 방법이다. 다시 던져진 예외는 한 번도 처리되지 않은 것처럼 남은 일련의 예외 처리 단계에 전파된다.

Spring MVC의 @ExceptionHandler 메소드에 대한 지원은 DispatcherServlet 수준에서 이루어진다.

Method Arguments

@ExceptionHandler는 다음 목록에 나열된 인자를 지원한다.

Return Values

@ExceptionHandler는 다음 목록에 나열된 반환값을 지원한다.

REST API exceptions

REST 서비스의 흔한 요구사항은 응답 본문에 에러에 대한 자세한 정보를 담는 것이다. 에러에 대한 자세한 정보를 어떻게 표현할지는 어플리케이션마다 다르므로 Spring 프레임워크가 자동으로 해주지는 않는다. 다만 @RestControllerResponseEntity를 반환하는 @ExceptionHandler 메소드를 사용하면 응답 상태와 본문을 설정할 수 있다. @ControllerAdvice 클래스에 해당 메소드를 추가하면 전역으로 적용 가능하다.

예외에 대한 자세한 정보를 응답 본문에 담는 전역 예외 처리를 사용하는 어플리케이션은 ResponseEntityExceptionHandler를 상속받는 것을 고려해야 한다. 이 클래스는 Spring MVC가 던지는 예외를 처리하는 방법과 응답 본문을 설정할 수 있는 방법을 제공한다. 이 클래스를 사용하려면 ResponseEntityExceptionHandler를 상속받아 @ControllerAdvice를 추가하고 필요한 메소드를 구현한 뒤, Spring bean으로 선언하면 된다.

Controller Advice

보통 @ExceptionHandler, @InitBinder, @ModelAttribute 메소드는 정의된 @Controller 클래스 안에서 적용된다. 이런 메소드를 컨트롤러간에도 공유하고 싶다면 메소드를 가진 클래스에 @ControllerAdvice@RestControllerAdvice를 추가한다.

@ControllerAdvice@Component가 붙어 있어 해당 어노테이션이 붙어있는 클래스는 Spring bean으로 등록된다. @RestControllerAdvice@ControllerAdvice@ResponseBody가 붙어있어, @ExceptionHandler 메소드가 (뷰 해석이나 템플릿 렌더링이 아닌) 메세지 변환을 통해 응답 본문으로 렌더링된다는 것을 의미한다.

Spring이 시작할 때, @RequestMapping@ExceptionHandler 메소드를 위한 내부 클래스가 @ControllerAdvice 타입의 Spring bean을 찾아 런타임에 해당 메소드들에 적용한다. @ControllerAdvice@ExceptionHandler 메소드는 @Controller@ExceptionHandler 메소드 뒤에 적용된다. 이와는 반대로, @ModelAttribute@InitBinder 메소드는 컨트롤러 각각의 메소드 이전에 적용된다.

기본값으로 @ControllerAdvice 메소드는 모든 요청에 적용되나, 다음 예제와 같이 어노테이션의 속성값을 통해 적용할 컨트롤러의 집합을 줄여나갈 수 있다.

// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}

// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}

위의 예제의 값들은 런타임에 평가하고 많이 사용하면 성능에 부정적인 영향을 미친다. 자세한 사항은 @ControllerAdvice Javadoc을 참고하라.