← iOS 개발자 CS 로드맵 9 / 35

09 · 데이터베이스와 영속성

데이터를 어디에·어떻게 저장할지 — 저장소 선택 기준부터 트랜잭션·인덱스·마이그레이션, 그리고 Core Data의 정체와 스레드 안전성까지
진행률
0 / 0 완료

데이터를 "어딘가에 남긴다"는 결정은 성능·보안·유지보수를 한꺼번에 좌우한다. 이 챕터는 저장소 선택 기준에서 출발해 트랜잭션·인덱스·마이그레이션 같은 DB 정본 개념을 iOS 스택에 매핑하고, 실무에서 오해가 가장 잦은 Core Data의 정체와 스레드 안전성으로 마무리한다.

Q1. UserDefaults, Keychain, File, Database는 어떤 기준으로 선택하는가?

🔑 30초 답변

네 축으로 판단한다. 민감도(토큰·비밀번호면 Keychain), 크기·구조(작은 key-value 설정은 UserDefaults), 형태(큰 blob·미디어·문서는 File), 쿼리·관계·대량(구조화된 조회가 필요하면 Database). 우선순위는 보안이 먼저다. 민감정보인지부터 걸러내고, 그다음 크기와 조회 요구로 좁힌다. UserDefaults는 값을 처음 읽는 시점에 해당 도메인을 통째로 캐시해 이후 계속 메모리에 상주시키고 plist는 암호화되지 않는다 — 큰 데이터·비밀에 부적합하다는 점을 기억하면 대부분의 선택이 갈린다.

CS 원리

영속성 저장소는 하나의 스펙트럼이다. 한쪽 끝에는 key-value 스토어(작은 값, 스키마 없음, 통째 로드), 다른 쪽 끝에는 구조화된 쿼리 엔진(스키마·인덱스·트랜잭션·부분 조회)이 있다. 그 사이에 파일 시스템(임의의 바이트 blob, OS가 이름·권한·암호화만 관리)과 보안 전용 스토어(OS가 키로 암호화하고 접근을 강제하는 비밀 저장소)가 놓인다. 선택은 결국 "이 데이터를 부분적으로/자주 질의하는가", "얼마나 크고 구조적인가", "노출되면 피해가 큰가"에 대한 답이다.

iOS에서는

UserDefaults~/Library/Preferences/<bundle-id>.plist에 저장되고 cfprefsd 데몬이 관리한다. 문제는 도메인 단위로 캐시된다는 점 — 값 하나를 읽어도 사실상 해당 도메인 전체가 메모리에 상주한다. 그래서 Apple 문서도 "적은 양의 데이터"로 한정한다. Keychain은 내부적으로 SQLite로 저장되지만 Keychain Services가 데이터 보호 클래스와 (기기에 따라) Secure Enclave로 암호화한다. kSecAttrAccessible* 로 "언제 접근 가능한지"를 강제할 수 있다. File은 샌드박스의 Documents/(사용자 데이터, 백업 대상)·Library/Caches/(재생성 가능, 백업 제외·시스템이 지울 수 있음)·tmp/로 나뉘고, 파일마다 데이터 보호 클래스를 붙일 수 있다. Database는 SQLite를 직접 쓰거나 그 위의 GRDB, 또는 Core Data / SwiftData로 접근한다(Realm은 2025년 9월 Atlas Device SDK EOL 이후 신규 채택 대상에서 빠졌고, 기존 프로젝트의 유지·이전 대상으로만 남았다).

실험 · 도구

시뮬레이터에서 저장 위치를 눈으로 확인하면 감이 온다. xcrun simctl get_app_container booted <bundle-id> data로 컨테이너 경로를 얻어 Library/Preferences/의 plist를 plutil -p로 열면 UserDefaults가 평문 XML/binary plist라는 걸 바로 본다. 반대로 Keychain 항목은 파일로 노출되지 않는다. 관찰 지표: plist 파일 크기 증가(= UserDefaults 오남용 신호), 앱 콜드스타트 시간.

토큰은 UserDefaults가 아니라 Keychain으로 (Swift)
// ❌ 토큰을 UserDefaults 에 — plist 평문, 백업·파일 접근으로 노출된다
UserDefaults.standard.set(accessToken, forKey: "token")

// ✅ Keychain — 데이터 보호로 암호화, 접근성 클래스로 노출 시점을 제한
let query: [String: Any] = [
    kSecClass as String:       kSecClassGenericPassword,
    kSecAttrAccount as String: "accessToken",
    kSecValueData as String:   Data(accessToken.utf8),
    // 첫 잠금 해제 후에만 접근, 이 기기에서만 (백업으로도 유출 안 됨)
    kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
SecItemAdd(query as CFDictionary, nil)
민감정보? (토큰·비밀번호·암호키) 🔐 Keychain 작은 key-value 설정값? UserDefaults 큰 blob·미디어·문서? File (FileManager) 구조화·쿼리·관계·대량? Database 위에서부터 검사 — 먼저 맞는 것이 이긴다 (보안 우선)
선택은 "무엇을 저장하는가"의 성격을 위에서부터 걸러내는 우선순위 문제다. 민감도를 가장 먼저 판단한다.

프로젝트 적용

토큰·리프레시토큰·PIN·암호화 키는 무조건 Keychain. UserDefaults·plist·평문 파일에 두지 않는다.

자격증명은 무조건 Keychain
import Foundation
import Security

// UserDefaults의 plist는 평문이다. 백업·파일 접근으로 그대로 읽힌다.
// 토큰·비밀번호·PIN·암호키는 예외 없이 Keychain으로 간다.

enum KeychainStore {
    static func save(_ value: String, account: String) throws {
        let data = Data(value.utf8)

        // 같은 account가 있으면 먼저 지운다 (SecItemAdd는 중복을 거부한다)
        let deleteQuery: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account
        ]
        SecItemDelete(deleteQuery as CFDictionary)

        let addQuery: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecValueData as String: data,
            // ✅ 접근성은 필요한 만큼만 좁게
            //    포그라운드에서만 쓰고 기기 이전도 필요 없다면 WhenPasscodeSetThisDeviceOnly가 가장 좁다
//    (단 패스코드 미설정 기기에는 저장 자체가 안 되고, 패스코드를 해제하면 항목이 삭제된다)
//    그 제약이 부담이면 WhenUnlockedThisDeviceOnly가 현실적인 차선이다
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
        ]
        let status = SecItemAdd(addQuery as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeychainError.status(status) }
    }

    static func read(account: String) throws -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        if status == errSecItemNotFound { return nil }
        guard status == errSecSuccess, let data = item as? Data else {
            throw KeychainError.status(status)
        }
        return String(data: data, encoding: .utf8)
    }
}

enum KeychainError: Error { case status(OSStatus) }

// ❌ 절대 하지 말 것
// UserDefaults.standard.set(accessToken, forKey: "token")
// try token.write(to: documentsURL, atomically: true, encoding: .utf8)

UserDefaults에는 수 KB 이내의 사용자 설정만. 이미지·JSON 응답·목록 캐시를 넣지 않는다(콜드스타트 지연 + 메모리 상주).

UserDefaults에는 작은 설정만
import Foundation

// UserDefaults는 값을 처음 읽는 시점에 그 도메인 plist 전체를 메모리로 읽어 캐시한다.
// 큰 값을 넣으면 콜드스타트가 느려지고 그만큼 계속 상주한다.

// ❌ 이런 것들을 넣으면 안 된다
func badUsage(_ feedJSON: Data, _ image: Data) {
    UserDefaults.standard.set(feedJSON, forKey: "feedCache")     // 수백 KB~MB
    UserDefaults.standard.set(image, forKey: "avatar")           // 이미지
    UserDefaults.standard.set(Array(repeating: "x", count: 10_000), forKey: "log")
}

// ✅ 수 KB 이내의 사용자 설정만
enum Settings {
    private static let defaults = UserDefaults.standard

    static var isDarkMode: Bool {
        get { defaults.bool(forKey: "isDarkMode") }
        set { defaults.set(newValue, forKey: "isDarkMode") }
    }

    static var lastSyncDate: Date? {
        get { defaults.object(forKey: "lastSync") as? Date }
        set { defaults.set(newValue, forKey: "lastSync") }
    }

    static var preferredLanguage: String {
        get { defaults.string(forKey: "language") ?? "ko" }
        set { defaults.set(newValue, forKey: "language") }
    }
}

// ✅ @AppStorage(SwiftUI)도 같은 저장소다 — 크기 기준은 동일하게 적용된다
// @AppStorage("isDarkMode") var isDarkMode = false

// ✅ 현재 크기를 확인해 보는 방법
func inspectSize() {
    guard let bundleID = Bundle.main.bundleIdentifier,
          let dict = UserDefaults.standard.persistentDomain(forName: bundleID),
          let data = try? PropertyListSerialization.data(
            fromPropertyList: dict, format: .binary, options: 0)
    else { return }
    print("UserDefaults 크기: \(data.count / 1024)KB")   // 수십 KB를 넘으면 점검
}

재생성 가능한 데이터는 Library/Cachestmp에 두고 Documents를 아끼며, 백업에서 빼야 하면 isExcludedFromBackup을 설정한다.

재생성 가능한 데이터는 Caches로, 백업 제외
import Foundation

// Documents는 백업되고 iCloud 용량을 먹는다.
// 다시 받을 수 있는 데이터를 여기 두면 사용자 백업이 불필요하게 커진다.

enum Storage {
    private static let fm = FileManager.default

    /// 사용자가 만든 유일본 — 백업 대상
    static var documents: URL {
        try! fm.url(for: .documentDirectory, in: .userDomainMask,
                    appropriateFor: nil, create: true)
    }

    /// 앱이 관리하는 영구 데이터(DB 등) — 백업되지만 사용자에겐 안 보인다
    static var applicationSupport: URL {
        try! fm.url(for: .applicationSupportDirectory, in: .userDomainMask,
                    appropriateFor: nil, create: true)
    }

    /// 재생성 가능 — 백업 제외, 공간 부족 시 OS가 지운다
    static var caches: URL {
        try! fm.url(for: .cachesDirectory, in: .userDomainMask,
                    appropriateFor: nil, create: true)
    }
}

// ✅ Documents에 큰 재생성 가능 파일을 둬야 한다면 백업에서 명시적으로 뺀다
func excludeFromBackup(_ url: URL) throws {
    var mutable = url
    var values = URLResourceValues()
    values.isExcludedFromBackup = true
    try mutable.setResourceValues(values)
}

// 사용 예
func setupPaths() throws {
    // 사용자가 그린 그림 → Documents (백업)
    let drawing = Storage.documents.appendingPathComponent("drawing.png")

    // 다운로드한 지도 타일 → Documents지만 백업 제외 (오프라인에 필요하므로 Caches는 위험)
    let tiles = Storage.documents.appendingPathComponent("tiles.db")
    try excludeFromBackup(tiles)

    // 이미지 썸네일 → Caches (없어져도 다시 만든다)
    let thumbs = Storage.caches.appendingPathComponent("thumbs")

    _ = (drawing, tiles, thumbs)
}

// ⚠️ 백업 제외 여부는 앱 심사에서도 본다. 재생성 가능한 큰 데이터를
//    Documents에 백업 대상으로 두면 리젝 사유가 될 수 있다.

"조회 조건이 생기기 시작"하면(정렬·필터·페이지네이션) 파일/UserDefaults를 억지로 붙들지 말고 Database로 승격한다.

조회 조건이 생기면 Database로 승격
import Foundation

// 파일·UserDefaults로 시작했다가 "정렬해서 보여 줘", "필터 걸어 줘",
// "페이지 단위로 잘라 줘"가 붙기 시작하면 그때가 DB로 옮길 시점이다.

// ❌ 파일 기반이 한계에 부딪히는 신호들
final class FileBasedStore {
    private var items: [Item] = []

    func load() throws {
        let data = try Data(contentsOf: url)
        items = try JSONDecoder().decode([Item].self, from: data)
        // 💥 신호 ①: 전체를 메모리에 올려야만 무엇이든 할 수 있다
    }

    func recent(limit: Int) -> [Item] {
        items.sorted { $0.date > $1.date }.prefix(limit).map { $0 }
        // 💥 신호 ②: 조회할 때마다 전체 정렬
    }

    func search(_ q: String) -> [Item] {
        items.filter { $0.title.contains(q) }
        // 💥 신호 ③: 전체 스캔. 인덱스가 없다.
    }

    func update(_ item: Item) throws {
        if let i = items.firstIndex(where: { $0.id == item.id }) { items[i] = item }
        try JSONEncoder().encode(items).write(to: url, options: .atomic)
        // 💥 신호 ④: 한 건 바꾸는 데 전체를 다시 쓴다
    }

    private let url = URL(fileURLWithPath: "/items.json")
}

// ✅ 위 신호가 둘 이상이면 DB로 옮긴다
//   · 정렬·필터·페이지네이션이 필요하다
//   · 전체를 메모리에 못 올릴 만큼 커진다
//   · 부분 갱신이 잦다
//   · 여러 화면이 같은 데이터를 다른 조건으로 본다
//   · 트랜잭션 정합성이 필요하다

// 선택 기준
//   객체 그래프·UI 자동 연동·CloudKit  → Core Data / SwiftData
//   복잡한 SQL·집계·명시적 스키마 통제 → GRDB / 직접 SQLite
//   단순 key-value가 크게 커진 경우     → SQLite 한 테이블도 충분하다

struct Item: Codable { let id: UUID; let title: String; let date: Date }
⚠️ 흔한 오해

"UserDefaults는 시스템이 저장하니 암호화된다"와 "Keychain 항목은 앱을 지우면 같이 지워진다"는 둘 다 틀리다. UserDefaults의 plist는 평문이고, Keychain 항목은 앱을 삭제해도 기본적으로 남는다(재설치 후 다시 읽힐 수 있어, 첫 실행에서 명시적으로 정리하는 팀도 있다).

🧒 쉽게 이해하기

집에 물건 넣을 자리를 고른다고 생각해 보세요. 도장이랑 통장은 금고에(Keychain), 짧은 메모는 냉장고 문에(UserDefaults), 이불이나 앨범 같은 부피 큰 짐은 창고 박스에(File), 책 수천 권은 번호를 매겨 서가에 꽂는다(Database). 순서가 중요하다. "이거 남이 보면 큰일 나나?"를 제일 먼저 묻고, 그다음에 크기와 "나중에 골라 찾아 쓸 일이 많은가"를 본다.

