/인프라/504 Gateway Time-out 원인 진단: nginx·ALB·gunicorn 타임아웃 정렬 런북
인프라HTTP 에러 해결504 Gateway Timeout

504 Gateway Time-out 원인 진단: nginx·ALB·gunicorn 타임아웃 정렬 런북

504 Gateway Time-out이 떴을 때 curl 계측과 응답 헤더만으로 CloudFront·ALB·nginx·gunicorn 중 누가 먼저 끊었는지 특정하는 절차입니다. 계층별 타임아웃 수치표, 정렬 원칙, proxy_read_timeout을 올리지 않고 해결하는 방법까지 정리했습니다.

504 Gateway Time-out 원인 진단: nginx·ALB·gunicorn 타임아웃 정렬 런북

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로 끊긴 시각을 계측한다

Bash
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
code=504 total=60.043 connect=0.031 ttfb=60.041

여기서 판별 원리가 나옵니다.

  • 끊긴 시각이 라운드 넘버(30/60/65/100/300초)로 반복 → 어딘가에 설정된 타임아웃이 발동한 것. 설정값 추적이 정답.
  • 12초·41초·88초처럼 들쭉날쭉 → 앱/DB 지연 또는 큐잉. 설정이 아니라 부하 문제.
  • connect 값이 크다 → 연결 단계 문제(백로그, 보안그룹, 커넥션 고갈). 504가 아니라 사실상 연결 계층 이슈.

같은 요청을 5회 반복해 total 분포를 보는 것이 가장 빠릅니다.

Bash
for i in $(seq 1 5); do
  curl -o /dev/null -s -w "%{http_code} %{time_total}\n" 'https://api.example.com/reports/heavy'
done

2단계 — 응답 헤더로 "누가 응답을 만들었나"를 본다

504 응답을 만든 주체가 곧 끊은 주체입니다.

Bash
curl -sS -D - -o /dev/null 'https://api.example.com/reports/heavy'
헤더 단서응답 생성 주체의미
X-Cache: Error from cloudfront, Via: ... CloudFrontCloudFront엣지가 오리진 응답을 기다리다 끊음
Server: awselb/2.0ALBALB idle timeout 초과
Server: nginx/1.x + 짧은 HTML 에러 페이지nginxproxy_read_timeout 등 발동
Server: gunicorn 등 앱 시그니처앱 프레임워크앱이 스스로 504를 만든 드문 경우
헤더 없이 커넥션만 종료중간 장비/방화벽504가 아닌 연결 리셋 계열 의심

3단계 — 엣지를 우회해 계층을 벗겨낸다

CloudFront가 앞에 있다면 오리진(ALB 또는 nginx)을 직접 호출해 같은 요청이 어떻게 끝나는지 봅니다.

Bash
# 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 박스 안에서는 업스트림을 직접 때려봅니다.

Bash
curl -o /dev/null -s -w 'code=%{http_code} total=%{time_total}\n' \
  http://127.0.0.1:8000/reports/heavy

30초 판정 분기표

입력: 응답 헤더입력: time_total입력: 로그 단서출력: 끊은 주체다음 액션
X-Cache: Error from cloudfront30초 부근 고정오리진 access log에 200이 찍힘CloudFront배포 동작(Origin Response timeout) 확인·상향 검토
Server: awselb/2.060초 부근 고정nginx error.log 없음ALBdescribe-load-balancer-attributes로 idle timeout 확인
Server: nginx60초 부근 고정upstream timed out ... while reading response headernginx앱 응답 지연 원인 규명 (앱이 진짜 느림)
Server: nginx5~10초 고정while connecting to upstreamnginx(연결 단계)업스트림 프로세스·포트·SG·백로그 점검
앱 시그니처 또는 502/50030초 부근[CRITICAL] WORKER TIMEOUT앱(자살)gunicorn --timeout과 핸들러 처리시간 조정
임의매번 다름(12/41/88초)앱 로그에 느린 쿼리앱/DB 지연쿼리·외부 API·큐잉 조사
오리진 직접 호출은 200, 엣지 경유만 504엣지/LB 계층앞단 타임아웃과 앱 응답시간 역전 확인

핵심은 마지막 두 줄입니다. 사용자에게는 504인데 앱 로그에는 200 완료가 찍히는 "유령 증상" 은 거의 항상 바깥 계층 타임아웃이 안쪽보다 짧아 생깁니다.

계층별 타임아웃 수치표와 정렬 원칙

기본값은 제품 버전과 배포 설정에 따라 다릅니다. 아래 표의 "기본값" 칸은 참고용이며, 반드시 확인 명령으로 현재값을 직접 읽는 것을 원칙으로 하세요.

