[spring] 모델 바인딩과 검증

메서드에 @ModelAttribute를 파라미터로 선언했을 경우 처리되는 과정은 다음과 같다.

  1. 파라미터 타입의 오브젝트를 새로 만든다. 때문에 디폴트 생성자가 필수로 필요하다.
    @SessionAttributes를 통해 저장된 오브젝트가 있으면 새로 만들지 않고 세션에서 가져온다.

  2. HTTP 요청을 생성(혹은 가져온) 오브젝트 프로퍼티에 바인딩 해준다.
    이 과정에서 각 프로퍼티에 맞게 타입을 변환해준다.
    만약 타입 변환 오류가 발생할 시 BindingResult 오브젝트에 오류를 저장해서 컨트롤러로 넘겨준다.

  3. 검증작업을 수행한다. 2번의 과정에서 타입에 대한 검증은 이미 끝냈고, 그 외의 검증은 검증기를 통해 등록할 수 있다.


프로퍼티 바인딩

프로퍼티 바인딩이란 오브젝트의 프로퍼티에 값을 넣는 행위를 말한다.
프로퍼티에 맞게 타입을 적절히 변환하고 해당 프로퍼티의 수정자 메서드를 호출하는 것이다.

스프링에선 크게 두가지의 프로퍼티 바인딩을 지원하는데

첫번째는 애플리케이션 컨텍스트 XML 설정파일로 빈을 정의할 때 사용했던 <property> 태그이다.
이 태그를 통해 빈의 프로퍼티에 값을 주입했었다.

두번째는 HTTP 요청 파라미터를 모델 오브젝트 등으로 변환하는 경우이다.
@ModelAttribute 뿐만 아니라 @RequestParam, @PathVariable 등도 해당된다.

근데 잘 생각해보면, 프로퍼티 바인딩이 일반 primitive 타입이 아닌 경우에도 가능했던 적이 있었다.
루트 웹 애플리케이션 컨텍스트에서 dataSource 빈을 설정할 때다.

1
2
3
<bean id="dataSource" class="org.springframework..SimpleDriverDataSource">
<property name="driverClass" value="com.mysql.jdbc.Driver" />
</bean>

보다시피 value에 문자열로 클래스명을 전달하고 있다.
그런데 driverClass 프로퍼티는 String 타입이 아닌 Class 타입이다. 하지만 잘 바인딩 된다.
이는 스프링이 제공하는 프로퍼티 바인딩 기능을 사용했기 때문이다.
스프링은 프로퍼티 바인딩을 위해 2가지 API를 제공한다.

PropertyEditor

스프링이 기본적으로 제공하는 바인딩용 타입 변환 API이다.

PropertyEditor는 스프링 API가 아니라 자바빈 표준에 정의된 API이다.
GUI 환경에서 비주얼 컴포넌트를 만들 때 사용하도록 설계되었고, 기본적인 기능은 문자열과 자바빈 프로퍼티 사이의 타입 변환이다.
스프링은 이 PropertyEditor문자열-오브젝트 상호변환이 필요한 XML 설정이나 HTTP 파라미터 변환에 유용하게 사용할 수 있다고 판단하여 이를 일찍부터 사용해왔다.

스프링은 20여가지 정도의 PropertyEditor를 만들어 디폴트로 제공하고 있다.
아래의 링크에서 확인할 수 있다.
https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/beans/propertyeditors/package-summary.html
이 디폴트 PropertyEditor들은 바인딩 과정에서 파라미터 타입에 맞게 자동으로 선정되어 사용된다.

PropertyEditor 구현

디폴트 프로퍼티 데이터에 등록되지 않은 타입을 파라미터로 사용하고 싶을 경우, 직접 PropertyEditor를 만들어 적용할 수 있다.
아래와 같은 enum이 하나 있고,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public enum Level {
GOLD(3), SILVER(2), BASIC(3);

private Integer level;

Level(Integer level){
this.level = level;
}

// 숫자를 받으면 해당하는 enum을 리턴
public static Level convert(Integer level){
switch(level){
case 3 : return GOLD;
case 2 : return SILVER;
case 1 : return BASIC;
default : throw new RuntimeException();
}
}
}

아래와 같이 컨트롤러를 등록하고 /user?level=1 과 같이 호출하면 자동으로 Level enum으로 변환해서 받고 싶다고 하자.
(level 파라미터가 Integer이지만 변환이 간단하므로 문제될 것 없다)

1
2
3
4
5
6
7
@Controller
public class UserController{
@RequestMapping("/user", method=RequestMethod.GET)
public String userSearch(@RequestParam Level level){
// ...
}
}

현재는 당연히 변환이 불가능하므로 오류가 발생한다.

Level 타입에 대한 PropertyEditor를 만들어야 한다.
아래는 프로퍼티 에디터가 변환할 때의 동작 방식이다.
프로퍼티 에디터 동작원리
setValue(), getValue()는 그냥 getter,setter이기 때문에 손댈 것 없고,
실제로 우리가 구현해야 할 메서드는 setAsText()getAsText()이다.

현재 우리한테 필요한 부분은 문자열 -> 오브젝트의 과정이므로 setAsText() 메서드를 구현해서 Level enum에 대한 PropertyEditor를 만들어보겠다.

1
2
3
4
5
6
public class LevelPropertyEditor extends PropertyEditorSupport{
@Override
public void setAsText(String text) throws IllegalArgumentException {
this.setValue(Level.convert(Integer.parseInt(text)));
}
}

