특정 요청이 아니라 아무 요청에서나 502가 났습니다. 커넥션을 재사용하는 쪽과 받는 쪽의 타임아웃이 서로를 모른 채 돌고 있었고, 그 틈에서 죽어가는 소켓이 재사용되고 있었습니다.
폴링으로 상태를 확인하는 요청에서 502가 간헐적으로 떴습니다. 특정 엔드포인트만 그런 것도 아니고, 부하가 몰린 시간대도 아니었습니다. 같은 요청을 다시 보내면 잘 됐습니다.
502는 Bad Gateway, 게이트웨이가 뒤에 있는 서버와 통신이 안 될 때 내는 응답입니다. 서버가 죽지 않았는데 502가 난다면 그 사이 경로를 의심해야 합니다. 로그를 모아보니 502가 난 요청은 모두 재사용된 커넥션을 쓰고 있었고, 여기서 keep-alive를 들여다보게 됐습니다.
HTTP keep-alive는 한 번 맺은 TCP 커넥션을 여러 요청에 다시 쓰는 기법입니다. 매 요청마다 3-way handshake를 하는 비용을 아끼려고 커넥션을 유휴 상태로 잠시 살려둡니다. 그리고 이 "잠시"를 정하는 타이머가 양쪽에 각각 있습니다.
idle timeout — 뒤쪽 서버로 연 커넥션을 얼마나 풀에 두고 재사용할지keepAliveTimeout — 받은 커넥션을 얼마나 유휴로 유지하다 닫을지문제는 이 두 타이머가 서로의 값을 모른 채 각자 돈다는 점입니다. 그 틈에서 레이스 컨디션이 생길 수 있습니다.
당시 상황은 이랬습니다.
받는 쪽 타임아웃이 더 짧으면 이런 일이 벌어집니다.
서버는 5초 뒤에 커넥션을 닫습니다. 로드밸런서는 300초까지 쓸 수 있다고 믿고 있습니다. 그 간극에서 이미 닫히기 시작한 소켓에 요청이 꽂힙니다.
로드밸런서가 FIN을 무시하는 건 아닙니다. FIN은 OS 커널이 자동으로 받아 ACK까지 응답합니다. 문제는 그 위, 커넥션 풀입니다. 소켓이 재사용 대상에서 빠지려면 두 단계를 거칩니다.
| 단계 | 무슨 일이 일어나나 | 이 시점에 재사용하면 |
|---|---|---|
| ① FIN이 커널에 도착 | 소켓은 이미 닫히는 중 | 깨진다 — 커넥션 풀은 아직 모를 수 있음 |
| ② 커넥션 풀이 "이 소켓 빼자"를 반영 | 재사용 목록에서 제거됨 | 안전 — 다른 소켓이나 새 커넥션을 씀 |
즉 안전해지는 시점은 FIN을 받은 때가 아니라 풀에 반영을 끝낸 뒤입니다. FIN 수신과 풀 반영 사이의 짧은 구간이 레이스 컨디션이 열리는 지점입니다.
반대로 로드밸런서가 항상 먼저 닫으면 어떻게 될까요. 그때는 닫는 결정을 내린 주체가 로드밸런서 자신입니다. 자기가 닫기로 한 소켓에 동시에 새 요청을 보내는 모순은 생기지 않습니다. 레이스 컨디션이 성립할 조건 자체가 없어집니다.
받는 쪽의 keep-alive timeout은 재사용하는 쪽의 idle timeout보다 길어야 한다.
받는 쪽이 더 오래 들고 있으면 로드밸런서가 항상 먼저 닫습니다. 로드밸런서는 자기가 닫은 소켓을 재사용하지 않으니 레이스가 원천적으로 사라집니다.
이 규칙을 "타임아웃은 길게"로 외우면 틀립니다. 기준은 길이가 아니라 내가 거는 쪽인지 받는 쪽인지입니다.
같은 SSR 서버가 hop에 따라 역할이 다릅니다.
keepAliveTimeout을 로드밸런서 A의 idle timeout보다 길게 둡니다. 이 글의 502가 난 지점입니다.hop마다 길게 두기도 하고 짧게 두기도 하지만, 원리는 하나입니다. 재사용을 결정하는 쪽이 소켓의 생사를 확실히 아는 상태로 만든다.
Node의 기본 keepAliveTimeout은 5초입니다. 주요 로드밸런서의 기본 idle timeout은 대개 그보다 훨씬 깁니다(수십 초에서 수백 초). 즉 아무 설정도 하지 않으면 위험한 조합이 기본값입니다.
설정 방법은 실행 방식에 따라 갈립니다. Next.js의 경우 next start는 --keepAliveTimeout 플래그를 받지만, output: "standalone"으로 빌드해 node server.js로 직접 실행하는 구조에서는 그 플래그를 쓸 수 없습니다. 이때는 standalone 서버가 읽는 환경변수를 씁니다.
# ALB idle 300초 + 60초 마진
ENV KEEP_ALIVE_TIMEOUT=360000
빌드 산출물(.next/standalone/server.js)을 따라가 보면 이 값이 어디로 흘러가는지 확인할 수 있습니다.
process.env.KEEP_ALIVE_TIMEOUT
→ parseInt(...)
→ startServer({ keepAliveTimeout })
→ server.keepAliveTimeout = 360000
모니터링 지표만 보면 프론트엔드에서 난 문제 같았지만, 답은 TCP 커넥션이 닫히는 두 단계와 커넥션 풀의 반영 시차에 있었습니다. 브라우저 위쪽만 보고 있었다면 "무작위"라는 말에 막혀 코드만 들여다보고 있었을지도 모릅니다. 이 문제를 해결하면서, 서비스에서 생긴 문제를 한 지점에서만 판단하지 않고 요청이 지나는 경로 전체를 놓고 보는 법을 배웠습니다.
이후저는 여기서 인프라에 대한 갈증을 느껴 공부를 시작했고, AWS 자격증 취득까지 이어졌습니다.
앞으로도 더 많은 경험을 통해 더 넓은 시야를 가지려 합니다.
전역 Suspense로 감싸면 경고는 사라지지만, 페이지의 정적 마크업까지 placeholder로 대체됩니다. Next.js 소스코드를 따라가 BailoutToCSRError가 CLIENT_RENDERED 경계로 어떻게 흐르는지 살펴봅니다.
문서에는 "queued"라고만 적혀 있습니다. 호출 단위인지 컴포넌트 단위인지 탭 전체인지 알 수 없어서, 30개 요청을 세 가지 방식으로 직접 재봤습니다.
참조형 데이터는 내용이 같아도 주소가 다르면 다른 값으로 취급됩니다. 이 특성이 useEffect 의존성 배열과 React.memo에서 왜 문제가 되는지, useCallback과 useMemo가 무엇을 해결하는지, 그리고 왜 모든 곳에 쓰면 안 되는지 정리합니다.
인앱 브라우저에서 기능이 깨지는 문제와 대용량 목록 렌더링 성능 문제, 두 가지를 실제로 부딪히고 풀어낸 기록입니다.
Promise 핸들러가 항상 비동기로 실행되는 이유인 마이크로태스크 큐를 짚고, 이를 편하게 다루는 async/await 문법과 실행 순서 종합 문제로 시리즈를 마무리합니다.
Promise.all/allSettled/race 같은 정적 메서드, AbortController로 요청을 취소하는 방법, 그리고 콜백을 Promise로 바꾸는 Promisification을 정리합니다.