Web/Spring 2019. 9. 10. 17:06

 

우리는 컨트롤러에서 사용자가 넘겨준 파라미터를 전달 받을 때, @ModelAttribute를 붙여서 혹은 생략한 특정 객체로 파라미터를 받게된다. 우리는 이때 2가지 상황을 고려할 수 있다. 

 

  1. 파라미터 바인딩에 실패(데이터 타입 등이 맞지 않는 경우)
  2. 파라미터 바인딩은 문제없이 됬으나, 들어온 파라미터가 비즈니스 로직에 맞지 않는 혹은 유효하지 않은 파라미터 일경우

이러한 파라미터를 검증하는 방법은 무엇이 있을까? 크게 2가지 방법이 존재한다.

 

  1. 사용자 정의 Validator 구현
  2. JSR-303 애너테이션

간단히 두가지를 다루어본다.

 

사용자 정의 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
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
72
@Slf4j
@Component
public class UserParamValidator implements Validator {
 
    @Override
    public boolean supports(Class<?> aClass) {
//isAssignableFrom은 하위 타입도 적용되게 하는 메서드이다.
        return (User.class.isAssignableFrom(aClass));
    }
 
    @Override
    public void validate(Object o, Errors errors) {
        log.info("User Param Validator");
        User user = (User)o;
 
        if(StringUtils.isEmpty(user.getFirstName())){
            errors.rejectValue("firstName","param.required");
        }
 
    }
}
 
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
 
    @Autowired
    private UserParamValidator validator;
 
    @InitBinder
    public void initBinder(WebDataBinder webDataBinder){
 
        webDataBinder.setValidator(validator);
    }
 
    @GetMapping("/find")
    public Object findById(@ModelAttribute("findUser") @Valid User user, BindingResult bindingResult){
        List<String> resultMessages = new ArrayList<>();
        if(bindingResult.hasErrors()){
            List<FieldError> fieldsErrors = bindingResult.getFieldErrors();
            fieldsErrors.stream().forEach(fieldError->{
                log.info("bind error objectName = {}, fieldName = {} ,error code = {}",fieldError.getObjectName(),fieldError.getField(),fieldError.getCode());
            });
            return resultMessages;
        }
 
        if(user.getId() == 1){
            user.setLastName("yoon");
            user.setAge(28);
            user.setLevel(Level.BASIC);
 
            return user;
        }
 
        return null;
    }
 
}
 
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
class User{
 
    private int id;
    private String firstName;
    private String lastName;
    private int age;
    private Level level;
 
}
cs

 

위와 같이 사용자 정의 Validator 클래스를 정의하고 특정 컨트롤러에서 DI 받은 후, @InitBinder 메서드에 해당 Validator를 등록해준다. 그리고 검증이 실패한 값이 들어온다면 컨트롤러 BindingResult에 검증 실패한 정보가 저장된다. 이러한 검증 실패정보를 로그로 찍고 있는 예제이다.

 

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
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
 
    @GetMapping("/find")
    public Object findById(@ModelAttribute("findUser") @Valid User user, BindingResult bindingResult, Locale locale){
        List<String> resultMessages = new ArrayList<>();
        if(bindingResult.hasErrors()){
            List<FieldError> fieldsErrors = bindingResult.getFieldErrors();
            fieldsErrors.stream().forEach(fieldError->{
                log.info("bind error objectName = {}, fieldName = {} ,error code = {}",fieldError.getObjectName(),fieldError.getField(),fieldError.getCode());
                resultMessages.add(messageSource.getMessage(fieldError.getField()+"."+fieldError.getCode(),null, locale));
            });
            return resultMessages;
        }
 
        if(user.getId() == 1){
            user.setLastName("yoon");
            user.setAge(28);
            user.setLevel(Level.BASIC);
 
            return user;
        }
 
        return null;
    }
 
}
 
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
class User{
 
    private int id;
    @NotNull
    private String firstName;
    private String lastName;
    private int age;
    private Level level;
 
}
cs

 

두번째 방법은 직접 엔티티 클래스에 제약조건 애너테이션을 달아주는 방법이다. 그러면 별도의 Validator DI 없이도 BindingResult에 엔티티 클래스에 있는 애너테이션을 이용한 검증을 한 후 검증에 실패하면 정보를 넣어준다.

 

만약 검증이 아니고 데이터 타입이 잘못넘어와서 바인딩 자체가 실패하면 어떻게 될까?

 

