클라우드 Mac에서 iOS Combine 구독 취소와 객체 수명 검증하기

클라우드 Mac에서 iOS Combine 구독 취소와 객체 수명 검증하기

검색 화면을 떠난 뒤에도 결과가 상태에 반영되거나, 화면에 다시 들어갈수록 요청이 늘어난다면 네트워크 지연보다 Combine 구독이 너무 오래 유지되는 것이 원인일 수 있습니다. 클라우드 Mac에서 회귀 테스트를 실행할 때는 먼저 실제 네트워크를 직접 제어할 수 있는 이벤트 소스로 바꾸세요. 언제 구독하고 취소하는지, 어떤 객체가 해제되어야 하는지 명확히 확인한 다음 실제 서비스를 다시 연결합니다. 이렇게 하면 원격 응답을 기다리지 않고도 실패를 안정적으로 재현할 수 있습니다.

취소 계약을 나누어 확인하기

한 번의 취소에는 적어도 세 가지 서로 다른 결과가 관련됩니다. 다운스트림이 더 이상 값을 받지 않는 것, 업스트림이 취소 신호를 받는 것, 그리고 기저 작업이 중단되는 것입니다. 앞의 두 가지는 퍼블리셔 파이프라인 테스트로 검증할 수 있습니다. 세 번째는 구체적인 작업 구현을 확인해야 하며, 구독 상태만으로 판단해서는 안 됩니다.

화면에는 네 가지 검증 기준을 정의합니다. 취소 후 값이 변하지 않을 것, 여러 번 취소해도 정리는 한 번만 수행될 것, 화면 모델이 해제될 것, 이전 요청이 새 요청의 결과를 덮어쓰지 못할 것입니다. 정상 완료는 별도 테스트로 다룹니다. 완료 알림과 취소 알림은 서로 다르기 때문입니다.

cancel은 롤백이 아닙니다. 취소 전에 이미 반영된 상태, 디스크에 저장된 파일, 서버에 제출된 작업은 자동으로 되돌려지지 않습니다.

먼저 Xcode 툴체인을 고정하고 기존 XCTest 단위 테스트 타깃에 테스트를 추가합니다. 아래 코드는 Combine과 XCTest만 사용하므로 시뮬레이터 UI를 실행하거나 네트워크 권한을 부여할 필요가 없습니다.

동기 이벤트 소스로 취소 전파 확인하기

다음 코드를 테스트 타깃에 추가합니다. 여기서 사용하는 카운터는 테스트 전용입니다. 프로덕션 코드에서는 전역 카운터를 취소 상태로 사용하지 마세요. 테스트는 값을 하나 보낸 뒤 취소를 두 번 호출하고, 다른 값을 하나 더 보냅니다. 세 개의 단언문은 각각 값 수신, 취소 전파, 취소 후 값 전달 차단을 확인합니다.

import Combine
import XCTest

final class SubscriptionLifetimeTests: XCTestCase {
    func testCancelStopsDeliveryAndPropagatesOnce() {
        let source = PassthroughSubject<Int, Never>()
        var received: [Int] = []
        var cancellations = 0

        let token = source
            .handleEvents(receiveCancel: {
                cancellations += 1
            })
            .sink { value in
                received.append(value)
            }

        source.send(1)
        XCTAssertEqual(received, [1])

        token.cancel()
        token.cancel()
        source.send(2)

        XCTAssertEqual(cancellations, 1)
        XCTAssertEqual(received, [1])
    }
}

이 파이프라인에는 스케줄러 전환이 없으므로 PassthroughSubject가 값을 동기적으로 전달합니다. 따라서 동작 직후에 바로 단언할 수 있습니다. “시스템이 처리할 시간을 주기 위해” sleep을 추가하지 마세요. 이벤트 순서가 불명확하다는 문제를 가릴 뿐입니다.

이 테스트가 검증하는 대상은 예제 파이프라인입니다. 자신의 서비스에 적용할 때는 의존성 주입으로 이벤트 소스를 비즈니스 모델에 전달하고, 모델이 공개하는 상태를 대상으로 같은 단언을 수행해야 합니다. 제품과 무관한 파이프라인만 테스트해서는 안 됩니다.

cancel 호출뿐 아니라 참조 관계도 확인하기

흔히 발생하는 순환 참조는 모델이 AnyCancellable을 보유하고, sink 클로저가 다시 모델을 강하게 참조하는 구조입니다. 이때 모델이 소멸하면서 구독을 해제하기를 기대하면 서로의 해제를 기다리게 됩니다. 아래 예제는 약한 캡처로 순환을 끊고, 약한 참조를 통해 객체가 실제로 해제되는지 검증합니다.

private final class ScreenModel {
    var value = 0
    private var token: AnyCancellable?

    init(source: PassthroughSubject<Int, Never>) {
        token = source.sink { [weak self] value in
            self?.value = value
        }
    }
}

