Skip to content

파일 업로드 유효성 검사 구현과 예외 처리

Connectrip에서 프로필 이미지 업로드 기능을 구현하던 중 예상치 못한 JSON 직렬화 예외에 직면했다. Spring Boot의 기본 Validation으로는 해결할 수 없는 MultipartFile의 특성과, 특정 상황에서 서버가 응답을 전달하지 못하고 연결을 끊어버리는 심각한 문제였다.

이 경험을 통해 파일 업로드 유효성 검사의 함정과 해결책을 정리했다.

기본 유효성 검사의 한계

표준 Validation의 예상치 못한 제약

프로필 이미지 업로드 기능을 개발하면서 3가지 필수 검증 로직이 필요했다.

  1. 필수 파일 존재 여부 검증: 사용자가 반드시 프로필 이미지를 설정해야 하는 경우
  2. 파일 확장자 제한: 보안상 이미지 파일만 허용해야 하는 경우
  3. 파일 크기 제한: 서버 저장소 용량과 성능을 고려한 대용량 파일 차단

처음에는 기존에 쓰던 @NotNull, @Size 같은 표준 어노테이션으로 간단히 해결될 줄 알았지만, MultipartFile에는 이런 어노테이션들이 전혀 먹히지 않았다.

빈 파일의 함정 발견

java
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 전용 검증 어노테이션을 만들어야 했다.

빈 파일 검증의 필요성

java
@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 {};
}
java
public class NotEmptyFileConstraintValidator implements ConstraintValidator<NotEmptyFile, MultipartFile> {

    @Override
    public boolean isValid(MultipartFile multipartFile, ConstraintValidatorContext context) {
        return multipartFile != null && !multipartFile.isEmpty();
    }
}

확장자 검증으로 보안 강화

java
@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"};
}
java
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);
    }
}

실제 프로젝트 적용 예시

필수 프로필 이미지

java
public record SignUpRequest(
    @NotBlank @Size(max = 50) String username,
    @NotEmptyFile
    @FileExtensions(allowed = {"jpg", "jpeg", "png"})
    MultipartFile profileImage
) {}

선택적 게시글 이미지

java
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 BootMethodArgumentNotValidException를 처리할 때 예외 타입과 메시지, 유효성 검사 실패 필드별 상세 정보를 JSON으로 만들려고 시도했다. 여기서 rejectedValue(실패한 값 자체)도 함께 포함되는데, 이게 문제였다.

rejectedValueMultipartFile 객체 전체가 들어가는데, MultipartFile은 내부적으로 ByteArrayInputStream 같은 직렬화 불가능한 객체를 포함하고 있어서 JacksonJSON으로 변환할 수 없었던 것 같다.

직접 구현한 예외 처리 로직

java
@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));
    }
}

더 상세한 파일 에러 정보 제공

java
@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);
}

추가로 구현한 보안 검증

기본적인 파일 검증만으로는 부족하다고 판단해서 보안을 위한 추가 검증도 구현했다.

대용량 파일 업로드 차단

java
@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 타입 검증

java
@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"};
}

파일 위조 방지를 위한 내용 검증

java
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 S3Google Cloud Storage 같은 클라우드 스토리지로 분리하는 게 좋겠다.

java
@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)

찾아보니 클라이언트가 직접 스토리지에 업로드하는 방식이 대부분 큰 기업에서 사용하는 방법이라고 한다.

java
@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로 직접 업로드하고, 완료되면 서버에 업로드 완료를 알린다.

js
// 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()로 실제 이미지인지 검증하는 과정이 부담스러워질 것 같다. 이때는 파일 헤더만 빠르게 체크하는 방식으로 바꾸는 게 좋겠다.

java
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과 캐싱 전략

업로드된 이미지를 자주 조회하게 되면 서버 부하가 커질 텐데, 이때는 CloudFrontCloudFlare 같은 CDN을 도입하는 게 좋겠다.

파일 용량 제한의 현실적 조정

사용자가 늘어나면 "10MB까지만 업로드 가능"이라는 제한이 빡빡할 수도 있겠다. 하지만 무작정 늘리면 서버 비용이 급증할 테니까, 사용자 등급별 차등 제한이나 압축 알고리즘 도입을 고려해볼 수 있겠다.

결국 소규모일 때는 단순하고 확실한 검증에 집중하고, 규모가 커지면 성능과 비용을 고려한 아키텍처로 점진적 발전시켜 나가는 게 현실적인 것 같다. 처음부터 복잡한 시스템을 만들 필요는 없지만, 확장 가능성은 염두에 두고 설계하는 게 좋겠다.

Released under the MIT License.