← Study Hub

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으로 접근합니다.

실험 · 도구

시뮬레이터에서 저장 위치를 눈으로 확인하면 감이 옵니다. 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 위에서부터 검사 — 먼저 맞는 것이 이긴다 (보안 우선)
선택은 "무엇을 저장하는가"의 성격을 위에서부터 걸러내는 우선순위 문제다. 민감도를 가장 먼저 판단한다.

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

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 db.write { ... } 클로저로 명시적으로 감싸, 클로저가 정상 종료하면 커밋, 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를 허용하지 않으므로 이전 값이 보여야 정상입니다.

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

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/2) 인덱스 있음 · 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 lower(name)=...) 옵티마이저가 인덱스를 못 쓰는 경우가 많습니다.

꼬리 질문

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 {           // name 을 title 로 분리하는 등 변경
    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를 테스트 번들에 넣어두고, 최신 코드로 열어 마이그레이션이 성공하고 데이터가 보존되는지 자동 테스트로 검증합니다. 실기기에서는 이전 버전 앱으로 데이터를 만든 뒤 새 빌드를 덮어 설치해 콜드스타트에서 크래시 없이 열리는지 확인합니다. 관찰 지표: 마이그레이션 소요 시간(대량 데이터에서 앱 시작 지연·워치독 종료 위험), 성공/실패율(원격 로깅).

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

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

꼬리 질문

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

🔑 30초 답변

아닙니다. NSManagedObjectContext와 거기서 나온 NSManagedObject자신에게 묶인 큐/스레드에서만 만져야 합니다(thread confinement). 그래서 컨텍스트 작업은 항상 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 트랩 스택트레이스가 가리키는 "큐 밖 접근" 지점.

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

출처 · 참고자료