그렇다면 아래와 같은 errorCode가 발생한다.

 

FieldName.typeMismatch

 

좋다 여기까지, 바인딩 오류 혹은 검증이 실패한 결과가 BindResult에 담겼다. 그렇다면 그 이후에는 어떻게 처리하면 좋을까? 보통 이럴때는 MessageSource를 이용해 errorCode에 해당되는 미리 준비된 문자열을 사용자에게 보여준다. 예를 들면 아래와 같은 상황이다.

 

회원가입을 하는 과정에서 사용자가 userId값을 넘기지 않으면 "ID를 입력해주세요."라는 문구가 뿌려지는데, 이럴 경우 미리 준비된 errorCode에 해당되는 메시지를 준비하여 userId 파라미터가 빈값일때 해당 메시지를 전달해줄 수 있다.

 

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
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
 
    @Autowired
    private LocaleChangeInterceptor localeChangeInterceptor;
 
    @Value("${spring.messages.basename}")
    String baseName;
    @Value("${spring.messages.encoding}")
    String defaultEncoding;
    @Value("${cookie.locale.param}")
    String cookieName;
 
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
 
        registry.addInterceptor(localeChangeInterceptor);
 
    }
 
    /*
     * MessageSource Bean register
     */
    @Bean
    public MessageSource messageSource() {
        ReloadableResourceBundleMessageSource reloadableResourceBundleMessageSource = new ReloadableResourceBundleMessageSource();
        reloadableResourceBundleMessageSource.setBasename(baseName);
        reloadableResourceBundleMessageSource.setDefaultEncoding(defaultEncoding);
 
        return reloadableResourceBundleMessageSource;
    }
 
    /*
     * Locale Resolver Bean register
     */
    @Bean
    public LocaleResolver localeResolver() {
        CookieLocaleResolver cookieLocaleResolver = new CookieLocaleResolver();
        cookieLocaleResolver.setCookieName(cookieName);
        cookieLocaleResolver.setDefaultLocale(Locale.KOREA);
 
        return cookieLocaleResolver;
    }
 
    @Bean
    public LocaleChangeInterceptor localeChangeInterceptor() {
        LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
        localeChangeInterceptor.setParamName(cookieName);
 
        return  localeChangeInterceptor;
    }
}
cs

 

위와 같이 필요한 빈을 등록해준다.

 

 

message.properties 파일들을 생성해준다.

 

1
2
firstName.param.required=이름 입력은 필수입니다.
id.typeMismatch=유효하지 않은 유저 아이디 값입니다.
cs

 

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
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
 
    @Autowired
    private UserParamValidator validator;
    @Autowired
    private MessageSource messageSource;
 
    @InitBinder
    public void initBinder(WebDataBinder webDataBinder){
        webDataBinder.setValidator(validator);
    }
 
    @GetMapping("/find")
    public Object findById(@ModelAttribute("findUser") @Valid User user, BindingResult bindingResult, Locale locale){
        List<String> resultMessages = new ArrayList<>();
        if(bindingResult.hasErrors()){
            List<FieldError> fieldsErrors = bindingResult.getFieldErrors();
            fieldsErrors.stream().forEach(fieldError->{
                log.info("bind error objectName = {}, fieldName = {} ,error code = {}",fieldError.getObjectName(),fieldError.getField(),fieldError.getCode());
                resultMessages.add(messageSource.getMessage(fieldError.getField()+"."+fieldError.getCode(),null, locale));
            });
            return resultMessages;
        }
 
        if(user.getId() == 1){
            user.setLastName("yoon");
            user.setAge(28);
            user.setLevel(Level.BASIC);
 
            return user;
        }
 
        return null;
    }
}
cs

 

바인딩 errorCode에 해당 되는 Message가 생성되는 것을 볼 수 있다.

posted by 여성게
:
Web/Spring 2019. 9. 10. 15:22

 

이번 포스팅은 @RequestParam과 @ModelAttribute의 차이점에 대해 다루어볼 것이다. 이번에 다루어볼 내용은 특정 유저관련 컨트롤러 코드를 작성하여 살펴볼 것이다.

 

