LabHub
배우기 러닝패스 코스

Tomcat & nginxの運用

502にタイムアウトを延ばす人にならない

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

502 는 "유효하지 않은 응답을 받았다" 이고 504 가 "시간 안에 응답하지 않았다" 이며, 이 둘을 섞으면 타임아웃만 늘리다가 몇 시간을 태운다.

フロー図: 틀린 원인을 확신하는 것・유효하지 않은 응답・정해진 시간 안에 응답하지 않음・502 를 "업스트림이 죽었다"로 단정하는 것.

왜 이게 문제인가

장애 대응에서 시간을 가장 많이 잡아먹는 것은 원인을 못 찾는 것이 아니라 틀린 원인을 확신하는 것이다. 502 를 "백엔드가 죽었다" 로 단정하면 백엔드를 재기동하고, 안 되면 타임아웃을 늘리고, 그래도 안 되면 서버를 늘린다. 그동안 진짜 원인인 응답 헤더 크기나 커넥션 조기 종료는 손도 대지 않는다.

숫자가 있으면 이 확신이 근거로 바뀐다. 액세스 로그에서 URL 별 응답시간 상위와 5xx 분포를 뽑고, nginx 에러 로그에서 connection refused 와 upstream timed out 을 따로 세면 502 와 504 가 저절로 갈린다. 이 두 숫자를 세는 데 걸리는 시간은 1 분이다.

502 와 504 는 다른 이야기다

코드 의미 nginx 가 이걸 낼 때 흔한 원인
502 Bad Gateway 업스트림에서 유효하지 않은 응답을 받았거나 연결이 거부됨 연결 거부, 응답 파싱 실패, 커넥션 조기 종료 백엔드 다운, 백로그 포화, 응답 헤더가 버퍼보다 큼
504 Gateway Timeout 업스트림이 정해진 시간 안에 응답하지 않음 proxy_read_timeout 초과 느린 쿼리, 외부 연동 지연, 락 대기
503 Service Unavailable 사용 가능한 업스트림이 없음 모든 서버가 실패 처리로 제외됨 전체 장애, 헬스 판정 오류

여기서 가장 자주 틀리는 지점은 이것이다. 502 를 "업스트림이 죽었다"로 단정하는 것.

규범이 말하는 502 는 "게이트웨이가 상류로부터 유효하지 않은 응답을 받았다"이지 "상류가 응답하지 않았다"가 아니다. 응답이 오긴 왔는데 프록시가 처리하지 못한 경우도 502 다. 대표적인 것이 응답 헤더가 프록시 버퍼보다 큰 경우다.

이 상황이 고약한 이유: 업스트림 헬스체크는 계속 정상으로 나온다. 헬스체크 응답은 헤더가 작으니까. 그래서 "서버는 멀쩡한데 특정 사용자만 502"가 된다. 그 특정 사용자는 대개 SSO 로 로그인해서 쿠키가 큰 사용자다.

proxy_buffer_size       16k;
proxy_buffers         4 32k;
proxy_busy_buffers_size 64k;

그리고 반드시 기억할 것: 502 가 나는데 타임아웃을 늘리는 것은 아무 효과가 없다. 타임아웃은 504 의 이야기다. 이 둘을 섞으면 몇 시간을 낭비한다.

진단 사다리 — curl 다섯 단계

증상이 애매할 때 이 순서로 좁힌다. 특히 5번이 탐색 공간을 절반으로 줄인다.

# 1. 이름이 풀리는가
getent hosts api.example.com

# 2. 포트가 열려 있는가 (ping 은 이 환경에서 안 되니 쓰지 않는다)
curl -s -o /dev/null -w '%{http_code}\n' --connect-timeout 3 http://api.example.com/

# 3. 상태코드와 헤더
curl -sSI https://api.example.com/health

# 4. 시간 분해
curl -s -o /dev/null -w 'dns=%{time_namelookup} conn=%{time_connect} \
tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' https://api.example.com/

