파일 업로드 유효성 검사 구현과 예외 처리
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까지만 업로드 가능"이라는 제한이 빡빡할 수도 있겠다. 하지만 무작정 늘리면 서버 비용이 급증할 테니까, 사용자 등급별 차등 제한이나 압축 알고리즘 도입을 고려해볼 수 있겠다.
결국 소규모일 때는 단순하고 확실한 검증에 집중하고, 규모가 커지면 성능과 비용을 고려한 아키텍처로 점진적 발전시켜 나가는 게 현실적인 것 같다. 처음부터 복잡한 시스템을 만들 필요는 없지만, 확장 가능성은 염두에 두고 설계하는 게 좋겠다.