작업환경은 MacOS+intelliJ로 구성하였다.

 

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
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
 
    @GetMapping("/insert")
    public Object insertUser(@ModelAttribute("findUser") User user, BindingResult bindingResult){
 
        if(bindingResult.hasErrors()){
            log.info("binding error");
            return "invalidParam";
        }
 
        if(user.getId() == 1){
            user.setLastName("yoon");
            user.setAge(28);
            user.setLevel(Level.BASIC);
 
            return user;
        }
 
        return null;
    }
 
    @GetMapping("/insert2")
    public Object insertUser(@RequestParam int id, @RequestParam String firstName, @RequestParam String lastName){
        return "RequestParamBinding";
    }
 
}
 
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
class User{
 
    private int id;
    private String firstName;
    private String lastName;
    private int age;
    private Level level;
 
}
cs

 

위와 같은 코드가 있다. 세부적인 로직은 제외하고 파라미터를 바인딩하는 부분을 집중해보자. 우선 @RequestParam과 @ModelAttribute의 파라미터 바인딩에는 큰 차이가 있다. 바로 객체로 받는냐, 혹은 쿼리스트링을 하나하나 바인딩 받느냐의 차이이다. 이것은 코드의 가독성에 아주 큰 차이가 있다. 두번째 차이점은 @ModelAttribute는 사용자가 직접 View에 뿌려질 Model객체를 직접 set하지 않아도 된다는 점이다. @ModelAttribute는 스프링이 직접 ModelMap에 객체를 넣어주기 때문이다. 이러한 바인딩 차이점 이외에 한가지 아주 큰 차이점이 있다.

 

매핑을 보면 @ModelAttribute에는 BindingResult라는 인자가 들어가있는 것을 볼 수 있다. 하지만 @RequestParam은 없다. 과연 무슨 말일까?

 

@ModelAttribute는 사실 파라미터를 검증할 수 있는 기능을 제공한다. 이 말은 파라미터 바인딩에 예외가 발생하여도 혹은 파라미터 바인딩은 문제없이 됬으나 로직처리에 적합하지 않은 파라미터가 들어오는 등 이러한 파라미터를 검증할 수 있는 기능이 있다. 만약 파라미터 바인딩에 예외가 발생하였을 때, @ModelAttribute와 @RequestParam의 차이점을 보자. 전자는 바인딩 예외를 BindingResult에 담아준다. 즉, 4xx 예외가 발생하지 않는 것이다. 하지만 @RequestParam 같은 경우 바인딩 예외가 발생하는 순간 TypeMisMatch 예외를 발생시켜 사용자에게 4xx status code를 전달한다. 

 

그러면 개발자는 파라미터를 검증하는 로직을 컨트롤러 코드와 따로 분리하여 바인딩된 파라미터의 값의 유효성을 검사할 수 있게 해준다. 만약 @RequestParam이라면 직접 비즈니스 로직내에서 파라미터를 검증하는 코드가 들어가야 할 것이다.

 

바인딩 예외 처리의 차이점을 간단한 테스트 코드로 테스트 해보았다.

 

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
@SpringBootTest
@RunWith(SpringRunner.class)
public class UserControllerTest {
 
    private static final String FINDBYID_MODELATTRIBUTE_REQUEST_URI = "/user/find";
    private static final String FINDBYID_REQUESTPARAM_REQUEST_URI = "/user/find2";
    private User validParamForFindByUserId ;
 
    private MockMvc mockMvc;
 
    @Before
    public void init(){
        this.mockMvc = MockMvcBuilders.standaloneSetup(new UserController()).build();
    }
 
    @Before
    public void initParam(){
        validParamForFindByUserId = new User(1,"yeoseong","yoon",28,Level.valueOf(1));
    }
 
    @Test
    public void paramVaildationWhenInvalidInputParamWithModelAttribute() throws Exception {
        MvcResult createResult = mockMvc.perform(get(FINDBYID_MODELATTRIBUTE_REQUEST_URI+"?id=abc"))
                                        .andDo(print())
                                        .andExpect(content().string("invalidParam"))
                                        .andReturn()
                                        ;
    }
 
    @Test
    public void paramValidationWhenInvalidInputParamWithRequestParam() throws Exception {
        MvcResult createResult = mockMvc.perform(get(FINDBYID_REQUESTPARAM_REQUEST_URI+"?id=abc"))
                                        .andDo(print())
                                        .andExpect(status().is4xxClientError())
                                        .andReturn()
                                        ;
    }
 
}
cs

 

위 테스트를 돌려보면 우리가 기대하는 바인딩 예외처리 테스트가 무사히 통과할 것이다.

posted by 여성게
: