목록 화면에서 간헐적으로 무효화된 NSManagedObject 를 읽고, 로컬에서는 수십 번 연속 실행해도 정상인데 CI에서는 저장, 병합 또는 화면 갱신 중 무작위로 충돌하는 경우가 있습니다. 이런 문제는 대개 데이터베이스 파일 손상이 아니라 객체가 자신이 속한 NSManagedObjectContext 의 큐를 벗어났기 때문에 발생합니다. 재시도 횟수를 늘리는 대신 클라우드 Mac에서 Core Data 동시성 디버깅을 활성화해, 잘못된 접근이 원인과 가장 가까운 지점에서 즉시 실패하도록 해야 합니다.
먼저 문제 범위 확인하기
Core Data의 기본 규칙은 명확합니다. 비공개 큐 context는 자체 perform 또는 performAndWait 안에서만 사용해야 하며, 메인 큐 context는 메인 큐에서만 접근해야 합니다. NSManagedObject 역시 다른 큐로 직접 전달할 수 없습니다. 큐 사이에서 데이터를 넘길 때는 안정적인 NSManagedObjectID 를 전달한 뒤 대상 context에서 객체를 다시 가져와야 합니다.
먼저 다음과 같은 위험 지점을 확인합니다.
- 네트워크 콜백이 관리 객체를 메인 스레드에 직접 넘겨 화면을 갱신한다.
Task.detached가 context 또는 관리 객체를 캡처한다.- 백그라운드 저장 완료 후 객체 배열을 알림이나 클로저에 넣는다.
- 테스트가 동일한 영구 저장소 컨테이너를 공유하면서 이전 비동기 쓰기 작업의 완료를 기다리지 않는다.
- context 병합 중에도 화면이 이미 삭제되거나 재설정된 객체를 계속 보유한다.
perform을 일반적인 비동기 래퍼로 취급하면 안 됩니다.perform이 보호하는 대상은 특정 저장 구문 하나가 아니라 context와 그 객체 그래프 전체입니다.
동시성 디버깅 옵션 활성화하기
전용 테스트 Scheme의 실행 인수에 다음 두 항목을 추가합니다.
-com.apple.CoreData.ConcurrencyDebug
1
UI 테스트에서는 개발자의 로컬 Scheme과 CI Scheme 사이에 설정 차이가 생기지 않도록 테스트 코드에서 명시적으로 전달하는 방식을 권장합니다.
let app = XCUIApplication()
app.launchArguments += [
"-com.apple.CoreData.ConcurrencyDebug",
"1"
]
app.launch()
일반 단위 테스트가 호스트 앱에 의존한다면 공유 Scheme에 같은 인수를 설정하고 .xcscheme 을 저장소에 커밋합니다. 이 인수를 릴리스 구성에 넣으면 안 됩니다. 위반이 발견되면 디버깅 메커니즘이 의도적으로 프로세스를 종료하며, 이는 테스트 환경에 정확히 필요한 동작입니다.
활성화한 후에는 먼저 최소한의 테스트 집합을 실행합니다. 처음부터 전체 테스트 모음을 실행하면 많은 동시 작업이 최초의 유효한 호출 스택을 가릴 수 있고, 로그 분석 비용도 커집니다.
객체 대신 ID 전달하기
대표적인 오류는 백그라운드 context에서 객체를 만든 뒤 그 객체를 메인 큐로 직접 넘기는 것입니다. 올바른 방법은 먼저 저장해 영구 object ID를 얻은 다음, 대상 context에서 객체를 다시 가져오는 것입니다.
func importItem(
payload: Payload,
backgroundContext: NSManagedObjectContext,
viewContext: NSManagedObjectContext
) async throws {
let objectID = try await backgroundContext.perform {
let item = Item(context: backgroundContext)
item.title = payload.title
try backgroundContext.obtainPermanentIDs(for: [item])
try backgroundContext.save()
return item.objectID
}
try await viewContext.perform {
let item = try viewContext.existingObject(with: objectID) as! Item
item.lastViewedAt = Date()
try viewContext.save()
}
}
여기에는 세 가지 확인 지점이 있습니다. 반환 전에 영구 ID를 얻었는지, 대상 context가 existingObject(with:) 로 객체를 다시 해석하는지, 모든 속성 읽기와 쓰기가 해당 context의 클로저 안에서 이루어지는지 확인해야 합니다. 임시 ID만 전달하면 대상 context에서 해석하지 못할 수 있습니다. 또한 클로저 밖에서 속성을 읽는다면 문자열 한 줄만 읽더라도 큐 제약을 위반한 것입니다.
안정적으로 재현되는 테스트 설계하기
테스트의 핵심은 무작위 동시 작업을 쌓는 것이 아니라 충돌 경로를 고정하는 데 있습니다. 시나리오는 백그라운드 삽입, 백그라운드 저장, 메인 context 병합, 메인 큐 읽기의 네 단계로 나눌 수 있습니다. 테스트마다 독립적인 인메모리 영구 저장소를 사용하고, 테스트가 끝날 때 모든 작업이 완료될 때까지 기다립니다.
| 확인 항목 | 실패 신호 | 수정 방향 |
|---|---|---|
| 백그라운드 쓰기 | 클로저 밖에서 context에 접근 | perform 안으로 이동 |
| 객체 전달 | 클로저가 관리 객체를 캡처 | object ID 전달로 변경 |
| 병합 알림 | 알림을 보낸 context가 불명확 | 저장 알림의 출처 제한 |
| 테스트 정리 | 이후 테스트 케이스에서도 알림 수신 | 관찰자를 제거하고 컨테이너 폐기 |
저장이 끝나기를 기다릴 때 고정된 sleep 을 사용하면 안 됩니다. await, expectation 또는 영구 저장소 컨테이너 콜백으로 실제 완료 조건을 만들어야 합니다. 고정된 대기는 경쟁 조건이 발생하는 구간을 다른 곳으로 옮길 뿐입니다.
간헐적으로 발생하는 경로는 동일한 소규모 테스트를 20~50회 반복할 수 있지만, 전체 제한 시간을 설정해야 합니다. 반복 횟수는 타이밍 범위를 넓히기 위한 것이며, 결정적인 상태 동기화를 대신해서는 안 됩니다.
CI에서 최초 실패 현장 보존하기
클라우드 Mac 작업에서는 Xcode 선택, 시뮬레이터 대상, 결과 디렉터리를 고정해야 합니다. 다음 스크립트는 실패 시 전체 로그와 결과 번들을 보존합니다.
#!/bin/zsh
set -euo pipefail
mkdir -p artifacts logs
rm -rf artifacts/CoreDataTests.xcresult
if ! xcodebuild test \
-workspace App.xcworkspace \
-scheme AppConcurrencyTests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath artifacts/CoreDataTests.xcresult \
> logs/coredata-tests.log 2>&1; then
grep -nE 'CoreData|Concurrency|NSManagedObjectContext' \
logs/coredata-tests.log || true
exit 1
fi
대상 기기 이름은 현재 Xcode에서 실제로 제공하는 시뮬레이터를 기준으로 지정해야 합니다. 모든 이미지에서 이름이 완전히 같다고 가정하면 안 됩니다. 로그를 제출하기 전에는 비즈니스 데이터, 액세스 토큰, 사용자 디렉터리에 포함된 민감한 내용도 제거해야 합니다. 결과 번들, 전체 로그, 실패한 테스트 이름, 커밋 번호, 실행 인수는 하나의 증거 묶음으로 보관합니다.
작업에서 병렬 테스트가 필요하더라도 먼저 동시성 디버깅 테스트 모음을 별도로 직렬 실행합니다. 이 테스트의 목적은 처리량을 극대화하는 것이 아니라 최초의 큐 경계 위반 접근을 찾는 것입니다. 안정성을 확인한 후 일반 테스트 샤드에 포함할지 평가합니다.
노이즈가 적은 병합 게이트 구축하기
병합 게이트는 두 단계로 나누는 것이 좋습니다. 첫 번째 단계에서는 데이터 계층 단위 테스트와 소수의 핵심 UI 흐름만 실행하며 동시성 디버깅을 항상 활성화합니다. 두 번째 단계에서는 전체 회귀 테스트를 실행하되, 기본적인 큐 오류를 찾는 일을 이 단계에 의존하지 않습니다. 첫 번째 단계가 실패하면 자동 재시도를 금지해야 합니다. 재시도 후 통과하면 실제 결함이 인프라의 일시적인 불안정으로 위장될 수 있기 때문입니다.
병합 전에 다음 항목을 다시 확인합니다.
- Scheme이 공유되어 있고 버전 관리에 포함되어 있다.
- 테스트가 독립된 저장소를 사용하며 개발 데이터베이스를 재사용하지 않는다.
- context, 객체, fetched results controller가 큐 사이에서 전달되지 않는다.
- 알림 관찰자가 테스트 종료 시 올바르게 제거된다.
- CI가 실패해도 로그와 xcresult가 업로드된다.
- 수정 커밋에 기존 코드에서 실패하는 회귀 테스트가 포함되어 있다.
큐를 넘나드는 객체 전달을 모두 object ID 방식으로 바꾸고, 비동기 완료 조건을 명확히 하며, 결과 증거를 추적할 수 있게 만들면 이런 “운영 환경에서만 우연히 발생하는” 충돌도 일반적인 코드 리뷰 문제로 전환할 수 있습니다.
자주 묻는 질문
Core Data 동시성 디버그 옵션을 배포 빌드에도 켜야 하나요?
아닙니다. 위반을 찾으면 프로세스를 중단하므로 개발 및 CI 전용 테스트 구성에서만 사용하고 배포 구성에는 포함하지 않아야 합니다.
일반 테스트가 통과해도 동시성 오류가 남는 이유는 무엇인가요?
경쟁 조건이 발생하는 순서를 충분히 반복하지 않았기 때문입니다. 백그라운드 저장, 메인 큐 조회, objectID 전달, 컨텍스트 병합을 하나의 시나리오로 묶어 반복해야 합니다.
CI 실패 시 어떤 자료를 먼저 보관해야 하나요?
전체 xcodebuild 로그, xcresult, 실패한 테스트 이름, 커밋 번호와 실행 인자를 보관합니다. 충돌 보고서가 생성되면 민감한 내용을 제거한 뒤 함께 저장합니다.
다음 빌드를 위한 독점 물리 Mac 준비
작업에 맞는 모델, 리전과 대여 기간을 선택하세요. 노드는 연중 정상 운영되며, 실제 사용 가능 여부는 콘솔의 실시간 응답을 기준으로 합니다.