@Validated 적용기
상황
1차 프로젝트를 진행하면서
@Validated로 유효성 로직을 구현하였다.
내가 체크해야될 필드는
아래 사진에서 보이는 필드 값들을
모두 not null + jpa 기본 컬럼 사이즈인 varchar(255)를 넘지 않는지 사이즈 체크를 해야했다.
우리 팀은 이미지 등록을 DB에 byte [] 로 저장하게 구현했으므로
이미지 validate 는 커스텀 어노테이션을 만들어야만했다.
(상품당 이미지는 1개만 들어가기도했고, 이미지의 크기가 작아서 이런 저장방식으로 구현했는데
멘토님,강사님이 이렇게 이미지 구현하는 것은 좋지 않다고 말씀해주셨다.
사실 팀원들도 모두 알고있었던건데 워낙 간단한 프로젝트라서 방심(?)했던 것 같다)
구현 코드
Request용 DTO
@Getter
@Setter
@NoArgsConstructor
public class ProductCreateRequest {
@NotBlank(message = "상품명은 필수입니다.")
@ByteSize(max = 255)
private String name;
private Category category;
@NotBlank(message = "상품설명은 필수입니다.")
@ByteSize(max = 255)
private String description;
@NotBlank(message = "가격 입력은 필수입니다.")
@ByteSize(max = 255)
private String price;
@ProductValidFile
private MultipartFile file;
@Builder
public ProductCreateRequest(String name, Category category, String description, String price,
MultipartFile file) {
this.name = name;
this.category = category;
this.description = description;
this.price = price;
this.file = file;
}
}
우선 DTO 에서
@NotBlank를 통해 not null 유효성 체크를 걸어준다.
이때 메세지를 설정할 수 있는데,
이 메세지는 화면에서 보여주기 위한 메세지이다.
@ByteSize 와 @ProductValidFile은
내가 만든 커스텀 어노테이션이다.
마지막에 설명할 예정이다.
@PostMapping
public String saveProduct(
@Validated @ModelAttribute("productForm") ProductCreateRequest productCreateRequest,
BindingResult bindingResult, Model model) {
if (bindingResult.hasErrors()) {
model.addAttribute("categories", Category.values());
model.addAttribute("isNew", true);
if (!productCreateRequest.getFile().isEmpty()) {
model.addAttribute("imageReUploadNotice", "다른 항목 오류로 인해 이미지는 다시 업로드해 주세요.");
}
return "admin/view_save_products";
}
Product product = productService.saveProduct(productCreateRequest);
return "redirect:/admin/products/" + product.getId();
}
Controller 에서
화면에서 넘어오는 값인 ProductCreateRequuest(request용 DTO)에
@Validtaed 어노테이션을 붙여주었다.
바로 뒤에는 BindingResult 객체를 받아온다.
이 객체는 DTO 에 선언해놓은
유효성 체크를 통과하지 못할 때
.hasErrors()가 true를 반환한다.
<div id="nameError" class="errorMsg" th:if="${#fields.hasErrors('name')}"
th:errors="*{name}" style="padding-top:5px;color:red">
</div>
화면에서 에러 메세지를 보여주기 위해
div 요소를 위와 같이 작성하였다.
(타임리프 기준)
커스텀 어노테이션 (@ByteSize, @ProductValidFile)
@ByteSize 어노테이션을 만든 이유는
한글은 3byte, 영어는 1byte로
@Size 어노테이션만으로는 정확한 유효성 체크를 할 수 없다고 생각했다.
@Size 어노테이션만으로 체크를 한다면
max = 255 로 설정했을 때
이 값은 byte 값으로 검사하는것이 아니기 때문에
(글자 수를 체크한다)
이 유효성 로직을 통과하여도 실제 DB 저장시에는
필드 사이즈를 초과하여 저장 오류가 발생할 수 있다.
우선 Validator를 만들어주었다.
public class ByteSizeValidator implements ConstraintValidator<ByteSize, String> {
private int max;
@Override
public void initialize(ByteSize constraintAnnotation) {
this.max = constraintAnnotation.max();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
if(value == null){
return true;
}
int byteLength = value.getBytes(StandardCharsets.UTF_8).length;
return byteLength <= max;
}
}
이 내용이 실질적인 검증로직인 셈 이다.
최소값은 not null로 설정했으니
max 값만 설정해주었다.
입력받은 string값을 utf-8 byte 값으로 변환한 후
max 값을 넘지 않는다면 true, 그렇지 않으면 false 를 반환하게 구현하였다.
ConstraintValidator<A, T>
이 부분 설정은 A에는 내가 만든 어노테이션 타입을,
T에는 검증할 타입을 명시해주면 된다.
ByteSize Annotation 생성
@Documented
@Constraint(validatedBy = ByteSizeValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ByteSize {
String message() default "바이트 길이가 허용 범위를 초과했습니다.";
int max();
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
위와 같이 어노테이션을 생성해주었다.
허용 글자수가 넘었을 때
화면에서 보여줄 메세지를 설정하였고
@Constraint(validatedBy = ByteSizeValidator.class)를 통해
@ByteSize 어노테이션에 ByteSizeValidator를 지정하여 유효성 검사를 수행하도록 구성하였다.
@Target 으로 필드 레벨에 적용할 수 있도록 명시한다.
@Retention(RetentionPolicy.RUNTIME) 으로
컴파일 시, class 파일에도 기록되며 runtime 도중에도 어노테이션이 유효할 수 있게 설정했다.
다음으로는 @ProductValidFile 어노테이션을 만들었다.
이 어노테이션은 파일 객체가 비어있는지는
@NotNull로 체크가 가능하지만,
파일 사이즈를 제한하기 위해 만들었다.
만드는 방식은 위와 동일하다.
우선 Validator 를 만들어주고
public class ProductFileValidator implements ConstraintValidator<ProductValidFile, MultipartFile> {
private long maxSize;
@Override
public void initialize(ProductValidFile constraintAnnotation) {
this.maxSize = constraintAnnotation.maxSize();
}
@Override
public boolean isValid(MultipartFile file,
ConstraintValidatorContext context) {
if (file == null || file.isEmpty()) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("파일은 필수입니다.").addConstraintViolation();
return false;
}
if (file.getSize() > maxSize) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("파일은 최대 " + (maxSize / 1024 / 1024) + "MB까지 업로드 가능합니다.").addConstraintViolation();
return false;
}
return true;
}
}
내가 설정하고 싶은 값을 체크하는 로직을 구현한다.
나는 파일 사이즈를 1MB 로 제한하였고,
(아래 어노테이션에서 설정)
파일 객체가 비어있는지 체크를 하였다.
@ProductValidFile 어노테이션
@Documented
@Target(value = ElementType.FIELD)
@Retention(value = RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ProductFileValidator.class)
public @interface ProductValidFile {
String message() default "파일을 선택해주세요.";
long maxSize() default 1048576;
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
maxSize를 1M 로 설정하였다.
여기서 spring boot는 기본적으로
MultipartFile에 대한 크기를 1MB로 제한하고있다.
그래서 내가 구현한 유효성 로직에 도달하기도 전에 에러 페이지를 응답한다.
따라서 applicatioin.yml 에 다음과 같은 설정값을 추가하였다.
spring:
servlet:
multipart:
max-request-size: 10MB
max-file-size: 10MB