특정 요청이 아니라 아무 요청에서나 502가 났습니다. 커넥션을 재사용하는 쪽과 받는 쪽의 타임아웃이 서로를 모른 채 돌고 있었고, 그 틈에서 죽어가는 소켓이 재사용되고 있었습니다.
폴링으로 상태를 확인하는 요청에서 502가 간헐적으로 떴습니다. 특정 엔드포인트만 그런 것도 아니고, 부하가 몰린 시간대도 아니었습니다. 같은 요청을 다시 보내면 잘 됐습니다.
재현이 안 되는 에러가 가장 다루기 어렵습니다. "무작위"라는 말은 대개 내가 아직 변수를 못 찾았다는 뜻이라, 무엇이 다른지부터 찾아야 했습니다.
502는 게이트웨이가 뒤에 있는 서버와 말이 안 통했을 때 내는 응답입니다. 서버가 죽지 않았는데 502가 난다면 그 사이 경로를 의심해야 합니다.
로그를 모아보니 공통점이 하나 있었습니다. 502가 난 요청은 모두 새로 맺은 커넥션이 아니라 재사용된 커넥션을 쓰고 있었습니다. 여기서 keep-alive를 들여다보게 됐습니다.
HTTP keep-alive는 한 번 맺은 TCP 커넥션을 여러 요청에 다시 쓰는 기법입니다. 매 요청마다 3-way handshake를 하는 비용을 아끼려고 커넥션을 유휴 상태로 잠시 살려둡니다. 그리고 이 "잠시"를 정하는 타이머가 양쪽에 각각 있습니다.
idle timeout — 뒤쪽 서버로 연 커넥션을 얼마나 풀에 두고 재사용할지keepAliveTimeout — 받은 커넥션을 얼마나 유휴로 유지하다 닫을지문제는 이 두 타이머가 서로의 값을 모른다는 점입니다. 각자 자기 시계만 보고 돕니다.
여기서 중요한 건 시간의 길이가 아니라 역할입니다.
받는 쪽 타임아웃이 더 짧으면 이런 일이 벌어집니다.
서버는 5초 뒤에 커넥션을 닫습니다. 로드밸런서는 300초까지 쓸 수 있다고 믿고 있습니다. 그 간극에서 이미 닫히기 시작한 소켓에 요청이 꽂힙니다.
처음엔 로드밸런서가 FIN을 무시하는 건가 싶었습니다. 그런데 그건 아닙니다. FIN은 TCP 4-way handshake의 일부고, OS 커널이 자동으로 받아 ACK까지 응답합니다.
문제는 커널이 아니라 그 위입니다. 소켓이 재사용 대상에서 빠지려면 두 단계를 거칩니다.
| 단계 | 무슨 일이 일어나나 | 이 시점에 재사용하면 |
|---|---|---|
| ① FIN이 커널에 도착 | 소켓은 이미 닫히는 중 | 깨진다 — 커넥션 풀은 아직 모를 수 있음 |
| ② 커넥션 풀이 "이 소켓 빼자"를 반영 | 재사용 목록에서 제거됨 | 안전 — 다른 소켓이나 새 커넥션을 씀 |
즉 안전해지는 시점은 "FIN을 받으면"이 아니라 "FIN을 받아 풀에 반영을 끝낸 뒤" 입니다. 그 사이의 짧은 구간이 race window입니다.
시간 ────────────────────────────▶
앱 서버: 유휴 5초 도달 → FIN 전송 ──┐
├── 서로 엇갈리는 구간
로드밸런서: 새 요청 도착 → 그 소켓 사용 ─┘
받는 쪽 타임아웃이 짧을수록 FIN이 자주, 그리고 로드밸런서가 예상하지 못한 타이밍에 발생합니다. race window가 계속 열리는 셈입니다.
반대로 로드밸런서가 항상 먼저 닫으면 어떻게 될까요. 그때는 닫는 결정을 내린 주체가 로드밸런서 자신입니다. 자기가 닫기로 한 소켓에 동시에 새 요청을 보내는 모순은 생기지 않습니다. race window가 0이 됩니다.
문제의 본질은 "소켓이 닫히는 것"이 아니었습니다. 재사용을 결정하는 쪽이 소켓의 죽음을 모른 채 재사용하는 것이었습니다.
여기서 규칙이 하나 나옵니다.
받는 쪽의 keep-alive timeout은 재사용하는 쪽의 idle timeout보다 길어야 한다.
받는 쪽이 더 오래 들고 있으면 로드밸런서가 항상 먼저 닫습니다. 로드밸런서는 자기가 닫은 소켓을 재사용하지 않으니 race가 원천적으로 사라집니다.
이 규칙을 "타임아웃은 길게 잡아라"로 외우면 틀립니다. 기준은 길이가 아니라 내가 거는 쪽인지 받는 쪽인지입니다.
같은 SSR 서버가 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
마진을 넉넉히 둔 이유가 있습니다. 305초처럼 빠듯하게 잡으면 두 타이머의 만료 시점이 거의 겹쳐서 race window가 남습니다. 로드밸런서가 확실히 먼저 닫게 만들어야 합니다.
환경변수를 넣었다고 적용된 것은 아닙니다. 이름이 틀렸거나, 컨테이너에 전달되지 않았거나, 코드가 다른 경로로 값을 읽을 수도 있습니다.
그래서 raw TCP 소켓을 열어놓고 서버가 언제 끊는지 직접 재봤습니다. 소켓을 연 뒤 아무것도 보내지 않고 끊기는 시각만 기록하면 됩니다.
| 서버 | 유휴 소켓이 끊긴 시각 |
|---|---|
| 환경변수 미설정 | 약 6초 — Node 기본값 5초 |
KEEP_ALIVE_TIMEOUT=360000 | 32초까지 유지 — 적용됨 |
두 번째는 360초 설정이라 32초에서도 끊기지 않는 것을 확인하는 것으로 충분했습니다. "설정했다"와 "적용됐다"는 다른 말이고, 이걸 구분하는 데 30초짜리 스크립트면 됩니다.
프론트엔드에서 시작한 문제였지만, 답은 TCP 커넥션이 닫히는 두 단계와 커넥션 풀의 반영 시차에 있었습니다. 브라우저 위쪽만 보고 있었다면 "무작위"라는 말에서 더 나아가지 못했을 겁니다.
전역 Suspense로 감싸면 경고는 사라지지만, 페이지의 정적 마크업까지 placeholder로 대체됩니다. Next.js 소스코드를 따라가 BailoutToCSRError가 CLIENT_RENDERED 경계로 어떻게 흐르는지 살펴봅니다.
hooks 기반 이벤트 드리븐 위젯을 만들고, 그 과정에서 왜 Electron을 선택했는지, hook과 기록 파일이라는 두 신호를 어떻게 교차 검증했는지 정리했습니다.
문서에는 "queued"라고만 적혀 있습니다. 호출 단위인지 컴포넌트 단위인지 탭 전체인지 알 수 없어서, 30개 요청을 세 가지 방식으로 직접 재봤습니다.
참조형 데이터는 내용이 같아도 주소가 다르면 다른 값으로 취급됩니다. 이 특성이 useEffect 의존성 배열과 React.memo에서 왜 문제가 되는지, useCallback과 useMemo가 무엇을 해결하는지, 그리고 왜 모든 곳에 쓰면 안 되는지 정리합니다.
인앱 브라우저에서 기능이 깨지는 문제와 대용량 목록 렌더링 성능 문제, 두 가지를 실제로 부딪히고 풀어낸 기록입니다.
Promise 핸들러가 항상 비동기로 실행되는 이유인 마이크로태스크 큐를 짚고, 이를 편하게 다루는 async/await 문법과 실행 순서 종합 문제로 시리즈를 마무리합니다.