이제 이 PropertyEditoruserSearch 메서드에서 사용할 수 있게 등록해줘야 한다.

PropertyEditor 등록

PropertyEditor를 추가하기 전에 먼저 컨트롤러에서 메서드 바인딩이 일어나는 순서를 알아보자.
AnnotationMethodHandlerAdapter@RequestParam, @PathVariable, @ModelAttribute와 같이 HTTP 요청을 변수에 바인딩하는 애노테이션을 만나면 먼저 WebDataBinder라는 것을 만든다.
WebDataBinder는 여러가지 기능을 포함하는데, 여기에 HTTP 요청 문자열을 파라미터로 변환하는 기능도 포함되어 있다.
즉, 우리가 만든 PropertyEditor를 사용하려면 이 WebDataBinder에 직접 등록해줘야 한다.
근데 WebDataBinder의 변환 과정이 외부로 노출되지 않으므로, 직접 등록해 줄 방법은 없다.
그래서 스프링이 제공하는 WebDataBinder 초기화 메서드를 사용해야 한다.

  1. @InitBinder
    컨트롤러 클래스에 아래와 같이 @InitBinder 애노테이션이 부여되고, WebDataBinder를 인자로 받는 메서드를 하나 생성하자.
1
2
3
4
5
6
7
8
9
10
11
12
@Controller
public class UserController{
@InitBinder
public void initBinder(WebDataBinder dataBinder){
dataBinder.registerCustomEditor(Level.class, new LevelPropertyEditor());
}

@RequestMapping("/user", method=RequestMethod.GET)
public String userSearch(@RequestParam Level level){
// ...
}
}

그리고 WebDataBinderregisterCustomEditor 메서드에 PropertyEditor를 적용할 타입과 PropertyEditor 인스턴스를 전달해주면 된다.
이후 다시 /user?level=1을 호출해보면 level 변수에 Level.BASIC 오브젝트가 들어가있는 것을 확인할 수 있다.

WebDataBinder 대신 WebRequest를 받을 수도 있다!

initBinder 메서드는 클래스내의 모든 메서드에 대해 파라미터를 바인딩하기 전에 자동으로 호출된다.
바인딩 적용 대상은 @RequestParam, @PathVariable, @CookieValue, @RequestHeader, @ModelAttribute의 프로퍼티 이다.

기본적으로 PropertyEditor는 지정한 타입과 일치하면 항상 적용된다.
여기에 프로퍼티 이름을 추가 조건으로 주고, 프로퍼티 이름까지 일치해야만 적용되게 할 수 있다.
이러한 타입의 PropertyEditor는 이미 PropertyEditor가 존재할 경우 사용한다.
WebDataBinder는 바인딩 시 커스텀 PropertyEditor가 있을 경우 이를 선적용하고, 없을 경우 디폴트 PropertyEditor를 적용하기 때문이다.
아래는 적절한 예시이다.

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 MinMaxPropertyEditor extends PropertyEditorSupport{
Integer min;
Integer max;

public MinMaxPropertyEditor(Integer min, Integer max) {
this.min = min;
this.max = max;
}

@Override
public void setAsText(String text) throws IllegalArgumentException {
Integer value = Integer.valueOf(text);
if(value < min){
value = min;
}
if(value > max){
value = max;
}

this.setValue(value);
}
}

public class User{
Integer id;
Integer age;
}

@Controller
public class UserController{
@InitBinder
public void initBinder(WebDataBinder dataBinder){
dataBinder.registerCustomEditor(Integer.class, "age", new MinMaxPropertyEditor(1, 50));
}

@RequestMapping("/user", method=RequestMethod.POST)
public String userAdd(@ModelAttribute User user){
// ...
}
}

이렇게 해두면 추가하는 유저의 age값은 1~50까지로 제한된다.
PropertyEditor를 등록할 때 프로퍼티 이름으로 age를 지정했기 때문에 id에는 적용되지 않는다.
참고로 이 방식은 프로퍼티 이름이 필요하므로 @RequestParam 같은 단일 파라미터 바인딩에는 적용되지 않는다.

  1. WebBindingInitializer
    @InitBinder 방식은 범위가 컨트롤러 하나로만 제한되므로 다른 컨트롤러에서 사용하려면 또 다시 등록해줘야 한다.
    만약 해당 PropertyEditor가 모든 곳에 적용해도 될 만큼 필요한 PropertyEditor라면 등록하는 방법을 달리하여 모든 컨트롤러에 적용해줄 수 있다.
    먼저 WebBindingInitializer 인터페이스를 구현한 클래스를 작성한다.
1
2
3
4
5
6
7
public class MyWebBindingInitializer implements WebBindingInitializer{

@Override
public void initBinder(WebDataBinder binder, WebRequest request) {
binder.registerCustomEditor(Level.class, new LevelPropertyEditor());
}
}

이제 이 클래스를 빈으로 등록하고 AnnotationMethodHandlerAdapterwebBindingInitializer 프로퍼티에 DI 해주면 전체적으로 적용된다.

1
2
3
4
5
<bean class="org.springframework..AnnotationMethodHandlerAdapter">
<property name="webBindingInitializer">
<bean class="MyWebBindingInitializer" />
</property>
</bean>

프로토타입 PropertyEditor

