500 에러가 알려 주는 것과 알려 주지 않는 것
500은 서버가 “내 쪽에서 뭔가 잘못됐는데, 더 구체적인 코드는 없다”고 말하는 것입니다. RFC 9110은 500을 서버가 요청을 처리하지 못하게 만든 예기치 못한 상황으로 정의합니다. 그게 전부입니다. 상태 코드 자체에는 원인에 대한 힌트가 없고, 운영 서버는 일부러 상세 내용을 숨깁니다. 브라우저에 찍힌 스택 트레이스는 공격자에게 주는 선물이기 때문입니다.
그래서 쓸모 있는 사실은 두 가지입니다.
- 요청은 서버까지 도착했고, 오류는 그 서버가 만들었습니다. DNS, 네트워크, TLS는 모두 정상이었습니다.
- 진짜 오류 메시지는 어딘가에 있습니다. 화면을 본 바로 그 초에 어떤 로그에 기록됐습니다. 할 일은 그 로그 한 줄을 찾는 것뿐입니다.
이웃 코드는 가리키는 곳이 다르니, 정말 500을 보고 있는지부터 확인하세요.
| 코드 | 일어난 일 |
|---|---|
| 500 | 응답한 서버가 요청을 처리하다 실패함. 대개 애플리케이션 코드나 그 설정 문제 |
| 502 | 프록시(nginx, 로드밸런서, CDN)가 뒤쪽 백엔드에서 올바른 응답을 받지 못함 |
| 503 | 서비스가 과부하이거나, 점검을 위해 일부러 꺼 둠 |
| 504 | 프록시가 백엔드 응답을 기다리다 포기함 |
흔한 nginx + PHP-FPM, nginx + Node 구성에서 이 차이는 중요합니다. 애플리케이션 프로세스가 죽어 있으면 nginx가 닿지 못해 502가 납니다. 애플리케이션이 살아 있는데 오류를 던지면 500입니다. 즉 500은 앱은 살아 있고, 요청을 처리하는 경로의 무언가가 실패했다는 뜻입니다.
방문자라면
대개 내 쪽에서 고칠 것은 없습니다. 한 번 새로고침해 보세요. 배포나 재시작 도중이라 한 번 500이 나는 경우도 있습니다. 로그인하거나, 장바구니에 담거나, 폼을 제출한 뒤에만 난다면 서버가 나에게 저장된 무언가를 처리하다 막히는 것일 수 있고, 그 사이트의 쿠키를 지우면 가끔 해결됩니다. 그 밖에는 사이트 운영자에게 URL과 오류가 난 시각을 알려 주세요. 시각이 있어야 운영자가 해당 로그를 찾을 수 있습니다.
1단계: 상태 코드와 누가 만들었는지 확인
바깥에서 시작합니다. HTTP 점검은 내 네트워크 밖에서 본 최종 상태 코드와 리다이렉트 경로 전체를 보여 줍니다. curl로는 이렇게 봅니다.
curl -sS -o /dev/null -w '%{http_code}\n' https://www.example.com/checkout
curl -sI https://www.example.com/checkout
본문과 헤더를 보면 어느 층이 오류 화면을 만들었는지 알 수 있습니다. 화면 아래에 “nginx”라고 적혀 있으면 nginx가 직접 만든 것입니다. 사이트 디자인 그대로의 오류 화면이면 애플리케이션의 오류 처리기가 만든 것입니다. CDN의 오류 화면이면 CDN이 상태를 만든 것입니다. Cloudflare에서는 5xx가 Cloudflare 고유의 52x 코드일 수도 있는데, 이것들은 다른 문제를 가리킵니다(예를 들어 522는 원 서버 연결 시간 초과이지 애플리케이션 오류가 아닙니다).
그다음 범위를 좁힙니다. 로그를 열기 전에 어디를 봐야 할지 알려 줍니다.
| 양상 | 가장 유력한 원인 |
|---|---|
/robots.txt 같은 정적 파일까지 모든 URL | 웹 서버 설정(.htaccess, 깨진 지시어, 권한) |
| 동적 페이지 전부, 정적 파일은 정상 | 애플리케이션이 요청 처리를 시작하지 못함: 설정, 환경변수, 의존성 누락, DB 다운 |
| 특정 페이지나 특정 동작 하나 | 그 코드 경로나 입력이 건드리는 버그 |
| 부하가 몰릴 때만, 또는 가끔 | 자원 한도, 또는 여러 서버 중 한 대의 고장 |
| 특정 시각부터 시작 | 그 시각에 배포했거나 바꾼 것 |
2단계: 로그 한 줄 찾기
로그를 보면서 오류를 재현하세요. 터미널 두 개를 열고 하나에는 tail -f, 다른 하나에는 위의 curl을 돌립니다. 그때 찍히는 줄이 답입니다.
어디를 볼지는 스택에 따라 다릅니다.
nginx: 기본값은 /var/log/nginx/error.log입니다. 뒤에 PHP-FPM이 있으면 PHP의 fatal error도 여기에 FastCGI sent in stderr: "PHP message: PHP Fatal error: ..." 같은 형태로 자주 남습니다. nginx -T를 실행하면 사이트별로 따로 지정한 error_log 경로를 포함해 실제 적용된 설정 전체를 볼 수 있습니다.
Apache: Debian·Ubuntu는 /var/log/apache2/error.log, RHEL 계열은 /var/log/httpd/error_log입니다. 가상 호스트마다 ErrorLog를 따로 둘 수 있으니 사이트 설정도 확인하세요.
PHP-FPM: FPM이 읽는 php.ini의 error_log 설정입니다. 명령줄에서 쓰는 php.ini와 다를 수 있습니다. php --ini는 CLI의 파일을 보여 줄 뿐 FPM의 파일은 아닙니다. Debian 계열에서는 FPM이 /etc/php/<버전>/fpm/php.ini를 읽습니다. FPM 서비스 자체의 로그는 journald에 있습니다.
systemd로 도는 모든 것(FPM, gunicorn, Node 서비스 등):
journalctl -u myapp --since "15 min ago" --no-pager
journalctl -u php8.3-fpm -n 100 --no-pager
컨테이너: docker logs --tail 200 <컨테이너>나 docker compose logs --tail=200 app. Kubernetes에서는 kubectl logs deploy/myapp이고, 컨테이너가 재시작됐다면 --previous를 붙이세요. 죽은 원인은 이전 컨테이너의 로그에 있습니다.
프레임워크는 웹 서버와 별개로 자기 로그를 남깁니다.
- Laravel:
storage/logs/laravel.log. - WordPress:
wp-config.php에서WP_DEBUG와WP_DEBUG_LOG를 true로,WP_DEBUG_DISPLAY를 false로 두면 방문자에게 보이지 않게wp-content/debug.log에 기록됩니다. 확인이 끝나면 다시 끄세요. - Django:
DEBUG = False이면 처리되지 않은 예외는 logging 설정을 따라가고, 메일이 설정돼 있으면ADMINS에게도 갑니다. 아무것도 안 보인다면 logging 설정이django.request를 어디에도 보내지 않는 것입니다. - Rails:
log/production.log. - Node/Express, Spring Boot, 대부분의 Go 서비스: 표준 출력. 즉 journald나 컨테이너 로그입니다.
호스팅(공유 호스팅): 관리 화면에서 “에러 로그” 메뉴를 찾으세요. cPanel 기반 호스팅 상당수는 PHP 오류를 실패한 스크립트가 있는 디렉터리에 error_log라는 파일로도 남깁니다.
어디에서도 아무것도 안 나온다면 오류가 삼켜지고 있는 것입니다. 로깅이 꺼져 있거나 아직 못 찾은 파일로 가고 있습니다. 그것부터 고치세요. 로그 없이 500을 추측으로 잡는 건 오래 걸립니다.
흔한 원인
깨진 .htaccess, 또는 로드되지 않은 모듈의 지시어
Apache는 요청마다 .htaccess를 읽고, 잘못된 줄이 하나라도 있으면 그 디렉터리의 모든 요청이 500으로 실패합니다. apachectl configtest는 .htaccess를 검사하지 않으므로, configtest가 통과했다고 안심할 수 없습니다. 오류 로그에는 파일과 줄이 찍힙니다.
/var/www/html/.htaccess: Invalid command 'RewriteEngine', perhaps misspelled or defined by a module not included in the server configuration
이건 mod_rewrite가 로드되지 않았다는 뜻입니다(Debian·Ubuntu에서는 a2enmod rewrite 후 reload). 아주 흔한 변종이 Invalid command 'php_value'입니다. php_value·php_flag는 mod_php에서만 동작하므로, 호스팅이 PHP-FPM으로 바뀌면 이 줄이 500을 냅니다. 그 설정은 .user.ini나 FPM pool 설정으로 옮기세요.
애플리케이션이 써야 할 곳에 쓰지 못함
정적 페이지는 되고 동적 페이지는 전부 실패합니다. 앱이 세션, 캐시 파일, 자기 로그를 쓰려다 “Permission denied”를 받는 경우입니다. Laravel의 storage/와 bootstrap/cache/, 업로드 디렉터리, 세션 디렉터리가 단골입니다. root나 다른 사용자로 배포를 돌린 뒤에 자주 생깁니다.
해결은 chmod 777이 아니라 소유권입니다. 그 디렉터리들을 애플리케이션이 실행되는 사용자의 소유로 하거나 그 사용자가 쓸 수 있게 하세요(ps -o user= -p <pid>로 누구로 도는지 확인할 수 있습니다).
PHP를 계정 사용자 권한으로 실행하는 일부 공유 호스팅은 그룹이나 다른 사용자가 쓸 수 있는 스크립트의 실행을 거부하고 500으로 응답합니다. chmod -R 777 뒤에 갑자기 스크립트가 실패한다면 이 때문입니다. 파일은 644, 디렉터리는 755가 맞습니다.
배포 후 환경변수·설정 누락
코드는 DB 주소, API 키, 앱 시크릿을 기대하는데 그것이 없습니다. Laravel은 APP_KEY가 없으면 모든 요청에서 “No application encryption key has been specified.”로 실패합니다. 필요한 시크릿 없이 뜬 컨테이너는 그 값을 쓰는 첫 요청에서 예외를 던집니다.
반복해서 걸리는 함정이 둘 있습니다. 예전 서버에 있던 .env 파일은 git에 없으니 새 서버로 복사되지 않았습니다. 그리고 셸에서 export한 변수는 systemd 서비스에는 없습니다. 서비스는 로그인 환경을 물려받지 않으므로 unit 파일에 Environment=나 EnvironmentFile=로 지정하고 서비스를 재시작하세요.
메모리 고갈
PHP의 메시지는 분명합니다.
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes)
134217728바이트는 128MB, PHP의 기본 memory_limit입니다. 원래 무거운 페이지(대량 내보내기, 이미지 처리)라면 한도를 올려도 됩니다. 평범한 페이지가 갑자기 더 필요해졌다면 모든 데이터를 메모리에 올리는 반복문이나 쿼리를 찾으세요.
운영체제 자체의 메모리가 바닥나면 커널의 OOM killer가 프로세스를 죽이고, 방문자가 보는 결과는 무엇이 죽었는지에 따라 다릅니다. nginx 뒤의 워커가 죽으면 대개 500이 아니라 502로 보입니다. 이렇게 확인합니다.
journalctl -k | grep -i -E 'out of memory|killed process'
DB나 다른 의존 서비스에 닿지 못함
앱은 돌고 있지만 DB, 캐시, 내부 API에 닿지 못하고, 프레임워크가 그 예외를 500으로 바꿉니다. 로그에는 보통 connection refused, 비밀번호 교체 뒤의 인증 실패, 부하 시 MySQL의 “Too many connections”가 찍힙니다. 의존 서비스가 살아 있는지는 내 노트북이 아니라 애플리케이션 서버에서 확인하세요.
반쯤만 된 배포
새 코드에 옛 의존성이 붙은 경우입니다. vendor/나 node_modules가 없거나, 클래스·모듈을 찾지 못하거나, DB 마이그레이션을 돌리지 않아 코드가 없는 컬럼을 조회합니다. 런타임 업그레이드(PHP나 Node 메이저 버전 변경)도 제거된 함수 때문에 같은 결과를 냅니다. 배포 시점부터 500이 시작됐다면 먼저 롤백하고 그다음에 원인을 찾으세요.
디스크 가득 참
세션, 업로드, 캐시, 로그 모두 공간이 필요합니다. 디스크가 차면 쓰기가 실패하고 앱이 예외를 던집니다. 용량과 inode를 둘 다 보세요. 작은 세션·캐시 파일이 수백만 개 쌓이면 df -h에는 여유가 있어도 inode가 바닥날 수 있습니다.
df -h; df -i
nginx가 직접 내는 500: 내부 리다이렉트 순환
nginx 자체가 500을 만드는 경우도 있습니다. 대표적인 예가 존재하지 않는 파일을 가리키는 SPA fallback입니다.
rewrite or internal redirection cycle while internally redirecting to "/index.html"
try_files $uri $uri/ /index.html;인데 root에 index.html이 없으면(빌드 디렉터리가 틀렸거나 빌드가 실패한 경우) nginx가 내부 리다이렉트를 반복하다 500으로 포기합니다. root가 실제 빌드 결과물을 가리키는지 확인하세요.
간헐적인 500
일부 요청만 실패한다면 그 요청들이 무엇이 다른지 찾아야 합니다.
로드밸런서 뒤에서는 여러 서버 중 한 대만 고장 나 있을 수 있습니다. 백엔드를 하나씩 직접 찔러 보세요. curl --resolve www.example.com:443:10.0.0.12 https://www.example.com/은 올바른 호스트명을 유지한 채 특정 서버 한 대로 요청을 보냅니다. 지난 배포가 빠졌거나 디스크가 찬 서버가 바로 드러납니다.
부하 때만 난다면 실패 시각 전후의 커넥션 풀, 워커 수, 메모리, 파일 디스크립터를 보세요. 특정 페이지에서만 나는 오류는 대개 로그에 원인이 된 입력이 보입니다. 폼 필드의 예상 못 한 문자, 가격이 빠진 상품, 프로필 행이 없는 사용자 같은 것들입니다.
운영 환경에서 디버그를 켜지 마세요
브라우저에서 오류를 보려고 APP_DEBUG=true나 DEBUG = True로 바꾸고 싶어집니다. 공개 사이트에서는 오류를 만난 모든 사람이 같은 화면을 보고, 프레임워크 디버그 화면에는 환경변수, 인증 정보, 전체 파일 경로, SQL이 담길 수 있습니다. 로그를 읽거나, 디버그를 켠 스테이징 사본에서 재현하세요.
다음번을 더 빨리 끝내려면
- 예외를 오류 추적 도구로 보내세요. 서버에 접속하지 않고도 스택 트레이스와 원인이 된 요청을 받을 수 있습니다.
- 모든 로그 줄에 요청 ID를 넣고 오류 화면에도 보여 주세요. “오류 화면에 ID abc123이 있었다”는 말이 곧바로 로그 항목으로 이어집니다.
- 직접 만든 오류 화면도 상태 코드 500을 유지하세요. 친절한 화면을 200으로 내보내면 모니터링과 검색엔진 모두 실패를 보지 못합니다.
고친 뒤에는 바깥에서 HTTP 점검을 다시 돌려 최종 상태가 200인지 확인하고, 신고된 URL 하나만이 아니라 같은 사이트의 다른 URL 몇 개도 확인하세요.
비슷해 보이는 다른 오류
- 502 Bad Gateway: 프록시가 앱에서 올바른 응답을 아예 받지 못했습니다.
- 503 Service Unavailable: 과부하이거나 점검 중입니다.
- 504 Gateway Timeout: 앱의 응답이 너무 늦었습니다.
내 도메인에서 바로 확인
무료, 가입 불필요. 이 가이드가 다루는 항목을 바로 검사하고 조치 방법을 알려드립니다.