07. 파라미터와 validation
- -
파라미터와 validation
validation은 화면에서 넘어온 데이터가 원하는 형식에 적합한가를 나타내는 것으로 앞서 살펴본 formatting과는 다르다.
포메팅이 원본 데이터를 가독성 좋은 문자열로 보이도록 처리하거나 그 문자를 다시 원본 데이터로 형 변환하기 위한 과정이라면 validation은 원하는 형태로 작성되었는가만 본다. 예를 들어 비밀번호의 복잡성이나 필수 입력 항목의 작성 여부, 값의 범위등을 확인하는 것이 validation이다.
클라이언트 단에서의 validation vs 서버 단에서의 validation
일반적으로 validation은 클라이언트 단에서 java script를 이용해서 처리하는 것으로 오해하기 쉽다.
하지만 일단 javascript를 통과한 패킷을 해커가 조작해버리면 서버 입장에서는 속수무책이 된다.
따라서 클라이언트 단에서의 검증은 단순한 입력 가이드 라인 역할만 하고 값은 언제나 오염될 수 있음을 명심해야 한다. 서버로 들어오기 전의 값은 절대 신뢰하면 안된다. validation 처리는 클라이언트에서도 필요하고 서버에서도 필요하다.
@기반의 validation 처리
validation에 대해서는 java 표준으로 이미 상당부분 정형화가 이뤄져있다.
spring-boot-starter-validation에는 hibernate-validator와 jakarta.validation-api가 포함되어있네 이 두 개의 maven dependency에 validation을 검증할 수 있는 다양한 애너테이션들이 포함되어 있다.
다음은 대표적인 validation 처리 annotation 들이다.
애너테이션 | 설명 |
@NotEmpty | CharSequence, Map, Collection, Array가 not null이고 length() 또는 size()가 0이 아닐 것 |
@NotNull | field의 값이 not null 임 |
@Null | field의 값이 null일 것 |
@Size(min="m", max="n") | CharSequence의 length() 또는 Map, Collection, Array의 size()가 m 보다 크고 n 보다 작을 것 |
@UniqueElements | Collection 내의 요소들이 중복되지 않을 것 |
@Min(m), @Max(n) | 각각 숫자형 field에 대해 최대, 최소 값 설정 |
@Range(min=i, max=a) | 최소 i에서 최대 a 사이의 값일 것 |
@Digits | 숫자 형태의 값으로만 구성될 것 |
이메일 형식을 갖출 것 | |
@SafeHtml | 전달된 HTML 내용이 안전한지 체크 |
@CreditCardNumber | 문자열이 신용카드 형태인지 점검 |
@Future[OrPresent], @Past[OrPresent] |
Date, Calendar, time package 타입에 대해 각각 미래 또는 이전 시간이거나 같을 것 |
@URL | 문자열이 URL 형식인지 검증 |
@Pattern(regexp="") | 문자열 field가 정규 표현식에 적합할 것. null은 valid |
@AssertTrue, @AssertFalse | 각각 값이 true, false인지 검증(주로 메서드의 리턴 값으로 검증할 때) |
애너테이션 적용
Employee에 validation을 위한 애너테이션을 적용해보자.
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Employee {
@Pattern(regexp = "^[a-zA-Z0-9]{4,8}$", message = "아이디는 영문 대/소문자와 숫자로 4~8자로 구성")
private String id;
@Pattern(regexp = "[가-힣]{2,5}")
private String name;
@Pattern(regexp = "^(?=.*[a-z])(?=.*[0-9])(?=.*[#?!@$%^&*-]).{8,}$")
private String password;
private String cPassword;
@Email
private String email;
@DateTimeFormat(pattern = "yyyy/MM/dd(E)")
@Past
private LocalDate birth;
@NumberFormat(style = Style.NUMBER)
private Double balance;
private EmpNo empNo;
}
위의 예를 보면 대부분 문자열 처리에는 @Pattern이 많이 사용되는 것을 볼 수 있다. 정규 표현식이 그만큼 중요하다는 이야기이다. 아직 정규식 표현에 익숙치 않다면 다음 포스트를 참조하기를 권장한다.
https://goodteacher.tistory.com/226?category=822465
클라이언트 처리
클라이언트 부분은 formatter에서와 마찬가지로 #fields와 th:errors를 이용해서 표현한다.
<input type="text" th:field="*{id}">
<span class="error" th:if="${#fields.hasErrors('id')}" th:errors="*{id}"></span>
<input type="text" th:field="*{name}">
<span class="error" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span>
<input type="password" th:field="*{password}">
<span class="error" th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></span>
<input type="password" th:field="*{cPassword}">
<span class="error" th:if="${#fields.hasErrors('cPassword')}" th:errors="*{cPassword}"></span>
<input type="text" th:field="*{email}">
<span class="error" th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></span>
서버 처리
이 DTO에 대한 검증을 요청하기 위해서는 @ModelAttribute 요소에 @Valid 애너테이션을 추가한다.
@Target({ METHOD, FIELD, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
public @interface Valid {
}
@PostMapping("/mod")
public String doModEmp(Model model, @ModelAttribute("emp") @Valid Employee emp, BindingResult result) {
if (result.hasErrors()) {
return "modEmp";
}
service.setEmp(emp);
model.addAttribute("message", emp);
return "modSuccess";
}
formatter와 마찬가지로 validation 처리에서 실패한 내용들은 BindingResult에 error로 쌓이게 된다. 따라서 formatter에 대한 처리가 끝난 경우라면 @Valid만 @ModelAttribute 대상 DTO에 선언해주면 끝이다.
동작 확인
이제 동작을 확인해보자.
아이디 부분은 message 속성의 값으로 메시지가 출력되고 있고 나머지 항목들은 기본 에러 메시지가 출력되고 있다.
에러 메시지 재정의
validation을 위한 애너테이션들은 모두 기본 오류 메시지를 내장하고 있다. 상황에 밀접한 에러 메시지로 재정의하기 위해서는 두 가지 방식이 사용된다.
먼저 id의 예에서 봤던 것처럼 애너테이션의 message 속성을 이용하는 것이다.
@NotEmpty(message="{0} 항목은 반드시 있어야 해요.")
String name;
대부분 이것으로 충분할 경우가 많지만 이 방법은 locale 적용이 어렵기 때문에 확장성을 위한다면 formatter의 메시지 설정처럼 별도의 properties에 메시지를 설정할 수 있다.
다음은 메시지의 적용 우선 순위로 먼저 발견된 내용을 message로 사용한다. 여기서 항목은 validation을 확인하는 애너테이션 이름이고 객체는 model에 설정된 DTO 객체의 이름, 필드명은 DTO 필드의 이름, 클래스는 애너테이션이 적용된 field의 클래스를 나타낸다.
src/main/resources/messages/error.properties에 아래 내용을 추가해보자.
Pattern.emp.id={0}는 영문과 숫자 4~8자로 구성될 것(예: hong123)
Pattern.name={0}은 한글 3 ~ 5글자로 구성될 것(홍길동)
Pattern.password=비밀번호는 영어소문자, 숫자, #?!@$%^&*-가 각각 1글자 이상 포함되며 8자 이상
Pattern.cPassword=비밀번호는 영어소문자, 숫자, #?!@$%^&*-가 각각 1글자 이상 포함되며 8자 이상
Email={0}에는 @와 .가 필요(abc@def.net)
Past.birth=생일은 오늘 이전의 날짜여야함
다시 페이지를 요청해보면 우리 설정대로 에러 메시지가 출력됨을 알 수 있다.
사용자 정의 validation 처리
제공되는 validation 애너테이션 이외에 추가적인 validation 처리가 필요하다면 사용자 정의 애너테이션을 만들어서 처리할 수 있다.
사용자 정의 애너테이션 작성
앞에서 @Pattern을 이용해서 처리했던 비밀번호에 대한 validation을 별도의 애너테이션으로 만들어서 처리해보자.
먼저 MyPassword라는 애너테이션을 작성해보자.
@Documented
@Target({ElementType.FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = PasswordValidChecker.class)
public @interface MyPassword {
String message() default "비번은 숫자, 알파벳소문자, !@#$%^&*() 이 최소 하나는 포함되어야 하며 8자 이상";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
적용 범위는 단지 DTO의 field에만 적용하면 된다. 신경쓸 부분은 @Constraint 부분인데 validatedBy 항목에 실제로 validation 처리 로직을 갖는 클래스를 적는다.
message는 오류 발생시 보여줄 기본 메시지이고 groups와 payload는 필수 사항이므로 적어둔다.
실제 validation을 처리할 PasswordValidChecker를 작성해보자. 이를 위해 ConstraintValidator를 구현하고 isValid를 재정의 한다.
package com.eshome.mvc.validation;
import java.util.regex.Pattern;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class PasswordValidChecker implements ConstraintValidator<MyPassword, String> {
@Override
public boolean isValid(String pass, ConstraintValidatorContext context) {
return Pattern.matches("^(?=.*[a-z])(?=.*[0-9])(?=.*[#?!@$%^&*-]).{8,}$", pass);
}
}
이제 기존의 @Pattern 부분을 @MyPassword로 바꿔주면 기존과 동일하게 동작한다.
// @Pattern(regexp = "^(?=.*[a-z])(?=.*[0-9])(?=.*[#?!@$%^&*-]).{8,}$")
@MyPassword
private String password;
그런데 이런 애너테이션은 개별 필드의 validation 처리에는 용이하지만 비빌번호와 확인비빌번호가 같은지 체크하는 등 여러 필드에 걸친 validation 처리에는 어려움이 있다.
global error 처리
앞서 처리해본 error들은 특정 field와 연관된 field error이다. 그래서 화면의 field에 1:1로 잘 연결할 수 있었다.
그런데 'password와 cPassword가 같아야 한다'는 validation을 처리하다가 발생한 에러는 어떤 field와 관련된 에러일까? 이처럼 특정 필드에 연결할 수 없고 DTO에서 발생하는 에러를 global error 라고 하고 annotation 적용도 클래스 레벨에서 처리해야 한다.
이 과정을 처리하기 위한 annotation을 만들어보자.
package com.eshome.mvc.validation;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import javax.validation.Constraint;
import javax.validation.Payload;
@Target({ElementType.TYPE})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {PasswordEqualChecker.class})
public @interface MyPasswordEquals {
String message() default "비밀번호와 다름니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
특히 주목할 점은 @Target 정보가 ElementType.TYPE이라는 점이다. 즉 MyPasswordEquals는 클래스 레벨에서 선언하는 애너테이션이다.
validation을 처리하는 PasswordEqualChecker를 살펴보자.
package com.eshome.mvc.validation;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import com.eshome.mvc.model.dto.Employee;
public class PasswordEqualChecker implements ConstraintValidator<MyPasswordEquals, Employee> {
@Override
public boolean isValid(Employee value, ConstraintValidatorContext context) {
return value.getPassword().equals(value.getCPassword());
}
}
이를 Employee 클래스에 적용해보자.
@Data
@AllArgsConstructor
@NoArgsConstructor
@MyPasswordEquals
public class Employee {
...
}
이제 Password와 cPassword가 다른 경우는 오류가 발생하게 된다. 문제는 이 오류가 특정 field와 연결되지 않는 global error이기 때문에 화면에서의 처리가 어렵다. controller에서 global error를 field error로 연결해보자.
@PostMapping("/mod")
public String doModEmp(Model model, @ModelAttribute("emp") @Valid Employee emp, BindingResult result) {
if (result.hasErrors()) {
List<ObjectError> globalErrors = result.getGlobalErrors();
for (ObjectError ge : globalErrors) {
if (ge.getCode().equals(MyPasswordEquals.class.getSimpleName())) {
result.rejectValue("cPassword", "Equal.pass", ge.getDefaultMessage());
}
}
// 직원 정보 수정 페이지로 다시 이동한다.
return "modEmp";
}
service.setEmp(emp);
model.addAttribute("message", emp);
return "modSuccess";
}
이제 모든 처리가 바라는 대로 이루어진것 같다.
단위테스트
마지막으로 validation error에 대한 단위테스트를 수행해보자. formatting에 대한 binding error도 동일한 방식으로 처리가 가능하다.
기본적인 테스트의 준비는 아래와 같다.
package com.eshome.mvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Locale;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.eshome.mvc.controller.UserController;
@SpringBootTest
public class ValidationUnitTest {
@Autowired
UserController userController;
private MockMvc mock;
@BeforeEach
public void setup() {
mock = MockMvcBuilders.standaloneSetup(userController).build();
}
. . .
}
첫번째 단위테스트를 처리해보자. 날짜에 (화) 이 있어서 Locale을 설정해야 함을 주의하자.
@Test
public void validTest1() throws Exception {
mock.perform(post("/mod").param("id", "hong")
.locale(Locale.KOREA) // 날짜 처리를 위해 locale 설정 필요
.param("name", "홍길동")
.param("password", "abcd1234#")
.param("cPassword", "abcd1234#")
.param("email", "abc@def.net")
.param("birth", "2020/07/07(화)")
.param("balance", "123,456,789.123")
.param("empNo", "12-3456-7890"))
.andExpect(status().isOk())
.andExpect(model().hasNoErrors()); // model에 물어보니 에러가 없더라.
}
다음은 오류를 발생시켜서 내용을 검증해보자. password와 cPassword가 다른점과 email 형식이 다른 오류에 대해 잘 검증됨을 알 수 있다.
@Test
public void validTest2() throws Exception {
mock.perform(post("/mod")
.locale(Locale.KOREA)
.param("id", "hong")
.param("name", "홍길동")
.param("password", "abcd1234#")
.param("cPassword", "bcd1234#")// password와 다르다. global과 filed 2개 발생
.param("email", "abc") // email 형식이 다르다.
.param("birth", "2020/07/07(화)")
.param("balance", "123,456,789.123")
.param("empNo", "12-3456-7890"))
.andExpect(status().isOk())
.andExpect(model().errorCount(3))
.andExpect(model().attributeHasFieldErrorCode("emp", "cPassword", "Equals.pass"))
.andExpect(model().attributeHasFieldErrorCode("emp", "email", "Email"));
}
}
'Spring MVC > 02.Spring @MVC' 카테고리의 다른 글
[SpringBoot]file upload /download 처리 (9) | 2021.05.06 |
---|---|
08. Redirection과 flash scope (0) | 2020.07.09 |
06. 파라미터의 formatting (0) | 2020.07.07 |
05. Handler Interceptor (1) | 2020.07.03 |
04. 웹과 관련된 설정 (0) | 2020.07.02 |
소중한 공감 감사합니다