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입니다. 파일이나 디렉터리의 작성, 변경, 삭제, 이름 변경을 이벤트로 받을 수 있어 편리하지만, CreatedChanged를 그대로 완료 알림이라고 생각해서 쓰면 누락, 중복 알림, 도중 파일의 오독으로 상당히 흔하게 사고가 납니다.

이 글에서는 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.1. 먼저 동작하는 최소 코드
  2. FileSystemWatcher에서 일어나는 오해 패턴(그림)
    • 2.1. Created를 완료 알림이라고 생각한다
    • 2.2. Changed의 횟수와 순서를 믿는다
    • 2.3. 내부 버퍼 넘침으로 변경을 잃는다
  3. 안티패턴
    • 3.1. 이벤트 핸들러 안에서 그대로 처리한다
    • 3.2. 이벤트 열로부터 진실의 상태를 복원하려 한다
    • 3.3. Changed가 멈추면 완료 취급
    • 3.4. InternalBufferSize를 올리면 해결됐다고 생각한다
    • 3.5. Error를 로그만 내고 무시한다
  4. 베스트 프랙티스
    • 4.1. 알림은 「재스캔 요청」으로 접는다
    • 4.2. 완료 조건은 송신 측에서 명시한다
    • 4.3. 수신 측은 claim을 원자적으로 취한다
    • 4.4. startup / overflow / 재접속 시에는 full rescan한다
    • 4.5. idempotency를 전제로 한다
  5. 의사 코드(발췌)
    • 5.1. 전형적인 실패 패턴
    • 5.2. 올바른 방향의 예(대략 쓰면 이렇게)
  6. 대강의 사용 구분
  7. 정리
  8. 참고 자료

이 글의 지식 맵

이 기사는 FileSystemWatcher의 Created/Changed 이벤트를 완료 알림으로 다루는 오류나, 내부 버퍼의 overflow로 인한 알림 누락, 이벤트 열로부터 상태를 복원하려는 설계를 피하고, 알림은 모두 1종류의 재스캔 요청으로 접어 full rescan으로 실물을 확인하는 설계를 제안합니다. 완료는 송신 측이 temp -> rename/done·manifest로 명시하고, 수신 측은 재스캔으로 찾은 ready 후보에 대해 원자적 claim을 취하며, 여러 번 보러 가는 것을 전제로 idempotency로 받아들입니다. 상시 기동할 수 없거나 누락이 허용되지 않는 요건에는 USN change journal이라는 선택지도 있다고 자리매김합니다.

FileSystemWatcher 실무 가이드의 지식 맵FileSystemWatcher의 알림이 완료 알림이 아니라 변화의 기미에 지나지 않는다는 것, 알림을 재스캔 요청으로 접어 full rescan과 claim을 조합하는 것, 내부 버퍼의 overflow로 인한 알림 누락과 USN change journal이라는 대안의 관계를 보여주는 그림원인이 될 수 있다이용한다권장되는 대응권장되는 대응사용은 비권장원인이 될 수 있다원인이 될 수 있다원인이 될 수 있다권장되는 대응이용한다이용한다권장되는 대응권장되는 대응권장되는 대응원인이 될 수 있다방지한다이용한다이용한다권장되는 대응보다 먼저 해야 한다보다 먼저 해야 한다원인이 될 수 있다완화한다권장되는 대응전제로 한다전제로 한다FileSystemWatcherfull rescan(디렉터리 전체 재스캔)내부 버퍼 overflow로 인한 알림 누락주기적인 디렉터리 열거변경 알림 누락InternalBufferSize 조정Error 이벤트를 로그만 남기고 무시하는 안티패턴Created를 완료 알림으로 오해쓰기 중인 파일의 읽기 사고송신측의 완료 조건 명시temp -> close -> rename/replace로 공개done/manifest 파일알림을 재스캔 요청으로 통합Changed 횟수·순서를 믿는 안티패턴이벤트 시퀀스로 상태를 복원하려는 안티패턴이중 처리(이중 계상·이중 전송·업데이트 유실)원자적 claimbundle(연계 단위 디렉터리)idempotency(멱등성)를 전제로 한 처리watcher 중지 중 변경 누락USN 저널(변경 저널)NTFS관리자 권한

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 26건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

