Node.js 이벤트 루프 블로킹으로 헬스체크가 흔들릴 때, 40분 안에 안정화하는 운영 플레이북
💡 Key Takeaways
- 헬스체크 플랩은 인프라 장애처럼 보여도 애플리케이션 이벤트 루프 지연에서 시작되는 경우가 많다.
- 복구 순서는 트래픽 완화, 블로킹 구간 식별, 임시 우회, 코드 교정으로 고정해야 재발률을 낮출 수 있다.
- p95 이벤트 루프 지연과 readiness 실패율을 함께 모니터링하면 원인-증상 연결이 빨라진다.
1) 문제 정의: CPU는 정상인데 readiness가 20초 간격으로 떨어진 이유
화요일 오전 9시 18분, 알람은 "pod 재시작 급증"으로 시작했지만 대시보드 CPU는 45% 수준으로 멀쩡했다. 처음 온콜은 노드 과부하를 의심했는데, 실제 로그를 보면 GET /healthz가 2~3초씩 지연되다가 타임아웃이 났고 그 직후 readiness가 false로 전환됐다. 흥미로운 점은 사용자 요청 오류율이 천천히 오르다가 특정 배치 작업 시작 시점에만 톱니처럼 튀었다는 것이다. 우리 팀은 여기서 인프라 증설 대신 "이벤트 루프가 잠깐 멈추는지"를 먼저 확인하기로 방향을 바꿨다. 직전 배포에서 주문 정산 데이터를 JSON으로 한 번에 직렬화하는 로직이 들어갔고, 1분마다 실행되는 크론 잡이 같은 프로세스에서 돌고 있었다. 현장에서는 "서버는 살아 있는데 헬스체크만 죽는" 모순 때문에 판단이 흔들리기 쉬운데, 이 구간을 애플리케이션 스케줄링 사고로 정의하니 대응 속도가 확 달라졌다.
2) 판단 기준: 재시작 횟수보다 이벤트 루프 지연과 요청 대기열을 같이 본다
우리가 고정한 판단 기준은 네 가지다. 첫째, event loop lag p95/p99가 readiness 실패 시점과 같은 분 단위로 겹치는지 본다. 둘째, GC pause와 블로킹 함수 호출(대용량 JSON.stringify, 동기식 파일 I/O, 압축 처리)의 타임라인을 같은 축에 놓는다. 셋째, 헬스체크 엔드포인트가 비즈니스 의존성을 과하게 검사해 자체적으로 무거워졌는지 확인한다. 넷째, HPA가 재시작된 파드를 다시 트래픽에 붙이며 스파이크를 증폭하는지 본다. 실제 사고에서는 p99 이벤트 루프 지연이 평소 40ms에서 1.8s까지 튀었고, 같은 시각에 크론 잡이 12MB payload를 동기 직렬화하고 있었다. 특히 팀 내에서 "메모리 여유가 있으니 앱은 정상"이라는 가정이 반복됐는데, 지연 지표를 먼저 보도록 런북 순서를 바꾸니 불필요한 롤링 재배포를 줄일 수 있었다. 핵심은 재시작이라는 결과보다, 이벤트 루프가 왜 막혔는지 원인 데이터를 우선 수집하는 것이다.
3) 실행 절차: 40분 복구를 만든 순서(트래픽 완화 -> 우회 -> 코드 교정)
복구는 세 단계로 쪼갰다. 1단계는 서비스 보호다. readiness 실패 임계치를 완화하기 전에 먼저 배치 잡을 일시 중지하고, 문제 워커가 붙은 디플로이먼트의 트래픽 비중을 낮췄다. 동시에 maxSurge를 줄여 재시작 폭주가 전체 클러스터로 번지지 않게 막았다. 2단계는 원인 우회다. 정산 직렬화 로직을 즉시 롤백하는 대신, 1회 처리량을 1/5로 분할하고 각 청크 사이에 setImmediate로 이벤트 루프 양보 구간을 넣었다. 이 임시 패치만으로 readiness 실패율이 14%에서 1.2%로 떨어졌고 사용자 오류율도 빠르게 안정됐다. 3단계는 코드 교정이다. 배치 워크로드를 별도 큐 워커로 분리하고, 헬스체크는 DB 왕복 검사를 제거해 프로세스 생존과 큐 지연 임계치만 보게 바꿨다. 장애 접수부터 안정화까지 39분이 걸렸고, 가장 효과가 컸던 조치는 "증설"이 아니라 블로킹 구간 분할이었다.
4) 운영 체크리스트와 주의사항: 다시 같은 밤샘을 하지 않기 위한 최소 장치
재발 방지는 화려한 리팩터링보다 운영 가드레일에서 시작됐다. 첫째, 배치/API 혼합 프로세스에는 "동기식 연산 50ms 초과 금지" 규칙을 코드리뷰 체크리스트에 넣고, 위반 시 머지 전에 flamegraph 캡처를 의무화했다. 둘째, 모니터링은 CPU와 메모리만 보지 않고 event_loop_lag_p95, readiness 실패율, 재시작 횟수를 한 패널에 묶어 원인-증상 연결을 즉시 보게 했다. 셋째, 헬스체크는 "의존성 전체 점검"이 아니라 "트래픽 수용 가능 여부" 중심으로 축소했다. 넷째, 배치 작업은 트래픽 피크 시간(오전 9~11시) 자동 실행을 막고 야간 창으로 이동했다. 마지막으로 주의할 점은, 장애 직후 재시작 횟수만 줄였다고 끝난 것으로 착각하지 않는 것이다. 우리 팀은 복구 다음 날 동일 부하를 재현해 p99 지연이 100ms 이하인지 확인한 뒤에야 incident를 닫는다. 이 검증 단계를 빼면 같은 패턴이 2주 안에 거의 반드시 돌아왔다.