# 5. ★ 프록시를 건너뛰고 업스트림에 직접, 원래 Host 헤더를 유지한 채
curl -sSI -H 'Host: api.example.com' http://127.0.0.1:8080/health

5번의 결과 해석이 핵심이다.

-H 'Host: ...' 를 빼면 이 비교는 무의미하다. 가상 호스트 라우팅이 걸린 경우 Host 가 다르면 아예 다른 앱으로 가기 때문이다.

4번의 시간 분해도 유용하다.

GC 로그를 읽는 최소한

[2026-08-19T02:14:33.221+0900][12.334s] GC(41) Pause Full (System.gc()) 486M->402M(512M) 812.443ms

이 한 줄에서 읽을 것.

판단 기준은 절대값이 아니라 추세와 패턴이다.

OOM 이 났을 때

java.lang.OutOfMemoryError 는 종류에 따라 조치가 완전히 다르다.

메시지 첫 조치
Java heap space 힙 부족 또는 누수 힙 덤프 분석. 무작정 -Xmx 올리면 누수는 그대로
Metaspace 클래스 메타데이터 부족 반복 배포로 클래스로더 누수 의심. 재기동 후 관찰
GC overhead limit exceeded GC 에 시간 대부분을 쓰는데 회수가 거의 안 됨 사실상 누수. 힙 덤프
unable to create native thread 스레드 생성 실패 힙이 아니라 OS 한계/스레드 누수. 스레드 덤프

마지막 줄이 특히 헷갈린다. OOM 인데 힙 문제가 아닌 경우다. -Xmx 를 올리면 오히려 나빠질 수 있다(힙이 커지면 스레드용 메모리가 줄어든다).

그리고 덤프는 터지는 순간에만 받을 수 있다. -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=<경로> 를 미리 넣어 두지 않으면 "재현되면 그때 보죠"가 되고, 재현은 대개 안 된다.

재기동 전에 증거를 남긴다

장애 대응의 원칙은 복구 우선이다. 다만 재기동 버튼을 누르기 전 1~2분이면 이 셋은 확보할 수 있다.

PID=$(pgrep -f tomcat | head -1)
jcmd $PID Thread.print            > /tmp/threads_$(date +%H%M%S).txt
jcmd $PID GC.heap_info            > /tmp/heap_$(date +%H%M%S).txt
cp /opt/tomcat/logs/catalina.out    /tmp/catalina_$(date +%H%M%S).out

이 1~2분을 아끼면 원인을 영원히 못 찾는다. "일단 재기동했더니 됐어요"가 세 번 반복되면 네 번째엔 재기동으로도 안 되고, 그때는 증거가 하나도 없다.

로그에서 실제로 뽑아야 하는 것

액세스 로그에서 즉시 뽑아야 할 네 가지.

  1. 상태코드 분포 — 5xx 가 언제부터 늘었는가
  2. 응답시간 상위 URL — 어디가 느린가
  3. 업스트림별 오류 분포 — 특정 서버만 문제인가
  4. 분당 요청 수 — 트래픽 급증이 원인인가, 결과인가

4번이 중요하다. 장애 때 요청이 늘어난 것이 원인일 수도 있지만, 사용자가 안 되니까 새로고침을 반복해서 늘어난 결과일 때도 많다. 시작 시각을 정확히 보면 구분된다.

현장에서 만나는 모습

가장 고약한 형태는 "서버는 멀쩡한데 특정 사용자만 502" 다. 헬스체크 응답은 헤더가 작아서 늘 통과하고, 세션 쿠키가 큰 일부 사용자만 프록시 버퍼를 넘겨 502 를 받는다. 이때 모니터링 대시보드는 전부 초록색이라 사용자 문의가 유일한 신호다.

그리고 재기동 전에 증거를 남기는 습관이 필요하다. 힙 덤프와 스레드 덤프는 재기동하는 순간 사라지고, 같은 장애가 다시 날 때까지 원인을 알 수 없게 된다.