1. 먼저 결론(한마디로)

  • FileSystemWatcher의 이벤트는 완료 알림이 아니라 변화의 기미입니다
  • Created / Changed / Renamed는 중복되거나 상상과 다른 순서로 오거나 overflow 시에는 누락됩니다
  • 이벤트 핸들러에서는 무거운 처리를 하지 않고 재스캔 요청을 쌓기만 하는 편이 안정됩니다
  • 완료 판정은 temp -> close -> rename / replacedone / manifest로 명시하는 것이 기본입니다
  • 복수 워커가 있다면 읽기 전에 claim을 원자적으로 취득할 필요가 있습니다
  • InternalBufferSize의 조정은 보조입니다. 마지막은 full rescan과 idempotency가 효과적입니다

요컨대 FileSystemWatcher를 「진실의 이력 스트림」으로서 다루지 않는 것입니다. 알림은 어디까지나 「슬슬 보러 가라」의 신호에 머무는 편이 망가지기 어려워집니다.

이 글의 설계 핵심알림은 계기에 그치고, 진실은 디렉터리 재스캔으로 확인하며, 소유권은 원자적인 claim으로 취하고, 마지막은 idempotency로 중복을 받아들인다는 이 글의 설계 핵심을 나타낸다.알림은 계기진실은 디렉터리 재스캔소유권은 원자적인 claim마지막은 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가 이어지는 경우가 있습니다.

수신 측FileSystemWatcherwatched dir송신 측수신 측FileSystemWatcherwatched dir송신 측아직 복사 도중행수 부족 / JSON 파손 / ZIP 파손orders.csv를 작성CreatedOnCreatedorders.csv를 열어 읽음나머지를 기록ChangedChanged

그림 2: 복사 도중에도 Created는 날아간다. 도착한 시점에 읽으러 가면 깨진 데이터를 쥐게 된다.

Created는 「이름이 보였다」를 나타내도, 「이제 읽어도 좋다」를 보증하지 않습니다. 여기를 같은 의미로 하면 지난번 기사의 2.1을 다른 경로로 밟게 됩니다.

2.2. Changed의 횟수와 순서를 믿는다

Changed는 1회만 오는 것이 아닙니다. 이동이나 저장 같은 일반적인 조작으로도, 복수의 이벤트로 나뉘어 보이는 경우가 있습니다. 또한 안티바이러스 소프트나 인덱서가 건드린 분까지 주워오는 경우도 있습니다.

FileSystemWatcherAV / indexerwatched dir저장하는 앱FileSystemWatcherAV / indexerwatched dir저장하는 앱횟수·순서 모두 보장되지 않음report.xlsx 저장 시작CreatedChanged임시 파일에서 renameRenamedChanged스캔 / 속성 참조Changed

그림 3: 일반적인 저장에서도 이벤트는 여러 개로 나뉘고, 외부 프로세스가 건드린 것까지 섞인다. 횟수도 순서도 믿을 수 없다.

Changed가 1회 오면 완료」「Renamed 다음은 더 이상 건드려지지 않는다」라는 기대는 상당히 위험합니다.

보충:

  • 파일 rename으로 Changed가 날아가는 경우가 있습니다
  • RenamedEventArgs.Name은 OS 측에서 old/new의 대응이 잡히지 않으면 null이 될 수 있습니다
  • hidden file도 무시되지 않습니다. 숨은 temp 이름이니 보이지 않겠지는 통용되지 않습니다
  • 감시하고 있는 디렉터리 자체를 rename해도 그 변경은 알림되지 않습니다

2.3. 내부 버퍼 넘침으로 변경을 잃는다

FileSystemWatcher에는 내부 버퍼가 있습니다. 단시간에 변경이 집중되면 여기가 넘쳐서 개별 알림을 누락합니다.

아니오단시간에 대량의 변경내부 버퍼에 알림이 쌓임처리가 따라잡나?개별 이벤트를 순서대로 처리overflowError 이벤트개별 이력의 완전성을 신용하지 않음디렉터리를 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 갱신까지 시작하면 버스트 시에 자기 목을 조르게 됩니다.

이벤트 핸들러의 무게 분기점이벤트 핸들러에서는 재스캔 요청을 세우고 즉시 돌아오는 것이 좋고, 핸들러 안에서 무거운 I/O나 DB 갱신까지 시작하면 미완성 내용을 읽는 위험과 버스트 시 처리가 따라잡지 못하는 문제를 안게 됨을 나타낸다.이벤트 핸들러재스캔 요청을 세우고 즉시 돌아옴핸들러 내에서 무거운 I/O나 DB 갱신미완성 내용을 읽는 위험버스트 시 처리가 따라잡지 못함

그림 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;
}

이 방향으로 분발하기보다 그때마다 디스크 상의 현물을 재확인하는 편이 강합니다. 파일 연계에서 중요한 것은 이벤트 이력의 재현이 아니라 지금 이 순간에 처리해도 좋은 대상을 올바르게 찾는 것입니다.

