파일 확장자 체크 with custom validator
업로드 파일의 타입을 체크하는 로직을 커스텀 밸리데이터로 구현한다.
- 파일업로드는 Multipart 업로드를 사용한다.
- 요청 DTO 클래스의 컬럼에 어노테이션을 적용하는 방식으로 제약사항을 설정한다.
- 허가되는 확장자는 신규 어노테이션에 문자열 목록으로 설정한다.
- 어노테이션 생성
FileExtension.kt @Target(AnnotationTarget.FIELD)@Retention(AnnotationRetention.RUNTIME)@Constraint(validatedBy = [FileExtensionValidator::class])annotation class FileExtension (val message: String = "확장자가 일치하지 않습니다.",val groups: Array<KClass<*>> = [],val payload: Array<KClass<out Payload>> = [],val allowedExtension: Array<String> = [])FileExtension.java @Target(ElementType.FIELD)@Retention(RetentionPolicy.RUNTIME)@Constraint(validatedBy = FileExtensionValidator.class)public @interface FileExtension {String message() default "확장자가 일치하지 않습니다.";Class<?>[] groups() default {};Class<? extends Payload>[] payload() default {};String[] allowedExtension() default {};} - ConstraintValidator 구현제 생성
FileExtension.kt class FileExtensionValidator : ConstraintValidator<FileExtension, MultipartFile> {private lateinit var allowedExtension: List<String>override fun initialize(constraintAnnotation: FileExtension) {allowedExtension = constraintAnnotation.allowedExtension.map { it.lowercase(Locale.ROOT) }.toList()}override fun isValid(file: MultipartFile, context: ConstraintValidatorContext): Boolean {//필수입력 여부는 별도의 NotNull 어노테이션으로 제약을 설정한다.if (file.isEmpty) {return true}val extension: String = getExtension(file.originalFilename).lowercase(Locale.ROOT)return allowedExtension.contains(extension)}}FileExtensionValidator.java class FileExtensionValidator implements ConstraintValidator<FileExtension, MultipartFile> {private Set<String> allowedExtensions;@Overridepublic void initialize(final FileExtension constraintAnnotation) {allowedExtensions = Arrays.stream(constraintAnnotation.allowedExtension()).filter(s -> s != null && !s.isBlank()).map(s -> s.trim().toLowerCase(Locale.ROOT)).collect(Collectors.toUnmodifiableSet());}@Overridepublic boolean isValid(final MultipartFile multipartFile, final ConstraintValidatorContext constraintValidatorContext) {if (multipartFile == null || multipartFile.isEmpty()) {return true;}final String originalFilename = multipartFile.getOriginalFilename();final String extension = getExtension(originalFilename);if (extension.isBlank()) {return false;}return allowedExtensions.contains(extension.trim().toLowerCase(Locale.ROOT));}} - 요청 DTO 클래스의 멤버에 제약 설정
any request dto class @field:Schema(description = "사진파일", example = "사진파일")@field:NotNull@field:FileExtension(allowedExtension = ["GIF", "JPEG", "JPG", "PNG"])var photo: MultipartFile,any request dto class @Schema(description = "사진파일", example = "사진파일")@NotNull@FileExtension(allowedExtension = {"GIF", "JPEG", "JPG", "PNG"})private MultipartFile photo; - Controller 메소드에 Valid(Validated) 어노테이션 추가
any controller class @Operation(summary = "사진게시판 등록", description = "사진게시판 등록 API")@PostMappingfun insertPhotoBoard(@Valid @ModelAttribute param: InsertPhotoBoardRequest): ResponseEntity<ResponseDto<Unit>> {log.info("insertPhotoBoard param: $param")val model = param.toModel()photoBoardService.insertPhotoBoard(model)return ResponseEntity.status(HttpStatus.CREATED).body((ResponseDto(Unit)))}any controller class @Operation(summary = "사진게시판 등록", description = "사진게시판 등록 API")@PostMappingpublic @ResponseBody ResponseEntity<ResponseDto<Void>> insertPhotoBoard(@Valid @ModelAttribute final InsertPhotoBoardRequest param) {log.info("insertPhotoBoard param: {}", param);final PhotoBoard model = param.toModel();photoBoardService.insertPhotoBoard(model);return ResponseEntity.status(HttpStatus.CREATED).body((new ResponseDto<>()));} - Validation 오류가 발생하면 MethodArgumentNotValidException 예외가 던져지는데, 이 예외가 발생시 405 Bad Request 오류가 발생하도록 설정
RestApiExceptionAdvice.kt @ExceptionHandler(MethodArgumentNotValidException::class)@ResponseBodyfun methodArgumentNotValidExceptionHandler(e: MethodArgumentNotValidException): ResponseEntity<ResponseDto<Unit>> {log.error("### MethodArgumentNotValidException occurred", e)val anyItem = e.bindingResult.fieldErrors[0]return ResponseEntity.badRequest().body(ResponseDto(ApiResultCode.WRONG_PARAMETER, String.format("%s (%s)", anyItem.defaultMessage, anyItem.field)))}RestApiExceptionAdvice.java @ExceptionHandler(MethodArgumentNotValidException.class)public @ResponseBody ResponseEntity<ResponseDto<Void>> methodArgumentNotValidExceptionHandler(final MethodArgumentNotValidException e) {log.error("### MethodArgumentNotValidException Occurred. ID: [{}]", ServletUtil.getRequestAttribute(ServletUtil.X_REQUEST_ID), e);return ResponseEntity.badRequest().contentType(MediaType.APPLICATION_JSON).body(new ResponseDto<>(ApiResultCode.INVALID_PARAMETER));} - 테스트
any test class @DisplayName("신규 등록시 유효성 체크 - 첨부파일 실패 케이스")@Testfun insertValidationTest() {return Given {spec(multipartRequestSpecification)formParam("title", "test")formParam("content", "test")multiPart("photo", "sample1.webp", readResourceFileAsBytes("sample/image/sample1.webp"), "image/webp")} When {post("/photo-board")} Then {statusCode(HttpStatus.SC_BAD_REQUEST)} Extract {println(body().asString())}}any test class @DisplayName("신규 등록시 유효성 체크 - 첨부파일 실패 케이스")@Testvoid insertValidationTest() {given().spec(multipartRequestSpecification).formParam("title", "test").formParam("content", "test").multiPart("photo", "sample1.webp", FileUtil.readClasspathFile("sample/image/sample1.webp"), "image/webp").when().post("/photo-board").then().statusCode(HttpStatus.SC_BAD_REQUEST).log().all();} - 로그 확인
2026-02-11 14:25:27,749 [ERROR] [http-nio-auto-1-exec-2] b.m.c.c.e.a.RestApiExceptionAdvice: ### MethodArgumentNotValidException occurredorg.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public org.springframework.http.ResponseEntity<biz.mintchoco.carrot.common.dto.ResponseDto<kotlin.Unit>> biz.mintchoco.carrot.api.photo_board.controller.PhotoBoardController.insertPhotoBoard(biz.mintchoco.carrot.api.photo_board.dto.InsertPhotoBoardRequest): [Field error in object 'insertPhotoBoardRequest' on field 'photo': rejected value [org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@697db0a3]; codes [FileExtension.insertPhotoBoardRequest.photo,FileExtension.photo,FileExtension.org.springframework.web.multipart.MultipartFile,FileExtension]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [insertPhotoBoardRequest.photo,photo]; arguments []; default message [photo],[Ljava.lang.String;@3115bef]; default message [확장자가 일치하지 않습니다.]]at org.springframework.web.method.annotation.ModelAttributeMethodProcessor.resolveArgument(ModelAttributeMethodProcessor.java:158)...at java.base/java.lang.Thread.run(Thread.java:1583){"payload":null,"isSuccess":false,"resultCode":"WRONG_PARAMETER","message":"확장자가 일치하지 않습니다. (photo)"}2026-02-13 13:54:39,821 [ERROR] [http-nio-auto-1-exec-1] c.e.s.c.e.a.RestApiExceptionAdvice: ### MethodArgumentNotValidException Occurred. ID: [519708]org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public org.springframework.http.ResponseEntity<com.example.sihelperbe.common.dto.ResponseDto<java.lang.Void>> com.example.sihelperbe.api.photo_board.controller.PhotoBoardController.insertPhotoBoard(com.example.sihelperbe.api.photo_board.dto.InsertPhotoBoardRequest): [Field error in object 'insertPhotoBoardRequest' on field 'photo': rejected value [org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@6797b82b]; codes [FileExtension.insertPhotoBoardRequest.photo,FileExtension.photo,FileExtension.org.springframework.web.multipart.MultipartFile,FileExtension]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [insertPhotoBoardRequest.photo,photo]; arguments []; default message [photo],[Ljava.lang.String;@483ac8cd]; default message [확장자가 일치하지 않습니다.]]at org.springframework.web.method.annotation.ModelAttributeMethodProcessor.resolveArgument(ModelAttributeMethodProcessor.java:158)at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240)...at java.base/java.lang.Thread.run(Thread.java:1583)2026-02-13 13:54:39,883 [INFO ] [http-nio-auto-1-exec-1] c.e.s.c.u.LogGenerator: ID: [519708] Response Status: [400] duration: [194] Body: [{"payload":null,"successYn":"N","messageCd":"INVALID_PARAMETER"}]HTTP/1.1 400Vary: OriginVary: Access-Control-Request-MethodVary: Access-Control-Request-HeadersX-Request-ID: 519708X-Content-Type-Options: nosniffX-XSS-Protection: 0Cache-Control: no-cache, no-store, max-age=0, must-revalidatePragma: no-cacheExpires: 0X-Frame-Options: DENYContent-Type: application/jsonTransfer-Encoding: chunkedDate: Fri, 13 Feb 2026 04:54:39 GMTConnection: close{"payload": null,"successYn": "N","messageCd": "INVALID_PARAMETER"}