앞서 작성했던 PropertyEditor 등록 코드들을 보면, 매번 new 키워드PropertyEditor를 생성하고 있다.
이 부분이 뭔가 부담스럽게 생각되어 PropertyEditor를 빈으로 등록하는 방식을 생각할 수 있는데, 이는 위험한 상황을 초래한다.
위의 PropertyEditor 동작방식을 다시 살펴보면, 변환과정에서 항상 set -> get의 순서로 2개의 메서드를 사용하고 있음을 볼 수 있다.
이 말인 즉, PropertyEditor짧은 시간이나마 상태를 가진다는 것을 의미한다.
상태를 가지는 오브젝트는 절대 빈으로 등록되서는 안된다.

매번 new 키워드로 생성되는 부분이 부담스러워 보일 수 있으나, 실상 PropertyEditor는 워낙 간단한 클래스라 자주 생성되도 별로 문제가 되지 않는다.
그러므로 싱글톤으로 PropertyEditor를 생성하는 실수를 하지 않도록 주의해야 한다.

근데 개발을 하다보면, PropertyEditor에서 다른 빈을 DI 받아야 할 경우가 가끔 생긴다.
예를 들면 아래와 같이 변환할 프로퍼티가 하나의 도메인 오브젝트에 대응하는 경우이다.

1
2
3
4
5
6
public class User{
Integer id;
String name;
Code userType; // 코드 테이블에 대응하는 도메인 오브젝트
// ...
}

이런 경우, 일반적인 방법으로는 변환할 수 없다. 요청 파라미터는 평범한 문자열이기 때문이다.

이 상황을 해결할 수 있는 방법은 2가지가 있다.

  1. 모조 PropertyEditor
    Code 오브젝트로 변환하되, 완벽하지 않은 오브젝트로 변환하는 방법이다.
1
2
3
4
5
6
7
8
9
public class CodePropertyEditor extends PropertyEditorSupport{
@Override
public void setAsText(String text) throws IllegalArgumentException {
Code code = new Code();
code.setId(Integer.valueOf(text));

this.setValue(code);
}
}

이런식으로 전달받은 id값만 채운 불완전한 Code 오브젝트를 돌려주는 것이다.
이런 방식을 모조 PropertyEditor라고 부른다.
하지만 이 방식은 조금 위험하다. 다른 프로퍼티들의 값이 모두 null인 불완전한 오브젝트로 변환해주기 때문이다.
이런 오브젝트는 업데이트가 발생하면 심각한 문제를 초래할 수 있으나,
사실상 이러한 코드성 도메인 오브젝트는 다른 테이블에서 참조하는 용도로만 사용하는 것이 대부분이다.
그래서 이런 부분만 유의해주면 매우 유용하게 활용할 수 있다.

  1. 프로토타입 도메인 오브젝트 PropertyEditor
    PropertyEditor를 프로토타입 빈으로 등록하고, 서비스나 DAO 객체를 DI 받아 Code 오브젝트를 조회해오는 방법이다.
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
@Component
@Scope("prototype")
public class CodePropertyEditor extends PropertyEditorSupport{
@Atuworied codeService;

@Override
public void setAsText(String text) throws IllegalArgumentException {
Code code = codeService.getCode(Integer.valueOf(text));

this.setValue(code);
}
}

@Controller
public class UserController{
@Inject Provider<CodePropertyEditor> codePropertyEditorProvider;

@InitBinder
public void initBinder(WebDataBinder dataBinder){
dataBinder.registerCustomEditor(Code.class, codePropertyEditorProvider.get());
}

@RequestMapping("/user", method=RequestMethod.POST)
public String userAdd(@ModelAttribute User user){
// ...
}
}

이 방식의 장점은 항상 완전한 도메인 오브젝트를 리턴해주므로, 앞서 제기했던 위험이 없어진다.
단점으로는 매번 DB에서 조회를 해야하므로 성능에 조금 부담을 주는 단점이 있다.
하지만 JPA와 같이 엔티티 단위의 캐싱 기법이 발달한 기술을 사용할 경우, DB에서 조회하는 대신 메모리에서 바로 읽어올 수 있으므로 DB 부하에 대한 걱정은 하지 않아도 된다.

Converter

PropertyEditor는 근본적인 단점이 있다.
상태를 가지고 있으므로 싱글톤으로 등록할 수 없고, 항상 새로운 오브젝트를 만들어야 한다는 점이다.
스프링 3.0이후로 이러한 PropertyEditor의 단점을 보완해주는 Converter라는 타입 변환 API가 등장하였다.
ConverterPropertyEditor와 달리 변환과정에서 메서드가 한번만 호출된다.
즉, 상태를 가지지 않는다는 뜻이고, 싱글톤으로 등록할 수 있다는 뜻이다!

Converter 구현

아래는 Converter 인터페이스이다.

1
2
3
public interface Converter<S, T>{
T convert(s source);
}

양방향 변환을 지원하던 PropertyEditor와는 달리, 단방향 변환만을 지원한다.
(양방향을 원하면 그냥 반대방향의 Converter를 하나 더 만들면 된다.)
게다가 한쪽 타입이 무조건 String으로 고정되는 불편함 없이 직접 지정 가능하다.
아래는 전달받은 파라미터를 Level 타입으로 변환해주는 Converter이다.

1
2
3
4
5
public class LevelConverter implements Convert<Integer, Level>{
public Level convert(Integer source){
return Level.convert(source)
}
}

