블로그

2026년 8월 15일 · 8분 읽기

iOS App Group SQLite 공유: 앱과 위젯이 한 DB를 쓰게 하기

앱과 위젯, 공유 확장이 같은 SQLite 파일을 쓰도록 App Group으로 옮긴 기록입니다. 세션이 만료되자 앱이 다른 파일을 열어 기록이 사라져 보인 사고에서 시작합니다.

  • iOS
  • App Group
  • SQLite
  • 데이터 동기화

2026년 8월 23일 업데이트

로그인 세션이 만료된 사용자가 앱을 열었습니다. 기록이 전부 사라져 보였습니다.

파일은 디스크에 멀쩡히 있었습니다. 앱이 다른 파일을 열었습니다.

세 프로세스가 한 데이터베이스를 보는 구조

한눈에 보기

문제 로그인 세션이 만료된 사용자가 앱을 열었습니다.
결정 마이그레이션으로 컬럼을 하나 추가했습니다.
결과 첨부 부분만 규칙 밖에 있었습니다.
제약 사용자가 목록을 스크롤하는데 빈칸이 있으면 그것이 로딩인지 실패인지 모릅니다.

위젯은 앱의 데이터를 볼 수 없습니다

위젯과 공유 확장은 앱과 다른 프로세스입니다. 샌드박스도 다릅니다.

앱의 Application Support 디렉터리에 있는 SQLite 파일을 위젯이 열 수 없습니다.

방법이 둘입니다. 앱이 데이터를 복사해서 넘기거나, 같은 디렉터리를 공유하는 것입니다.

App Group을 씁니다. 앱과 확장이 같은 컨테이너를 봅니다.

// DatabaseLocation.swift:10-19
static public let appGroupId = "group.dev.example.justsend"

public static var containerRootURL: URL? {
    FileManager.default.containerURL(
        forSecurityApplicationGroupIdentifier: appGroupId
    )
}

저장소와 첨부가 모두 이 아래에 있습니다. 위젯이 데이터베이스를 직접 읽습니다.

문제는 이미 사용자 기기에 있는 파일입니다. 옮겨야 합니다.

이전은 마이그레이터가 아니라 기동 루틴입니다

데이터베이스 마이그레이션은 보통 스키마를 바꿉니다. 파일 위치를 바꾸는 것은 그것과 다릅니다.

// DatabaseLocation.swift:5-8
/// GRDB 파일의 App Group 이전 — 위젯·Share Extension이 같은 DB를
/// 직독하기 위한 선행 조건. 마이그레이터가 아니라 기동 시 1회 파일 이동
/// 루틴이며, 어떤 실패도 legacy 경로 폴백으로 흡수한다.

마지막 문장이 이 루틴의 전부입니다. 이전이 실패하면 옛 경로를 그대로 씁니다.

앱이 열리는 것이 위젯이 동작하는 것보다 중요합니다. 이전에 실패해도 앱은 정상 동작해야 합니다.

// DatabaseLocation.swift:75-81
guard let containerURL else { return legacyURL }
let target = containerURL.appendingPathComponent(legacyURL.lastPathComponent)
guard target != legacyURL else { return target }
guard migrateDatabase(from: legacyURL, to: target, fileManager: fileManager) else {
    return legacyURL   // 실패하면 옛 경로
}
return target

엔타이틀먼트가 없거나 컨테이너를 못 만들면 옛 경로입니다. 위젯만 데이터를 못 봅니다.

Catalyst가 거짓말을 합니다

Mac Catalyst에서 함정을 하나 만났습니다.

// DatabaseLocation.swift:57-62
// Catalyst ad-hoc/debug launches can report a non-nil App Group URL before
// the system has provisioned the directory. Treat that as unavailable so
// the existing legacy Application Support store remains usable.
let usableContainer = container.flatMap { containerURL in
    FileManager.default.fileExists(atPath: containerURL.path) ? containerURL : nil
}

컨테이너 URL을 물으면 값을 줍니다. 그 경로에 디렉터리가 아직 없습니다.

시스템이 프로비저닝을 안 했는데 주소는 알려 줍니다. 그 주소로 파일을 옮기면 실패합니다.

nil 검사만으로는 부족합니다. 경로가 실제로 존재하는지 확인해야 합니다.

WAL 파일을 함께 옮겨야 합니다

SQLite를 WAL 모드로 쓰면 파일이 셋입니다.

파일 무엇
db.sqlite 본체
db.sqlite-wal 아직 본체에 반영되지 않은 쓰기
db.sqlite-shm 공유 메모리 인덱스

본체만 옮기면 WAL에 있던 최근 쓰기를 잃습니다.

// DatabaseLocation.swift:131-142
for suffix in ["-wal", "-shm"] {
    let side = URL(fileURLWithPath: source.path + suffix)
    let targetSide = URL(fileURLWithPath: destination.path + suffix)
    guard fileManager.fileExists(atPath: side.path),
          !fileManager.fileExists(atPath: targetSide.path) else { continue }
    do {
        try fileManager.moveItem(at: side, to: targetSide)
    } catch {
        // The main file is still usable and a later launch retries the
        // missing sidecar; never fall back to an unscoped database.
    }
}

