OKKY를 참고한 방송대 커뮤니티 게시판을 만들어보고 있다. 이번에는 회원가입 폼 데이터를 검증해보았다.
사용자가 폼 데이터를 입력하고 POST 요청을 보내면 컨트롤러에서 그 데이터를 받아 검사해야 한다. 예를 들어 사용자의 실명을 받는 name
값이 null
은 아닌지, 숫자나 특수문자 데이터가 들어오지는 않았는지 검사할 필요가 있다.
기존에는 if
조건문을 통해 검증을 한 뒤, 문제가 있다면 BindingResult
의 rejectValue()
또는 reject()
를 통해 오류 내역을 추가했다. 이것은 상당히 반복되는 작업이기 때문에 이것을 상당 부분 자동화해주는 Java Bean Validation이라는 기술이 등장했다.
BeanValidation은 구현체가 아니라 Bean Validation 2.0(JSR-380) 이라는 기술 표준이다. JPA는 표준 기술이고 Hibernate가 구현체인 것처럼 BeanValidation도 구현체를 바꿔 낄 수 있는데 일반적으로 사용되는 구현체는 Hibernate Validator이다. (이름에 하이버네이트가 붙지만 ORM과는 관련이 없다.)
implementation 'org.springframework.boot:spring-boot-starter-validation'
build.gradle
에 BeanValidationn 의존성을 추가해준다. 스프링 부트에서 관리되기 때문에 버전을 명시하지 않아도 된다.
도메인 객체를 폼 데이터를 받을 때 재사용하지 말고, 폼 데이터를 받는 전용 DTO 객체를 만드는 것이 좋다고 한다. 프론트에서 컨트롤러로 넘어올 때만 사용되는 검증 로직을 분리해두고, 도메인 객체는 순수하게 유지함으로써 유지보수가 용이해지는 것이다.
나는 회원의 프로필 정보를 받는 도메인 객체 Member
가 있었지만 회원가입용 폼 객체인 MemberSignUpForm
을 별도로 만들었다.
@Data
public class MemberSignUpForm {
@NotBlank // null, 공백문자 허용 X (String 타입 필드 검증)
@Size(min = 4, message = "아이디는 최소 4자 이상 입력하세요.")
@Size(max = 15, message = "아이디는 15자 이하로 입력하세요.")
@Pattern(regexp = "^[a-z0-9]*$", message = "아이디는 영문 소문자와 숫자만 가능합니다.")
private String loginName;
@NotBlank
@Size(min = 6, message = "비밀번호는 최소 6자 이상 입력하세요.")
@Size(max = 25, message = "비밀번호는 25자 이하로 입력하세요.")
@Pattern(regexp = "^[a-zA-Z0-9!@#$%^&*]*$", message = "비밀번호는 알파벳, 숫자, 특수문자(!@#$%^&*)만 가능합니다.")
@Pattern(regexp = "^(?=.*[a-zA-Z0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*]*$", message = "비밀번호는 알파벳, 숫자, 특수문자(!@#$%^&*)가 각 1개 이상 포함되어야 합니다.")
private String password;
@NotBlank // 비밀번호 확인이 유효하지 않으면 컨트롤러에서 BindingResult에 에러 추가
private String passwordCheck;
@NotBlank
@Size(min = 1, max = 20, message = "닉네임은 20자 이하로 입력하세요.")
@Pattern(regexp = "^[a-zA-Z가-힣0-9]*$", message = "닉네임은 한글(음절), 알파벳, 숫자만 가능합니다.")
private String nickname;
@NotNull(message = "학년을 선택하세요.") // Enum 필드 유효성 검사는 setter 바인딩 시 수행됨
private Grade grade;
@NotNull(message = "지역을 선택하세요.")
private Region region;
@NotNull(message = "편입여부를 선택하세요.")
private Boolean transferred;
}
@NotNull
: null
을 허용하지 않는다.@NotBlank
: @NotNull
+ 공백문자를 허용하지 않는다. String
타입 검증에 사용된다.@Size
: 문자열 길이를 제한한다. @Length
는 기능은 동일하지만 Hibernate 전용이다.@Pattern
: 문자열 검증시 정규식 패턴 조건을 줄 수 있다.편입여부를 입력하는 필드인 transferred
는 Boolean
타입이다. (방송대에서는 편입이 아주 흔하고, 편입 여부에 따라 공유할 수 있는 주제가 다를 수 있어 필드를 추가했다.)
이 필드에서 true
또는 false
값이 들어오는지 검사를 해야한다고 생각해서 애노테이션을 찾아봤는데, 값이 true
인지 검사하는 @AssertTrue
와 false
인지 검사하는 @AssertFalse
애노테이션 밖에 없었다.
결론적으로, Boolean
필드의 검증은 @NotNull
하나면 충분하다. 어차피 Boolean
필드는 true
또는 false
둘 중 하나만 가질 수 있기 때문이다. 스프링은 POST로 들어온 문자열 값을 Boolean
타입에 바인딩할 때 True
, true
, TRUE
, 1
값 모두를 참 값으로 처리해준다. 하지만 만약 aaa
와 같이 의미없는 값이 들어온다면 BindingResult
객체에 typeMismatch
필드 오류로 추가한다.
@Pattern
애노테이션을 사용하면 정규식 패턴 조건을 걸어줄 수 있다. 이번에 정규식을 처음 사용해보았는데 정규식이 생긴 것은 외계어 같지만 차근차근 배워보니 간단한 것은 어렵지 않게 작성할 수 있었다. 도움이 많이 된 두 글의 링크를 아래 첨부한다.
^[a-z0-9]*$ // 아이디 검증 정규식
아이디는 영어 소문자와 숫자만 허용했다. 위의 정규식은 문자열의 처음(^
)과 끝($
)의 모든 문자(*
)에 앞의 조건([a-z0-9]
)을 적용한다는 뜻이다. -
는 아스키 코드에서 연속적으로 존재하는 코드들을 묶어서 지정하는 기호이다.
정규식에 not null 조건이나 길이 조건도 추가할 수 있지만, 나는 오류 메세지를 세분화하기 위해 @NotBlank
, @Size
애노테이션을 통해 따로따로 검증을 했으므로 @Pattern
에서는 허용 문자에 대해서만 검증을 했다.
^[a-zA-Z0-9!@#$%^&*]*$ // 비밀번호 허용 문자 검증
^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*]*$ // 혼합 문자 검증
비밀번호 검증에는 두 개의 @Pattern
을 사용했다. 이것 또한 합쳐서 검증할 수 있지만 오류 메세지를 사용자에게 자세하게 전달하기 위해 분리한 것이다.
첫 번째 정규식은 영어 소문자(a-z
), 영어 대문자(A-Z
), 숫자(0-9
), 특수문자(!@#$%^&*
)만을 허용한다. 아스키 코드에서 영어 소문자와 영어 대문자가 연속적으로 위치하지 않고 중간에 특수문자가 존재하기 때문에 A-z
와 같은 방식으로 사용하면 안 된다.
두 번째 정규식은 영문자, 숫자, 특수문자를 최소 1개씩 포함하고 있는지 검사하는 혼합 검증용이다. (?=.)
는 현재 검사 위치에서 조건을 걸어주는 방식인데, 조건을 충족하면 해당 조건이 소거된다. 만약 같은 위치에 여러 조건이 걸리는 경우, 조건이 충족되면 다음 문자로 검사가 이동되는 것이 아니라 충족된 조건이 소거되고 동일한 위치에서 다음 조건을 검사한다. 비밀번호의 경우에는 조건 위치를 모두 *
로 걸어주었기 때문에 조건을 1번 이상(.
) 충족해주면 검사가 통과된다. 또한 모든 문자(*
)는 영문자, 숫자, 일부 특수문자만 가능하다. 정리하면 비밀번호는 영문자, 숫자, 일부 특수문자만 올 수 있으며 각각 최소 1개씩 가져야 한다는 조건이 된다.
비밀번호 확인은 바인딩 시점에는 @NotBlank
만 적용하고 컨트롤러에서 비밀번호와 동일한지 검사하도록 했다.
^[a-zA-Z가-힣0-9]*$
닉네임은 영문자, 한글, 숫자만 허용했다.
Enum이 값 제한을 걸어주는 기능은 좋지만 초보자로서는 사용하기가 까다롭다. 이번에도 어김없이 진행에 브레이크를 걸어주는 것은 Enum 이었다 ^^
회원가입용 폼 객체 MemberSignUpForm
에는 Enum 타입 필드가 두 개 있다.
Grade
는 학년을 표시하는 필드로 1~4학년과 졸업생 총 5개의 값 중 하나를 가질 수 있다. Region
은 서울, 부산, 대구 등 지역을 표시한다. (방송대는 온라인 기반 대학교이고 전국에 지역대학이 있기 때문에 지역이 의미가 커서 프로필에도 지역 항목을 넣었다.) 참고로 두 Enum 클래스는 모두 Member
안에 선언된 내부 클래스이다.
@NotNull(message = "학년을 선택하세요.") // Enum 필드 유효성 검사는 setter 바인딩 시 수행됨
private Grade grade;
@NotNull(message = "지역을 선택하세요.")
private Region region;
폼에서 POST로 값이 넘어올 때 Enum 필드의 값은 String
으로 넘어오기 때문에, 컨트롤러 파라미터에 바인딩될 때 아래와 같은 MethodArgumentNotValidException
오류가 발생한다.
org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public java.lang.String ...
검색해보니 전용 타입 컨버터를 만드는 등 여러 가지 방법이 있었지만, 나는 그냥 단순하게 setter에 valueOf()
를 넣어서 파라미터로 받은 String
값과 일치하는 Enum
을 찾아 바인딩하는 방법을 사용했다. valueOf()
를 사용하면 매칭되는 Enum이 없는 경우 400 Bad Request
가 발생하니 바인딩 시점에 검증도 되는 셈이다.
public void setGrade(String grade) {
this.grade = Member.Grade.valueOf(grade);
}
public void setRegion(String region) {
this.region = Member.Region.valueOf(region);
}
Postman을 사용하여 Enum 필드에 유효하지 않은 값을 전달해 봤더니 methodInvocation
오류가 발생한다.
codes [methodInvocation.memberSignUpForm.grade,methodInvocation.grade,methodInvocation.java.lang.Enum,methodInvocation]
Enum 바인딩 오류 메세지는 공통적으로 '올바르지 않은 값입니다.' 라는 메세지를 추가했다. errors.properties
등록 방법은 아래에서 정리하겠다.
methodInvocation.java.lang.Enum=올바르지 않은 값입니다.
@PostMapping("/signup")
public String signUp(@Validated @ModelAttribute MemberSignUpForm form, BindingResult bindingResult, Model model, RedirectAttributes redirectAttributes) {}
위와 같이 컨트롤러에서 @Validated
를 붙이면 @ModelAttribute
를 통해 폼 데이터가 객체에 바인딩될 때 지정한 검증 애노테이션에 따라 검증을 수행하고, 오류가 있으면 BindingResult
에 오류 명칭과 오류 메세지를 찾아서 추가해준다.
오류 메세지는 오류 코드나 애노테이션을 통해서 유연하게 설정 가능한데, 스프링이 BindingResult
에 오류 메세지를 찾아서 추가해줄 때 탐색 우선순위가 있다.
message
속성 값 사용 (예. @NotNull(message = "지역을 선택하세요.")
)MessageSource
에서 레벨별로 탐색 (아래에서 정리)(강의노트에서는 1, 2 순서가 반대였는데 직접 적용해보니 위의 순서였다.)
먼저 오류 코드를 통해 오류 메세지를 작성할 errors.properties
파일을 만든다. 그 다음 애플리케이션 설정 파일 application.properties
에서 MessageSource
를 등록해준다.
spring:
messages:
basename: errors
바인딩 오류는 오류 코드에 매칭할 수 있는데, 세분화된 오류 코드를 사용할 수도 있고 간단하게 범용 오류 코드를 사용할 수도 있다. 예를 들어 회원가입 폼 nickname
필드의 @NotBlank
오류에 매칭할 수 있는 오류 코드는 아래와 같다.
NotBlank.memberSignUpForm.nickname
NotBlank.nickname
NotBlank.java.lang.String
NotBlank
스프링은 오류 메세지를 가장 상세한 위쪽에서부터 아래쪽으로 설정 파일에 지정된 오류 메세지가 있는지 탐색하고, 없으면 라이브러리가 제공하는 기본 메세지를 사용한다.
NotNull=필수 항목입니다.
NotBlank=필수 입력 항목입니다.
typeMismatch=올바르지 않은 값입니다.
methodInvocation.java.lang.Enum=올바르지 않은 값입니다.
영한님이 추천하는 방법은 범용 메세지를 먼저 작성하고 필요에 따라 상세한 메세지를 작성하는 것이 좋다고 하셨다.
나는 위와 같이 errors.properties
범용 메세지를 작성했고, 상세한 메세지는 회원가입 폼 애노테이션의 message
속성을 사용하여 작성했다. (검증 애노테이션의 message
속성을 사용하면 min, max 등 보다 상세하게 메세지를 작성할 수는 있지만 국제화 메세지 적용이 가능한지는 모르겠다. 이거는 지금 문제가 아니기 때문에 패스했다.)
JSP View에 BindingResult
오류 메세지를 출력하는 것은 🔗 회원가입 폼 검증하기 − ② (JSP)에 정리했다.