타입을 바로 Integer로 지정함으로써 지저분한 타입변환 코드를 제거할 수 있다.

Converter 등록

PropertyEditor처럼 직접 등록할 수 없고, ConversionService 타입의 오브젝트를 통해서 WebDataBinder에 등록해야 한다.
ConversionService 타입의 오브젝트를 빈으로 등록하고 이를 DI받아 WebDataBinder에 등록하는 방식이므로 PropertyEditor에 비해 부담이 적다.

ConversionService를 등록하는 방법은 2가지가 있다.

첫째로 직접 클래스를 만들고 GenericConversionService를 상속받은 뒤, addConverter() 메서드로 Converter들을 등록하는 방식이다. 이후 빈으로 등록한다.

둘째는 추가할 Converter들을 빈으로 등록해두고 ConversionServiceFactoryBean을 이용해서 Converter들이 추가된 GenericConversionService를 빈으로 등록하는 방식이다.
직접 클래스를 만들지 않고 설정만으로 가능하므로 좀 더 편리하다.

아래는 두번째 방법이다.

1
2
3
4
5
6
7
8
<bean class="org.springframework..ConversionServiceFactoryBean">
<property name="converters">
<set>
<bean class="LevelConverter" />
<!-- 추가하고 싶은 Converter들... -->
</set>
</property>
</bean>

그리고 컨트롤러에서 아래와 같이 해주면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Controller
public class UserController{
@Autowired ConversionService conversionService;

@InitBinder
public void initBinder(WebDataBinder dataBinder){
dataBinder.setConversionService(this.conversionService);
}

@RequestMapping("/user", method=RequestMethod.GET)
public String userSearch(@RequestParam Level level){
// ...
}
}

매번 개별적으로 등록해줘야하는 PropertyEditor와는 달리 하나의 ConversionServiceConverter들을 일괄적으로 지정할 수 있어 매우 편리하다.
때에 따라서는 여러개의 ConversionService를 만들어놓고 사용하기도 한다.
하지만 WebDataBinder는 하나의 ConversionService 타입 오브젝트만 허용한다는 점은 알고있어야 한다.

ConverterPropertyEditor처럼 WebBindingInitializer를 이용해 일괄등록 할 수 있다.
하지만 ConversionService를 등록할 떄는 ConfigurableWebBindingInitializer를 이용하는 것이 더 편리하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<bean class="org.springframework..ConversionServiceFactoryBean">
<property name="converters">
<set>
<bean class="LevelConverter" />
<!-- 추가하고 싶은 Converter들... -->
</set>
</property>
</bean>

<bean id="webBindingInitializer" class="org.springframework..ConfigurableWebBindingInitializer">
<property name="conversionService" ref="conversionService" />
</bean>

<bean class="org.springframework..AnnotationMethodHandlerAdapter">
<property name="webBindingInitializer" ref="webBindingInitializer" />
</bean>

이게 전부 ConversionService를 싱글톤 빈으로 등록할 수 있기에 생겨난 방법들이다.

만약 spring 설정에서 <mvc:annotation-driven />를 사용했을 경우,
위의 두 방법처럼 ConversionService를 등록하는 것이 불가능하다.
이럴경우 <mvc:annotation-driven conversion-service="conversionService"/> 처럼 엘리먼트를 이용해서 등록해줘야 하며, 이렇게 등록할 경우 모든 클래스에 자동으로 적용된다.

Formatter

위의 두 가지 외에 Formatter라는 타입 변환 API가 하나 더 있다.
근데 이는 스프링에서 기본으로 제공하는 API가 아니라서, 절차가 조금 까다롭다.
일단 Formatter인터페이스는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// Formatter interface
public interface Formatter<T> extends Printer<T>, Parser<T> {
}

// Printer interface
public interface Printer<T> {
String print(T object, Locale locale);
}

// Parser interface
public interface Parser<T> {
T parse(String text, Locale locale) throws ParseException;
}

이 인터페이스를 구현해서 Formatter를 만들면 된다. 보다시피 Locale을 파라미터로 받을 수 있어 컨트롤러에 사용하기 좀 더 특화되었다고 할 수 있다.
근데… Formatter는 스프링 기본 API가 아니라서 GenericConversionService에 직접 등록할 수 없다.
FormatterGenericConverter로 포장해서 등록해주는 FormattingConversionService를 통해서만 등록될 수 있다.
그리고 Formatter를 본격적으로 사용하려면 이게 끝이 아니라
애노테이션을 연결시켜야 하므로 AnnotationFormatterFactory도 사용해야 한다.
이래서 굳이 Locale이 타입 변환에 필요한 경우가 아니라면 Converter를 사용하는 편이 낫다.

당장은 FormattingConversionServiceFactoryBean을 통해 FormattingConversionService를 등록하고, 거기서 기본으로 등록되는 Formatter만 사용해도 유용하다.

1
<bean class="org.springframework..FormattingConversionServiceFactoryBean" />

이렇게 빈으로 등록하고 위와 같은 방식으로 conversionService를 주입해주면 된다.

