MultipartFile Validation 체크

wjdghks95·2024년 4월 4일

사용자가 파일 업로드 시 업로드가 허용된 확장자인지 확인하기 위해 MultipartFile에 Validation 체크를 적용하고자 했다.
물론, 프론트 단에서 간단하게 해결할 수 있었지만 프론트 단에서는 언제든 정보를 변경하여 전달할 수 있기 때문에 서버에서 확인하는 작업이 필요했다.

ConstraintValidator

ConstraintValidator란?

ConstraintValidator는 자바에서 사용되는 인터페이스로, Bean Validation API의 일부이다. 이 인터페이스는 사용자가 정의한 제약 조건(annotation)에 대한 유효성 검사를 수행하는 데 사용된다.
보통 자바에서 사용되는 객체나 데이터에 대한 유효성 검사는 어노테이션을 사용하여 정의됩니다. 예를 들어, @NotNull, @Size, @Pattern 등이 있다. 이러한 어노테이션들은 객체나 데이터의 제약 조건을 표현한다.
ConstraintValidator는 이러한 어노테이션을 처리하고, 주어진 제약 조건(annotation)에 따라 유효성을 검사하는 구현을 제공한다. 사용자는 ConstraintValidator를 구현하여 어떤 유효성 검사를 수행할 지 정의할 수 있다.

장점

  1. 일관성 있는 처리 방법 - 검증 방법과 검증 시점에 대해 통일성을 가질 수 있다. 사람마다 검증하는 단계가 컨트롤러가 될 수도 있고 서비스 레이어가 될 수도 있는데 이를 validation을 사용함으로써 controller 진입 전 interceptor 단계에서 검증을 함으로써 일관성을 가질 수 있다는 의미이다.
  2. 일관성 있는 ErrorResponse - 에러가 발생하면 ConstraintValidationException이 발생한다. 이를 통해 에러처리의 응답을 일관성 있게 리턴할 수 있다.

구현

  1. 업로드 허용 파일들에 대해 정의할 enum 작성
@Getter
@AllArgsConstructor
public enum UploadAllowFileDefine {
	MIDI("midi", "audio/midi"),
	MID("mid", "audio/mid")
	;

	private String fileExtensionLowerCase; // 파일 확장자(소문자)
	private String allowMimeType;  // 허용하는 mime type array(파일 내용 변조 후 확장자 변경하는 공격을 막기 위해서 사용.)
}
  1. 어노테이션을 생성한다.
@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)
}
  1. Validator 구현체 작성
@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;
		}
	}
}
  1. 검증하고자 하는 MultipartFile에 어노테이션 적용
public class ItemDto {
	...
    @FileUploadValid(allowFileDefines = {MIDI, MID}, message = "파일을 다시 등록해주세요.")
	MultipartFile mf;
}
  1. Validation 체크를 진행할 Dto에 @Valid 어노테이션 적용
 public ResponseEntity save(@Valid @ModelAttribute ItemDto) {
 	...
 }
  1. 공통 에러 처리
@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/

0개의 댓글