제일 많이 걸려 넘어지는 자리는 냉장고 문이다. 저기 붙은 메모는 한 장만 보려 해도 붙어 있는 종이가 통째로 눈앞에 펼쳐진다 — UserDefaults는 키 하나를 읽어도 그 도메인 plist 전체가 메모리에 올라온다. 그래서 메모지에 이미지나 목록 캐시까지 붙여 놓으면 앱 시작이 그만큼 늦어지고, 안 쓰는 동안에도 계속 자리를 차지한다. 게다가 냉장고 문 메모는 지나가던 사람도 그냥 읽는다. plist는 평문이라 토큰을 넣어두면 파일을 여는 것만으로 보인다. 비유가 어긋나는 곳도 하나 있다. 진짜 금고는 이사 가면 같이 따라가지만 Keychain 항목은 앱을 지워도 기기에 남는다.

키 하나만 읽어도 벌어지는 일 앱 콜드스타트 도메인 plist 를 통째로 로드 필요한 키가 하나뿐이어도 앱이 사는 내내 메모리 상주 설정값만 넣은 경우 · 수 KB 로드가 순식간, 메모리도 티가 안 남 → UserDefaults 가 잘 맞는 크기 이미지·목록 캐시까지 넣은 경우 · 수 MB 시작할 때마다 그만큼 읽고 기다림 → 콜드스타트 지연 + 상시 메모리 점유 plist 는 평문 — 토큰·비밀번호는 크기와 무관하게 애초에 이 자리가 아니다
UserDefaults 는 값 단위가 아니라 도메인 단위로 통째 로드된다. "작은 설정만"이라는 제약이 취향이 아니라 구조에서 나오는 이유다.

꼬리 질문

Keychain 항목이 앱 삭제 후에도 남는 걸 역이용/방어하려면 첫 실행 시 어떤 전략을 쓰나? App Group·Keychain Sharing이 걸리면 판단이 어떻게 달라지나?

방어 쪽은 흔히 UserDefaults에 "설치 완료" 플래그를 두고, 그게 없으면(=클린 설치) 첫 실행에서 관련 Keychain 항목을 SecItemDelete로 싹 지운다. Keychain은 앱 삭제에도 살아남지만 UserDefaults는 삭제 시 함께 지워지므로, 둘의 수명 차이를 감지 신호로 쓰는 것이다. 역이용 예로는 "재설치해도 로그인/구매 상태 유지", "기기 단위 디바이스 ID 고정" 같은 게 있다.

kSecAttrAccessGroup(Keychain Sharing)이나 App Group으로 항목을 공유하면 판단이 달라진다. 삭제 대상 항목이 다른 앱/확장과 공유되는 그룹에 속하면 나 혼자 지우는 게 다른 멤버에게 영향을 주므로, 삭제 범위를 accessGroup으로 명확히 한정하거나 소유권 규칙을 팀 차원에서 합의해야 한다.

쉽게 말하면 Keychain은 앱을 지워도 서랍에 남는 물건이고, UserDefaults는 앱과 함께 버려지는 메모지다. 그래서 첫 실행 때 메모지가 없으면 "아 이거 새로 깐 거구나" 하고 서랍을 비우는 것이다. 단 그 서랍을 옆집(다른 앱·확장)과 같이 쓰는 중이라면 내 맘대로 비우는 순간 남의 물건까지 없어진다.
App Group으로 위젯·확장과 공유해야 하는 설정값이라면 UserDefaults·File·Database 중 무엇이 어떻게 달라지나?

셋 다 App Group 컨테이너 경로를 써야 공유된다. UserDefaultsUserDefaults(suiteName:)로 그룹 suite를 열어야 하고(기본 .standard는 앱 전용이라 위젯이 못 본다), File은 FileManager.containerURL(forSecurityApplicationGroupIdentifier:)가 준 경로에 두며, SQLite/Core Data도 그 컨테이너 안에 store 파일을 둔다.

차이의 핵심은 동시 쓰기다. 앱과 확장이 동시에 실행될 수 있어(위젯 갱신 중 앱도 켜져 있음), 공유 UserDefaults나 파일은 락 없는 경합에 취약하다. 반면 SQLite는 WAL로 다중 reader/단일 writer 조율을 이미 해주므로, 확장과 자주 함께 쓰는 구조화 데이터라면 파일보다 낫다. 단 공유 컨테이너의 SQLite는 락을 쥔 채 서스펜드되면 OS가 0xdead10cc로 앱을 죽이므로, 쓰기는 짧게 끊고 beginBackgroundTask(앱)·beginActivity(확장)로 락 해제까지의 시간을 확보해야 한다. 파일이면 NSFileCoordinator로 조정해야 한다.

쉽게 말하면 앱과 위젯은 각자 방을 쓰는 형제라, 공용 거실(App Group 컨테이너)에 물건을 둬야 서로 볼 수 있다. 진짜 문제는 둘이 동시에 깨어 있을 수 있다는 것 — 공용 노트에 둘이 같이 쓰면 글씨가 겹친다. SQLite는 "쓰는 사람은 한 번에 하나"라는 순번 규칙을 스스로 갖고 있어서 이 상황에 덜 위험하다.
기기가 잠긴 상태(백그라운드 페치 등)에서도 데이터를 읽어야 한다면 데이터 보호 클래스를 어떻게 고르나?

가장 강한 파일 보호인 NSFileProtectionComplete나 Keychain의 기본값인 ...WhenUnlocked는 화면이 잠기면 키가 메모리에서 내려가 복호화가 막힌다. 백그라운드에서 잠금 상태로 접근해야 하면 NSFileProtectionCompleteUntilFirstUserAuthentication(부팅 후 한 번 잠금 해제하면 이후 잠겨도 접근 가능)이나 Keychain의 kSecAttrAccessibleAfterFirstUnlock을 쓴다.

다만 이건 보안과의 트레이드오프다. "잠금 후에도 접근 가능"은 곧 도난 기기에서 잠긴 채로도 데이터가 복호화될 수 있다는 뜻이므로, 정말 백그라운드에서 필요한 최소 데이터에만 완화된 클래스를 주고 토큰 같은 민감 항목은 가급적 WhenUnlocked로 조여둔다.

쉽게 말하면 화면을 잠그는 건 금고 열쇠를 잠깐 회수해 가는 것과 같다. 열쇠가 없으니 백그라운드 작업도 파일을 못 연다. AfterFirstUnlock은 "부팅하고 한 번만 열어 주면 그다음엔 열쇠를 계속 갖고 있겠다"는 뜻이라 편한 대신, 폰을 잃어버려도 잠긴 채로 그 데이터는 읽힌다. 그래서 꼭 필요한 것만 여기 둔다.

Q2. Transaction과 ACID는 무엇인가?

🔑 30초 답변

트랜잭션은 여러 연산을 하나의 논리적 단위로 묶어 "전부 반영되거나 전부 무효"가 되게 하는 장치다. 그 보장을 네 글자로 요약한 게 ACID이다. 원자성(all-or-nothing), 일관성(제약을 깨지 않은 상태에서 상태로 이동), 격리성(동시 트랜잭션이 서로의 중간 상태를 안 봄), 지속성(커밋되면 크래시·정전에도 살아남음). SQLite는 저널·WAL로 이를 구현하고, Core Data의 save()도 내부적으로 하나의 트랜잭션이다.

CS 원리

ACID는 각각 다른 실패로부터 지킨다. Atomicity는 "돈 빼기"와 "돈 넣기" 사이에서 프로세스가 죽어도 반쪽 상태가 남지 않게 한다 — 커밋 전 로그(undo)로 되돌린다. Consistency는 외래키·유니크·NOT NULL 같은 불변식을 트랜잭션 경계에서 항상 만족시킨다. Isolation은 동시 실행을 마치 순차 실행처럼 보이게 하는 정도이며, 격리 수준(Read Committed, Repeatable Read, Serializable 등)에 따라 dirty read·non-repeatable read·phantom read가 허용되는지가 달라진다. Durability는 커밋 응답을 받은 데이터가 저장 매체에 안전히 내려간 것(fsync)을 의미한다. 구현의 핵심은 Write-Ahead Logging — 데이터 페이지를 덮어쓰기 전에 변경 기록을 먼저 로그에 안전하게 써두어, 크래시 후 재시작 때 redo/undo로 일관 상태를 복구한다.

iOS에서는

iOS 영속성의 바닥에는 대개 SQLite 트랜잭션이 있다. SQLite는 기본적으로 원자적 커밋을 보장하고, WAL 모드에서는 변경을 별도 -wal 파일에 먼저 쓴 뒤 체크포인트 때 본 DB에 반영한다. WAL의 실질적 이점은 동시성 — 다수의 reader와 한 명의 writer가 서로를 막지 않는다. Core Data는 iOS 7부터 SQLite 스토어에 WAL을 기본으로 쓴다. NSManagedObjectContext.save()는 그 컨텍스트에 쌓인 변경을 한 트랜잭션으로 커밋하므로, 저장이 실패하면 부분 반영 없이 오류를 던진다. GRDB는 이를 try dbQueue.write { db in ... } 클로저로 명시적으로 감싸, 클로저가 정상 종료하면 커밋, throw하면 롤백한다.

계좌 이체 — 원자성이 없으면 돈이 사라진다 (SQL)
BEGIN;                                                   -- 트랜잭션 시작
UPDATE account SET balance = balance - 100 WHERE id = 1;  -- 출금
UPDATE account SET balance = balance + 100 WHERE id = 2;  -- 입금
COMMIT;                                                   -- 둘 다 반영(원자성)
-- 두 UPDATE 사이에서 앱이 죽어도, 커밋 전이면 SQLite 가 자동 ROLLBACK →
-- "출금은 됐는데 입금은 안 된" 반쪽 상태가 남지 않는다.

실험 · 도구

Core Data가 실제로 WAL을 쓰는지 확인해 보세요. 스토어 폴더에 Model.sqlite 옆으로 Model.sqlite-wal-shm이 함께 생기면 WAL 모드다. sqlite3 Model.sqlite "PRAGMA journal_mode;"wal을 반환하는지 본다. 격리 실험은 두 연결을 열고 한쪽에서 BEGIN; UPDATE ... 후 커밋하지 않은 상태로 다른 연결에서 같은 행을 읽어, 커밋 전 값이 보이는지(dirty read 여부)를 관찰한다. SQLite는 dirty read를 허용하지 않으므로 이전 값이 보여야 정상이다.

프로젝트 적용

"읽고-계산하고-쓰기"가 하나의 정합성 단위면 반드시 한 트랜잭션으로 묶는다. 개별 save()를 여러 번 나누면 중간 크래시에 반쪽 상태가 남는다.

정합성 단위를 하나의 트랜잭션으로
import CoreData

// "읽고-계산하고-쓰기"가 하나의 의미 단위면 반드시 한 트랜잭션이어야 한다.
// 중간에 죽으면 반쪽 상태가 남는다.

final class TransferService {
    private let container: NSPersistentContainer
    init(container: NSPersistentContainer) { self.container = container }

    // ❌ 개별 save를 두 번 — 사이에 크래시하면 돈이 사라진다
    func transferBad(from: NSManagedObjectID, to: NSManagedObjectID, amount: Int) throws {
        let ctx = container.newBackgroundContext()
        try ctx.performAndWait {
            let a = try ctx.existingObject(with: from) as! Account
            a.balance -= Int64(amount)
            try ctx.save()                    // 💥 여기서 죽으면 출금만 반영

            let b = try ctx.existingObject(with: to) as! Account
            b.balance += Int64(amount)
            try ctx.save()
        }
    }

    // ✅ 하나의 save = 하나의 트랜잭션
    func transfer(from: NSManagedObjectID, to: NSManagedObjectID, amount: Int) async throws {
        let ctx = container.newBackgroundContext()
        try await ctx.perform {
            let a = try ctx.existingObject(with: from) as! Account
            let b = try ctx.existingObject(with: to) as! Account

            guard a.balance >= Int64(amount) else { throw BankError.insufficient }

            a.balance -= Int64(amount)
            b.balance += Int64(amount)

            try ctx.save()                    // ✅ 둘 다 반영되거나 둘 다 무효
        }
    }
}

// ✅ 실패 시 컨텍스트를 되돌린다
func withRollback(_ ctx: NSManagedObjectContext, _ body: () throws -> Void) throws {
    do { try body(); try ctx.save() }
    catch { ctx.rollback(); throw error }     // 미저장 변경을 버린다
}

final class Account: NSManagedObject { @NSManaged var balance: Int64 }
enum BankError: Error { case insufficient }

대량 삽입은 행마다 커밋하지 말고 한 트랜잭션으로 묶어라 — 커밋마다 발생하는 fsync 비용이 수백 배 차이를 만든다.

대량 삽입은 배치로 묶기
import CoreData

// 커밋마다 fsync가 붙는다. 행마다 커밋하면 그 비용이 행 수만큼 곱해진다.

final class Importer {
    private let container: NSPersistentContainer
    init(container: NSPersistentContainer) { self.container = container }

    // ❌ 행마다 save — 10만 건이면 10만 번의 fsync
    func importBad(_ rows: [RowDTO]) async throws {
        let ctx = container.newBackgroundContext()
        try await ctx.perform {
            for row in rows {
                let e = Row(context: ctx)
                e.id = row.id
                try ctx.save()                      // 💥
            }
        }
    }

    // ✅ 청크 단위로 커밋 — 트랜잭션 크기와 메모리의 균형점
    func importBatched(_ rows: [RowDTO], batchSize: Int = 5_000) async throws {
        let ctx = container.newBackgroundContext()
        ctx.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

        try await ctx.perform {
            for (i, row) in rows.enumerated() {
                let e = Row(context: ctx)
                e.id = row.id

                if (i + 1) % batchSize == 0 {
                    try ctx.save()
                    ctx.reset()                     // ✅ 메모리도 함께 비운다
                }
            }
            try ctx.save()
        }
    }

    // ✅ 더 빠른 경로: NSBatchInsertRequest (객체를 만들지 않고 SQL로 직행)
    func importFast(_ rows: [RowDTO]) async throws {
        let ctx = container.newBackgroundContext()
        try await ctx.perform {
            var index = 0
            let request = NSBatchInsertRequest(entity: Row.entity()) {
                (dict: NSMutableDictionary) -> Bool in
                guard index < rows.count else { return true }   // true면 종료
                dict["id"] = rows[index].id
                index += 1
                return false
            }
            request.resultType = .objectIDs
            try ctx.execute(request)
            // ⚠️ 검증·관계 설정·willSave를 건너뛴다. 단순 적재에만 쓴다.
        }
    }
}