중간에 죽어도 다음 기동이 마칩니다

본체를 옮기고 WAL을 옮기기 전에 앱이 죽을 수 있습니다.

이 함수는 그 상태에서 다시 불러도 됩니다. 본체가 이미 목적지에 있으면 옮기지 않고, WAL만 남아 있으면 그것만 옮깁니다.

멱등이라는 것을 주석에 적어 뒀습니다.

The operation is idempotent so a process interrupted between the main file and sidecars can finish the migration on its next launch.

사이드카 이동 실패에는 폴백하지 않습니다

본체는 이미 옮겨졌습니다. 여기서 옛 경로로 돌아가면 본체가 없는 곳을 가리킵니다.

catch 블록의 주석이 그것을 말합니다.

never fall back to an unscoped database

본체가 목적지에 있으면 목적지를 씁니다. WAL은 다음 기동이 다시 시도합니다.

실패 지점마다 폴백 여부가 다릅니다. 같은 함수 안에서 어떤 실패는 옛 경로로 돌아가고 어떤 실패는 돌아가지 않습니다.

어디서 실패했나 폴백하나
컨테이너 URL이 nil 옛 경로 App Group을 못 쓰는 기기다
컨테이너 디렉터리가 없음 옛 경로 Catalyst가 거짓 URL을 준 경우
본체 이동 실패 옛 경로 아직 아무것도 옮기지 않았다
사이드카 이동 실패 안 함 본체가 이미 목적지에 있다
양쪽에 데이터 있음 안 함, 플래그 어느 쪽이 맞는지 모른다

위의 셋과 아래의 둘이 갈리는 기준은 하나입니다. 본체를 아직 안 옮겼으면 물러서고, 옮긴 뒤에는 물러서지 않습니다. 폴백이 안전한 구간이 정해져 있습니다.

양쪽에 데이터가 있으면 아무것도 하지 않습니다

옛 경로와 새 경로에 데이터가 모두 있으면 코드는 어느 쪽도 고르지 않습니다.

