502와 504는 완전히 다른 사고다
502 Bad Gateway는 "연결 또는 응답 자체가 실패"한 상태입니다. 커넥션 거부, 프로토콜 오류, 업스트림이 응답 도중 커넥션을 끊어버린 경우가 대표적이죠. 반면 504 Gateway Time-out은 연결도 정상적으로 맺어졌고 요청도 전달됐는데, 정해진 시간 안에 응답 헤더가 돌아오지 않은 상태입니다. 즉 502는 "말을 걸었더니 문전박대", 504는 "말은 걸었는데 대답이 없어 먼저 자리를 뜬" 상황입니다. 진단 출발점이 다르기 때문에 502용 체크리스트를 504에 그대로 적용하면 헛다리를 짚습니다. 502가 의심된다면 nginx 502 Bad Gateway 원인 진단표·복붙 명령어로 5분 해결을 먼저 보시고, 이 글은 504 전용으로 진행합니다.
504의 핵심 질문은 딱 하나입니다. "누가 먼저 끊었는가?" 요즘은 LLM API 프록시, 리포트 생성, 대용량 배치 트리거처럼 응답이 수십 초 걸리는 엔드포인트가 늘면서 504가 다시 자주 보고되고 있습니다. 여기에 서비스 메시나 API 게이트웨이가 붙으면 타임아웃 계층이 한 겹 더 늘어나 범인 찾기가 더 어려워집니다. 그래서 "일단 proxy_read_timeout을 300초로" 같은 대응은 증상만 숨기고 장애 시간을 늘립니다.
30초 판정: 끊긴 시각과 응답 헤더로 범인 지목
1단계 — curl로 끊긴 시각을 계측한다
curl -o /dev/null -s -w \
'code=%{http_code} total=%{time_total} connect=%{time_connect} ttfb=%{time_starttransfer}\n' \
'https://api.example.com/reports/heavy'예상 정상 결과는 code=200 total=1.2 ... 형태입니다. 504가 재현되면 다음처럼 나옵니다.
code=504 total=60.043 connect=0.031 ttfb=60.041여기서 판별 원리가 나옵니다.
- 끊긴 시각이 라운드 넘버(30/60/65/100/300초)로 반복 → 어딘가에 설정된 타임아웃이 발동한 것. 설정값 추적이 정답.
- 12초·41초·88초처럼 들쭉날쭉 → 앱/DB 지연 또는 큐잉. 설정이 아니라 부하 문제.
connect값이 크다 → 연결 단계 문제(백로그, 보안그룹, 커넥션 고갈). 504가 아니라 사실상 연결 계층 이슈.
같은 요청을 5회 반복해 total 분포를 보는 것이 가장 빠릅니다.
for i in $(seq 1 5); do
curl -o /dev/null -s -w "%{http_code} %{time_total}\n" 'https://api.example.com/reports/heavy'
done2단계 — 응답 헤더로 "누가 응답을 만들었나"를 본다
504 응답을 만든 주체가 곧 끊은 주체입니다.
curl -sS -D - -o /dev/null 'https://api.example.com/reports/heavy'| 헤더 단서 | 응답 생성 주체 | 의미 |
|---|---|---|
X-Cache: Error from cloudfront, Via: ... CloudFront | CloudFront | 엣지가 오리진 응답을 기다리다 끊음 |
Server: awselb/2.0 | ALB | ALB idle timeout 초과 |
Server: nginx/1.x + 짧은 HTML 에러 페이지 | nginx | proxy_read_timeout 등 발동 |
Server: gunicorn 등 앱 시그니처 | 앱 프레임워크 | 앱이 스스로 504를 만든 드문 경우 |
| 헤더 없이 커넥션만 종료 | 중간 장비/방화벽 | 504가 아닌 연결 리셋 계열 의심 |
3단계 — 엣지를 우회해 계층을 벗겨낸다
CloudFront가 앞에 있다면 오리진(ALB 또는 nginx)을 직접 호출해 같은 요청이 어떻게 끝나는지 봅니다.
# DNS를 무시하고 특정 오리진 IP로 직접 호출 (Host 헤더는 유지)
curl -o /dev/null -s -w 'code=%{http_code} total=%{time_total}\n' \
--resolve api.example.com:443:10.0.12.34 \
'https://api.example.com/reports/heavy'nginx 박스 안에서는 업스트림을 직접 때려봅니다.
curl -o /dev/null -s -w 'code=%{http_code} total=%{time_total}\n' \
http://127.0.0.1:8000/reports/heavy30초 판정 분기표
| 입력: 응답 헤더 | 입력: time_total | 입력: 로그 단서 | 출력: 끊은 주체 | 다음 액션 |
|---|---|---|---|---|
X-Cache: Error from cloudfront | 30초 부근 고정 | 오리진 access log에 200이 찍힘 | CloudFront | 배포 동작(Origin Response timeout) 확인·상향 검토 |
Server: awselb/2.0 | 60초 부근 고정 | nginx error.log 없음 | ALB | describe-load-balancer-attributes로 idle timeout 확인 |
Server: nginx | 60초 부근 고정 | upstream timed out ... while reading response header | nginx | 앱 응답 지연 원인 규명 (앱이 진짜 느림) |
Server: nginx | 5~10초 고정 | while connecting to upstream | nginx(연결 단계) | 업스트림 프로세스·포트·SG·백로그 점검 |
| 앱 시그니처 또는 502/500 | 30초 부근 | [CRITICAL] WORKER TIMEOUT | 앱(자살) | gunicorn --timeout과 핸들러 처리시간 조정 |
| 임의 | 매번 다름(12/41/88초) | 앱 로그에 느린 쿼리 | 앱/DB 지연 | 쿼리·외부 API·큐잉 조사 |
| 오리진 직접 호출은 200, 엣지 경유만 504 | — | — | 엣지/LB 계층 | 앞단 타임아웃과 앱 응답시간 역전 확인 |
핵심은 마지막 두 줄입니다. 사용자에게는 504인데 앱 로그에는 200 완료가 찍히는 "유령 증상" 은 거의 항상 바깥 계층 타임아웃이 안쪽보다 짧아 생깁니다.
계층별 타임아웃 수치표와 정렬 원칙
기본값은 제품 버전과 배포 설정에 따라 다릅니다. 아래 표의 "기본값" 칸은 참고용이며, 반드시 확인 명령으로 현재값을 직접 읽는 것을 원칙으로 하세요.
| 계층 | 기본값 | 설정 위치 | 현재값 확인 명령 |
|---|---|---|---|
| CloudFront | 배포/버전에 따라 다름 — 콘솔 확인 필수 | Distribution → Origins → Origin settings | aws cloudfront get-distribution-config --id EXXXXXX |
| ALB | idle timeout 60초(변경 가능) | EC2 콘솔 → Load Balancer → Attributes | aws elbv2 describe-load-balancer-attributes --load-balancer-arn <ARN> |
nginx proxy_read_timeout | 60초 | nginx.conf / conf.d/*.conf | nginx -T | grep -i timeout |
nginx proxy_connect_timeout | 60초(단, 75초 상한 규칙 존재) | 동일 | 동일 |
gunicorn --timeout | 30초 | 실행 커맨드 / gunicorn.conf.py | ps aux | grep -i gunicorn |
uWSGI harakiri | 미설정 시 무제한 | uwsgi.ini | grep -i harakiri /etc/uwsgi/*.ini |
Tomcat connectionTimeout | 커넥터 설정에 따라 다름 | conf/server.xml | grep -i connectionTimeout conf/server.xml |
PostgreSQL statement_timeout | 0(무제한)인 경우가 많음 | postgresql.conf / 세션 | SHOW statement_timeout; |
MySQL max_execution_time | 0(무제한)인 경우가 많음 | my.cnf / 세션 | SHOW VARIABLES LIKE 'max_execution_time'; |
확인 명령 모음
# nginx: 실제 로드된 전체 설정에서 타임아웃 관련 지시어만 추출
nginx -T 2>/dev/null | grep -i -E 'timeout|keepalive'
# ALB: idle timeout 속성 확인
aws elbv2 describe-load-balancer-attributes \
--load-balancer-arn arn:aws:elasticloadbalancing:ap-northeast-2:1234:loadbalancer/app/my-alb/abcd \
--query "Attributes[?Key=='idle_timeout.timeout_seconds']"
# gunicorn: 실행 중 프로세스의 인자에서 timeout/worker 확인
ps aux | grep '[g]unicorn'
grep -i -E 'timeout|workers' /etc/gunicorn/gunicorn.conf.py정렬 원칙: 바깥이 안쪽보다 길어야 한다
타임아웃은 바깥 계층 > 안쪽 계층 순으로 정렬되어야 합니다. 역전되면 이런 일이 벌어집니다.
- 앱은 70초 걸려 정상적으로 응답을 완성한다.
- 그런데 ALB idle timeout이 60초라 60초 시점에 커넥션을 끊는다.
- 사용자는 504를 본다. 그러나 앱 access log에는
200 ... 70.1s가 찍힌다. - 개발팀은 "우리 쪽은 정상"이라고 하고, 장애 원인 파악이 몇 시간씩 늘어진다.
권장 정렬 세트 예시입니다.
| 계층 | 권장값 | 이유 |
|---|---|---|
| LB(ALB/CloudFront) | 65초 | 가장 바깥, 가장 관대 |
nginx proxy_read_timeout | 60초 | LB보다 짧아야 nginx가 원인을 로그에 남김 |
앱(gunicorn --timeout) | 55초 | nginx보다 먼저 끊어 워커 회수 |
DB(statement_timeout) | 50초 | 가장 안쪽, 가장 먼저 포기 |
이렇게 두면 가장 안쪽에서 먼저 실패하므로 로그에 진짜 원인이 남습니다. 반대로 정렬하면 항상 LB만 소리를 지르고 아무도 원인을 모릅니다.
로그 원문별 처방과 진짜 느린 앱 잡기
nginx의 타임아웃 로그는 뒤에 붙는 문구가 곧 진단명입니다. /var/log/nginx/error.log에서 확인하세요.
grep -i 'timed out' /var/log/nginx/error.log | tail -20| 로그 원문 | 발생 단계 | 유력 원인 | 조치 |
|---|---|---|---|
upstream timed out ... while connecting to upstream | TCP 연결 수립 | 백로그 초과, 보안그룹/방화벽, 업스트림 프로세스 다운, 커넥션 풀 고갈 | ss -ltnp로 리스닝 확인, net.core.somaxconn·backlog 점검, SG 인바운드 확인 |
upstream timed out ... while sending request to upstream | 요청 본문 전송 중 | 대용량 업로드, 느린 클라이언트, client_body_* 관련 설정 | proxy_send_timeout·client_body_timeout 검토, 업로드는 별도 location 분리 |
upstream timed out ... while reading response header from upstream | 응답 생성 대기 | 앱이 실제로 느림(확률 최상), DB 지연, 외부 API 지연, 워커 큐잉 | 타임아웃 상향 금지. 아래 앱 진단 절차로 진행 |
[CRITICAL] WORKER TIMEOUT (pid:1234) (gunicorn) | 앱 자체 | 핸들러가 --timeout 초과, 동기 워커에서 블로킹 I/O | 워커 타입(gevent/uvicorn) 전환, 무거운 작업 비동기화 |
HARAKIRI ON WORKER (uWSGI) | 앱 자체 | harakiri 초과 | 요청 처리 시간 프로파일링, harakiri는 최후 안전장치로만 |
중요한 갈림길: nginx 로그와 gunicorn WORKER TIMEOUT이 함께 찍혔다면 앱이 스스로 포기한 것이고, nginx 로그만 있고 앱은 조용하다면 프록시가 먼저 끊은 것입니다. 후자는 정렬 문제일 가능성이 큽니다.
진짜 느린 앱 규명하기
PostgreSQL 장기 실행 쿼리 조회
SELECT pid,
now() - query_start AS duration,
state,
wait_event_type,
left(query, 120) AS query
FROM pg_stat_activity
WHERE state <> 'idle'
AND now() - query_start > interval '5 seconds'
ORDER BY duration DESC;정상이라면 결과가 비어 있거나 짧은 배치 쿼리 몇 건만 보입니다. 504와 동일한 시각에 수십 초짜리 쿼리가 계속 나온다면 원인은 DB입니다. wait_event_type이 Lock이면 잠금 경합, IO면 디스크/인덱스 문제로 갈래가 갈립니다.
MySQL slow query log 확인
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';
-- 세션 단위 임시 활성화 예시
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;큐잉 판별 계산식
동기 워커 기준으로, 이론상 초당 처리 가능한 요청 수는 대략 다음과 같습니다.
처리량(req/s) ≈ 워커 수 / 평균 응답시간(초)워커 8개, 평균 응답 0.8초라면 약 10 req/s가 한계입니다. 여기에 30 req/s가 들어오면 초과분은 큐에 쌓이고, 개별 요청은 빠른데도 대기 시간이 누적돼 504가 납니다. 이때는 쿼리를 아무리 튜닝해도 안 잡히고 워커 수·워커 타입·오토스케일링이 답입니다.
N+1 vs 외부 API 지연 구분
- 요청 1건의 DB 쿼리 수가 수백 건인데 각각은 1ms 미만 → N+1.
select_related/join/배치 로딩으로 해결. - DB 쿼리는 몇 건뿐인데 응답시간의 대부분이 특정 구간에 몰림 → 외부 API 호출. 호출 측 타임아웃을 반드시 명시하고 재시도 정책을 붙여야 합니다. 외부 API에 타임아웃이 없으면 우리 서비스의 504는 남의 장애에 종속됩니다.
늘리기 전에 구조를 바꾼다
정공법: 비동기 잡 + 폴링/SSE
리포트 생성, LLM 호출, 대용량 집계처럼 정당하게 오래 걸리는 작업은 타임아웃을 늘려서 버틸 대상이 아닙니다. 요청을 받으면 즉시 202 Accepted와 잡 ID를 반환하고, 클라이언트가 상태를 폴링하거나 SSE/WebSocket으로 진행 상황을 받는 구조로 바꾸는 것이 정답입니다. HTTP 커넥션을 60초씩 붙잡아두는 설계는 LB 재시작, 배포, 스케일인 어느 하나에도 취약합니다.
스트리밍 응답(SSE)을 도입한다면 프록시 버퍼링 설정을 반드시 함께 점검하세요. 버퍼링이 켜져 있으면 앱이 토큰을 흘려보내도 nginx가 모아뒀다 한 번에 내보내면서 결국 헤더 대기 타임아웃에 걸립니다.
그래도 늘려야 한다면: location 단위로 한정
전역 상향은 느린 엔드포인트 하나 때문에 전체 서비스의 커넥션 회수를 늦춥니다. 반드시 범위를 좁히세요.
# 일반 트래픽: 짧고 엄격하게 유지
server {
listen 80;
server_name api.example.com;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
location / {
proxy_pass http://app_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 오래 걸리는 엔드포인트만 예외 처리
location /api/reports/generate {
proxy_pass http://app_upstream;
proxy_connect_timeout 5s;
proxy_send_timeout 180s;
proxy_read_timeout 180s;
proxy_set_header Host $host;
}
# SSE/스트리밍 엔드포인트: 버퍼링 해제가 핵심
location /api/stream {
proxy_pass http://app_upstream;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
}적용 전에는 항상 문법 검증부터 합니다.
sudo nginx -t && sudo nginx -s reloadnginx -t가 syntax is ok / test is successful을 출력하면 정상입니다. 오류가 나면 리로드하지 말고 메시지에 표시된 파일·라인을 먼저 수정하세요.
안전 범위 가이드: 예외 location이라도 앞단 LB의 idle timeout보다 길게 잡으면 의미가 없습니다. nginx만 180초로 올리고 ALB가 60초면 사용자는 여전히 60초에 504를 봅니다. 반드시 바깥부터 순서대로 올리세요.
쿠버네티스 보정
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-ingress
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "180"
nginx.ingress.kubernetes.io/proxy-send-timeout: "180"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "5"
spec:
ingressClassName: nginx
rules:
- host: api.example.com
http:
paths:
- path: /api/reports
pathType: Prefix
backend:
service:
name: api-svc
port:
number: 8000어노테이션이 안 먹는 흔한 이유는 다음과 같습니다.
| 증상 | 원인 | 확인 방법 |
|---|---|---|
| 어노테이션 무시됨 | ingressClassName 불일치(다른 컨트롤러가 처리) | kubectl get ingress -o yaml로 클래스 확인 |
| 값이 반영 안 됨 | 값 형식 오류("180s"처럼 단위 포함) — 초 단위 숫자만 | 컨트롤러 파드 로그의 파싱 경고 확인 |
| 일부만 반영 | ConfigMap 전역값과 어노테이션 우선순위 혼동 | 실제 생성된 conf 확인 |
| 설정은 맞는데 여전히 504 | 앞단 클라우드 LB(NLB/ALB)가 먼저 끊음 | LB idle timeout 확인 |
실제 컨트롤러에 반영된 설정을 직접 읽는 것이 가장 확실합니다.
kubectl get ingress api-ingress -o yaml
kubectl -n ingress-nginx exec deploy/ingress-nginx-controller -- \
nginx -T 2>/dev/null | grep -i -E 'proxy_read_timeout|proxy_send_timeout'여기서 proxy_read_timeout 180s;가 보이면 반영된 것이고, 여전히 60s라면 어노테이션이 적용되지 않은 것입니다. 쿠버네티스 5xx 전반의 계층별 추적은 K8s 5xx 에러 원인 분석: Ingress/Gateway API 7단계 디버깅 가이드도 함께 보면 좋습니다.
재발 방지: 타임아웃 인벤토리와 모니터링
504는 "누가 어디에 무슨 값을 넣어놨는지 아무도 모르는 상태"에서 반복됩니다. 아래 표를 위키나 리포지토리에 두고 분기마다 갱신하세요.
| 계층 | 현재값 | 목표값 | 담당 | 최종 확인일 |
|---|---|---|---|---|
| CloudFront origin response | 70s | 플랫폼팀 | ||
| ALB idle timeout | 65s | 인프라팀 | ||
| nginx proxy_read_timeout | 60s | 인프라팀 | ||
| ingress-nginx 어노테이션 | 60s | 플랫폼팀 | ||
| gunicorn --timeout | 55s | 백엔드팀 | ||
| DB statement_timeout | 50s | DBA | ||
| 외부 API 호출 타임아웃 | 20s | 백엔드팀 |
모니터링에서 볼 지표는 세 가지입니다.
HTTPCode_ELB_5XX_Count— LB가 스스로 만든 5xx. 급증하면 앱이 아니라 LB 계층 판단.upstream_response_time백분위(p95/p99) — nginx access log에$upstream_response_time을 반드시 포함시켜 수집.- 타임아웃 임계 대비 p99 여유율 — p99가 임계의 70%를 넘으면 경보. 504가 터진 뒤가 아니라 터지기 전에 대응할 수 있습니다.
access log 포맷 예시입니다.
log_format timing '$remote_addr - $status $request_time '
'upstream=$upstream_response_time '
'addr=$upstream_addr "$request"';
access_log /var/log/nginx/access.log timing;$request_time(클라이언트 관점 전체)과 $upstream_response_time(업스트림 처리 시간)의 차이가 크면 클라이언트 네트워크나 요청 본문 전송이 느린 것이고, 둘이 비슷하게 크면 앱이 느린 것입니다. 이 한 줄 차이만으로도 다음 504의 진단 시간이 크게 줄어듭니다.
자주 묻는 질문 (FAQ)
Q. proxy_read_timeout을 300초로 올리면 504가 사라지나요?
A. 사용자 화면에서는 사라질 수 있지만, 커넥션과 워커가 그만큼 오래 점유되어 동시 처리량이 떨어집니다. 트래픽이 조금만 늘어도 큐잉이 발생해 더 큰 장애로 번집니다. 앞단 LB의 idle timeout이 더 짧다면 아예 효과도 없습니다. 오래 걸리는 작업은 비동기 잡으로 전환하고, 타임아웃 상향은 해당 location에만 한정 적용하는 것이 안전합니다.
Q. 사용자는 504를 보는데 앱 로그에는 200이 찍힙니다. 왜 그런가요? A. 전형적인 타임아웃 역전입니다. 앱은 정상적으로 응답을 완성했지만, 그 전에 바깥 계층(ALB나 CloudFront)이 먼저 커넥션을 끊어 클라이언트에 504를 반환한 것입니다. 계층별 타임아웃을 "바깥 > 안쪽" 순으로 정렬하면 가장 안쪽에서 먼저 실패해 원인이 로그에 남습니다.
Q. while connecting to upstream과 while reading response header from upstream은 어떻게 다르게 대응하나요?
A. 앞쪽은 TCP 연결조차 못 맺은 상태로, 업스트림 프로세스 다운·보안그룹·백로그 초과를 봐야 합니다. 뒤쪽은 연결과 요청 전달은 성공했는데 응답이 안 오는 것이라 앱 처리 지연일 확률이 가장 높습니다. 후자에서 타임아웃 값을 올리는 것은 대부분 오답이며, 느린 쿼리와 외부 API 호출을 먼저 프로파일링해야 합니다.
AI 도구는 자료 조사와 초안 작성의 보조 수단으로 사용될 수 있습니다. Nodelog는 공개 전 내용과 출처를 검토하고, 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서를 함께 확인하도록 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
댓글
첫 번째 댓글을 남겨보세요.