struct RowDTO { let id: UUID }
final class Row: NSManagedObject { @NSManaged var id: UUID }

-wal이 계속 부풀면 체크포인트가 완료되지 못한다는 신호다. SQLite는 열려 있는 읽기 트랜잭션이 있으면 WAL을 리셋하지 못하므로, 오래 도는 fetch·쿼리 세대 고정(setQueryGenerationFrom)·끝나지 않은 작업 단위를 먼저 의심하고, 백그라운드 컨텍스트는 작업 단위로 만들어 끝나면 놓는다.

WAL이 계속 커지면 장수명 reader를 의심
import CoreData
import SQLite3

// WAL 모드에서 변경은 -wal 파일에 먼저 쌓이고, 체크포인트 때 본 DB로 옮겨진다.
// 그런데 오래된 reader가 붙어 있으면 체크포인트가 진행되지 못해 -wal이 무한히 커진다.

// ❌ 앱 시작부터 종료까지 백그라운드 컨텍스트를 열어 두는 패턴
final class BadStack {
    let container: NSPersistentContainer
    lazy var backgroundContext = container.newBackgroundContext()   // 영구 보유
    init(container: NSPersistentContainer) { self.container = container }
    // 이 컨텍스트가 스냅샷을 붙들고 있으면 체크포인트가 막힌다
}

// ✅ 작업 단위로 만들고 끝나면 놓는다
final class Stack {
    let container: NSPersistentContainer
    init(container: NSPersistentContainer) { self.container = container }

    func perform<T>(_ body: @escaping (NSManagedObjectContext) throws -> T) async throws -> T {
        let ctx = container.newBackgroundContext()      // 매번 새로
        return try await ctx.perform {
            defer { ctx.reset() }                        // 끝나면 비운다
            return try body(ctx)
        }
    }
}

// ✅ 파일 크기를 주기적으로 확인한다
func walSize(storeURL: URL) -> Int {
    let wal = URL(fileURLWithPath: storeURL.path + "-wal")
    let attrs = try? FileManager.default.attributesOfItem(atPath: wal.path)
    return (attrs?[.size] as? Int) ?? 0
}

// ✅ 백그라운드 진입 시 강제 체크포인트로 정리한다
func checkpoint(_ container: NSPersistentContainer) {
    guard let store = container.persistentStoreCoordinator.persistentStores.first,
          let url = store.url else { return }
    // ⚠️ Apple은 Core Data가 만든 SQLite 스토어를 네이티브 sqlite3 API로 조작하지 말라고 명시한다
    //    (포맷이 private이고, Core Data가 연 상태에서는 SQLITE_BUSY로 조용히 실패한다).
    //    지원되는 방법은 스토어를 추가할 때 journal_mode 프라그마를 지정하는 것이다:
    //      desc.setOption(["journal_mode": "DELETE"] as NSDictionary, forKey: NSSQLitePragmasOption)
    //    이러면 Core Data가 -wal 내용을 본 파일로 병합한 뒤 롤백 저널로 동작한다(동시성은 포기).
    _ = (store, url)
}

// ⚠️ 앱이 -wal을 정리하지 않은 채 종료되면 다음 실행의 첫 열기가 느려진다.
⚠️ 흔한 오해

"SQLite는 파일 기반이라 동시성이 없다/트랜잭션이 약하다"는 오해다. SQLite는 완전한 ACID를 제공하며, WAL 모드에서 다중 reader + 단일 writer 동시성을 지원한다. 약한 건 "여러 writer 동시 쓰기"이지 트랜잭션 보장 자체가 아니다.

🧒 쉽게 이해하기

트랜잭션은 인터넷 쇼핑의 장바구니와 결제 버튼이다. 담고 빼고 수량 바꾸는 동안에는 아무것도 확정되지 않다가, 결제 버튼을 눌러야 그 묶음이 한꺼번에 확정된다. 누르기 직전에 인터넷이 끊기면 주문이 통째로 없던 일이 되지, "돈만 나가고 물건은 안 오는" 반쪽 상태로 굳지 않는다(원자성). 주문이 처리되는 동안 옆 사람이 내 장바구니 속을 못 들여다보는 게 격리성이고, "주문 완료" 문자가 온 뒤엔 쇼핑몰 서버가 껐다 켜져도 주문이 남아 있는 게 지속성이다.

WAL은 노트 필기 요령에 가깝다. 본문을 지우개로 직접 고치면 고치는 동안 그 쪽은 아무도 못 읽는다. 대신 고칠 내용을 포스트잇에 적어 뒤에 계속 붙여 두면, 쓰는 사람은 붙이기만 하고 읽는 사람은 본문에 포스트잇을 겹쳐 읽으면 되니 서로 안 막힌다. 한가할 때 포스트잇 내용을 본문에 옮겨 적고 떼는 게 체크포인트다. 그래서 -wal 파일이 계속 부풀면 "누가 옛날 쪽을 펴 둔 채 안 덮어서 포스트잇을 뗄 수가 없다"는 신호다.

Writer 1명 Model.sqlite-wal 변경을 뒤에 덧붙이기만 한다 체크포인트 — 모아서 옮기고 wal 비움 Model.sqlite (본 DB) 가끔 한 번에 반영 Reader 여럿 쓰기 본 파일은 건드리지 않는다 읽기 본 DB + wal 을 겹쳐 읽는다 → writer 와 서로 안 막힌다 reader 가 오래 붙잡고 있으면 체크포인트를 못 돌아 -wal 만 계속 커진다
WAL의 요점은 속도가 아니라 "쓰는 사람이 읽는 사람을 막지 않는 것". 대신 포스트잇(-wal)을 떼려면 옛 쪽을 펴 둔 reader가 없어야 한다.

꼬리 질문

격리 수준 중 Serializable이 가장 안전한데도 항상 쓰지 않는 이유는? 어떤 이상현상(anomaly)까지 허용할지 어떻게 결정하나?

Serializable은 동시 트랜잭션을 마치 한 줄로 세워 실행한 것과 똑같은 결과를 보장하지만, 그 대가로 락 경합이나 재시도(직렬화 실패 abort)가 늘어 동시성 처리량이 떨어진다. 안전과 속도의 트레이드오프라, 은행 잔액처럼 틀리면 안 되는 곳엔 강한 격리를, 조회 위주 화면엔 느슨한 격리를 쓴다.

결정은 "이 트랜잭션이 겪으면 실제로 곤란한 이상현상이 무엇인가"를 역으로 본다. dirty read(커밋 안 된 값을 읽음)조차 허용 못 하는지, non-repeatable read(같은 행을 두 번 읽는데 값이 바뀜)까지는 괜찮은지, phantom(범위 재조회 시 행이 늘어남)이 문제인지에 따라 필요한 최소 수준을 고른다. 참고로 SQLite는 격리 수준 다이얼이 없고, 파일 락으로 사실상 Serializable에 가깝게 동작한다.

쉽게 말하면 Serializable은 계산대를 하나만 열고 손님을 한 줄로 세우는 것이다. 계산 실수는 절대 안 나지만 줄이 길어진다. 그래서 잔액처럼 틀리면 큰일 나는 것만 그 줄에 세우고, 목록 화면처럼 잠깐 옛날 값이 보여도 괜찮은 건 여러 계산대로 흘려보낸다.
WAL 모드에서 -wal 파일이 계속 커진다면 원인과 해결책은 무엇인가? (체크포인트, 장수명 트랜잭션)

-wal은 본 DB에 아직 반영 안 된 변경을 모아두는 곳이고, 체크포인트 때 본 파일로 옮겨지며 비워진다. 무한히 커진다면 체크포인트가 못 도는 것이고, 가장 흔한 범인은 오래 열려 있는 reader/트랜잭션이다. reader가 특정 시점 스냅샷을 붙잡고 있으면 그 지점 이후의 WAL 프레임을 잘라낼 수 없다.

해결은 장수명 컨텍스트/커서를 닫아 체크포인트가 진행되게 하는 것이고, 필요하면 PRAGMA wal_checkpoint(TRUNCATE)로 강제 체크포인트를 건다. iOS에서는 백그라운드 NSManagedObjectContext를 앱 수명 내내 열어두는 패턴이 이 증상을 자주 만든다.

쉽게 말하면 포스트잇을 본문에 옮겨 적고 떼려는데, 누가 그 페이지를 계속 펴 놓고 안 비켜 주는 상황이다. 앱 켜질 때부터 안 닫고 붙들고 있는 백그라운드 컨텍스트가 딱 그 사람이다. 자리를 비켜 주면(다 쓰고 닫으면) 포스트잇은 알아서 정리된다.
Core Data에서 save() 한 번에 수만 건을 넣으면 무슨 문제가 생기고, 어떻게 배치로 쪼개면서도 원자성을 유지하나?

모든 객체가 저장 전까지 컨텍스트에 등록된 채 메모리에 상주하고, 변경 추적·유효성 검사·관계 관리 오버헤드가 건수에 비례해 쌓여 메모리 급증과 저장 지연이 난다. 심하면 앱 시작/전환 중 워치독에 걸려 종료된다.

보통 수천 건 단위로 끊어 save()context.reset()(또는 refreshAllObjects)으로 메모리를 비우며 진행한다. 다만 이렇게 배치마다 save()하면 각 배치가 독립 트랜잭션이라, 중간에 크래시가 나면 앞 배치만 반영된 상태가 남는다. "전부 아니면 전무"가 꼭 필요할 때, 값 가공이 없다면 컨텍스트를 거치지 않고 store에 직접 삽입하는 NSBatchInsertRequest를 쓴다 — 객체를 만들지 않아 메모리 문제를 근본적으로 피하면서, 그 자체가 하나의 원자적 연산이다. 값 가공 때문에 반드시 객체를 만들어야 한다면 전 건을 한 번의 save()로 묶어 메모리 부담을 감수하는 수밖에 없다. Core Data에는 '커밋하지 않은 채 배치별로 메모리만 비우는' 중간 옵션이 없기 때문이다.

쉽게 말하면 이삿짐을 한 번에 다 들면 허리가 나가고(메모리 폭발), 여러 번 나눠 옮기면 중간에 비가 왔을 때 절반만 옮겨진 상태로 남는다. Core Data엔 "나눠 들되 끝까지 손에서 안 놓기" 같은 중간 옵션이 없다. 그래서 둘 다 피하고 싶으면 짐을 아예 풀지 않고 통째로 넘기는 NSBatchInsertRequest를 쓴다.

Q3. Database Index는 검색을 어떻게 빠르게 만들며 어떤 비용이 있는가?

🔑 30초 답변

인덱스는 특정 컬럼을 미리 정렬해 둔 별도의 자료구조(주로 B-tree)이다. 정렬돼 있으니 이진 탐색처럼 값을 O(log n)에 찾아 들어가, 전체를 훑는 풀 스캔 O(n)을 대체한다. 대신 공짜가 아니다 — 행을 넣고·바꾸고·지울 때마다 인덱스도 갱신해야 하니 쓰기가 느려지고, 인덱스 자체가 디스크 공간을 먹는다. 그래서 "자주 조건·정렬·조인에 쓰는 컬럼"에만 선별적으로 건다.

CS 원리

인덱스 없이 WHERE room_id = 42를 처리하면 DB는 모든 행을 하나씩 검사한다(full table scan, O(n)). 인덱스는 그 컬럼의 값들을 정렬된 B-tree/B+tree로 유지한다. 트리 높이가 log(n)이라 몇 번의 비교로 원하는 값(또는 행 위치)에 도달한다. 정렬돼 있다는 성질 덕분에 범위 검색(BETWEEN), 정렬(ORDER BY), 접두 검색도 빨라진다. 비용의 본질은 중복된 진실이다 — 같은 데이터를 두 벌(테이블 + 인덱스) 유지하므로, 쓰기마다 양쪽을 맞춰야 하고(write amplification) 저장 공간이 늘어난다. 복합 인덱스는 컬럼 순서가 중요하다(맨 앞 컬럼부터 접두로만 활용 가능 — leftmost prefix 규칙).

인덱스 없음 · Full Scan 모든 행을 하나씩 검사 — O(n) 42 SCAN → 6번 비교 (n행 전부, 조기 종료 불가) 인덱스 있음 · B-tree 루트에서 몇 번만 하강 — O(log n) 30 | 60 10 | 20 40 | 50 70 | 80 42 ✓ SEARCH → 2~3번 비교
같은 값 42를 찾을 때, 풀 스캔은 모든 행을 훑지만 B-tree는 정렬을 이용해 몇 단계만에 도달한다. 대신 트리를 항상 최신으로 유지하는 쓰기 비용이 생긴다.

iOS에서는

SQLite는 테이블과 인덱스를 모두 B-tree 계열로 구현한다. 어떤 인덱스를 쓸지(또는 못 쓸지)는 EXPLAIN QUERY PLAN으로 확인한다. Core Data에서는 엔티티 속성에 인덱스(fetch index)를 모델 에디터에서 지정하고, 복합 인덱스도 만들 수 있다. SwiftData는 @Model#Index·#Unique 매크로로 선언한다. 요점은 어느 계층을 쓰든 결국 바닥의 SQLite 인덱스로 내려간다는 것이다.

인덱스 유무를 쿼리 플랜으로 확인 (SQLite CLI)
sqlite> EXPLAIN QUERY PLAN SELECT * FROM message WHERE room_id = 42;
--  SCAN message                                    ← 풀 스캔 (O(n))

CREATE INDEX idx_message_room ON message(room_id);

sqlite> EXPLAIN QUERY PLAN SELECT * FROM message WHERE room_id = 42;
--  SEARCH message USING INDEX idx_message_room (room_id=?)   ← 인덱스 탐색 (O(log n))

실험 · 도구

수만 건을 넣은 테이블에서 인덱스 생성 전후로 같은 쿼리의 EXPLAIN QUERY PLANSCANSEARCH ... USING INDEX로 바뀌는지 보고, 실제 실행 시간을 잰다. 반대 실험도 중요하다 — 인덱스를 여러 개 건 테이블에 대량 INSERT를 돌려 삽입 시간이 인덱스 수에 비례해 늘어나는지 측정하면 "쓰기 비용"이 체감된다. Core Data라면 Instruments의 Core Data 템플릿으로 fetch가 실제로 몇 건을 훑는지 관찰한다.

프로젝트 적용

인덱스는 읽기 패턴을 보고 건다. WHERE·ORDER BY·JOIN·외래키에 자주 쓰이는 컬럼이 후보다.

