사용자가 파일 업로드 시 업로드가 허용된 확장자인지 확인하기 위해 MultipartFile에 Validation 체크를 적용하고자 했다.
물론, 프론트 단에서 간단하게 해결할 수 있었지만 프론트 단에서는 언제든 정보를 변경하여 전달할 수 있기 때문에 서버에서 확인하는 작업이 필요했다.
ConstraintValidator는 자바에서 사용되는 인터페이스로, Bean Validation API의 일부이다. 이 인터페이스는 사용자가 정의한 제약 조건(annotation)에 대한 유효성 검사를 수행하는 데 사용된다.
보통 자바에서 사용되는 객체나 데이터에 대한 유효성 검사는 어노테이션을 사용하여 정의됩니다. 예를 들어, @NotNull, @Size, @Pattern 등이 있다. 이러한 어노테이션들은 객체나 데이터의 제약 조건을 표현한다.
ConstraintValidator는 이러한 어노테이션을 처리하고, 주어진 제약 조건(annotation)에 따라 유효성을 검사하는 구현을 제공한다. 사용자는 ConstraintValidator를 구현하여 어떤 유효성 검사를 수행할 지 정의할 수 있다.
@Getter
@AllArgsConstructor
public enum UploadAllowFileDefine {
MIDI("midi", "audio/midi"),
MID("mid", "audio/mid")
;
private String fileExtensionLowerCase; // 파일 확장자(소문자)
private String allowMimeType; // 허용하는 mime type array(파일 내용 변조 후 확장자 변경하는 공격을 막기 위해서 사용.)
}
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = FileUploadValidator.class)
public @interface FileUploadValid {
String message();
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
UploadAllowFileDefine[] allowFileDefines(); // 업로드 허용 파일들의 정의 array(여러 종류의 파일 타입을 허용할 수도 있기에 array)
boolean required() default true; // 콘텐츠 수정 시 파일은 수정하지 않는 경우 파일 정보가 없기 때문에 validation 체크를 하지 않기 위한 값(false인 경우 체크x)
}
@Slf4j
public class FileUploadValidator implements ConstraintValidator<FileUploadValid, MultipartFile> {
private FileUploadValid annotation;
@Override
public void initialize(FileUploadValid constraintAnnotation) {
this.annotation = constraintAnnotation;
}
@Override
public boolean isValid(MultipartFile multipartFile, ConstraintValidatorContext constraintValidatorContext) {
if (!annotation.required()) {
if (multipartFile.isEmpty()) {
return true;
}
}
if (multipartFile.isEmpty()) {
constraintValidatorContext.buildConstraintViolationWithTemplate("업로드 대상 파일이 없습니다. 정확히 선택 업로드해주세요.").addConstraintViolation();
return false;
}
final String fileName = multipartFile.getOriginalFilename();
if (!StringUtils.hasText(fileName)) {
constraintValidatorContext.buildConstraintViolationWithTemplate("업로드 요청한 파일명이 존재하지 않습니다.").addConstraintViolation();
return false;
}
try {
int targetByte = multipartFile.getBytes().length;
if (targetByte == 0) {
constraintValidatorContext.buildConstraintViolationWithTemplate("파일의 용량이 0 byte입니다.").addConstraintViolation();
return false;
}
} catch (IOException e) {
log.error(e.getMessage(), e);
constraintValidatorContext.buildConstraintViolationWithTemplate("파일의 용량 확인 중 에러가 발생했습니다.").addConstraintViolation();
return false;
}
//허용된 파일 확장자 검사
final String detectedMediaType = this.getMimeTypeByTika(multipartFile); //확장자 변조한 파일인지 확인을 위한 mime type 얻기
final UploadAllowFileDefine[] allowExtArray = annotation.allowFileDefines();
final String fileExt = FilenameUtils.getExtension(fileName);
String[] allowExtLowerCaseArray = Arrays.stream(allowExtArray).map(UploadAllowFileDefine::getFileExtensionLowerCase).toArray(String[]::new);
String[] allowMimeTypes = Arrays.stream(allowExtArray).map(UploadAllowFileDefine::getAllowMimeType).toArray(String[]::new);
//파일명의 허용 확장자 검사
if (!ArrayUtils.contains(allowExtLowerCaseArray, fileExt.toLowerCase())) {
StringBuilder sb = new StringBuilder();
sb.append("허용되지 않는 확장자의 파일이며 다음 확장자들만 허용됩니다.");
sb.append(": ");
sb.append(Arrays.toString(allowExtArray));
constraintValidatorContext.buildConstraintViolationWithTemplate(sb.toString()).addConstraintViolation();
return false;
}
//파일 변조 업로드를 막기위한 mime타입 검사(예. exe파일을 csv로 확장자 변경하는 업로드를 막음)
if (!ArrayUtils.contains(allowMimeTypes, detectedMediaType)) {
StringBuilder sb = new StringBuilder();
sb.append("확장자 변조 파일은 허용되지 않습니다.");
constraintValidatorContext.buildConstraintViolationWithTemplate(sb.toString()).addConstraintViolation();
return false;
}
return true;
}
/**
* apache Tika라이브러리를 이용해서 파일의 mimeType을 가져옴
*
* @param multipartFile
* @return
*/
private String getMimeTypeByTika(MultipartFile multipartFile) {
try {
Tika tika = new Tika();
String mimeType = tika.detect(multipartFile.getInputStream());
log.debug("업로드 요청된 파일 {}의 mimeType:{}", multipartFile.getOriginalFilename(), mimeType);
return mimeType;
} catch (IOException e) {
log.error(e.getMessage(), e);
return null;
}
}
}
public class ItemDto {
...
@FileUploadValid(allowFileDefines = {MIDI, MID}, message = "파일을 다시 등록해주세요.")
MultipartFile mf;
}
@Valid 어노테이션 적용 public ResponseEntity save(@Valid @ModelAttribute ItemDto) {
...
}
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ConstraintViolationException.class)
@ResponseBody
public ResponseEntity<CustomResponse> handleConstraintViolationException(ConstraintViolationException constraintViolationException) {
constraintViolationException.getConstraintViolations().stream().peek(o -> log.error(o.getMessage()));
return new ResponseEntity<>(CustomResponse.builder()
.message("유효하지 않은 입력값입니다.")
.status(HttpStatus.BAD_REQUEST.value()).build() // bad request
, HttpStatus.BAD_REQUEST);
}
}
어노테이션의 의도는 숨어있기 때문에 내부적으로 어떤 동작을 하게 되는지 명확하지 않다면 로직 플로우를 이해하기 어렵다.
하물며 커스텀 어노테이션은 그 부담을 증가시킬 수 있다.
어노테이션의 추가가 당장의 작업 속도을 끌어올릴 순 있지만, 장기적 관점에서 시의적절한 것인지를 공감할 수 있어야 한다.
코드가 간결해진다는 장점 하나만 보고 커스텀 어노테이션을 남용하지 않게 주의해야 한다.
반복적으로 사용하지도 않고, 특정 요청 외에는 사용할 일이 없어 보이는 유효성 검사라면 단순 메서드로 만들어 처리하는 것이 더 좋을 것이다.
https://blog.eomsh.com/202
https://velog.io/@ljks789/spring-ConstraintValidator%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90
https://tecoble.techcourse.co.kr/post/2021-06-21-custom-annotation/