FileSystemWatcher 사용법과 주의점 - 누락, 중복 알림, 완료 판정의 함정
FileSystemWatcher는 Windows 상의 .NET에서 파일 변경을 감시할 때 먼저 후보가 되는 API입니다.
다만, Created나 Changed를 그대로 완료 알림이라고 생각해서 사용하면 누락, 중복 알림, 도중 파일의 오독으로 상당히 흔하게 사고가 납니다.
이 글에서는 FileSystemWatcher의 사용법과 주의점을, 주로 Windows 상의 .NET에 의한 파일 연계를 전제로 정리합니다.
아울러 전제가 되는 배타 제어의 사고방식은 파일 연계의 배타 제어 기초 지식 - 파일 락과 원자적 claim의 베스트 프랙티스도 참조할 수 있도록 했습니다.
FileSystemWatcher는 편리합니다. 파일이나 디렉터리의 작성, 변경, 삭제, 이름 변경을 이벤트로 받을 수 있습니다.
다만, 여기서 이벤트를 「완료 알림」이라고 생각하면 상당히 흔하게 사고가 납니다.
예를 들어, 파일의 복사 중에 Created가 먼저 날아오는 경우가 있습니다. Changed는 1회라는 보장이 없습니다. 단시간에 변경이 집중되면 내부 버퍼가 넘쳐서 개별 변경을 누락할 수도 있습니다.
그래서 설계의 핵심은 이렇습니다.
- 알림은 계기
- 진실은 디렉터리 재스캔
- 소유권은 원자적인 claim
- 마지막은 idempotency로 받아들인다
이 글에서는 이 사고방식으로 FileSystemWatcher를 파일 연계에 편성할 때의 함정을 정리합니다.
1. 먼저 결론(한마디로)
FileSystemWatcher의 이벤트는 완료 알림이 아니라 변화의 기미입니다Created/Changed/Renamed는 중복되거나 상상과 다른 순서로 오거나 overflow 시에는 누락됩니다- 이벤트 핸들러에서는 무거운 처리를 하지 않고 재스캔 요청을 쌓기만 하는 편이 안정됩니다
- 완료 판정은
temp -> close -> rename / replace나done/ manifest로 명시하는 것이 기본입니다 - 복수 워커가 있다면 읽기 전에 claim을 원자적으로 취득할 필요가 있습니다
InternalBufferSize의 조정은 보조입니다. 마지막은 full rescan과 idempotency가 효과적입니다
요컨대 FileSystemWatcher를 「진실의 이력 스트림」으로서 다루지 않는 것입니다.
알림은 어디까지나 「슬슬 보러 가라」의 신호에 머무는 편이 망가지기 어려워집니다.
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 파손
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: 1회만・이 순서라는 보장 없음
「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]
여기서 중요한 것은 「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 갱신까지 시작하면 버스트 시에 자기 목을 조르게 됩니다.
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;
}
이 방향으로 분발하기보다 그때마다 디스크 상의 현물을 재확인하는 편이 강합니다. 파일 연계에서 중요한 것은 이벤트 이력의 재현이 아니라 지금 이 순간에 처리해도 좋은 대상을 올바르게 찾는 것입니다.
3.3. Changed가 멈추면 완료 취급
지난번의 「파일 크기가 멈추면 완료」와 같은 냄새가 나는 설계입니다. 편리해 보이지만 추측으로 완료를 결정하고 있습니다.
if (lastChangedAt + TimeSpan.FromSeconds(10) < DateTime.UtcNow)
{
return Ready;
}
이것으로 곤란한 것은 예를 들어 다음과 같은 경우입니다.
- 큰 파일의 복사가 도중에 일시 정지한다
- 송신 측 앱이 복수 단계로 저장한다
- 네트워크 공유에서 알림이 늦게 보인다
- 외부 프로세스가 나중에 속성이나 시각을 덮어쓴다
완료는 추측이 아니라 명시하는 편이 안정됩니다.
3.4. InternalBufferSize를 올리면 해결됐다고 생각한다
InternalBufferSize의 조정은 중요하지만, 이것은 설계의 본체가 아닙니다.
- 기본값은
8192바이트 4096바이트 미만으로는 할 수 없고,64 KB를 넘을 수도 없음- 버퍼는 non-paged memory를 사용하므로 늘릴수록 가벼운 것은 아님
즉, 64 KB까지 올려도 알림 버스트가 그것을 넘으면 끝입니다.
게다가 완료 알림인지의 문제는 1mm도 해결되지 않습니다.
먼저 할 것은 예를 들어 다음과 같은 것들입니다.
Filter/Filters로 감시 대상을 좁힌다NotifyFilter를 필요 최소한으로 한다IncludeSubdirectories를 함부로true로 하지 않는다- 이벤트 핸들러를 가볍게 한다
- full rescan과 idempotency를 넣는다
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을 시도]
구현상의 포인트:
- 이벤트 핸들러에서는
dirty = true로 하고 signal을 내보내는 정도로 한다 - 주사는 1개의 worker에 모은다
- 버스트 시에는 100~300ms 정도 모아서 1회 주사한다
- 주사 중에 추가 알림이 오면 끝난 후에 한 번 더 주사한다
이렇게 하면 이벤트가 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만 본다]
지난번 기사와 같지만, 여기가 정말로 효과적입니다.
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: 먼저 성공한 쪽만 소유권을 가짐
지난번 기사에서도 언급했듯이, 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는 차분의 힌트, 재스캔은 정합성의 회복」입니다.
4.5. idempotency를 전제로 한다
FileSystemWatcher를 사용하면 같은 대상을 복수 회 보러 가게 됩니다.
이것은 버그가 아니라 설계로서 받아들이는 편이 안정됩니다.
예를 들어 다음과 같이 합니다.
- manifest에
IdempotencyKey를 넣는다 - 이미 처리 완료라면 부작용을 재실행하지 않는다
- archive 완료 / DB 기록 완료 / 송신 완료를 대조할 수 있도록 한다
- full rescan해도 「같은 것을 한 번 더 안전하게 본다」만으로 한다
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로 옮긴다
FileSystemWatcher의 이벤트는 여기서는 trigger에 지나지 않습니다.
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는 편리하지만 만능의 진실 검출기가 아닙니다.
7. 정리
짚어두고 싶은 점은 다음입니다.
배타 제어와 완료 판정의 본체:
FileSystemWatcher는 완료 알림의 대체가 아니다- 진실은 이벤트 열이 아니라 지금 디스크 상에 보이고 있는 상태
- 완료는
temp -> close -> rename / replace나done/ manifest로 명시한다 - 소유권은 claim을 원자적으로 취해 결정한다
피하고 싶은 설계:
Created로 즉시 처리Changed의 횟수나 순서를 믿는다Changed가 멈추면 완료 취급InternalBufferSize만으로 안심한다Error를 봤는데 회복하지 않는다
실무에서 효과 있는 대책:
- 알림은 재스캔 요청으로 접는다
- startup / overflow / 재접속에서 full rescan
- claim rename으로 소유권을 취한다
- idempotency로 중복과 재주사를 받아들인다
즉, FileSystemWatcher에서는 「이벤트를 받은 것」과 「처리해도 좋은 것」을 같게 하지 않는 것이 요령입니다.
여기를 나누기만 해도 가끔만 망가지는 타입의 감시 처리가 상당히 줄어듭니다.
8. 참고 자료
- 관련 기사: 파일 연계의 배타 제어 기초 지식 - 파일 락과 원자적 claim의 베스트 프랙티스
- FileSystemWatcher Class (System.IO)
- System.IO.FileSystemWatcher class - .NET
- FileSystemWatcher.InternalBufferSize 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
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Generic Host / BackgroundService를 데스크톱 앱에 가지고 들어오는 이유 - 기동・수명・graceful shutdown의 정리가 꽤 편해진다
WPF나 WinForms 같은 데스크톱 앱에서 Generic Host와 BackgroundService를 도입해 기동, 상주 처리, graceful shutdown, DI, 로그, 설정의 입구를 한 곳으로 모으는 설계 정리법과 안티패턴을 실무 시...
시리얼 통신 앱의 함정 - 1 byte 단위, 타임아웃, 플로우 컨트롤, 재접속, USB 변환, UI 프리즈를 먼저 정리
시리얼 통신 앱이 가끔 멈추거나 응답이 어긋나는 진짜 원인은 byte stream의 메시지 경계, 타임아웃, 재접속, single writer 설계에 있습니다. 실무에서 무너지기 쉬운 함정과 먼저 정리할 체크리스트를 한 번에 정리했습니다.
.NET의 Generic Host란 무엇인가 - DI, 설정, 로그, BackgroundService를 먼저 정리
Generic Host의 정체를, DI・설정・로그・BackgroundService와 Host.CreateApplicationBuilder / WebApplicationBuilder의 관계로 정리합니다. 어디서 효과적이고 어디서 과잉인지를 .NET...
.NET의 Native AOT란 무엇인가 - JIT, ReadyToRun, trimming과의 차이를 먼저 정리
Native AOT가 publish 시점에 .NET 앱을 정적으로 굳히는 배포 모델임을 JIT, ReadyToRun, trimming, source generator와 비교해 정리하고, 어느 앱에 잘 맞고 어디서 막히는지 실무 관점에서 판별 기준...
PeriodicTimer / System.Threading.Timer / DispatcherTimer의 사용 구분 - .NET의 정기 실행을 먼저 정리
PeriodicTimer는 async 루프, System.Threading.Timer는 ThreadPool callback, DispatcherTimer는 WPF UI 스레드라는 책무 차이를 정리하고, .NET 6 이후의 worker・WPF에서 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.