읽기 패턴을 보고 인덱스를 건다
-- 인덱스는 "어떤 쿼리를 빠르게 할 것인가"에서 출발한다.
-- 후보는 WHERE · ORDER BY · JOIN · 외래키에 자주 등장하는 컬럼이다.

-- ① 먼저 실제로 도는 쿼리를 모은다
SELECT * FROM message WHERE room_id = ? ORDER BY created_at DESC LIMIT 20;
SELECT COUNT(*) FROM message WHERE room_id = ? AND is_read = 0;
SELECT * FROM message WHERE sender_id = ? ORDER BY created_at DESC;

-- ② 실행 계획을 확인한다 — SCAN이 보이면 인덱스가 안 쓰이고 있다
EXPLAIN QUERY PLAN
SELECT * FROM message WHERE room_id = 42 ORDER BY created_at DESC LIMIT 20;
--  SCAN message                                   ← 전체 훑기 O(n)

-- ③ 인덱스를 걸고 다시 확인한다
CREATE INDEX idx_message_room_created ON message(room_id, created_at DESC);

EXPLAIN QUERY PLAN
SELECT * FROM message WHERE room_id = 42 ORDER BY created_at DESC LIMIT 20;
--  SEARCH message USING INDEX idx_message_room_created (room_id=?)   ← 적용됨
--  ORDER BY 도 인덱스 순서로 해결되어 정렬 단계가 사라진다

-- ④ 외래키에는 거의 항상 인덱스가 필요하다
--    (SQLite는 외래키에 자동으로 인덱스를 만들지 않는다)
CREATE INDEX idx_message_sender ON message(sender_id);

-- ⑤ 통계를 갱신해 옵티마이저가 좋은 선택을 하게 한다
ANALYZE;

-- ⚠️ 컬럼을 함수로 감싸면 그 컬럼의 '일반' 인덱스는 못 쓴다 (SQLite 3.9.0+의 표현식 인덱스 CREATE INDEX ... ON user(lower(email))를 만들고 WHERE절에 글자 그대로 같은 표현식을 쓰면 탄다)
-- ❌ SELECT * FROM user WHERE lower(email) = ?;
-- ✅ 정규화한 컬럼을 따로 두고 거기에 인덱스를 건다
ALTER TABLE user ADD COLUMN email_lower TEXT;
CREATE INDEX idx_user_email_lower ON user(email_lower);

복합 인덱스는 컬럼 순서가 성능을 좌우한다. 등호(=)로 걸리는 컬럼을 앞에, 범위·정렬용 컬럼을 뒤에 두고, 여러 쿼리에서 공통으로 쓰이는 컬럼을 선두에 둔다(leftmost prefix). "선택도 높은 컬럼을 무조건 앞에"는 흔한 미신이니 실제 쿼리 조건 모양에 맞춘다.

복합 인덱스는 컬럼 순서가 성능을 정한다
-- 복합 인덱스는 '왼쪽부터 연속으로' 쓸 수 있다(leftmost prefix).
-- (a, b, c) 인덱스는 (a), (a,b), (a,b,c) 조회에는 쓰이지만 (b), (c)에는 안 쓰인다.

CREATE INDEX idx_msg ON message(room_id, is_read, created_at);

-- ✅ 쓰인다
SELECT * FROM message WHERE room_id = 1;
SELECT * FROM message WHERE room_id = 1 AND is_read = 0;
SELECT * FROM message WHERE room_id = 1 AND is_read = 0 ORDER BY created_at;

-- ❌ 사실상 못 쓴다 (선두 컬럼이 빠졌다. ANALYZE를 돌렸고 선두 컬럼 중복이 아주 많으면 skip-scan으로 탈 수도 있지만 설계 기준으로 삼을 경로는 아니다)
SELECT * FROM message WHERE is_read = 0;
SELECT * FROM message ORDER BY created_at;

-- 순서를 정하는 규칙
--   ① 등호(=)로 걸리는 컬럼을 앞에
--   ② 범위(<, >, BETWEEN)·정렬용 컬럼을 뒤에
--   ③ 여러 쿼리에서 공통으로 쓰는 컬럼을 선두에

-- ❌ 범위 컬럼을 앞에 두면 뒤 컬럼이 무력해진다
CREATE INDEX idx_bad ON message(created_at, room_id);
SELECT * FROM message WHERE created_at > ? AND room_id = 1;
--  created_at 범위로 좁힌 뒤 room_id는 그 안에서 스캔한다

-- ✅ 등호를 앞에
CREATE INDEX idx_good ON message(room_id, created_at);
SELECT * FROM message WHERE room_id = 1 AND created_at > ?;
--  room_id로 정확히 진입한 뒤 created_at 범위를 인덱스 순서로 훑는다

-- ⚠️ "선택도(cardinality)가 높은 컬럼을 무조건 앞에"는 흔한 미신이다.
--    실제 기준은 '그 쿼리에서 등호로 걸리는가'와 '여러 쿼리의 공통 접두인가'다.

-- ✅ 커버링 인덱스 — 필요한 컬럼이 전부 인덱스에 있으면 테이블 접근이 사라진다
CREATE INDEX idx_cover ON message(room_id, created_at, sender_id);
SELECT sender_id FROM message WHERE room_id = 1 ORDER BY created_at;
--  EXPLAIN에 "USING COVERING INDEX"가 뜬다

쓰기가 압도적으로 많은 테이블(로그·이벤트 적재)에 불필요한 인덱스를 남발하지 않는다.

쓰기가 많은 테이블에 인덱스를 남발하지 않기
-- 인덱스는 읽기를 위해 쓰기를 희생하는 거래다.
-- 행 하나를 넣을 때마다 모든 인덱스가 함께 갱신된다.

-- ❌ 로그·이벤트 적재 테이블에 인덱스 5개
CREATE TABLE event (
    id INTEGER PRIMARY KEY,
    type TEXT, user_id INTEGER, session_id TEXT,
    created_at INTEGER, payload TEXT
);
CREATE INDEX idx_e1 ON event(type);
CREATE INDEX idx_e2 ON event(user_id);
CREATE INDEX idx_e3 ON event(session_id);
CREATE INDEX idx_e4 ON event(created_at);
CREATE INDEX idx_e5 ON event(type, created_at);
--  INSERT 한 번에 B-tree 갱신 6회(테이블 + 인덱스 5) + 공간 수 배

-- ✅ 적재 중심 테이블은 인덱스를 최소로
--    조회는 별도의 집계 테이블이나 배치로 처리한다
CREATE TABLE event_lean (
    id INTEGER PRIMARY KEY,             -- rowid만
    type TEXT, created_at INTEGER, payload TEXT
);
CREATE INDEX idx_event_created ON event_lean(created_at);   -- 정리용 하나만

-- ✅ 대량 적재 전에는 인덱스를 잠시 떼는 것도 방법이다
DROP INDEX IF EXISTS idx_event_created;
-- … 수십만 건 INSERT (트랜잭션 하나로) …
CREATE INDEX idx_event_created ON event_lean(created_at);
ANALYZE;

-- ✅ 실제로 쓰이지 않는 인덱스를 찾아 지운다
--    쿼리 로그를 모아 EXPLAIN QUERY PLAN을 돌려 보고,
--    어떤 쿼리에도 등장하지 않는 인덱스는 순손실이다
SELECT name, tbl_name FROM sqlite_master WHERE type = 'index';

-- ✅ 부분 인덱스로 크기를 줄인다 (자주 조회하는 부분집합만)
CREATE INDEX idx_unread ON message(room_id) WHERE is_read = 0;
--  전체가 아니라 읽지 않은 행만 인덱싱 → 훨씬 작고 갱신도 적다
⚠️ 흔한 오해

"인덱스를 많이 걸수록 빨라진다"는 반쪽 진실이다. 읽기는 빨라질 수 있지만, 안 쓰는 인덱스는 쓰기를 느리게 하고 공간만 먹는 순손실이다. 또 인덱스가 있어도 함수·형변환으로 컬럼을 감싸면(WHERE lower(name)=...) 옵티마이저가 인덱스를 못 쓰는 경우가 많다.

🧒 쉽게 이해하기

교실 출석부는 번호순으로만 적혀 있다. 여기서 "김하늘"을 찾으려면 1번부터 한 명씩 이름을 확인해야 한다. 그래서 가나다순 명부를 한 장 더 만들어 두면, ㄱ 쪽만 펴서 몇 번 만에 찾고 "출석부 17번"이라는 위치만 읽어 오면 된다. 인덱스가 바로 이 두 번째 명부다. 원본을 대신하는 게 아니라, 원본으로 빨리 가는 지름길을 따로 적어 둔 종이다.

공짜는 아니다. 전학생이 한 명 오면 출석부에도 적고 가나다 명부에도 끼워 넣어야 한다. 명부를 이름순·생일순·주소순으로 세 장 만들어 뒀다면 학생 한 명 들어올 때마다 네 군데를 고쳐야 하고요 — 인덱스가 쓰기를 느리게 하는 이유가 이것이다. 그리고 가나다 명부가 있어도 "이름을 거꾸로 뒤집었을 때 ㄱ으로 시작하는 사람"을 찾으라고 하면 그 명부는 아무 소용이 없다. WHERE lower(name) = ...처럼 컬럼을 함수로 감싸면 인덱스를 못 쓰는 게 딱 이 상황이다. 복합 인덱스도 같은 결이다. 학년 다음 반 순으로 정렬한 명부는 "3학년"은 금방 찾아도 "전 학년의 2반"은 학년마다 흩어져 있어 훑어야 한다.

인덱스 0개 · INSERT 한 건 message 테이블에 행 추가 고칠 곳: 1군데 읽기는 매번 처음부터 훑어야 한다 인덱스 3개 · 똑같은 INSERT 한 건 message 테이블에 행 추가 idx(room_id) idx(sent_at) idx(sender) 고칠 곳: 4군데 — 인덱스마다 자리를 찾아 끼워 넣는다 비대칭이 핵심 읽기 이득은 그 인덱스를 실제로 타는 쿼리에서만 · 쓰기 비용은 INSERT·UPDATE·DELETE 마다 항상
인덱스를 하나 늘릴 때마다 같은 쓰기 한 번이 고쳐야 할 곳도 하나씩 늘어난다. 안 쓰이는 인덱스가 순손실인 이유.

꼬리 질문

커버링 인덱스(covering index)가 뭐고, 언제 테이블 접근 자체를 없앨 수 있나?

보통 인덱스로 행 위치를 찾은 뒤 실제 컬럼 값을 읽으려 테이블(본체)에 한 번 더 접근한다. 커버링 인덱스는 쿼리가 필요로 하는 모든 컬럼을 인덱스 자체가 이미 담고 있는 경우로, 인덱스만 읽고 답을 낼 수 있어 이 두 번째 접근(테이블 lookup)을 생략한다.

따라서 SELECT·WHERE·ORDER BY에 등장하는 컬럼 집합이 한 인덱스 안에 다 들어갈 때 발동한다. SQLite에서는 EXPLAIN QUERY PLANUSING COVERING INDEX로 표시된다. 대신 컬럼을 인덱스에 더 넣는 만큼 인덱스가 커지고 쓰기 비용이 오르므로, 정말 뜨거운 쿼리에만 맞춰 설계한다.

쉽게 말하면 가나다 명부에 이름만이 아니라 전화번호까지 적어 뒀다면, 누가 전화번호를 물어도 출석부(원본)를 펼 필요가 없다. 명부 한 장으로 답이 끝난다. 그게 커버링 인덱스다. 대신 명부에 칸을 늘린 만큼 명부가 두꺼워지고, 학생이 새로 올 때 적어 넣을 것도 늘어난다.
WHERE a = ? AND b > ? 쿼리에 (a, b) 인덱스는 잘 쓰이는데 (b, a)는 왜 그만큼 못 쓰이나? (두 조건이 모두 등호라면 순서가 성능을 가르지 않는 이유도 함께)

복합 인덱스는 사전 순서처럼 앞 컬럼으로 먼저 정렬하고, 같은 값 안에서 뒷 컬럼으로 정렬된다. (a, b)a = ?로 좁힌 구간 안에서 b가 정렬돼 있으니 b > ?를 연속 범위로 바로 훑는다. (b, a)b가 바깥 정렬 축이라, 특정 a를 찾으려면 b 값마다 흩어진 위치를 봐야 해 범위 스캔의 이점이 죽는다.

반면 두 조건이 모두 등호(a = ? AND b = ?)면 얘기가 다르다. 어느 순서든 두 컬럼을 다 등호로 고정하므로 인덱스 안에서 정확히 한 지점(또는 한 좁은 구간)으로 내려가고, 범위 축이 없어 순서가 성능을 가르지 않는다.

쉽게 말하면 학년 다음 반 순으로 꽂힌 명부에서 "3학년이면서 2반 이상"은 3학년 칸만 펴서 쭉 읽으면 끝난다. 반대로 반 다음 학년 순이면 3학년이 반마다 흩어져 있어 여기저기 뒤져야 하고요. 다만 "3학년 2반"처럼 둘 다 콕 집어 말하면 어느 순서로 꽂혀 있든 한 자리로 바로 간다.
인덱스가 오히려 안 쓰이는 게 나은 경우(테이블이 아주 작거나, 결과가 테이블의 대부분일 때)는 왜 그런가요?

인덱스는 "정렬된 별도 구조를 타고 들어가 위치를 찾고, 다시 본체에서 값을 읽는" 두 단계다. 테이블이 몇 페이지밖에 안 되면 그냥 통째로 읽는 풀 스캔이 이 왕복보다 싸서, 옵티마이저가 인덱스를 무시하는 게 정상이다.

결과가 테이블 대부분을 차지할 때도 마찬가지다. 이땐 인덱스로 대다수 행의 위치를 찾은 뒤 거의 모든 행에 대해 본체 lookup을 하는 꼴이라, 그 임의 접근(random I/O)이 순차 풀 스캔보다 느려진다. 그래서 옵티마이저는 통계(선택도)를 보고 "많이 걸릴 것 같으면" 인덱스를 안 쓰기도 한다.

쉽게 말하면 20명짜리 교실에서 한 명 찾겠다고 명부를 꺼내는 건 오히려 손해다. 그냥 한 바퀴 도는 게 빨라요. 반 전체를 다 불러야 할 때도 마찬가지다 — 명부에서 자리 번호 확인하고 그 자리로 왔다 갔다 하느니, 앞줄부터 순서대로 훑는 게 낫다.

Q4. Migration은 왜 필요하며 실패에 어떻게 대비하는가?

