이 오류가 뜻하는 것

서버가 Content-Length: 500000을 보내면 “본문은 정확히 500,000바이트”라고 약속하는 것입니다. 브라우저는 도착하는 바이트를 셉니다. 410,000바이트에서 연결이 닫히면 응답이 잘렸다는 걸 알고, 받은 것을 버린 뒤 ERR_CONTENT_LENGTH_MISMATCH(크로미움 net error -354)를 냅니다.

보통 개발자 도구 콘솔에서 이렇게 보입니다.

Failed to load resource: net::ERR_CONTENT_LENGTH_MISMATCH

화면에 보이는 모습은 무엇이 잘렸느냐에 따라 다릅니다. 이미지는 깨지거나 반쯤만 그려지고, 스크립트나 스타일시트는 적용되지 않고, 다운로드는 도중에 멈춥니다.

즉 이것은 연결 오류가 아니라 잘림 오류입니다. DNS는 됐고, 연결도 열렸고, 헤더도 도착했고, 본문도 흐르기 시작했습니다. 그다음 무언가가 멈춰 세웠습니다. 그래서 질문은 “왜 연결이 안 되나”가 아니라 “무엇이 본문을 끝까지 못 가게 했나”입니다.

시간을 아껴 주는 사실 세 가지가 있습니다.

  • 바이트가 너무 많은 것은 이 오류가 아닙니다. 본문이 Content-Length보다 길면 브라우저는 약속된 길이에서 그냥 읽기를 멈춥니다. 그런 버그는 오류 없이 내용이 조용히 잘려 보입니다.
  • chunked 응답은 다른 이름으로 실패합니다. 서버가 길이 대신 Transfer-Encoding: chunked로 보내다가 끊기면 크롬은 ERR_INCOMPLETE_CHUNKED_ENCODING을 냅니다. 원인은 같고 표현만 다릅니다. 어느 쪽이 나오느냐는 문제가 생기기 전에 길이를 보냈느냐에 달려 있습니다.
  • 프로토콜을 확인하세요. 개발자 도구 → Network에서 Protocol 열을 켜 보세요. 이 오류는 주로 HTTP/1.1 연결에서 보입니다. HTTP/2나 HTTP/3에서는 잘린 본문이 대개 프로토콜 오류(ERR_HTTP2_PROTOCOL_ERROR 등)로 나타납니다. CDN이 브라우저와는 HTTP/2로, 내 서버와는 HTTP/1.1로 통신할 때 알아 두면 좋습니다.

방문자라면

내 쪽에서 고칠 것은 거의 없습니다. 다른 네트워크(모바일 데이터로 접속한 휴대폰)에서 열어 보고, VPN·프록시·백신의 웹 검사 기능을 꺼 보세요. 이런 것들은 연결 한가운데에 있어서 전송을 끊을 수 있습니다. 강력 새로고침(Ctrl+Shift+R, 맥은 Cmd+Shift+R)으로 반쯤 캐시된 사본도 배제할 수 있습니다.

어느 네트워크에서나 실패한다면 사이트가 불완전한 응답을 보내고 있는 것이고, 운영자만 고칠 수 있습니다. 어떤 URL이 몇 시에 실패했는지 알려 주면 운영자가 로그에서 찾기 쉽습니다.

curl로 재 보기

같은 요청을 재현해서 선언된 길이와 실제로 받은 양을 비교합니다. curl 7.84 이상이라면:

curl -sS --http1.1 -o /dev/null -H 'Accept-Encoding: gzip, br' \
  -w 'status=%{http_code} declared=%header{content-length} received=%{size_download}\n' \
  https://example.com/assets/app.js

본문이 짧게 끊기면 curl도 자기 식으로 같은 오류를 냅니다.

curl: (18) transfer closed with 183402 bytes remaining to read

chunked 응답이라면 메시지가 transfer closed with outstanding read data remaining입니다.

명령에서 중요한 부분이 둘 있습니다. --http1.1은 이 오류가 나는 프로토콜로 시험을 고정합니다. Accept-Encoding 헤더는 압축을 요청하는 브라우저와 똑같이 서버가 동작하게 만듭니다. --compressed를 붙이지 않으면 curl은 압축을 풀지 않으므로 size_download가 회선 위의 원래 바이트 수를 세고, 그게 바로 Content-Length가 가리키는 값입니다.

간헐적으로 실패한다면 반복해 보세요.

for i in $(seq 20); do
  curl -sS --http1.1 -o /dev/null -H 'Accept-Encoding: gzip, br' \
    -w '%{http_code} %{size_download} %{time_total}s\n' https://example.com/big.zip
