본문 바로가기

nginx 뒤 스프링부트에서 502가 간헐적으로만 납니다 (upstream prematurely closed connection)

조회 2 · 댓글 1
모나미153모나미153작성자2026년 8월 27일

지난번에 RemoteIpValve로 IP 문제 잡은 그 서버입니다(우분투 24.04, nginx 1.24, 스프링부트 3.4 내장 톰캣). 이번엔 다른 증상인데, 하루에 몇 번씩 502 Bad Gateway가 뜨고 바로 새로고침하면 정상입니다. 부하가 높은 시간대도 아니고 앱 로그에는 에러가 하나도 없습니다.

nginx error.log에는 이 줄만 반복해서 찍힙니다.

upstream prematurely closed connection while reading response header from upstream, client: x.x.x.x, request: "GET /api/... HTTP/2.0", upstream: "http://127.0.0.1:8080/api/..."

location 설정은 이렇게 돼 있습니다. keepalive는 성능 글 보고 따라 넣은 겁니다.

upstream app {
    server 127.0.0.1:8080;
    keepalive 32;
}
location / {
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

톰캣이 죽는 것도 아니고 재시작한 적도 없는데 왜 연결이 끊긴다고 하는 건지 모르겠습니다. 어디를 봐야 할까요?

appsrootappsroot2026년 8월 28일

증상과 로그 조합이 딱 keepalive 연결의 타임아웃 불일치입니다. 톰캣이 죽는 게 아니라, nginx가 재사용하려고 붙잡고 있던 유휴 연결을 톰캣이 먼저 닫아버린 순간에 요청이 그 연결로 나가면서 생기는 현상입니다. 앱 로그에 아무것도 없는 것도 그래서입니다. 요청이 앱까지 도달하지 못했으니까요.

1. 왜 간헐적인가

keepalive 32를 넣으면 nginx는 워커당 최대 32개의 유휴 연결을 캐시해 두고 다음 요청에 재사용합니다. 그런데 톰캣도 자기 쪽에서 유휴 연결을 정리합니다. 톰캣의 keepAliveTimeout은 따로 안 주면 connectionTimeout 값을 그대로 쓰는데, 스프링부트 내장 톰캣은 이 값을 건드리지 않아서 톰캣 기본 60초가 됩니다(server.xml을 쓰는 순정 톰캣은 배포판 설정이 20초라 더 벌어집니다). nginx 업스트림 쪽 keepalive_timeout 기본도 60초입니다. 양쪽이 같은 값이면 경계 근처에서 누가 먼저 닫을지 보장이 없고, 톰캣 타이머가 먼저 돌면 nginx는 이미 닫힌 연결을 살아 있다고 믿는 상태가 됩니다. 그 연결로 다음 요청을 쓰는 순간 톰캣 쪽에서 FIN/RST가 돌아오고, nginx는 응답 헤더를 읽기 전에 연결이 닫혔다고 판단해 지금 보시는 upstream prematurely closed connection while reading response header를 남기고 502를 냅니다. 트래픽이 뜸한 시간대에 오히려 더 잘 나는 이유도 유휴 시간이 길어져서입니다.

두 번째 후보는 요청 횟수 상한입니다. 톰캣 maxKeepAliveRequests 기본이 100이라 한 연결에 100번째 응답 뒤에는 톰캣이 연결을 닫습니다. nginx 쪽 keepalive_requests 기본은 1000이라 이것도 어긋납니다. 다만 이 경우 톰캣이 마지막 응답에 Connection: close를 붙여주기 때문에 nginx가 알고 닫아서 실제로 502까지 가는 일은 드물고, 주범은 거의 항상 위의 유휴 타임아웃입니다.

2. 고치는 방법: nginx가 먼저 닫게 만든다

원칙은 하나입니다. 프록시(nginx)의 유휴 타임아웃을 백엔드(톰캣)보다 짧게 잡아서 항상 nginx가 먼저 연결을 버리게 합니다. 어느 한쪽만 고쳐도 되는데 둘 다 맞춰두는 것을 권합니다.

nginx 쪽(upstream 블록 안, 1.15.3 이상):

upstream app {
    server 127.0.0.1:8080;
    keepalive 32;
    keepalive_timeout 15s;
    keepalive_requests 90;
}

스프링부트 쪽(application.properties):

server.tomcat.keep-alive-timeout=60s
server.tomcat.max-keep-alive-requests=1000

이렇게 하면 nginx는 15초 놀면 자기가 먼저 닫고, 톰캣은 60초까지 기다리므로 죽은 연결을 재사용할 틈이 없어집니다. 숫자 자체는 중요하지 않고 부등호 방향(nginx < 톰캣)만 지키면 됩니다.

3. 그래도 남는 경우를 위한 보험

드물게 경계 시점에 걸리는 건 완전히 없앨 수 없어서, nginx가 실패한 요청을 한 번 다시 보내도록 두는 것이 보험입니다. proxy_next_upstream 기본값이 error timeout이라 이 케이스는 이미 재시도 대상인데, 업스트림에 서버가 하나뿐이면 재시도할 곳이 없어서 무의미합니다. 같은 서버를 한 번 더 시도하게 하려면 이렇게 둡니다.

upstream app {
    server 127.0.0.1:8080 max_fails=0;
    keepalive 32;
    keepalive_timeout 15s;
}
location / {
    proxy_next_upstream error timeout;
    proxy_next_upstream_tries 2;
}

주의할 점은 nginx가 POST, PATCH 같은 비멱등 요청은 기본적으로 재시도하지 않는다는 것입니다. 이걸 켜려면 non_idempotent를 붙여야 하는데, 결제나 주문처럼 두 번 실행되면 안 되는 API가 있으면 켜지 마세요. GET만 재시도되는 기본이 안전합니다.

4. 확인

적용 후 하루 정도 두고 error.log에서 같은 줄이 사라지는지 보면 됩니다. 톰캣이 실제로 어떤 값으로 떠 있는지 의심되면 액추에이터가 켜져 있을 때 /actuator/configprops에서 server.tomcat 항목을 보거나, 그냥 톰캣 keepAliveTimeout(기본 60초) 언저리까지 쉬었다가 첫 요청을 여러 번 날려 보는 것으로 재현이 됩니다. 재현이 되면 원인이 맞는 것이고 위 설정 뒤에는 안 나와야 합니다.

5. 참고

nginx 1.29.7부터는 keepalive 32proxy_http_version 1.1이 기본값이 되어 keepalive가 자동으로 켜집니다. 우분투 패키지(1.24/1.26)는 아직 해당 없지만, 나중에 버전을 올리면 설정에 keepalive를 안 썼던 서버에서도 같은 증상이 새로 나타날 수 있으니 그때 이 글 기억해 두시면 좋겠습니다.

공식 문서: nginx upstream keepalive / keepalive_timeout / keepalive_requests, nginx proxy_next_upstream, Tomcat 10.1 HTTP Connector (connectionTimeout, keepAliveTimeout, maxKeepAliveRequests)

로그인 후 답글을 남길 수 있습니다.