FileSystemWatcher 실무 가이드 - 누락과 중복 대책
· 업데이트: · Go Komura · FileSystemWatcher, C#, .NET, Windows 개발, 파일 연계, 설계
수정 이력(6건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635101)
- 설계의 핵심과 재스캔 흐름을 그림으로도 따라갈 수 있게 Mermaid 그림 9점을 추가했습니다(본문 500~750자당 1그림 규약에 맞춘 것입니다). 기존 그림에는 일련번호 캡션을 달았습니다. 본문 문장은 바꾸지 않았습니다.
- 글 서두에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건) 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참고합니다.
- 서두에 대상 독자와 전제, 용어표를 추가하고, 먼저 동작하는 최소 코드 절을 신설했습니다. 디바운스 시간 「100~300ms」를 운용 경험에서 온 초기값이라고 명기하고, 스캔 시간과 허용 감지 지연으로 정하는 조정표를 추가했습니다. USN change journal과의 대비표도 추가했습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635100)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「FileSystemWatcher 실무 가이드 - 누락과 중복 대책」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635100 https://comcomponent.com/ko/blog/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 | 「이 파일은 내가 처리한다」는 소유권을, 다른 worker가 끼어들지 못하는 형태로 취하는 일입니다. 구현으로는 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. 내부 버퍼 overflow로 변경을 잃는다
- 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로 명시하는 것이 기본입니다 - 여러 worker가 있다면, 읽기 전에 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로 하기 전까지 이벤트는 하나도 오지 않습니다. 핸들러를 등록만 해서는 동작하지 않습니다watcher의 수명이 앱의 수명입니다. 지역 변수의 스코프가 끝나 폐기되면, 그 시점에서 알림이 멈춥니다. 상주시키려면 필드처럼 계속 살아 있는 곳에 둡니다NotifyFilter의 기본값은LastWrite | FileName | DirectoryName의 조합입니다(8. 참고 자료의 FileSystemWatcher.NotifyFilter Property). 무엇을 잡을지는 명시해 두는 편이, 나중에 다시 읽을 때 헤매지 않습니다
그리고 중요한 것은, 이 코드는 「이벤트가 도착하는 것」만 확인하고 있다는 점입니다. Created 시점에 파일을 읽어도 되는지, 알림을 놓치지 않았는지도 이 형태로는 알 수 없습니다. 여기부터가 본론입니다.
2. FileSystemWatcher의 사용법에서 일어나기 쉬운 오해 패턴(그림)
2.1. Created를 완료 알림이라고 생각한다
이것이 가장 알기 쉬운 지뢰입니다.
복사나 전송에서는 파일이 만들어진 순간에 Created가 발생하고, 그 뒤에 Changed가 1회 이상 이어지는 경우가 있습니다.
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회만 온다고 할 수 없습니다.
이동이나 저장 같은 평범한 조작도 여러 이벤트로 나뉘어 보일 수 있습니다. 게다가 바이러스 백신이나 indexer가 건드린 것까지 잡히는 경우도 있습니다.
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: 1회만·이 순서라고 할 수 없다
그림 3: 평범한 저장에서도 이벤트는 여러 개로 나뉘고, 외부 프로세스가 건드린 것까지 섞인다. 횟수도 순서도 믿을 수 없다.
「Changed가 1회 오면 완료」「Renamed 다음은 더 이상 건드리지 않는다」는 기대는 상당히 위태롭습니다.
보충:
- 파일 rename으로
Changed가 발생하는 경우가 있습니다 RenamedEventArgs.Name은 OS 측에서 old/new 대응을 잡지 못하면null이 될 수 있습니다- hidden file도 무시되지 않습니다. 숨은 temp 이름이니 안 보이겠지, 는 통하지 않습니다
- 감시 중인 디렉터리 자체를 rename해도, 그 변경은 알림이 오지 않습니다
2.3. 내부 버퍼 overflow로 변경을 잃는다
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()); // 출력만
};
문제는 두 가지입니다.
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까지 올려도, 알림 버스트가 이를 넘으면 끝입니다.
게다가 완료 알림인지 여부의 문제는 전혀 해결되지 않습니다.
버퍼를 늘리기 전에, 먼저 손댈 일이 있습니다.
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를 각각 다른 업무 처리에 직결시키면 전망이 나빠집니다. 우선 전부 「보러 가라」는 한 종류의 신호로 접습니다.
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이든 한 종류의 scan request로 접고, 재스캔으로 ready한 후보를 찾아 claim을 시도한다.
구현상의 포인트:
- 이벤트 핸들러에서는
dirty = true로 두고 signal을 내는 정도로 한다 - 스캔은 1개의 worker에 모은다
- 버스트 시에는 100~300ms 정도 모은 뒤 1회 스캔한다
- 스캔 중에 추가 알림이 오면, 끝난 뒤에 한 번 더 스캔한다
세 번째의 100~300ms라는 값은 규격이나 공식 문서에 근거가 있는 숫자가 아니라, 필자의 운용 경험에서 온 초기값입니다. 실제로는 다음 두 가지를 잰 뒤에 정하는 편이 확실합니다.
| 볼 것 | 정하는 법 |
|---|---|
| 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한 후보를 찾아도, 그대로 읽으러 가면 여러 worker가 동시에 집을 수 있습니다. 그래서 처리 전에 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: 같은 후보를 여러 worker가 찾아도, 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를 다시 만든 직후, 정기적인 보험으로서의 일정 간격마다라는 네 타이밍에서 full rescan을 넣어, 이벤트로 잡지 못한 변경을 회복함을 나타냅니다.
t1["기동 시"] --> fr["full rescan"]
t2["Error 수신 시"] --> fr
t3["watcher 재생성 직후"] --> fr
t4["정기적인 보험으로"] --> fr
fr --> heal["정합성의 회복"]
그림 12: watcher는 차이의 힌트, full rescan은 정합성의 회복. 이 네 타이밍에서 반드시 넣는다.
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());
// 회복하지 않는다
};
문제점은 네 가지입니다.
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; // 다른 worker가 먼저 획득
}
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. 대략적인 구분
-
단일 수신 worker / 스스로 송신 측도 고칠 수 있다 우선
temp -> close -> rename과 startup scan. 이것만으로도 꽤 안정됩니다. -
여러 수신 worker가 있다 위에 더해
incoming -> processing의 claim rename을 넣는 편이 좋습니다. -
고빈도로 알림이 많다
Filter/NotifyFilter/IncludeSubdirectories를 좁히고, 이벤트 핸들러를 최소화합니다.InternalBufferSize의 조정은 그 다음입니다. -
overflow해서 곤란하다 / 누락이 허용되지 않는다 full rescan을 전제로 하고, 그래도 어렵다면
FileSystemWatcher만으로 버티지 않는 편이 좋습니다. Windows 한정이라면 USN change journal도 선택지가 됩니다. -
상대 시스템의 쓰는 방식을 제어할 수 없다 완료 조건을 추측으로 보완하기보다, 공개 프로토콜을 교섭할 수 없는지를 먼저 생각하는 편이 안전합니다. 무리라면 보증 수준을 내린 뒤 idempotent하게 받는 설계로 기울입니다.
마지막 두 항목은 꽤 중요한 선 긋기 판단입니다.
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
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
업무 시스템의 코드 설계 ── 상품 코드·고객 코드를 정하는 법과 check digit
상품 코드·고객 코드 등 업무 시스템의 코드 체계를 정하는 실무 가이드. 유의미 코드와 무의미 일련번호 판단표, JAN·Luhn 등의 check digit 산식과 C# 구현, Excel의 선행 0 소실 대책, 자릿수 초과와 이행까지 정리합니다.
.NET Generic Host와 BackgroundService를 데스크톱 앱에서 쓰는 이유
Windows 도구나 상주 앱에서 시작, 주기 처리, 종료 처리, 로그, 설정, DI를 정리하기 위해 Generic Host와 BackgroundService를 어떻게 쓰는지 정리합니다.
멀티스레드 실무 베스트 프랙티스 .NET 편 ── 스레드를 늘리기 전에 정해 둘 것
「스레드를 만들었더니 가끔 죽거나 멈춘다」를 막는 설계의 정석을 .NET/C# 대상으로 정리합니다. 스레드를 직접 만들지 않고 Task에 맡기기, 공유 가변 상태 줄이기, 락의 규율, CancellationToken으로 정지 설계하기, UI 스레...
WMI/CIM을 C#·PowerShell에서 쓰기 ── 하드웨어 정보 가져오기·프로세스 모니터링·원격 조회의 실무 가이드
PC 시리얼 번호 조회, 디스크 여유 공간 모니터링, 프로세스 시작 감지의 흔한 답이 WMI/CIM입니다. Get-CimInstance 등 CIM cmdlet 사용법과 구 Get-WmiObject에서의 이전, C#의 System.Managemen...
업무 앱의 DB 스키마를 버전 관리한다 ── 「고객사마다 DB가 다르다」를 막는 마이그레이션 실무
고객사마다 흩어진 업무 앱의 DB 스키마를 버전 관리하는 실무 가이드. PRAGMA user_version과 전진 마이그레이션의 C# 구현, EF Core Migrations·DbUp·자체 구현 판단표, 2단계 릴리스까지 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
FileSystemWatcher를 쓰는 파일 연계나 감시 도구는 Windows 앱 개발 안에서도 실무에서 자주 나오는 주제입니다.
기술 상담 & 설계 리뷰
누락 대책, 재스캔, 완료 판정을 설계로 정리하고 싶을 때는 기술 상담·설계 리뷰와 궁합이 좋습니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- FileSystemWatcher의 Created 이벤트로 파일을 읽어도 되나요?
- 안 됩니다. Created는 「이름이 보였다」만을 나타내며, 「이제 읽어도 된다」를 보증하지 않습니다. 복사나 전송에서는 파일이 만들어진 순간에 Created가 발생하고, 그 뒤에 Changed가 1회 이상 이어지는 경우가 있습니다. 완료는 송신 측이 temp -> close -> rename/replace나 done/manifest로 명시하고, 수신 측은 final 이름이나 done만 보는 것이 기본입니다.
- FileSystemWatcher에서 알림이 누락될 수 있나요?
- 있습니다. 내부 버퍼(기본값 8192바이트, 4096바이트 미만으로 내릴 수 없고 상한은 64KB)가 넘치면 개별 알림을 놓치며, Error 이벤트가 발생합니다. overflow가 일어나면 개별 이벤트 시퀀스의 완전성 자체가 의심스러워지므로, 디렉터리를 full rescan하여 전체를 다시 보는 것이 안전합니다. 기동 시, Error 수신 시, watcher 재생성 직후, 정기적인 보험으로도 full rescan을 넣어야 합니다.
- Changed 이벤트가 여러 번 오는 이유는 무엇인가요?
- 이동이나 저장 같은 평범한 조작도 여러 이벤트로 나뉘어 보일 수 있고, 여기에 바이러스 백신이나 indexer가 건드린 것까지 잡히기 때문입니다. 횟수나 순서를 믿는 설계는 위험합니다. 알림은 「재스캔 요청」이라는 한 종류의 신호로 접고, 스캔은 1개의 worker에 모으며, 버스트 시에는 100~300ms 정도 모은 뒤 1회 스캔하는 편이 안정적입니다.
- InternalBufferSize를 늘리면 누락이 해결되나요?
- 해결되지 않습니다. 64KB까지 올려도 알림 버스트가 이를 넘으면 놓치며, 완료 알림인지 여부의 문제는 전혀 해결되지 않습니다. 버퍼는 non-paged memory를 쓰므로 늘릴수록 가볍게 볼 수도 없습니다. 먼저 Filter/NotifyFilter로 감시 대상을 좁히고, IncludeSubdirectories를 재검토하고, 이벤트 핸들러를 가볍게 한 뒤, full rescan과 idempotency를 넣는 것이 순서입니다.