done

늘 비슷한 경과 시간에 잘린다면 타임아웃을, 늘 비슷한 바이트 수에서 잘린다면 버퍼나 임시 파일을 의심하세요.

어느 계층이 본문을 자르는지 알려면 CDN이나 로드밸런서를 건너뛰고 원 서버에 직접 같은 시험을 합니다.

curl -sS --http1.1 -o /dev/null --resolve example.com:443:203.0.113.10 \
  -w '%{size_download}\n' https://example.com/big.zip

HTTP 점검은 내 네트워크 밖에서 그 URL에 서버가 어떤 헤더를 보내는지 보여 줍니다. Content-Length가 있는지 Transfer-Encoding: chunked인지, Content-Encoding은 무엇인지 확인할 수 있습니다. 헤더만 읽으므로 바이트 수는 curl로 재세요.

흔한 원인

1. nginx가 프록시 임시 파일을 쓰지 못함

nginx가 애플리케이션 앞에 있을 때 가장 전형적인 원인이고, “큰 파일에서만”이라는 패턴을 설명해 줍니다.

nginx는 업스트림 응답을 메모리에 버퍼링합니다(proxy_buffers, PHP-FPM이라면 fastcgi_buffers). 응답이 메모리에 다 들어가지 않으면 나머지를 proxy_temp_path(또는 fastcgi_temp_path) 아래 임시 파일에 씁니다. 그 파일을 쓰지 못하면, 클라이언트는 nginx가 이미 보낸 Content-Length보다 짧은 응답을 받게 됩니다.

쓰지 못하는 이유는 대개 둘입니다.

  • 권한. 임시 디렉터리의 소유자가 nginx 워커 프로세스의 사용자와 다릅니다. user 지시어를 바꾼 뒤, nginx를 한 번 다른 사용자로 실행한 뒤, 또는 디렉터리 소유자가 잘못된 채로 만들어진 컨테이너 이미지에서 흔합니다.
  • 디스크가 꽉 참, 또는 inode가 바닥남.

오류 로그에 이런 줄이 남습니다.

open() "/var/lib/nginx/proxy/3/00/0000000003" failed (13: Permission denied) while reading upstream

디스크가 꽉 찼다면 (28: No space left on device)입니다. 확인해 보세요.

