Node.js OOMKilled 반복 장애를 40분 안에 멈추는 운영 플레이북
💡 Key Takeaways
- OOM 장애는 코드 수정보다 먼저 메모리 상승 구간과 트래픽 패턴을 함께 고정해 원인 범위를 줄여야 한다.
- 복구는 리소스 상향보다 유입 제어와 누수 경로 차단을 먼저 적용해야 재발 확률이 낮다.
- 사고 종료 후 힙 스냅샷 채집 조건과 롤백 기준을 런북으로 남겨야 다음 온콜 교대에서 흔들리지 않는다.
1) 문제 정의: 재시작은 되는데 장애는 끝나지 않는 상황
화요일 오후 2시 10분, 결제 직전 API를 담당하는 Node.js 서비스 파드가 6분 간격으로 죽기 시작했다. 모니터링 대시보드에는 CPU보다 메모리 그래프가 먼저 치솟았고, OOMKilled 이벤트가 누적되면서 HPA가 오히려 파드를 더 늘려 클러스터 전체 메모리 압박이 커졌다. 현장에서 자주 나오는 반응은 "일단 메모리 제한을 두 배로 올리자"인데, 이 선택은 증상만 지연시키고 장애 시간을 길게 만들기 쉽다. 우리 팀은 먼저 기준을 고정했다. 장애 시작 시각, 재시작 주기, 상위 3개 엔드포인트의 요청량, 그리고 마지막 정상 배포 SHA를 같은 문서에 묶어 두고, 온콜 2명은 지표 수집과 트래픽 완화 역할을 분리했다. 이때 핵심은 "어디서 새고 있는지"보다 "언제부터 얼마나 빠르게 증가하는지"를 먼저 숫자로 잡는 것이다. 상승 기울기와 트래픽 스파이크가 맞물리는 구간이 보이면, 코드 누수·캐시 누적·대용량 응답 버퍼링 중 어느 쪽이 우선 의심 대상인지 훨씬 빠르게 좁힐 수 있다.
2) 판단 기준: 누수인지, 설정 미스인지, 일시 부하인지 구분하는 순서
판단 순서를 뒤집으면 디버깅이 길어진다. 첫 질문은 "최근 배포가 메모리 곡선을 바꿨는가"다. 배포 직후부터 RSS가 계단형으로 계속 오르면 코드 경로 변경 가능성이 크고, 배포와 무관하게 특정 시간대에만 급등하면 트래픽 특성이나 배치성 작업을 먼저 본다. 두 번째는 컨테이너 제한값과 Node 런타임 플래그를 대조하는 것이다. memory.limit는 512Mi인데 --max-old-space-size를 1024로 둔 상태면 GC가 버티다가 커널에 강제 종료당한다. 세 번째는 엔드포인트별 응답 크기와 객체 생존 시간을 확인한다. 우리 사고에서도 CSV 내보내기 요청이 몰리는 15분 동안, 스트리밍으로 보내야 할 데이터를 메모리에 모아 압축한 뒤 응답하는 코드가 남아 있었다. 마지막으로 에러율보다 p95 응답시간과 큐 적체를 같이 본다. 누수는 즉시 500을 만들지 않고, 지연 증가 후 타임아웃으로 번지는 경우가 많다. 이 질문 순서를 지키면 "일단 증설" 같은 비싼 대응을 늦추고, 실제 원인에 가까운 조치를 먼저 선택할 수 있다.
3) 실행 절차: 40분 복구를 위한 단계별 대응
복구는 유입 완화 -> 누수 경로 차단 -> 제한적 롤백 -> 안정화 검증 순서로 진행했다. 1단계에서 API 게이트웨이의 특정 내보내기 엔드포인트에 분당 요청 상한을 즉시 걸어 메모리 상승 속도를 낮췄다. 2단계에서 문제 경로를 feature flag로 우회해 대용량 응답을 비동기 작업 큐로 넘기고, 사용자에게는 "생성 후 다운로드" 방식으로 전환했다. 이 조치만으로 파드 재시작 주기가 6분에서 28분으로 늘어 응급 상황을 벗어났다. 3단계에서는 직전 안정 버전으로 부분 롤백하되, 인증/결제 공통 모듈은 유지해서 2차 회귀를 막았다. 롤백 범위를 작게 가져간 이유는 전체 롤백이 더 안전해 보여도, 동시 진행 중이던 보안 패치를 되돌릴 위험이 있었기 때문이다. 4단계에서 20분 동안 재시작 횟수, 노드 메모리 여유, 핵심 트랜잭션 성공률을 함께 모니터링했고, 기준치를 만족할 때만 장애 종료를 선언했다. 복구의 목적은 "원인 완전 제거"가 아니라 "서비스 영향도를 빠르게 줄이면서 안전한 조사 상태를 확보"하는 데 있다.
4) 운영 체크리스트: 같은 OOM 사고를 다시 만들지 않는 마감 작업
장애 종료 후 가장 먼저 한 일은 힙 스냅샷 채집 조건을 런북에 명시한 것이다. 예를 들어 "RSS 75% 초과가 5분 지속되면 자동 스냅샷 1회"처럼 조건을 고정하면, 다음 온콜이 주관적으로 버튼을 누르지 않아도 된다. 둘째, CI에 간단한 메모리 회귀 테스트를 추가해 대표 시나리오 10분 부하에서 RSS 증가율 임계치를 넘으면 배포를 막도록 했다. 셋째, Node 프로세스 플래그와 쿠버네티스 리소스 제한의 조합을 템플릿으로 고정해 서비스마다 제각각 설정되는 문제를 줄였다. 넷째, "임시 증설"을 했던 변경은 만료 시간을 지정해 자동 원복되게 만들었다. 실제 현장에서는 긴급 대응으로 올린 자원이 몇 주씩 남아 비용만 증가하는 경우가 잦다. 마지막으로 회고 문서에는 기술 원인뿐 아니라 의사결정 타임라인을 남겼다. 어느 시점에 요청 제한을 걸었고, 왜 전체 롤백 대신 부분 롤백을 택했는지 기록해 두면, 다음 사고에서 팀이 같은 논쟁을 반복하지 않는다. 운영 품질은 완벽한 코드보다, 반복 가능한 판단 기준과 종료 조건을 남기는 습관에서 나온다.