// DatabaseLocation.swift:96-101
if destinationExists && sourceExists {
    let sourceHasRows = databaseHasRows(at: source, fileManager: fileManager)
    let destinationHasRows = databaseHasRows(at: destination, fileManager: fileManager)
    if sourceHasRows && destinationHasRows {
        return false        // 아무것도 하지 않습니다
    }

어느 쪽이 맞는지 코드가 알 수 없습니다. 덮어쓰면 한쪽을 영구히 잃습니다.

그래서 이전을 포기하고 옛 경로를 씁니다. 그리고 플래그를 세웁니다.

// DatabaseLocation.swift:12-13
/// Set during launch path resolution when both unscoped and account stores contain data.
public private(set) static var activePathConflict = false

한쪽이 비어 있으면 판단이 가능합니다.

// DatabaseLocation.swift:102-115
if sourceHasRows && !destinationHasRows {
    // 목적지가 빈 파일이면 지우고 옮깁니다
    try fileManager.removeItem(at: destination)
    for suffix in ["-wal", "-shm"] { /* 사이드카도 */ }

목적지에 빈 데이터베이스가 있는 것은 흔합니다. 앱이 한 번 열려서 스키마만 만들어 놓은 경우입니다.

행이 있는지 없는지가 판단 기준입니다. 파일이 있는지가 아닙니다.

사고는 계정 스코프에서 났습니다

데이터베이스 파일이 계정별로 있습니다. 어느 것을 열지 결정해야 합니다.

처음에는 키체인의 세션을 봤습니다. 로그인한 계정의 파일을 엽니다. 자연스럽습니다.

강제 로그아웃에서 무너졌습니다.

토큰이 만료되거나 서버가 세션을 무효화하면 앱이 세션을 지웁니다. 그 다음 기동에서 계정 파일을 여는 키가 사라집니다.

앱은 스코프 없는 파일을 엽니다. 비어 있습니다.

파일은 디스크에 멀쩡히 있습니다. 사용자에게는 기록이 전부 없어진 것으로 보입니다.

사용자가 지적했습니다. 2026년 8월 12일입니다.

강제 로그아웃과 누른 로그아웃이 다릅니다

고침은 두 로그아웃을 구분하는 것이었습니다.

// DatabaseLocation.swift:48-51
public static func launchUserID(sessionUserID: String?) -> String? {
    if let sessionUserID, !sessionUserID.isEmpty { return sessionUserID }
    return sharedActiveUserID
}

세션이 있으면 그것을 씁니다. 없으면 직전 스코프를 이어받습니다.

직전 스코프는 App Group의 UserDefaults에 있습니다. 세션과 별개로 삽니다.

로그아웃 종류 세션 스코프 결과
강제 (토큰 만료) 지움 남김 같은 기록을 계속 봅니다
사용자가 누름 지움 지움 깨끗해집니다

강제 로그아웃은 스코프를 일부러 남깁니다. 로그아웃 처리 쪽 주석에 그 약속이 적혀 있습니다.

강제 로그아웃에서는 세션만 내린다… 사용자는 같은 기록을 계속 보고

기동 경로가 그 약속을 지킵니다. 사용자가 직접 누른 로그아웃만 스코프를 지웁니다.

두 곳이 같은 약속을 지켜야 했습니다

이 사고의 구조가 눈에 걸립니다.

로그아웃 처리는 "세션만 내리고 기록은 유지"를 지키고 있었습니다. 주석에도 있었습니다.

기동 경로가 그 약속을 몰랐습니다. 세션만 봤습니다.

약속이 한 곳에만 있으면 다른 곳이 깰 수 있습니다. 두 곳이 같은 값을 봐야 합니다.

다른 기기에서는 주소를 잃었습니다

첨부 파일이 사용자의 iCloud에 있습니다. 서버에는 어디 있는지만 있습니다.

새 기기에서 동기화하면 문제가 생겼습니다.

바이트를 부르는 데 세 값이 필요합니다.

무엇
record name iCloud의 어느 레코드인가
nonce 복호화에 필요
wrapped CK 감싸인 콘텐츠 키

셋이 서버의 manifest에만 있었습니다. 동기화가 지나가면 앱에 남지 않았습니다.

주소를 잃습니다. 새 기기에서 문서와 녹음이 열리지 않습니다.

셋을 한 값 타입으로 묶었습니다

마이그레이션으로 컬럼을 하나 추가했습니다. 세 개가 아니라 하나입니다.

item_attachment.cloudBlobRef  ← record name + nonce + wrapped CK

이유가 있습니다. 셋은 함께 오고 함께 쓰입니다. 하나만 있으면 아무것도 못 합니다.

컬럼 세 개로 두면 둘만 있는 상태가 표현 가능해집니다. 그 상태에서 무엇을 해야 하는지 정해야 합니다. 정할 것이 없습니다.

처음 구현은 cloudRecordName 하나만 넣었습니다. 복호화에 나머지 둘도 필요한 것을 커밋 전에 발견해 고쳤습니다.

감싸인 키는 기기에 둬도 안전하다고 판단했습니다

wrapped CK는 콘텐츠 키를 감싼 것입니다. 이걸 기기에 저장해도 되는지 판단이 필요했습니다.

서버가 이미 같은 값을 갖고 있습니다. 기기에 두는 것이 서버에 두는 것과 같은 수준입니다.

푸는 키는 다른 곳에 있습니다. 키체인에만 있고 서버도 기기 DB도 갖지 않습니다.

복구는 주소만 받고 바이트는 나중에 받습니다

새 기기에서 동기화할 때 첨부 바이트를 전부 받으면 오래 걸립니다.

주소만 받습니다. 바이트는 열 때 받습니다.

restoreAll     → 주소만 남긴다 (바이트를 건드리지 않음)
materializeBytes → 열 때 그 하나만 받는다

사진은 예외입니다

목록 카드가 사진을 썸네일로 그립니다. 바이트가 없으면 빈칸이 됩니다.

사용자가 목록을 스크롤하는데 빈칸이 있으면 그것이 로딩인지 실패인지 모릅니다.

그래서 사진만 복구 때 함께 받습니다.

못 받은 것과 못 받는 것을 구분합니다

주소가 없으면 실패로 돌려줍니다. 화면이 두 상태를 구분해야 합니다.

상태 화면
아직 안 받았다 스피너 ("받는 중")
받을 수 없다 사유 + 다시 시도

이 배선이 없어서 회귀가 있었습니다. 뷰어의 재시도 버튼이 재시도 카운터만 올리고 있었습니다.

없는 파일을 다시 여는 것이었습니다. 파일을 받아 오는 경로가 연결돼 있지 않았습니다.

문구는 이미 맞았습니다. "아직 이 기기로 내려받지 못했습니다"와 "다시 시도"가 있었습니다. 버튼이 아무것도 하지 않았습니다.

스키마를 추가하면 과거 스키마 테스트가 깨집니다

새 컬럼을 넣었더니 오래된 마이그레이션 테스트가 실패했습니다.

그 테스트는 옛 버전 테이블을 만들고 데이터를 넣은 뒤 마이그레이션이 보존하는지 봅니다.

데이터를 넣을 때 오늘의 모델을 썼습니다. 오늘의 모델에는 새 컬럼이 있습니다. 옛 테이블에는 없습니다.

같은 테스트의 다른 부분에는 이미 그 교훈이 주석으로 적혀 있었습니다. 이전에 한 번 깨진 기록이었습니다.

첨부 부분만 규칙 밖에 있었습니다. 한 번 배운 것을 파일 전체에 적용하지 않았습니다.