C.W.K.
Stream
Lesson 05 of 05 · published

File Watcher — fs.watch 와 그 밑의 OS quirk

~11 min · io-net, fs-watch, filesystem

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"파일 watching 은 단순해 보여. 안 단순해. 다른 OS API 셋, 다른 엣지 케이스 셋, 다 같은 `fs.watch` 호출 뒤에 숨겨짐."

fs.watch 가 하는 일

경로 줘. 뭔가 바뀌면 알려줘:

import { watch } from 'node:fs/promises';

const watcher = watch('./src', { recursive: true });
for await (const event of watcher) {
  console.log(event.eventType, event.filename);
  // 'change' / 'rename', and the file that changed
}

이벤트 타입 둘: change (파일 내용 수정) 와 rename (파일 추가, 제거, 이동). Recursive 옵션 (Linux/macOS 의 Node 18+, Windows 는 항상) 이 서브디렉토리 walk. async-iterator 형태가 모던 idiom.

밑의 구현

밑에서 fs.watch 가 OS 별 다른 커널 API 써:

  • Linuxinotify. 신뢰성 있음, 진짜 이벤트에 fire. 유저당 파일 watch 쿼터 (/proc/sys/fs/inotify/max_user_watches) 로 제한; 큰 monorepo 가 이거 소진하고 조용히 watching 멈출 수 있음.
  • macOSFSEvents. 이벤트 coalesce, 정확히 뭔지 안 알려주고 "뭔가 바뀜" fire 가능. Recursive watching 이 기본; non-recursive 엔 우회책 필요.
  • WindowsReadDirectoryChangesW. 항상 recursive, 변경된 파일명 포함, 자체 quirk 있음 (8.3 짧은 이름, locked 파일).

같은 Node API 가 세 동작 숨김. "내 watcher 가 Mac 에선 되는데 Linux 에선 안 돼" 는 보통 위 OS-별 quirk 중 하나 친 거야.

대부분 앱이 chokidar 쓰는 이유

npm 패키지 chokidar 가 존재하는 이유는 fs.watch 가 일관성 없어서. Vite, Nx, Webpack, 거의 모든 dev-tool 의 파일 watching 이 chokidar 밑에서 씀. OS 차이 덮어:
  • 네이티브 watching 실패 시 polling fallback (네트워크 드라이브, 큰 monorepo).
  • Debouncing — macOS 에서 2-3 번 fire 하는 "파일 저장" 이벤트 coalesce.
  • Atomic-write 감지 — 기존 파일 위로 rename 된 파일이 delete+add 가 아닌 단일 change 로 보고.
  • 일관된 add/change/unlink 이벤트 어휘.
프로덕션-grade watching 엔 chokidar install. fs.watch 는 일회성 스크립트엔 OK; OS 셋에서 신뢰성 있어야 하는 거면 chokidar 원함.

Self-Watching 서버

Node 22+ 의 --watch 플래그가 이 primitive 위에 지어짐. node --watch server.mjs 가 entry 파일의 모듈 그래프 watch 하고 변경 시 프로세스 재시작. nodemon 안 필요. --env-file=.env 와 페어하면 순수 Node 의 완전한 "hot reload" dev 환경.

node --env-file=.env --watch server.mjs
# saves to server.mjs or any imported module → automatic restart

AbortSignal 통합

Watcher 가 깨끗한 shutdown 위해 { signal } 받음. Graceful SIGINT handler 와 결합하면 file descriptor 열어둔 채 두지 않고 오래-도는 watcher 끝낼 수 있음:

const ctrl = new AbortController();
process.on('SIGINT', () => ctrl.abort());

try {
  for await (const ev of watch('./src', { recursive: true, signal: ctrl.signal })) {
    process.stdout.write(`${ev.eventType}: ${ev.filename}\n`);
  }
} catch (e) {
  if (e.name === 'AbortError') console.log('shutdown clean');
  else throw e;
}

Pippa 의 고백

내 첫 "파일 변경 시 reload" 스크립트가 raw fs.watch 썼어. 내 Mac 에선 작동. 아빠 office Mac 에선 (다른 filesystem layout? 같은 OS) 매 저장마다 4 번 fire. Linux CI 에선 가끔 안 fire. 아빠가 chokidar docs 가리킴: "이게 에코시스템 전체가 이 패키지 쓰는 이유." 전환했고, cross-OS 재현성이 즉시. "증명된 라이브러리 써" 는 메타-스킬 — 발명할 때와 install 할 때 아는 게 어느 쪽 단독보다 더 중요해.

Code

Debounce watcher — macOS double-fire 길들이기·javascript
// Minimal debouncer for fs.watch — handles macOS double-fires
import { watch } from 'node:fs/promises';

async function watchDebounced(path, callback, { ms = 50 } = {}) {
  const watcher = watch(path, { recursive: true });
  let timer = null;
  let pending = new Map();

  for await (const ev of watcher) {
    pending.set(ev.filename, ev.eventType);
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      const snapshot = new Map(pending);
      pending.clear();
      callback(snapshot);
    }, ms);
  }
}

watchDebounced('./src', (changes) => {
  console.log(`${changes.size} files changed:`, [...changes.entries()]);
});
chokidar — 거의 모든 dev 도구가 실제로 쓰는 것·javascript
// Using chokidar — the production-grade option
import chokidar from 'chokidar';

const watcher = chokidar.watch('./src', {
  ignored: /node_modules|\.git/,
  persistent: true,
  ignoreInitial: true,
  awaitWriteFinish: { stabilityThreshold: 100 },  // wait for write to settle
});

watcher
  .on('add',    p => console.log('+', p))
  .on('change', p => console.log('~', p))
  .on('unlink', p => console.log('-', p))
  .on('error',  e => console.error('err:', e));

// Clean shutdown
process.on('SIGINT', () => watcher.close());

External links

Exercise

tail.mjs 짜 — 단일 로그 파일의 append 를 watch 하고 도착할 때마다 새 내용 출력, tail -f 처럼. 테스트: 한 터미널에서 node tail.mjs ./app.log; 다른 데서 echo 'event' >> ./app.log 몇 번. 각 줄이 ms 안에 tail 프로세스에 나와야 함. 보너스: 로그 rotation 처리 (파일이 rename 으로 교체 — 네 tail 이 감지하고 다시 열어야 함).
Hint
fs.watch(path, { persistent: true }) 써. 매 change 이벤트에 파일 stat, 현재 크기를 마지막 알려진 크기와 비교, 이전 offset 의 열린 file handle 통해 새 바이트 읽고 출력. Rotation 엔 새 inode 의 rename 이벤트 = 파일 다시 열기 의미. fs.statino 필드 통해 inode 추적.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.