[toby의스프링] 13장 - 스프링 @MVC #2
앞서 13장은 3부분으로 나누어 정리하겠다고 말한바 있다. 사실 바로 정리를 했어야 했는데.. 이전 글을 쓴 지 거의 일주일이 다 되었다. 게으름은 인류의 적, 아니 나의 적이다.
이번 편에서 정리할 내용은 '모델 바인딩과 검증' 이다. 책에서는 13-3 로 분류되어 있다.
앞서 살펴봤던 @ModelAttribute 애노테이션을 사용하면 크게 세 가지의 작업이 자동으로 진행된다.
@ModelAttribute 선언 후 자동으로 진행되는 작업들
첫째, 파라미터로 넘겨 준 타입의 오브젝트를 자동으로 생성한다. 예를 들어 @ModelAttribute User user 라고 인수를 넘기면 User 타입의 user 오브젝트를 자동으로 생성한다.
둘째, 생성된 오브젝트에 HTTP로 넘어 온 값들을 자동으로 바인딩한다. HTTP 파라미터는 문자열이기 때문에 오브젝트에 맞는 형 변환이 필요하다. 이때 스프링의 기본 프로퍼티 에디터를 사용한다. 변환 중 오류가 생겨도 작업은 중단되지 않는다.
셋째, 오브젝트로 넘어 온 값을 검증한다. 사용자가 자체적으로 검증기를 등록해 Validation 체크를 진행할 수 있다.
위에서 첫번째로 진행되는 작업 - 즉, 오브젝트를 자동으로 생성하는 부분은 앞선 정리에서 살펴 보았다. (뭐 대단한 작업 없이 그냥 애노테이션 선언만 하면 알아서 만든다!)
이번에는 두번째와 세번째 내용을 학습할 차례다.
@ModelAttribute를 이용해 넘어 온 파라미터를 오브젝트에 자동으로 바인딩 할 때 기본 프로퍼티 에디터를 사용한다고 했는데, 꼭 여기 뿐 아니라 XML 설정 파일에서 빈에 대한 값 주입을 할 때도 마찬가지의 동작이 일어난다. 또 @RequestParam 이나 @PathVariable 파라미터의 바인딩에도 마찬가지의 동작이 일어난다. 스프링에서 바인딩이 일어날 때 값에 적절한 형 변환이 일어나도록 하는 방법에는 PropertyEditor를 이용하는 방식과 Converter를 이용하는 방식 두 가지가 있다.
1. PropertyEditor
스프링이 기본적으로 제공하는 바인딩용 타입(형) 변환 API 다.
애초에 GUI 개발도구에서 사용자가 입력한 값을 적절한 형태로 형 변환하기 위해 제공되는 도구로 시작했다는데, 마찬가지의 필요에 의해 만들어졌다는 걸 알 수 있다.
자바에서 기본으로 지원하는 타입들은 자동 형 변환이 일어난다.
Integer와 같은 기본적인 오브젝트는 물론이요, 책에서 예로 든 Charset 와 같은 타입도 자동으로 형 변환된다. IDE 도구에서 자동 볼드 처리되는 모든 타입이라 봐도 무방하겠지.
그런데 개발자가 필요에 따라 만든 타입이라면 자동으로 형 변환이 일어나지 않을 것이다.(당연하게도)
이런 경우 커스텀 프로퍼티 에디터가 필요하다.
먼저 간단한 형태의 커스텀 프로퍼티 에디터를 만들어 보자.
커스텀 프로퍼티 에디터를 만들 때는 PropertyEditorSupport 클래스를 상속해서 필요한 메소드(getAsText, setAsText)만 구성하면 된다.
Level.java - Level에 대한 도메인 오브젝트
public enum Level {
GOLD(3, null), SILVER(2, GOLD), BASIC(1, SILVER);
private final int value;
private final Level next;
Level(int value, Level next) {
this.value = value;
this.next = next;
}
public int intValue() {
return value;
}
public Level nextLevel() {
return this.next;
}
public static Level valueOf(int value) {
switch(value) {
case 1: return BASIC;
case 2: return SILVER;
case 3: return GOLD;
default: throw new AssertionError("Unknown value: " + value);
}
}
}
levelPropertyEditor.java - 커스텀 프로퍼티 에디터
static class LevelPropertyEditor extends PropertyEditorSupport {
public void setAsText(String text) throws IllegalArgumentException {
this.setValue(Level.valueOf(Integer.parseInt(text.trim())));
}
public String getAsText() {
return String.valueOf(((Level)getValue()).intValue());
}
}
코드를 보면, level 타입은 1,2,3 의 숫자를 Basic, Silver, Gold 로 각각 해석하도록 되어 있다. 반대로 Basic, Silver, Gold 의 level 타입은 숫자 1,2,3 과 대응한다.
이미 Level 도메인 오브젝트에서 Level 타입에 대한 해석과 적용에 대한 코드가 만들어져 있는 것이다.
이것을 levelPropertyEditor.java 에서 가져다 쓰는데 - PropertyEditorSupport 클래스를 상속해 getAsText와 setAsText를 오버라이드해서 쓰면 된다.
여기까지 볼 때는 그냥 메소드 하나 만들어서 형 변환하는 거랑 차이가 없다.
2. InitBinder
이제 스프링에서 자동으로 형 변환이 일어날 때 우리의 levelPropertyEditor를 불러들이도록 해 보자.
스프링이 형 변환을 할 때 여러가지 작업이 일어나지만, 이를 다 열거할 필요는 없을 것 같고(궁금하면 책 봐! p.1186) - 우리가 알아 둘 것은 WebDataBinder 가 이런 기능을 하는데 개발자가 직접 WebDataBinder를 건드리지는 못 하지만, 이런 경우를 위해 스프링이 특별히 제공하는 WebDataBinder 초기화 메소드를 사용해야 한다는 것이다.
아.. 말이 중언부언하는데, 결국 WebDataBinder 초기화 메소드를 사용해서 우리의 커스텀 프로퍼티 에디터를 등록할 수 있다는 것이다.
사용 방법은 아래와 같다.
@InitBinder 메소드
@InitBinder
public void initBinder(WebDataBinder dataBinder){
dataBinder.registerCustomEditor(Level.class, new LevelPropertyEditor());
//dataBinder에 커스텀에디터를 등록한다. Level 오브젝트를 만나면 LevelPropertyEditor와 연결된다.
}
위처럼 @InitBinder 애노테이션을 걸어 주면 level 타입을 만나면 무조건 LevelPropertyEditor와 연결된다. 이와 달리 특정 조건에서만 커스텀 형 변환을 하게 할 수도 있다.
특정 조건에서만 실행되는 @InitBinder
@InitBinder
public void initBinder(WebDataBinder dataBinder){
dataBinder.registerCustomEditor(int.class, "age", new MinMaxPropertyEditor(0,200));
//dataBinder에 커스텀에디터를 등록한다. 숫자면서 age 라는 이름을 가진 파라미터에 대해서만 형 변환을 시도한다.
}
보다시피 역시 간단하다. 오브젝트와 커스텀 에디터 사이에 파라미터 이름을 문자열로 던져 주면 된다.
3. WebBindingInitializer
자주 쓰이는 커스텀 형 변환의 경우 매번 쓸 때 마다 선언할게 아니라 한번 선언으로 계속 쓸 수 있게 해 줄 수도 있다. 이럴 때 쓰이는 것이 WebBindingInitializer 다.
사용 방법 역시 엄청 간단하다.
스프링의 미학은 단순함에 있는 것 같다.
먼저 커스텀 프로퍼티 에디터 클래스를 만든다.
다음으로 WebBindingInitializer 를 구현해서 커스텀 프로퍼티 에디터를 특정 오브젝트와 연결하는 클래스를 만든다.
이 클래스를 빈으로 등록한 다음 AnnotationMethodHandlerAdapter 핸들러의 webBindingInitializer 프로퍼티에 DI 해 주면 된다.
어차피 이 WebBindingInitializer 는 다른 곳에서 참조해 쓸 일이 없으므로 그냥 바로 프로퍼티로 주입하는 편이 좋다.
WebBindingInitializer를 구현해 커스텀프로퍼티 에디터와 연결하는 클래스
public class MyWebBindingInitializer implements WebBindingInitializer{
public void initBinder(WebDataBinder binder, WebRequest request){
binder.registerCustomEditor(Level.class, new LevelPropertyEditor());
}
}
설정 파일에서 빈에 값 주입
<bean class="org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerAdapter">
<property name="webBindingInitializer">
<bean class="MyWebBindingInitializer" />
</property>
</bean>
하지만 이 경우 어쨌든 리소스의 낭비가 예상되므로, 빈번히 쓰이는 게 아니면 꼭 이렇게 할 필요는 없을 것 같다.
4. 프로토타입 프로퍼티 에디터
어떤 파라미터의 값과 타입(형)이 규정된 하나의 형식이 아니라 DB에서 관리되는 동적인 형태의 경우 위에서 살펴본 바와 같이 처리하기가 어렵다.
이해를 위해 책에서 들은 구체적인 예를 보자.
회원관리 기능에서, 회원의 권한 Code userType을 코드 테이블에서 가져 온다고 하자. 코드 테이블은 id(Integer) 와 name(String)으로 구성되어 있고 동적으로 관리된다.
코드의 아이디는 1,2,3 이고 각각 관리자,회원,손님으로 매핑된다.
이때 Code 와 코드테이블의 id 가 어떤 상관관계가 있는지 모르기 때문에 파라미터로 userType이 1 이라고 넘어 왔을 때 자동으로 형 변환하지 못한다.
책에서는 이런 경우에 대해 세가지 방법을 제시하고 있다.
첫째는 코드테이블의 id 값과 1:1 로 매핑되는 int userTypeId 를 선언해 request 된 파라미터를 받아 들인 후 userTypeId 를 코드테이블과 대조해 name을 Code와 연결하는 것.
기존의 노가다 방식과 전혀 다르지 않다. 그러므로 별로 눈여겨 볼 필요가 없다.
둘째는 모조 프로퍼티 에디터를 사용하는 방법.
스프링에서는 id 값만 덜렁 하나 가진 프로퍼티 에디터를 모조(Fake) 프로퍼티 에디터라고 한다.
파라미터로 userType이 1 이라고 넘어 온 경우 이를 Code의 id 값에 그냥 1 이라고 넣어 주는 역할만 한다.
특정 화면에서 잠깐잠깐 갖다 쓸 때는 유용하겠지만, 이 역시 기존 방식과 크게 달라 보이지는 않는다.
셋째는 바로 프로토타입 프로퍼티 에디터를 이용하는 것이다.
스프링에서 자동으로 형 변환이 일어나는 과정을 자세히 보면, getAsText 와 setAsText가 번갈아 일어나는데, 이때 아주 잠깐이기는 하지만 값이 저장되는 - 상태가 유지되는 순간이 있다. 만약 프로퍼티 에디터를 빈으로 등록해 여럿이 공유하게 되면, 이 상태유지 때문에 큰 문제가 일어날 수 있다.
개발자 혼자 테스트 할 때는 전혀 문제가 없는데, 서비스에 올려 놓고 돌리면 알 수 없는 문제가 생길 때가 있는데 그 중 하나가 바로 이런 경우이다.
따라서 프로퍼티 에디터를 빈으로 등록해 재사용하는 것은 기본적으로 불가능하다.
하지만 이런 경우 빈의 생명주기를 제어하면 된다고 우리는 배운 바 있다.
먼저, 코드 테이블의 모든 레코드를 커스텀 프로퍼티 에디터에 등록해 놓고 계속해서 재사용한다고 생각해 보자.
이러려면 이 커스텀 프로퍼티 에디터는 DB로부터 코드 테이블을 뒤져서 Code 오브젝트를 가져올 수 있어야 한다.
따라서 codeDao나 codeService 같은 빈을 DI 받아야 하는데, DI 받기 위해서는 자신도 빈으로 등록되어야 한다.
물론, 앞서 얘기한대로 반드시 프로토타입의 생명주기를 가지고 있어야 한다.
빈으로 사용될 프로퍼티 에디터
@Component
@Scope("prototype") // 빈의 생명주기를 프로토타입으로 설정한다.
static class CodePropertyEditor extends PropertyEditorSupport {
@Autowired CodeService codeService; //빈에서 주입해도 되지만, 이렇게 오토와이어 해도 된다.
public void setAsText(String text) throws IllegalArgumentException {
setValue(codeService.getCode(Integer.parseInt(text)));
}
public String getAsText() {
return String.valueOf(((Code)getValue()).getId());
}
}
컨트롤러에서 사용하기
@Controller
static class UserController2 {
@Inject Provider<CodePropertyEditor> codeEditorProvider;
@InitBinder public void initBinder(WebDataBinder dataBinder) {
dataBinder.registerCustomEditor(Code.class, codeEditorProvider.get());
// codeEditorProvider.get() - Provider<> 를 통해 프로토타입 빈을 새로 가져온다.
}
@RequestMapping("/add") public void add(@ModelAttribute User user) {
System.out.println(user);
}
}
5. Converter
PropertyEditor는 파라미터를 자동으로 바인딩하는 매우 유용하고 강력한 도구다. 하지만 매번 필요할 때 마다 새로 생성해줘야 하는 단점으로 인해 관리가 어렵고 작긴 하지만 리소스의 낭비가 예상된다.
이런 단점을 극복하기 위해 스프링 3.0 에서는 Converter 라는 기술을 도입했다.
Converter의 인터페이스는 다음과 같이 구성되어 있다.
public interface Converter<S,T>{
T convert(S source);
}
Converter 인터페이스가 <S,T> 라는 제네릭스 타입으로 생성되어 있기 때문에 모든 타입에서 그냥 가져다 쓰기만 하면 된다.
그리고 Converter는 단방향변환만 지원하므로, S -> T 와 T -> S 두가지를 함께 등록해야 프로퍼티 에디터와 같은 기능을 수행할 수 있다.
예제 코드를 보자.
LevelToStringConverter.java
public class LevelToStringConverter implements Converter<Level, String>{
public String convert(Level level){
return String.valueOf(level.intValue());
//level.intValue 와 같은 메소드는 앞서 생성한 Level.class에 있다.
}
}
StringToLevelConverter.java
public class StringToLevelConverter implements Converter<String, Level>{
public Level convert(String text){
return Level.valueOf(Integer.parseInt(text));
}
}
이번에는 이렇게 만든 컨버터를 바인딩 할 때 자동으로 쓸 수 있도록 해 보자.
바인딩 할 때 컨버터를 사용하도록 하는 방법에는 @InitBinder를 이용하는 방법과 ConfigurableWebBindingInitializer를 이용하는 방법 두가지가 있다.
@InitBinder 를 이용하면 필요할 때 마다 매번 생성해서 쓸 수 있다.
ConverstionServiceFactoryBean에 빈 등록
<bean class="org.springframework.context.support.ConversionServiceFactoryBean" >
<property name="converters">
<set>
<bean class="LevelToStringConverter">
<bean class="StringToLevelConverter">
</set>
</property>
</bean>
컨트롤러에서 컨버터 등록
@Controller public static class SearchController{
@Autowired ConversionService conversionService; //컨버전서비스를 주입받는다.
@InitBinder
public void initBinder(WebBinder dataBinder){
dataBinder.setConversionService(this.conversionService);
}
}
또 ConfigurableWebBindingInitializer를 이용해 일괄등록하는 방법도 있다.
설정파일에서 일괄 등록
<bean class="org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerAdapter" >
<property name="webBindingInitializer" ref="webBindingInitializer" />
</bean>
<bean id="webBindingInitializer" class="org.springframework.web.bind.support.ConfigurableWebBindingInitializer" >
<property name="conversionService" ref="conversionService" />
</bean>
<bean id="conversionService" class="org.springframework.context.support.ConversionServiceFactoryBean" >
<property name="converters">
<set>
// 일괄 적용할 컨버터 목록
<bean class="LevelToStringConverter">
<bean class="StringToLevelConverter">
</set>
</property>
</bean>
6. Formatter
원래 포맷터는 바인딩 시 자동 형 변환을 하기 위해 만들어 진 기능은 아니지만 이를 이용하면 컨버터와는 다르게 특정 오브젝트 포맷에 최적화 된 형태의 형변환을 할 수 있다.
이를테면 숫자를 통화 형태로 변경한다든가 할 때 쓰면 되는데, 사실 형 변환이라는 것이 로직 설계 상 반드시 필요한 것이 아니라면 굳이 써야 하나, 하는 생각이 들기도 한다.
따라서 책에 나온 사용법만 간단히 소개하는 정도로 마치기로 한다.
통화표현의 예
class product{
...
@NumberFormat("$###,##0.00") // 아래 선언된 price를 이와 같은 통화 표현으로 포맷팅한다.
BigDecimal price;
}
날짜 표현의 예
system.out.println(org.joda.time.format.DatetimeFormat.patternForStyle("SS",Locale.KOREA));
// 앞의 인수는 날짜 표현 방식인데, S(Short), M(Medium), L(Long), F(Full) 네개의 문자를 날짜+시간 표현으로 정의한다.
@DateTimeFormat(style="F-") //애노테이션으로 특정 오브젝트만 형 변환할 수도 있다. F- 라고 하면 시간은 생략된다.
Calendar birthday
@DateTimeFormat(pattern="yyyy/MM/dd") //아예 날짜 표현 방식을 직접 정의할 수도 있다.
Date orderDate;
7. WebDataBinder 설정
- AllowedFields, disAllowedFields : 파라미터의 자동 바인딩을 허용/비허용 설정할 수 있다.
@InitBinder
public void initBinder(WebDataBinder dataBinder){
dataBinder.setAllowedFields("name","email","tel"); // 정의된 세 파라미터만 바인딩 허용
}
- requiredFields : 필수 값인 경우 값이 없으면 예외를 발생
@InitBinder
public void initBinder(WebDataBinder dataBinder){
dataBinder.setRequiredFields("name"); // 정의된 파라미터는 필수값
}
user.setName(ServletRequestUtils.getRequiredStringParameter(request,"name"); // 값이 없으면 예외 발생
- fieldMarkerPrefix : HTML의 체크박스의 경우, 체크해제를 하면 값 자체가 없어지기 때문에 자동 바인딩 때 예외 오류가 생긴다. 이럴 때는 HTML의 폼에서 체크박스 아이템의 이름과 같은 아이템을 히든 필드로 만들되 앞에 _ 를 붙여 주면 자동으로 체크해제 값으로 인식한다.
<form>
<input type="checkbox" name="check" value="true" />
<input type="hidden" name="_check" value="false" />
</form>
- fieldDefaultPrefix : fieldMarkerPrefix와 거의 비슷한 기능을 하지만, 단순히 true, false 가 아니라 디폴트 값을 ! 로 표시해 설정할 수 있다.
<form>
<input type="checkbox" name="usertype" value="admin" /> 관리자일 경우 체크
<input type="hidden" name="!usertype" value="member" />
</form>
8. 값의 검증 - Validation Check
파라미터를 바인딩할 때 값에 대한 검증은 Validator - 검증기 를 통해 처리할 수 있다.
자세한 설명은 생략하고 그냥 예제 코드를 보자.
Validator 메소드
public void validate(Object target, Errors errors){
User user = (User)target;
if (user.getName() == null || user.getName().length() == 0){
errors.rejectvalue("name", "field.required"); // name 파라미터가 해당 조건인 경우 "field.required" 예외 메시지를 리턴한다.
// 예외 메시지는 message.property 에 정의되어있다.
}
}
컨트롤러 메소드 내에서 사용하기
@Controller
public class UserController{
@Autowired UserValidator validator; // Validator 메소드를 빈으로 주입
@RequestMapping("/add")
public void add(@ModelAttribute User user, BindingResult result){
this.validator.valodate(user, result); // validator를 선언한다.
if (result.hasErrors()){
// 오류가 발생한 경우
}else{
// 오류가 없는 경우
}
}
}
@Valid 를 이용한 자동 검증
@Controller
public class UserController{
@Autowired UserValidator validator; // Validator 메소드를 빈으로 주입
@InitBinder
public void initBinder(WebDataBinder dataBinder){
dataBinder.serValidator(this.validator); // @InitBinder에서 validator를 선언할 수 있다.
}
@RequestMapping("/add")
public void add(@ModelAttribute User user, BindingResult result){
}
}
또 JSR-303의 빈 검증 기능을 이용할 수도 있다.
먼저 아래 코드처럼 설정 파일에서 빈을 생성한다.
<bean id="lovalValidator" class="org.springframework.validation.beanvalidation.LocalValidatorFactoryBean" />
그리고 이 빈을 @Autowired 해서 쓰면 된다. 적용 방법은 위와 동일하다.
JSR-303 빈 검증 예제 코드
public class User {
...
@NotNull
String name;
@Min(0)
int age
}
그 외 JSR-303에 대한 정보는 따로 찾아 봐야 겠다.
9. 모델의 일생
무슨 삼류 소설 제목 같은 챕터명이다. 모델의 일생..
어쩐지 처연한 느낌도 나고.
응? ;;;
책에서도 간단히 그림으로 설명했다. 나도 그리자.
- HTTP 요청으로부터 컨트롤러 메소드까지
그림 13-4. HTPP 요청으로부터 컨트롤러 메소드까지의 과정(p.1235)
그림 13-5. 컨트롤러 메소드로부터 뷰까지의 과정(p.1237)