🤖 이 글은 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