nginx -V 2>&1 | tr ' ' '\n' | grep temp-path       # 컴파일된 기본 경로
sudo nginx -T 2>/dev/null | grep -E '^\s*user|_temp_path|max_temp_file_size'
ps -o user= -C nginx | sort | uniq -c              # 워커가 어떤 사용자로 도는지
sudo ls -ld /var/lib/nginx /var/lib/nginx/*        # 경로는 배포판마다 다름
df -h /var/lib/nginx; df -i /var/lib/nginx

해결: 임시 디렉터리를 워커 사용자에게 넘기거나(데비안·우분투라면 sudo chown -R www-data: /var/lib/nginx, 사용자와 경로는 내 환경에 맞게), 디스크를 비우세요. 프록시 응답에 임시 파일을 아예 쓰지 않게 하려면 proxy_max_temp_file_size 0;을 넣으면 됩니다. 이렇게 하면 nginx는 클라이언트가 읽는 속도에 맞춰 데이터를 넘기므로, 느린 클라이언트가 있을 때 업스트림 연결을 더 오래 붙잡게 됩니다.

2. 백엔드가 본문 도중에 멈춤

Content-Length를 포함한 헤더는 이미 나갔습니다. 그다음 본문을 만들던 프로세스가 죽거나 멈췄습니다. nginx는 이를 upstream prematurely closed connection while reading upstream으로 기록합니다.

흔한 형태는 이렇습니다.

  • PHP-FPM request_terminate_timeout. 큰 파일을 느린 클라이언트에게 흘려보내는 스크립트가 이 실제 경과 시간 한도에 걸려 강제 종료됩니다. FPM 로그에 execution timed out과 terminating이 들어간 줄이 남습니다. 리눅스에서 max_execution_time은 스트림 작업이나 시스템 호출에 쓴 시간을 세지 않으므로, 느린 readfile() 다운로드를 멈추는 것은 대개 max_execution_time이 아니라 request_terminate_timeout입니다.
  • 큰 내보내기 파일을 만드는 도중의 크래시나 OOM 강제 종료. 실패 시각에 journalctl -u <서비스>와 sudo dmesg -T | grep -i 'killed process'를 확인하세요.
  • nginx의 proxy_read_timeout(기본 60초). 응답 전체 시간이 아니라 업스트림에서 두 번 읽는 사이의 간격을 재므로, 백엔드가 전송 도중에 멈출 때 걸립니다. 예를 들어 다음 덩어리를 보내기 전에 느린 DB 쿼리를 기다리는 경우입니다.

해결: 필요한 엔드포인트에 한해 해당 한도를 올리거나, 더 낫게는 요청 안에서 큰 응답을 만들지 마세요. 내보내기 결과를 저장소에 써 두고 정적 파일로 내려주면 됩니다.

3. 전송 도중 파일이 바뀜

nginx는 정적 파일을 보낼 때 요청 시작 시점의 파일 크기로 Content-Length를 정합니다. 전송 중에 배포가 그 파일을 제자리에서 덮어쓰거나 로그 로테이션이 파일을 잘라 버리면, 남은 바이트가 더는 존재하지 않을 수 있습니다. nginx는 파일이 잘렸다는 경고를 로그에 남깁니다.

해결: 배포를 원자적으로 하세요. 새 파일을 임시 이름으로 쓴 뒤 mv로 옮기거나(같은 파일 시스템 안의 이름 변경은 원자적입니다), 새 릴리스 디렉터리에 배포하고 심볼릭 링크를 바꾸세요. 서비스 중인 파일 위에 복사해 덮어쓰지 마세요.

개발 환경에서만 나는 변형도 있습니다. VirtualBox·Vagrant 공유 폴더에서는 nginx의 sendfile이 오래되거나 잘린 파일을 내보내는 원인으로 알려져 있습니다. 그 환경에서는 sendfile off;로 두세요.

4. Content-Length 헤더가 그냥 틀림

무언가가 길이를 계산했고, 그 뒤에 다른 무언가가 본문을 바꿨습니다.

  • 애플리케이션 코드가 압축 전 크기로 Content-Length를 정하고 출력을 압축합니다(또는 프레임워크 미들웨어가 나중에 압축합니다).
  • PHP 스크립트가 filesize()로 Content-Length를 정했는데, zlib.output_compression이나 출력 버퍼 핸들러가 출력을 압축하거나 바꿉니다.
  • 나가는 HTML을 고치는 필터(스크립트 태그 삽입, 압축·최소화)가 헤더를 다시 계산하지 않습니다.

nginx 자체의 gzip 모듈은 여기서 문제가 아닙니다. 압축할 때 Content-Length를 지우기 때문입니다. 문제는 헤더를 손으로 정하는 코드입니다.

해결: Content-Length를 직접 정하지 마세요. 웹 서버나 프레임워크가 최종 본문으로 계산하게 하거나, chunked로 보내게 두세요.

거울에 비친 반대 버그도 알아 둘 만합니다. 오류가 전혀 나지 않기 때문입니다. Node.js에서 한글처럼 ASCII가 아닌 문자가 든 문자열에 res.setHeader('Content-Length', body.length)를 쓰면 실제보다 적은 바이트를 선언하게 됩니다. length는 UTF-8 바이트가 아니라 UTF-16 코드 단위를 세기 때문입니다. 브라우저는 선언된 길이에서 멈추고, JSON이나 HTML의 끝부분이 조용히 사라집니다. Buffer.byteLength(body)를 쓰세요.

5. 서버와 방문자 사이의 무언가

--resolve로 원 서버를 직접 시험하면 본문이 다 오는데 공개 URL로는 안 온다면, 그 사이의 계층을 보세요. 가장 느린 다운로드보다 짧은 유휴·응답 타임아웃을 가진 로드밸런서나 CDN, 작은 디스크에 버퍼링하는 프록시 같은 것들입니다. 일부 방문자에게서만 실패한다면 그들의 네트워크(회사 프록시, 다운로드를 검사하는 백신)가 끊고 있을 수 있고, 그때는 내 서버에서 바꿀 것이 없습니다.

고친 다음에

  • 위의 curl 반복 시험을 20번 연속으로 전체 크기가 나올 때까지 돌리세요.
  • 신고된 파일 하나가 아니라 실제로 서비스하는 가장 큰 파일들을 시험하세요. 이 문제를 일으키는 한도는 크기에 비례해 걸립니다.
  • 실제 트래픽에서 하루 동안 nginx 오류 로그에 while reading upstream이 나오는지 지켜보세요.

비슷해 보이는 다른 오류

내 도메인에서 바로 확인

무료, 가입 불필요. 이 가이드가 다루는 항목을 바로 검사하고 조치 방법을 알려드립니다.