클라우드 Mac에서 빌드 도구가 소스 디렉터리를 감시하면, 파일을 저장할 때마다 컴파일이 시작됩니다. 하지만 실제로는 편집기가 임시 파일을 먼저 쓴 뒤 원본 경로로 이름을 바꿔 교체할 수 있고, 연속 저장에 대한 콜백이 한 번만 발생할 수도 있습니다. 도구가 ‘이벤트 한 번’을 ‘파일 하나가 정확히 한 번 변경됨’으로 해석하면 빌드에서 변경 사항이 누락되기 쉽습니다. 다음의 작은 실험으로 알림과 최종 상태를 나누어 검증해 보겠습니다.
감시 대상을 먼저 정하기
특정 파일 디스크립터만 감시하면 해당 객체 자체를 관찰하기에는 좋지만, 같은 경로의 파일이 새 파일로 교체됐는지 판단하기에는 적합하지 않습니다. 많은 편집기는 임시 파일을 다 쓴 다음 이름을 바꿔 교체하는 원자적 저장 방식을 사용합니다. 이때 경로는 같아도 그 뒤의 파일 객체는 달라질 수 있습니다.
이 실험에서는 대신 상위 디렉터리를 감시합니다. 절차는 두 단계입니다. 디렉터리 이벤트를 받으면 ‘변경됐을 가능성 있음’으로 표시하고, 이어서 디렉터리를 다시 읽어 이전 스냅샷과 비교합니다. 이 방식은 중간에 일어난 모든 쓰기 작업을 기록한다고 보장하지 않습니다. 대신 빌드 도구에 더 중요한 질문, 즉 지금 어떤 입력 파일이 지난번 검사 때와 달라졌는지에 답할 수 있습니다.
콜백은 재스캔 신호이지 변경 이력이 아닙니다. 두 번의 쓰기가 하나의 알림으로 합쳐져도 최종 파일은 다음 검사 대상에 포함되어야 합니다.
실행 가능한 최소 감시기 작성하기
클라우드 Mac의 명령줄에서 실험용 디렉터리를 만들고, 아래 내용을 watch.swift로 저장하세요. 이 스크립트는 디렉터리의 바로 아래 항목만 검사하며 재귀적으로 탐색하지 않습니다. 파일 이름, 크기, 수정 시각, 파일 번호를 기록합니다. 파일 번호는 같은 이름의 파일이 교체됐는지 알아보는 데 도움이 됩니다.
import Darwin
import Dispatch
import Foundation
struct Entry: Equatable {
let size: UInt64
let modified: Date
let inode: UInt64
}
let path = CommandLine.arguments.dropFirst().first ?? ""
guard !path.isEmpty else {
fputs("usage: swift watch.swift DIRECTORY\n", stderr)
exit(2)
}
guard FileManager.default.fileExists(atPath: path) else {
fputs("directory does not exist\n", stderr)
exit(2)
}
func snapshot() -> [String: Entry] {
var result: [String: Entry] = [:]
let names = (try? FileManager.default.contentsOfDirectory(atPath: path)) ?? []
for name in names {
let file = (path as NSString).appendingPathComponent(name)
guard let attributes = try? FileManager.default.attributesOfItem(atPath: file),
let size = attributes[.size] as? NSNumber,
let inode = attributes[.systemFileNumber] as? NSNumber,
let modified = attributes[.modificationDate] as? Date else {
continue
}
result[name] = Entry(
size: size.uint64Value,
modified: modified,
inode: inode.uint64Value
)
}
return result
}
let fd = open(path, O_EVTONLY)
guard fd >= 0 else {
perror("open")
exit(1)
}
let queue = DispatchQueue(label: "watch.lab")
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .rename, .delete],
queue: queue
)
var previous = snapshot()
source.setEventHandler {
let current = snapshot()
let changed = Set(previous.keys).union(current.keys)
.filter { previous[$0] != current[$0] }
.sorted()
if !changed.isEmpty {
print("changed: \(changed.joined(separator: ", "))")
fflush(stdout)
}
previous = current
}
source.setCancelHandler { close(fd) }
source.resume()
print("watching: \(path)")
dispatchMain()
여기서는 .write 콜백을 특정 파일 이름에 직접 대응시키지 않습니다. 디렉터리 이벤트는 디렉터리를 확인해야 한다는 신호일 뿐입니다. snapshot()에서 읽기에 실패한 항목은 건너뛰므로, 실제 운영 도구라면 읽기 오류를 별도로 기록하고 다음 검사에서 재시도해야 합니다. 일시적인 권한 문제나 이름 변경 과정의 경합을 확정적인 삭제로 판단해서는 안 됩니다.
세 가지 작업으로 동작 범위 확인하기
먼저 한 터미널에서 감시기를 실행하고, 두 번째 터미널에서 작업을 수행하세요. 아래 경로는 실험 전용이므로 작업 중인 저장소를 지정하지 마세요.
mkdir -p "$HOME/watch-lab"
swift watch.swift "$HOME/watch-lab"
printf 'first\n' > "$HOME/watch-lab/input.txt"
printf 'second\n' > "$HOME/watch-lab/input.txt"
printf 'replacement\n' > "$HOME/watch-lab/.input.tmp"
mv -f "$HOME/watch-lab/.input.tmp" "$HOME/watch-lab/input.txt"
rm "$HOME/watch-lab/input.txt"
출력을 볼 때 다섯 명령에 각각 한 줄씩 대응할 것이라고 기대하지 마세요. 두 스냅샷 사이에 쓰기와 이름 변경이 일어나면 중간 상태는 한 번도 표시되지 않을 수 있습니다. 다만 마지막 삭제 이후의 스냅샷에는 input.txt가 없어야 합니다. 세 결과를 각각 확인하려면 생성, 교체, 삭제 후 작업을 잠시 멈추고 해당 출력이 나타난 다음 다음 단계로 진행하세요.
같은 이름으로 교체할 때 파일 번호가 필요한 이유
파일 이름만 비교하면 ‘기존 파일이 사라지고 새 파일이 같은 경로를 차지한 상황’을 변경 없음으로 판단하게 됩니다. 파일 크기마저 우연히 같을 수 있습니다. 이 실험에서는 파일 번호도 기록하므로, 원자적 교체 후 이름과 크기가 같아도 대체로 파일 객체의 변화를 알아낼 수 있습니다. 수정 시각은 일반적인 제자리 쓰기를 감지하는 데 쓰입니다. 이 스냅샷은 가벼운 변경 판별 수단이지 파일 내용 검증 수단은 아닙니다. 배포 여부를 결정할 때 바이트 단위의 일치 여부가 필요하다면, 대상 파일이 안정된 뒤 콘텐츠 해시를 별도로 계산해야 합니다.
빌드 흐름에 실험 방식 적용하기
실제 빌드에서는 이벤트가 올 때마다 즉시 컴파일을 시작하지 않는 편이 좋습니다. 더 안정적인 상태 흐름은 ‘알림 수신 → 재스캔 예약 → 변경 경로 수집 → 빌드 실행’입니다. 재스캔 중에 알림이 또 오면 현재 스캔이 끝난 뒤 한 번 더 스캔합니다. 이렇게 하면 이벤트가 합쳐져도 처리할 수 있고, 빌드가 실행 중이라는 이유로 새 변경 사항을 그대로 버리지도 않습니다.
디렉터리 범위도 명확히 해야 합니다. 위 스크립트는 바로 아래 항목만 나열하므로 중첩된 소스 디렉터리의 내용 변경을 완전히 찾아낼 수 없습니다. 디렉터리 트리 전체를 다뤄야 한다면 재귀적인 디렉터리 변경에 적합한 방식을 사용하되, ‘알림을 받은 뒤 재스캔한다’는 원칙은 유지하세요. 빌드 출력 디렉터리도 제외해야 합니다. 그렇지 않으면 결과물 쓰기가 빌드를 반복해서 트리거할 수 있습니다.
마지막으로 감시 대상 디렉터리 자체의 생명주기를 확인하세요. 디렉터리가 삭제되거나 교체되면 기존 디스크립터는 더 이상 새 경로를 가리키지 않습니다. 도구는 경로가 여전히 예상한 디렉터리를 가리키는지 확인하고, 필요하면 다시 열어 감시를 설정해야 합니다.
실행 가능한 검증 체크리스트
감시 로직을 운영에 적용하기 전에 별도의 임시 디렉터리에서 다음을 확인하세요.
- 빠르게 연속해서 쓴 뒤 최종 콘텐츠에 해당하는 파일이 빌드 대기 집합에 들어갑니다. 콜백 횟수를 통과 기준으로 삼지 않습니다.
- 임시 파일의 이름을 바꿔 원본 파일을 덮어쓴 뒤, 같은 이름의 입력 파일을 다시 검사합니다.
- 입력 파일을 삭제한 뒤에는 이전 스냅샷의 파일을 여전히 존재하는 것으로 취급하지 않습니다.
- 빌드 중 두 번째 변경이 발생하면 빌드가 끝난 뒤 최소 한 번 더 재스캔합니다.
- 출력 디렉터리를 제외하고, 감시 대상 디렉터리가 교체되면 감시를 다시 설정합니다.
- 읽기 실패를 오류로 기록하고 재시도할 수 있으며, 조용히 ‘변경 없음’으로 처리하지 않습니다.
이 검증은 질문을 ‘시스템이 왜 이벤트를 한 번 덜 보냈는가’에서 ‘현재 입력을 빠짐없이 확인했는가’로 바꿉니다. 파일 변경으로 작업을 트리거하는 클라우드 Mac 워크플로에서 검증 가능한 결과는 후자입니다.
자주 묻는 질문
파일을 두 번 수정했는데 콜백이 한 번만 실행되는 이유는 무엇인가요?
파일 시스템 이벤트가 병합될 수 있기 때문입니다. 콜백 횟수를 수정 횟수로 해석하지 말고 디렉터리를 다시 스캔하세요.
원자적 저장 이후 파일 자체의 감시가 멈추는 이유는 무엇인가요?
기존 파일이 새 파일로 교체되면 이전 파일 설명자는 옛 객체를 가리킬 수 있습니다. 부모 디렉터리를 감시하고 경로를 다시 확인하세요.
다음 실험을 위한 나만의 Mac
필요에 따라 전용 Mac mini, 노드 및 요금제를 선택하세요. 모델과 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.