이 또한 만약 spring 설정에서 <mvc:annotation-driven />를 사용했을 경우 사용이 불가능하다.
참고로 FormattingConversionServiceFactoryBean<mvc:annotation-driven /> 사용 시 디폴트로 등록해주는 ConversionService라 위처럼 conversion-service 엘리먼트를 이용해 따로 등록해 줄 필요없다.

  1. @NumberFormat
    첫째로 사용할 수 있는 애노테이션 기반 포멧터이다.
    이는 NumberFormatter, CurrencyFormatter, PercentFormatter 와 연결되어 있다.
    엘리먼트로 stylepattern을 줄 수 있다.
    styleNumber, Currency, Percent 세 가지를 설정할 수 있고, 각각 위의 Formatter와 연결된다.
    style에 없는 패턴을 사용하고 싶을 경우 pattern 엘리먼트를 통해 직접 지정할 수 있다.
    아래는 pattern엘리먼트를 사용한 예제이다.
1
2
3
4
5
6
class Product{
@NumberFormat("$###,##0")
Long price;

// getter, setter
}

request 인자로 price=$100,000 과 같이 넘겨줘도 price 프로퍼티에서 변환해서 받을 수 있고, 뷰로 내려줄 때에 $100,000의 형태로 내려줄 수 있다.

  1. @DateTimeFormat
    강력한 날짜, 시간 라이브러리인 Joda Time을 이용하는 애노테이션 기반 포멧터이다.
    이는 DateTimeFormatter와 연결되어 있다.
    엘리먼트로 stylepattern을 줄 수 있다.
    S(short), M(medium), L(long), F(full) 4개의 문자를 날짜와 시간에 대해 1글자씩 사용해 스타일을 지정한다.
1
2
@DateTimeFormat(style="FS")
Calenadar birthday;

이는 yyyy'년' M'월' d'일' EEEE a h:mm 포멧으로 매핑된다. 물론 각각의 지역정보에 따라 다르게 출력된다.
style에서 지정한 패턴이 마음에 들지 않는 경우, pattern 엘리먼트를 통해 직접 지정 가능하다.

1
2
@DateTimeFormat(pattern="yyyy/MM/dd")
Calendar birthday;

바인딩 기술 활용 전략

위의 3가지 방법은 각기 장단점이 있기 때문에 하나만 골라 사용하는 것은 바람직하지 않다.
아래는 어떤 경우에 어떤 바인딩 기술을 활용하는 것이 좋은지에 대한 몇 가지 시나리오이다.

  1. 사용자 정의 타입 바인딩을 위한 일괄 적용 : Converter
    앞의 Level enum처럼 애플리케이션에서 정의한 타입이면서 모델에서 자주 활용되는 타입이라면 Converter로 만들고 ConversionService로 묶어서 일괄 적용하는 것이 편리하다.

  2. 메타정보를 활용하는 조건부 바인딩 : ConditionalGenericConverter
    바인딩이 특정 조건(필드, 메서드 파라미터, 애노테이션 등)에 따라 다르게 동작할 때에는 ConditionalGenericConverter를 이용해야 한다. 구현이 까다롭다.

  3. 애노테이션을 통한 바인딩 : AnnotationFormatterFactory, Formatter
    애노테이션을 통해 바인딩 하고 싶을 경우 사용하면 좋다.

  4. 특정 필드에만 바인딩 : PropertyEditor
    특정 모델의 특정 필드에 제한해서 바인딩을 적용해야 할 경우 PropertyEditor를 사용하는 것이 편리하다. 필드 이름을 메서드 파라미터로 전달할 수 있기 때문이다.

이렇듯 여러 바인딩 기술들을 등록하다 보면 서로 중복되는 부분이 발생할 것이다.
이럴 경우 우선순위에 의해 바인딩이 적용된다.
Custom PropertyEditor > ConversionService > Default PropertyEditor
중복 시 위의 순서로 바인딩된다.
그리고 WebBindingInitializer를 통해 등록한 공통 바인딩은 @InitBinder보다 우선순위가 뒤쳐진다.

WebDataBinder 설정 항목

WebDatBinder에는 PropertyEditor, ConversionService 등록 외에도 여러 유용한 바인딩 옵션들이 있다.

  1. allowedFields, disallowedFields
    @ModelAttribute를 사용할 경우 근본적인 보안 문제가 하나 있다.
    @SessionAttributes를 사용해 변경할 필드만 폼에 표출했다고 하더라도 사용자가 임의로 폼을 조작하여 전달하는 값에 대해서는 변경을 막지 못한다는 점이다.
    폼에는 표출하지 않았지만 사용자가 예를 들어 level이라는 필드를 폼에 추가하여 전송할 경우 실제 값이 바뀌는 일이 생길 수도 있다는 것이다.
    이를 대비해 폼에 표출한 필드 외에는 모델에 바인딩 되지 않도록 설정할 필요가 있다.

여기에 사용되는 것이 위의 두 속성이다.
allowedFields에는 바인딩을 허용할 필드 목록을 넣을 수 있고, disallowedFields에는 바인딩을 금지할 필드 목록을 넣을 수 있다.

1
2
3
4
@InitBinder
public void initBinder(WebDatBinder dataBinder){
datBinder.setAllowedFields("name", "email", "tel", "*level*");
}

이러면 위의 지정한 필드명 외에 다른 필드는 아무리 HTTP 요청으로 보내봐야 바인딩 되지 않는다.
게다가 *level*처럼 와일드카드도 사용할 수 있다.

  1. requiredFields
    필수 파라미터를 지정할 수 있다.
    @ModelAttribute의 특성 상 파라미터가 들어오지 않았다고 바로 에러를 발생 시키지 않고 BindingResult에 검증 결과를 저장한다.
    하지만 setRequiredFiedls() 메서드로 필수 파라미터를 지정해 줄 경우, 파라미터가 들어오지 않으면 바로 에러가 발생한다.

  2. fieldMarkerPrefix
    input checkbox는 조금 특별한 성질이 있다.