이벤트 복원과 현물 확인의 대비이벤트 열로부터 상태를 복원하는 설계는 중복·분할·overflow·외란으로 앞뒤가 무너지므로, 그때마다 디스크 상의 현물을 재확인하여 지금 이 순간에 처리해도 좋은 대상을 올바르게 찾는 편이 강함을 나타낸다.이벤트 열로부터 상태를 복원한다중복·분할·overflow로 앞뒤가 무너짐그때마다 디스크 상의 현물을 확인처리해도 좋은 대상을 올바르게 찾음

그림 6: 목적은 이벤트 이력의 재현이 아니라 지금 처리해도 좋은 대상을 찾는 것이다.

3.3. Changed가 멈추면 완료 취급

지난번의 「파일 크기가 멈추면 완료」와 같은 냄새가 나는 설계입니다. 편리해 보이지만 추측으로 완료를 결정하고 있습니다.

if (lastChangedAt + TimeSpan.FromSeconds(10) < DateTime.UtcNow)
{
    return Ready;
}

이것으로 곤란한 것은 예를 들어 다음과 같은 경우입니다.

  • 큰 파일의 복사가 도중에 일시 정지한다
  • 송신 측 앱이 복수 단계로 저장한다
  • 네트워크 공유에서 알림이 늦게 보인다
  • 외부 프로세스가 나중에 속성이나 시각을 덮어쓴다

완료는 추측이 아니라 명시하는 편이 안정됩니다.

고요함에 의한 완료 추측의 위험함Changed가 한동안 멈추면 완료로 간주하는 추측은 복사의 일시 정지나 다단계 저장, 알림 지연, 나중의 속성 재작성으로 오판정하므로, 완료는 송신 측이 명시하는 편이 안정적임을 나타낸다.Changed가 멈추면 완료로 추측복사의 일시 정지로 오판정다단계 저장·알림 지연으로 오판정완료는 송신 측이 명시한다추측에 의존하지 않아 안정적

그림 7: 고요함은 완료의 증거가 되지 않는다. 완료는 추측이 아니라 명시로 결정한다.

3.4. InternalBufferSize를 올리면 해결됐다고 생각한다

InternalBufferSize의 조정은 중요하지만, 이것은 설계의 본체가 아닙니다.

  • 기본값은 8192 바이트
  • 4096 바이트 미만으로는 할 수 없고, 64 KB를 넘을 수도 없음
  • 버퍼는 non-paged memory를 사용하므로 늘릴수록 가벼운 것은 아님

즉, 64 KB까지 올려도 알림 버스트가 그것을 넘으면 끝입니다. 게다가 완료 알림인지의 문제는 1mm도 해결되지 않습니다.

먼저 손댈 것은 예를 들어 다음과 같은 것들입니다.

  • Filter / Filters로 감시 대상을 좁힌다
  • NotifyFilter를 필요 최소한으로 한다
  • IncludeSubdirectories를 함부로 true로 하지 않는다
  • 이벤트 핸들러를 가볍게 한다
  • full rescan과 idempotency를 넣는다
버퍼 확대보다 먼저 할 일InternalBufferSize를 64KB까지 올려도 버스트가 그것을 넘으면 놓치므로, 먼저 Filter나 NotifyFilter로 감시 대상을 좁히고 핸들러를 가볍게 하고 full rescan과 idempotency를 넣는 순서를 나타낸다.먼저 손댈 것Filter와 NotifyFilter로 좁힌다핸들러를 가볍게 한다full rescan과 idempotencyInternalBufferSize의 조정마지막 보조에 그친다

그림 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종류의 신호로 접습니다.

Created / Changed / Deleted / Renamedscan requestError / overflowstartup디렉터리 재스캔ready한 후보를 열거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를 마지막에 둔다
data.tmp에 전체 내용을 쓴다flush / closedata.csv에 rename / replacedata.done / manifest.json을 둔다수신 측은 final 이름이나 done만 본다

그림 10: 송신 측은 temp에 전체 내용을 쓰고 close한 뒤 rename으로 공개하고, 필요하면 done / manifest를 마지막에 둔다.

지난번 기사와 같지만, 여기가 정말로 효과적입니다. FileSystemWatcher는 완료를 발명하는 도구가 아니라 명시된 완료를 빨리 찾는 도구라고 생각하면 정리하기 쉽습니다.

4.3. 수신 측은 claim을 원자적으로 취한다

