MCP 도구의 처리는 끝났는데 Node.js 프로세스가 종료되지 않는다면, 실행 중인 타이머와 연결을 먼저 구분한다. 응답 완료, 연결 종료, 프로세스 종료는 같은 사건이 아니다. 계속 요청을 받는 서버가 살아 있는 것은 정상일 수 있다. 일회성 작업이나 명시적인 종료 절차 뒤에도 남아 있는 경우가 진단 대상이다.
이 글은 Node.js 기반 MCP 서버·보조 프로세스의 종료 진단이다. MCP 프로토콜이나 특정 SDK의 종료 계약을 새로 정의하지 않는다. 공식 API 설명은 Node.js v22.23.3 문서와 대조했고, 최소 예제는 Linux·Node.js v24.19.0에서 실행했다. 확인일은 2026년 10월 18일 KST다. 다른 런타임에서는 같은 이름의 API를 가정하지 않는다.
먼저 “무엇이 끝나야 하는가”를 정한다
가상 사례로, 도구 호출 하나가 끝날 때마다 상태 확인용 반복 타이머를 새로 만든다고 하자. 응답은 정상 반환되지만 그 타이머가 정리되지 않아 보조 프로세스가 남을 수 있다. 반면 HTTP 서버가 다음 요청을 기다리거나 stdio 연결이 유지되는 상황이라면 프로세스 생존 자체가 오류는 아니다. 도구 실행 완료만 보고 서버를 강제로 종료하면 다음 호출을 망가뜨릴 수 있다.
| 관측한 상태 | 먼저 확인할 것 |
|---|---|
| 응답 뒤 서버가 살아 있음 | 상시 서버인지 일회성 작업인지 |
| 종료 요청 뒤에도 남음 | 타이머·소켓·서버 등 종료를 막는 자원 |
| CPU 사용과 함께 모든 응답 지연 | 이벤트 루프 차단 여부, 종료 대기와 구별 |
| 강제 종료 뒤 결과 일부 누락 | 출력·저장·하위 작업 완료 여부 |
자원 종류를 전후로 비교한다
process.getActiveResourcesInfo()는 이벤트 루프를 살아 있게 하는 활성 자원의 종류를 배열로 제공한다. 특정 요청의 소유자나 생성 위치를 알려주는 목록은 아니다. 시작 전, 작업 중, 정상 완료 뒤, 취소 뒤를 같은 조건에서 비교하고, 자신이 만든 타이머의 참조와 정리 지점을 별도로 추적한다. 자원 종류가 하나 남았다는 이유만으로 누수나 장애 원인을 확정하지 않는다.
다음은 네트워크 없이 타이머 한 개의 생성·정리를 관찰하는 합성 예제다. timer-probe.mjs로 저장하고 node timer-probe.mjs로 실행한다. 진단 출력은 stderr로 보내며 실제 MCP의 stdout 프로토콜 메시지와 섞지 않는다. 60,000 ms는 예제가 정리하기 전에 콜백이 실행되지 않도록 둔 값이지 운영 주기 권장치가 아니다.
import {getActiveResourcesInfo} from 'node:process';
const before = getActiveResourcesInfo().filter(x => x === 'Timeout').length;
const timer = setInterval(() => {}, 60_000);
const during = getActiveResourcesInfo().filter(x => x === 'Timeout').length;
console.error(JSON.stringify({before,during,hasRef:timer.hasRef()}));
clearInterval(timer);
console.error(JSON.stringify({after:getActiveResourcesInfo().filter(x => x === 'Timeout').length}));
이 환경의 실제 출력은 생성 전 before:0, 생성 뒤 during:1과 hasRef:true, 정리 뒤 after:0이었다. 프로세스는 종료 코드 0으로 끝났다. 여기서 확인한 것은 이 작은 예제의 타이머 수명뿐이다. 실제 MCP 장애를 재현하거나 운영 서버를 고쳤다는 결과는 아니다. 다른 환경의 기본 자원 종류·개수는 달라질 수 있다.
clear와 unref는 다른 선택이다
clearInterval(timer)은 해당 반복 타이머를 취소한다. 반면 timer.unref()는 타이머 자체를 취소하지 않고, 그것만으로 프로세스를 계속 살려 두지 않도록 한다. 다른 작업이 프로세스를 유지하면 콜백은 여전히 실행될 수 있다. 다른 활성 작업이 없으면 콜백이 실행되기 전에 프로세스가 끝날 수 있다. 따라서 반드시 끝나야 하는 저장·정산·결과 전송을 unref 타이머에 맡기는 것은 완료 보장이 아니다.
작업이 끝나 더 필요 없는 반복 관측이라면 자신이 만든 타이머를 정리하는 쪽이 맞다. 프로세스가 살아 있는 동안만 해도 되는 부수 작업이라면 unref를 검토할 수 있다. 어느 쪽이든 타이머가 수행하는 일을 먼저 확인한다. 모든 자원을 일괄 해제하거나 공용 서버·연결을 임의로 닫는 처방은 피한다.
정리 범위는 비동기 작업의 끝까지
설계 제안은 타이머를 만든 구성요소가 성공·실패·취소의 정리 책임을 함께 갖는 것이다. 비동기 작업을 기다리는 try/finally 범위를 사용하되, 작업이 영원히 끝나지 않으면 finally에도 도달하지 않는다는 점을 남겨 둔다. 취소 신호와 시간 제한을 실제 작업이 처리해야 한다. 타이머를 지우는 것만으로 이미 실행 중인 네트워크 요청이나 콜백의 작업이 취소되지는 않는다.
process.exit()로 조용히 끝내는 것은 원인 확인의 대체물이 아니다. Node.js 문서는 강제 종료가 아직 완료되지 않은 stdout·stderr 출력 등을 잘라낼 수 있다고 설명한다. 필요한 자원을 정상 종료하고, 실패 상태를 전달해야 한다면 종료 코드를 설정하는 방식을 검토한다. 종료 코드 설정 자체는 살아 있는 자원을 정리하지 않는다. 실제 저장·전송을 이미 요청했다면 재시도보다 반영 여부 확인이 먼저다.
수정 뒤 확인할 네 가지
① 정상 완료와 오류·취소 각각에서 자신이 만든 타이머가 정리되는가. ② 필요한 최종 응답과 저장 결과가 남는가. ③ 상시 서버는 다음 요청을 계속 받을 수 있는가. ④ 일회성 보조 프로세스는 다른 자원이 정리된 뒤 정상 종료되는가. “프로세스가 없어졌다” 하나만으로는 네 조건을 대신할 수 없다.
공식 근거와 이어 읽기
Node.js v22.23.3 Timers에서 기본 참조 동작, hasRef·unref와 타이머 취소의 차이를 확인했다. Process: getActiveResourcesInfo와 exitCode, exit 관련 설명을 대조했다. Process 문서는 확인 당시 v22.23.3이며 latest 주소는 갱신될 수 있다. 확인일: 2026년 10월 18일 KST.
콜백 등록 수가 쌓인다면 MaxListenersExceededWarning과 리스너 정리, 모든 도구가 함께 느리다면 이벤트 루프 지연 진단을 함께 읽는다. 자원의 종류와 종료 조건이 다르므로 같은 해결책을 복사하지 않는다.