extension SubscriptionLifetimeTests {
    func testModelReleaseCancelsSubscription() {
        let source = PassthroughSubject<Int, Never>()
        var cancellations = 0
        let publisher = source.handleEvents(receiveCancel: {
            cancellations += 1
        })
        var token: AnyCancellable?
        weak var weakModel: ScreenModel?

        do {
            let model = ScreenModel(source: source)
            weakModel = model
            token = publisher.sink { [weak model] value in
                model?.value = value
            }
        }

        XCTAssertNil(weakModel)
        token?.cancel()
        XCTAssertEqual(cancellations, 1)
    }
}

두 번째 테스트는 모델 해제와 외부 구독 취소를 별도로 관찰하며, 둘을 하나의 동작으로 취급하지 않습니다. 모델 내부 구독은 모델이 해제될 때 취소됩니다. 반면 외부 token은 여전히 테스트가 보유하므로 명시적으로 종료해야 합니다. 실제 프로젝트에 적용할 때는 내부 구독에도 취소 관찰 지점을 추가하고, 두 구독을 각각 단언해야 합니다.

약한 캡처가 모든 문제를 해결해 주는 것은 아닙니다. 화면을 떠난 뒤에도 계속되어야 하는 작업이라면 수명이 더 긴 서비스가 해당 작업을 보유하고, 화면은 진행 상태만 구독하도록 구성해야 합니다. 그래야 화면 해제 때문에 작업까지 잘못 중단되는 일을 막을 수 있습니다.

비동기 상태 반영 전에 요청 식별자 확인하기

검색 A를 취소한 뒤 검색 B를 시작해도, 이미 다른 실행 큐에 들어간 작업이 상태를 갱신하려 할 수 있습니다. “취소를 호출했다”는 사실을 상태 안전성을 보장하는 장벽으로 간주하지 마세요.

각 요청에 단조 증가하는 식별자를 부여하고, 결과를 상태에 반영할 때 식별자가 일치하는지 확인합니다. 식별자 변경과 상태 갱신은 반드시 같은 actor 또는 직렬 실행 컨텍스트 안에서 처리해야 합니다. 테스트 순서는 A 시작, B 시작, B 응답, 마지막으로 A 응답으로 고정합니다. 최종 상태는 여전히 B의 결과여야 합니다.

화면 이탈 시나리오도 추가합니다. 요청을 시작하고 화면을 떠난 다음 늦은 응답을 발생시켜, 화면에 표시되는 상태가 갱신되지 않는지 확인합니다. 백그라운드 실행을 지원하는 기능이라면 서비스 상태는 갱신되고 화면 콜백은 멈추는지 각각 단언해야 합니다.

receive(on:)이 포함된 경우에는 먼저 동기적인 핵심 로직을 검증한 다음 스케줄링 경계를 검증합니다. 제어 가능한 스케줄러로 큐의 실행을 진행하세요. 그런 기반이 없다면 명시적인 실행 완료 신호를 기다려야 합니다. 일정 시간 기다렸다는 이유만으로 늦게 도착하는 이벤트가 없다고 결론 내려서는 안 됩니다.

실패 원인을 추적할 수 있는 회귀 테스트로 만들기

실패한 각 테스트에는 요청 식별자, 이벤트 순서, 취소 횟수, 최종 상태를 기록합니다. 요청 토큰이나 실제 사용자 데이터는 기록하지 않습니다. 한 번에 하나의 조건만 바꾸세요. 먼저 단일 구독을 검증한 뒤 빠른 재구독, 정상 완료, 실패 종료, 화면 해제를 차례로 다룹니다.

실제 네트워크 작업을 연결할 때는 취소 콜백이 기저 작업의 cancel을 호출하는지와 작업의 최종 상태를 별도로 확인합니다. 서버에서 결과가 돌아오지 않았다는 사실만으로 취소가 성공했다고 판단해서는 안 됩니다. 취소와 완료가 경합할 때는 계약에 부합하는 종료 순서를 허용하되, 종료 후 비즈니스 상태가 갱신되는 것은 허용하면 안 됩니다.

마지막으로 의도적으로 코드를 망가뜨려 검증합니다. 약한 캡처를 제거하면 순환 참조가 드러나야 하고, 요청 식별자 검사를 제거하면 응답 순서가 뒤바뀌는 테스트가 실패해야 합니다. 인위적으로 주입한 오류를 잡아낼 수 있어야 테스트가 단순히 파이프라인을 통과시키는 데 그치지 않고 실제 경계를 지키고 있다고 할 수 있습니다.

자주 묻는 질문

cancel을 호출하면 네트워크 요청도 반드시 멈추나요?

아닙니다. 실제 작업 중단은 게시자 구현에 달려 있습니다. 취소 경로에서 작업 취소를 호출하는지와 작업 상태를 별도로 검증해야 합니다.

고정 대기 시간 없이 어떻게 테스트하나요?

동기 이벤트는 PassthroughSubject로 직접 전달하세요. 비동기 전달은 제어 가능한 스케줄러나 명시적인 완료 신호를 사용하고 sleep으로 결과를 추정하지 마세요.

다음 빌드를 위한 클라우드 Mac 예약하기

원하는 모델과 도시, 대여 기간을 선택하고 전용 물리 노드에서 개발 도구를 실행하세요. 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.

읽은 내용을 다음 빌드로 이어가세요

먼저 모델과 대여 기간을 확인한 뒤 프로젝트를 준비하세요.