🔑 30초 답변

앱은 계속 바뀌는데 사용자 기기에는 옛 스키마로 저장된 데이터가 이미 있다. 마이그레이션은 그 기존 데이터를 지우지 않고 새 스키마로 옮기는 작업이다. Core Data는 단순 변경이면 lightweight(자동 추론) 마이그레이션을, 복잡하면 mapping model + 커스텀 코드를 쓴다. 실패 대비의 핵심은 커밋 전 원자성(중간에 죽어도 원본이 살아있게), 단계적(staged) 마이그레이션, 그리고 실제 사용자 데이터에 가까운 케이스로 업그레이드 경로를 테스트하는 것이다. 마이그레이션은 기본적으로 앞으로만 가므로 다운그레이드는 설계로 막는다.

CS 원리

데이터베이스 스키마는 시간이 지나며 진화한다(schema evolution). 문제는 코드는 배포로 한 번에 교체되지만 데이터는 각 기기에 과거 버전 그대로 남아 있다는 점이다. 마이그레이션은 "버전 N의 데이터 → 버전 N+1의 스키마"로 변환하는 함수다. 안전한 마이그레이션의 조건은 (1) 원자성 — 성공하면 새 스키마, 실패하면 원본 그대로여야 하고 반쪽 변환 상태가 남으면 안 된다. (2) 단조 전진 — 보통 롤백(다운 마이그레이션)은 유실 없이 되돌릴 수 없어 앞으로만 간다. (3) 멱등/재개 가능성 — 도중에 실패해도 다시 실행할 수 있어야 한다. 대량 데이터 변환은 메모리·시간이 커서, 여러 버전을 한 번에 건너뛰기보다 중간 단계를 거쳐(staged) 옮기는 편이 안전하다.

기존 데이터 Schema v1 v2 v3 현재 스키마 Schema v4 각 단계는 하나의 트랜잭션 — 성공하면 다음 단계로 커밋 실패하면? 단계 중간에 죽어도 원본 store 는 그대로 살아있다 → 백업 복원 후 재시도 (앞으로만 전진)
여러 버전을 한 번에 건너뛰기보다 v1→v2→v3→v4처럼 단계를 거치면 각 단계가 작고 검증 가능해 실패에 강하다.

iOS에서는

Core Data는 변경이 단순하면(속성 추가, 엔티티/관계 추가·삭제, renaming identifier로 이름 변경) lightweight migration으로 매핑을 자동 추론한다 — NSMigratePersistentStoresAutomaticallyOption·NSInferMappingModelAutomaticallyOption을 켠다. 속성 분해·병합처럼 추론 불가한 변경은 .xcmappingmodelNSEntityMigrationPolicyheavyweight migration을 쓴다. SwiftData는 SchemaMigrationPlanVersionedSchema로 버전을 명시하고, 각 MigrationStage.lightweight 또는 .custom(before/after 클로저)으로 정의한다. 순수 SQLite/GRDB라면 PRAGMA user_version이나 GRDB의 DatabaseMigrator로 버전을 추적하며 ALTER TABLE을 순서대로 적용한다.

SwiftData 버전 스키마 + 단계 마이그레이션 (Swift)
enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [Trip.self] }
}
enum SchemaV2: VersionedSchema {           // 새 속성 추가처럼 추론 가능한 변경만 lightweight다. 개명은 @Attribute(originalName:)이 필요하고, name을 title·subtitle로 쪼개는 값 가공은 .custom 스테이지가 필요하다
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] { [Trip.self] }
}

enum TripMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
    static var stages: [MigrationStage] {
        // 자동 추론 가능하면 lightweight, 데이터 가공이 필요하면 .custom(before:after:)
        [ .lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self) ]
    }
}

실험 · 도구

회귀 방지의 핵심은 구버전 store 파일을 테스트 픽스처로 보관하는 것이다. v1 시절의 실제 .sqlite를 테스트 번들에 넣어두고, 최신 코드로 열어 마이그레이션이 성공하고 데이터가 보존되는지 자동 테스트로 검증한다. 실기기에서는 이전 버전 앱으로 데이터를 만든 뒤 새 빌드를 덮어 설치해 콜드스타트에서 크래시 없이 열리는지 확인한다. 관찰 지표: 마이그레이션 소요 시간(대량 데이터에서 앱 시작 지연·워치독 종료 위험), 성공/실패율(원격 로깅).

프로젝트 적용

마이그레이션 직전에 store 파일을 백업하고, 실패 시 백업으로 복원한 뒤 재시도하거나 사용자에게 알린다. 그냥 스토어를 지워버리는 fallback은 데이터 유실이니 최후수단으로만.

마이그레이션 전 백업, 실패 시 복원
import CoreData

// 마이그레이션 실패의 기본 대응이 "스토어 삭제"면 그건 데이터 유실이다.
// 백업 → 시도 → 실패 시 복원의 순서를 반드시 만든다.

final class MigrationCoordinator {
    private let fm = FileManager.default
    private let storeURL: URL

    init(storeURL: URL) { self.storeURL = storeURL }

    private var backupURL: URL { storeURL.appendingPathExtension("backup") }

    func migrate(_ container: NSPersistentContainer) async throws {
        try makeBackup()

        do {
            try await load(container)
            try? removeBackup()                    // 성공하면 백업 정리
        } catch {
            try restoreBackup()                    // ✅ 실패하면 되돌린다
            throw MigrationError.failedAndRestored(underlying: error)
        }
    }

    private func makeBackup() throws {
        try? removeBackup()          // ✅ -wal, -shm 백업까지 전부 지우고 시작한다
        //    (backupURL 하나만 지우면 이전 크래시 잔재와 충돌해 copyItem이 throw한다)
        guard fm.fileExists(atPath: storeURL.path) else { return }
        // ⚠️ -wal, -shm도 함께 복사해야 일관된 백업이 된다
        for suffix in ["", "-wal", "-shm"] {
            let src = URL(fileURLWithPath: storeURL.path + suffix)
            let dst = URL(fileURLWithPath: backupURL.path + suffix)
            if fm.fileExists(atPath: src.path) { try fm.copyItem(at: src, to: dst) }
        }
    }

    private func restoreBackup() throws {
        for suffix in ["", "-wal", "-shm"] {
            let live = URL(fileURLWithPath: storeURL.path + suffix)
            let back = URL(fileURLWithPath: backupURL.path + suffix)
            try? fm.removeItem(at: live)
            if fm.fileExists(atPath: back.path) { try fm.moveItem(at: back, to: live) }
        }
    }

    private func removeBackup() throws {
        for suffix in ["", "-wal", "-shm"] {
            try? fm.removeItem(at: URL(fileURLWithPath: backupURL.path + suffix))
        }
    }

    private func load(_ c: NSPersistentContainer) async throws {
        try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
            c.loadPersistentStores { _, error in
                if let error { cont.resume(throwing: error) } else { cont.resume() }
            }
        }
    }
}

enum MigrationError: Error { case failedAndRestored(underlying: Error) }

큰 변환은 메인 스레드에서 하지 않는다. 시작 화면을 막고 백그라운드에서 진행 상태를 노출한다.

큰 변환은 메인 밖에서 진행 상태와 함께
import CoreData
import UIKit

// 수십만 건 변환을 메인에서 하면 워치독에 걸려 강제 종료된다.
// 백그라운드에서 진행하고 사용자에게 상태를 보여 준다.

@MainActor
final class MigrationViewController: UIViewController {
    private let progressView = UIProgressView()
    private let statusLabel = UILabel()

    func runMigration(_ coordinator: MigrationCoordinator) async {
        statusLabel.text = "데이터를 정리하는 중입니다…"
        progressView.progress = 0

        do {
            try await coordinator.migrate { [weak self] fraction in
                Task { @MainActor in self?.progressView.progress = Float(fraction) }
            }
            statusLabel.text = "완료"
            showMainScreen()
        } catch {
            // ✅ 실패해도 앱이 죽지 않고 안내로 이어진다
            statusLabel.text = "업그레이드에 실패했습니다."
            showRecoveryOptions(error)
        }
    }

    private func showMainScreen() {}
    private func showRecoveryOptions(_ e: Error) {}
}

// 진행률을 보고하며 변환한다
struct MigrationCoordinator {
    func migrate(onProgress: @escaping (Double) -> Void) async throws {
        let total = try countRows()
        var processed = 0

        try await withCheckedThrowingContinuation { cont in
            DispatchQueue.global(qos: .userInitiated).async {
                do {
                    try self.forEachBatch { batch in
                        try self.convert(batch)
                        processed += batch.count
                        onProgress(Double(processed) / Double(total))
                    }
                    cont.resume()
                } catch { cont.resume(throwing: error) }
            }
        }
    }

    private func countRows() throws -> Int { 0 }
    private func forEachBatch(_ body: ([Row]) throws -> Void) throws {}
    private func convert(_ batch: [Row]) throws {}
}

struct Row {}

버전을 건너뛰는 업그레이드(v1→v5)를 반드시 테스트한다. 실제 사용자는 몇 버전을 건너뛰고 온다.

버전 건너뛰기를 반드시 테스트
import CoreData
import XCTest

// 실제 사용자는 v1에서 바로 v5로 온다. v4→v5만 테스트하면 그 경로가 비어 있다.

final class MigrationTests: XCTestCase {

    /// 각 버전의 실제 스토어 파일을 픽스처로 보관해 두고 전부 돌린다
    func test_모든_버전에서_최신으로_마이그레이션된다() throws {
        let versions = ["v1", "v2", "v3", "v4"]

        for version in versions {
            let fixture = Bundle(for: Self.self)
                .url(forResource: "store-\(version)", withExtension: "sqlite")!
            let temp = FileManager.default.temporaryDirectory
                .appendingPathComponent("\(version)-\(UUID()).sqlite")
            try FileManager.default.copyItem(at: fixture, to: temp)

            let container = makeContainer(at: temp)
            try loadSynchronously(container)          // 여기서 마이그레이션이 돈다

            // ✅ 데이터가 살아남았는지도 확인한다 — "열렸다"만으로는 부족하다
            let ctx = container.viewContext
            let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Item")
    let count = try ctx.count(for: request)
            XCTAssertGreaterThan(count, 0, "\(version)에서 데이터가 유실됐다")

            try? FileManager.default.removeItem(at: temp)
        }
    }

    private func makeContainer(at url: URL) -> NSPersistentContainer {
        let c = NSPersistentContainer(name: "Model")
        let d = NSPersistentStoreDescription(url: url)
        d.shouldMigrateStoreAutomatically = true
        d.shouldInferMappingModelAutomatically = true
        c.persistentStoreDescriptions = [d]
        return c
    }

    private func loadSynchronously(_ c: NSPersistentContainer) throws {
        var loadError: Error?
        c.loadPersistentStores { _, e in loadError = e }
        if let loadError { throw loadError }
    }
}

// ✅ 픽스처를 만드는 방법: 각 릴리스 시점에 시뮬레이터에서 앱을 돌려
//    데이터를 넣고 .sqlite 파일을 꺼내 테스트 번들에 커밋해 둔다.
//    이게 마이그레이션 회귀를 잡는 가장 확실한 방법이다.

다운그레이드는 지원하지 않는 걸 전제로 설계한다(앞으로만 전진). 필요하면 새 필드는 nullable/기본값으로 추가해 하위 호환을 열어둔다.

다운그레이드는 지원하지 않는 전제로 설계
import CoreData

// 마이그레이션은 앞으로만 간다. 구버전 앱이 신버전 스토어를 열면
// 모르는 엔티티·속성 때문에 실패한다.

// ✅ ① 하위 호환 여지를 남기는 스키마 변경 규칙
//    · 새 속성은 optional 또는 기본값을 준다 → 구버전이 무시할 수 있다
//    · 기존 속성을 지우거나 이름을 바꾸는 건 파괴적 변경이다
//    · 엔티티 추가는 비교적 안전, 삭제는 위험

// ✅ ② 스토어에 스키마 버전을 명시적으로 기록한다
enum SchemaVersion {
    static let current = 5

    // ⚠️ 버전 마커는 스토어 '안'에 둔다 — UserDefaults에 두면 스토어만 교체·복원될 때
    //    (백업 복원, App Group 공유 스토어 교체) 다운그레이드 감지가 그대로 깨진다.
    private static let key = "appSchemaVersion"

    static func read(_ store: NSPersistentStore) -> Int {
        (store.metadata[key] as? Int) ?? 1
    }
    static func write(_ v: Int, to store: NSPersistentStore) {
        var md = store.metadata ?? [:]
        md[key] = v
        store.metadata = md          // 다음 save 시 스토어 파일에 함께 기록된다
    }
}

// ✅ ③ 구버전 앱이 신버전 스토어를 만나면 '명확히' 안내하고 멈춘다
func guardAgainstDowngrade() throws {
    let ctx = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
    let stored = SchemaVersion.read(ctx)
    guard stored <= SchemaVersion.current else {
        // 앱을 업데이트하라고 안내한다. 스토어를 지우면 안 된다.
        throw StoreError.storeIsNewerThanApp(stored: stored, app: SchemaVersion.current)
    }
}

enum StoreError: Error {
    case storeIsNewerThanApp(stored: Int, app: Int)
}

// ✅ ④ 정말 되돌려야 한다면 '데이터 내보내기'로 우회한다
//    구버전이 읽을 수 있는 중립 포맷(JSON)으로 내보내고 다시 가져오게 한다
struct ExportBundle: Codable {
    let schemaVersion: Int
    let items: [ItemDTO]
}
struct ItemDTO: Codable { let id: UUID; let title: String }

// ⚠️ TestFlight 사용자는 구버전과 신버전을 오간다. 개발 중에는 이 경로가
//    실제로 발생하므로, 최소한 "앱을 업데이트하세요" 안내는 만들어 둔다.
⚠️ 흔한 오해

"lightweight migration은 항상 자동으로 알아서 된다"는 오해다. 자동 추론은 단순·모호하지 않은 변경(속성/엔티티/관계 추가·삭제, 명시적 rename)에 한한다. 하나의 속성을 둘로 쪼개거나, 값을 변환하거나, 관계 방향을 바꾸는 등은 매핑 모델·커스텀 정책이 필요하며, 이를 빼먹으면 콜드스타트에서 스토어 열기가 실패해 앱이 죽는다.

🧒 쉽게 이해하기

