security-scoped bookmark에서 틀렸던 네 가지
Divecut은 푸티지 폴더를 가리키고, 종료했다가 다시 실행해도 계속 그 폴더를 가리키는 샌드박스 macOS 앱입니다. 이를 가능하게 하는 메커니즘이 security-scoped bookmark입니다. 사용자에게 폴더를 한 번 물어보고, 그 결과인 blob을 저장해 뒀다가 다음 실행 때 다시 풀어냅니다 (resolve).
API 자체는 작습니다. 그런데도 네 번이나 틀렸습니다. 버그마다 모양이 똑같았습니다 — 아무 상관 없어 보이는 무언가가 바뀌기 전까지는 보이지 않다가, 그 순간 한꺼번에 드러났습니다. 고친 방법보다 이 패턴 자체가 더 쓸모 있는 부분입니다.
1. 북마크를 쥐고 있는 것과 접근 권한을 가진 것은 다릅니다
실행할 때 북마크가 존재하는지만 확인하고 넘어갔습니다. 썸네일이 로드되고, 그리드가 채워지고, 다 멀쩡해 보였습니다.
그러다 썸네일 캐시 버전을 올렸습니다. 모든 칸이 한꺼번에 파일 손상 배지로 바뀌었습니다.
북마크는 한 번도 resolve된 적이 없었습니다. resolve()와
startAccessingSecurityScopedResource()가 전혀 호출되지
않아서, 앱에는 원본을 읽을 권한이 아예 없었습니다. 캐시가 살아 있는 동안은
원본 파일을 건드릴 일이 없어서 문제가 안 됐습니다. 캐시를 무효화하자,
버그를 숨기고 있던 바로 그것이 사라졌습니다.
권한 버그 앞에 캐시를 두면, 캐시가 살아 있는 동안은 계속 보이지 않습니다.
고치는 방법은 그리 화려하지 않습니다: 실행할 때 resolve → start accessing까지 전부 거치고, 앱이 실행되는 동안 그 접근 권한을 계속 유지합니다.
2. /var와 /private/var가 서로 다릅니다
시스템 임시 디렉터리 아래에 있는 폴더를 불러오면 클립이 0개 나왔습니다. 에러도, 크래시도 없이 — 그냥 빈 그리드였습니다.
FileManager.enumerator는 심볼릭 링크가 이미 풀린 경로를
반환하므로, 파일은 /private/var/…로 돌아왔습니다. 반면 제가
비교하던 폴더 경로는 /var/…였습니다. 둘 사이의 접두사 비교는
절대 맞아떨어지지 않습니다.
이게 특히 골치 아픈 이유는, 딱 떠오르는 정규화 방법들이 이 문제를 고쳐
주지 않기 때문입니다. standardizedFileURL도,
resolvingSymlinksInPath()도 /var 특수 심볼릭
링크를 풀어 주지 않습니다 — 오히려 반대 방향으로 작동해서
/private를 떼어냅니다. 실제로 통하는 건
canonicalPath 리소스 값입니다:
let canonical = try? standardized
.resourceValues(forKeys: [.canonicalPathKey])
.canonicalPath
고친 뒤에도 함정이 남아 있습니다: 비교되는 모든 경로에 정규화가 적용돼야 한다는 점입니다. 저는 불러오기 시점에는 정규화를 했지만 폴더 이동 확인 부분에는 하지 않았고, 불일치가 그대로 다시 돌아왔습니다.
3. 오래된(stale) 북마크는 밖에서 갱신할 수 없습니다
사용자가 Finder에서 폴더를 옮기면, 북마크는 여전히 resolve되긴 하지만
stale로 표시된 채 돌아오고, 새 북마크를 다시 써 두는 게 정석입니다. 저는
정확히 그렇게 했습니다: isStale을 감지하고,
bookmarkData()를 다시 호출해서 저장했습니다.
그런데 이건 실제로 아무것도 갱신하지 못합니다. 현재 접근하고 있지 않은, 재구성된 URL에서 만든 북마크는 그냥 다시 stale이 됩니다 — 이건 Apple Developer Forums에도 보고된 내용이고, 제가 본 현상과도 일치했습니다.
갱신은 접근 범위(access scope) 안에서 일어나야 합니다: start accessing → 새 북마크 쓰기 → stop accessing, 이 전부가 resolve에서 반환되기 전에 끝나야 합니다.
4. 두 번의 쓰기, 한 번의 크래시, 엉뚱한 폴더
"사용자가 폴더를 옮겼다"를 감지하려고, 마지막으로 성공적으로 resolve된 경로를 북마크 옆에 별도의 키로 저장해 뒀습니다. 값 두 개, 쓰기 두 번입니다.
그 사이에 앱이 죽으면, 새 위치를 가리키는 북마크와 옛 위치를 가리키는 경로 힌트가 남습니다. 다음 실행에서는 이 조합이 폴더가 이동했다로 읽혀서, 앱은 애초에 틀린 적 없는 클립 경로를 다시 씁니다. 하필 그 순간에 일어난 크래시 하나가 라이브러리를 망가뜨립니다.
고친 방법은 쓰기를 두 번으로 나누지 않는 것이었습니다. 두 값을 하나의 키
아래 저장되는 단일 blob에 함께 넣습니다. UserDefaults의 단일
쓰기는 중간에 찢기지(torn) 않으므로, 읽는 쪽은 완전한 옛 값이든 완전한 새
값이든 둘 중 하나만 보게 됩니다 — 절대 섞이지 않습니다.
이 문제의 뻔한 버전을 막아 주는 짝
모든 임시 접근은 헬퍼 함수 하나를 거치므로, 에러가 던져져도 접근 범위가 새어 나가지 않습니다:
public func withAccess<T>(to url: URL, _ body: (URL) throws -> T) throws -> T {
let granted = url.startAccessingSecurityScopedResource()
defer {
if granted { url.stopAccessingSecurityScopedResource() }
}
guard granted else { throw StoreError.accessDenied }
return try body(url)
}
라이브러리 폴더는 의도적인 예외입니다. 앱이 실행되는 동안 계속 열려
있어야 하므로 이 헬퍼를 거치지 않습니다 — 여기서는 defer로
닫는 게 바로 잘못된 동작이기 때문입니다.
시작하기 전의 나에게 해 주고 싶은 말
| 버그 | 무엇이 숨기고 있었나 |
|---|---|
| 북마크가 한 번도 resolve되지 않음 | 살아 있는 썸네일 캐시 |
/var 대 /private/var | 임시 디렉터리만 그 심볼릭 링크를 씀 |
| 갱신되지 않는 stale 리프레시 | 개발 중에는 아무도 폴더를 옮기지 않음 |
| 쓰기 두 번, 크래시 한 번 | 특정 밀리초에 앱이 죽어야만 드러남 |
이 중 어느 것도 처음부터 더 조심스럽게 코드를 짠다고 잡히는 게 아니었습니다. 다른 무언가를 바꾸고 — 캐시 버전, 테스트 폴더, 코드 리뷰 — 뭐가 무너지는지 지켜보다가 잡힌 것들입니다. 이 API 위에 뭔가를 만들고 있다면, 캐시를 한 번쯤 일부러 무효화해서 무엇이 아무 근거 없이 서 있었는지 확인해 볼 가치가 있습니다.
Divecut은 macOS와 iPad용 푸티지 브라우저입니다: Log 클립을 제 색으로, 평범한 말로 검색할 수 있게, 고른 클립을 Log 그대로 DaVinci Resolve로 넘깁니다. 무엇을 하는지 보기.