1 KiB 파일이 1 KiB 업로드 제한에 걸리는 이유
목차
최대 1 KiB 파일을 받도록 서버를 설정했는데 정확히 1 KiB인 파일이 거절된다. multipart/form-data 요청에는 파일 외에도 경계 문자열과 각 항목의 헤더가 들어가기 때문에 전체 본문은 이미 1 KiB를 넘는다. 업로드 API를 만들 때는 요청 전체와 파일 하나에 서로 다른 상한을 두어야 한다.
업로드 정책을 여러 경로에 맞추는 작업에서 다시 확인한 구분이다. 아래는 실제 서비스의 설정 대신 Go 표준 라이브러리로 새로 만든 작은 예제다. 파일 크기를 1 KiB로 줄여 같은 경계를 확인한다. KiB는 1,024바이트다.
파일 앞뒤에도 바이트가 붙는다
multipart는 여러 항목을 한 본문에 담는 형식이다. file 항목에는 파일 이름과 콘텐츠 유형 등을 적은 헤더가 붙고 항목 사이에는 경계를 나타내는 문자열이 들어간다. 다음은 구조만 보여 주는 축약 예시다.
--study-boundary
Content-Disposition: form-data; name="file"; filename="sample.bin"
Content-Type: application/octet-stream
[파일 1,024바이트]
--study-boundary--실제 인코딩에는 줄바꿈 바이트도 포함된다. 이 글의 예제에서 본문 크기는 파일 바이트에 150바이트를 더한 값이다. 파일 이름, 경계 문자열, 다른 필드가 바뀌면 이 부가 크기도 달라진다. 150바이트를 모든 요청에 적용할 고정값으로 삼아서는 안 된다.
요청 전체의 상한을 파일 상한과 같게 두면 부가 데이터가 파일 몫을 차지한다. 반대로 요청 상한만 넉넉하게 늘리면 파일 하나도 그만큼 커질 수 있다. 두 제한은 서로 다른 대상을 센다.
같은 파일을 두 가지 요청 상한으로 읽어 보기
실행 예제는 동일한 경계 문자열로 본문을 만든 뒤 httptest 요청을 Go 파서에 직접 전달한다. 실제 프록시나 네트워크 서버는 띄우지 않는다. Go 1.24.5에서 다음과 같이 실행했다.
go run multipart-limits.gogo1.24.5
file=1024 body=1174 bodyLimit=1024: body rejected
file=1024 body=1174 bodyLimit=2048: accepted
file=1025 body=1175 bodyLimit=2048: file rejected첫 두 줄의 입력 파일과 인코딩된 본문은 같다. 요청 상한만 1,024에서 2,048바이트로 바꾸자 1,174바이트 본문을 읽을 수 있게 됐다. 마지막 줄에서는 파일만 1바이트 늘렸다. 본문은 요청 상한 안에 들어오지만 파일별 검사에서 거절한다.
| 파일 바이트 | 본문 바이트 | 요청 상한 | 파일 상한 | 결과 |
|---|---|---|---|---|
| 1,024 | 1,174 | 1,024 | 1,024 | 본문 읽기에서 거절 |
| 1,024 | 1,174 | 2,048 | 1,024 | 허용 |
| 1,025 | 1,175 | 2,048 | 1,024 | 파일 검사에서 거절 |
2,048바이트는 이 실험의 요청 예산이다. 서비스에 권장하는 비율이 아니다. 서비스에서는 허용할 파일 개수, 일반 필드, 파일 이름과 헤더의 길이까지 정한 뒤 요청 예산을 잡아야 한다. 여유분을 둔다면 그 안에 들어갈 입력도 제한한다.
메모리 기준을 줄여도 파일 상한은 생기지 않는다
Go의 ParseMultipartForm(maxMemory)에서 maxMemory는 파일을 거절할 최대 크기가 아니다. 파일 데이터 중 메모리에 둘 양을 정하고 나머지는 임시파일에 저장한다. Go 1.24.5의 ReadForm은 일반 필드 등을 위해 별도 메모리 예산도 사용하므로 프로세스 전체의 메모리 상한으로 해석해서도 안 된다. Go 1.24.5 multipart 구현
실행 예제는 이 인자를 8바이트로 두었다. 그런데 1,024바이트 파일은 두 번째 경우에 정상 처리된다. 메모리 기준을 넘은 파일을 디스크에 두었기 때문이다. 예제는 처리가 끝나면 MultipartForm.RemoveAll()로 임시파일을 정리한다.
요청 전체의 읽기 상한은 파싱 전에 둔다. 다음 두 줄에서 bodyLimit은 파일과 부가 데이터를 합한 예산이고 8은 파일 데이터를 메모리에 보관할 기준이다. 파싱 오류 처리는 실행 파일에 포함했다.
r.Body = http.MaxBytesReader(w, r.Body, bodyLimit)
err := r.ParseMultipartForm(8)MaxBytesReader는 한도를 넘겨 읽으려 할 때 *http.MaxBytesError를 반환한다. 한도만큼 읽고 정상 EOF를 돌려주는 일반적인 io.LimitReader와 오류 계약이 다르다. Go 1.24.5 HTTP 구현
파일별 검사에서는 상한보다 1바이트 더 읽을 수 있게 한다. fileLimit만큼만 읽으면 파일 크기가 정확히 그 크기인지, 뒤에 데이터가 더 있는지 구분되지 않고 같은 길이로 보이기 때문이다.
n, err := io.Copy(io.Discard, io.LimitReader(file, fileLimit+1))
// err를 먼저 처리한다.
if n > fileLimit {
// 파일 상한 초과
}본문 전체를 먼저 제한했으므로 예제의 파서가 읽을 입력에도 상한이 있다. 큰 파일을 스트리밍으로 처리한다면 part를 읽는 동안 같은 파일별 검사를 적용할 수 있다. 어느 경로든 초과한 파일을 최종 저장하거나 후속 작업에 넘기기 전에 거절해야 한다.
서버까지 도착하지 않은 413도 있다
앞에 프록시가 있다면 애플리케이션의 파일 검사보다 요청 상한 검사가 먼저 일어날 수 있다. NGINX의 client_max_body_size는 요청 본문 크기를 제한하며 초과하면 413을 반환한다. 이 단계에서 거절된 요청은 애플리케이션의 오류 처리기를 거치지 않는다. NGINX 요청 크기 제한
따라서 업로드 오류를 읽는 클라이언트는 모든 413이 애플리케이션의 JSON 형식이라고 가정할 수 없다. 상태 코드와 응답의 콘텐츠 유형을 먼저 확인하고 본문을 해석해야 한다. 오류 메시지를 바꾸는 것만으로 프록시에서 거절되는 경계값 파일을 살릴 수는 없다.
프록시 상한을 넓힐 때는 그 경로의 다른 요청도 같은 예산을 받는지 확인한다. 파일 상한과 별도로 요청 개수, 동시성, 임시 디스크 사용량의 제한은 여전히 필요하다. 파일 하나를 허용하는 정책이 서버 전체의 자원 예산까지 정해 주지는 않는다.
허용 경계와 초과 경계를 함께 보자
업로드 검사는 작은 파일 하나의 성공으로 끝내기 어렵다. 이 예제처럼 파일 상한과 정확히 같은 크기, 1바이트 큰 크기를 먼저 대조하고 파일을 그대로 둔 채 일반 필드만 늘려 보면 어떤 제한이 작동했는지 구분할 수 있다.
파일별 상한을 그대로 둔 채 요청 상한만 늘렸을 때 경계값 파일이 통과하고 초과 파일은 계속 거절되는가. 이 두 결과를 함께 확인하면 여유분이 파일 정책을 느슨하게 만들지 않았다는 근거가 된다. 실제 배포에서는 같은 입력을 프록시 경유 경로에도 보내 제한을 적용한 계층까지 확인한다.