개발용 Mac에서는 백그라운드 새로 고침이 가끔 성공하지만 CI에 연결하면 자주 “실행되지 않은” 것처럼 보인다. 대개 문제는 작업 코드가 아니라 테스트가 시스템 스케줄링 시점을 확정적인 조건으로 취급하는 데 있다. iOS가 프로세스를 언제 깨울지는 시스템이 결정하며, 클라우드 Mac도 이를 고정 타이머처럼 만들 수는 없다. 안정적으로 검증하려면 정적 등록 구성, 스케줄러 어댑터 계층, 실제 동기화나 정리를 수행하는 비즈니스 로직을 서로 분리해야 한다.
검증 가능한 경계부터 정의하기
하나의 백그라운드 작업 흐름에는 최소한 등록, 요청 제출, 시스템 콜백, 작업 실행, 만료 시 취소, 결과 보고가 포함된다. 지속적 통합에서는 시스템이 “우연히” 작업을 실행하기를 기다리지 말고, 앞뒤의 통제 가능한 영역을 안정적으로 검증해야 한다.
| 계층 | 자동화 검사 | 단언 조건으로 삼지 말아야 할 항목 |
|---|---|---|
| 구성 계층 | 식별자, 기능 선언, 대상 번들 구성 | 시스템이 깨우는 시점 |
| 어댑터 계층 | 등록 성공, 콜백 전달, 완료 상태 | 스케줄링 우선순위 |
| 작업 계층 | 출력, 오류, 취소, 반복 실행 | 실제 배터리 상태와 사용 습관 |
회귀 테스트의 목적은 작업이 특정 시각에 시작된다는 사실을 입증하는 것이 아니다. 시스템이 작업을 전달했을 때 앱이 이를 올바르게 완료하거나 취소하고, 안전하게 재시도할 수 있음을 확인하는 데 있다.
먼저 com.example.app.refresh와 같이 작업 식별자를 고정한다. 이 식별자는 등록 코드와 BGTaskSchedulerPermittedIdentifiers에 모두 있어야 한다. 빌드 구성에 따라 서로 다른 Info.plist가 생성된다면 저장소의 원본 파일만 읽지 말고 실제 빌드 산출물을 검사해야 한다.
스케줄러를 얇은 어댑터로 축소하기
BGAppRefreshTask 콜백 안에 네트워크, 데이터베이스, 캐시 로직을 직접 작성하지 않는다. 먼저 교체 가능한 실행 단위를 정의한다.
protocol RefreshJob {
func run() async throws
func cancel()
}
final class BackgroundRefreshAdapter {
private let job: RefreshJob
init(job: RefreshJob) {
self.job = job
}
func handle(task: BGAppRefreshTask) {
task.expirationHandler = { [job] in
job.cancel()
}
Task {
do {
try await job.run()
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
}
}
실제 작업에는 네트워크 클라이언트, 저장소 인터페이스와 시계를 주입한다. 이렇게 하면 단위 테스트에서 BGAppRefreshTask를 가짜로 만들 필요 없이, 주어진 입력에 대해 RefreshJob이 어떤 출력을 만드는지만 검증하면 된다. 어댑터 계층은 짧게 유지하고 실행 전달, 만료 처리, 완료 상태 보고만 담당하게 한다.
취소를 최하위 계층까지 전달하기
불리언 변수만 설정하는 것으로는 대개 충분하지 않다. 다운로드, 파싱, 일괄 쓰기 사이에서 모두 취소 상태를 확인해야 한다. Swift 동시성을 사용할 때는 단계 경계마다 Task.checkCancellation()을 호출할 수 있다. 데이터베이스 쓰기는 짧은 트랜잭션으로 처리해 작업이 만료된 뒤에도 긴 트랜잭션을 계속 유지하거나 불완전한 데이터를 남기지 않도록 한다.
정적 구성 검사를 먼저 통과시키기
백그라운드 작업 실패의 가장 흔한 원인은 식별자 오타, 대상 구성에 포함되지 않은 기능, 또는 테스트가 잘못된 plist를 읽는 것이다. 빌드가 끝난 뒤 앱 번들을 직접 검사한다.
set -euo pipefail
APP_PATH="$BUILT_PRODUCTS_DIR/$WRAPPER_NAME"
PLIST="$APP_PATH/Info.plist"
TASK_ID="com.example.app.refresh"
plutil -extract BGTaskSchedulerPermittedIdentifiers raw "$PLIST" |
grep -Fx "$TASK_ID"
plutil -extract UIBackgroundModes raw "$PLIST" |
grep -F "fetch"
스크립트는 별도의 빌드 단계로 실행하고 입력 파일 의존성을 명시해 매번 조건 없이 실행되지 않도록 해야 한다. 처리형 작업을 사용한다면 해당 백그라운드 모드도 확인해야 한다. 두 작업을 같은 프레임워크로 등록한다는 이유만으로 필요한 선언이 완전히 같다고 가정해서는 안 된다.
등록 코드도 관찰 가능한 결과를 반환해야 한다. 앱이 시작될 때 각 식별자의 등록 결과를 앱 내부 진단 기록에 남기고, 테스트에서 이 기록을 읽어 모두 성공했는지 단언한다. 로그에는 작업 식별자, 단계와 오류 유형만 남기며 액세스 토큰이나 전체 요청 내용은 기록하지 않는다.
성공, 만료와 반복 실행 검증하기
비즈니스 작업에는 최소 네 가지 테스트 그룹이 필요하다. 첫 번째는 고정된 응답을 사용해 성공 후 커서, 캐시와 갱신 시각이 원자적으로 커밋되는지 검증한다. 두 번째는 다운로드 또는 쓰기 단계에서 오류를 주입해 기존 데이터를 계속 읽을 수 있는지 확인한다. 세 번째는 취소를 발생시켜 임시 파일이 정리되고 성공 표시가 갱신되지 않는지 확인한다. 네 번째는 같은 입력을 연속으로 두 번 실행해 중복 레코드가 생성되지 않는지 검증한다.
func testRepeatedRunIsIdempotent() async throws {
let store = InMemoryStore()
let client = StubClient(items: [.init(id: "42")])
let job = SyncRefreshJob(client: client, store: store)
try await job.run()
try await job.run()
XCTAssertEqual(store.items.map(\.id), ["42"])
XCTAssertEqual(store.commitCount, 2)
}
멱등성이 두 번째 실행에서는 아무 작업도 하지 않아야 한다는 뜻은 아니다. 최종 상태가 같고 반복해서 커밋해도 중복 객체가 늘어나지 않아야 한다는 의미다. 작업에서 파일을 업로드한다면 안정적인 비즈니스 키를 사용해 이미 제출한 상태를 기록할 수 있다. 페이지네이션 커서를 사용한다면 “데이터는 기록됐지만 커서는 아직 갱신되지 않은” 중단 지점도 테스트해야 한다.
테스트에 sleep 대기를 넣지 않기
고정된 sleep으로 비동기 작업의 완료 시점을 추측하지 않는다. 시계와 백오프 정책을 프로토콜로 추상화하고, 테스트에서는 수동으로 진행할 수 있는 시계를 사용한다. 폴링에는 명확한 종료 조건이 있어야 하며, 실패 메시지에는 시간 초과만 보고하지 말고 현재 단계도 출력해야 한다.
클라우드 Mac에서 다시 검토할 수 있는 증거 보관하기
먼저 현재 노드에서 사용할 수 있는 시뮬레이터를 나열한 다음, 프로젝트에 설치된 런타임을 선택한다.
xcrun simctl list devices available
xcodebuild test \
-scheme BackgroundTasks \
-destination 'platform=iOS Simulator,OS=latest,name=iPhone 16' \
-resultBundlePath artifacts/BackgroundTasks.xcresult
기기 이름은 파이프라인 변수로 전달해 Xcode 업데이트 후 스크립트가 존재하지 않는 런타임에 묶이지 않도록 해야 한다. 실패하면 xcresult, 테스트 로그, 앱 진단 기록과 사용한 커밋 번호를 보관한다. 콘솔 출력의 마지막 수십 줄만 업로드해서는 안 된다.
디버깅 단계에서는 Xcode의 백그라운드 작업 디버깅 기능을 이용해 시스템 콜백을 수동으로 실행할 수 있다. 다만 이런 진입점은 어댑터 계층의 연결 상태를 확인하는 용도로만 사용해야 하며 릴리스 빌드의 의존성이 되어서는 안 된다. 최종 검증은 두 계층으로 나눈다. CI에서는 구성과 작업 로직을 결정론적으로 검증하고, 통제된 기기에서는 시스템이 작업을 전달한 뒤의 실제 생명주기를 검증한다. 두 계층의 결과를 따로 기록해야 실패 원인이 구성 회귀인지, 비즈니스 로직 오류인지, 시스템 스케줄링 시점에 대한 잘못된 기대인지 판단할 수 있다.
자주 묻는 질문
iOS 시뮬레이터로 백그라운드 작업의 정확한 실행 시점을 검증할 수 있나요?
검증할 수 없습니다. 등록, 작업 로직, 취소와 멱등성은 확인할 수 있지만 시스템이 깨우는 시점은 결정적인 CI 조건으로 사용할 수 없습니다.
BGTaskScheduler 테스트를 안정적으로 만드는 핵심은 무엇인가요?
스케줄러 코드를 얇은 어댑터로 제한하고 실제 동기화나 정리 작업을 별도 비동기 구성 요소로 분리해 시간, 입력, 오류와 취소를 주입하는 것입니다.
지속적인 빌드 작업에 사용할 독점 클라우드 Mac 선택
M4, 메모리, 스토리지, 노드 및 결제 주기를 확인한 뒤 기존 파이프라인에 고정 빌드 환경을 연결하세요.