핵심 요약
웹 파일 업로드 실패를 HTTP 상태 코드와 요청이 끊긴 계층으로 나누고, FormData boundary, 프록시·웹 서버·PHP 용량 제한, 임시 저장소와 보안 검증을 순서대로 확인합니다.
검수 범위
브라우저에서 FormData로 파일을 보내고 프록시 또는 웹 서버를 거쳐 애플리케이션이 저장하는 일반적인 웹앱을 대상으로 합니다. 클라우드 스토리지 직접 업로드와 대용량 분할 업로드는 설계가 다르므로 여기서는 진단 기준만 다룹니다.
파일 업로드 오류는 화면에 “업로드 실패” 한 줄만 보여도 원인은 서로 다릅니다. 브라우저가 multipart 요청을 잘못 만들었을 수도 있고, CDN·프록시·웹 서버가 애플리케이션에 도달하기 전에 요청을 거절했을 수도 있습니다. PHP나 프레임워크가 파일을 받았지만 임시 디렉터리 또는 저장소에서 실패하는 경우도 있습니다. 해결의 핵심은 용량 값을 무작정 키우는 것이 아니라 요청이 어느 계층까지 도착했는지 확인하는 것입니다.
증상으로 첫 점검 위치 찾기
| 관찰한 증상 | 가능성이 높은 지점 | 먼저 볼 증거 |
|---|---|---|
| HTTP 413 응답 | CDN·프록시·웹 서버의 요청 본문 제한 | 애플리케이션 로그에 요청이 들어왔는지 확인 |
| 400 응답과 파일 필드 없음 | multipart boundary 또는 필드 이름 불일치 | 브라우저 Network의 Content-Type과 요청 payload |
| 작은 파일은 성공하고 큰 파일만 실패 | 계층별 크기 제한·시간 제한·임시 공간 | 성공 크기와 실패 크기의 경계값 |
| 다른 출처의 화면에서만 실패 | CORS preflight 또는 credentials 설정 | OPTIONS와 실제 POST를 분리해 확인 |
| 응답은 성공인데 파일을 찾을 수 없음 | 애플리케이션 저장·DB 기록·후처리 | 저장 키와 처리 작업 ID |
| 간헐적으로 일부만 올라감 | 연결 중단·부분 업로드·동시 처리 한도 | 서버가 기록한 수신 바이트와 업로드 오류 코드 |
먼저 한 번의 실패를 재현 가능하게 기록하기
같은 파일을 반복해서 올리기 전에 파일 크기, 확장자, 브라우저, 요청 시각, 응답 상태 코드와 요청 ID를 기록합니다. 정상 동작하는 작은 파일 하나와 실패하는 파일 하나를 준비하면 “파일 내용 문제”와 “크기 경계 문제”를 구분하기 쉽습니다. 인증 쿠키나 업로드된 원본 파일 내용을 일반 로그에 남기지는 않습니다.
- 요청 URL과 HTTP method
- 파일 필드 이름과 파일 크기
- OPTIONS가 있었는지와 POST가 실제로 전송됐는지
- 응답 상태 코드와 가능한 오류 본문
- 프록시, 웹 서버, 애플리케이션 로그의 같은 요청 ID
1단계: 브라우저가 multipart 요청을 올바르게 만드는지 확인
FormData를 사용할 때는 파일 객체를 필드에 넣고 그대로 요청 body로 전달합니다.
const file = document.querySelector('#file').files[0];
const formData = new FormData();
formData.append('file', file, file.name);
const response = await fetch('/api/uploads', {
method: 'POST',
body: formData,
credentials: 'include'
});
이때 요청의 Content-Type: multipart/form-data를 JavaScript에서 직접 지정하지 않습니다. 브라우저가 각 필드를 구분할 boundary 값을 Content-Type에 함께 넣어야 하는데, 개발자가 헤더만 고정하면 boundary가 빠져 서버가 파일을 해석하지 못할 수 있습니다.
기존 <form>에서 new FormData(form)를 만든다면 파일 input에 name이 있어야 포함됩니다. 비활성화된 input은 FormData에 들어가지 않으므로 화면에 파일명이 보이더라도 실제 요청 payload를 다시 확인합니다.
2단계: CORS 오류와 업로드 본문 오류를 분리하기
프론트엔드와 업로드 API의 출처가 다르면 브라우저는 실제 POST 전에 OPTIONS preflight를 보낼 수 있습니다. 개발자 도구에서 OPTIONS가 실패했다면 아직 파일 본문이 애플리케이션에 도착하지 않은 것입니다. 허용 origin, method, 요청 헤더와 credentials 정책을 먼저 맞춥니다.
반대로 OPTIONS는 성공했지만 POST가 413 또는 500으로 끝난다면 CORS 헤더만 반복해서 바꿔도 해결되지 않습니다. 서버 로그에서 POST가 어느 계층에서 끊겼는지 확인합니다. 인증 쿠키를 쓰는 교차 출처 요청은 와일드카드 origin과 함께 처리할 수 없으므로 허용할 출처를 정확히 지정해야 합니다.
3단계: 가장 작은 서버 제한값 찾기
업로드 가능한 최대 크기는 화면에 적어 둔 값 하나가 아니라 요청 경로에 있는 제한 중 가장 작은 값으로 결정됩니다.
- CDN, WAF 또는 로드밸런서
- 리버스 프록시와 웹 서버
- 애플리케이션 런타임
- 프레임워크의 요청·파일 검증
- 임시 디렉터리, 최종 저장소와 사용자별 할당량
애플리케이션 로그가 전혀 없고 앞단 서버가 413을 반환했다면 프레임워크의 파일 제한을 바꿀 단계가 아닙니다. 반대로 요청이 애플리케이션에 들어왔고 명시적인 검증 오류를 반환했다면 프록시보다 앱 정책을 확인합니다.
Nginx를 사용하는 경우
Nginx의 client_max_body_size는 허용할 요청 본문의 최대 크기를 정하며 초과하면 413을 반환합니다. 설정은 http, server, location 범위에 둘 수 있으므로 업로드 URL에 실제 적용되는 값을 확인합니다.
location /api/uploads {
client_max_body_size 25m;
proxy_pass http://app_backend;
}
제한을 0으로 만들어 검사를 끄기보다 서비스가 실제로 필요한 크기와 동시 요청 수를 기준으로 상한을 정합니다. 설정을 변경했다면 문법 검사와 reload가 성공했는지 확인하고, 작은 파일과 경계값 파일을 다시 시험합니다.
Apache HTTP Server를 사용하는 경우
Apache의 LimitRequestBody는 요청 본문 크기를 바이트 단위로 제한할 수 있습니다. 서버·가상 호스트·디렉터리·.htaccess 등 적용 범위가 다르므로 한 곳의 값만 보고 판단하지 않습니다.
LimitRequestBody 26214400
이 예시는 25MiB에 해당하지만 그대로 복사하기보다 업로드 정책과 앞뒤 계층의 제한을 함께 맞춰야 합니다.
PHP를 사용하는 경우
upload_max_filesize는 개별 파일 크기를, post_max_size는 파일과 다른 form field 및 multipart 구분자를 포함한 전체 POST 크기를 제한합니다. 따라서 post_max_size는 upload_max_filesize보다 커야 합니다.
upload_max_filesize = 20M
post_max_size = 24M
전체 POST가 post_max_size를 넘으면 PHP의 $_POST와 $_FILES가 모두 비어 보일 수 있습니다. 이 증상을 단순히 “파일 필드 이름이 틀렸다”고 판단하지 말고 요청의 Content-Length와 PHP의 실제 적용 설정을 같이 확인합니다. 임시 업로드 디렉터리는 PHP 실행 사용자가 쓸 수 있어야 하며 디스크 공간도 충분해야 합니다.
4단계: 애플리케이션이 받은 업로드 오류를 그대로 분류하기
PHP라면 $_FILES['file']['error']를 무시하지 않습니다. 설정 크기 초과, form 크기 초과, 부분 업로드, 파일 없음, 임시 디렉터리 없음, 디스크 쓰기 실패는 서로 다른 조치가 필요합니다. 오류 코드를 하나의 “업로드 실패” 문구로 덮으면 운영 환경에서 원인을 찾기 어렵습니다.
클라이언트에는 민감한 서버 경로 대신 사용자가 조치할 수 있는 메시지와 요청 ID를 반환합니다. 서버 로그에는 다음과 같은 구조화된 항목을 남길 수 있습니다.
- 요청 ID와 인증된 내부 사용자 ID
- 선언된 파일 크기와 실제 수신 크기
- 업로드 오류 분류와 처리 단계
- 원본 확장자, 서버가 탐지한 MIME 유형, 저장 키
- 프록시와 애플리케이션의 처리 시간
5단계: MIME과 확장자는 보안 경계로 다시 검증
브라우저가 보낸 파일명과 Content-Type은 사용자가 바꿀 수 있으므로 보안 판단의 단일 근거로 신뢰하면 안 됩니다. 필요한 확장자만 허용하고, 서버에서 실제 파일 유형과 signature를 확인하며, 파일 내용에 맞는 파서로 안전하게 처리합니다.
- 업무에 필요한 확장자만 allowlist로 허용
- 원본 파일명 대신 서버가 생성한 저장 키 사용
- 파일명 길이와 경로 문자 제한
- 가능하면 웹 루트 밖이나 분리된 저장소에 보관
- 업로드 권한, CSRF 방어, 사용자별 크기·횟수 제한 적용
- 서비스 위험도에 따라 악성코드 검사와 이미지 재인코딩 검토
확장자를 차단 목록으로만 관리하거나 Content-Type만 비교하는 검사는 쉽게 우회될 수 있습니다. 검사를 통과하기 전 파일을 공개 URL로 제공하지 않고, 후처리가 필요하면 pending, ready, rejected 같은 상태를 분리합니다.
6단계: 저장과 DB 기록의 성공 조건 정하기
파일을 저장했지만 DB 기록이 실패하거나, DB에는 성공으로 남았지만 객체 저장소 업로드가 실패하면 고아 파일이나 깨진 링크가 생깁니다. 업로드 API가 언제 성공을 반환할지 명확히 정하고, 실패한 단계에 맞는 정리 작업을 둡니다.
큰 파일을 백그라운드에서 검사한다면 최초 응답은 처리 작업 ID와 현재 상태를 반환하고, 프론트는 완료 상태를 조회하도록 설계할 수 있습니다. 네트워크 재시도로 같은 파일이 여러 번 등록되지 않게 요청의 멱등 키나 파일 레코드의 고유 조건도 검토합니다.
재현 테스트 표
| 테스트 | 기대 결과 |
|---|---|
| 허용된 작은 파일 | 저장·DB 기록·응답이 모두 성공 |
| 허용 상한 바로 아래 파일 | multipart overhead를 포함해 정상 처리 |
| 허용 상한을 넘는 파일 | 일관된 413 또는 앱 검증 오류와 안내 |
| 확장자만 바꾼 파일 | 내용 검증에서 거절 |
| 같은 이름의 파일 재업로드 | 기존 파일을 의도치 않게 덮어쓰지 않음 |
| 전송 중 연결 끊기 | 부분 파일과 임시 레코드가 정리됨 |
| 허용·비허용 출처 | CORS 정책에 맞게 각각 허용·차단 |
| 동시 업로드 | 사용자별 제한과 저장소 부하가 통제됨 |
가장 짧은 진단 순서
- 브라우저 Network에서 OPTIONS와 POST, 상태 코드, multipart boundary를 확인합니다.
- 애플리케이션 로그에 같은 요청 ID가 있는지 확인합니다.
- 없다면 CDN·프록시·웹 서버의 본문 제한과 로그를 확인합니다.
- 있지만 파일 배열이 비어 있다면 전체 POST 제한, field 이름, 임시 디렉터리를 확인합니다.
- 파일을 받았다면 MIME·signature 검증, 저장소, DB 기록 순서로 추적합니다.
- 작은 파일과 경계값 파일로 수정 결과를 다시 검증합니다.
이 순서대로 보면 413, CORS, multipart 파싱, PHP 설정, 저장소 오류를 하나의 문제로 섞지 않고 해결할 수 있습니다. 용량 상한을 올리는 조치는 문제의 위치를 확인한 뒤 보안·비용·동시 처리량을 함께 계산해서 적용해야 합니다.
검증 기준과 참고 자료
이 글은 2026-09-10에 브라우저 multipart 전송, Nginx·Apache·PHP 요청 제한과 OWASP 파일 업로드 보안 지침을 공식 문서 기준으로 검수했습니다. 실제 제한값과 설정 위치는 호스팅 구성 및 애플리케이션 런타임에 따라 달라질 수 있습니다.
- MDN: FormData 객체로 파일 전송하기
- MDN: CORS 가이드
- Nginx: client_max_body_size 지시어
- Apache HTTP Server: LimitRequestBody 지시어
- PHP: 파일 업로드와 POST 크기 설정
- PHP: 파일 업로드 오류 코드
- OWASP: 파일 업로드 보안 체크리스트