2026년 8월 14일

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된 경로를 북마크 옆에 별도의 키로 저장해 뒀습니다. 값 두 개, 쓰기 두 번입니다.

그 사이에 앱이 죽으면, 새 위치를 가리키는 북마크와 옛 위치를 가리키는 경로 힌트가 남습니다. 다음 실행에서는 이 조합이 폴더가 이동했다로 읽혀서, 앱은 애초에 틀린 적 없는 클립 경로를 다시 씁니다. 하필 그 순간에 일어난 크래시 하나가 라이브러리를 망가뜨립니다.

실행 N 쓰기 1: 북마크 = 새 위치 크래시 쓰기 2: 경로 힌트 실행되지 않음 실행 N+1 북마크: 새 위치 경로 힌트: 옛 위치 "폴더 이동"으로 읽힘 클립 경로를 다시 씀 틀린 적 없었던
그 틈은 짧지만, 폴더가 바뀔 때마다 존재합니다 — 그리고 피해는 이동과 아무 상관도 없었던 클립들에게 돌아갑니다.

고친 방법은 쓰기를 두 번으로 나누지 않는 것이었습니다. 두 값을 하나의 키 아래 저장되는 단일 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로 넘깁니다. 무엇을 하는지 보기.