학교 서류 양식이 바뀌었다고 해 봅시다. 새 양식은 오늘부터 쓰면 그만인데, 캐비닛에는 옛 양식으로 쓴 서류가 이미 수천 장 쌓여 있다. 그걸 버릴 수 없으니 새 양식으로 옮겨 적는 일이 마이그레이션이다. "전화번호 칸 하나 추가" 정도면 빈칸으로 두고 넘기면 되니 컴퓨터가 알아서 한다(lightweight migration, 변경을 자동으로 짐작해 옮기는 방식). 그런데 "이름 한 칸을 성과 이름 두 칸으로 쪼개라"는 어디서 잘라야 할지 컴퓨터가 알 수 없다. 사람이 규칙을 적어 줘야 하고, 그게 매핑 모델과 커스텀 정책이 필요한 경우다.

실패 대비는 규칙 하나로 요약된다. 옮겨 적는 동안 원본 서류를 절대 버리지 않는 것. 새 캐비닛에 전부 옮겨 넣고 맨 마지막에 이름표만 바꿔 달면, 옮기다 정전이 나도 원본이 그대로라 처음부터 다시 하면 그만이다. 반대로 원본을 지우개로 고쳐 가며 옮기면 반쯤 고쳐진 서류가 남아서 다음 시도가 더 꼬인다. 그리고 사용자는 몇 달 만에 앱을 열어 v1에서 v5로 한 번에 건너뛰기도 하니, 옛 양식 서류 한 뭉치를 표본으로 보관해 두고 배포 전마다 "이게 새 양식으로 제대로 옮겨지나"를 실제로 돌려 보는 게 제일 확실한 보험이다.

꼬리 질문

사용자가 v1에서 v5로 한 번에 올라올 때 순차(staged) 마이그레이션과 직접 매핑 중 무엇을, 왜 택하나?

일반적으로 순차(staged)가 유지보수에 유리하다. 각 단계(v1→v2, v2→v3…)를 한 번만 정의하면 v1→v5, v3→v5 등 모든 시작점을 조합으로 커버할 수 있고, 새 버전이 붙어도 단계 하나만 추가하면 된다. v1→v5 직접 매핑을 매 버전 쌍마다 만들면 조합이 폭발한다.

직접 매핑은 중간 단계를 다 거치는 게 비효율적일 때(예: v2에서 만들었다가 v4에서 없앤 컬럼을 두 번 손대는 낭비)나, 대량 데이터를 여러 번 다시 쓰는 비용이 클 때 성능 목적으로 예외적으로 쓴다. 기본은 순차, 병목이 측정되면 특정 경로만 직접 매핑으로 최적화하는 접근이 안전하다.

쉽게 말하면 1층에서 5층까지 계단을 한 층씩만 만들어 두면 몇 층에서 올라오든 그 계단을 이어 붙여 쓸 수 있다. 반대로 "1층→5층 직통", "3층→5층 직통" 사다리를 층 조합마다 따로 만들면 층이 하나 늘 때마다 사다리 개수가 확 불어난다. 그래서 기본은 계단이고, 특정 구간이 유난히 느릴 때만 거기에 직통 사다리를 단다.
마이그레이션 도중 앱이 강제 종료되면 다음 실행에서 무슨 일이 벌어지나? 재개 가능하게 하려면?

마이그레이션이 한 트랜잭션으로 잘 감싸여 있고 하나의 트랜잭션으로 감싸여 있으면(SQLite 스토어 + 추론 매핑이면 Core Data는 파일을 갈아 끼우지 않고 제자리에서 SQL 문만 발행한다), 커밋 전에 죽었으니 원본 store가 그대로 남고 다음 실행에서 처음부터 다시 시도한다. Core Data의 lightweight 마이그레이션이 대체로 이렇게 원자적이다.

위험한 건 원본을 제자리에서(in-place) 조금씩 고치는 방식인데, 이건 반쪽 변환 상태가 남아 재실행이 깨질 수 있다. 재개 가능하게 하려면 (1) 원본을 건드리지 않고 새 파일로 변환 후 원자적 rename으로 교체하거나, (2) 각 단계를 별도 트랜잭션으로 하고 PRAGMA user_version 같은 버전 마커를 커밋과 함께 올려 "어디까지 끝났는지"를 재시작 시 읽어 이어가게 한다.

쉽게 말하면 새 공책에 옮겨 적으면 중간에 정전이 나도 헌 공책이 멀쩡하니 처음부터 다시 하면 그만이다. 반대로 헌 공책을 지우개로 고쳐 가며 옮기면 반쯤 고쳐진 페이지가 남아서 다음 시도가 더 꼬여요. 어쩔 수 없이 나눠서 해야 한다면 "여기까지 끝냄"이라는 쪽번호(PRAGMA user_version)를 각 단계와 같이 남겨 두고 거기서부터 이어 간다.
서버 스키마와 로컬 캐시 스키마가 함께 바뀔 때 마이그레이션 순서와 하위 호환은 어떻게 조율하나?

핵심은 클라이언트 배포가 점진적이라 구/신 앱이 한동안 공존한다는 점이다. 그래서 서버는 보통 확장 우선(expand) 전략을 쓴다 — 새 필드는 optional로 추가하고 구 필드를 한동안 함께 내려주다가, 구버전 사용량이 충분히 빠진 뒤에야 구 필드를 제거(contract)한다. 이러면 구 앱은 새 필드를 무시하고, 신 앱은 새 필드를 읽는다.

로컬 캐시는 서버 응답을 담는 그릇이므로 서버 계약과 독립적으로 앞으로만 마이그레이션하되, 새 필드는 nullable/기본값으로 열어 하위 호환을 둔다. 최악의 경우 캐시는 재생성 가능한 데이터라, 스키마 충돌 시 캐시를 비우고 서버에서 다시 받는 fallback을 두면 된다(서버가 진실의 원천). 즉 서버는 하위 호환을 오래 유지하는 쪽, 캐시는 필요시 버릴 수 있는 쪽으로 역할을 나눈다.

쉽게 말하면 앱 업데이트는 모두가 같은 날 하지 않는다. 그래서 서버는 새 칸을 먼저 만들어 두고 옛 칸도 한동안 같이 내려준다. 옛날 앱 쓰는 사람이 거의 없어진 뒤에야 옛 칸을 떼고요. 로컬 캐시는 어차피 서버에서 다시 받아올 수 있는 사본이라, 정 꼬이면 지우고 새로 받으면 된다.

Q5. Core Data는 Database인가? 어떤 역할을 하는 Framework인가?

🔑 30초 답변

아니다. Core Data는 데이터베이스가 아니라 객체 그래프 관리 + 영속성 프레임워크이다. 메모리 안의 객체들과 그 관계(그래프)를 추적하고, 변경·되돌리기·유효성 검사·faulting(지연 로딩)을 관리하며, 그 상태를 저장소(persistent store)에 저장·복원한다. 저장소로 SQLite를 쓸 수 있을 뿐, in-memory·binary도 된다. 즉 SQLite는 Core Data가 고를 수 있는 백엔드의 하나이고, Core Data 자체는 SQL도 DB 엔진도 아니다. ORM과도 다르다 — 관계형 매핑이 아니라 객체 그래프 자체를 다룬다.

CS 원리

객체지향 메모리 모델(포인터로 연결된 객체 그래프)과 관계형 저장(테이블·행·외래키) 사이에는 임피던스 불일치(object-relational impedance mismatch)가 있다. 객체 그래프 관리 프레임워크는 이 간극을 메운다 — 개발자는 객체와 관계를 다루고, 프레임워크가 (1) 어떤 객체가 바뀌었는지 추적하고, (2) 필요할 때만 디스크에서 채워 넣고(lazy/faulting), (3) 같은 레코드는 메모리에서 한 인스턴스로 유지하며(uniquing/identity map), (4) 변경을 모아 저장소에 반영한다. 이건 "질의 엔진"의 일이 아니라 "메모리 상태와 저장 상태를 동기화"하는 일이다. 그래서 Core Data는 DB의 대체가 아니라 DB(또는 다른 저장소) 위의 계층이다.

NSPersistentContainer (스택 전체를 조립·소유) 앱 코드 · NSManagedObject 그래프 포인터로 연결된 객체들 (메모리) NSManagedObjectContext 변경 추적 · undo · faulting · save() 트랜잭션 NSPersistentStoreCoordinator Persistent Store SQLite · Binary · In-Memory (교체 가능) NSManagedObjectModel 스키마(.momd) 엔티티·속성·관계 정의 ↓ SQLite 파일 (.sqlite) — Core Data 는 이 아래로만 SQL 을 쓴다
Core Data 스택. 앱은 맨 위 객체 그래프만 다루고, 저장소(맨 아래)는 SQLite든 in-memory든 교체할 수 있다. 이 교체 가능성이 "Core Data ≠ 데이터베이스"의 증거다.

iOS에서는

스택은 네 조각이다. NSManagedObjectModel(스키마, .momd), NSPersistentStoreCoordinator(모델과 저장소를 잇는 중재자), NSManagedObjectContext(변경을 쌓는 작업 공간·scratchpad), 그리고 NSPersistentStore(실제 바이트가 사는 곳). iOS 10부터는 NSPersistentContainer가 이 조립을 대신해 준다. 핵심 관찰: 저장소 타입만 바꿔도 앱 코드(객체 그래프 API)는 그대로라는 점이 Core Data가 DB 엔진이 아님을 드러낸다.

저장소만 교체 — 같은 객체 그래프 API (Swift)
let container = NSPersistentContainer(name: "Model")
let desc = container.persistentStoreDescriptions.first!

// 저장소를 통째로 바꿔도 위층 코드(fetch/insert/save)는 그대로다.
desc.type = NSInMemoryStoreType     // 테스트용 (또는 NSSQLiteStoreType / NSBinaryStoreType)

container.loadPersistentStores { _, error in
    if let error { fatalError("store load 실패: \(error)") }
}
// → Core Data 는 "무엇으로 저장하느냐"와 "객체를 어떻게 다루느냐"를 분리한다.

프로젝트 적용

"복잡한 조인·집계·리포팅 쿼리가 중심"인 요구라면 Core Data보다 SQLite/GRDB의 직접 SQL이 잘 맞을 수 있다. Core Data의 강점은 객체 그래프·관계·UI 연동(NSFetchedResultsController)이다.

쿼리 중심이면 GRDB, 그래프 중심이면 Core Data
import Foundation

// 선택 기준은 "무엇이 중심인가"다.

// ✅ Core Data / SwiftData가 잘 맞는 경우
//    · 객체 그래프와 관계가 중심 (Post ↔ Author ↔ Comment)
//    · UI와 자동 연동이 필요 (NSFetchedResultsController, @FetchRequest, @Query)
//    · CloudKit 동기화
//    · undo/redo, 변경 추적
//    · 애플 생태계 통합을 공짜로 얻고 싶다

// ✅ GRDB / 직접 SQLite가 잘 맞는 경우
//    · 복잡한 조인·집계·리포팅 쿼리가 중심
//    · 예측 가능한 SQL 성능과 실행 계획 통제
//    · 스키마·마이그레이션을 명시적으로 관리
//    · 서버와 같은 SQL 지식을 재사용

// GRDB 예 — SQL이 그대로 드러난다
import GRDB

struct MonthlyStat: FetchableRecord, Decodable {
    let month: String
    let total: Int
    let orderCount: Int
}

func monthlyStats(_ db: Database) throws -> [MonthlyStat] {
    try MonthlyStat.fetchAll(db, sql: """
        SELECT strftime('%Y-%m', created_at) AS month,
               SUM(amount) AS total,
               COUNT(*)    AS orderCount
          FROM "order"
         WHERE status = 'completed'
         GROUP BY month
         ORDER BY month DESC
         LIMIT 12
        """)
}
// Core Data로 같은 걸 하려면 NSExpression·NSFetchRequest 조합이 필요하고
// 실행 계획을 통제하기 어렵다.

// ⚠️ 둘을 섞는 것도 가능하다 — 주 저장소는 Core Data, 통계는 별도 SQLite.
//    다만 두 스키마를 동기화하는 부담이 생기므로 신중히 결정한다.

테스트에서는 in-memory 스토어로 갈아끼워 디스크 없이 빠르게 돌린다. (기능을 온전히 쓰려면 NSInMemoryStoreType 대신 store URL을 /dev/null로 둬 SQLite in-memory로 여는 방식도 흔히 쓴다.)

테스트는 in-memory 스토어로
import CoreData
import XCTest

// 디스크를 쓰면 테스트가 느리고, 이전 테스트의 상태가 남아 서로 간섭한다.

enum TestStack {
    /// SQLite in-memory — 기능이 온전하고 빠르다
    static func makeContainer() -> NSPersistentContainer {
        let container = NSPersistentContainer(name: "Model")
        let description = NSPersistentStoreDescription()

        // ✅ /dev/null을 URL로 주면 SQLite in-memory로 열린다.
        //    NSInMemoryStoreType과 달리 실제 SQLite 동작(제약·인덱스·SQL)이 그대로다.
        description.url = URL(fileURLWithPath: "/dev/null")
        description.shouldAddStoreAsynchronously = false

        container.persistentStoreDescriptions = [description]
        container.loadPersistentStores { _, error in
            precondition(error == nil, "테스트 스토어 로드 실패: \(error!)")
        }
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        return container
    }
}

final class OrderTests: XCTestCase {
    private var container: NSPersistentContainer!

    override func setUp() {
        super.setUp()
        container = TestStack.makeContainer()      // ✅ 테스트마다 새 스토어
    }

    override func tearDown() {
        container = nil
        super.tearDown()
    }

    func test_주문_저장() throws {
        let ctx = container.viewContext
        let order = Order(context: ctx)
        order.id = UUID()
        try ctx.save()

        let count = try ctx.count(for: Order.fetchRequest())
        XCTAssertEqual(count, 1)
    }
}

// ⚠️ NSInMemoryStoreType은 SQLite가 아니라 별도 구현이라
//    제약·정렬·일부 predicate 동작이 실제와 다를 수 있다. /dev/null 방식이 안전하다.

final class Order: NSManagedObject {
    @NSManaged var id: UUID
    @nonobjc class func fetchRequest() -> NSFetchRequest<Order> {
        NSFetchRequest<Order>(entityName: "Order")
    }
}

Core Data를 "그냥 DB"로 취급해 raw SQL 감각으로 접근하면 faulting·context 수명·저장 타이밍에서 버그가 난다. 프레임워크의 모델을 존중한다.

프레임워크의 모델을 존중하기
import CoreData

// Core Data를 "SQL 래퍼"로 취급하면 faulting·컨텍스트 수명에서 버그가 난다.