재스캔으로 ready한 후보를 찾아도 그대로 읽으러 가면 복수 워커가 동시에 잡을 수 있습니다. 그래서 처리 전에 claim을 원자적으로 취합니다.

processing/worker2processing/worker1incomingscannerprocessing/worker2processing/worker1incomingscanner먼저 성공한 쪽만 소유권을 가짐order-123을 발견rename order-123rename order-123

그림 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는 차분의 힌트, 재스캔은 정합성의 회복」입니다.

full rescan을 넣는 타이밍기동 시, Error 수신 시, watcher를 다시 만든 직후, 정기적인 보험으로서의 일정 간격마다라는 4가지 타이밍에 full rescan을 넣어 이벤트로 주울 수 없는 변경을 회복함을 나타낸다.기동 시full rescanError 수신 시watcher 재생성 직후정기적인 보험으로서정합성 회복

그림 12: watcher는 차분의 힌트, full rescan은 정합성의 회복. 이 4가지 타이밍에 반드시 넣는다.

4.5. idempotency를 전제로 한다

FileSystemWatcher를 사용하면 같은 대상을 복수 회 보러 가게 됩니다. 이것은 버그가 아니라 설계로서 받아들이는 편이 안정됩니다.

예를 들어 다음과 같이 합니다.

  • manifest에 IdempotencyKey를 넣는다
  • 이미 처리 완료라면 부작용을 재실행하지 않는다
  • archive 완료 / DB 기록 완료 / 송신 완료를 대조할 수 있도록 한다
  • full rescan해도 「같은 것을 한 번 더 안전하게 본다」만으로 한다

exactly-once를 이벤트만으로 만들려고 하면 상당히 힘들어집니다. at-least-once를 받아들이고 마지막을 idempotency로 조이는 편이 실무에서는 강합니다.

중복 전제의 받아들이는 방법같은 대상을 여러 번 보러 가는 것을 버그가 아니라 설계로서 받아들이고, manifest의 IdempotencyKey로 처리 완료를 대조해 부작용을 재실행하지 않음으로써 재주사해도 안전해짐을 나타낸다.같은 대상을 여러 번 보러 감설계로서 받아들임IdempotencyKey로 처리 완료를 대조부작용을 재실행하지 않음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로 옮긴다
올바른 방향의 처리 흐름알림을 scan request로 접고, 주사로 ready한 후보를 찾고, claim을 취하고, idempotency를 확인한 뒤 처리와 기록을 하여 archive로 옮긴다는, 의사 코드가 나타내는 일련의 흐름을 나타낸다.알림을 scan request로 접는다주사로 ready를 찾는다claim을 취한다idempotency를 확인한다처리해서 기록하고 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 쪽이 구현은 더 간단합니다.

FileSystemWatcher와 USN change journal의 차이FileSystemWatcher는 앱 정지 중의 변경을 알 수 없어 full rescan으로 메우는 데 비해, USN change journal은 볼륨 측에 기록이 남아 이전에 읽은 위치부터 정지 중의 변경도 다시 읽을 수 있다는 차이를 나타낸다.FileSystemWatcher정지 중의 변경은 알 수 없음full rescan으로 메움USN change journal볼륨 측에 기록이 남음이전 USN부터 다시 읽을 수 있음

그림 15: 상시 기동할 수 없다·정지 중의 변경도 줍고 싶다는 요건이 나오면, change journal이 선택지가 된다.

7. 정리

FileSystemWatcher는 완료 알림의 대체가 되지 않습니다. 진실은 이벤트 열이 아니라 지금 디스크 위에 보이고 있는 상태에 있습니다. 완료는 temp -> close -> rename / replacedone / manifest로 명시하고, 소유권은 claim을 원자적으로 취해 결정합니다. 설계의 본체는 여기에 있습니다.

Created로 즉시 처리한다, Changed의 횟수나 순서를 믿는다, Changed가 멈추면 완료 취급한다, InternalBufferSize만으로 안심한다, Error를 봤는데 회복하지 않는다 ── 어느 쪽도 피하고 싶은 설계입니다. 대신 알림은 재스캔 요청으로 접고, startup / overflow / 재접속에서는 full rescan을 넣고, claim rename으로 소유권을 취하고, 중복과 재주사는 idempotency로 받아들입니다.

즉, FileSystemWatcher에서는 「이벤트를 받은 것」과 「처리해도 좋은 것」을 같게 하지 않는 것이 요령입니다. 여기를 나누기만 해도 가끔만 망가지는 타입의 감시 처리가 상당히 줄어듭니다.

8. 참고 자료

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

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를 넣는 것이 순서입니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기