5MB 넘는 파일을 막는 자리 — 업로드 크기·타입 가드
프런트의 '5MB 이하' 검증은 신뢰할 수 없다. 컨테이너·프레임워크 한도와 애플리케이션 검증의 역할 분담, 확장자가 아닌 실제 타입을 보는 다층 방어를 정리한다.
그 주엔 업로드 입력에 5MB 한도와 빈 값·타입 검증을 붙이는 작업을 했다. 화면에서 이미 “5MB 이하만 됩니다”라고 막고 있었지만, 그 검증은 사용자의 브라우저 안에서만 도는 코드다. 누군가 개발자 도구로 우회하거나 curl로 직접 요청을 던지면 그대로 뚫린다. 프런트의 검증은 UX고, 서버의 검증이 보안이다. 업로드는 이 원칙이 가장 노골적으로 드러나는 자리다.
검증은 한 군데가 아니다 — 다층 방어
업로드 한도는 책임이 서로 다른 세 계층에 걸쳐 있다.
flowchart LR
A[클라이언트<br/>UX 검증] --> B[컨테이너/WAS<br/>multipart 한도]
B --> C[프레임워크<br/>MultipartResolver]
C --> D[애플리케이션<br/>비즈니스 검증]
- 컨테이너(WAS) 한도 — 톰캣 같은 서블릿 컨테이너는
maxPostSize,maxSwallowSize등으로 요청 바디 자체의 크기를 끊는다. 이 한도를 넘으면 애플리케이션 코드가 돌기도 전에 연결 단계에서 거부된다. - 프레임워크 한도 — 스프링의
MultipartResolver는max-file-size(파일 하나),max-request-size(요청 전체) 두 값을 본다. 멀티파트로 여러 파일을 한 번에 올리면 둘이 다르게 동작한다. - 애플리케이션 검증 — 빈 파일인지, 확장자·MIME이 허용 목록에 드는지, 비즈니스적으로 허용되는지는 우리 코드가 본다.
핵심은 각 계층이 막는 대상이 다르다는 것이다. 컨테이너·프레임워크 한도는 “거대한 바디로 메모리·디스크를 고갈시키는 공격”을 입구에서 끊기 위한 것이고, 애플리케이션 검증은 “허용되지 않은 파일”을 거르기 위한 것이다. 프레임워크 한도를 넉넉히 잡고 애플리케이션에서만 5MB를 본다면, 8MB 파일이 일단 다 업로드된 뒤에 거부된다 — 대역폭과 임시 저장 공간을 이미 다 쓴 뒤다. 한도는 가능한 한 바깥 계층에서 끊어라.
확장자를 믿지 마라
photo.jpg라는 이름은 아무 의미가 없다. 파일명은 사용자가 마음대로 짓는 문자열이고, Content-Type 헤더 역시 클라이언트가 보내는 값이라 위조 가능하다. 실제 파일 형식을 알려면 파일의 첫 바이트(매직 넘버)를 봐야 한다. PNG는 89 50 4E 47, JPEG는 FF D8 FF로 시작한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void validate(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new UploadException("파일이 비어 있습니다.");
}
if (file.getSize() > 5 * 1024 * 1024) {
throw new UploadException("파일은 5MB를 넘을 수 없습니다.");
}
// 확장자 화이트리스트 (블랙리스트 금지 — 새 위험 확장자를 못 막는다)
String ext = getExtension(file.getOriginalFilename());
if (!Set.of("png", "jpg", "jpeg").contains(ext)) {
throw new UploadException("허용되지 않은 확장자입니다.");
}
// 실제 내용으로 타입 판별 (헤더/확장자 위조 방어)
String detected = detectByMagicNumber(file.getInputStream());
if (!Set.of("image/png", "image/jpeg").contains(detected)) {
throw new UploadException("파일 내용이 이미지가 아닙니다.");
}
}
검증 순서도 의미가 있다. 싼 검증을 먼저 한다. 빈 값·크기 확인은 메타데이터만 보면 되니 가장 싸고, 스트림을 읽어 매직 넘버를 보는 건 가장 비싸다. 거를 수 있는 건 일찍 걸러라.
운영 함정
한도 초과 시 응답이 추하다. 프레임워크 한도를 넘으면 MaxUploadSizeExceededException이 컨트롤러 진입 전에 터진다. 이 예외를 잡지 않으면 사용자는 의미 없는 500 에러 페이지를 본다. @ControllerAdvice로 이 예외를 받아 “5MB를 넘습니다”라는 명확한 메시지와 적절한 상태 코드(413 Payload Too Large)로 변환해야 한다.
컨테이너 한도와 프레임워크 한도가 어긋난다. 프레임워크에 max-file-size=10MB를 줬는데 톰캣 maxPostSize가 2MB라면, 프레임워크 한도는 무의미하고 사용자는 항상 컨테이너 단계에서 끊긴 정체불명의 에러를 본다. 두 값을 일관되게 설정하고, 애플리케이션 한도(5MB) < 프레임워크 한도 ≤ 컨테이너 한도 순으로 여유를 둬라.
핵심 요약
- 프런트 검증은 UX, 서버 검증이 방어선. 둘 다 필요하다.
- 한도는 바깥 계층(컨테이너·프레임워크)에서 끊어 자원 낭비를 막고, 애플리케이션은 비즈니스 규칙을 본다.
- 확장자·
Content-Type은 위조 가능하다. 매직 넘버로 실제 타입을 확인하라. - 화이트리스트로 막고, 한도 초과 예외는 413으로 친절하게 변환하라.
면접 한 줄 Q&A Q. 클라이언트가 5MB로 막는데 서버에서 또 봐야 하나? A. 클라이언트 검증은 우회 가능하므로 신뢰 경계가 아니다. 서버 검증이 유일한 방어선이고, 가능하면 컨테이너 한도로 입구에서 거대한 바디를 끊는다.