파일 업로드 유효성 검사 구현과 예외 처리 
Connectrip에서 프로필 이미지 업로드 기능을 구현하던 중 예상치 못한 JSON 직렬화 예외에 직면했다. Spring Boot의 기본 Validation으로는 해결할 수 없는 MultipartFile의 특성과, 특정 상황에서 서버가 응답을 전달하지 못하고 연결을 끊어버리는 심각한 문제였다.
이 경험을 통해 파일 업로드 유효성 검사의 함정과 해결책을 정리했다.
기본 유효성 검사의 한계 
표준 Validation의 예상치 못한 제약 
프로필 이미지 업로드 기능을 개발하면서 3가지 필수 검증 로직이 필요했다.
- 필수 파일 존재 여부 검증: 사용자가 반드시 프로필 이미지를 설정해야 하는 경우
 - 파일 확장자 제한: 보안상 이미지 파일만 허용해야 하는 경우
 - 파일 크기 제한: 서버 저장소 용량과 성능을 고려한 대용량 파일 차단
 
처음에는 기존에 쓰던 @NotNull, @Size 같은 표준 어노테이션으로 간단히 해결될 줄 알았지만, MultipartFile에는 이런 어노테이션들이 전혀 먹히지 않았다.
빈 파일의 함정 발견 
public class TestRequest {
    @NotNull
    private final MultipartFile file;
    public TestRequest(MultipartFile file) {
        this.file = file;
    }
}테스트 중 프론트엔드에서 image 필드는 전송하지만 실제 파일을 선택하지 않은 경우 다음과 같은 상황이 발생했다.
file: org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@생략
file name: [공백]MultipartFile 객체는 생성되었지만 실제 파일은 없는 상태였다. @NotNull 검증은 통과하지만 file.isEmpty()는 true를 반환하는 예상하지 못한 엣지 케이스를 마주했다.
직접 해결한 커스텀 검증 
표준 방법으로는 해결이 안 되어서 직접 MultipartFile 전용 검증 어노테이션을 만들어야 했다.
빈 파일 검증의 필요성 
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NotEmptyFileConstraintValidator.class)
public @interface NotEmptyFile {
    String message() default "must not be null or empty";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}public class NotEmptyFileConstraintValidator implements ConstraintValidator<NotEmptyFile, MultipartFile> {
    @Override
    public boolean isValid(MultipartFile multipartFile, ConstraintValidatorContext context) {
        return multipartFile != null && !multipartFile.isEmpty();
    }
}확장자 검증으로 보안 강화 
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = FileExtensionsConstraintValidator.class)
public @interface FileExtensions {
    String message() default "must be an allowed extension";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    String[] allowed() default {"jpg", "jpeg", "png", "gif"};
}public class FileExtensionsConstraintValidator implements ConstraintValidator<FileExtensions, MultipartFile> {
    private String[] allowedExtensions;
    @Override
    public void initialize(FileExtensions constraintAnnotation) {
        allowedExtensions = Arrays.stream(constraintAnnotation.allowed())
                .map(String::toLowerCase)
                .toArray(String[]::new);
    }
    @Override
    public boolean isValid(MultipartFile multipartFile, ConstraintValidatorContext context) {
        // 파일이 없는 경우는 통과 (선택적 파일 업로드)
        if (multipartFile == null || multipartFile.isEmpty()) {
            return true;
        }
        String fileName = multipartFile.getOriginalFilename();
        if (fileName == null || fileName.isBlank()) {
            return false;
        }
        int lastDotIndex = fileName.lastIndexOf(".");
        if (lastDotIndex == -1 || lastDotIndex == fileName.length() - 1) {
            return false; // 확장자 없음
        }
        String extension = fileName.substring(lastDotIndex + 1).toLowerCase();
        return Arrays.asList(allowedExtensions).contains(extension);
    }
}실제 프로젝트 적용 예시 
필수 프로필 이미지
public record SignUpRequest(
    @NotBlank @Size(max = 50) String username,
    @NotEmptyFile
    @FileExtensions(allowed = {"jpg", "jpeg", "png"})
    MultipartFile profileImage
) {}선택적 게시글 이미지
public record CreatePostRequest(
    @NotBlank @Size(max = 200) String title,
    @NotBlank @Size(max = 2000) String content,
    @FileExtensions(allowed = {"jpg", "jpeg", "png", "gif"})
    MultipartFile image // 필수가 아님
) {}예상치 못한 JSON 직렬화 함정 
클라이언트가 아무 응답도 받지 못하는 버그 
커스텀 검증 어노테이션을 만들어서 문제가 해결됐다고 생각했는데, QA 테스트 중에 더 심각한 문제를 발견했다. 파일 업로드 API에서 image Key는 전송하지만 빈 파일을 보내는 경우, 서버에서 다음과 같은 예외가 발생했다.
com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
No serializer found for class java.io.ByteArrayInputStream and no properties discovered to create BeanSerializer문제는 유효성 검사 자체에서 발생한 게 아니라, 검증 실패 시 에러 응답을 만드는 과정에서 터진 예외였다. 클라이언트는 400 Bad Request 응답을 받아야 하는데, 서버에서 500 에러가 발생해 연결이 끊어져버렸다.
원인 분석: Jackson의 직렬화 한계 
디버깅해보니 Spring Boot가 MethodArgumentNotValidException를 처리할 때 예외 타입과 메시지, 유효성 검사 실패 필드별 상세 정보를 JSON으로 만들려고 시도했다. 여기서 rejectedValue(실패한 값 자체)도 함께 포함되는데, 이게 문제였다.
rejectedValue에 MultipartFile 객체 전체가 들어가는데, MultipartFile은 내부적으로 ByteArrayInputStream 같은 직렬화 불가능한 객체를 포함하고 있어서 Jackson이 JSON으로 변환할 수 없었던 것 같다.
직접 구현한 예외 처리 로직 
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult()
            .getAllErrors()
            .stream()
            .map(DefaultMessageSourceResolvable::getDefaultMessage)
            .collect(Collectors.toList());
        return ResponseEntity.badRequest()
            .body(new ErrorResponse("유효성 검사 실패", errors));
    }
}더 상세한 파일 에러 정보 제공 
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleFieldValidationException(
        MethodArgumentNotValidException ex) {
    Map<String, String> errors = new HashMap<>();
    ex.getBindingResult().getFieldErrors().forEach(error -> {
        String fieldName = error.getField();
        String errorMessage = error.getDefaultMessage();
        // `MultipartFile` 필드는 특별 처리
        if (error.getRejectedValue() instanceof MultipartFile) {
            MultipartFile file = (MultipartFile) error.getRejectedValue();
            errorMessage = String.format("%s (파일명: %s)",
                errorMessage,
                file.getOriginalFilename());
        }
        errors.put(fieldName, errorMessage);
    });
    return ResponseEntity.badRequest().body(errors);
}추가로 구현한 보안 검증 
기본적인 파일 검증만으로는 부족하다고 판단해서 보안을 위한 추가 검증도 구현했다.
대용량 파일 업로드 차단 
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = FileSizeConstraintValidator.class)
public @interface FileSize {
    String message() default "file size exceeds maximum allowed size";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    long maxBytes() default 10 * 1024 * 1024; // 10MB
}MIME 타입 검증 
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ContentTypeConstraintValidator.class)
public @interface ContentType {
    String message() default "invalid content type";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    String[] allowed() default {"image/jpeg", "image/png", "image/gif"};
}파일 위조 방지를 위한 내용 검증 
public class FileSecurityValidator {
    public boolean isValidImageFile(MultipartFile file) {
        try {
            BufferedImage image = ImageIO.read(file.getInputStream());
            return image != null;
        } catch (IOException e) {
            return false;
        }
    }
}생각 
간단할 줄 알았던 파일 업로드 검증이 생각보다 복잡했다. Spring Boot의 표준 Validation만으로는 MultipartFile의 특수한 상황들을 모두 해결할 수 없어서 직접 커스텀 어노테이션을 만들어야 했다.
특히 JSON 직렬화 예외 문제는 정말 예상치 못한 함정이었다. 유효성 검사 자체는 잘 동작하는데, 에러 응답을 만드는 과정에서 서버가 터져버리니 클라이언트 입장에서는 뭐가 잘못된 건지 전혀 알 수 없는 상황이 되었다.
결국 이 문제를 해결하기 위해 꽤 많은 시간을 투자했지만, 얻은 건 컸다. @NotEmptyFile, @FileExtensions 같은 커스텀 어노테이션으로 복잡한 파일 검증 로직을 깔끔하게 표현할 수 있게 됐고, 프로젝트 어디서든 동일한 검증 정책을 적용할 수 있었다. 무엇보다 JSON 직렬화 문제로 서버가 다운되는 일은 없앴다.
지금 돌이켜보면 Connectrip 같은 소규모 서비스에서는 서버 업로드 방식이 적합했지만, 사용자가 급격히 늘어난다면 서버 대역폭 비용 때문에 Pre-signed URL 방식으로 바꿔야 할 것 같다. 하지만 그때는 파일 검증 로직도 다시 설계해야 할 거다. 클라이언트 검증은 우회 가능하니까 결국 서버에서 업로드된 파일을 다시 다운로드해서 검증해야 하는 아이러니한 상황이 생긴다.
파일 업로드는 처음엔 간단해 보이지만 확장자 위조, 대용량 파일 공격, MIME 타입 조작 등 고려할 게 많다. MultipartFile의 특수한 특성 때문에 일반적인 검증 방식도 통하지 않는다. 하지만 한 번 제대로 구축해두면 이후 파일 관련 버그를 크게 줄일 수 있다.
서비스가 커진다면? 
지금까지 구현한 파일 검증 시스템은 소규모 서비스에는 충분하지만, 사용자가 늘어나면 추가로 고려해야 할 것들이 있다.
파일 저장소 분리 
현재는 서버 로컬 파일시스템에 저장하고 있는데, 사용자가 많아지면 저장 공간과 백업 문제가 생길 것 같다. 이때는 AWS S3나 Google Cloud Storage 같은 클라우드 스토리지로 분리하는 게 좋겠다.
@Service
public class FileUploadService {
    private final AmazonS3 s3Client;
    public String uploadFile(MultipartFile file) {
        String fileName = generateUniqueFileName(file.getOriginalFilename());
        try {
            s3Client.putObject(new PutObjectRequest(
                bucketName,
                fileName,
                file.getInputStream(),
                createObjectMetadata(file)
            ));
            return s3Client.getUrl(bucketName, fileName).toString();
        } catch (IOException e) {
            throw new FileUploadException("파일 업로드 실패", e);
        }
    }
}클라이언트 직접 업로드 (Pre-signed URL) 
찾아보니 클라이언트가 직접 스토리지에 업로드하는 방식이 대부분 큰 기업에서 사용하는 방법이라고 한다.
@Service
public class PresignedUploadService {
    private final AmazonS3 s3Client;
    public PresignedUrlResponse generateUploadUrl(String fileName) {
        String key = generateUniqueKey(fileName);
        // 15분간 유효한 업로드용 Pre-signed URL 생성
        Date expiration = new Date();
        expiration.setTime(expiration.getTime() + 1000 * 60 * 15);
        GeneratePresignedUrlRequest generatePresignedUrlRequest =
            new GeneratePresignedUrlRequest(bucketName, key)
                .withMethod(HttpMethod.PUT)
                .withExpiration(expiration);
        URL presignedUrl = s3Client.generatePresignedUrl(generatePresignedUrlRequest);
        return new PresignedUrlResponse(presignedUrl.toString(), key);
    }
}클라이언트는 받은 URL로 직접 업로드하고, 완료되면 서버에 업로드 완료를 알린다.
// 1. 클라이언트에서 기본 검증 (확장자, 크기)
if (!isValidFileType(file) || file.size > MAX_FILE_SIZE) {
  alert("파일 형식이나 크기를 확인해주세요");
  return;
}
// 2. 서버에서 Pre-signed URL 받아오기
const response = await fetch("/api/upload-url", {
  method: "POST",
  body: JSON.stringify({
    fileName: file.name,
    fileSize: file.size,
    contentType: file.type,
  }),
});
const { uploadUrl, key } = await response.json();
// 3. S3에 직접 업로드
await fetch(uploadUrl, {
  method: "PUT",
  body: file,
  headers: { "Content-Type": file.type },
});
// 4. 서버에 업로드 완료 알림 (서버에서 추가 검증)
await fetch("/api/upload-complete", {
  method: "POST",
  body: JSON.stringify({ key: key }),
});이 방식의 장점은 서버 대역폭 절약과 업로드 속도 향상이다. 하지만 파일 검증은 클라이언트와 서버 양쪽에서 해야 할 것 같다. 클라이언트에서는 기본적인 파일 크기와 확장자 검증으로 사용자 경험을 향상시키고, 서버에서는 S3 Lambda 트리거나 업로드 완료 콜백에서 실제 파일 내용을 검증하는 방식이다.
파일 검증 성능 최적화 
파일 개수가 많아지면 각 파일마다 ImageIO.read()로 실제 이미지인지 검증하는 과정이 부담스러워질 것 같다. 이때는 파일 헤더만 빠르게 체크하는 방식으로 바꾸는 게 좋겠다.
public class OptimizedFileValidator {
    private static final Map<String, byte[]> IMAGE_SIGNATURES = Map.of(
        "JPEG", new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF},
        "PNG", new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47},
        "GIF", new byte[]{0x47, 0x49, 0x46}
    );
    public boolean isValidImageByHeader(MultipartFile file) {
        try {
            byte[] header = new byte[8];
            file.getInputStream().read(header);
            return IMAGE_SIGNATURES.values().stream()
                .anyMatch(signature -> Arrays.mismatch(header, 0, signature.length,
                                                      signature, 0, signature.length) == -1);
        } catch (IOException e) {
            return false;
        }
    }
}CDN과 캐싱 전략 
업로드된 이미지를 자주 조회하게 되면 서버 부하가 커질 텐데, 이때는 CloudFront나 CloudFlare 같은 CDN을 도입하는 게 좋겠다.
파일 용량 제한의 현실적 조정 
사용자가 늘어나면 "10MB까지만 업로드 가능"이라는 제한이 빡빡할 수도 있겠다. 하지만 무작정 늘리면 서버 비용이 급증할 테니까, 사용자 등급별 차등 제한이나 압축 알고리즘 도입을 고려해볼 수 있겠다.
결국 소규모일 때는 단순하고 확실한 검증에 집중하고, 규모가 커지면 성능과 비용을 고려한 아키텍처로 점진적 발전시켜 나가는 게 현실적인 것 같다. 처음부터 복잡한 시스템을 만들 필요는 없지만, 확장 가능성은 염두에 두고 설계하는 게 좋겠다.