// ⚠️ ① faulting — 관계를 순회하면 건마다 쿼리가 나간다 (N+1)
func nPlusOneProblem(_ ctx: NSManagedObjectContext) throws {
    let posts = try ctx.fetch(Post.fetchRequest())
    for post in posts {
        print(post.author?.name ?? "")     // 💥 post 개수만큼 추가 쿼리
    }
}

// ✅ 미리 채워 온다
func prefetched(_ ctx: NSManagedObjectContext) throws {
    let request = Post.fetchRequest()
    request.relationshipKeyPathsForPrefetching = ["author"]   // ✅ 한 번에
    let posts = try ctx.fetch(request)
    for post in posts { print(post.author?.name ?? "") }
}

// ✅ 개수만 필요하면 객체를 만들지 않는다
func countOnly(_ ctx: NSManagedObjectContext) throws -> Int {
    try ctx.count(for: Post.fetchRequest())    // 객체 생성 없이 SELECT COUNT
}

// ✅ 특정 컬럼만 필요하면 dictionary 결과로
func titlesOnly(_ ctx: NSManagedObjectContext) throws -> [String] {
    let request = NSFetchRequest<NSDictionary>(entityName: "Post")
    request.resultType = .dictionaryResultType
    request.propertiesToFetch = ["title"]
    return try ctx.fetch(request).compactMap { $0["title"] as? String }
}

// ⚠️ ② 저장 타이밍 — save()를 부르기 전까지는 아무것도 디스크에 없다
func saveTiming(_ ctx: NSManagedObjectContext) throws {
    let post = Post(context: ctx)
    post.title = "제목"
    // 여기서 앱이 죽으면 이 변경은 사라진다
    try ctx.save()                              // ✅ 여기서 트랜잭션이 커밋된다
}

// ⚠️ ③ 컨텍스트 수명 — 객체는 자기 컨텍스트에 묶여 있다
//    컨텍스트가 해제되면 그 객체들도 무효가 된다

final class Post: NSManagedObject {
    @NSManaged var title: String
    @NSManaged var author: Author?
    @nonobjc class func fetchRequest() -> NSFetchRequest<Post> {
        NSFetchRequest<Post>(entityName: "Post")
    }
}
final class Author: NSManagedObject { @NSManaged var name: String }
⚠️ 흔한 오해

"Core Data는 데이터베이스다" 혹은 "Core Data = SQLite 래퍼"는 정확하지 않다. Core Data는 객체 그래프·영속성 프레임워크이고 SQLite는 그것이 선택할 수 있는 저장소 중 하나다. 또 "Core Data는 ORM"이라는 말도 어긋난다 — 관계형 테이블을 객체로 매핑하는 게 목적이 아니라, 객체 그래프의 수명·관계·변경을 관리하는 게 목적이다.

🧒 쉽게 이해하기

Core Data는 워드 같은 문서 편집기고, SQLite 파일은 그 편집기가 저장할 때 쓰는 파일 형식이라고 보면 얼추 맞다. 편집기에서 글자를 아무리 고쳐도 저장 버튼을 누르기 전엔 파일은 그대로다. NSManagedObjectContext에 변경이 쌓여 있다가 save() 한 번에 내려가는 게 딱 그 모양이다. 그리고 저장 형식을 다른 걸로 바꾼다고 글 쓰는 방법이 달라지지는 않는다. Core Data도 저장소를 SQLite에서 in-memory로 갈아 끼워도 위층 코드는 손대지 않는데, 이 갈아 끼우기가 "Core Data는 데이터베이스가 아니다"의 가장 직접적인 증거다.

faulting(필요해질 때까지 값을 안 채워 두는 지연 로딩)도 같은 그림 안에서 보인다. 500쪽짜리 문서를 열면 편집기가 500쪽을 다 그려 놓지 않고 지금 보는 쪽만 그린다. Core Data도 객체 껍데기만 만들어 두었다가 실제로 속성을 읽는 순간 디스크에서 값을 채운다. 비유가 어긋나는 지점도 짚어 둔다. 워드는 파일을 통째로 읽고 통째로 다시 쓰지만, Core Data는 필요한 행만 골라 읽고 바뀐 것만 쓴다.

꼬리 질문

faulting(fault)이 뭐고, 이게 왜 "Core Data는 DB가 아니다"라는 성격과 연결되나?

fault는 아직 실제 값을 채우지 않은 자리표시 객체이다. Core Data는 관계나 객체를 미리 다 로드하지 않고, 껍데기만 만들어 두었다가 실제로 속성에 접근하는 순간 디스크에서 값을 채운다(fault를 fire한다). 지연 로딩으로 메모리·I/O를 아끼는 장치다.

이게 DB와 다른 성격을 드러낸다. 순수 DB라면 "쿼리를 날리면 결과 값이 나온다"가 전부지만, Core Data는 메모리 안 객체 그래프의 상태를 관리하는 게 본질이라, "지금 이 객체가 채워졌나/비었나"라는 개념 자체가 존재한다. 즉 faulting은 질의 엔진의 기능이 아니라 객체 그래프 수명 관리의 일부이고, 그래서 Core Data는 DB가 아니라 그 위의 그래프 관리 계층이라는 점과 맞물린다.

쉽게 말하면 fault는 이름표만 붙어 있고 속은 비어 있는 상자다. 열어 보려는 순간에야 창고에서 내용물을 가져다 채워 넣는다. 순수한 DB라면 "물어보면 답이 나온다"가 전부인데, Core Data는 상자마다 "채워졌나 비었나"라는 상태를 따로 들고 있다. 그 살림을 한다는 것 자체가 Core Data가 DB가 아니라 DB 위에 얹힌 관리자라는 표시다.
SwiftData는 Core Data와 어떤 관계인가? 내부적으로 무엇을 공유하나?

SwiftData는 Core Data를 대체하는 새 엔진이 아니라, 그 위에 얹은 Swift 친화적 현대 API이다. 내부적으로 Core Data의 스택(사실상 NSPersistentStore 계열과 SQLite 저장소)을 상당 부분 공유하고, @Model 매크로가 컴파일 타임에 모델 메타데이터를 생성해 NSManagedObjectModel에 해당하는 스키마를 만들어 준다.

그래서 개념 매핑이 거의 1:1이다 — ModelContextNSManagedObjectContext, ModelContainerNSPersistentContainer. 실제로 같은 store 파일을 Core Data와 SwiftData가 함께 쓰도록 상호운용도 지원한다. 차이는 KVC 기반 문자열 키·수동 스택 조립이 사라지고, 매크로와 타입 안전한 #Predicate로 바뀐 개발자 경험 쪽이다.

쉽게 말하면 SwiftData는 엔진은 그대로 두고 운전석만 새로 짠 차다. 핸들과 계기판(API)은 훨씬 편해졌는데 밑에서 도는 엔진과 연료통(SQLite 파일)은 Core Data가 쓰던 그것 그대로다. 그래서 같은 store 파일을 둘이 나눠 쓰는 것도 된다.
같은 요구를 GRDB(직접 SQL)로 풀 때와 Core Data로 풀 때, 결정을 가르는 기준은 무엇인가?

쿼리 중심이냐 객체 그래프 중심이냐가 가른다. 복잡한 조인·집계·리포팅, 예측 가능한 SQL 성능, 스키마와 마이그레이션을 명시적으로 통제하고 싶다면 GRDB가 잘 맞다. SQL을 그대로 쓰니 옵티마이저·인덱스를 직접 다루기 쉽고 학습 부채도 얕다.

반대로 서로 관계로 얽힌 객체 그래프를 다루고, UI와 자동으로 연동되는 관찰(NSFetchedResultsController·SwiftUI @FetchRequest·@Query)·CloudKit 동기화·undo/변경 추적이 필요하면 Core Data/SwiftData가 유리하다. 애플 생태계 통합을 공짜로 얻는 대신, faulting·컨텍스트 수명 같은 프레임워크 모델을 배워야 한다. 팀 숙련도와 "이 데이터가 뷰에 실시간으로 묶이는가"도 실무에선 큰 판단 요소다.

쉽게 말하면 GRDB는 수동 기어, Core Data는 자동 기능이 잔뜩 붙은 차다. 복잡한 집계 쿼리를 직접 짜고 성능을 눈으로 통제하고 싶으면 수동이 낫고, "데이터 바뀌면 화면도 알아서 갱신되고 iCloud로도 알아서 동기화"가 필요하면 자동 쪽이 압도적으로 편하다. 대신 자동 기능의 사용법(faulting, 컨텍스트 수명)을 익히는 시간이 그 값이다.

Q6. Core Data의 Context는 Thread-safe한가?

🔑 30초 답변

아니다. NSManagedObjectContext와 거기서 나온 NSManagedObject자신에게 묶인 큐/스레드에서만 만져야 한다(queue confinement — 특정 스레드에 묶던 구식 thread confinement, 즉 NSConfinementConcurrencyType은 iOS 9에서 폐기됐다). 그래서 컨텍스트 작업은 항상 perform { }(비동기)·performAndWait { }(동기)로 그 컨텍스트의 큐 위에서 실행한다. 스레드 경계를 넘겨야 할 땐 객체를 직접 넘기지 말고 NSManagedObjectID만 넘긴 뒤 상대 컨텍스트에서 다시 조회한다. main 큐용 컨텍스트(viewContext)와 백그라운드용 컨텍스트를 분리해 쓰는 게 정석이다.

CS 원리

동시성을 다루는 방법에는 락(공유 가변 상태를 잠금)과 confinement(가변 상태를 하나의 실행 맥락에 가둬 애초에 공유하지 않음)가 있다. Core Data는 후자를 택한다. 컨텍스트는 변경을 쌓아두는 가변 상태 덩어리라, 여러 스레드가 동시에 건드리면 내부 자료구조가 깨진다. 그래서 "이 컨텍스트에 대한 모든 접근은 지정된 하나의 큐에서 직렬로"라는 규칙을 강제하고, perform은 작업을 그 큐에 넣는 관문이다. 데이터를 다른 맥락으로 옮길 때 객체 자체는 특정 컨텍스트에 종속되므로 옮길 수 없고, 영속 식별자(어느 컨텍스트에서도 같은 레코드를 가리키는, 불변이고 스레드 안전한 식별자 — NSObject 서브클래스인 참조 타입)만 안전하게 넘어간다.

Main Queue · viewContext NSManagedObject UI 바인딩 · @FetchRequest · NSFetchedResultsController Private Queue · backgroundContext NSManagedObject 대량 import · 무거운 fetch (perform 안에서만) NSManagedObject 직접 전달 ❌ objectID 로만 ✓
각 컨텍스트는 자기 큐에 객체를 가둔다. 레인을 건너는 유일한 안전 통로는 불변·스레드 안전한 식별자 NSManagedObjectID이며, 객체 포인터를 그대로 넘기면 크래시·데이터 손상이 난다.

iOS에서는

컨텍스트는 생성 시 concurrency type을 정한다. .mainQueueConcurrencyType(UI용, container.viewContext)과 .privateQueueConcurrencyType(백그라운드용, newBackgroundContext() 또는 performBackgroundTask). 어느 쪽이든 접근은 NSManagedObjectContextperform(_:)/performAndWait(_:)로 감싼다. main 컨텍스트조차 예외는 UI 코드가 이미 main에서 도는 경우일 뿐, 원칙은 "그 컨텍스트의 큐 위에서". 변경을 컨텍스트 간에 반영하려면 automaticallyMergesChangesFromParentNSManagedObjectContextDidSave 알림을 쓴다. 자세한 패턴은 Using Core Data in the Background에 정리돼 있다.

경계 넘기기 — objectID로만, perform 안에서만 (Swift)
let bg = container.newBackgroundContext()
let objectID = person.objectID          // main 컨텍스트의 객체에서 ID만 추출 (불변·스레드 안전, 넘기기 안전)

bg.perform {                            // 반드시 이 컨텍스트의 큐 위에서 실행
    guard let obj = try? bg.existingObject(with: objectID) else { return }
    obj.setValue("done", forKey: "status")
    try? bg.save()                      // 이 save() 가 하나의 트랜잭션
}

// ❌ 절대 금지: 다른 스레드에서 만든 NSManagedObject 를 그대로 참조
// DispatchQueue.global().async { print(person.name) }   // objectID 아님 → 크래시/데이터 손상

실험 · 도구

규칙 위반을 런타임에 크래시로 잡아주는 도구가 있다. 스킴의 실행 인자에 -com.apple.CoreData.ConcurrencyDebug 1을 추가하면, 컨텍스트를 잘못된 큐에서 만지는 순간 즉시 트랩이 걸린다. 개발·CI에서 항상 켜 두면 confinement 위반을 조기에 발견한다. 여기에 Thread Sanitizer를 함께 켜면 Core Data 밖의 데이터 레이스도 잡힌다. 관찰 지표: ConcurrencyDebug 트랩 스택트레이스가 가리키는 "큐 밖 접근" 지점.

프로젝트 적용

컨텍스트를 지역 변수로 붙잡아 아무 스레드에서나 쓰지 않는다. 접근은 항상 그 컨텍스트의 perform 안에서.

컨텍스트 접근은 항상 perform 안에서
import CoreData

// NSManagedObjectContext와 거기서 나온 객체는 자기 큐에서만 만져야 한다.
// 이 규칙을 어기면 조용히 데이터가 손상되거나 무작위 크래시가 난다.

final class Repository {
    private let container: NSPersistentContainer
    init(container: NSPersistentContainer) { self.container = container }

    // ❌ 컨텍스트를 지역 변수로 붙잡아 아무 데서나 사용
    func updateBad(_ id: NSManagedObjectID) {
        let ctx = container.newBackgroundContext()
        DispatchQueue.global().async {
            let obj = try? ctx.existingObject(with: id)   // 💥 그 큐가 아니다
            obj?.setValue("done", forKey: "status")
            try? ctx.save()
        }
    }

    // ✅ 반드시 그 컨텍스트의 큐 위에서
    func update(_ id: NSManagedObjectID) async throws {
        let ctx = container.newBackgroundContext()
        try await ctx.perform {
            guard let obj = try? ctx.existingObject(with: id) else { return }
            obj.setValue("done", forKey: "status")
            try ctx.save()
        }
    }

    // ✅ 동기 버전이 필요하면 performAndWait
    func updateSync(_ id: NSManagedObjectID) throws {
        let ctx = container.newBackgroundContext()
        try ctx.performAndWait {
            guard let obj = try? ctx.existingObject(with: id) else { return }
            obj.setValue("done", forKey: "status")
            try ctx.save()
        }
    }

