Core Data + async/await 사용 시 흔히 발생하는 Sendable 오류 해결법
🚨 1️⃣ 에러 상황
Core Data에서 async/await을 적용하려고
다음과 같은 코드를 작성했다고 해보자 👇
func fetchDiariesAsync(mode: FetchMode) async -> [EmotionDiaryModel] {
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let result = self.fetchDiaries(mode: mode)
continuation.resume(returning: result)
}
}
}
그런데 빌드하자마자 이런 에러가 발생한다 👇
Capture of 'self' with non-sendable type 'DiaryCoreDataManager' in a '@Sendable' closure
💡 2️⃣ 이게 왜 생기는 걸까?
Swift의 Concurrency 모델(= async/await) 은
스레드 간 안전성(thread safety) 을 매우 중요하게 생각해.
그래서 다른 스레드(Task) 로 객체를 전달할 때,
그 객체가 "안전하게 전송 가능한 타입"인지 확인해.
이걸 바로 Sendable 검사라고 해.
🔍 핵심 포인트
- @Sendable 클로저 = “다른 스레드로 넘길 수 있는 클로저”
- Swift는 이 클로저 안에서 캡처한 객체가
스레드 간 안전하게 공유될 수 있는지 검사함
- 그런데 Core Data의 주요 타입(NSManagedObjectContext, NSPersistentContainer)은
thread-safe하지 않음 ⚠️
즉, Swift 입장에서 보면
“Core Data 객체는 스레드 간 넘기면 안 되는데,
너 async 안에서 self 썼잖아?”
라는 의미야.
🧩 3️⃣ 해결 방법 3가지
| 방법 | 설명 | 안전성 | 추천도 |
| ✅ @MainActor 클래스 선언 | Core Data는 기본적으로 메인 스레드 전용 → 가장 자연스러움 | 매우 높음 | ⭐️⭐️⭐️ |
| ✅ Task.detached 사용 | Swift Concurrency 친화적 백그라운드 Task | 중간 | ⭐️⭐️ |
| ⚠️ [weak self] 캡처 | 일시적인 회피용, Sendable은 해결 안 됨 | 낮음 | ⚠️ |
| 🚫 @unchecked Sendable 선언 | 강제로 검사 무시, 위험 | 매우 낮음 | ❌ |
🧩 4️⃣ 추천 해결책 — @MainActor 클래스 선언
Core Data의 viewContext는 원래 메인 스레드 전용이라
이 객체를 다루는 매니저 전체를 @MainActor로 선언하면 완벽해요.
@MainActor
final class DiaryCoreDataManager {
static let shared = DiaryCoreDataManager()
private init() {}
func fetchDiaries(mode: FetchMode) -> [EmotionDiaryModel] {
let request: NSFetchRequest<EmotionDiaryEntity> = EmotionDiaryEntity.fetchRequest()
...
return try! context.fetch(request).compactMap { $0.toModel() }
}
func fetchDiariesAsync(mode: FetchMode) async -> [EmotionDiaryModel] {
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let result = self.fetchDiaries(mode: mode)
continuation.resume(returning: result)
}
}
}
}
이렇게 하면 self가 non-Sendable이어도
Swift가 “어차피 메인 액터에서만 접근하니까 괜찮다”라고 판단해. ✅
⚙️ 5️⃣ 다른 방법 — Task.detached 활용
만약 정말로 백그라운드에서 완전히 분리된 fetch를 원한다면,
DispatchQueue.global() 대신 Swift의 Task.detached를 사용할 수도 있어 👇
func fetchDiariesAsync(mode: FetchMode) async -> [EmotionDiaryModel] {
await withCheckedContinuation { continuation in
Task.detached {
let result = self.fetchDiaries(mode: mode)
continuation.resume(returning: result)
}
}
}
하지만 이 경우에는
Core Data의 컨텍스트를 thread-safe하게 다뤄야 하므로
전용 backgroundContext를 따로 만들어서 써야 해.
(그건 v3.0 이후 Firebase 병합할 때 다룰 부분 😉)
💬 6️⃣ 잠깐, [weak self]는 안 되나?
DispatchQueue.global().async { [weak self] in
guard let self else { return }
let result = self.fetchDiaries(mode: mode)
continuation.resume(returning: result)
}
이건 컴파일은 통과하지만,
Swift Concurrency의 타입 안전성 자체는 유지되지 않아.
즉, "그냥 경고를 없애는 임시 방편" 이지
진짜 해결은 아냐 ⚠️
📘 7️⃣ 정리 요약
| 항목 | 설명 |
| 에러 메시지 | Capture of 'self' with non-sendable type ... |
| 원인 | Core Data 객체는 thread-safe하지 않음 |
| 해결 핵심 | Sendable 타입이 아닌 객체를 async 클로저로 넘기지 말 것 |
| 실전 해결책 | Core Data Manager를 @MainActor로 선언 |
| 부가 팁 | backgroundContext를 쓰려면 Task.detached + privateQueueContext 조합 |
🚀 결론
Core Data는 기본적으로 메인 스레드 전용 구조다.
따라서 Core Data Manager 전체를 @MainActor로 선언하는 것이
Swift Concurrency 환경에서 가장 안전하고 자연스러운 설계 방식이다.
'감정일기(가칭)' 카테고리의 다른 글
| 🍋 Swift FileManager 설계 — private 유지하면서 안전하게 경로 노출하기 (0) | 2025.10.19 |
|---|---|
| 🚀 Swift 6에서 바뀐 Concurrency 규칙 완전 정리 (0) | 2025.10.19 |
| 💾 왜 Core Data의 Read는 반드시 async여야 할까? (0) | 2025.10.18 |
| 📚 DiaryCoreDataManager 내부 구조 완전 정리 (0) | 2025.10.18 |
| ☁️ Core Data에서 Firebase로 확장하는 iOS 데이터 구조 설계 (0) | 2025.10.18 |