내가 정말 궁금해 하던 부분이 나왔다.

아니 사실 이거랑 api 예외처리 공부하려고 mvc 강의를 듣기 시작했다.

 

예전에 장고로 칵테일 레시피 사이드 프로젝트를 할 때

재료 양을 ml로 받는 요청에 문자를 넣어도 db에 그대로 저장되는 참사가 있었다.

 

Validation(유효성 검사) 이란?

사용자(Client)가 서버에 보내는 요청에 담긴 데이터가 유효한지(양식이 정확한지, 따로 합의해놓은 기준을 만족하는지 등) 확인하는 과정을 말한다.

 

보통 프론트와 백에서 둘 다 검증을 진행한다.

프론트에서 검증을 하면 서버에서 검증을 위해 동작하는 네트워크 비용과 시간을 아낄 수 있다. 

그러나 프론트의 검증은 사용자가 고의로 변경해서 우회할 수 있다.

DB의 데이터를 안전하고 정확하게 관리하기 위해선 백엔드에서 검증은 필수이다.

 

당장 프론트 개발자로 취업할건 아니니까 css 깨지는건 그냥 넘어가자

유저 서비스에서 회원가입을 하기 위해선 이름, 아이디, 비밀번호를 폼으로 입력받는다.

지금까지 개발한 어플리케이션 로직은 서비스단에서 아이디가 중복인지는 체크하지만

이름, 비밀번호에 null값, 빈 문자열이 들어와도 그대로 저장했다.

 

이름, 아이디, 비밀번호에 공백을 입력하고 form을 넘기면 오른쪽 그림처럼 오류 화면을 띄우는 것이 오늘의 목표다.

 

BindingResult란?

Validation(검증) 오류를 보관하는 객체이다.

 

스프링에서 폼에서 넘어온 데이터를 ModelAttribute를 통해 바인딩해서 받아오면

바인딩 과정에서 오류(int 타입에 문자가 넘어오는 등)가 발생할 경우

컨트롤러를 거치지 않고 그냥 400 오류화면을 보내버린다. 

    //유저 생성
    @PostMapping("/admin/users/create-users")
    public String createUser(@ModelAttribute("request") CreateUserRequest request, 
    				BindingResult bindingResult,
                    		Model model){
        ...
    }

그러나 컨트롤러의 파라미터로 BindingResult가 있을 경우

스프링은 BindingResult에 오류를 담아서 컨트롤러를 호출한다.

 

BindingResult에 저장되는 에러는 FieldError와 ObjectError가 있다.

 

1. FieldError : 검증하려는 객체의 특정 필드에 에러가 있는 경우 사용한다. ( int 타입 필드에 문자가 들어옴, null이 허용되지 않는 필드에 null이 들어옴 등등) 

new FieldError(”objectName”, “field”, “rejectedValue" , bindingFailure(boolean) , codes,  arguments, "default message")

로 직접 만들 수 있다.

 

2. ObjectError : 특정 필드에서 나오는 에러가 아닌 여러 필드가 복합적으로 작용한 오류 (상품 개수 * 가격 >= 10000원 이상이어야함 등)

new ObjectError(”objectName”, codes, arguments, "default message")로 직접 만들 수 있다.

 

rejectedValue : 오류가 발생한 사용자에게 돌려줄 값

bindingFailure : 타입 에러 등 Binding 과정에서 에러가 발생했는지 여부

codes : error 메세지를 spring messages 기능을 통해 제공하는 파라미터, 문자열 배열을 입력받는다.

arguments : messages 기능에 들어갈 파라미터

//유저 생성
@PostMapping("/admin/users/create-users")
public String createUser(@ModelAttribute("request") CreateUserRequest request, 
			BindingResult bindingResult,
			Model model){
	if (!StringUtils.hasText(request.getAccountId())){
		bindingResult.rejectValue("accountId", null, null, "아이디는 필수 값입니다.");
        
	}

	if (!StringUtils.hasText(request.getAccountId())){
		bindingResult.rejectValue("accountId", "required", null, null); //messages 기능 사용
	}
    
	if(bindingResult.hasErrors()){ // 검증 실패
		return "admin/users/users-create-form";
	}
    ...
}

이때 BindingResult는 반드시 검증하려는 객체의 바로 뒤에 위치해야한다.

BindingResult는 바로 앞의 파라미터에 위치하는 객체를 target으로 지정해놓고,

Error 객체를 직접 만들고, model에 자동으로 반환해주는 등 검증에 필요한 편의 기능들을 지원한다.

 

다음과 같이 bingingResult.rejectValue(), reject()를 통해 FieldError, ObjectError를 쉽게 생성할 수 있다.

rejectValue에는 FieldError와 다르게 objectName을 적을 필요가 없으며 (target인 객체의 이름으로 저장된다)

messages기능을 위한 codes에 문자열 배열이 아닌 문자열을 직접 입력받는다.

 

축약된 codes는 MessageCodesResolver가 문자열 배열로 전환해주며

타임리프에서 문자열 배열을 읽어 우선순위에 따라 message를 연결해준다.

 

그리고 오류가 난 경우 입력 폼으로 다시 되돌아가서 어느 필드에서 오류가 발생했는지를 알리는데

ModelAttribute를 쓰면 들어온 입력 값이 담긴 객체가 그대로 model에 추가되서 다시 입력폼으로 전달할 수 있다.

그리고 BindingResult에 들어온 Error 객체 또한 그대로 model에 자동으로 추가된다.

 

타임리프에서 th:errors="*{fieldName}"을 사용하면 그 필드에 에러가 있을 시에만 태그가 보이게 설정할 수 있다.

(bindingErrors에서 그 필드를 대상으로 FieldErrors를 만들어 전달했을때.)

 

그리고 예외처리 로직이 커졌을 경우 컨트롤러에 남겨두는 것 보다는

Validator 클래스를 만들어 분리하는 것이 좋다.

 

//UserValidator
@Component
public class UserValidator implements Validator{
    
    @Override
    public boolean supports(Class<?> clazz){
        return CreateUserRequest.class.isAssignableFrom(clazz);
    }
    
    @Override
    public void validate(Object target, Errors errors){
        CreateUserRequest request = (CreateUserRequest) target;
        
                // === 검증 로직 ===
        if (!StringUtils.hasText(request.getAccountId())){
            errors.rejectValue("accountId", "required", null, null);
        }
        if (!StringUtils.hasText(request.getName())){
            errors.rejectValue("name", "required", null, null);
        }
        if (!StringUtils.hasText(request.getPassword())){
            errors.rejectValue("password", "required", null, null);
        }
    }
}
//Controller
@Controller
@RequiredArgsConstructor
public class AdminUserController{

    private final UserValidator userValidator;
    
    @InitBinder
    public void init(WebDataBinder dataBinder){
        dataBinder.addValidators(userValidator);
    }
    
    @PostMapping("/admin/users/create-users")
    public String createUser(@Validated @ModelAttribute("request") CreateUserRequest request, BindingResult bindingResult, Model model){
        
        if(bindingResult.hasErrors()){ // 검증 실패
            return "admin/users/users-create-form";
        }
        else { //검증 성공
            User user = request.toUser();
            Long userId = userService.register(user);
            //redirect 해야함
            return "redirect:/admin/users/read-users";    
        }
    }
    ...
 }

컨트롤러에선 WebDataBinder을 이용해 userValidator를 컨트롤러에서 사용하도록 등록할 수 있고

@Validated 어노테이션을 달아 검증 로직을 자동으로 적용할 수 있다.

 

 

 

 

 

 

 

 

 

 

+ Recent posts