    // ✅ viewContext도 예외가 아니다 — 메인 큐에서만
    @MainActor
    func readOnMain() throws -> Int {
        let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Item")
        return try container.viewContext.count(for: request)
    }
}

// ⚠️ .privateQueueConcurrencyType 컨텍스트는 '자기만의 전용 큐'를 갖는다.
//    그 큐가 매번 같은 스레드라는 보장은 없다 — 그래서 perform이 필요하다.

스레드 경계를 넘길 땐 NSManagedObject가 아니라 objectID를 넘기고, 상대 컨텍스트에서 existingObject(with:)로 재조회한다.

경계를 넘길 때는 objectID만
import CoreData

// NSManagedObject는 컨텍스트에 묶여 있다. 다른 컨텍스트·스레드로 넘기면
// 크래시하거나 데이터가 깨진다. 넘길 수 있는 건 objectID뿐이다.

final class SyncService {
    private let container: NSPersistentContainer
    init(container: NSPersistentContainer) { self.container = container }

    @MainActor
    func upload(_ post: Post) async throws {
        // ✅ ① 메인에서 ID만 뽑는다 (불변·스레드 안전)
        let objectID = post.objectID

        // ✅ ② 백그라운드 컨텍스트에서 다시 조회한다
        let bg = container.newBackgroundContext()
        try await bg.perform {
            guard let local = try? bg.existingObject(with: objectID) as? Post else { return }
            local.syncedAt = Date()
            try bg.save()
        }
    }

    // ❌ 절대 금지
    func uploadBad(_ post: Post) {
        DispatchQueue.global().async {
            print(post.title)          // 💥 다른 큐에서 접근
        }
    }
}

// ⚠️ save() 전의 objectID는 '임시 ID'다 — 다른 컨텍스트에서 못 찾는다
func permanentIDBeforeHandoff(_ ctx: NSManagedObjectContext,
                              _ object: NSManagedObject) throws -> NSManagedObjectID {
    if object.objectID.isTemporaryID {
        try ctx.obtainPermanentIDs(for: [object])   // ✅ 영구 ID를 먼저 확보
    }
    return object.objectID
}

// ✅ 값만 필요하면 아예 DTO로 변환해 넘긴다 — 가장 안전하다
struct PostDTO: Sendable {
    let id: UUID
    let title: String

    @MainActor init(_ post: Post) {
        id = post.id
        title = post.title
    }
}

final class Post: NSManagedObject {
    @NSManaged var id: UUID
    @NSManaged var title: String
    @NSManaged var syncedAt: Date?
}

UI는 viewContext(main), 쓰기·import는 background 컨텍스트로 분리하고, 변경 병합 정책(mergePolicy)을 명시한다.

역할별 컨텍스트 분리와 병합 정책
import CoreData

// UI는 viewContext(main), 쓰기·import는 background로 나눈다.
// 두 컨텍스트가 같은 객체를 건드리면 충돌하므로 병합 정책을 명시해야 한다.

final class CoreDataStack {
    let container: NSPersistentContainer

    init(name: String) {
        container = NSPersistentContainer(name: name)
        container.loadPersistentStores { _, error in
            precondition(error == nil)
        }

        // ✅ 백그라운드 저장이 viewContext에 자동 반영되게 한다
        container.viewContext.automaticallyMergesChangesFromParent = true

        // ✅ 충돌 시 어느 쪽을 이길지 명시한다 (기본은 error를 던진다)
        container.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
        //   StoreTrump  : 디스크(다른 컨텍스트가 저장한 것)가 이긴다  ← 읽기 전용 UI에 적합
        //   ObjectTrump : 메모리(내 변경)가 이긴다                    ← 편집 화면에 적합

        container.viewContext.undoManager = nil          // UI에서 안 쓰면 끈다(성능)
    }

    /// 쓰기 전용 백그라운드 컨텍스트
    func newWriteContext() -> NSManagedObjectContext {
        let ctx = container.newBackgroundContext()
        ctx.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        ctx.automaticallyMergesChangesFromParent = true
        return ctx
    }

    /// 대량 import — 병합 부담을 줄이려고 자동 병합을 끈다
    func newImportContext() -> NSManagedObjectContext {
        let ctx = container.newBackgroundContext()
        ctx.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        ctx.automaticallyMergesChangesFromParent = false
        ctx.undoManager = nil
        return ctx
    }
}

// ⚠️ 병합 정책을 안 정하면 충돌 시 save()가 throw한다.
//    "가끔 저장이 실패한다"의 흔한 원인이다.

개발/CI 빌드에 -com.apple.CoreData.ConcurrencyDebug 1을 상시 켠다.

ConcurrencyDebug를 상시로 켜기
import CoreData

// Core Data의 동시성 위반은 즉시 크래시하지 않고 '나중에 이상하게' 나타난다.
// 이 플래그를 켜면 위반 시점에 바로 멈춰 준다.

/*  설정 방법
      Xcode → Product → Scheme → Edit Scheme
        → Run → Arguments → Arguments Passed On Launch
          -com.apple.CoreData.ConcurrencyDebug 1

    켜 두면 잘못된 큐에서 컨텍스트·객체에 접근하는 순간
    __Multithreading_Violation_AllThatIsLeftToUsIsHonor 에서 정지한다.       */

// ✅ CI 테스트에서도 켠다
//    테스트 플랜(.xctestplan)의 Configurations → Arguments Passed On Launch 에
//    -com.apple.CoreData.ConcurrencyDebug 1 을 넣고 xcodebuild test -testPlan <플랜이름> 으로 돌린다
//    (xcodebuild에 -- 로 인자를 직접 붙이는 경로는 없다)

// ✅ 함께 켜면 좋은 다른 디버그 플래그들
/*
    -com.apple.CoreData.SQLDebug 1          실제 실행되는 SQL을 로그로 (1~4 단계)
    -com.apple.CoreData.MigrationDebug 1    마이그레이션 과정 상세 로그
    -com.apple.CoreData.Logging.stderr 1    로그를 stderr로
*/

// ✅ SQLDebug로 N+1을 눈으로 확인할 수 있다
func detectNPlusOne(_ ctx: NSManagedObjectContext) throws {
    // SQLDebug 1을 켜고 이 코드를 돌리면 콘솔에 SELECT가 몇 번 나가는지 보인다
    let posts = try ctx.fetch(NSFetchRequest<NSManagedObject>(entityName: "Post"))
    for post in posts {
        _ = post.value(forKey: "author")     // 여기서 매번 SELECT가 찍히면 N+1
    }
}

// ✅ 코드로도 방어선을 둔다
extension NSManagedObjectContext {
    func assertCorrectQueue() {
        #if DEBUG
        // ⚠️ 이 검사는 viewContext(메인 큐 컨텍스트)의 메인 스레드 접근만 잡는다.
        //    private 큐 컨텍스트는 조건이 항상 참이라 perform 밖에서 만져도 통과한다 —
        //    그건 -com.apple.CoreData.ConcurrencyDebug 1 에 의존해야 한다.
        precondition(concurrencyType != .mainQueueConcurrencyType || Thread.isMainThread,
                     "viewContext를 메인이 아닌 곳에서 접근했다")
        #endif
    }
}
⚠️ 흔한 오해

"백그라운드 컨텍스트는 백그라운드니까 아무 백그라운드 스레드에서 써도 된다"는 오해다. .privateQueueConcurrencyType 컨텍스트는 자신만의 전용 큐를 가지며, 그 큐는 매번 같은 스레드라는 보장도 없다. 그래서 반드시 perform으로 진입해야 한다. 또 "objectID는 항상 안전하다"도 조건부다 — save() 전의 객체는 임시 ID라 다른 스토어 맥락에서 못 찾을 수 있어, 저장 후의 영구 ID를 넘겨야 한다.

🧒 쉽게 이해하기

바쁜 주방을 떠올려 보세요. 요리사마다 자기 도마와 재료 바구니가 있고, 남의 도마 위 재료에 불쑥 손을 대면 사고가 난다. NSManagedObjectContext가 그 도마다. 컨텍스트마다 "이 도마는 이 큐에서만 만진다"는 임자가 정해져 있어서(thread confinement, 가변 상태를 한 곳에 가둬 아예 공유하지 않는 방식), 아무 스레드에서나 손을 뻗으면 내부 장부가 꼬인다. perform { }은 그 요리사에게 주문표를 밀어 넣는 창구다. 창구로 넣으면 주문이 줄을 서서 하나씩 처리되니 두 손이 겹칠 일이 없다. "백그라운드 컨텍스트니까 아무 백그라운드 스레드에서나 써도 되겠지"가 위험한 이유도 여기 있다. 그 컨텍스트에는 전용 큐라는 임자가 따로 있지, 아무 백그라운드나 임자인 게 아니다.

그럼 옆 요리사에게 재료를 건네야 할 땐 어떻게 할까. 재료를 그대로 던지지 말고 번호표만 건넨다. 상대가 그 번호로 자기 창고에서 같은 재료를 꺼내 오면 안전하다. objectID를 넘기고 상대 컨텍스트에서 existingObject(with:)로 다시 조회하는 게 정확히 이 동작이다. 단, 아직 save() 안 한 객체의 번호표는 창고에 입고되기 전에 손으로 적어 둔 임시 번호라 상대가 찾지 못한다. 그래서 저장 뒤의 영구 ID를 넘기거나, 급하면 obtainPermanentIDs(for:)로 정식 번호를 미리 받아 둔다.

❌ 아무 스레드에서 컨텍스트를 직접 만지면 Thread A Thread B backgroundContext 변경 목록 · 캐시 (가변 상태) 두 손이 동시에 닿으면 내부 장부가 꼬인다 → 크래시, 또는 조용한 데이터 손상 ✓ perform 이라는 창구로 넣으면 Thread A Thread B context 전용 큐 작업 ① 작업 ② 작업 ③ 줄을 서서 한 번에 하나씩 실행된다 건너편으로는 objectID 번호표만 건네고 상대 큐에서 다시 조회한다 전용 큐는 매번 같은 스레드라는 보장이 없다 — "백그라운드니까 괜찮다"가 아니라 "그 큐 위인가"가 기준
perform은 성능 장치가 아니라 순서를 강제하는 창구다. 같은 컨텍스트에 대한 작업을 한 줄로 세워 동시 접근 자체를 없앤다.

꼬리 질문

임시 objectID와 영구 objectID의 차이는? obtainPermanentIDs(for:)는 언제 필요한가요?

객체를 insert하면 아직 store에 자리가 없어 Core Data가 임시 ID를 붙인다. save()가 실제로 store에 행을 쓰면서 영구 ID로 승격된다. 임시 ID는 그 컨텍스트 안에서만 유효하고, 다른 스토어 맥락(예: 백그라운드 컨텍스트)에서 existingObject(with:)로 찾으면 못 찾을 수 있다.

obtainPermanentIDs(for:)저장하기 전에 미리 영구 ID가 필요할 때 쓴다. 대표적으로 방금 만든(아직 미저장) 객체의 ID를 다른 컨텍스트나 알림 payload로 넘겨야 할 때다. 이걸 빼먹고 임시 ID를 넘기면 상대 쪽에서 조회 실패나 중복 삽입 같은 버그가 난다.

쉽게 말하면 insert 직후에 붙는 ID는 창고에 입고되기 전에 손으로 대충 적어 둔 임시 번호다. 나만 알아본다. save()를 해야 창고 정식 번호가 붙는다. 저장하기 전에 이 번호를 남한테 건네야 한다면 obtainPermanentIDs(for:)로 정식 번호를 미리 발급받아 두면 된다.
parent/child 컨텍스트 구조에서 child의 save()는 어디까지 반영되나? 디스크에 언제 실제로 쓰이나?

child 컨텍스트의 save()는 변경을 부모 컨텍스트로 밀어 올릴 뿐 디스크로 내려보내지 않는다. 부모의 메모리에 반영되는 것뿐이라, 아직 store에는 아무것도 안 쓰였다. 실제 디스크 쓰기는 그 변경이 store를 직접 소유한 최상위 컨텍스트까지 save가 전파됐을 때 일어난다.

그래서 흔한 실수가 "child에서 save 했으니 저장됐다"고 믿는 것이다. 부모까지 저장을 이어서 호출하거나(parent.performAndWait { try parent.save() }), scratchpad용 child에서 편집·검증한 뒤 확정 시에만 위로 커밋하는 식으로 단계적 저장을 설계 의도로 활용하는 게 이 구조의 목적이다.

쉽게 말하면 child의 save()는 서류를 상사 책상에 올려놓는 데까지다. 도장은 아직 안 찍혔으니 회사 밖으로 나간 건 하나도 없다. 디스크에 실제로 쓰이려면 store를 직접 쥐고 있는 맨 위 컨텍스트까지 save가 올라가야 한다. 이건 결함이 아니라, "확정 전까지 마음대로 고쳐 보는 초안 공간"을 만들려고 일부러 쓰는 구조다.
SwiftData의 ModelContext·@ModelActor는 이 confinement 문제를 어떻게 다르게(또는 같게) 풉니까?

근본 제약은 같다 — ModelContext도 여러 스레드에서 동시에 만지면 안 되는 가변 상태다. 달라진 건 그 규칙을 Swift Concurrency의 액터 격리로 강제한다는 점이다. 수동 perform 블록 대신, @ModelActor가 자신만의 ModelContext를 액터에 가둬 그 액터 안의 코드만 접근하도록 컴파일러가 보장한다.

@MainActor에 묶인 mainContext는 UI용으로 Core Data의 viewContext에 대응하고, 백그라운드 작업은 별도 @ModelActor에서 돌린다. 경계를 넘길 때 객체 자체 대신 PersistentIdentifier(Core Data의 objectID에 해당)를 넘기는 원칙도 그대로다. 즉 confinement라는 해법은 동일하되, 런타임 트랩(ConcurrencyDebug)에 기대던 것을 컴파일 타임 액터 검사로 앞당긴 것이 핵심 차이다.

쉽게 말하면 지켜야 할 규칙은 그대로인데 감시자가 바뀌었다. 예전엔 "perform 안에서 만져라"를 사람이 알아서 지키다가 어기면 실행 중에 터졌는데, @ModelActor는 컴파일러가 미리 막는다. 도마 밖에서 손대는 코드는 아예 빌드가 안 되는 셈이다. 건너편에 건네는 게 물건이 아니라 번호표(PersistentIdentifier)라는 점은 그대로고요.

출처 · 참고자료