"파일이 바뀌었다는 알림은 사실의 완성본이 아니야. 다시 확인하라는 신호에 가까워."
fs.watch로 변경 알림 받기
node:fs/promises의 watch()는 파일이나 디렉터리의 변경 알림을 비동기 순회 대상으로 돌려줘. 반복문이 기다리다가 이벤트가 오면 eventType과 filename을 읽는 방식이야.
import { watch } from 'node:fs/promises';
for await (const event of watch('./src', { recursive: true })) {
console.log(event.eventType, event.filename);
}
eventType은 change 또는 rename이야. 이름만 보면 내용 수정과 이름 변경을 정확히 나눌 것 같지만 그렇게 믿으면 안 돼. 파일을 만들고 지우거나 편집기가 임시 파일로 교체하는 동작도 rename으로 보일 수 있고, filename이 아예 오지 않는 플랫폼도 있어. 이벤트를 받으면 해당 경로를 다시 읽거나 stat()으로 현재 상태를 확인해.
같은 API 아래에서 운영체제가 하는 일
Node는 운영체제가 제공하는 감시 기능을 감싸. Linux에서는 inotify를, macOS에서는 파일에 kqueue를 쓰고 디렉터리에는 FSEvents를 쓰며, Windows에서는 ReadDirectoryChangesW를 사용해. 그래서 JavaScript 호출 모양은 같아도 이벤트가 묶이는 방식과 파일 이름 제공 여부, 재귀 감시 지원 범위가 달라질 수 있어.
- Linux에서는 감시할 수 있는 항목 수에 사용자별 한도가 있어. 큰 저장소를 여러 도구가 함께 감시하면 새 감시 등록이 실패할 수 있어.
- macOS에서는 짧은 시간에 이어진 변경이 한 알림으로 묶이거나, 편집기의 원자적 저장이 여러 알림처럼 보일 수 있어.
- Windows에서도 파일 교체와 이름 변경이 애플리케이션이 기대한 한 번의 수정과 다르게 보고될 수 있어.
네트워크 파일 시스템과 가상화로 공유한 폴더에서는 운영체제 감시 기능 자체가 불안정할 수도 있어. “내 컴퓨터에서는 된다”가 다른 환경의 보장이 아닌 이유야.
여러 번 온 알림을 한 번의 작업으로 모으기
편집기에서 저장 한 번을 눌러도 임시 파일 생성, 내용 쓰기, 이름 교체가 연달아 일어날 수 있어. 알림마다 곧바로 빌드하면 같은 파일을 몇 번씩 처리하게 되지. 짧은 시간 동안 들어온 변경을 모았다가 한 번에 처리하는 디바운스가 필요한 까닭이야.
다만 알림을 합치는 동안 파일이 또 바뀔 수 있으므로 이벤트 내용을 진실의 원장처럼 쌓지 마. 시간이 지난 뒤 현재 파일 상태를 다시 읽고, 그 상태를 기준으로 빌드나 동기화를 실행하는 편이 안전해.
일관된 동작이 필요하면 chokidar
fs.watch는 작은 스크립트와 제한된 환경에서 충분해. 여러 운영체제에서 같은 add, change, unlink 의미가 필요하거나 원자적 저장과 쓰기 완료 대기를 다뤄야 한다면 chokidar가 그 차이를 정리해 줘. 네트워크 드라이브처럼 네이티브 알림을 믿기 어려운 환경에서는 폴링도 선택할 수 있어. 폴링은 주기적으로 상태를 확인하므로 CPU와 디스크 비용이 늘어난다는 점까지 함께 결정해야 해.node --watch는 다시 시작이지 모듈 교체가 아니야
현재 Node의 --watch 모드는 진입 파일과 불러온 모듈이 바뀌면 프로세스를 다시 시작해. 단순한 개발 서버에서는 nodemon이 맡던 흔한 역할을 내장 기능만으로 처리할 수 있어.
node --env-file=.env --watch server.mjs
이건 실행 중인 모듈만 갈아 끼우는 핫 모듈 교체가 아니야. 메모리 상태와 열린 연결은 프로세스와 함께 사라지고 새로 시작돼. 재시작 사이에도 유지해야 하는 상태는 데이터베이스나 파일처럼 프로세스 밖에 둬야 해.
종료 신호로 감시 반복문 닫기
watch()에 AbortSignal을 넘기면 종료할 때 비동기 반복문을 깨끗하게 끝낼 수 있어. 장시간 실행하는 도구라면 감시 핸들을 열린 채 남기지 않도록 종료 경로를 함께 만들어.
const controller = new AbortController();
process.once('SIGINT', () => controller.abort());
try {
for await (const event of watch('./src', {
recursive: true,
signal: controller.signal,
})) {
processChange(event);
}
} catch (error) {
if (error.name !== 'AbortError') throw error;
}