FileSystemWatcher 사용법과 주의점 - 누락, 중복 알림, 완료 판정의 함정
· 업데이트: · 小村 豪 · FileSystemWatcher, C#, .NET, Windows 개발, 파일 연계, 설계
수정 이력(1건, 최종 수정 2026년 09월 01일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635101)
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635100)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
小村 豪 (2026). 「FileSystemWatcher 사용법과 주의점 - 누락, 중복 알림, 완료 판정의 함정」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635100 https://comcomponent.com/ko/blog/2026/03/10/000-filesystemwatcher-safe-basics/
- DOI(최신 버전)
- 10.5281/zenodo.21635100
- DOI(이 버전)
- 10.5281/zenodo.22217417
FileSystemWatcher는 Windows 상의 .NET에서 파일 변경을 감시할 때 가장 먼저 후보가 되는 API입니다. 파일이나 디렉터리의 작성, 변경, 삭제, 이름 변경을 이벤트로 받을 수 있어 편리하지만, Created나 Changed를 그대로 완료 알림이라고 생각해서 쓰면 누락, 중복 알림, 도중 파일의 오독으로 상당히 흔하게 사고가 납니다.
이 글에서는 FileSystemWatcher의 사용법과 주의점을, 주로 Windows 상의 .NET에 의한 파일 연계를 전제로 정리합니다. 아울러 전제가 되는 배타 제어의 사고방식은 파일 연계의 배타 제어 기초 지식 - 파일 락과 원자적 claim의 베스트 프랙티스도 참조할 수 있도록 했습니다.
실제로 파일 복사 중에 Created가 먼저 날아가는 경우도 있고, Changed도 1회로 끝난다는 보장이 없습니다. 단시간에 변경이 집중되면 내부 버퍼가 넘쳐서 개별 변경을 놓치는 경우도 있습니다.
그래서 설계의 핵심은 이렇습니다.
- 알림은 계기
- 진실은 디렉터리 재스캔
- 소유권은 원자적인 claim
- 마지막은 idempotency로 받아들인다
본문에서는 이 사고방식으로 FileSystemWatcher를 파일 연계에 편성할 때의 함정을 순서대로 살펴봅니다.
또한 이 글에 등장하는 코드는 빌드·실행할 수 있는 샘플 일체(라이브러리, 임시 디렉터리에서 동작하는 콘솔 데모, 실제로 파일을 작성·변경해 이벤트를 검증하는 유닛 테스트)로 GitHub에 공개하고 있습니다.
filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)
대상 독자와 전제
Windows 상의 .NET에서 수신 디렉터리를 감시해 파일을 가져오는 처리를 작성하는 개발자를 대상으로 씁니다. 코드 예시는 C# / .NET 8 이상을 전제로 하지만, 사고방식 자체는 언어를 가리지 않습니다.
이 글은 위에 링크한 지난 기사(파일 연계의 배타 제어)와 같은 용어를 그대로 씁니다. claim, idempotency, manifest, bundle 등은 4장 이후에 설명 없이 등장하므로, 지난 기사를 읽지 않았어도 따라올 수 있도록 먼저 한 줄씩 정리해 둡니다.
먼저 짚어 둘 용어
| 용어 | 의미 |
|---|---|
| claim | 「이 파일은 내가 처리한다」는 소유권을, 다른 워커에게 끼어들지 못하는 형태로 취하는 것입니다. 구현으로는 incoming/에서 processing/<worker>/로의 rename을 사용하며, rename에 성공한 1개 프로세스만 소유자가 됩니다(4.3) |
| idempotency(멱등성) | 같은 대상을 2회 이상 처리해도 결과가 1회 처리했을 때와 같아지는 성질입니다. 중복 알림이나 재주사를 전제로 하는 이상, 마지막은 여기서 받아들이게 됩니다(4.5) |
| manifest | 본체 데이터와 함께 두는, 내용을 설명하는 작은 파일입니다. 건수, 해시, IdempotencyKey 등을 넣어 두면 수신 측이 「이것은 처리 완료인가」를 판단할 수 있습니다 |
| bundle | 1건분의 연계를 하나로 묶은 단위입니다. 본체 + manifest + 보조 파일을 1개의 디렉터리에 넣어 두면, 그 디렉터리째로 1회의 rename으로 claim할 수 있습니다(4.3) |
| full rescan | 이벤트에 의존하지 않고, 감시 대상 디렉터리를 처음부터 다시 열거해 처리해도 좋은 대상을 다시 가려내는 것입니다(4.4) |
| overflow | FileSystemWatcher의 내부 버퍼가 넘쳐 개별 알림을 잃는 것입니다. Error 이벤트로 통지됩니다(2.3) |
| ready | 「이제 읽어도 좋다」고 판단할 수 있는 상태입니다. 추측이 아니라 final 이름이나 done / manifest의 존재로 판정합니다(4.2) |
목차
- 먼저 결론(한마디로)
- 1.1. 먼저 동작하는 최소 코드
FileSystemWatcher에서 일어나는 오해 패턴(그림)- 2.1.
Created를 완료 알림이라고 생각한다 - 2.2.
Changed의 횟수와 순서를 믿는다 - 2.3. 내부 버퍼 넘침으로 변경을 잃는다
- 2.1.
- 안티패턴
- 3.1. 이벤트 핸들러 안에서 그대로 처리한다
- 3.2. 이벤트 열로부터 진실의 상태를 복원하려 한다
- 3.3.
Changed가 멈추면 완료 취급 - 3.4.
InternalBufferSize를 올리면 해결됐다고 생각한다 - 3.5.
Error를 로그만 내고 무시한다
- 베스트 프랙티스
- 4.1. 알림은 「재스캔 요청」으로 접는다
- 4.2. 완료 조건은 송신 측에서 명시한다
- 4.3. 수신 측은 claim을 원자적으로 취한다
- 4.4. startup / overflow / 재접속 시에는 full rescan한다
- 4.5. idempotency를 전제로 한다
- 의사 코드(발췌)
- 5.1. 전형적인 실패 패턴
- 5.2. 올바른 방향의 예(대략 쓰면 이렇게)
- 대강의 사용 구분
- 정리
- 참고 자료
이 글의 지식 맵
이 기사는 FileSystemWatcher의 Created/Changed 이벤트를 완료 알림으로 다루는 오류나, 내부 버퍼의 overflow로 인한 알림 누락, 이벤트 열로부터 상태를 복원하려는 설계를 피하고, 알림은 모두 1종류의 재스캔 요청으로 접어 full rescan으로 실물을 확인하는 설계를 제안합니다. 완료는 송신 측이 temp -> rename/done·manifest로 명시하고, 수신 측은 재스캔으로 찾은 ready 후보에 대해 원자적 claim을 취하며, 여러 번 보러 가는 것을 전제로 idempotency로 받아들입니다. 상시 기동할 수 없거나 누락이 허용되지 않는 요건에는 USN change journal이라는 선택지도 있다고 자리매김합니다.
flowchart LR
accTitle: FileSystemWatcher 실무 가이드의 지식 맵
accDescr: FileSystemWatcher의 알림이 완료 알림이 아니라 변화의 기미에 지나지 않는다는 것, 알림을 재스캔 요청으로 접어 full rescan과 claim을 조합하는 것, 내부 버퍼의 overflow로 인한 알림 누락과 USN change journal이라는 대안의 관계를 보여주는 그림
filesystemwatcher["FileSystemWatcher"]
full_rescan["full rescan(디렉터리 전체 재스캔)"]
buffer_overflow_event_loss["내부 버퍼 overflow로 인한 알림 누락"]
periodic_directory_listing["주기적인 디렉터리 열거"]
change_notification_loss["변경 알림 누락"]
internal_buffer_size_tuning["InternalBufferSize 조정"]
error_event_ignored_antipattern["Error 이벤트를 로그만 남기고 무시하는 안티패턴"]
created_event_misinterpreted_as_complete["Created를 완료 알림으로 오해"]
partial_write_read["쓰기 중인 파일의 읽기 사고"]
sender_side_completion_signaling["송신측의 완료 조건 명시"]
temp_then_rename_publish["temp -> close -> rename/replace로 공개"]
done_manifest_file["done/manifest 파일"]
scan_request_coalescing["알림을 재스캔 요청으로 통합"]
changed_event_order_assumption["Changed 횟수·순서를 믿는 안티패턴"]
event_log_state_reconstruction_antipattern["이벤트 시퀀스로 상태를 복원하려는 안티패턴"]
duplicate_processing["이중 처리(이중 계상·이중 전송·업데이트 유실)"]
atomic_claim["원자적 claim"]
bundle["bundle(연계 단위 디렉터리)"]
idempotent_processing["idempotency(멱등성)를 전제로 한 처리"]
watcher_downtime_gap["watcher 중지 중 변경 누락"]
usn_journal["USN 저널(변경 저널)"]
ntfs["NTFS"]
admin_rights["관리자 권한"]
filesystemwatcher -->|"원인이 될 수 있다"| buffer_overflow_event_loss
full_rescan -.->|"이용한다"| periodic_directory_listing
full_rescan -->|"권장되는 대응"| change_notification_loss
full_rescan -->|"권장되는 대응"| buffer_overflow_event_loss
internal_buffer_size_tuning -->|"사용은 비권장"| buffer_overflow_event_loss
error_event_ignored_antipattern -.->|"원인이 될 수 있다"| change_notification_loss
filesystemwatcher -.->|"원인이 될 수 있다"| created_event_misinterpreted_as_complete
created_event_misinterpreted_as_complete -->|"원인이 될 수 있다"| partial_write_read
sender_side_completion_signaling -->|"권장되는 대응"| created_event_misinterpreted_as_complete
sender_side_completion_signaling -->|"이용한다"| temp_then_rename_publish
sender_side_completion_signaling -->|"이용한다"| done_manifest_file
scan_request_coalescing -->|"권장되는 대응"| changed_event_order_assumption
scan_request_coalescing -->|"권장되는 대응"| event_log_state_reconstruction_antipattern
full_rescan -->|"권장되는 대응"| event_log_state_reconstruction_antipattern
changed_event_order_assumption -->|"원인이 될 수 있다"| duplicate_processing
atomic_claim -->|"방지한다"| duplicate_processing
bundle -->|"이용한다"| atomic_claim
bundle -->|"이용한다"| done_manifest_file
idempotent_processing -->|"권장되는 대응"| duplicate_processing
full_rescan -->|"보다 먼저 해야 한다"| atomic_claim
scan_request_coalescing -->|"보다 먼저 해야 한다"| full_rescan
filesystemwatcher -->|"원인이 될 수 있다"| watcher_downtime_gap
full_rescan -->|"완화한다"| watcher_downtime_gap
usn_journal -->|"권장되는 대응"| watcher_downtime_gap
usn_journal -->|"전제로 한다"| ntfs
usn_journal -.->|"전제로 한다"| admin_rights
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 26건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
1. 먼저 결론(한마디로)
FileSystemWatcher의 이벤트는 완료 알림이 아니라 변화의 기미입니다Created/Changed/Renamed는 중복되거나 상상과 다른 순서로 오거나 overflow 시에는 누락됩니다- 이벤트 핸들러에서는 무거운 처리를 하지 않고 재스캔 요청을 쌓기만 하는 편이 안정됩니다
- 완료 판정은
temp -> close -> rename / replace나done/ manifest로 명시하는 것이 기본입니다 - 복수 워커가 있다면 읽기 전에 claim을 원자적으로 취득할 필요가 있습니다
InternalBufferSize의 조정은 보조입니다. 마지막은 full rescan과 idempotency가 효과적입니다
요컨대 FileSystemWatcher를 「진실의 이력 스트림」으로서 다루지 않는 것입니다.
알림은 어디까지나 「슬슬 보러 가라」의 신호에 머무는 편이 망가지기 어려워집니다.
flowchart TB
accTitle: 이 글의 설계 핵심
accDescr: 알림은 계기에 그치고, 진실은 디렉터리 재스캔으로 확인하며, 소유권은 원자적인 claim으로 취하고, 마지막은 idempotency로 중복을 받아들인다는 이 글의 설계 핵심을 나타낸다.
notif["알림은 계기"] --> rescan["진실은 디렉터리 재스캔"]
rescan --> claim["소유권은 원자적인 claim"]
claim --> idem["마지막은 idempotency로 받아들인다"]
그림 1: 설계의 핵심. 이벤트를 진실의 이력으로 다루지 않고, 「슬슬 보러 가라」는 신호에 머물게 한다.
1.1. 먼저 동작하는 최소 코드
FileSystemWatcher를 아직 다뤄본 적이 없는 분을 위해, 정상 경로만의 최소 형태를 놓아 둡니다. 여기서부터의 장은 이 10줄이 「동작해 버리는」 지점에서 시작되는 함정에 관한 이야기입니다.
// C# / .NET 8 콘솔 앱. 알림이 도착하는지만 확인하는 최소 형태
using System.IO;
using var watcher = new FileSystemWatcher(@"C:\incoming")
{
Filter = "*.csv",
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite,
};
watcher.Created += (_, e) => Console.WriteLine($"Created: {e.FullPath}");
watcher.Changed += (_, e) => Console.WriteLine($"Changed: {e.FullPath}");
watcher.Renamed += (_, e) => Console.WriteLine($"Renamed: {e.OldFullPath} -> {e.FullPath}");
watcher.Error += (_, e) => Console.WriteLine($"Error: {e.GetException().Message}");
watcher.EnableRaisingEvents = true; // 여기서 감시가 시작됨
Console.WriteLine("Enter 키로 종료합니다");
Console.ReadLine();
최소 형태라도 다음 3가지는 처음에 짚어 두면 헤매지 않습니다.
EnableRaisingEvents = true로 하기 전까지는 이벤트가 1개도 오지 않습니다. 핸들러를 등록하는 것만으로는 동작하지 않습니다watcher의 수명이 앱의 수명입니다. 로컬 변수의 스코프가 끝나 파기되면 거기서 알림이 멈춥니다. 상주시키려면 필드 등 계속 살아 있는 곳에 둡니다NotifyFilter의 기본값은LastWrite | FileName | DirectoryName의 조합입니다(8. 참고 자료의 FileSystemWatcher.NotifyFilter Property). 무엇을 주울지는 명시해 두는 편이 나중에 다시 읽었을 때 헤매지 않습니다
그리고 중요한 것은 이 코드는 「이벤트가 도착하는 것」만 확인하고 있다는 점입니다. Created 시점에서 파일을 읽어도 좋은지도, 알림을 놓치고 있지 않은지도 이 형태로는 알 수 없습니다. 여기서부터가 본론입니다.
2. FileSystemWatcher의 사용법에서 일어나기 쉬운 오해 패턴(그림)
2.1. Created를 완료 알림이라고 생각한다
이것이 가장 알기 쉬운 지뢰입니다.
복사나 전송에서는 파일이 만들어진 순간에 Created가 날아가고, 그 후에 1회 이상의 Changed가 이어지는 경우가 있습니다.
sequenceDiagram
participant 送信 as 송신 측
participant 共有 as watched dir
participant W as FileSystemWatcher
participant 受信 as 수신 측
送信->>共有: orders.csv를 작성
共有-->>W: Created
W-->>受信: OnCreated
受信->>共有: orders.csv를 열어 읽음
Note over 受信: 아직 복사 도중
送信->>共有: 나머지를 기록
共有-->>W: Changed
共有-->>W: Changed
Note over 受信: 행수 부족 / JSON 파손 / ZIP 파손
그림 2: 복사 도중에도 Created는 날아간다. 도착한 시점에 읽으러 가면 깨진 데이터를 쥐게 된다.
Created는 「이름이 보였다」를 나타내도, 「이제 읽어도 좋다」를 보증하지 않습니다.
여기를 같은 의미로 하면 지난번 기사의 2.1을 다른 경로로 밟게 됩니다.
2.2. Changed의 횟수와 순서를 믿는다
Changed는 1회만 오는 것이 아닙니다.
이동이나 저장 같은 일반적인 조작으로도, 복수의 이벤트로 나뉘어 보이는 경우가 있습니다. 또한 안티바이러스 소프트나 인덱서가 건드린 분까지 주워오는 경우도 있습니다.
sequenceDiagram
participant App as 저장하는 앱
participant Dir as watched dir
participant AV as AV / indexer
participant W as FileSystemWatcher
App->>Dir: report.xlsx 저장 시작
Dir-->>W: Created
Dir-->>W: Changed
App->>Dir: 임시 파일에서 rename
Dir-->>W: Renamed
Dir-->>W: Changed
AV->>Dir: 스캔 / 속성 참조
Dir-->>W: Changed
Note over W: 횟수·순서 모두 보장되지 않음
그림 3: 일반적인 저장에서도 이벤트는 여러 개로 나뉘고, 외부 프로세스가 건드린 것까지 섞인다. 횟수도 순서도 믿을 수 없다.
「Changed가 1회 오면 완료」「Renamed 다음은 더 이상 건드려지지 않는다」라는 기대는 상당히 위험합니다.
보충:
- 파일 rename으로
Changed가 날아가는 경우가 있습니다 RenamedEventArgs.Name은 OS 측에서 old/new의 대응이 잡히지 않으면null이 될 수 있습니다- hidden file도 무시되지 않습니다. 숨은 temp 이름이니 보이지 않겠지는 통용되지 않습니다
- 감시하고 있는 디렉터리 자체를 rename해도 그 변경은 알림되지 않습니다
2.3. 내부 버퍼 넘침으로 변경을 잃는다
FileSystemWatcher에는 내부 버퍼가 있습니다.
단시간에 변경이 집중되면 여기가 넘쳐서 개별 알림을 누락합니다.
flowchart LR
A[단시간에 대량의 변경] --> B[내부 버퍼에 알림이 쌓임]
B --> C{처리가 따라잡나?}
C -- 예 --> D[개별 이벤트를 순서대로 처리]
C -- 아니오 --> E[overflow]
E --> F[Error 이벤트]
F --> G[개별 이력의 완전성을 신용하지 않음]
G --> H[디렉터리를 full rescan]
그림 4: 알림 버스트가 내부 버퍼를 넘으면 overflow가 일어나고, 개별 이벤트 열의 완전성이 무너진다.
여기서 중요한 것은 「overflow가 일어나면 1건만 잃는다」고 한정되지 않는다는 점입니다. 개별 이벤트 열의 완전성 자체가 수상해지므로 차라리 전체를 다시 보는 편이 좋습니다.
3. 안티패턴
3.1. 이벤트 핸들러 안에서 그대로 처리한다
이것은 완료 판정과 소유권 취득을 이벤트에 너무 짊어지게 합니다.
watcher.Created += (_, e) =>
{
using var stream = File.OpenRead(e.FullPath);
Import(stream); // 아직 복사 중일지도 모름
};
watcher.Error += (_, e) =>
{
Console.WriteLine(e.GetException()); // 표시만
};
문제는 2가지 있습니다.
Created시점에서는 내용이 미완성일 수도 있음- 실패나 overflow의 회복이 없음
이벤트 핸들러는 재스캔 요청을 세우고 즉시 돌아오기 정도가 딱 좋습니다. 여기서 무거운 I/O나 DB 갱신까지 시작하면 버스트 시에 자기 목을 조르게 됩니다.
flowchart TB
accTitle: 이벤트 핸들러의 무게 분기점
accDescr: 이벤트 핸들러에서는 재스캔 요청을 세우고 즉시 돌아오는 것이 좋고, 핸들러 안에서 무거운 I/O나 DB 갱신까지 시작하면 미완성 내용을 읽는 위험과 버스트 시 처리가 따라잡지 못하는 문제를 안게 됨을 나타낸다.
ev["이벤트 핸들러"] --> light["재스캔 요청을 세우고 즉시 돌아옴"]
heavy["핸들러 내에서 무거운 I/O나 DB 갱신"] -.-> raw["미완성 내용을 읽는 위험"]
heavy -.-> choke["버스트 시 처리가 따라잡지 못함"]
그림 5: 핸들러는 가볍게. 완료 판정과 소유권 취득을 이벤트에 짊어지우지 않는다.
3.2. 이벤트 열로부터 진실의 상태를 복원하려 한다
「Created로 딕셔너리에 추가, Changed로 갱신, Deleted로 삭제, Renamed로 키 교체」라는 설계는 얼핏 깔끔합니다.
다만, 중복, 분할, overflow, 외란이 들어가면 점차 앞뒤가 안 맞게 됩니다.
switch (e.ChangeType)
{
case WatcherChangeTypes.Created:
state[e.FullPath] = Pending;
break;
case WatcherChangeTypes.Changed:
state[e.FullPath] = Modified;
break;
case WatcherChangeTypes.Deleted:
state.Remove(e.FullPath);
break;
}
이 방향으로 분발하기보다 그때마다 디스크 상의 현물을 재확인하는 편이 강합니다. 파일 연계에서 중요한 것은 이벤트 이력의 재현이 아니라 지금 이 순간에 처리해도 좋은 대상을 올바르게 찾는 것입니다.
flowchart TB
accTitle: 이벤트 복원과 현물 확인의 대비
accDescr: 이벤트 열로부터 상태를 복원하는 설계는 중복·분할·overflow·외란으로 앞뒤가 무너지므로, 그때마다 디스크 상의 현물을 재확인하여 지금 이 순간에 처리해도 좋은 대상을 올바르게 찾는 편이 강함을 나타낸다.
ev2["이벤트 열로부터 상태를 복원한다"] -.-> broke["중복·분할·overflow로 앞뒤가 무너짐"]
disk["그때마다 디스크 상의 현물을 확인"] --> goal["처리해도 좋은 대상을 올바르게 찾음"]
그림 6: 목적은 이벤트 이력의 재현이 아니라 지금 처리해도 좋은 대상을 찾는 것이다.
3.3. Changed가 멈추면 완료 취급
지난번의 「파일 크기가 멈추면 완료」와 같은 냄새가 나는 설계입니다. 편리해 보이지만 추측으로 완료를 결정하고 있습니다.
if (lastChangedAt + TimeSpan.FromSeconds(10) < DateTime.UtcNow)
{
return Ready;
}
이것으로 곤란한 것은 예를 들어 다음과 같은 경우입니다.
- 큰 파일의 복사가 도중에 일시 정지한다
- 송신 측 앱이 복수 단계로 저장한다
- 네트워크 공유에서 알림이 늦게 보인다
- 외부 프로세스가 나중에 속성이나 시각을 덮어쓴다
완료는 추측이 아니라 명시하는 편이 안정됩니다.
flowchart TB
accTitle: 고요함에 의한 완료 추측의 위험함
accDescr: Changed가 한동안 멈추면 완료로 간주하는 추측은 복사의 일시 정지나 다단계 저장, 알림 지연, 나중의 속성 재작성으로 오판정하므로, 완료는 송신 측이 명시하는 편이 안정적임을 나타낸다.
guess["Changed가 멈추면 완료로 추측"] -.-> c1["복사의 일시 정지로 오판정"]
guess -.-> c2["다단계 저장·알림 지연으로 오판정"]
fix["완료는 송신 측이 명시한다"] --> stable["추측에 의존하지 않아 안정적"]
그림 7: 고요함은 완료의 증거가 되지 않는다. 완료는 추측이 아니라 명시로 결정한다.
3.4. InternalBufferSize를 올리면 해결됐다고 생각한다
InternalBufferSize의 조정은 중요하지만, 이것은 설계의 본체가 아닙니다.
- 기본값은
8192바이트 4096바이트 미만으로는 할 수 없고,64 KB를 넘을 수도 없음- 버퍼는 non-paged memory를 사용하므로 늘릴수록 가벼운 것은 아님
즉, 64 KB까지 올려도 알림 버스트가 그것을 넘으면 끝입니다.
게다가 완료 알림인지의 문제는 1mm도 해결되지 않습니다.
먼저 손댈 것은 예를 들어 다음과 같은 것들입니다.
Filter/Filters로 감시 대상을 좁힌다NotifyFilter를 필요 최소한으로 한다IncludeSubdirectories를 함부로true로 하지 않는다- 이벤트 핸들러를 가볍게 한다
- full rescan과 idempotency를 넣는다
flowchart TB
accTitle: 버퍼 확대보다 먼저 할 일
accDescr: InternalBufferSize를 64KB까지 올려도 버스트가 그것을 넘으면 놓치므로, 먼저 Filter나 NotifyFilter로 감시 대상을 좁히고 핸들러를 가볍게 하고 full rescan과 idempotency를 넣는 순서를 나타낸다.
first["먼저 손댈 것"] --> f1["Filter와 NotifyFilter로 좁힌다"]
first --> f2["핸들러를 가볍게 한다"]
first --> f3["full rescan과 idempotency"]
buf["InternalBufferSize의 조정"] -.-> aux["마지막 보조에 그친다"]
그림 8: 버퍼 확대는 설계의 본체가 아니다. 좁히기와 회복 구조가 먼저다.
3.5. Error를 로그만 내고 무시한다
Error는 「가끔 나오지만 신경 쓰지 않는다」 종류의 알림이 아닙니다.
buffer overflow나 감시 계속에 실패한 상황이 여기에 나옵니다.
watcher.Error += (_, e) =>
{
_logger.LogError(e.GetException(), "watcher error");
// 여기서 끝나면 누락을 알아챘는데 회복하지 않음
};
적어도 필요한 것은 다음입니다.
- full rescan을 요청한다
- 감시 계속이 수상하면 watcher의 재생성도 검토한다
- 누락 전제로 idempotent하게 재처리할 수 있도록 한다
4. 베스트 프랙티스
4.1. 알림은 「재스캔 요청」으로 접는다
Created / Changed / Deleted / Renamed / Error를 각각 별도의 업무 처리에 직결시키지 않는 편이 정리하기 쉽습니다.
우선 전부 「보러 가라」는 1종류의 신호로 접습니다.
flowchart LR
A[Created / Changed / Deleted / Renamed] --> Q[scan request]
B[Error / overflow] --> Q
C[startup] --> Q
Q --> D[디렉터리 재스캔]
D --> E[ready한 후보를 열거]
E --> F[claim을 시도]
그림 9: 어떤 알림도 startup도 1종류의 scan request로 접어, 재스캔으로 ready한 후보를 찾아 claim을 시도한다.
구현상의 포인트:
- 이벤트 핸들러에서는
dirty = true로 하고 signal을 내보내는 정도로 한다 - 주사는 1개의 worker에 모은다
- 버스트 시에는 100~300ms 정도 모아서 1회 주사한다
- 주사 중에 추가 알림이 오면 끝난 후에 한 번 더 주사한다
세 번째의 100~300ms라는 값은 규격이나 공식 문서에 근거가 있는 숫자가 아니라, 필자의 운용 경험에서 나온 초기값입니다. 실제로는 다음 2가지를 측정한 뒤 정하는 편이 확실합니다.
| 볼 것 | 정하는 법 |
|---|---|
| 1회 주사에 걸리는 시간 | 대기 시간이 이보다 짧으면 주사가 끝나기 전에 다음 주사 요청이 쌓이기만 합니다. 주사 시간과 같은 정도 이상을 하한의 기준으로 삼습니다 |
| 허용할 수 있는 검지 지연 | 대기 시간은 그대로 검지 지연이 됩니다. 「놓인 뒤 n초 이내에 처리」라는 요건이 있다면, 그 일부에 들어가는 범위에 상한을 둡니다 |
예를 들어 1회 주사가 50ms로 끝나고 검지는 1초 이내면 충분하다면, 이 100~300ms는 딱 들어맞습니다. 반대로 파일 수가 많아서 1회 주사에 몇 초가 걸린다면, 대기 시간을 늘리기보다 먼저 주사의 구조(대상 좁히기, done만 보기, 서브디렉터리 나누기)를 재검토하는 편이 효과적입니다.
이렇게 하면 이벤트가 5회 와도 50회 와도, 최종적으로 할 일은 「현물을 보고 ready한 것을 찾는다」로 통일할 수 있습니다.
4.2. 완료 조건은 송신 측에서 명시한다
자신이 송신 측도 쥐고 있다면, FileSystemWatcher 측에서 완료 판정을 분발하기보다 공개 프로토콜을 고치는 편이 효과적입니다.
왕도는 역시 이것입니다.
temp이름에 전체 내용을 쓴다close한다- 동일 파일 시스템 상에서
rename / replace한다 - 필요하면
done/ manifest를 마지막에 둔다
flowchart TD
A[data.tmp에 전체 내용을 쓴다] --> B[flush / close]
B --> C[data.csv에 rename / replace]
C --> D[data.done / manifest.json을 둔다]
D --> E[수신 측은 final 이름이나 done만 본다]
그림 10: 송신 측은 temp에 전체 내용을 쓰고 close한 뒤 rename으로 공개하고, 필요하면 done / manifest를 마지막에 둔다.
지난번 기사와 같지만, 여기가 정말로 효과적입니다.
FileSystemWatcher는 완료를 발명하는 도구가 아니라 명시된 완료를 빨리 찾는 도구라고 생각하면 정리하기 쉽습니다.
4.3. 수신 측은 claim을 원자적으로 취한다
재스캔으로 ready한 후보를 찾아도 그대로 읽으러 가면 복수 워커가 동시에 잡을 수 있습니다. 그래서 처리 전에 claim을 원자적으로 취합니다.
sequenceDiagram
participant Scan as scanner
participant IN as incoming
participant P1 as processing/worker1
participant P2 as processing/worker2
Scan->>IN: order-123을 발견
Scan->>P1: rename order-123
Scan->>P2: rename order-123
Note over P1,P2: 먼저 성공한 쪽만 소유권을 가짐
그림 11: 같은 후보를 여러 워커가 찾아도, rename에 성공한 하나만 소유권을 가진다.
지난번 기사에서도 언급했듯이, incoming -> processing/<worker>/의 rename이 알기 쉽습니다.
특히 본체 + manifest + 보조 파일을 1개의 디렉터리에 묶어두면 bundle 단위로 claim할 수 있어 편합니다.
incoming/
order-123/
payload.csv
manifest.json
이것이라면 bundle directory를 1회 rename하는 것만으로 소유권을 취할 수 있습니다.
4.4. startup / overflow / 재접속 시에는 full rescan한다
이것은 상당히 중요합니다.
- 앱 기동 전부터 놓여 있던 파일은 이벤트로는 주울 수 없습니다
- overflow가 일어나면 개별 이벤트 열은 신용하기 어려워집니다
- 네트워크 공유나 일시 단절이 얽히면 「그 사이의 무언가」가 빠진 전제로 보는 편이 안전합니다
그래서 적어도 다음 타이밍에는 full rescan을 넣는 편이 좋습니다.
- 기동 시
Error수신 시- watcher를 다시 만든 직후
- 정기적인 보험으로서 일정 간격마다
여기서의 사상은 「watcher는 차분의 힌트, 재스캔은 정합성의 회복」입니다.
flowchart TB
accTitle: full rescan을 넣는 타이밍
accDescr: 기동 시, Error 수신 시, watcher를 다시 만든 직후, 정기적인 보험으로서의 일정 간격마다라는 4가지 타이밍에 full rescan을 넣어 이벤트로 주울 수 없는 변경을 회복함을 나타낸다.
t1["기동 시"] --> fr["full rescan"]
t2["Error 수신 시"] --> fr
t3["watcher 재생성 직후"] --> fr
t4["정기적인 보험으로서"] --> fr
fr --> heal["정합성 회복"]
그림 12: watcher는 차분의 힌트, full rescan은 정합성의 회복. 이 4가지 타이밍에 반드시 넣는다.
4.5. idempotency를 전제로 한다
FileSystemWatcher를 사용하면 같은 대상을 복수 회 보러 가게 됩니다.
이것은 버그가 아니라 설계로서 받아들이는 편이 안정됩니다.
예를 들어 다음과 같이 합니다.
- manifest에
IdempotencyKey를 넣는다 - 이미 처리 완료라면 부작용을 재실행하지 않는다
- archive 완료 / DB 기록 완료 / 송신 완료를 대조할 수 있도록 한다
- full rescan해도 「같은 것을 한 번 더 안전하게 본다」만으로 한다
exactly-once를 이벤트만으로 만들려고 하면 상당히 힘들어집니다. at-least-once를 받아들이고 마지막을 idempotency로 조이는 편이 실무에서는 강합니다.
flowchart TB
accTitle: 중복 전제의 받아들이는 방법
accDescr: 같은 대상을 여러 번 보러 가는 것을 버그가 아니라 설계로서 받아들이고, manifest의 IdempotencyKey로 처리 완료를 대조해 부작용을 재실행하지 않음으로써 재주사해도 안전해짐을 나타낸다.
multi["같은 대상을 여러 번 보러 감"] --> accept["설계로서 받아들임"]
accept --> key["IdempotencyKey로 처리 완료를 대조"]
key --> safe["부작용을 재실행하지 않음"]
safe --> strong["full rescan해도 안전하게 보기만 함"]
그림 13: exactly-once를 이벤트로 만들지 않고, at-least-once를 받아들여 idempotency로 조인다.
5. 의사 코드(발췌)
5.1. 전형적인 실패 패턴
using var watcher = new FileSystemWatcher(incomingDir)
{
Filter = "*.csv",
IncludeSubdirectories = false,
EnableRaisingEvents = true,
InternalBufferSize = 64 * 1024
};
watcher.Created += (_, e) =>
{
// Created = 완료 알림, 이라고 잘못 생각하고 있다
ProcessFile(e.FullPath);
};
watcher.Changed += (_, e) =>
{
// 몇 번이나 오므로 일단 한 번 더 처리
ProcessFile(e.FullPath);
};
watcher.Error += (_, e) =>
{
Console.WriteLine(e.GetException());
// 회복하지 않음
};
문제점은 4개 있습니다.
Created/Changed를 그대로 업무 처리에 결부시키고 있다- 완료 판정이 없다
- overflow 시에 full rescan하지 않는다
- 같은 파일을 몇 번 처리해도 멈추는 구조가 없다
5.2. 올바른 방향의 예(대략 쓰면 이렇게)
private readonly SemaphoreSlim _scanSignal = new(0, int.MaxValue);
private int _scanRequested = 0;
private int _fullRescanRequested = 0;
void OnAnyChange(object? sender, FileSystemEventArgs e)
{
RequestScan(full: false);
}
void OnRenamed(object? sender, RenamedEventArgs e)
{
RequestScan(full: false);
}
void OnError(object? sender, ErrorEventArgs e)
{
Log(e.GetException());
RequestScan(full: true);
}
void RequestScan(bool full)
{
if (full)
{
Interlocked.Exchange(ref _fullRescanRequested, 1);
}
if (Interlocked.Exchange(ref _scanRequested, 1) == 0)
{
_scanSignal.Release();
}
}
async Task ScannerLoopAsync(CancellationToken cancellationToken)
{
RequestScan(full: true); // startup scan
while (!cancellationToken.IsCancellationRequested)
{
await _scanSignal.WaitAsync(cancellationToken);
// 알림 버스트를 약간 모은다
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
Interlocked.Exchange(ref _scanRequested, 0);
bool full = Interlocked.Exchange(ref _fullRescanRequested, 0) == 1;
foreach (var bundle in EnumerateReadyBundles(incomingDir, full))
{
var claimedPath = Path.Combine(processingDir, bundle.Name);
if (!TryClaimByRename(bundle.Path, claimedPath))
{
continue; // 다른 워커가 먼저 취득
}
var manifest = ReadManifest(Path.Combine(claimedPath, "manifest.json"));
if (AlreadyProcessed(manifest.IdempotencyKey))
{
MoveToArchive(claimedPath, archiveDir);
continue;
}
ProcessBundle(claimedPath);
RecordProcessed(manifest.IdempotencyKey);
MoveToArchive(claimedPath, archiveDir);
}
if (Volatile.Read(ref _scanRequested) == 1)
{
_scanSignal.Release(); // 주사 중에 온 알림을 놓치지 않음
}
}
}
이 예에서 중요한 것은 세세한 API가 아니라 흐름입니다.
- 알림은 scan request로 접는다
- 주사로 ready를 찾는다
- claim을 취한다
- idempotency를 확인한다
- 처리하고 기록하고 archive로 옮긴다
flowchart TB
accTitle: 올바른 방향의 처리 흐름
accDescr: 알림을 scan request로 접고, 주사로 ready한 후보를 찾고, claim을 취하고, idempotency를 확인한 뒤 처리와 기록을 하여 archive로 옮긴다는, 의사 코드가 나타내는 일련의 흐름을 나타낸다.
n["알림을 scan request로 접는다"] --> s["주사로 ready를 찾는다"]
s --> c["claim을 취한다"]
c --> i["idempotency를 확인한다"]
i --> p["처리해서 기록하고 archive로 옮긴다"]
그림 14: 세세한 API보다 이 흐름이 본체다. 이벤트는 어디까지나 trigger.
FileSystemWatcher의 이벤트는 여기서는 trigger에 지나지 않습니다.
또한 EnumerateReadyBundles / TryClaimByRename / ReadManifest / AlreadyProcessed 등은 흐름을 보여주기 위해 이 글에서 이름을 붙인 함수이며, .NET의 표준 API가 아닙니다. 실제로 빌드해서 동작하는 형태(라이브러리, 임시 디렉터리에서 동작하는 콘솔 데모, 이벤트를 검증하는 유닛 테스트)는 서두에서도 언급한 샘플 일체에 있습니다.
filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)
6. 대강의 사용 구분
-
단일 수신 워커 / 자신이 송신 측도 고칠 수 있다 우선은
temp -> close -> rename과 startup scan입니다. 이것만으로도 상당히 안정됩니다. -
복수 수신 워커가 있다 위에 더해
incoming -> processing의 claim rename을 넣는 편이 좋습니다. -
고빈도로 알림이 많다
Filter/NotifyFilter/IncludeSubdirectories를 좁히고 이벤트 핸들러를 극소화합니다.InternalBufferSize의 조정은 그다음입니다. -
overflow해서 곤란하다 / 누락이 허용되지 않는다 full rescan을 전제로 하고, 그래도 어렵다면
FileSystemWatcher단독에 걸지 않는 편이 좋습니다. Windows 한정이라면 USN change journal도 선택지가 됩니다. -
상대 시스템의 작성 방식을 제어할 수 없다 완료 조건을 추측으로 보완하기보다 공개 프로토콜을 협상할 수 없는지를 먼저 생각하는 편이 안전합니다. 무리라면 보증 수준을 낮춘 뒤 idempotent하게 받는 설계로 옮겨 갑니다.
마지막 2가지 항목은 꽤 중요한 철수 판단입니다.
FileSystemWatcher는 편리하지만 만능의 진실 검출기는 아닙니다.
USN change journal은 무엇이 다른가
USN change journal은 NTFS가 볼륨 단위로 가지고 있는 변경 기록입니다. FileSystemWatcher 같은 디렉터리 알림은 변경이 일어난 순간에 앱이 동작하고 있지 않으면 받을 수 없지만, change journal은 볼륨 측에 기록이 남으므로 앱이 멈춰 있던 동안의 변경도, 이전에 읽은 위치(USN)부터 나중에 다시 읽을 수 있습니다. Microsoft 문서에서도 디렉터리 알림의 약점으로 「앱을 상시 동작시켜 둘 필요가 있다」는 점이 꼽히고, 그 회피책으로 change journal이 설명되어 있습니다.
한편 부담도 늘어납니다.
FileSystemWatcher |
USN change journal | |
|---|---|---|
| 감시 단위 | 지정한 디렉터리(+ 서브디렉터리) | 볼륨 전체. 필요한 범위는 직접 좁혀야 함 |
| 앱이 멈춰 있던 동안 | 알 수 없음. full rescan으로 메움 | 기록에서 다시 읽을 수 있음 |
| 누락 | 내부 버퍼의 overflow로 발생 | 저널의 상한을 넘으면 오래된 기록부터 사라짐 |
| 필요한 것 | .NET API만 | 볼륨 핸들과 FSCTL_* 호출. 저널 생성·삭제 등 관리 작업에는 관리자 권한이 필요 |
즉, 「상시 기동할 수 없다」「정지 중의 변경도 줍고 싶다」가 요건에 들어왔을 때의 선택지입니다. 반대로 그것이 필요 없다면 FileSystemWatcher + full rescan 쪽이 구현은 더 간단합니다.
flowchart TB
accTitle: FileSystemWatcher와 USN change journal의 차이
accDescr: FileSystemWatcher는 앱 정지 중의 변경을 알 수 없어 full rescan으로 메우는 데 비해, USN change journal은 볼륨 측에 기록이 남아 이전에 읽은 위치부터 정지 중의 변경도 다시 읽을 수 있다는 차이를 나타낸다.
fsw["FileSystemWatcher"] -.-> gap["정지 중의 변경은 알 수 없음"]
gap --> fill["full rescan으로 메움"]
usn["USN change journal"] --> keep["볼륨 측에 기록이 남음"]
keep --> resume["이전 USN부터 다시 읽을 수 있음"]
그림 15: 상시 기동할 수 없다·정지 중의 변경도 줍고 싶다는 요건이 나오면, change journal이 선택지가 된다.
7. 정리
FileSystemWatcher는 완료 알림의 대체가 되지 않습니다. 진실은 이벤트 열이 아니라 지금 디스크 위에 보이고 있는 상태에 있습니다. 완료는 temp -> close -> rename / replace나 done / manifest로 명시하고, 소유권은 claim을 원자적으로 취해 결정합니다. 설계의 본체는 여기에 있습니다.
Created로 즉시 처리한다, Changed의 횟수나 순서를 믿는다, Changed가 멈추면 완료 취급한다, InternalBufferSize만으로 안심한다, Error를 봤는데 회복하지 않는다 ── 어느 쪽도 피하고 싶은 설계입니다. 대신 알림은 재스캔 요청으로 접고, startup / overflow / 재접속에서는 full rescan을 넣고, claim rename으로 소유권을 취하고, 중복과 재주사는 idempotency로 받아들입니다.
즉, FileSystemWatcher에서는 「이벤트를 받은 것」과 「처리해도 좋은 것」을 같게 하지 않는 것이 요령입니다.
여기를 나누기만 해도 가끔만 망가지는 타입의 감시 처리가 상당히 줄어듭니다.
8. 참고 자료
- 이 글의 샘플 코드 일체(라이브러리, 데모, 유닛 테스트) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/filesystemwatcher-safe-basics
- 관련 기사: 파일 연계의 배타 제어 기초 지식 - 파일 락과 원자적 claim의 베스트 프랙티스
- FileSystemWatcher Class (System.IO)
- System.IO.FileSystemWatcher class - .NET
- FileSystemWatcher.InternalBufferSize Property (System.IO)
- FileSystemWatcher.NotifyFilter Property (System.IO)
- FileSystemWatcher.Error Event (System.IO)
- FileSystemWatcher.Created Event (System.IO)
- FileSystemWatcher.Changed Event (System.IO)
- FileSystemWatcher.Renamed Event (System.IO)
- Change Journals - Win32 apps
- Creating, Modifying, and Deleting a Change Journal - Win32 apps
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
업무 시스템의 코드 설계 ── 상품 코드・고객 코드 정하는 방법과 체크 디지트
상품 코드・고객 코드 등 업무 시스템의 코드 체계를 정하는 실전 가이드. 유의미 코드와 무의미 일련번호의 판단표, JAN・Luhn 등 체크 디지트 산식과 C# 구현, Excel의 앞자리 0 소실 대책, 자릿수 초과와 이관까지 정리합니다.
Generic Host / BackgroundService를 데스크톱 앱에 가지고 들어오는 이유 - 기동·수명·graceful shutdown의 정리가 꽤 편해진다
Windows 도구나 상주 앱에서 기동, 정기 처리, 종료 처리, 로그, 설정, DI를 정리하기 위해 Generic Host와 BackgroundService를 어떻게 활용할지 정리합니다.
멀티스레드 실무 베스트 프랙티스 .NET 편 ── 스레드를 늘리기 전에 정해둘 것
「스레드를 만들면 가끔 죽거나 멈춘다」를 막는 설계의 정석을 .NET/C# 대상으로 정리합니다. 스레드를 직접 만들지 않고 Task에 올라타기, 공유 가변 상태 줄이기, 락의 규율, CancellationToken을 통한 정지 설계, UI 스레드...
WMI/CIM을 C#・PowerShell에서 사용하기 ── 하드웨어 정보 취득・프로세스 감시・원격 조회 실무 가이드
PC 시리얼 번호 취득, 디스크 여유 공간 감시, 프로세스 시작 감지의 정석이 WMI/CIM입니다. Get-CimInstance 등 CIM 계열 명령어의 사용법과 구형 Get-WmiObject로부터의 이전, C#의 System.Management...
업무 앱의 DB 스키마를 버전 관리하는 법 ── 「거래처마다 DB가 다르다」를 막는 마이그레이션 실천
거래처마다 흩어져 있는 업무 앱의 DB 스키마를 버전 관리하는 실천 가이드. PRAGMA user_version과 전진 마이그레이션의 C# 구현, EF Core Migrations・DbUp・직접 구현의 판단표, 2단계 릴리스까지 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- FileSystemWatcher의 Created 이벤트에서 파일을 읽어도 되나요?
- 안 됩니다. Created는 「이름이 보였다」는 것을 나타낼 뿐, 「이제 읽어도 좋다」는 것을 보증하지 않습니다. 복사나 전송에서는 파일이 만들어진 순간에 Created가 날아가고, 그 후에 1회 이상의 Changed가 이어지는 경우가 있습니다. 완료는 송신 측이 temp -> close -> rename/replace나 done/manifest로 명시하고, 수신 측은 final 이름이나 done만 보는 것이 기본입니다.
- FileSystemWatcher에서 알림을 놓치는 경우가 있나요?
- 있습니다. 내부 버퍼(기본값 8192바이트, 4096바이트 미만으로는 할 수 없고 64KB가 상한)가 넘치면 개별 알림을 놓치고 Error 이벤트가 발생합니다. overflow가 일어나면 개별 이벤트 열의 완전성 자체가 수상해지므로, 디렉터리를 full rescan해서 전체를 다시 보는 것이 안전합니다. 기동 시, Error 수신 시, watcher 재생성 직후, 정기적인 보험으로서도 full rescan을 넣어야 합니다.
- Changed 이벤트가 여러 번 오는 이유는 무엇인가요?
- 이동이나 저장 같은 일반적인 조작에서도 여러 이벤트로 나뉘어 보이는 경우가 있고, 게다가 안티바이러스 소프트나 인덱서가 건드린 것까지 주워오기 때문입니다. 횟수나 순서를 믿는 설계는 위험합니다. 알림은 「재스캔 요청」이라는 1종류의 신호로 접고, 주사는 1개의 worker에 모으고, 버스트 시에는 100~300ms 정도 모은 뒤 1회 주사하는 것이 안정적입니다.
- InternalBufferSize를 늘리면 알림 누락이 해결되나요?
- 해결되지 않습니다. 64KB까지 올려도 알림 버스트가 그것을 넘으면 놓치고, 완료 알림인지의 문제는 전혀 해결되지 않습니다. 버퍼는 non-paged memory를 사용하므로 늘릴수록 가볍다고도 할 수 없습니다. 먼저 Filter/NotifyFilter로 감시 대상을 좁히고, IncludeSubdirectories를 재검토하고, 이벤트 핸들러를 가볍게 하고, full rescan과 idempotency를 넣는 것이 순서입니다.