1
<input type="checkbox" name="type" value="on" />

폼에 이와 같은 체크박스가 있다고 했을 때, 이를 체크하고 전달하면 type=on의 형태로 데이터가 전달되지만, 체크하지 않고 전달하면 아예 값을 전달하지 않는다는 점이다.
즉 수정폼에서 기존에 체크되어있던 체크박스를 해제하고 전달할 경우 아무런 값도 전달되지 않기 때문에 사용자는 값을 변경할 수 없는 문제가 발생하게 되는 것이다.
이럴 때 필드마커라는 것을 이용해 해결 할 수 있는데, 아래와 같다.

1
2
<input type="checkbox" name="type" />
<input type="hidden" name="_type" value="on" />

_type의 앞에 붙은 _필드마커라고 하는데, 스프링은 이런 필드마커가 있는 필드를 발견할 경우, 필드마커를 제외한 이름의 필드가 폼에 존재한다고 생각한다.
즉, 체크박스를 선택하지 않아 type 파라미터가 전달되지 않았지만, _type필드가 전달 되었으므로 스프링은 type필드가 폼에 있다고 판단하는 것이다.
그리고 이처럼 _type은 전달되고 type은 전달되지 않았을 경우, 체크박스를 해제했기 때문이라 생각하고 해당 프로퍼티 값을 리셋해준다.
리셋 방식은 boolean 타입이면 false, 배열타입이면 빈 배열, 그 외라면 null을 넣어주는 것이다.
WebDataBindersetFieldMarkerPrifix() 메서드는 이 필드마커를 변경해주는 메서드이다. 기본값은 _이다.

  1. fieldDefaultPrefix
    필드 디폴트는 히든 필드를 이용해 체크박스의 디폴트 값을 지정하는데 사용한다.
1
2
<input type="checkbox" name="type" value="A"/>
<input type="hidden" name="!type" value="Z" />

!type 히든 필드를 지정해서 type필드의 기본값을 지정해줬다.
이럴 경우 체크박스를 선택하지 않아 type필드가 전달되지 않을 경우, 디폴트 값인 Z가 전달된다.
모델 프로퍼티 값이 단순값이 아닐 경우 유용하게 사용할 수 있다.
이 또한 setFieldDefaultPrefix()메서드를 이용해 접두어를 변경해 줄 수 있다. 기본값은 !이다.


검증

@ModelAttribute의 바인딩 작업이 실패로 끝나는 경우는 2가지가 있다.
첫째로 @ModelAttribute가 기본적으로 실행하는 타입 변환에서 오류가 발생했을 경우이고,
둘째로 검증기(validator)를 통과하지 못했을 경우이다. 이는 사용자가 직접 정의하는 부분이다.
사실상 폼의 서브밋을 처리하는 컨트롤러 메서드에서는 검증기를 이용한 검증 작업은 필수이다.
검증 결과에 따라 다음 스텝으로 넘어가든, 다시 폼을 띄워 수정을 요구하든 해야한다.
이 과정에서 쓰이는 API인 Validator, BindingResult, Errors에 대해 알아보자.

Validator

오브젝트 검증기를 정의할 수 있는 API이다. @ModelAttribute 바인딩 때 주로 사용된다.
아래는 Validator 인터페이스이다.

1
2
3
4
5
public interface Validator{
boolean supports(Class<?> clazz);

void validate(Object target, Errors erros);
}

supports()는 이 검증기가 검증할 수 있는 타입인지 확인하는 메서드이고,
이를 통과할 경우 validate()를 통해 검증이 진행된다.
validate()의 검증과정에서 아무 문제가 없으면 메서드를 정상 종료하면 되고,
문제가 있을 시 Errors 인터페이스에 오류정보를 등록해주면 된다.
이후 이 오류정보를 통해 컨트롤러에서 적절한 작업을 해주면 되는 것이다.

자바스크립트로 입력값을 검증했을 경우 서버에서 검증작업을 생략해도 될까?
안된다. 서버의 검증작업을 생략하면 매우 위험해진다.
브라우저에서 자바스크립트가 동작하지 않게 할수도 있고, 강제로 폼을 조작할수도 있고, Burp suite 같은 것을 사용하여 전달되는 데이터를 변경할 수도 있다.
그러므로 서버 검증작업은 필수로 있어야 한다.

아래는 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
28
29
public class UserValidator implements Validator{
@Override
public boolean supports(Class<?> clazz) {
// 자식클래스도 검증 가능하게 하기 위해 isAssignableFrom을 사용
return User.class.isAssignableFrom(clazz);
}

@Override
public void validate(Object target, Errors errors) {
User user = (User)target; // supports를 통과했으므로 바로 캐스팅하면 됨

// name 필수
if(user.getName() == null || user.getName().length == 0){
errors.rejectValue("name", "name.required");
}
// null 체크 & 길이 체크가 귀찮으면 아래와 같이 사용 가능
ValidationUtils.rejectIfEmpty(erros, "name", "name.required");

if(user.getAge() < 0){
// argument도 전달 가능
errors.rejectValue("age", "age.min", new Object[]{0} /* arguments */, null /* default message */);
}

if(user.getAge() < 20 && !"M".equals(user.getSex())){
// 필드명을 안 줄수도 있다
errors.reject("cannot.enter.army");
}
}
}

