[Swift 입문] 41편 — FSEvents와 파일 시스템 감시

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [40편] Apple Events와 다른 앱 제어

파일이 바뀌었는지 어떻게 아는가

27편에서 FileManager로 파일을 읽고 쓰는 법을 배웠습니다. 그런데 “누군가 이 파일을 수정하면 즉시 알려달라”는 요구는 또 다른 문제입니다. 매초 파일을 다시 읽어서 내용이 바뀌었는지 비교하는 폴링(polling) 방식도 가능하지만, 감시할 파일이 많아질수록 비효율적이고 반응이 느립니다.

macOS는 커널 수준에서 파일 시스템 변경을 감지해 알려주는 두 가지 API를 제공합니다.

  • FSEvents: 디렉터리 단위로, 넓은 범위(폴더 전체, 심지어 디스크 전체)를 적은 리소스로 감시
  • DispatchSource(kqueue 기반): 특정 파일 디스크립터 하나를 정밀하게 감시

DispatchSource로 파일 하나 감시하기

감시 대상이 파일 몇 개뿐이라면 GCD의 DispatchSourceFileSystemObject가 더 간단합니다.

import Foundation

final class FileWatcher {
    private var source: DispatchSourceFileSystemObject?
    private var fileDescriptor: Int32 = -1

    func watch(path: String, onChange: @escaping () -> Void) {
        fileDescriptor = open(path, O_EVTONLY)  // 이벤트 감시 전용 — 읽기/쓰기 목적 아님
        guard fileDescriptor >= 0 else { return }

        source = DispatchSource.makeFileSystemObjectSource(
            fileDescriptor: fileDescriptor,
            eventMask: [.write, .rename, .delete],
            queue: .main
        )

        source?.setEventHandler {
            onChange()
        }

        source?.setCancelHandler { [weak self] in
            guard let self else { return }
            close(self.fileDescriptor)
        }

        source?.resume()
    }

    func stop() {
        source?.cancel()
    }
}

// 사용
let watcher = FileWatcher()
watcher.watch(path: "/Users/me/config.json") {
    print("설정 파일이 변경됨")
}

O_EVTONLY 플래그가 핵심입니다. 이 파일을 실제로 읽거나 쓰려는 게 아니라 “이벤트만 지켜보겠다”는 의도를 커널에 알립니다. 일반 O_RDONLY로 열면 그 파일을 다른 프로세스가 마운트 해제하려 할 때 방해가 될 수 있는데, O_EVTONLY는 그런 부작용이 없습니다.

eventMask에는 감시할 이벤트 종류를 지정합니다. .write(내용 변경), .rename(이름 변경/이동), .delete(삭제) 외에도 .link, .extend, .attrib 등이 있습니다.


DispatchSource의 한계 — 파일이 삭제되면?

파일이 삭제되거나 이름이 바뀌면, 열어두었던 파일 디스크립터는 더 이상 그 경로를 가리키지 않습니다. 에디터가 저장할 때 “임시 파일 쓰기 → 원본 삭제 → 임시 파일을 원래 이름으로 변경”하는 방식(atomic save)을 쓰는 경우가 흔한데, 이때 .delete 이벤트가 발생하고 나면 기존 fd로는 더 이상 새 내용을 감시할 수 없습니다. 이런 경우 .delete.rename 이벤트를 받으면 감시를 취소하고, 같은 경로로 다시 watch()를 호출해 새로 열어주는 재시작 로직이 필요합니다.

source?.setEventHandler { [weak self] in
    guard let source = self?.source else { return }
    let flags = source.data  // 어떤 이벤트가 발생했는지

    if flags.contains(.delete) || flags.contains(.rename) {
        self?.stop()
        self?.watch(path: path, onChange: onChange)  // 재구독
    }
    onChange()
}

FSEvents로 디렉터리 전체 감시하기

파일 하나가 아니라 폴더 안에서 무슨 일이 일어나는지(파일이 몇 개든, 하위 폴더가 몇 겹이든) 감시하려면 FSEvents가 적합합니다. 파일마다 fd를 여는 DispatchSource와 달리, 커널의 변경 로그를 구독하는 방식이라 대상이 아무리 많아도 리소스 사용량이 일정합니다.

import CoreServices

final class DirectoryWatcher {
    private var streamRef: FSEventStreamRef?

    func watch(directory: String, onChange: @escaping ([String]) -> Void) {
        var context = FSEventStreamContext(
            version: 0,
            info: Unmanaged.passUnretained(self).toOpaque(),
            retain: nil, release: nil, copyDescription: nil
        )

        let callback: FSEventStreamCallback = { (streamRef, clientInfo, numEvents, eventPaths, eventFlags, eventIds) in
            let watcher = Unmanaged.fromOpaque(clientInfo!).takeUnretainedValue()
            let paths = unsafeBitCast(eventPaths, to: NSArray.self) as! [String]
            watcher.onChange?(paths)
        }

        self.onChange = onChange

        streamRef = FSEventStreamCreate(
            kCFAllocatorDefault,
            callback,
            &context,
            [directory] as CFArray,
            FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
            0.5,  // 지연 시간(초) — 짧은 시간 내 여러 변경을 묶어서 전달
            FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents)
        )

        FSEventStreamSetDispatchQueue(streamRef!, .main)
        FSEventStreamStart(streamRef!)
    }

    private var onChange: (([String]) -> Void)?

    func stop() {
        guard let streamRef else { return }
        FSEventStreamStop(streamRef)
        FSEventStreamInvalidate(streamRef)
        FSEventStreamRelease(streamRef)
    }
}

세 번째 인자인 지연 시간(위 예시에서 0.5초)은 성능과 즉시성 사이의 트레이드오프입니다. 짧은 시간에 수백 개 파일이 바뀌는 상황(예: git checkout)에서 매 파일마다 콜백을 호출하면 과도한 부하가 생기므로, FSEvents는 지연 시간 동안의 변경을 모아 한 번에 알려줍니다. 즉시성이 중요하면 값을 줄이고, 부하가 걱정되면 늘립니다.


어떤 것을 골라야 할까

DispatchSource FSEvents
감시 대상 파일/디렉터리 하나 디렉터리 트리 전체
리소스 대상마다 fd 하나씩 필요 대상 수와 무관하게 일정
세밀함 이벤트 종류가 세밀함 변경된 경로 목록 위주
적합한 경우 설정 파일 하나 감시 프로젝트 폴더 전체 감시

핵심 요약

  • 파일 변경 감지는 폴링 대신 커널이 알려주는 이벤트 기반 API를 사용
  • DispatchSource: 파일 하나를 O_EVTONLY로 열어 정밀하게 감시, 삭제/이름변경 시 재구독 필요
  • FSEvents: 디렉터리 트리 전체를 낮은 리소스로 감시, 지연 시간으로 이벤트를 배칭
  • 대상이 하나면 DispatchSource, 폴더 전체면 FSEvents가 자연스러운 선택

다음 편에서는 UserNotifications 프레임워크로 시스템 알림 센터에 알림을 띄우는 방법을 다룹니다.

🤖 Generated with Claude Code

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다