계층기본값설정 위치현재값 확인 명령
CloudFront배포/버전에 따라 다름 — 콘솔 확인 필수Distribution → Origins → Origin settingsaws cloudfront get-distribution-config --id EXXXXXX
ALBidle timeout 60초(변경 가능)EC2 콘솔 → Load Balancer → Attributesaws elbv2 describe-load-balancer-attributes --load-balancer-arn <ARN>
nginx proxy_read_timeout60초nginx.conf / conf.d/*.confnginx -T | grep -i timeout
nginx proxy_connect_timeout60초(단, 75초 상한 규칙 존재)동일동일
gunicorn --timeout30초실행 커맨드 / gunicorn.conf.pyps aux | grep -i gunicorn
uWSGI harakiri미설정 시 무제한uwsgi.inigrep -i harakiri /etc/uwsgi/*.ini
Tomcat connectionTimeout커넥터 설정에 따라 다름conf/server.xmlgrep -i connectionTimeout conf/server.xml
PostgreSQL statement_timeout0(무제한)인 경우가 많음postgresql.conf / 세션SHOW statement_timeout;
MySQL max_execution_time0(무제한)인 경우가 많음my.cnf / 세션SHOW VARIABLES LIKE 'max_execution_time';

확인 명령 모음

Bash
# 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

정렬 원칙: 바깥이 안쪽보다 길어야 한다

타임아웃은 바깥 계층 > 안쪽 계층 순으로 정렬되어야 합니다. 역전되면 이런 일이 벌어집니다.

  1. 앱은 70초 걸려 정상적으로 응답을 완성한다.
  2. 그런데 ALB idle timeout이 60초라 60초 시점에 커넥션을 끊는다.
  3. 사용자는 504를 본다. 그러나 앱 access log에는 200 ... 70.1s가 찍힌다.
  4. 개발팀은 "우리 쪽은 정상"이라고 하고, 장애 원인 파악이 몇 시간씩 늘어진다.

권장 정렬 세트 예시입니다.

계층권장값이유
LB(ALB/CloudFront)65초가장 바깥, 가장 관대
nginx proxy_read_timeout60초LB보다 짧아야 nginx가 원인을 로그에 남김
앱(gunicorn --timeout)55초nginx보다 먼저 끊어 워커 회수
DB(statement_timeout)50초가장 안쪽, 가장 먼저 포기

이렇게 두면 가장 안쪽에서 먼저 실패하므로 로그에 진짜 원인이 남습니다. 반대로 정렬하면 항상 LB만 소리를 지르고 아무도 원인을 모릅니다.

로그 원문별 처방과 진짜 느린 앱 잡기

nginx의 타임아웃 로그는 뒤에 붙는 문구가 곧 진단명입니다. /var/log/nginx/error.log에서 확인하세요.

Bash
grep -i 'timed out' /var/log/nginx/error.log | tail -20
로그 원문발생 단계유력 원인조치
upstream timed out ... while connecting to upstreamTCP 연결 수립백로그 초과, 보안그룹/방화벽, 업스트림 프로세스 다운, 커넥션 풀 고갈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 장기 실행 쿼리 조회

SQL
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_typeLock이면 잠금 경합, IO면 디스크/인덱스 문제로 갈래가 갈립니다.

MySQL slow query log 확인

SQL
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';
-- 세션 단위 임시 활성화 예시
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;

큐잉 판별 계산식

동기 워커 기준으로, 이론상 초당 처리 가능한 요청 수는 대략 다음과 같습니다.

CODE
처리량(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 단위로 한정

전역 상향은 느린 엔드포인트 하나 때문에 전체 서비스의 커넥션 회수를 늦춥니다. 반드시 범위를 좁히세요.

Nginx
# 일반 트래픽: 짧고 엄격하게 유지
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;
    }
}

적용 전에는 항상 문법 검증부터 합니다.

Bash
sudo nginx -t && sudo nginx -s reload

nginx -tsyntax is ok / test is successful을 출력하면 정상입니다. 오류가 나면 리로드하지 말고 메시지에 표시된 파일·라인을 먼저 수정하세요.

안전 범위 가이드: 예외 location이라도 앞단 LB의 idle timeout보다 길게 잡으면 의미가 없습니다. nginx만 180초로 올리고 ALB가 60초면 사용자는 여전히 60초에 504를 봅니다. 반드시 바깥부터 순서대로 올리세요.

쿠버네티스 보정

YAML
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 확인

실제 컨트롤러에 반영된 설정을 직접 읽는 것이 가장 확실합니다.

Bash
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 response70s플랫폼팀
ALB idle timeout65s인프라팀
nginx proxy_read_timeout60s인프라팀
ingress-nginx 어노테이션60s플랫폼팀
gunicorn --timeout55s백엔드팀
DB statement_timeout50sDBA
외부 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 포맷 예시입니다.

Nginx
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 upstreamwhile reading response header from upstream은 어떻게 다르게 대응하나요? A. 앞쪽은 TCP 연결조차 못 맺은 상태로, 업스트림 프로세스 다운·보안그룹·백로그 초과를 봐야 합니다. 뒤쪽은 연결과 요청 전달은 성공했는데 응답이 안 오는 것이라 앱 처리 지연일 확률이 가장 높습니다. 후자에서 타임아웃 값을 올리는 것은 대부분 오답이며, 느린 쿼리와 외부 API 호출을 먼저 프로파일링해야 합니다.

✦ ✦ ✦
편집 검토 · Editorial Review

AI 도구는 자료 조사와 초안 작성의 보조 수단으로 사용될 수 있습니다. Nodelog는 공개 전 내용과 출처를 검토하고, 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서를 함께 확인하도록 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.

편집 책임 · Nodelog 기술 편집팀·발행 · ·업데이트 ·
관련 공식 문서NGINX 공식 문서

댓글

첫 번째 댓글을 남겨보세요.