브라우저가 알려 주는 것
크롬 오류 화면에는 “example.com에서 데이터를 전송하지 않았습니다”라는 문장과 함께 ERR_EMPTY_RESPONSE(내부 코드 net error -324)가 표시됩니다. 엣지 등 다른 크로미움 계열 브라우저도 같습니다.
문장 그대로입니다. 브라우저가 연결을 열고 요청을 보냈는데, 응답이 한 바이트도 오기 전에 상대가 연결을 닫았습니다. 상태 줄도, 헤더도 없습니다.
덕분에 몇 가지는 바로 배제할 수 있습니다.
- DNS는 정상입니다. 브라우저가 연결할 IP를 알고 있었습니다.
- 포트는 열려 있습니다. 닫힌 포트라면
ERR_CONNECTION_REFUSED, 방화벽이 패킷을 조용히 버린다면ERR_CONNECTION_TIMED_OUT이 납니다. - HTTPS라면 TLS 핸드셰이크도 대개 끝난 상태입니다. 핸드셰이크 도중에 끊기면 보통
ERR_CONNECTION_CLOSED나 SSL 오류로 나옵니다.
남는 것은, 연결을 받아 준 쪽이 응답하지 않고 끊기로 했거나 그럴 수밖에 없었다는 사실입니다. 정상적으로 연결을 닫으면 이 오류가 나고, TCP 리셋으로 끊으면 크롬은 ERR_CONNECTION_RESET을 보여 줍니다. 같은 계열의 문제가 조금 다른 모습으로 나타나는 것입니다.
‘연결을 받아 준 쪽’이라는 표현에 주목하세요. 꼭 웹 서버가 아닐 수 있습니다. 로드밸런서일 수도, 회사 네트워크의 프록시일 수도, 내 PC에서 웹 트래픽을 가로채 검사하는 백신일 수도 있습니다.
curl은 같은 상황을 자기 방식으로 알려 주기 때문에, 재현할 때 가장 좋은 도구입니다.
$ curl -v http://example.com/
* Trying 203.0.113.10:80...
* Connected to example.com (203.0.113.10) port 80
> GET / HTTP/1.1
> Host: example.com
> ...
* Empty reply from server
curl: (52) Empty reply from server
curl 버전마다 출력 줄은 조금씩 다르지만, 찾아야 할 줄은 curl: (52) Empty reply from server입니다.
먼저, 누구의 문제인지 가리기
내가 운영하지 않는 사이트를 방문하다 이 오류를 봤다면, 다른 걸 하기 전에 2분만 이걸 해 보세요.
- 다른 네트워크에서 열어 봅니다. 휴대폰 와이파이를 끄고 모바일 데이터로 접속해 보세요. 거기서 된다면 사이트는 살아 있고, 내 네트워크나 PC 쪽 무언가가 관여하고 있습니다.
- 시크릿 창에서 열어 봅니다. 시크릿 창에서는 되는데 평소 창에서 안 된다면, 차이는 거의 언제나 쿠키입니다(아래 ‘요청이 너무 큼’ 참고). 그 사이트의 쿠키만 지우세요. 크롬에서는 주소창 왼쪽 아이콘을 눌러 사이트 설정으로 들어가 데이터를 삭제하면 됩니다.
- VPN, 프록시, 백신의 웹 보호 기능을 하나씩 끄고 새로고침해 봅니다. 웹 트래픽을 검사하는 소프트웨어는 연결 한가운데에 끼어 있어서, 스스로 오류가 나면 아무것도 넘기지 않고 연결을 닫아 버릴 수 있습니다.
- 바깥에서 점검합니다. 그 URL로 HTTP 점검을 돌려 보세요. 내 네트워크가 아니라 우리 서버에서 페이지를 요청합니다. 정상 상태 코드가 나오면 사이트는 멀쩡하고 문제는 내 쪽에 있습니다. 여기서도 아무 응답이 없다면 모두에게 고장 난 상태이고, 사이트 운영자만 고칠 수 있습니다.
사이트가 Cloudflare 같은 CDN 뒤에 있다면, 원 서버 문제로 방문자가 ERR_EMPTY_RESPONSE를 보는 일은 드뭅니다. 방문자의 연결은 CDN이 잡고 있기 때문에, 원 서버가 응답 없이 끊으면 CDN 오류 화면이 대신 나옵니다(Cloudflare는 원 서버 응답이 비었거나 해석할 수 없을 때 520을 씁니다). CDN 뒤에 있는 사이트에서 맨 ERR_EMPTY_RESPONSE가 보인다면, 대개 방문자 쪽이나 그 호스트명에 대한 CDN 설정을 다시 봐야 합니다.
사이트 운영자라면: 흔한 원인
1. nginx return 444
의도적으로 넣은 설정이라 오히려 잊기 쉽습니다. nginx에는 비표준 상태 코드 444가 있는데, “응답을 보내지 않고 연결을 닫아라”라는 뜻입니다. 브라우저에는 ERR_EMPTY_RESPONSE로 보입니다.
흔히 두 곳에 들어 있습니다.
- 모르는 호스트명으로 온 요청을 버리는 기본 server 블록. IP로 들어오는 스캐너에게 아무것도 주지 않으려는 용도입니다.
- 봇이나 국가 차단 규칙.
if ($http_user_agent ~* ...) { return 444; }같은 형태입니다.
문제는 실제 호스트명이 기본 블록으로 떨어질 때(도메인을 추가했는데 server_name에는 안 넣었거나, 새로 만든 www 레코드가 이 서버를 가리킬 때), 또는 차단 규칙이 실제 방문자에게 걸릴 때 생깁니다.
sudo nginx -T 2>/dev/null | grep -n 'return 444'
sudo grep '" 444 ' /var/log/nginx/access.log | tail
nginx는 이런 요청을 상태 444로 액세스 로그에 남기므로, 어떤 요청이 어떤 Host로 들어와서 버려졌는지 로그에서 정확히 보입니다. 그 호스트명을 올바른 server_name에 추가하거나, 규칙을 좁히면 됩니다.
2. HTTPS 포트에 평문 HTTP를 보냄
브라우저가 http://example.com:443/이나 http://localhost:8443/으로 요청하면, 서버는 TLS 핸드셰이크를 기대한 자리에서 평문 요청을 받습니다. 그다음은 서버에 따라 다릅니다.
- nginx와 Apache는 이 상황을 알아채고 400 오류 페이지로 답합니다(nginx는 “The plain HTTP request was sent to HTTPS port”). 빈 응답이 아니라 오류 페이지가 보입니다.
- 이를 감지하지 않는 서버는 그냥 소켓을 닫습니다. 닫는 방식에 따라 크롬은
ERR_EMPTY_RESPONSE또는ERR_CONNECTION_RESET을 보여 줍니다.
로컬 개발 환경(개발 서버를 HTTPS로 바꿨는데 북마크는 아직 http://), HTTP 포트를 TLS 리스너로 연결해 둔 포트 포워딩, 스킴과 포트를 잘못 조합해 URL을 만드는 리다이렉트에서 자주 나옵니다.
반대 실수, 즉 평문 HTTP만 하는 포트에 https://로 접속하면 보통 ERR_SSL_PROTOCOL_ERROR가 납니다. 브라우저는 TLS 응답을 기대했는데 평문 HTTP 오류가 돌아오기 때문입니다.
포트에 두 스킴을 모두 시험해 보세요.
curl -v http://example.com:8443/
curl -vk https://example.com:8443/
제대로 된 응답이 오는 쪽이 그 포트가 실제로 하는 말입니다.
3. 연결을 들고 있던 프로세스가 죽음
연결을 받아 준 프로세스가 요청을 처리하다 크래시가 나거나, 강제 종료되거나, 재시작되면 커널이 그 소켓들을 닫고 브라우저는 아무것도 받지 못합니다.
여기서 쓸모 있는 단서가 하나 있습니다. 앱 앞에 nginx 같은 리버스 프록시가 있으면, 앱이 죽어도 브라우저의 연결은 프록시가 잡고 있으므로 502 Bad Gateway로 답합니다. 그래서 빈 응답이 난다면 대개 죽은 것이 맨 앞의 프로세스입니다. 앞단 없이 바로 노출된 앱 서버(Node, Gunicorn, 프레임워크 내장 서버)이거나, 프록시 자신입니다.
실패한 요청 시각에 크래시나 강제 종료가 있었는지 보세요.
journalctl -u myapp --since "30 min ago"
systemctl status myapp # 재시작 횟수, 마지막 종료 상태
sudo dmesg -T | grep -i -E 'out of memory|killed process'
docker ps -a # "Exited (137)"은 SIGKILL, OOM killer인 경우가 많음
부하가 몰릴 때나 무거운 페이지에서만 실패한다면 메모리를 가장 먼저 의심하세요. 누수를 고치거나 한도를 올려야 합니다. 주기적 재시작은 문제를 가릴 뿐입니다.
4. Docker로 포트를 공개했는데 앱은 127.0.0.1에서 듣고 있음
로컬 개발에서 아주 흔한 경우입니다. 컨테이너 안의 앱이 127.0.0.1:3000에서 듣고 있는데, 컨테이너 안에서 이 주소는 “이 컨테이너 안에서만 접근 가능”이라는 뜻입니다. -p 3000:3000으로 포트를 공개하고 http://localhost:3000을 엽니다.
같은 머신에서 접속하면 Docker의 포트 포워딩이 연결을 받아 놓고 넘길 곳이 없어서, curl은 Empty reply from server나 Connection reset by peer를, 브라우저는 ERR_EMPTY_RESPONSE나 ERR_CONNECTION_RESET을 보여 줍니다. 다른 머신에서 접속하면 연결 거부가 나올 가능성이 더 큽니다.
해결은 컨테이너 안의 앱이 모든 인터페이스에서 듣게 하는 것입니다. 대부분의 개발 서버는 --host 0.0.0.0, Flask는 app.run(host="0.0.0.0"), Node는 server.listen(3000, "0.0.0.0")입니다. docker exec <컨테이너> ss -ltn(이미지에 ss가 있다면)으로 주소가 127.0.0.1:3000이 아니라 0.0.0.0:3000인지 확인하세요.
5. 요청이 너무 큼
주범은 대개 거대한 쿠키입니다. 쿠키를 계속 추가하는 앱, 방문할 때마다 커지는 세션 쿠키 때문에 요청 헤더가 서버나 로드밸런서의 허용치를 넘게 됩니다. 어떤 서버는 400이나 431 오류 페이지로 답하고, 다른 서버나 일부 로드밸런서·보안 장비는 그냥 연결을 닫습니다.
구별하는 단서는 “시크릿 창에서는 되고 평소 브라우저에서는 안 됨”입니다. 명령줄에서 확인하려면 일부러 큰 쿠키를 보내 보세요.
curl -v -H "Cookie: test=$(head -c 12000 /dev/zero | tr '\0' a)" https://example.com/
쿠키 없는 같은 요청은 되는데 이 요청만 빈 응답이라면 한도를 찾은 것입니다. 쿠키가 커지지 않게 막는 것이 진짜 해결이고, 아니면 모든 계층의 헤더 크기 한도를 올려야 합니다. nginx large_client_header_buffers, Apache LimitRequestFieldSize, 그리고 로드밸런서의 해당 설정입니다.
큰 파일 업로드도 같은 일을 일으킬 수 있습니다. 어떤 계층이 본문을 거절할 때 413으로 답하지 않고 연결을 닫아 버리는 경우입니다.
6. 응답 대신 연결을 끊는 타임아웃
일부 애플리케이션 서버, 프록시, 네트워크 장비는 유휴 또는 요청 타임아웃이 걸렸을 때 503이나 504를 보내지 않고 소켓을 닫습니다. 느린 엔드포인트(엑셀 내보내기, 보고서, 큰 검색)가 주로 걸립니다.
패턴은 알아보기 쉽습니다. 요청이 늘 거의 같은 시간 뒤에 실패합니다. 재 보세요.
curl -sS -o /dev/null -w '%{http_code} %{time_total}s\n' https://example.com/slow-report
매번 60.0초에서 죽는다면, 요청이 지나가는 모든 계층에서 60초짜리 타임아웃을 찾으세요. 그다음 타임아웃을 늘리거나, 더 낫게는 느린 작업을 비동기로 돌려 요청이 빨리 돌아오게 만드세요.
7. 연결을 끊는 보안 소프트웨어
요청을 검사하는 WAF, 침입 방지 시스템, 호스트 방화벽은 요청을 읽어 본 뒤 연결을 닫기로 결정할 수 있습니다. 닫는 방식에 따라 빈 응답이 되기도 하고 리셋이 되기도 합니다. 특정 URL이나 특정 쿼리 문자열에서만 실패한다면, 애플리케이션을 파기 전에 그 시각의 보안 장비 차단 로그부터 보세요.
원 서버에 직접 재현하기
로드밸런서나 CDN이 앞에 있다면, 그 뒤의 서버를 직접 시험해서 어느 계층이 끊는지 확인하세요. --resolve를 쓰면 curl이 특정 IP로 연결하면서도 올바른 호스트명을 보냅니다.
curl -v --resolve example.com:443:203.0.113.10 https://example.com/
서버가 모르는 호스트명을 받았을 때 어떻게 하는지 보려면 이렇게 합니다. 기본 server의 return 444를 잡아내는 방법입니다.
curl -v -H "Host: not-configured.example" http://203.0.113.10/
포트 점검은 바깥에서 그 포트가 열려 있고 연결을 받는지 확인해 줍니다. ‘받아 놓고 끊는 것’과 ‘가끔만 듣고 있는 서비스’를 가를 수 있습니다.
고친 다음에
- HTTP 점검을 다시 돌려 내 네트워크 밖에서도 그 URL이 제대로 된 상태 코드를 돌려주는지 확인하세요.
- 그 서버를 가리키는 모든 호스트명(루트,
www, 각 서브도메인)을 시험하세요. 기본 server 규칙은 내가 빠뜨린 이름에서만 걸립니다. - 원인이 크래시나 타임아웃이었다면 실제 트래픽에서 하루쯤 지켜보세요. 간헐적인 빈 응답은 부하가 돌아오면 같이 돌아옵니다.
비슷해 보이는 다른 오류
- ERR_CONNECTION_RESET: 조용히 닫힌 게 아니라 TCP 리셋으로 끊긴 경우입니다.
- ERR_CONNECTION_REFUSED: 처음부터 연결을 받아 준 프로그램이 없는 경우입니다.
- 502 Bad Gateway: 같은 ‘죽은 백엔드’지만, 앞에 프록시가 있어서 그 사실을 말해 줄 수 있는 경우입니다.
- ERR_CONTENT_LENGTH_MISMATCH: 응답은 시작됐지만 본문 중간에 잘린 경우입니다.
내 도메인에서 바로 확인
무료, 가입 불필요. 이 가이드가 다루는 항목을 바로 검사하고 조치 방법을 알려드립니다.