Backend

Spring Validation 정리

연_우리 2021. 12. 8. 23:26
반응형

목차

     

     

     

    Validation : 데이터 검사

    Validation이란 데이터의 값이 유효한지 검사하는 것이다.

    유효성검사를 통해 올바르지 않은 데이터가 서버로 전송되거나, DB에 저장되지 않도록 한다.

     

    의존성 설정

    spring-boot-starter-validation은 가장 유명한 hibernate validator를 포함하고있다

     

    Maven이용 시 pom.xml

    <dependency>
    	<groupId>org.springframework.boot</groupId>
    	<artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

     

    Gradle이용 시 build.gradle

    implementation 'org.springframework.boot:spring-boot-starter-validation'

     

     

    Controller에 검증로직 직접 구현

    Map<String, String> errors 객체를 생성하여 에러에 해당되는 필드와 에러메시지를 저장하여 view에 넘겨준다

     

    타임리프 TIP

    th:if="${errors?.containsKey( 'key값' )}" : model을 통해 받은 errors 객체에 'key값'이 있다면 해당 태그를 표시한다
    th:text="${errors[ 'key값' ]}" : errors 객체의 'key값'에 해당되는 value값을 꺼낸다

    @PostMapping("/form")
    public String formSave(@ModelAttribute Board board, RedirectAttributes redirectAttributes, Model model){
        // 검증 오류 결과 저장
        Map<String, String> errors = new HashMap<>();
    
        //검증 로직
        if (!StringUtils.hasText(board.getTitle())) {
            errors.put("title", "제목은 필수이고, 2~10글자를 입력해주세요");
        }
        if (!StringUtils.hasText(board.getContent())){
            // str이 null이거나 길이가 0이면 true 반환.
            errors.put("content", "내용은 필수입니다");
        }
    
        // 오류 발생 시 입력폼으로
        if (!errors.isEmpty()) {
            model.addAttribute("errors", errors);
            return "board/form";
        }
    
        // 성공 시
        Board savedBoard = boardRepository.save(board);
        redirectAttributes.addAttribute("boardId", savedBoard.getId());
        return "redirect:/board/form?id={boardId}";
    }
    <form method="post" th:action="@{/board/form}" th:object="${board}">
      <div class="mb-3">
        <label for="title" class="form-label">제목</label>
        <input type="text" class="form-control" id="title" th:field="*{title}">
    
        <div class="invalid-feedback"
        th:if="${errors?.containsKey('title')}" th:text="${errors['title']}"
        th:style="${errors?.containsKey('title')} ? 'display:block'">
        	Please select a valid state.	
        </div>
      </div>
    </form>

    문제점

    view에서 각 항목마다 비슷한 로직을 넣어야한다

    타입 오류는 스프링 컨트롤러에 오기도 전에 예외가 발생하기때문에 컨트롤러 호출 불가하다 (오류페이지 못띄워줌)

    아예 오류페이지가 떠버리니 기존에 입력했던 값을 보여줄 수 없다

     

     

     

    스프링의 BindingResult 객체 사용

    Map<String, String> errors = new HashMap<>(); 에 오류메시지를 담는것이 아닌 BindingResult객체를 사용한다

    @ModelAttribute에서 데이터가 바인딩될 때 오류가 발생하면, 해당 오류 객체는 BindingResult에 저장된다.

     = BindingResult객체를 사용하면 데이터 검증 결과를 담고 있다.

    @ModelAttribute에 오류가 발생해도 Controller가 호출될 수 있다. 

    BindingResult객체는 Model처럼 view로 자동 전달된다.

     

    BindingResult객체는 반드시 @ModelAttribute 바로 옆에 작성한다!!

    bindingResult.addError(
        new FieldError( "오류 발생 객체명",
                             "필드명",
                             사용자가 입력한 값,
                             "바인딩실패인지, 검증실패인지에 대한 구분값",
                             "에러코드",
                             "메시지에서 사용하는 인자"
                             "에러메시지" )  //필드에 오류가 있을 때
        OR
        new ObjectError( "오류 발생 객체명", "에러메시지" )  //복합적으로 오류가 있을 때 (객체수준)
    );

    (사실 BindingResult는 @ModelAttribute의 객체를 참고하고있기때문에 "오류 발생 객체명"은 생략 가능하다)

     

     

    타임리프 TIP

    th:errors="*{필드명}" : th:if와 th:text를 합친 것이다. 에러가 있으면 해당 필드명으로 저장된 에러메시지를 출력한다

    @PostMapping("/form")
    public String formSave(@ModelAttribute Board board,
                           BindingResult bindingResult,
                           RedirectAttributes redirectAttributes){
    
        //검증 로직
        if (!StringUtils.hasText(board.getTitle())) {
            bindingResult.addError(new FieldError(
                    "board", "title", "제목은 필수이고, 2~10글자를 입력해주세요"
            ));
        }
        if (!StringUtils.hasText(board.getContent())){
            // str이 null이거나 길이가 0이면 true 반환.
            bindingResult.addError(new FieldError(
                    "board", "content", "내용은 필수입니다"
            ));
        }
    
        // 오류 발생 시 입력폼으로
        if (bindingResult.hasErrors()) {
            return "board/form";
        }
    
        // 성공 시
        Board savedBoard = boardRepository.save(board);
        redirectAttributes.addAttribute("boardId", savedBoard.getId());
        return "redirect:/board/form?id={boardId}";
    }
    <form method="post" th:action="@{/board/form}" th:object="${board}">
      <div class="mb-3">
        <label for="title" class="form-label">제목</label>
        <input type="text" class="form-control" id="title" th:field="*{title}">
    
        <div class="invalid-feedback"
        th:errors="*{title}"
        th:style="${#fields.hasErrors('title')} ? 'display:block'">
        	Please select a valid state.	
        </div>
      </div>
    </form>

    ⭐만약 오류 발생하면

    org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 3 errors Field error in object 'board' on field 'title': rejected value

    만약 이런 오류가 발생하거나, 오류 발생하는 이유를 모르겠다 싶으면 
    @ModelAttribute 바로 다음 인자가 BindingResult인지 확인해보자!!
    (나는 @ModelAttribute 바로 다음 인자가 BindingResult가 아닌 RedirectAttribute여서 오류가 발생했다..😅)

     

     

    오류코드와 메시지 (rejectValue, reject)

    FieldError 객체의 에러코드와 메시지에서 사용하는 인자가 많아졌을 때를 대비하여 별도의 파일로 관리하자

    MessageCodesResolver 인터페이스를 사용하자.

     

     

    application.properties

    spring.messages.basename=messages,errors

    이렇게 하면 errors.properties, messages.properties 파일을 인식할 수 있게된다

     

    erros.properties

    ⚠ 인텔리제이의 경우 properties파일의 인코딩은 ISO8859이므로 한글을 사용하고자하면 

    settings > Editor > File Encodings > Transparent native-to-ascii conversion을 체크하고 저장해주자

    required=필수값입니다.
    
    typeMismatch=타입오류입니다
    #typeMismatch는 스프링이 만든 오류이다
    
    #필드오류 메시지 생성규칙
    #오류코드.객체명.필드명
    #오류코드.필드명
    #오류코드.필드타입
    #오류코드
    
    #객체오류 메시지 생성규칙
    #오류코드.객체명
    #오류코드

     

    FieldError는 rejectValue("오류필드명", "오류코드", 사용자 입력값)

    ObjectError는 reject("오류코드", 사용자 입력값)으로 대체한다.

    @PostMapping("/form")
    public String formSave(@ModelAttribute Board board,
    			BindingResult bindingResult,
                           RedirectAttributes redirectAttributes){
    
        //검증 로직
        if (!StringUtils.hasText(board.getTitle())) {
            bindingResult.rejectValue("title", "required");
        }
        if (!StringUtils.hasText(board.getContent())){
            bindingResult.rejectValue("content", "required");
        }
    
        // 오류 발생 시 입력폼으로
        if (bindingResult.hasErrors()) {
            return "board/form";
        }
    
        // 성공 시
        Board savedBoard = boardRepository.save(board);
        redirectAttributes.addAttribute("boardId", savedBoard.getId());
        return "redirect:/board/form?id={boardId}";
    }
    <form method="post" th:action="@{/board/form}" th:object="${board}">
    	...
        <div class="invalid-feedback" th:errors="*{content}" style="display:block;">
          Please select a valid state.
        </div>
        ...
    </form>

    rejectValue(), reject() 메서드는 내부적으로 MessageCodesResolver를 사용하여

    다음과 같은 메시지 코드들의 String배열을 자동으로 생성한다.

    #필드오류 메시지 생성규칙
    #오류코드.객체명.필드명
    #오류코드.필드명
    #오류코드.필드타입
    #오류코드
    rejectValue("title", "required")
    required.board.title
    required.title
    required.java.lang.String
    required
    #객체오류 메시지 생성규칙
    #오류코드.객체명
    #오류코드
    reject("required")
    required.board
    required

     

     

    Validator 인터페이스

    컨트롤러에서 검증로직의 비율이 너무 무거워지고있다

    boolean supports(Class<?> clazz) : 파라미터의 클래스가 해당 validator에서 처리할 수 있는지 확인

    validate(Object target, Errors errors) : 검증대상(target)과 대상의 Error객체(bindingResult객체)를 받아서 검증로직을 처리한다

    BindingResult클래스는 errors를 구현하는 객체이다.

    @Component
    public class BoardValidator implements Validator {
        @Override
        public boolean supports(Class<?> clazz) {
            return Board.class.isAssignableFrom(clazz);
            //isAssignableForm : 인스턴스를 현재 형식의 인스턴스에 할당할 수 있는지 여부 확인
        }
    
        @Override
        public void validate(Object target, Errors errors) {
            Board board = (Board) target;
    
            //검증 로직
            if (!org.springframework.util.StringUtils.hasText(board.getTitle())) {
                errors.rejectValue("title", "required");
            }
            if (!StringUtils.hasText(board.getContent())){
                errors.rejectValue("content", "required");
            }
        }
    }
    @Autowired
    private BoardValidator boardValidator;
    
    @PostMapping("/form")
    public String formSave(@ModelAttribute Board board,
                           BindingResult bindingResult,
                           RedirectAttributes redirectAttributes){
    
        boardValidator.validate(board, bindingResult);
    
        // 오류 발생 시 입력폼으로
        if (bindingResult.hasErrors()) {
            return "board/form";
        }
    
        // 성공 시
        Board savedBoard = boardRepository.save(board);
        redirectAttributes.addAttribute("boardId", savedBoard.getId());
        return "redirect:/board/form?id={boardId}";
    }
    <form method="post" th:action="@{/board/form}" th:object="${board}">
    	...
        <div class="invalid-feedback" th:errors="*{content}" style="display:block;">
          Please select a valid state.
        </div>
        ...
    </form>

     

     

    Bean Validation (어노테이션 사용)

    아주 간단한 검증로직은 어노테이션으로 대체할 수 있다.

    타겟 객체에 @Validated를 넣고, 검증 오류가 발생하면 FieldError, ObjectError를 생성해서 BindingResult에 담는다

    (=@Validated가 붙은 객체 바로뒤에는 BindingResult를 매개변수로 추가해주어야한다)

     

    필드는 어노테이션으로 처리가 가능하지만 특정 필드가 아닌 객체수준의 검증은 어노테이션으로는 한계가 있기때문에 Validator를 구현해서 직접 검증로직을 작성해주자

    public class Board {
        private Long id;
    
        @NotBlank
        @Size(min=2, max=10, message = "제목은 2자 이상 30자 이하입니다")
        private String title;
    
    //    @NotNull //null비허용, "" " " 허용됨
    //    @NotEmpty //null, "" 비허용, " " 허용됨
        @NotBlank //null, "", " " 모두 비허용
        private String content;
    }
    @PostMapping("/form")
    public String formSave(@Validated  @ModelAttribute Board board,
                           BindingResult bindingResult,
                           RedirectAttributes redirectAttributes){
    
        // 오류 발생 시 입력폼으로
        if (bindingResult.hasErrors()) {
            return "board/form";
        }
    
        // 성공 시
        Board savedBoard = boardRepository.save(board);
        redirectAttributes.addAttribute("boardId", savedBoard.getId());
        return "redirect:/board/form?id={boardId}";
    }
    <form method="post" th:action="@{/board/form}" th:object="${board}">
    	...
        <div class="invalid-feedback" th:errors="*{content}" style="display:block;">
          Please select a valid state.
        </div>
        ...
    </form>
    @Null Null값만 허용됨
    @NotNull null (X), "" (O), " " (O)
    @NotEmpty null (X), "" (X), " " (O)
    @NotBlank null (X), "" (X), " " (X)
    @Pattern(regexp="정규표현식", message="에러메시지") 입력값이 정규표현식에 해당되어야함
    @Range(min=최소값, max=최대값) 입력값이 범위 내의 값이어야함.
    @Size(min=최소값, max=최대값) 입력크기가 범위 내의 크기이어야함.
    @Max(최대값) 입력값이 최대값보다 작아야함
    @Min(최소값) 입력값이 최소값보다 커야함
    @Email 유효한 이메일인지 확인함
    @DateTimeFormat(pattern="YYYYMMDD") 패턴에 맞는 날짜형식이어야함

    한계점

    각 view마다 다른 검증을 적용할 수 없다

    해결방법 1 : view별 다른Board 클래스를 만들어 적용한다 

    해결방법 2 : BeanValidation의 groups 기능 사용 (크게 추천하지않음)

     

     

    groups 기능

    객체의 검증 어노테이션에 (groups = 허용할클래스명.class) 으로 그룹을 지정한다.

    @PostMapping(/주소)에 따라 @Validated(그룹명.class)를 다르게 적어주면된다.

    public interface SaveForm { }
    public interface UpdateForm { }
    public class Board {
        @NotNull(groups = UpdateForm.class)  //updateForm클래스만 해당
        private Long id;
    
        @NotBlank(groups = {SaveForm.class, UpdateForm.class})
        @Size(min=2, max=10, message = "제목은 2자 이상 30자 이하입니다")
        private String title;
    
        @NotBlank(groups = {SaveForm.class, UpdateForm.class})
        private String content;
    }
    @PostMapping("/form")
    public String formSave(@Validated(SaveForm.class) @ModelAttribute Board board,
                           BindingResult bindingResult,
                           RedirectAttributes redirectAttributes){
    }
    
    
    @PostMapping("/edit")
    public String formEdit(@Validated(UpdateForm.class) @ModelAttribute Board board,
                           BindingResult bindingResult,
                           RedirectAttributes redirectAttributes){
    }

     

     

    @ModelAttribute와 @RequestBody차이점

    @ModelAttribute는 HTTP 요청 파라미터(쿼리스트링, form)를 가져올 때

    @RequestBody는 HTTP Body의 데이터(JSON)를 가져올 때 사용

     

    @Validated @ModelAttribute Board board

    @RequestBody @Validated Board board

     

    @ModelAttribute는 필드 단위로 적용되기때문에

    TypeMismatch가 발생해도 다른 필드는 정상처리 가능하다 => @Validated 검증 가능

     

    @RequestBody는 객체 단위로 적용되기때문에 

    TypeMismatch가 발생하면 HttpMessageConverter에서 JSON 객체로 변환 자체가 안된다 => 예외 발생 (컨트롤러조차 호출 안됨)

     

     

     

    정리

    필드 수준의 검증(어노테이션 이용)

    1. Board 객체에 @NotNull 등의 어노테이션 추가

    2. Controller의 메서드 매개변수에 @Valiedated 추가 (@ModelAttribute 앞에)

    3. @ModelAttribute 다음인자로 반드시 BindingResult 추가

    4. 타임리프 th:object="${board}", th:errors="*{content}" 로 에러 출력

     

    객체 수준의 검증(Validator 인터페이스 이용)

    1. Validator 인터페이스를 구현한 BoardValidator 클래스 생성 후 @Component 선언

    2. supports 메서드에 Board.class.isAssignableForm 입력

    3. validate 메서드에 검증로직 작성 (StringUtils 사용하면 편리)
        errors.rejectValue("에러필드", "에러코드") / errors.reject("에러코드")

    4. Controller 파일에 @Autowired private BoardValidator boardValidator;

    5. 메서드에 boardValidator.validate(객체, bindingResult) 작성하여 메서드 수행

     

    에러메시지 설정

    1. application.properties 파일에 "spring.messages.basename=errors" 추가

    2. errors.properties 파일에 "에러코드=에러메시지" 작성 (인코딩 꼭 확인)

    반응형
    • 네이버 블러그 공유하기
    • 페이스북 공유하기
    • 트위터 공유하기
    • 구글 플러스 공유하기
    • 카카오톡 공유하기