주석에도 써놓았지만 오류 정보를 등록하는 방법이 다양하다.
rejectValue()에 사용된 name은 필드 이름이며, name.required는 에러 코드를 정의한 것이다.
(이 에러코드는 messageSource와 함께 사용될 수 있다. 사용 방법 보기)
age 필드를 검증할때, 보다시피 에러코드에 파라미터를 전달할수도 있으며 디폴트 메세지도 전달할 수 있다.
제일 아랫부분처럼 2가지 이상의 필드에 대해 검증하는 경우, 필드명을 생략 가능하다.
ValidationUtils 같은 유틸리티 클래스도 제공되니 잘 활용하면 좋다.

Validator는 싱글톤 빈으로 등록 가능하기 때문에 서비스 로직을 이용하여 검증작업을 진행할 수도 있다. 대표적인 것이 아이디 중복 검사이다.

근데 사실 이 정도 검증까지 가면 좀 모호해지는게 있는데, 검증이 수행되는 계층이다.
검증 작업을 컨트롤러 로직이라고 보는 개발자도 있는 반면, 대부분이 서비스 계층과 연관이 있으니 서비스 계층의 로직이라고 보는 개발자도 있다.
이는 개인이 잘 판단하면 될 문제인 것 같다.
중요한 것은 어느 곳에서 사용하든, 위와 같이 검증로직은 따로 분리되어 있는 것이 좋다.

Validator 사용하기

  1. 컨트롤러 메서드 내에서 검증
    Validator는 빈으로 등록 가능하니 이를 컨트롤러에서 DI 받은 뒤, 각 컨트롤러 메서드에서 validate()를 직접 호출해서 검증을 진행하는 방식이다.
    (모델 오브젝트의 타입은 굳이 확인할 필요 없으므로 supports()는 생략 가능하다)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
public class UserController{
@Autowired UserValidator userValidator;

@RequestMapping("/user", method=RequestMethod.POST)
public String userAdd(@ModelAttribute User user, BindingResult result){
this.userValidator.validate(user, result);

if(result.hasError()){
// 오류 정보가 있을 시
} else{
// 오류 정보가 없을 시
}
}
}
  1. @Valid를 이용한 자동 검증
    JSR-303의 @javax.validation.Valid 애노테이션을 사용하는 방법이다.
    컨트롤러에서 직접 validate()를 호출하여 검증하던 방식과 달리, 바인딩 과정에서 자동으로 검증이 진행되도록 할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Controller
public class UserController{
@Autowired UserValidator userValidator;

@InitBinder
public void initBinder(WebDataBinder dataBinder){
dataBinder.setValidator(userValidator);
}

@RequestMapping("/user", method=RequestMethod.POST)
public String userAdd(@ModelAttribute @Valid User user, BindingResult result){
// ...
}
}

WebDataBinder에는 보다시피 Validator 타입의 검증용 오브젝트도 등록할 수 있다.
그리고 아래 @ModelAttribute를 사용하는 부분에 추가로 @Valid 애노테이션을 사용해주면 자동으로 검증작업이 수행된다.
개인적으로 위의 방식보다 훨씬 나아 보인다 ㅋㅋ
참고로 @InitBinder 말고 WebBindingInitializer를 이용해 모든 컨트롤러에 일괄 적용할 수도 있다.

  1. 서비스 계층에서 검증
    자주 사용되지 않지만 Validator가 싱글톤 빈으로 등록되기에 서비스 계층에서도 얼마든지 DI 받아 사용할 수 있다.
    서비스 계층에서 반복적으로 같은 검증작업을 수행할 경우 사용하기도 한다.
    근데 이럴 경우, BindingResult 타입 오브젝트를 직접 만들어서 validate()에 전달해야 하는데, 이때는 BeanPropertyBindingResult를 사용하는 것이 적당하다.

서비스 계층을 활용하는 Validator
Validator는 싱글톤 빈으로 등록될 수 있으므로 다른 빈을 DI받아 사용할 수 있다.
앞서 예시로 들었던 ID 중복 검사처럼, Validator내에서 서비스 계층 빈을 사용하여 검증할 수도 있다.
게다가 이 경우 결과를 BindingResult에 담으면 되므로 서비스 계층에서 번거롭게 예외를 던지던 방식을 제거할 수 있다.
대신 이 방식을 사용하면 컨트롤러에서 서비스 계층을 두번 호출한다는 단점이 있다.
하지만 전체적으로 코드가 깔끔해지고 역할 분담이 확실해지는 장점이 있다.

JSR-303 빈 검증 기능

@Valid를 포함하고 있는 JSR-303의 빈 검증 방식도 스프링에서 사용할 수 있다.

1
2
3
4
5
6
7
public class User{
@NotNull
String name;

@Min(0)
int age;
}

이런식으로 모델에 특정 애노테이션만 작성해주면 된다. Validator를 직접 구현해서 검증기를 만들필요 없이 간단하게 검증작업을 진행할 수 있다.
이 검증 방식을 사용하려면 LocalValidatorFactoryBean을 사용해야 한다.
LocalValidatorFactoryBean이 생성하는 클래스 타입은 Validator이므로 이를 빈으로 등록한 뒤 DI받아 사용하면 된다.
(컨트롤러에서 직접 생성해도 되고, WebDataBinder에 등록해도 된다)

