큰 PDF 업로드가 거부될 때: 100 MiB 요청 본문 한도와 잘못된 방향을 가리키는 오류
요청 본문 하나는 multipart 프레이밍을 포함해 100 MiB, 즉 104,857,600바이트까지이고, 한도를 넘으면 작업 엔드포인트가 413이 아니라 400과 코드 bad_request, multipart 필드 읽기 실패라는 상세만 돌려줘서 크기 문제가 매개변수 문제처럼 보이며, Idempotency-Key가 있으면 응답 버퍼에 100 MiB 한도가 하나 더 있어 넘으면 500이 반환되고 아무것도 캐시되지 않습니다.

큰 PDF가 업로드되지 않는 이유는 대개 파일이 손상됐기 때문이 아닙니다. 요청 본문이 요청당 한도에 걸린 것입니다. 100 MiB, 즉 104,857,600바이트입니다. 이는 multipart 경계와 필드 헤더를 포함한 본문 전체로 측정되며, 정확히 이 값은 통과하고 1바이트만 넘어도 거부됩니다.
돌아오는 오류는 다른 곳을 가리킵니다. 작업 엔드포인트는 400을 반환하고 code는 bad_request이며, 상세에는 multipart 필드를 읽지 못했다고 적혀 있을 뿐 크기는 어디에도 언급되지 않습니다. code로 분기하는 클라이언트는 이를 매개변수 오류로 분류하고 필드 이름을 확인하러 갑니다. 실제로 바꿔야 하는 것은 파일 크기인데도 말입니다.
같은 100 MiB는 반대 방향도 지배합니다. Idempotency-Key를 실은 요청은 응답을 캐시하기 전에 응답 본문을 메모리로 읽어들이고, 그 읽기에도 같은 값이 적용됩니다. 넘으면 작업은 이미 끝났는데 호출자는 500을 받습니다. 숫자 하나가 정반대의 두 가지 실패 방식을 만듭니다.
100 MiB = 104,857,600바이트 (정확히 이 값은 통과)
|
+------------------+------------------+
| |
인바운드(요청 본문) 아웃바운드(응답 본문)
multipart 경계와 필드 헤더를 Idempotency-Key가 있고
포함한 본문 전체; 작업 하나와 캐시 미스이며 업스트림이
파이프라인이 공유 2xx일 때만
초과: 400 + bad_request 초과: 500, 캐시 없음
(상세: multipart 필드를 읽지 못함)
그림 설명: 한도의 인바운드와 아웃바운드 양쪽이 서로 다른 상태 코드와 정반대의 결과를 냅니다.
한도는 경계를 포함한 요청 본문 전체를 셉니다
100 MiB가 제한하는 것은 요청 하나의 본문이며, 파일 하나의 크기도, 압축을 푼 뒤의 크기도 아닙니다.
- 작업 하나와 여러 단계 파이프라인이 같은 값을 공유합니다. 한 번의
/api/v1/pipeline호출 안에서 작업을 10단계로 나눠도 천장이 1 GB가 되지는 않습니다. 단계 수는 실행 시간만 바꿉니다. - 경계값 자체는 통과합니다. 104,857,600바이트 요청 본문은 지나가고 104,857,601바이트는 그렇지 않습니다.
- 본문에는 파일 바이트뿐 아니라 각 필드의 헤더와 경계 구분자도 실립니다. 그래서 파일 하나에 쓸 수 있는 여유는 엄격히 100 MiB 미만이고, 정확히 104,857,600바이트인 파일은 거부됩니다.
마지막 지점이 실무에서 가장 쉽게 미끄러지는 곳입니다. curl -F는 경계를 대신 붙여 주므로, 파일 크기를 그 선에 견줘 보는 방식은 절대 맞아떨어질 수 없습니다.
한도를 넘으면 400과 bad_request를 받습니다
한도 초과 응답은 413을 쓰지 않고, 크기에 관한 표현도 전혀 없습니다. 1바이트 넘는 본문으로 재현하면 이렇게 됩니다:
head -c 104857601 /dev/zero > /tmp/over.bin
curl -s -X POST "$API_BASE/api/v1/misc/compress-pdf" \
-H "X-API-KEY: $API_KEY" \
-F "fileInput=@/tmp/over.bin"
HTTP/1.1 400 Bad Request
content-type: application/problem+json
{ "code": "bad_request",
"detail": "failed to read multipart field: Error parsing `multipart/form-data` request",
"hint": "Fix request parameters or upload a valid PDF.",
"status": 400,
"title": "Bad Request",
"type": "https://pdf123.xyz/developers/errors#bad_request" }
세 가지를 함께 읽어야 합니다:
- 상태는 400입니다. 본문 읽기 실패가 여기서는
bad_request로 분류되고 그대로 problem+json을 통과합니다. 그래서 한도를 넘으면 413이라고 가정하고 짠 클라이언트는 잘못된 분기를 탑니다. code는bad_request이며 오류 코드 표에 실제로 있는 항목입니다. 포괄 분기로 떨어지지 않고, 필드 이름을 잘못 쓴 경우나 깨진 multipart 인코딩과 같은 코드를 공유합니다. 그 표에는 크기와 관련된 항목이 하나도 없습니다.hint는 유효한 PDF를 업로드하라고 안내합니다. 하지만 업로드한 파일은 십중팔구 유효한 PDF이고, 다만 수백 바이트가 클 뿐입니다.
그러므로 단서는 상태 코드가 아니라 본문의 바이트 수입니다. detail에 failed to read multipart field가 들어 있는 400을 받았다면, 다음에 재야 할 것은 크기이고 폼을 되짚을 필요는 없습니다.
413도 이 사이트에서 일어납니다. 다만 작업 엔드포인트에서는 아닙니다. 파일 업로드를 받는 모든 진입점은 100 MiB로 올려 두었고, 업로드를 받지 않는 엔드포인트는 HTTP 프레임워크(Rust의 axum) 기본값인 2 MiB로 계속 동작합니다. 거기서 한도를 넘긴 본문은 413과 한 줄의 평문을 받습니다:
head -c 2097153 /dev/zero > /tmp/big.json
curl -s -w '\n%{http_code}\n' -X POST "$API_BASE/api/v1/auth/login" \
-H 'Content-Type: application/json' \
--data-binary @/tmp/big.json
Failed to buffer the request body: length limit exceeded
413
같은 사실이 엔드포인트에 따라 두 개의 상태 코드와 두 개의 응답 본문을 만듭니다. 어느 한쪽 경험을 다른 쪽에 그대로 옮기면 잘못된 판단을 하게 됩니다.
같은 숫자가 만드는 또 하나의 실패, 이번에는 돌아오는 길
Idempotency-Key가 있는 요청은 재시도가 재생할 수 있도록 응답을 캐시합니다. 캐시한다는 것은 응답 본문을 먼저 메모리로 읽는다는 뜻이고, 그 읽기도 같은 100 MiB로 제한됩니다. 다만 조건은 훨씬 좁습니다:
- 요청에
Idempotency-Key가 있었다 - 캐시가 비어 있어 이 키가 처음이다
- 업스트림 작업이 2xx를 반환했다
실패한 응답은 캐시되지 않고 그대로 통과하므로, 이 한도에 닿는 것은 성공한 큰 출력뿐입니다.
그때 벌어지는 일이 가장 기억할 만한 지점입니다. 500이 반환되고, 캐시에는 아무것도 쓰이지 않습니다. 작업은 이미 실행됐는데 호출자는 실패를 봅니다. 캐시된 것이 없으니 재시도는 전부를 처음부터 다시 돌립니다. 중복 작업을 없애려고 존재하는 멱등 키가 가장 필요한 순간에 정확히 실패하며, 끝난 작업을 서버 장애처럼 포장합니다. 캐시는 프로세스 메모리에 있고 디스크에 쓰이지 않으므로 재시작하면 사라집니다. 의미론은 Idempotency-Key: PDF 작업을 안전하게 재시도하기를 보세요.
100 MiB는 설정으로 바꿀 수 있는 플랫폼 값이 아닙니다
이 값은 바꿀 수 없습니다. 어떤 환경 변수도 양방향으로 올리거나 내리지 못합니다. 다른 숫자를 쓰려면 코드를 바꾸고 이미지를 다시 빌드해야 합니다. 배포 설정에서 찾으려 해도 없습니다.
그것은 단순한 할당량 이상입니다. 요청 본문의 바이트는 처리 전에 전부 메모리로 읽히므로, 동시에 들어오는 큰 업로드마다 그에 상응하는 메모리가 함께 잡힙니다. 천장을 올린다는 것은 더 높은 메모리 피크를 받아들인다는 뜻이기도 합니다. 이 숫자는 요청 하나가 프로세스를 끌어내리지 않게 막는 장치이기도 합니다.
기본 셀프 호스팅 배포에는 리버스 프록시가 없고, 포털도 제출 전에 크기를 확인하지 않습니다. 그래서 그 거부는 서버 자체의 한도에서 옵니다. 앞에 nginx를 두면 nginx가 먼저 걸립니다. client_max_body_size는 기본적으로 1 MiB만 허용하고 초과하면 413을 반환합니다. 그 모양은 위 비교에 나온 것과 비슷해서 같은 한도로 오해하기 쉽습니다.
한도에 걸렸을 때 할 일
비용이 낮은 것부터:
- 보내기 전에 재세요. 요청을 보내기 전에 본문 바이트 수를 104,857,600과 비교하고 경계 몫의 여유를 남기세요. 나중에 상태 코드를 읽는 것보다 낫습니다.
- 파일을 한도 안으로 압축하세요. 스캔의 크기는 대부분 이미지 레이어에서 오고, 다시 압축하면 보통 눈에 띄는 몫이 줄어듭니다. PDF 압축은 브라우저에서 바로 돌아가고 스크립트가 필요 없습니다.
- 작업을 여러 호출로 나누세요. 내용이 나뉜다면 분할해서 몇 번의 요청으로 보내세요. 한도를 올리는 것보다 번거롭지 않고, 요청 하나가 붙드는 메모리도 늘리지 않습니다.
- 한 번의 요청으로 끝내야 하는 확실한 요구가 있을 때만 한도를 바꾸세요. 그건 코드를 고치고 다시 빌드하는 일이며, 앞 절의 메모리 비용을 받아들이는 일입니다.
이 한도는 클라이언트가 미리 판단하기를 요구합니다
두 실패는 같은 결론을 가리킵니다. 100 MiB라는 값은 클라이언트가 보내기 전에 직접 계산해야 합니다. 인바운드에서는 bad_request로 답하는데 그 코드는 크기에 대해 아무 말도 하지 않고, 아웃바운드에서는 500으로 답하는데 서버 장애처럼 보입니다. 요청이 떠나기 전에 바이트 수를 세는 것이 오류 메시지의 문구에 의존하지 않는 유일한 판단 방법입니다.