View
504 Gateway Timeout 진짜 누가 끊었는가 — backend·nginx·ALB·Cloudflare 4단 스택을 직접 띄워 헤더로 귀속하다
DevNinja 2026. 5. 8. 23:36운영 중에 504가 떴을 때 일단 nginx의 proxy_read_timeout부터 늘리고 보는 사람이 많은데, 그것이 답이 아닐 수 있다. 진짜로 끊은 주체가 nginx가 아니라 ALB(Amazon 로드밸런서)나 Cloudflare인 경우가 더 자주 있으며, 근거 없이 nginx만 만지면 같은 에러가 또 발생한다.
각설하고 결론부터 박는다. 504 Gateway Timeout 원인을 nginx에만 떠넘기면 ALB가 끊은 케이스를 놓친다. 4단 스택(backend → nginx → ALB → Cloudflare)에서는 누가 먼저 timeout을 만료시키느냐에 따라 응답 status 한 줄과 식별자 헤더 3개(X-Nginx-Trace, X-Amzn-Trace-Id, CF-Ray)의 존재·부재 조합이 결정론적으로 갈린다. 그 조합만 보고 30초 안에 어느 레이어가 끊었는지 귀속할 수 있다.
비유하면 이렇다. 택배가 한국 → 일본 → 미국 → 우리집 4단을 경유하는데, 어디서 멈췄는지 알려면 각 거점이 찍은 도장 3개를 보면 된다. 도장이 어디서부터 안 찍혔는지가 곧 누가 끊었는지의 증거다. 이 글은 그 도장을 직접 만들어 5개 시나리오로 실측한 결과를 보여준다.
504와 524는 같지 않다 — 코드부터 다른 이유
검색해보면 504와 524를 묶어 "둘 다 timeout이니 같은 것"으로 설명하는 글이 더러 있는데, 진단 측면에서는 완전히 다른 신호다. 코드가 다르다는 것은 발급한 주체가 다르다는 뜻이며, 발급 주체가 다르면 fix해야 할 위치도 다르다.
504(Gateway Timeout)는 RFC 7231 표준이다
504는 RFC 7231 표준 status code이다. 게이트웨이나 프록시 역할을 하는 어떤 서버든 발급할 수 있다. 즉 nginx, ALB, HAProxy, Cloudflare 모두 504를 던질 권한이 있다. 그래서 504만 보고는 누가 끊었는지 단정할 수 없다. 헤더 추가 분기가 필요하다.
524는 Cloudflare 전용이다
524는 표준이 아니라 Cloudflare 자체 상태 코드다. Cloudflare가 origin(원본 서버)과 TCP 연결은 했는데 100초 안에 HTTP 응답을 받지 못하면 발급한다. 즉 524가 보이면 origin이 살아 있긴 하지만 너무 느린 것이다. 백엔드 처리가 느리다는 신호이지 네트워크 끊김 신호가 아니다.
504/524 분기 자체가 진단 단서다
이 둘 분기만 잘 봐도 디버깅 시간이 절반으로 줄어든다.
- status 524 → 무조건 Cloudflare가 끊은 것. 백엔드 처리 시간 점검부터
- status 504 → nginx 또는 ALB가 끊었음. 헤더로 추가 분기 필요
운영 중에 524가 떴는데 "Cloudflare 끄면 되겠지" 하고 끄는 것은 헛삽질이다. 524는 origin이 느리다는 신호일 뿐, 끄면 원인이 보이지 않을 뿐 사라지지 않는다.
504 Gateway Timeout 원인을 가르는 식별자 헤더 3개
504 Gateway Timeout 디버깅에 쓸 수 있는 식별자 헤더가 정확히 3개 있다. 이 3개가 결정론적으로 어느 레이어가 응답을 만졌는지 알려준다. 없는 헤더는 곧 그 레이어를 통과하지 못했다는 뜻이다.
X-Nginx-Trace — nginx가 응답을 만졌는지 확인용
nginx가 직접 발급한 504라면 이 헤더가 살아 있다. nginx는 자신이 만든 응답에 자신의 식별자를 부착한다. 만약 504 응답에 X-Nginx-Trace가 없다면 nginx보다 위 레이어(ALB나 Cloudflare)가 nginx 응답이 도달하기 전에 먼저 끊은 것이다.
함정이 있다. nginx 설정에서 add_header X-Nginx-Trace $request_id;만 쓰고 always를 붙이지 않으면 200 응답에만 헤더가 붙고 504에는 붙지 않는다. 그러면 진단이 무용지물이 된다. 무조건 always를 붙여야 한다. 이는 글 마지막에 다시 박는다.
X-Amzn-Trace-Id — ALB가 연결을 받았는지 증거
ALB는 통과한 모든 요청·응답에 X-Amzn-Trace-Id를 자동 부착한다. 별도 설정이 필요 없다. 즉 status 504 + X-Nginx-Trace 부재 + X-Amzn-Trace-Id 존재 조합이라면 "ALB가 nginx 응답이 도착하기 전에 idle timeout으로 끊었다"는 명백한 증거다.
이것이 가장 흔한 오진 케이스다. nginx의 proxy_read_timeout을 60초로 두고 ALB도 60초인 채로 두면 ALB가 5~10ms 차이로 먼저 끊는 경우가 많다. 그 상황에서 nginx만 늘리면 효과가 0이다.
CF-Ray — Cloudflare가 끊었는지 최종 분기
CF-Ray는 Cloudflare가 처리한 모든 요청·응답에 자동 부착된다. 524 응답에는 무조건 존재한다. 504 응답에서도 origin 응답을 그대로 relay했을 때 CF-Ray가 함께 따라온다.
즉 status 504 + X-Nginx-Trace 부재 + X-Amzn-Trace-Id 부재 + CF-Ray 존재라면 매우 드문 케이스인데, Cloudflare가 504를 직접 발급한 것일 수 있다. 이때는 origin 연결 자체가 안 됐을 가능성도 있어 origin health부터 확인해야 한다.
직접 띄워 검증한 실험 — 4단 스택을 단일 파이썬 프로세스로
여기까지가 이론이다. 다만 이론만 보면 "그래서 진짜 그렇게 동작하는가?"라는 의심이 남는다. 그래서 직접 띄워 확인해봤다. 운영 환경을 만지지 말고 단일 파이썬 프로세스 안에 4단 스택을 in-process simulator로 재현한 것이다.
실험명은 504-gateway-timeout-layers이며, python:3.12-slim 컨테이너에서 메모리 512MB, CPU 1.0, 네트워크 deny 환경으로 돌렸다. 외부 의존 없이 단일 프로세스만으로 결정론적 재현이 가능하도록 만든 것이다. 전체 실험은 66.64초 만에 끝났고 exit 0으로 PASS했다.
왜 in-process 시뮬레이터로 했는가
운영 환경 timeout 60초를 그대로 쓰면 한 시나리오 검증에만 1분 이상 걸린다. 5개 시나리오 + race 6회 반복이면 11분+ 소요되며, 결정론적 재현도 어렵다(외부 네트워크 변동의 영향). 그래서 시간 스케일을 0.1배로 압축했다. 운영 60초 → 실험 6초로 줄이니 전체 실험이 60초 안에 끝나면서도 race condition 같은 미세 시간차 현상은 그대로 재현된다.
구성 — ThreadingHTTPServer 4개 + 식별자 헤더 relay
backend, nginx-sim, alb-sim, cloudflare-sim 네 개의 http.server.ThreadingHTTPServer 인스턴스를 단일 프로세스 안에 띄운다. 각 레이어는 upstream 응답을 그대로 흘려보내되 자기 식별자 헤더만 덮어쓴다.
- nginx-sim → X-Nginx-Trace 부착
- alb-sim → X-Amzn-Trace-Id 부착
- cloudflare-sim → CF-Ray + Server: cloudflare 부착
각 레이어가 자기 timeout을 만료시키면 nginx와 ALB는 504를, Cloudflare는 524를 발급하도록 구현했다. http.client.HTTPConnection의 timeout만 사용해 redis·실제 nginx·ALB·Cloudflare 부재 환경에서도 그대로 재현 가능하다.
축 4개로 시나리오 5종 설계
축은 backend_delay × nginx_timeout × alb_timeout × cloudflare_timeout이다. 이 네 변수 조합으로 시나리오 5종을 설계했다.
- healthy: 모든 timeout이 backend_delay보다 큼 → 정상 통과
- nginx_kills: nginx_timeout이 가장 짧음 → nginx가 자체 cut
- alb_kills: alb_timeout이 nginx보다 짧음 → ALB가 nginx 응답 전에 cut
- cloudflare_kills: cf_timeout이 가장 짧음 → Cloudflare가 524 발급
- race: nginx_timeout = alb_timeout (동일) → 경합 발생, 6회 반복
5개 시나리오 실측 결과 매트릭스
504 Gateway Timeout 원인별 응답 헤더 패턴을 매트릭스로 정리하면 다음과 같다. 진단할 때 이 표 한 장만 옆에 두고 보면 된다.
| 시나리오 | status | elapsed (s) | X-Nginx-Trace | X-Amzn-Trace-Id | CF-Ray | 누가 끊었나 |
| healthy | 200 | 2.008 | O | O | O | 정상 통과 |
| nginx_kills | 504 | 3.020 | O | O | O | nginx (proxy_read_timeout 만료) |
| alb_kills | 504 | 4.007 | X | O | O | ALB (idle_timeout 만료, nginx 응답 전 cut) |
| cloudflare_kills | 524 | 5.007 | X | X | O | Cloudflare (hard ceiling 만료) |
| race | 504 | 6.012 | X | O | O | ALB (6/6 모두 ALB가 먼저 끊음) |
각 시나리오 파라미터(압축된 시간 스케일, 초 단위)와 응답 본문 일부는 다음과 같다.
| 시나리오 | backend_delay | nginx_timeout | alb_timeout | cf_timeout | body_preview |
| healthy | 2.0 | 6.0 | 6.0 | 10.0 | `hello-from-backend` |
| nginx_kills | 10.0 | 3.0 | 6.0 | 10.0 | `nginx: upstream cut (TimeoutError), status=504` |
| alb_kills | 10.0 | 8.0 | 4.0 | 10.0 | `alb: upstream cut (TimeoutError), status=504` |
| cloudflare_kills | 15.0 | 20.0 | 20.0 | 5.0 | `cloudflare: upstream cut (TimeoutError), status=524` |
결정론적 시나리오 4종은 예상한 status code와 헤더 매트릭스에 100% 일치한다. 즉 헤더 부재·존재 조합으로 발급 주체를 귀속하는 결정 트리가 실제로 결정론적임을 확인한 것이다.
race 케이스가 가장 헷갈리는 이유
race 시나리오는 의도적으로 nginx_timeout = alb_timeout = 6.0s로 동일하게 두고 6회 반복했다. backend_delay는 10.0s, cf_timeout은 12.0s다. 결과는 다음과 같이 나왔다.
| trial | status | elapsed (s) | X-Nginx-Trace | X-Amzn-Trace-Id | CF-Ray | 진단 |
| 1 | 504 | 6.010 | X | O | O | alb_cut_before_nginx |
| 2 | 504 | 6.010 | X | O | O | alb_cut_before_nginx |
| 3 | 504 | 6.019 | X | O | O | alb_cut_before_nginx |
| 4 | 504 | 6.010 | X | O | O | alb_cut_before_nginx |
| 5 | 504 | 6.009 | X | O | O | alb_cut_before_nginx |
| 6 | 504 | 6.013 | X | O | O | alb_cut_before_nginx |
6회 모두 ALB가 먼저 끊은 것으로 귀속됐고, 평균 elapsed는 6.012초다. 이 환경에서는 ALB가 nginx보다 항상 미세하게 먼저 끊은 것이다. 다만 운영 환경에서는 백엔드 부하·네트워크 jitter 때문에 분포가 갈릴 수 있다. 그래서 nginx와 ALB의 timeout을 동일하게 두면 안 된다. 운영 권장은 "nginx < ALB" 구성이다 (예: nginx 60s, ALB 65s). 이렇게 하면 항상 nginx가 먼저 끊어 X-Nginx-Trace로 깔끔하게 귀속된다.
stdout 핵심 라인을 그대로 인용하면 다음과 같이 출력됐다.
================================================================================================
4-layer HTTP stack cut-off attribution sweep (time scale 0.1x)
================================================================================================
scenario status elapsed nginx alb cf diagnosis match
------------------------------------------------------------------------------------------------
healthy 200 2.008 Y Y Y healthy_full_relay OK
nginx_kills 504 3.020 Y Y Y nginx_cut OK
alb_kills 504 4.007 . Y Y alb_cut_before_nginx OK
cloudflare_kills 524 5.007 . . Y cloudflare_cut OK
------------------------------------------------------------------------------------------------
race_nginx_vs_alb (n=6, nginx=alb=6.0s, backend=10.0s, cf=12.0s):
alb_cut_before_nginx 6/6
avg elapsed: 6.012s
================================================================================================
deterministic scenario validation: ALL MATCH EXPECTED
30초 안에 끝나는 504 Gateway Timeout 진단 결정 트리
504 Gateway Timeout 빠른 디버깅 절차다. 응답 한 번만 까보면 바로 결론이 나온다. 시간을 측정해봤는데 헤더 보고 분기 따라가면 진짜로 30초 컷이다.
1. 응답 status 확인
- 524 → Cloudflare가 끊음. backend 응답 시간 점검 (origin이 살아있는데 느림)
- 504 → 2번으로
2. X-Nginx-Trace 헤더 존재?
- O → nginx가 자체 cut. proxy_read_timeout / proxy_connect_timeout 점검
- X → 3번으로
3. X-Amzn-Trace-Id 헤더 존재?
- O → ALB가 nginx 응답 전에 cut. ALB idle_timeout > nginx 전체 응답 시간으로 늘려라
- X → 매우 드문 케이스. Cloudflare가 504 직접 발급한 것일 수 있음. CF-Ray로 재확인
실험 stdout이 같은 트리를 그대로 출력했다.
status 524 -> Cloudflare self-cut
status 504 + X-Nginx-Trace present -> nginx self-cut (proxy_read_timeout)
status 504 + X-Nginx-Trace absent + X-Amzn-Trace-Id ON -> ALB cut before nginx responded
status 200 + all 3 IDs present -> healthy full relay
흔한 오진 패턴
운영 중에 자주 보는 헛삽질 두 가지다.
- "일단 nginx 늘려보자" → ALB가 끊은 것이라면 효과가 0이다. 실험의 alb_kills 케이스가 정확히 이 상황이다. nginx_timeout이 8.0s, alb_timeout이 4.0s인데 nginx를 아무리 늘려도 ALB가 4초에서 자른다
- "Cloudflare 비활성화하면 되겠지" → 524는 origin 느림 신호다. Cloudflare를 끄면 원인이 보이지 않을 뿐 사라지지 않는다. 같은 백엔드는 ALB·nginx 단계에서 똑같이 timeout을 만료시킬 것이다
운영에서 바로 쓸 수 있는 timeout 권장값 + 헤더 노출 설정
여기까지 왔으면 이제 운영에서 적용할 차례다. 권장 timeout 순서와 nginx 설정 한 줄만 박으면 다음번 504가 떴을 때 즉시 진단할 수 있다.
각 레이어 timeout 권장 순서
backend 처리 시간 SLO 30초 가정 시 다음과 같다.
- backend 처리 SLO: 30s
- nginx proxy_read_timeout: 60s (= SLO × 2)
- ALB idle_timeout: 65s (nginx보다 5초 위)
- Cloudflare hard ceiling: 100s (Enterprise면 6000s까지 늘릴 수 있음)
이렇게 두면 race가 나지 않고 항상 nginx가 먼저 끊어 진단이 깔끔해진다. ALB가 5초 여유가 있어 nginx 응답이 도달할 시간이 보장된다.
X-Nginx-Trace 헤더 활성화 — nginx에서 직접 발급
nginx 설정에서 한 줄만 추가하면 된다.
add_header X-Nginx-Trace $request_id always;
always가 핵심이다. 이를 빠뜨리면 200 응답에만 헤더가 붙고 504 응답에는 붙지 않아 진단이 무용지물이 된다. nginx 기본 동작은 2xx·3xx만 헤더를 부착하므로 4xx·5xx는 명시적으로 always 키워드를 줘야 적용된다.
ALB·Cloudflare 헤더는 자동이다
X-Amzn-Trace-Id는 ALB가 자동 부착한다. CF-Ray도 Cloudflare가 자동 부착한다. 별도 설정 없이 그대로 받으면 된다. 즉 운영자가 직접 만져야 하는 것은 nginx의 add_header X-Nginx-Trace $request_id always; 한 줄뿐이다.
마지막 정리 — 504 Gateway Timeout 원인 추적은 헤더 한 줄에서 시작된다
길게 썼는데 핵심은 5문장으로 압축된다.
- 504와 524는 같은 것이 아니다. 524가 보이면 origin이 느린 신호다
- X-Nginx-Trace, X-Amzn-Trace-Id, CF-Ray 헤더 3개 + status 한 줄로 어느 레이어가 끊었는지 즉시 알 수 있다
- nginx와 ALB가 동일 timeout이면 race가 발생한다 → ALB > nginx로 5초 갭을 두라 (실험에서 6/6 모두 ALB가 먼저 끊는 결과 확인)
- 진단 결정 트리는 30초 안에 끝난다 → status → X-Nginx-Trace → X-Amzn-Trace-Id 순으로 분기
- 직접 in-process 시뮬레이터를 띄워 결정론적으로 재현 가능하다. 0.1배 시간 스케일 압축으로 60초 안에 5개 시나리오 + race 6회를 모두 검증했다
지금 운영 환경에서 nginx의 add_header X-Nginx-Trace $request_id always; 한 줄부터 추가하라. 다음번 504가 떴을 때 응답 헤더 한 번만 까보면 누가 끊었는지 30초 안에 나온다. 504 Gateway Timeout 원인 추적은 결국 식별자 헤더 설정 한 줄에서 시작된다. 운영 60초 timeout을 그대로 만지지 않고도 단일 파이썬 프로세스에서 결정론적으로 재현 가능한 디버깅 환경을 만들 수 있다는 것, 이번 실험으로 직접 확인했다.
'Backend' 카테고리의 다른 글
| Bloom Filter로 DB 호출을 99% 차단해보았다 - set·dict·SQLite와 메모리 58MB→1.2MB 직접 비교 (0) | 2026.05.05 |
|---|---|
| Redis 캐시 stampede를 진짜로 막는 법 — single-flight, XFetch, 분산 lock 직접 돌려본 결과 (0) | 2026.05.04 |
| HTTP Keep-Alive는 정말 효과가 있는가? 1000회 호출로 직접 비교한다 (0) | 2026.05.04 |
| 로그 적재에 큐(Queue)가 진정 필요한가? 5만 건으로 비교했다 (0) | 2026.05.03 |
| SSE 스트림은 어떤 언어가 안정적인가? Go·Python·Node·Rust를 User 1000명까지 굴려본 결과 (0) | 2026.05.03 |