BindingResult의 에러 코드

앞서 Validator에서 errors.rejectValue("name", "name.required")와 같이 에러코드를 지정하던 작업이 기억날 것이다.
이 정보는 보통 컨트롤러에 의해 폼을 다시 띄울 때 활용된다.
스프링은 등록된 에러 코드를 아래의 같은 파일에서 찾아와 에러 메세지로 활용한다.

1
name.required=이름은 필수로 입력하셔야 합니다.

근데 이렇게 지정한 에러코드로 바로 메세지를 찾는것은 아니고, 스프링의 MessageCodeResolver라는 것을 거쳐 에러코드를 확장하는 작업을 한번 거친다.
(스프링의 디폴트 MessageCodeResolverDefaultMessageCodeResolver 이다.)
이 리졸버를 거치게 되면 우리가 등록한 에러코드 name.required는 아래와 같이 4가지 에러코드로 확장된다.

  1. 에러코드.모델이름.필드이름 : name.required.user.name
  2. 에러코드.필드이름 : name.required.name
  3. 에러코드.타입이름 : name.required.User
  4. 에러코드 : name.required

이 4가지 에러코드는 위에서부터 우선순위를 가진다.
즉 메세지 파일이 아래와 같다면,

1
2
name.required.user.name=무언가 잘못된 메세지
name.required=이름은 필수로 입력하셔야 합니다.

우리는 에러코드를 분명 name.required라고 지정했지만 계속해서 무언가 잘못된 메세지가 출력될 것이다.
그러므로 에러코드 지정 시 어떤 에러코드로 확장되는지 정확히 알고 있어야 한다.
아니면 위처럼 의도하지 않은 상황이 발생할 수 있기 떄문이다.
(개인적으로 좀 혼란스러운 방식이라고 생각한다…)

위는 한가지 예시였을 뿐이고, 검증방식(rejectValue, reject, 타입 오류 등, JSR-303)에 따라 에러코드가 확장되는 룰이 다르니 사용할 떄 주의해야 한다.

MessageSource

위에서 확장된 에러코드는 마지막으로 MessageSourceResolver라는 것을 거쳐 실제 메세지로 생성된다. 이 때 사용하는 것이 이 MessageSource이다.
이는 디폴트로 등록되지 않으니 스프링의 빈으로 직접 등록해줘야 한다.
MessageSource는 2가지 종류가 있는데 보통 ResourceBundlerMessageSource를 사용한다.
이는 일정시간마다 메세지 파일 변경 여부를 확인해서 메세지를 갱신해주므로 서버가 구동중인 상황에도 메세지를 변경해줄 수 있다.

1
<bean id="messageSource" class="org.springframework...ResourceBundleMessageSource" />

속성값으로 메세지 파일을 지정해주지 않을 경우 디폴트로 messages.properties 파일이 사용된다.

MessageSource는 아래의 4가지 정보를 활용해 최종 메세지를 생성한다.

  1. 코드
    메세지 파일은 키=벨류의 형태로 등록되어 있기 때문에, 메세지를 찾을 키 값은 필수이다.
    앞서 우리가 Errors에 등록했던 에러 코드가 이 키 값인 것이다.

  2. 메세지 파라미터 배열
    앞서 에러코드를 등록하며 Object[] 타입의 파라미터를 넘겨줬던 것을 기억할 것이다.
    해당 파라미터는 메세지를 생성하는데 사용될 수 있다.
    메세지 파일은 아래와 같이 작성된다.

1
field.min={0}보다 작은 값을 사용할 수 없습니다.

파라미터는 1개 이상 올 수 있기때문에 Object 배열을 사용한다.

  1. 디폴트 메세지
    코드에 맞는 메세지를 찾지 못하였을때 디폴트 메세지를 지정해줄 수 있다.
    에러코드를 충실히 적용했다면 이 부분은 생략하거나 null로 주면 된다.
    참고로 코드에 해당하는 메세지도 없고, 디폴트도 없을 경우 예외가 발생하니 주의해야 한다.

  2. 지역정보
    LocaleResolver에 의해 결정된 현재의 지역정보를 사용할 수 있다.
    지역정보에 따라 다른 프로퍼티 파일을 사용 가능하다.
    만약 messages.properties 파일을 사용했다면 LocaleENGLISH일 경우 messages_en.properties 파일이 사용된다.
    이를 통해 다국어 서비스를 적용할 수 있다.


모델의 사이클

모델은 MVC 아키텍쳐에서 정보를 담당하는 컴포넌트이다.
요청 정보를 담기도 하고, 비즈니스 로직에 사용되기도 하고, 뷰에 출력되기도 한다.
또한 이 모델은 아주 여러곳을 거쳐가며 만들어지고, 변형된다.
그러므로 모델의 사이클에 대한 지식은 스프링 MVC를 사용할 떄 가장 중요하다고 할 수 있다.

  1. HTTP 요청에서 컨트롤러 메서드까지
    HTTP 요청으로부터 컨트롤러 메서드까지
    왼쪽에서 오른쪽으로 보면 된다.

  2. 컨트롤러 메서드에서 뷰까지
    컨트롤러 메서드에서 뷰까지
    오른쪽에서 왼쪽으로 보면 된다.

모델의 생성, 변형, 사용이 한눈에 볼 수 있게 잘 표시되어 있다.

From